From 8654838c4f3e2efdb77b3221f5a2a7dfec849733 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 13:37:30 -0300 Subject: [PATCH 01/25] Add email verification for user registration Implements Issue #46 - optional email verification during join flow: Server changes: - Add configurable email provider (SMTP or Resend API) - Add verification code generation and storage - Add VerifyEmail and ResendVerificationEmail gRPC handlers - Require valid verification before creating principal when enabled CLI changes: - Add --verification-code flag to join command - Interactive flow: prompts for code when verification required Web app changes: - Update join page to handle verification step - Show verification code input when server requires it E2E testing: - Add MailHog support via docker-compose.test.yaml - Add email_verification.rs tests for CLI flow - Add join-verification.spec.ts Playwright tests for web - Update CI workflows to run MailHog for tests Configuration via environment variables: - ZOPP_EMAIL_VERIFICATION_REQUIRED (true/false) - ZOPP_EMAIL_PROVIDER (smtp/resend) - SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD - RESEND_API_KEY - ZOPP_EMAIL_FROM, ZOPP_EMAIL_FROM_NAME --- .github/workflows/e2e.yaml | 11 + .github/workflows/web-e2e.yaml | 27 + Cargo.lock | 458 +++- Cargo.toml | 2 + apps/e2e-tests/Cargo.toml | 11 +- apps/e2e-tests/tests/common/harness.rs | 272 +++ apps/e2e-tests/tests/common/mailhog.rs | 177 ++ apps/e2e-tests/tests/common/mock_smtp.rs | 200 ++ apps/e2e-tests/tests/common/mod.rs | 6 + apps/e2e-tests/tests/email_verification.rs | 165 ++ apps/zopp-cli/src/cli.rs | 4 + apps/zopp-cli/src/commands/join.rs | 195 +- apps/zopp-cli/src/main.rs | 2 + apps/zopp-server/Cargo.toml | 9 + apps/zopp-server/src/backend.rs | 56 + apps/zopp-server/src/config.rs | 423 ++++ apps/zopp-server/src/email/code.rs | 59 + apps/zopp-server/src/email/mod.rs | 99 + apps/zopp-server/src/email/resend.rs | 61 + apps/zopp-server/src/email/smtp.rs | 129 ++ apps/zopp-server/src/email/templates.rs | 111 + apps/zopp-server/src/handlers/auth.rs | 123 +- apps/zopp-server/src/handlers/mod.rs | 18 + apps/zopp-server/src/handlers/verification.rs | 336 +++ apps/zopp-server/src/main.rs | 33 +- apps/zopp-server/src/server.rs | 39 +- apps/zopp-server/src/tests.rs | 206 +- apps/zopp-web/package-lock.json | 2043 ++++++++++++++++- apps/zopp-web/package.json | 10 +- apps/zopp-web/src/pages/register.rs | 608 ++++- apps/zopp-web/tests/fixtures/mock-smtp.ts | 149 ++ .../tests/fixtures/verification-setup.ts | 214 ++ apps/zopp-web/tests/join-verification.spec.ts | 309 +++ crates/zopp-proto-web/src/lib.rs | 28 + crates/zopp-proto/proto/zopp.proto | 38 +- crates/zopp-storage/src/lib.rs | 106 + ...adac4168b078c5f979d06c70d649ec8a4dde.json} | 12 +- ...22360cc9d11137e4ee95d25119a2ea36c9ba0.json | 14 + ...581dcf7b02e9f911837df9704cbc15dce4095.json | 58 + ...edc33460362192a43ba21cac576f25c7db1f4.json | 22 + ...a8963ea4f604e94e0d873c55848dd276a4243.json | 14 + ...afabfc4d907e77b19cb54745aa46512893a2.json} | 12 +- ...547f1621dc5344a12ab5cf0a7fc16f9c1712.json} | 12 +- ...2c9b94f2925fd48ae2b8c20a37236dc4b4b44.json | 62 + ...2f6ea088e29f2c33a9ee8eba6dc80cd28fbc.json} | 12 +- ...428b515c7b26311fcc23cfab18bf06160cdaf.json | 14 + ...dc7e1c9529343dab86c8feab39307ac45ff08.json | 14 + .../20260125000001_add_email_verification.sql | 24 + crates/zopp-store-postgres/src/lib.rs | 153 +- ...20de32c1a7c4d0106603004fde0098b566015.json | 12 + ...9e76ef46b9833cbe08268bce7c802128d1a3f.json | 12 + ...4e16b20acabf9794dcd6dbe6ced0d786536f6.json | 12 + ...3ad9883d7f21faae9c7760842e8da25412da9.json | 20 + ...cf9b611b3ced09b2929abc9603ab943a3b6d.json} | 14 +- ...ebf8c637e667a0e76b000f1093768d832fa1.json} | 14 +- ...32c905a4fbf4fb06296e2c05c91248b7936c5.json | 12 + ...977bedc1358d15d232d1eb4b680b9021a3d4.json} | 14 +- ...ed81d8f80b43ce8f82f00dd713f8dc6b42c5e.json | 12 + ...59f23c29faccc6a6bed608384587241e6ccf.json} | 14 +- ...9067d0259ed1e9697ccb2982b830b30bc24f7.json | 12 + ...14d0301e3f7e2726683b008c668eafc7b0c8e.json | 56 + .../20260125000001_add_email_verification.sql | 24 + crates/zopp-store-sqlite/src/lib.rs | 385 +++- docker/docker-compose.test.yaml | 35 + docs/docs/guides/joining-a-team.md | 12 + docs/docs/reference/environment-variables.md | 29 + docs/docs/self-hosting/server.md | 77 + 67 files changed, 7672 insertions(+), 254 deletions(-) create mode 100644 apps/e2e-tests/tests/common/mailhog.rs create mode 100644 apps/e2e-tests/tests/common/mock_smtp.rs create mode 100644 apps/e2e-tests/tests/email_verification.rs create mode 100644 apps/zopp-server/src/config.rs create mode 100644 apps/zopp-server/src/email/code.rs create mode 100644 apps/zopp-server/src/email/mod.rs create mode 100644 apps/zopp-server/src/email/resend.rs create mode 100644 apps/zopp-server/src/email/smtp.rs create mode 100644 apps/zopp-server/src/email/templates.rs create mode 100644 apps/zopp-server/src/handlers/verification.rs create mode 100644 apps/zopp-web/tests/fixtures/mock-smtp.ts create mode 100644 apps/zopp-web/tests/fixtures/verification-setup.ts create mode 100644 apps/zopp-web/tests/join-verification.spec.ts rename crates/zopp-store-postgres/.sqlx/{query-5e235e795e8ae96f88596fdf40839d73534e2aa607cdf78638bc797f8aa9f6bd.json => query-08a20072e1d83847bb7073f622afadac4168b078c5f979d06c70d649ec8a4dde.json} (68%) create mode 100644 crates/zopp-store-postgres/.sqlx/query-1e81dfa973d6118a99e4595f6d722360cc9d11137e4ee95d25119a2ea36c9ba0.json create mode 100644 crates/zopp-store-postgres/.sqlx/query-2dbbe181a67af9408456fae3da6581dcf7b02e9f911837df9704cbc15dce4095.json create mode 100644 crates/zopp-store-postgres/.sqlx/query-544f360cf9e89ba532bd4891644edc33460362192a43ba21cac576f25c7db1f4.json create mode 100644 crates/zopp-store-postgres/.sqlx/query-738ec493069918d00302339f499a8963ea4f604e94e0d873c55848dd276a4243.json rename crates/zopp-store-postgres/.sqlx/{query-1a285dd71495b22c02108a7e4771d2e3d72018117646ed60f1d71a4aee4286ca.json => query-8252d71d0b77bbeb8d62158b5554afabfc4d907e77b19cb54745aa46512893a2.json} (68%) rename crates/zopp-store-postgres/.sqlx/{query-65d598cc37def0ad646786055025e60c57ea9054b9e3b1e386b27fe9d1809026.json => query-9ecf9bec1f1555e9831df527cc90547f1621dc5344a12ab5cf0a7fc16f9c1712.json} (71%) create mode 100644 crates/zopp-store-postgres/.sqlx/query-b75b1dae312512d71b77625a1032c9b94f2925fd48ae2b8c20a37236dc4b4b44.json rename crates/zopp-store-postgres/.sqlx/{query-7dbcdfe7b2f7c200d8060fdc78ec61569d79c9b153f6a3f3c64a473c3bbe859e.json => query-d00910b970071aaefc4c46a26d2c2f6ea088e29f2c33a9ee8eba6dc80cd28fbc.json} (80%) create mode 100644 crates/zopp-store-postgres/.sqlx/query-ded0b0364a6eb190ecfc3734bd0428b515c7b26311fcc23cfab18bf06160cdaf.json create mode 100644 crates/zopp-store-postgres/.sqlx/query-e312f4ed9d5bb23d52391b24f69dc7e1c9529343dab86c8feab39307ac45ff08.json create mode 100644 crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql create mode 100644 crates/zopp-store-sqlite/.sqlx/query-045156a45d66fd0a2db3b483fea20de32c1a7c4d0106603004fde0098b566015.json create mode 100644 crates/zopp-store-sqlite/.sqlx/query-27acc49ba9407e635b7ce84853e9e76ef46b9833cbe08268bce7c802128d1a3f.json create mode 100644 crates/zopp-store-sqlite/.sqlx/query-334ac514da587552fdd8c18840f4e16b20acabf9794dcd6dbe6ced0d786536f6.json create mode 100644 crates/zopp-store-sqlite/.sqlx/query-354e15030102ae3406948f967123ad9883d7f21faae9c7760842e8da25412da9.json rename crates/zopp-store-sqlite/.sqlx/{query-b8d60c03a3d748340630a28d9961790e4fd2c5a78d0122c765c5cb17dcc6c5ed.json => query-43184580805f2604b05bc215cdbccf9b611b3ced09b2929abc9603ab943a3b6d.json} (60%) rename crates/zopp-store-sqlite/.sqlx/{query-02e252cbe927d2b3b350c28286f81f72faee8cb0c7d10c28d31880d2290daa43.json => query-4dafcbd07e2cb143c53d5ed7cc3febf8c637e667a0e76b000f1093768d832fa1.json} (60%) create mode 100644 crates/zopp-store-sqlite/.sqlx/query-50304f1fda474cb4c3554604f5d32c905a4fbf4fb06296e2c05c91248b7936c5.json rename crates/zopp-store-sqlite/.sqlx/{query-bbc63cffac81e98d1f1933634b23cffc0b89062d4e6cdc8388285278dd63de50.json => query-6d4f2da0f6cfe7be582f4c7b631a977bedc1358d15d232d1eb4b680b9021a3d4.json} (83%) create mode 100644 crates/zopp-store-sqlite/.sqlx/query-8d7e649f9dabcf1fd8e83cf95e8ed81d8f80b43ce8f82f00dd713f8dc6b42c5e.json rename crates/zopp-store-sqlite/.sqlx/{query-270ae06cdf65b7c1e0e9edf93006727ccb30ece860a735e8b03f7929e758355c.json => query-bef5faa625e45467d36e87265e7859f23c29faccc6a6bed608384587241e6ccf.json} (76%) create mode 100644 crates/zopp-store-sqlite/.sqlx/query-f34c7ab913fc98c5dff31e7cf8f9067d0259ed1e9697ccb2982b830b30bc24f7.json create mode 100644 crates/zopp-store-sqlite/.sqlx/query-f3a0db47f07176db3474f0e28b914d0301e3f7e2726683b008c668eafc7b0c8e.json create mode 100644 crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql create mode 100644 docker/docker-compose.test.yaml diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 13190d5d..263e84f1 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:latest + 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..570b1cd6 100644 --- a/.github/workflows/web-e2e.yaml +++ b/.github/workflows/web-e2e.yaml @@ -58,6 +58,22 @@ jobs: cd apps/zopp-web npx playwright install chromium --with-deps + - name: Start MailHog + run: | + docker run -d \ + --name mailhog \ + --network host \ + mailhog/mailhog:latest + # 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" + break + fi + echo "Waiting for MailHog... ($i/30)" + sleep 1 + done + - name: Start zopp-server run: | ./target/debug/zopp-server serve --health-addr 0.0.0.0:8081 & @@ -70,6 +86,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 +123,8 @@ jobs: npx playwright test --reporter=list env: CI: true + # MailHog API for verification code retrieval + MAILHOG_API_URL: http://localhost:8025/api/v2 - name: Upload test results uses: actions/upload-artifact@v4 @@ -112,4 +138,5 @@ jobs: if: always() run: | docker stop envoy || true + docker stop mailhog || true pkill -f zopp-server || true diff --git a/Cargo.lock b/Cargo.lock index 1f7a4e14..ceed217e 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" @@ -242,6 +251,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.7.9" @@ -359,6 +390,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-compat" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8d4d2746f89841e49230dd26917df1876050f95abafafbe34f47cb534b88d7" +dependencies = [ + "byteorder", +] + [[package]] name = "base64ct" version = "1.8.0" @@ -412,6 +452,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bufstream-fresh" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c431e5d450eceb6f5096c371f502946ae1cc65407935bc2cae8f1d625a2035f" + [[package]] name = "bumpalo" version = "3.19.0" @@ -443,6 +489,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -496,6 +544,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" @@ -547,6 +605,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + [[package]] name = "codee" version = "0.3.5" @@ -909,6 +976,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -921,14 +994,26 @@ version = "0.1.0" dependencies = [ "k8s-openapi", "kube", + "mailin-embedded", "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 +1070,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 +1210,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" @@ -1139,6 +1246,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.31" @@ -1222,6 +1335,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 +1389,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 +1448,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 +1501,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 +1514,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1374,6 +1522,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" @@ -1830,6 +1983,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.81" @@ -2237,6 +2400,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 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.3", +] + [[package]] name = "libc" version = "0.2.177" @@ -2314,6 +2505,34 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "mailin" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261c858a4b0f914d12dd9da38b9e55a7d991cd6c3e369f503344c4f1c2137fc4" +dependencies = [ + "base64-compat", + "either", + "log", + "nom 7.1.3", + "ternop", +] + +[[package]] +name = "mailin-embedded" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c60f4b1d7bed01be9f6fa08ad92c50f1d53c61e1461a058b94197046bfc5844f" +dependencies = [ + "bufstream-fresh", + "cfg-if", + "log", + "mailin", + "rustls", + "rustls-pemfile", + "scoped_threadpool", +] + [[package]] name = "manyhow" version = "0.11.4" @@ -2358,6 +2577,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" @@ -2400,6 +2630,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.0" @@ -2495,6 +2731,31 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[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 +2822,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 +3124,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 +3293,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 +3323,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 +3369,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 +3440,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 +3607,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" @@ -3395,6 +3766,7 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -3416,6 +3788,15 @@ dependencies = [ "security-framework 3.5.1", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.13.2" @@ -3431,6 +3812,7 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3500,6 +3882,12 @@ dependencies = [ "syn", ] +[[package]] +name = "scoped_threadpool" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" + [[package]] name = "scopeguard" version = "1.2.0" @@ -3897,6 +4285,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 +4509,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" @@ -4267,6 +4677,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "ternop" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d4ae32d0a4605a89c28534371b056919c12e7a070ee07505af75130ff030111" + [[package]] name = "thiserror" version = "1.0.69" @@ -5073,6 +5489,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 +5527,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 +5552,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" @@ -5761,7 +6209,7 @@ dependencies = [ "k8s-openapi", "kube", "prost", - "reqwest", + "reqwest 0.12.28", "schemars", "serde", "serde_json", @@ -5832,10 +6280,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..0163b488 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,10 @@ nix = { version = "0.29", features = ["signal"] } # For backend_test! macro name generation paste = "1.0" +# For mock SMTP server in email verification tests +mailin-embedded = "0.8" +regex = "1" + [[test]] name = "demo" path = "tests/demo.rs" @@ -64,3 +69,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..54c45ba4 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. + /// Uses MailHog if available (via docker-compose.test.yaml), otherwise falls back + /// to built-in mock SMTP server. + /// 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 + let _ = 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,112 @@ 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 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); + + // Block on async - this is OK for tests + let rt = tokio::runtime::Handle::current(); + let code = rt.block_on(async { + // 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()); + } + client.get_verification_code(email).await + })?; + + code.ok_or_else(|| { + format!( + "No verification code found in MailHog emails for: {}", + email + ) + .into() + }) + } + + /// Get the latest verification code for an email from the database. + /// This is used for E2E testing email verification flows. + pub fn get_verification_code(&self, email: &str) -> Result> { + if self.database_url.starts_with("postgres:") { + // PostgreSQL: use psql + let output = Command::new("psql") + .arg(&self.database_url) + .arg("-t") // tuple only (no headers) + .arg("-A") // unaligned output + .arg("-c") + .arg(format!( + "SELECT code FROM email_verifications WHERE email = '{}' ORDER BY created_at DESC LIMIT 1", + email + )) + .output()?; + + if !output.status.success() { + return Err( + format!("psql failed: {}", String::from_utf8_lossy(&output.stderr)).into(), + ); + } + + let code = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if code.is_empty() { + return Err(format!("No verification code found for email: {}", email).into()); + } + Ok(code) + } 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")?; + + let output = Command::new("sqlite3") + .arg(db_path) + .arg("-noheader") + .arg(format!( + "SELECT code FROM email_verifications WHERE email = '{}' ORDER BY created_at DESC LIMIT 1;", + email + )) + .output()?; + + if !output.status.success() { + return Err(format!( + "sqlite3 failed: {}", + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + + let code = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if code.is_empty() { + return Err(format!("No verification code found for email: {}", email).into()); + } + Ok(code) + } + } } impl Drop for TestHarness { @@ -524,6 +776,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..e1dd6437 --- /dev/null +++ b/apps/e2e-tests/tests/common/mailhog.rs @@ -0,0 +1,177 @@ +//! 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)] +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> { + self.client + .delete(format!("{}/messages", self.api_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 let Some(_) = self.get_email_for(to_email).await? { + return Ok(true); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + Ok(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_mailhog_client_creation() { + let client = MailHogClient::new("localhost", 8025); + assert!(client.api_url.contains("localhost:8025")); + } +} diff --git a/apps/e2e-tests/tests/common/mock_smtp.rs b/apps/e2e-tests/tests/common/mock_smtp.rs new file mode 100644 index 00000000..00c21e52 --- /dev/null +++ b/apps/e2e-tests/tests/common/mock_smtp.rs @@ -0,0 +1,200 @@ +//! Mock SMTP server for E2E testing email verification. +//! +//! Uses mailin-embedded to create a real SMTP server that captures emails, +//! allowing tests to retrieve verification codes from the email body. + +use mailin_embedded::response::{OK, START_DATA}; +use mailin_embedded::{Handler, Response, Server}; +use regex::Regex; +use std::net::TcpListener; +use std::sync::{Arc, Mutex}; +use std::thread; + +/// Captured email data +#[derive(Debug, Clone)] +pub struct CapturedEmail { + pub from: String, + pub to: Vec, + pub data: String, +} + +/// Handler that captures emails +#[derive(Clone)] +struct EmailCapture { + emails: Arc>>, + current_from: Arc>, + current_to: Arc>>, + current_data: Arc>>, +} + +impl Handler for EmailCapture { + fn helo(&mut self, _ip: std::net::IpAddr, _domain: &str) -> Response { + OK + } + + fn mail(&mut self, _ip: std::net::IpAddr, _domain: &str, from: &str) -> Response { + *self.current_from.lock().unwrap() = from.to_string(); + self.current_to.lock().unwrap().clear(); + self.current_data.lock().unwrap().clear(); + OK + } + + fn rcpt(&mut self, to: &str) -> Response { + self.current_to.lock().unwrap().push(to.to_string()); + OK + } + + fn data_start( + &mut self, + _domain: &str, + _from: &str, + _is8bit: bool, + _to: &[String], + ) -> Response { + START_DATA + } + + fn data(&mut self, buf: &[u8]) -> std::io::Result<()> { + // Accumulate data chunks - email content comes in multiple calls + self.current_data.lock().unwrap().extend_from_slice(buf); + Ok(()) + } + + fn data_end(&mut self) -> Response { + // Now we have the complete email, store it + let data = String::from_utf8_lossy(&self.current_data.lock().unwrap()).to_string(); + let from = self.current_from.lock().unwrap().clone(); + let to = self.current_to.lock().unwrap().clone(); + + self.emails + .lock() + .unwrap() + .push(CapturedEmail { from, to, data }); + + // Clear for next email + self.current_data.lock().unwrap().clear(); + + OK + } +} + +/// Mock SMTP server that captures emails for testing +pub struct MockSmtpServer { + port: u16, + emails: Arc>>, + shutdown_handle: Option>, +} + +impl MockSmtpServer { + /// Start a new mock SMTP server on a random available port + pub fn start() -> Result> { + // Find an available port + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + drop(listener); // Release the port so mailin can use it + + let emails: Arc>> = Arc::new(Mutex::new(Vec::new())); + let emails_clone = emails.clone(); + + let handle = thread::spawn(move || { + let handler = EmailCapture { + emails: emails_clone, + current_from: Arc::new(Mutex::new(String::new())), + current_to: Arc::new(Mutex::new(Vec::new())), + current_data: Arc::new(Mutex::new(Vec::new())), + }; + + let mut server = Server::new(handler); + server + .with_addr(format!("127.0.0.1:{}", port)) + .expect("Failed to set server address"); + + // This blocks until the server is stopped + // For tests, the server will be dropped when the test ends + let _ = server.serve(); + }); + + // Give the server a moment to start + std::thread::sleep(std::time::Duration::from_millis(100)); + + Ok(Self { + port, + emails, + shutdown_handle: Some(handle), + }) + } + + /// Get the port the server is listening on + pub fn port(&self) -> u16 { + self.port + } + + /// Get all captured emails + pub fn get_emails(&self) -> Vec { + self.emails.lock().unwrap().clone() + } + + /// Get the latest email sent to a specific address + pub fn get_email_for(&self, to_email: &str) -> Option { + self.emails + .lock() + .unwrap() + .iter() + .rev() + .find(|e| e.to.iter().any(|t| t.contains(to_email))) + .cloned() + } + + /// Extract verification code from the latest email to an address. + /// Looks for a 6-digit code in the email body. + pub fn get_verification_code(&self, to_email: &str) -> Option { + let email = self.get_email_for(to_email)?; + + // Look for 6-digit code in the email body + let re = Regex::new(r"\b(\d{6})\b").ok()?; + re.captures(&email.data) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) + } + + /// Clear all captured emails + pub fn clear(&self) { + self.emails.lock().unwrap().clear(); + } + + /// Wait for at least one email to arrive (with timeout) + pub fn wait_for_email(&self, timeout_ms: u64) -> bool { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(timeout_ms); + + while start.elapsed() < timeout { + if !self.emails.lock().unwrap().is_empty() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + false + } +} + +impl Drop for MockSmtpServer { + fn drop(&mut self) { + // The server thread will end when the test process ends + // We don't need to explicitly stop it for tests + if let Some(handle) = self.shutdown_handle.take() { + // Don't wait for the thread - it's blocking on accept() + drop(handle); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mock_smtp_starts() { + let server = MockSmtpServer::start().expect("Failed to start mock SMTP"); + assert!(server.port() > 0); + } +} diff --git a/apps/e2e-tests/tests/common/mod.rs b/apps/e2e-tests/tests/common/mod.rs index 9f69a048..f83009b6 100644 --- a/apps/e2e-tests/tests/common/mod.rs +++ b/apps/e2e-tests/tests/common/mod.rs @@ -1,6 +1,8 @@ //! Common utilities for E2E tests. pub mod harness; +pub mod mailhog; +pub mod mock_smtp; pub mod utils; // Re-export for tests that use the new infrastructure @@ -8,4 +10,8 @@ pub mod utils; #[allow(unused_imports)] pub use harness::*; #[allow(unused_imports)] +pub use mailhog::*; +#[allow(unused_imports)] +pub use mock_smtp::*; +#[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..31abd8ef --- /dev/null +++ b/apps/e2e-tests/tests/email_verification.rs @@ -0,0 +1,165 @@ +//! 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", + "000000", // Invalid code + ]); + + // 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())?; + 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 code should match email code..."); + + let db_code = harness.get_verification_code(&alice.email())?; + assert_eq!(code, db_code, "Email code should match database code"); + println!(" Database code matches: {}", db_code); + + 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", + "000000", + ]); + 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())?; + 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/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..879909fb 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,94 @@ 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> { + const MAX_ATTEMPTS: u32 = 3; + let mut attempt = 0; + + loop { + attempt += 1; + + // Prompt for verification code + 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); + attempt = 0; // Reset attempts after successful resend + continue; + } else { + println!("⚠ {}\n", resend_response.message); + 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 we've exceeded max attempts + if attempt >= MAX_ATTEMPTS { + println!("Too many failed attempts. You can request a new code with 'r'.\n"); + attempt = 0; // Reset to allow more attempts after message + } + } +} 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..35085e69 --- /dev/null +++ b/apps/zopp-server/src/config.rs @@ -0,0 +1,423 @@ +//! 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 + 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..7c12627e 100644 --- a/apps/zopp-server/src/handlers/auth.rs +++ b/apps/zopp-server/src/handlers/auth.rs @@ -1,5 +1,6 @@ //! Authentication handlers: join, register, login +use chrono::Utc; use prost::Message; use sha2::{Digest, Sha256}; use tonic::{Request, Response, Status}; @@ -7,9 +8,11 @@ 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 +39,102 @@ 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 - this could be a retry or existing user joining a workspace + 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 = server.store.get_email_verification(&email).await.ok(); + 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(); + + // Store verification record (expires in 15 minutes) + // 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: code.clone(), + invite_token: req.invite_token.clone(), + expires_at, + }) + .await + .map_err(|e| Status::internal(format!("Failed to create verification: {}", e)))?; + + // Send verification email - fail if email cannot be sent + 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)) + })?; + } + } + + // 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 +203,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 +229,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 +259,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..74c9eeb3 --- /dev/null +++ b/apps/zopp-server/src/handlers/verification.rs @@ -0,0 +1,336 @@ +//! Email verification handlers. + +use chrono::Utc; +use tonic::{Request, Response, Status}; +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![], + })); + } + + // Verify the code using constant-time comparison + let code_matches: bool = + subtle::ConstantTimeEq::ct_eq(req.code.as_bytes(), verification.code.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)))?; + + let remaining = MAX_ATTEMPTS - attempts; + 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(); + + // Update verification record with new code (upsert preserves invite_token) + let expires_at = Utc::now() + chrono::Duration::minutes(15); + server + .store + .create_email_verification(&CreateEmailVerificationParams { + email: email.clone(), + code: code.clone(), + invite_token: existing.invite_token, // Preserve the original invite token + expires_at, + }) + .await + .map_err(|e| Status::internal(format!("Failed to update verification: {}", e)))?; + + // Send verification email + 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(), + })); + } + + 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..641f66ef 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,36 @@ 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 server_config.email.is_some() { + match email::create_provider(server_config.email.as_ref().unwrap()) { + Ok(provider) => { + println!("Email verification enabled"); + Some(Arc::from(provider)) + } + Err(e) => { + eprintln!("Warning: Failed to create email provider: {}. Email verification will be disabled.", e); + None + } + } + } else { + if server_config.is_verification_required() { + eprintln!("Warning: Email verification is required but no email provider configured. Verification will be skipped."); + } + 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..253e1a8f 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) @@ -1118,14 +1146,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 +1204,169 @@ 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 user with unverified principal (default state) + let (_user_id, principal_id, signing_key) = + create_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 user with unverified principal + let (_user_id, principal_id, signing_key) = + create_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_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 +4191,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 +4639,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 +5666,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 +14437,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..000208b9 100644 --- a/apps/zopp-web/src/pages/register.rs +++ b/apps/zopp-web/src/pages/register.rs @@ -6,18 +6,39 @@ 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)] +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 +50,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 +60,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 +101,281 @@ 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; + } + + 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_error, + set_loading, + ) + .await + { + // Navigate on success + if let Some(window) = web_sys::window() { + let _ = window.location().set_href("/workspaces"); + } + } + } 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 |_| { + 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 +383,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 @@ -318,11 +587,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 +606,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) + } 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/verification-setup.ts b/apps/zopp-web/tests/fixtures/verification-setup.ts new file mode 100644 index 00000000..30e8f267 --- /dev/null +++ b/apps/zopp-web/tests/fixtures/verification-setup.ts @@ -0,0 +1,214 @@ +/** + * 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, 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); + await fetch(url, { signal: controller.signal }); + clearTimeout(timeoutId); + return true; + } catch { + 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:${envoyPort}`, // Would be Envoy port if 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 + } +} + +/** + * Get verification code from the database directly. + * This is a fallback if mock SMTP doesn't capture the email. + */ +export function getVerificationCodeFromDb(dbPath: string, email: string): string | null { + try { + const result = execSync( + `sqlite3 "${dbPath}" "SELECT code FROM email_verifications WHERE email = '${email}' ORDER BY created_at DESC LIMIT 1;"`, + { encoding: 'utf-8' } + ).trim(); + return result || null; + } catch { + return null; + } +} + +// 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..3638c038 --- /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 + await expect(page.getByText(/verification code/i)).toBeVisible({ timeout: 10000 }); + await expect(page.getByPlaceholder(/Enter.*code/i)).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 + await expect(page.getByText(/verification code/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(/Enter.*code/i).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 + await expect(page.getByText(/verification code/i)).toBeVisible({ timeout: 10000 }); + + // Enter an invalid code + await page.getByPlaceholder(/Enter.*code/i).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 + await expect(page.getByText(/verification code/i)).toBeVisible({ timeout: 10000 }); + + // First attempt with invalid code + await page.getByPlaceholder(/Enter.*code/i).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(/Enter.*code/i).clear(); + await page.getByPlaceholder(/Enter.*code/i).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-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..109456cc 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: String, // 6-digit verification code + 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: String, // 6-digit verification code + 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: params.code.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-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-2dbbe181a67af9408456fae3da6581dcf7b02e9f911837df9704cbc15dce4095.json b/crates/zopp-store-postgres/.sqlx/query-2dbbe181a67af9408456fae3da6581dcf7b02e9f911837df9704cbc15dce4095.json new file mode 100644 index 00000000..fd9ee021 --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-2dbbe181a67af9408456fae3da6581dcf7b02e9f911837df9704cbc15dce4095.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, email, code, 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", + "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": "2dbbe181a67af9408456fae3da6581dcf7b02e9f911837df9704cbc15dce4095" +} 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-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-b75b1dae312512d71b77625a1032c9b94f2925fd48ae2b8c20a37236dc4b4b44.json b/crates/zopp-store-postgres/.sqlx/query-b75b1dae312512d71b77625a1032c9b94f2925fd48ae2b8c20a37236dc4b4b44.json new file mode 100644 index 00000000..eb717aa6 --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-b75b1dae312512d71b77625a1032c9b94f2925fd48ae2b8c20a37236dc4b4b44.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO email_verifications(id, email, code, invite_token, expires_at)\n VALUES($1, $2, $3, $4, $5)\n ON CONFLICT (email) DO UPDATE SET\n id = EXCLUDED.id,\n code = EXCLUDED.code,\n invite_token = EXCLUDED.invite_token,\n expires_at = EXCLUDED.expires_at,\n attempts = 0,\n created_at = NOW()\n RETURNING id, email, code, 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", + "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": "b75b1dae312512d71b77625a1032c9b94f2925fd48ae2b8c20a37236dc4b4b44" +} 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/.sqlx/query-e312f4ed9d5bb23d52391b24f69dc7e1c9529343dab86c8feab39307ac45ff08.json b/crates/zopp-store-postgres/.sqlx/query-e312f4ed9d5bb23d52391b24f69dc7e1c9529343dab86c8feab39307ac45ff08.json new file mode 100644 index 00000000..241fbb03 --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-e312f4ed9d5bb23d52391b24f69dc7e1c9529343dab86c8feab39307ac45ff08.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE invites SET consumed = TRUE WHERE token = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "e312f4ed9d5bb23d52391b24f69dc7e1c9529343dab86c8feab39307ac45ff08" +} 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..e04602d5 --- /dev/null +++ b/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql @@ -0,0 +1,24 @@ +-- 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 +ALTER TABLE users ADD COLUMN verified BOOLEAN NOT NULL 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 TEXT NOT NULL, -- 6-digit verification code + 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..95ccad3c 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, @@ -142,7 +143,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 +154,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 +162,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 +173,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 +318,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 +360,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 +368,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 +405,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 +427,19 @@ impl Store for PostgresStore { } } + async fn consume_invite(&self, token: &str) -> Result<(), StoreError> { + let result = sqlx::query!("UPDATE invites SET consumed = TRUE WHERE token = $1", token) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + if result.rows_affected() == 0 { + Err(StoreError::NotFound) + } else { + Ok(()) + } + } + // ───────────────────────────── Principal Exports ────────────────────────── async fn create_principal_export( @@ -555,6 +574,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, invite_token, expires_at) + VALUES($1, $2, $3, $4, $5) + ON CONFLICT (email) DO UPDATE SET + id = EXCLUDED.id, + code = EXCLUDED.code, + invite_token = EXCLUDED.invite_token, + expires_at = EXCLUDED.expires_at, + attempts = 0, + created_at = NOW() + RETURNING id, email, code, invite_token, attempts, created_at, expires_at"#, + id, + email, + params.code, + 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: row.code, + 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, 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: row.code, + 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-334ac514da587552fdd8c18840f4e16b20acabf9794dcd6dbe6ced0d786536f6.json b/crates/zopp-store-sqlite/.sqlx/query-334ac514da587552fdd8c18840f4e16b20acabf9794dcd6dbe6ced0d786536f6.json new file mode 100644 index 00000000..d8b762c6 --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-334ac514da587552fdd8c18840f4e16b20acabf9794dcd6dbe6ced0d786536f6.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE invites SET consumed = 1 WHERE token = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "334ac514da587552fdd8c18840f4e16b20acabf9794dcd6dbe6ced0d786536f6" +} 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-b8d60c03a3d748340630a28d9961790e4fd2c5a78d0122c765c5cb17dcc6c5ed.json b/crates/zopp-store-sqlite/.sqlx/query-43184580805f2604b05bc215cdbccf9b611b3ced09b2929abc9603ab943a3b6d.json similarity index 60% rename from crates/zopp-store-sqlite/.sqlx/query-b8d60c03a3d748340630a28d9961790e4fd2c5a78d0122c765c5cb17dcc6c5ed.json rename to crates/zopp-store-sqlite/.sqlx/query-43184580805f2604b05bc215cdbccf9b611b3ced09b2929abc9603ab943a3b6d.json index 103b13d5..a963785f 100644 --- a/crates/zopp-store-sqlite/.sqlx/query-b8d60c03a3d748340630a28d9961790e4fd2c5a78d0122c765c5cb17dcc6c5ed.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 email = ?", + "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": "b8d60c03a3d748340630a28d9961790e4fd2c5a78d0122c765c5cb17dcc6c5ed" + "hash": "43184580805f2604b05bc215cdbccf9b611b3ced09b2929abc9603ab943a3b6d" } diff --git a/crates/zopp-store-sqlite/.sqlx/query-02e252cbe927d2b3b350c28286f81f72faee8cb0c7d10c28d31880d2290daa43.json b/crates/zopp-store-sqlite/.sqlx/query-4dafcbd07e2cb143c53d5ed7cc3febf8c637e667a0e76b000f1093768d832fa1.json similarity index 60% rename from crates/zopp-store-sqlite/.sqlx/query-02e252cbe927d2b3b350c28286f81f72faee8cb0c7d10c28d31880d2290daa43.json rename to crates/zopp-store-sqlite/.sqlx/query-4dafcbd07e2cb143c53d5ed7cc3febf8c637e667a0e76b000f1093768d832fa1.json index bec94287..38d7e7c7 100644 --- a/crates/zopp-store-sqlite/.sqlx/query-02e252cbe927d2b3b350c28286f81f72faee8cb0c7d10c28d31880d2290daa43.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 id = ?", + "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": "02e252cbe927d2b3b350c28286f81f72faee8cb0c7d10c28d31880d2290daa43" + "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-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-f34c7ab913fc98c5dff31e7cf8f9067d0259ed1e9697ccb2982b830b30bc24f7.json b/crates/zopp-store-sqlite/.sqlx/query-f34c7ab913fc98c5dff31e7cf8f9067d0259ed1e9697ccb2982b830b30bc24f7.json new file mode 100644 index 00000000..4a9d9ca4 --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-f34c7ab913fc98c5dff31e7cf8f9067d0259ed1e9697ccb2982b830b30bc24f7.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO email_verifications(id, email, code, invite_token, expires_at) VALUES(?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "f34c7ab913fc98c5dff31e7cf8f9067d0259ed1e9697ccb2982b830b30bc24f7" +} diff --git a/crates/zopp-store-sqlite/.sqlx/query-f3a0db47f07176db3474f0e28b914d0301e3f7e2726683b008c668eafc7b0c8e.json b/crates/zopp-store-sqlite/.sqlx/query-f3a0db47f07176db3474f0e28b914d0301e3f7e2726683b008c668eafc7b0c8e.json new file mode 100644 index 00000000..f399008a --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-f3a0db47f07176db3474f0e28b914d0301e3f7e2726683b008c668eafc7b0c8e.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, email, code, 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", + "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": "f3a0db47f07176db3474f0e28b914d0301e3f7e2726683b008c668eafc7b0c8e" +} 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..cdf7b1b2 --- /dev/null +++ b/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql @@ -0,0 +1,24 @@ +-- 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 +ALTER TABLE users ADD COLUMN verified INTEGER NOT NULL DEFAULT 0; + +-- 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 TEXT NOT NULL, -- 6-digit verification code + 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..fcf16706 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, @@ -122,7 +123,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 { @@ -179,7 +187,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 +205,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 +216,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 +234,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 +425,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 +435,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 +486,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 +501,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 +551,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 +571,19 @@ impl Store for SqliteStore { } } + async fn consume_invite(&self, token: &str) -> Result<(), StoreError> { + let result = sqlx::query!("UPDATE invites SET consumed = 1 WHERE token = ?", token) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + if result.rows_affected() == 0 { + Err(StoreError::NotFound) + } else { + Ok(()) + } + } + // ───────────────────────────── Principal Exports ────────────────────────── async fn create_principal_export( @@ -711,6 +737,139 @@ 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 + sqlx::query!( + "INSERT OR REPLACE INTO email_verifications(id, email, code, invite_token, expires_at) VALUES(?, ?, ?, ?, ?)", + id_str, + email, + params.code, + 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: params.code.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, 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: row.code, + 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( @@ -4112,4 +4271,216 @@ mod tests { .unwrap_err(); 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: "123456".to_string(), + 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, "123456"); + 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(); + 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: "123456".to_string(), + 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: "123456".to_string(), + 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(); + 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(); + 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: "111111".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: "222222".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(); + 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 with principal + let (user_id, _) = s + .create_user(&CreateUserParams { + email: "test@example.com".to_string(), + principal: Some(CreatePrincipalData { + name: "laptop".to_string(), + public_key: vec![1, 2, 3, 4], + x25519_public_key: Some(vec![5, 6, 7, 8]), + is_service: false, + }), + workspace_ids: vec![], + }) + .await + .unwrap(); + + // New users should be unverified by default + 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(); + 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: "111111".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: "222222".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 + let got = s.get_email_verification("test@example.com").await.unwrap(); + assert_eq!(got.id, second.id); + assert_eq!(got.code, "222222"); + 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: From 87eb831a5671ce9831d6455bd1d75b73826065e5 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 13:42:58 -0300 Subject: [PATCH 02/25] Fix clippy warnings for conditional code paths --- apps/zopp-server/src/config.rs | 1 + apps/zopp-server/src/main.rs | 6 ++++-- apps/zopp-server/src/tests.rs | 2 +- apps/zopp-web/src/pages/register.rs | 3 +++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/zopp-server/src/config.rs b/apps/zopp-server/src/config.rs index 35085e69..a9ea0196 100644 --- a/apps/zopp-server/src/config.rs +++ b/apps/zopp-server/src/config.rs @@ -51,6 +51,7 @@ 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 diff --git a/apps/zopp-server/src/main.rs b/apps/zopp-server/src/main.rs index 641f66ef..5075b0e9 100644 --- a/apps/zopp-server/src/main.rs +++ b/apps/zopp-server/src/main.rs @@ -309,8 +309,10 @@ async fn cmd_serve_with_ready( .map_err(|e| format!("Failed to load server configuration: {}", e))?; // Create email provider if configured - let email_provider: Option> = if server_config.email.is_some() { - match email::create_provider(server_config.email.as_ref().unwrap()) { + 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)) diff --git a/apps/zopp-server/src/tests.rs b/apps/zopp-server/src/tests.rs index 253e1a8f..d8527f35 100644 --- a/apps/zopp-server/src/tests.rs +++ b/apps/zopp-server/src/tests.rs @@ -1328,7 +1328,7 @@ mod handler_tests { let server = create_test_server_with_verification().await; // Create verified user to set up workspace/project/env - let (user_id, owner_principal_id, _) = + 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(); diff --git a/apps/zopp-web/src/pages/register.rs b/apps/zopp-web/src/pages/register.rs index 000208b9..03096f86 100644 --- a/apps/zopp-web/src/pages/register.rs +++ b/apps/zopp-web/src/pages/register.rs @@ -8,6 +8,7 @@ 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, @@ -110,6 +111,7 @@ pub fn RegisterPage() -> impl IntoView { return; } + #[allow(unused_variables)] // Used in wasm32 target let pending = match pending_verification.get() { Some(p) => p, None => return, @@ -182,6 +184,7 @@ pub fn RegisterPage() -> impl IntoView { }; let on_resend = move |_| { + #[allow(unused_variables)] // Used in wasm32 target let pending = match pending_verification.get() { Some(p) => p, None => return, From 2ebaf3216afe6dceeb5b768585fc6df9ab949268 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 13:47:26 -0300 Subject: [PATCH 03/25] Fix clippy warnings in e2e-tests module --- apps/e2e-tests/tests/common/mailhog.rs | 3 ++- apps/e2e-tests/tests/common/mock_smtp.rs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/e2e-tests/tests/common/mailhog.rs b/apps/e2e-tests/tests/common/mailhog.rs index e1dd6437..290edbf5 100644 --- a/apps/e2e-tests/tests/common/mailhog.rs +++ b/apps/e2e-tests/tests/common/mailhog.rs @@ -10,6 +10,7 @@ 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, @@ -156,7 +157,7 @@ impl MailHogClient { let timeout = std::time::Duration::from_millis(timeout_ms); while start.elapsed() < timeout { - if let Some(_) = self.get_email_for(to_email).await? { + if self.get_email_for(to_email).await?.is_some() { return Ok(true); } tokio::time::sleep(std::time::Duration::from_millis(200)).await; diff --git a/apps/e2e-tests/tests/common/mock_smtp.rs b/apps/e2e-tests/tests/common/mock_smtp.rs index 00c21e52..b469193c 100644 --- a/apps/e2e-tests/tests/common/mock_smtp.rs +++ b/apps/e2e-tests/tests/common/mock_smtp.rs @@ -2,6 +2,11 @@ //! //! Uses mailin-embedded to create a real SMTP server that captures emails, //! allowing tests to retrieve verification codes from the email body. +//! +//! Note: This module is kept for potential future use but currently tests use +//! MailHog (via docker-compose.test.yaml) for email capture. + +#![allow(dead_code)] use mailin_embedded::response::{OK, START_DATA}; use mailin_embedded::{Handler, Response, Server}; From 38931edf131c3d04328f8787408ac24161f21b7a Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 13:52:22 -0300 Subject: [PATCH 04/25] Fix unused imports in e2e-tests --- apps/e2e-tests/tests/common/mailhog.rs | 2 +- apps/e2e-tests/tests/common/mock_smtp.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/e2e-tests/tests/common/mailhog.rs b/apps/e2e-tests/tests/common/mailhog.rs index 290edbf5..19aacf5e 100644 --- a/apps/e2e-tests/tests/common/mailhog.rs +++ b/apps/e2e-tests/tests/common/mailhog.rs @@ -168,7 +168,7 @@ impl MailHogClient { #[cfg(test)] mod tests { - use super::*; + use super::MailHogClient; #[tokio::test] async fn test_mailhog_client_creation() { diff --git a/apps/e2e-tests/tests/common/mock_smtp.rs b/apps/e2e-tests/tests/common/mock_smtp.rs index b469193c..b5499913 100644 --- a/apps/e2e-tests/tests/common/mock_smtp.rs +++ b/apps/e2e-tests/tests/common/mock_smtp.rs @@ -195,7 +195,7 @@ impl Drop for MockSmtpServer { #[cfg(test)] mod tests { - use super::*; + use super::MockSmtpServer; #[test] fn test_mock_smtp_starts() { From 1ef185e8ec60075b2fde04526718869ab80e6f69 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 13:56:02 -0300 Subject: [PATCH 05/25] Fix test imports to use super:: path --- apps/e2e-tests/tests/common/mailhog.rs | 4 +--- apps/e2e-tests/tests/common/mock_smtp.rs | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/e2e-tests/tests/common/mailhog.rs b/apps/e2e-tests/tests/common/mailhog.rs index 19aacf5e..93ec70ca 100644 --- a/apps/e2e-tests/tests/common/mailhog.rs +++ b/apps/e2e-tests/tests/common/mailhog.rs @@ -168,11 +168,9 @@ impl MailHogClient { #[cfg(test)] mod tests { - use super::MailHogClient; - #[tokio::test] async fn test_mailhog_client_creation() { - let client = MailHogClient::new("localhost", 8025); + let client = super::MailHogClient::new("localhost", 8025); assert!(client.api_url.contains("localhost:8025")); } } diff --git a/apps/e2e-tests/tests/common/mock_smtp.rs b/apps/e2e-tests/tests/common/mock_smtp.rs index b5499913..030c4be0 100644 --- a/apps/e2e-tests/tests/common/mock_smtp.rs +++ b/apps/e2e-tests/tests/common/mock_smtp.rs @@ -195,11 +195,9 @@ impl Drop for MockSmtpServer { #[cfg(test)] mod tests { - use super::MockSmtpServer; - #[test] fn test_mock_smtp_starts() { - let server = MockSmtpServer::start().expect("Failed to start mock SMTP"); + let server = super::MockSmtpServer::start().expect("Failed to start mock SMTP"); assert!(server.port() > 0); } } From 0a859cfd02b7491a1beba452afe49044fe6ec928 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 14:05:16 -0300 Subject: [PATCH 06/25] Make get_verification_code_from_email async to avoid nested runtime --- apps/e2e-tests/tests/common/harness.rs | 30 ++++++++++------------ apps/e2e-tests/tests/email_verification.rs | 4 +-- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/apps/e2e-tests/tests/common/harness.rs b/apps/e2e-tests/tests/common/harness.rs index 54c45ba4..ac5f6567 100644 --- a/apps/e2e-tests/tests/common/harness.rs +++ b/apps/e2e-tests/tests/common/harness.rs @@ -578,7 +578,7 @@ impl TestHarness { /// Get the latest verification code for an email from MailHog. /// Returns an error if MailHog is not configured or no email was found. - pub fn get_verification_code_from_email( + pub async fn get_verification_code_from_email( &self, email: &str, ) -> Result> { @@ -590,23 +590,19 @@ impl TestHarness { 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); - // Block on async - this is OK for tests - let rt = tokio::runtime::Handle::current(); - let code = rt.block_on(async { - // 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()); - } - client.get_verification_code(email).await - })?; + // 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: {}", diff --git a/apps/e2e-tests/tests/email_verification.rs b/apps/e2e-tests/tests/email_verification.rs index 31abd8ef..9e48cad0 100644 --- a/apps/e2e-tests/tests/email_verification.rs +++ b/apps/e2e-tests/tests/email_verification.rs @@ -97,7 +97,7 @@ async fn run_test_invalid_verification_code( 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())?; + 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()), @@ -139,7 +139,7 @@ async fn run_test_full_verification_flow( 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())?; + 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..."); From 933803a83a3b05b1a098fb5be159a1d655ee3df7 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 14:10:42 -0300 Subject: [PATCH 07/25] Run cargo fmt --- apps/e2e-tests/tests/email_verification.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/e2e-tests/tests/email_verification.rs b/apps/e2e-tests/tests/email_verification.rs index 9e48cad0..d37bc8e7 100644 --- a/apps/e2e-tests/tests/email_verification.rs +++ b/apps/e2e-tests/tests/email_verification.rs @@ -97,7 +97,9 @@ async fn run_test_invalid_verification_code( 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?; + 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()), @@ -139,7 +141,9 @@ async fn run_test_full_verification_flow( 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?; + 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..."); From 6c7d8e17e69b4e45947dd8da59c113c369f7ab06 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 14:43:19 -0300 Subject: [PATCH 08/25] Fix web app compilation errors: clone values and fix type conversion --- apps/zopp-web/src/pages/register.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/zopp-web/src/pages/register.rs b/apps/zopp-web/src/pages/register.rs index 03096f86..707358dd 100644 --- a/apps/zopp-web/src/pages/register.rs +++ b/apps/zopp-web/src/pages/register.rs @@ -580,9 +580,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))?; @@ -659,7 +659,7 @@ async fn verify_email_code( Some(response.message) }, attempts_remaining: if response.attempts_remaining > 0 { - Some(response.attempts_remaining) + Some(response.attempts_remaining as u32) } else { None }, From 0cd5a8136c7e68826cf4791da3aadc763e55f27f Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 15:56:26 -0300 Subject: [PATCH 09/25] Address Cubic code review feedback for email verification Fixes issues identified by Cubic AI reviewer: P1 (Critical): - Server now aborts startup if email verification is required but provider initialization fails, preventing silent verification bypass P2 (Security/Correctness): - Verification handler now locks out and deletes record immediately when MAX_ATTEMPTS is reached, preventing negative remaining attempts - SQL injection prevention: escape single quotes in email before SQL interpolation in test harness and TypeScript fixtures - Pin MailHog image to v1.0.1 for reproducible CI builds - Fail web-e2e workflow if MailHog doesn't become ready - CLI join flow now requires resend when server returns 0 attempts remaining, preventing unlimited local retries - Bootstrap invites now reject verified existing users when email verification is required, matching non-verification flow behavior - consume_invite is now atomic in both PostgreSQL and SQLite stores, checking consumed=FALSE in UPDATE to prevent concurrent consumption - Migration now sets existing users as verified (DEFAULT TRUE) then changes default to FALSE for new users, preserving existing accounts Also updates sqlx metadata for both stores. --- .github/workflows/e2e.yaml | 2 +- .github/workflows/web-e2e.yaml | 6 ++- apps/e2e-tests/tests/common/harness.rs | 8 +++- apps/zopp-cli/src/commands/join.rs | 30 ++++++++----- apps/zopp-server/src/handlers/auth.rs | 32 ++++++++++---- apps/zopp-server/src/handlers/verification.rs | 21 +++++++++- apps/zopp-server/src/main.rs | 13 +++++- .../tests/fixtures/verification-setup.ts | 10 +++-- ...449b5e50898be6899df531afe8072775dc65c.json | 15 ------- ...e31d7067516df92e30e6f4899cbbb863f7433.json | 16 +++++++ ...d3068bf65761764c9429d4d923ace5850a91a.json | 22 ++++++++++ ...bf58ed26b7ee5eac3a8b56a9611154ba23932.json | 14 +++++++ ...dc7e1c9529343dab86c8feab39307ac45ff08.json | 14 ------- .../20260125000001_add_email_verification.sql | 5 ++- crates/zopp-store-postgres/src/lib.rs | 42 +++++++++++++++---- ...4dd46eebdf150ec1e4c666eaa0284a722a686.json | 12 ++++++ ...c11f8fdf82a19ef1069b7e4025c2d88a72480.json | 12 ++++++ ...4e16b20acabf9794dcd6dbe6ced0d786536f6.json | 12 ------ ...7d56e039664dbf1cf4c08836c84990c93198d.json | 20 +++++++++ ...8341cf1fadb2698e55375fa0eb9f085e7f591.json | 12 ------ .../20260125000001_add_email_verification.sql | 4 +- crates/zopp-store-sqlite/src/lib.rs | 42 ++++++++++++++----- 22 files changed, 263 insertions(+), 101 deletions(-) delete mode 100644 crates/zopp-store-postgres/.sqlx/query-14e74c8e54d3554b2a3c18f95fa449b5e50898be6899df531afe8072775dc65c.json create mode 100644 crates/zopp-store-postgres/.sqlx/query-4e82b0d6f3a330f45b287c4be36e31d7067516df92e30e6f4899cbbb863f7433.json create mode 100644 crates/zopp-store-postgres/.sqlx/query-4ed1f63d30d7a812224d49f5db2d3068bf65761764c9429d4d923ace5850a91a.json create mode 100644 crates/zopp-store-postgres/.sqlx/query-8ee895804b3dbc55ed08e7d66dfbf58ed26b7ee5eac3a8b56a9611154ba23932.json delete mode 100644 crates/zopp-store-postgres/.sqlx/query-e312f4ed9d5bb23d52391b24f69dc7e1c9529343dab86c8feab39307ac45ff08.json create mode 100644 crates/zopp-store-sqlite/.sqlx/query-22c3a135e3f231678a4865e17cd4dd46eebdf150ec1e4c666eaa0284a722a686.json create mode 100644 crates/zopp-store-sqlite/.sqlx/query-2f9136a88959b45e3095b5ee200c11f8fdf82a19ef1069b7e4025c2d88a72480.json delete mode 100644 crates/zopp-store-sqlite/.sqlx/query-334ac514da587552fdd8c18840f4e16b20acabf9794dcd6dbe6ced0d786536f6.json create mode 100644 crates/zopp-store-sqlite/.sqlx/query-a8c0c502c39e88507def64c9cca7d56e039664dbf1cf4c08836c84990c93198d.json delete mode 100644 crates/zopp-store-sqlite/.sqlx/query-d27d6d7e46a8019339d526322ec8341cf1fadb2698e55375fa0eb9f085e7f591.json diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 263e84f1..fbd69229 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -32,7 +32,7 @@ jobs: # MailHog - Mock SMTP server for email verification tests mailhog: - image: mailhog/mailhog:latest + image: mailhog/mailhog:v1.0.1 ports: - 1025:1025 # SMTP - 8025:8025 # Web UI / API diff --git a/.github/workflows/web-e2e.yaml b/.github/workflows/web-e2e.yaml index 570b1cd6..c05c6678 100644 --- a/.github/workflows/web-e2e.yaml +++ b/.github/workflows/web-e2e.yaml @@ -63,16 +63,18 @@ jobs: docker run -d \ --name mailhog \ --network host \ - mailhog/mailhog:latest + 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" - break + 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: | diff --git a/apps/e2e-tests/tests/common/harness.rs b/apps/e2e-tests/tests/common/harness.rs index ac5f6567..f8171388 100644 --- a/apps/e2e-tests/tests/common/harness.rs +++ b/apps/e2e-tests/tests/common/harness.rs @@ -617,6 +617,8 @@ impl TestHarness { pub fn get_verification_code(&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) @@ -624,7 +626,7 @@ impl TestHarness { .arg("-c") .arg(format!( "SELECT code FROM email_verifications WHERE email = '{}' ORDER BY created_at DESC LIMIT 1", - email + escaped_email )) .output()?; @@ -648,12 +650,14 @@ impl TestHarness { .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 code FROM email_verifications WHERE email = '{}' ORDER BY created_at DESC LIMIT 1;", - email + escaped_email )) .output()?; diff --git a/apps/zopp-cli/src/commands/join.rs b/apps/zopp-cli/src/commands/join.rs index 879909fb..338d06b9 100644 --- a/apps/zopp-cli/src/commands/join.rs +++ b/apps/zopp-cli/src/commands/join.rs @@ -274,14 +274,16 @@ async fn verify_email_flow( email: &str, keys: &PrincipalKeys, ) -> Result, Box> { - const MAX_ATTEMPTS: u32 = 3; - let mut attempt = 0; + // Track if user must resend before trying another code (after server lockout) + let mut must_resend = false; loop { - attempt += 1; - - // Prompt for verification code - print!("Enter verification code (or 'r' to resend, 'q' to quit): "); + // 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(); @@ -304,7 +306,7 @@ async fn verify_email_flow( if resend_response.success { println!("✓ New verification code sent to {}\n", email); - attempt = 0; // Reset attempts after successful resend + must_resend = false; // Allow attempts again after successful resend continue; } else { println!("⚠ {}\n", resend_response.message); @@ -312,6 +314,12 @@ async fn verify_email_flow( } } + // 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"); @@ -343,10 +351,10 @@ async fn verify_email_flow( println!("⚠ {}\n", verify_response.message); - // Check if we've exceeded max attempts - if attempt >= MAX_ATTEMPTS { - println!("Too many failed attempts. You can request a new code with 'r'.\n"); - attempt = 0; // Reset to allow more attempts after 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-server/src/handlers/auth.rs b/apps/zopp-server/src/handlers/auth.rs index 7c12627e..4395f620 100644 --- a/apps/zopp-server/src/handlers/auth.rs +++ b/apps/zopp-server/src/handlers/auth.rs @@ -66,13 +66,31 @@ pub async fn join( { Ok((uid, _)) => uid, Err(StoreError::AlreadyExists) => { - // User exists - this could be a retry or existing user joining a workspace - server - .store - .get_user_by_email(&email) - .await - .map_err(|e| Status::internal(format!("Failed to get existing user: {}", e)))? - .id + // 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))), }; diff --git a/apps/zopp-server/src/handlers/verification.rs b/apps/zopp-server/src/handlers/verification.rs index 74c9eeb3..c5027dab 100644 --- a/apps/zopp-server/src/handlers/verification.rs +++ b/apps/zopp-server/src/handlers/verification.rs @@ -107,7 +107,26 @@ pub async fn verify_email( .await .map_err(|e| Status::internal(format!("Failed to increment attempts: {}", e)))?; - let remaining = MAX_ATTEMPTS - attempts; + // 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!( diff --git a/apps/zopp-server/src/main.rs b/apps/zopp-server/src/main.rs index 5075b0e9..904cf7f0 100644 --- a/apps/zopp-server/src/main.rs +++ b/apps/zopp-server/src/main.rs @@ -318,13 +318,24 @@ async fn cmd_serve_with_ready( 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() { - eprintln!("Warning: Email verification is required but no email provider configured. Verification will be skipped."); + // 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 }; diff --git a/apps/zopp-web/tests/fixtures/verification-setup.ts b/apps/zopp-web/tests/fixtures/verification-setup.ts index 30e8f267..377a580c 100644 --- a/apps/zopp-web/tests/fixtures/verification-setup.ts +++ b/apps/zopp-web/tests/fixtures/verification-setup.ts @@ -9,7 +9,7 @@ */ import { test as base, Page } from '@playwright/test'; -import { execSync, spawn, ChildProcess } from 'child_process'; +import { execSync, execFileSync, spawn, ChildProcess } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -190,8 +190,12 @@ export async function teardownVerificationTest(ctx: VerificationTestContext): Pr */ export function getVerificationCodeFromDb(dbPath: string, email: string): string | null { try { - const result = execSync( - `sqlite3 "${dbPath}" "SELECT code FROM email_verifications WHERE email = '${email}' ORDER BY created_at DESC LIMIT 1;"`, + // 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 code FROM email_verifications WHERE email = '${escapedEmail}' ORDER BY created_at DESC LIMIT 1;`], { encoding: 'utf-8' } ).trim(); return result || null; 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-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-4ed1f63d30d7a812224d49f5db2d3068bf65761764c9429d4d923ace5850a91a.json b/crates/zopp-store-postgres/.sqlx/query-4ed1f63d30d7a812224d49f5db2d3068bf65761764c9429d4d923ace5850a91a.json new file mode 100644 index 00000000..27ceac46 --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-4ed1f63d30d7a812224d49f5db2d3068bf65761764c9429d4d923ace5850a91a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM invites WHERE token = $1) as \"exists!: bool\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!: bool", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4ed1f63d30d7a812224d49f5db2d3068bf65761764c9429d4d923ace5850a91a" +} diff --git a/crates/zopp-store-postgres/.sqlx/query-8ee895804b3dbc55ed08e7d66dfbf58ed26b7ee5eac3a8b56a9611154ba23932.json b/crates/zopp-store-postgres/.sqlx/query-8ee895804b3dbc55ed08e7d66dfbf58ed26b7ee5eac3a8b56a9611154ba23932.json new file mode 100644 index 00000000..865f748f --- /dev/null +++ b/crates/zopp-store-postgres/.sqlx/query-8ee895804b3dbc55ed08e7d66dfbf58ed26b7ee5eac3a8b56a9611154ba23932.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE invites SET consumed = TRUE WHERE token = $1 AND consumed = FALSE", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "8ee895804b3dbc55ed08e7d66dfbf58ed26b7ee5eac3a8b56a9611154ba23932" +} diff --git a/crates/zopp-store-postgres/.sqlx/query-e312f4ed9d5bb23d52391b24f69dc7e1c9529343dab86c8feab39307ac45ff08.json b/crates/zopp-store-postgres/.sqlx/query-e312f4ed9d5bb23d52391b24f69dc7e1c9529343dab86c8feab39307ac45ff08.json deleted file mode 100644 index 241fbb03..00000000 --- a/crates/zopp-store-postgres/.sqlx/query-e312f4ed9d5bb23d52391b24f69dc7e1c9529343dab86c8feab39307ac45ff08.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE invites SET consumed = TRUE WHERE token = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "e312f4ed9d5bb23d52391b24f69dc7e1c9529343dab86c8feab39307ac45ff08" -} diff --git a/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql b/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql index e04602d5..d69ea96c 100644 --- a/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql +++ b/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql @@ -3,7 +3,10 @@ -- Principal is created only after successful verification -- Add verified flag to users table -ALTER TABLE users ADD COLUMN verified BOOLEAN NOT NULL DEFAULT FALSE; +-- 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; diff --git a/crates/zopp-store-postgres/src/lib.rs b/crates/zopp-store-postgres/src/lib.rs index 95ccad3c..b7600c55 100644 --- a/crates/zopp-store-postgres/src/lib.rs +++ b/crates/zopp-store-postgres/src/lib.rs @@ -64,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 @@ -120,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 @@ -428,13 +435,32 @@ impl Store for PostgresStore { } async fn consume_invite(&self, token: &str) -> Result<(), StoreError> { - let result = sqlx::query!("UPDATE invites SET consumed = TRUE WHERE token = $1", token) - .execute(&self.pool) + // Atomically consume invite only if not already consumed + // This prevents concurrent requests from both succeeding + let result = sqlx::query!( + "UPDATE invites SET consumed = TRUE WHERE token = $1 AND consumed = FALSE", + token + ) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; + + if result.rows_affected() == 0 { + // Either token doesn't exist or invite was already consumed + // Check which case it is for a more specific error + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM invites WHERE token = $1) as \"exists!: bool\"", + token + ) + .fetch_one(&self.pool) .await .map_err(|e| StoreError::Backend(e.to_string()))?; - if result.rows_affected() == 0 { - Err(StoreError::NotFound) + if exists { + Err(StoreError::AlreadyExists) // Invite was already consumed + } else { + Err(StoreError::NotFound) // Token doesn't exist + } } else { Ok(()) } diff --git a/crates/zopp-store-sqlite/.sqlx/query-22c3a135e3f231678a4865e17cd4dd46eebdf150ec1e4c666eaa0284a722a686.json b/crates/zopp-store-sqlite/.sqlx/query-22c3a135e3f231678a4865e17cd4dd46eebdf150ec1e4c666eaa0284a722a686.json new file mode 100644 index 00000000..4ede4b65 --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-22c3a135e3f231678a4865e17cd4dd46eebdf150ec1e4c666eaa0284a722a686.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE invites SET consumed = 1 WHERE token = ? AND consumed = 0", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "22c3a135e3f231678a4865e17cd4dd46eebdf150ec1e4c666eaa0284a722a686" +} 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-334ac514da587552fdd8c18840f4e16b20acabf9794dcd6dbe6ced0d786536f6.json b/crates/zopp-store-sqlite/.sqlx/query-334ac514da587552fdd8c18840f4e16b20acabf9794dcd6dbe6ced0d786536f6.json deleted file mode 100644 index d8b762c6..00000000 --- a/crates/zopp-store-sqlite/.sqlx/query-334ac514da587552fdd8c18840f4e16b20acabf9794dcd6dbe6ced0d786536f6.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE invites SET consumed = 1 WHERE token = ?", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "334ac514da587552fdd8c18840f4e16b20acabf9794dcd6dbe6ced0d786536f6" -} diff --git a/crates/zopp-store-sqlite/.sqlx/query-a8c0c502c39e88507def64c9cca7d56e039664dbf1cf4c08836c84990c93198d.json b/crates/zopp-store-sqlite/.sqlx/query-a8c0c502c39e88507def64c9cca7d56e039664dbf1cf4c08836c84990c93198d.json new file mode 100644 index 00000000..914ba3c3 --- /dev/null +++ b/crates/zopp-store-sqlite/.sqlx/query-a8c0c502c39e88507def64c9cca7d56e039664dbf1cf4c08836c84990c93198d.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT token FROM invites WHERE token = ?", + "describe": { + "columns": [ + { + "name": "token", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "a8c0c502c39e88507def64c9cca7d56e039664dbf1cf4c08836c84990c93198d" +} 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/migrations/20260125000001_add_email_verification.sql b/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql index cdf7b1b2..0e88aa81 100644 --- a/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql +++ b/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql @@ -3,7 +3,9 @@ -- Principal is created only after successful verification -- Add verified flag to users table -ALTER TABLE users ADD COLUMN verified INTEGER NOT NULL DEFAULT 0; +-- 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; diff --git a/crates/zopp-store-sqlite/src/lib.rs b/crates/zopp-store-sqlite/src/lib.rs index fcf16706..6caaaa76 100644 --- a/crates/zopp-store-sqlite/src/lib.rs +++ b/crates/zopp-store-sqlite/src/lib.rs @@ -90,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 @@ -161,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 @@ -572,13 +578,29 @@ impl Store for SqliteStore { } async fn consume_invite(&self, token: &str) -> Result<(), StoreError> { - let result = sqlx::query!("UPDATE invites SET consumed = 1 WHERE token = ?", token) - .execute(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; + // Atomically consume invite only if not already consumed + // This prevents concurrent requests from both succeeding + let result = sqlx::query!( + "UPDATE invites SET consumed = 1 WHERE token = ? AND consumed = 0", + token + ) + .execute(&self.pool) + .await + .map_err(|e| StoreError::Backend(e.to_string()))?; if result.rows_affected() == 0 { - Err(StoreError::NotFound) + // Either token doesn't exist or invite was already consumed + // Check which case it is for a more specific error + let exists = sqlx::query!("SELECT token FROM invites WHERE token = ?", 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 + } } else { Ok(()) } From 2c58773d1ce8897eac5bd15815aa4c63d5c1c12d Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 15:57:18 -0300 Subject: [PATCH 10/25] Ignore root-level .sqlx directory in gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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/ From dabc5374dfb140607698267a0c50ba18cbcd6a51 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 16:03:59 -0300 Subject: [PATCH 11/25] Fix mark_user_verified test to use unverified user flow The test was creating a user WITH a principal, but according to our implementation: - Users with principals are verified immediately (no email verification) - Users without principals are unverified (email verification required) Changed test to create user without principal to properly test the mark_user_verified flow used after email verification. --- crates/zopp-store-sqlite/src/lib.rs | 12 +- issue-46-email-verification.md | 451 ++ session-ses_423c.md | 10799 ++++++++++++++++++++++++++ 3 files changed, 11254 insertions(+), 8 deletions(-) create mode 100644 issue-46-email-verification.md create mode 100644 session-ses_423c.md diff --git a/crates/zopp-store-sqlite/src/lib.rs b/crates/zopp-store-sqlite/src/lib.rs index 6caaaa76..963dca01 100644 --- a/crates/zopp-store-sqlite/src/lib.rs +++ b/crates/zopp-store-sqlite/src/lib.rs @@ -4437,22 +4437,18 @@ mod tests { async fn mark_user_verified() { let s = SqliteStore::open_in_memory().await.unwrap(); - // Create user with principal + // 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: Some(CreatePrincipalData { - name: "laptop".to_string(), - public_key: vec![1, 2, 3, 4], - x25519_public_key: Some(vec![5, 6, 7, 8]), - is_service: false, - }), + principal: None, // No principal - email verification required workspace_ids: vec![], }) .await .unwrap(); - // New users should be unverified by default + // Users without principals should be unverified let user = s.get_user_by_id(&user_id).await.unwrap(); assert!(!user.verified); diff --git a/issue-46-email-verification.md b/issue-46-email-verification.md new file mode 100644 index 00000000..852f5d21 --- /dev/null +++ b/issue-46-email-verification.md @@ -0,0 +1,451 @@ +# Implementation Plan: Email Verification for New Principals (Issue #46) + +## Executive Summary + +Implement email verification using **6-digit codes** with support for **2 email providers** (Resend, SMTP). Email verification will be **enabled by default** but configurable by server admin. + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| **Verification default** | Enabled | Secure by default | +| **Email providers** | Resend + SMTP | Resend for simplicity, SMTP for universality | +| **Config validation** | Fail to start | Strict - prevents misconfiguration | +| **Rate limiting** | Basic | 5 attempts/code, 3 codes/hour per email | +| **Code format** | 6-digit | 1M combinations, CLI-friendly | +| **Code expiration** | 15 minutes | Balance security vs UX | + +--- + +## 1. Email Provider Libraries + +### Chosen Crates + +| Provider | Crate | Version | Notes | +|----------|-------|---------|-------| +| **Resend** | `resend-rs` | 0.20.0 | Official SDK, simple API, built-in rate limiting | +| **SMTP** | `lettre` | 0.11.19 | Universal fallback, works with any provider | + +### Dependencies to Add + +```toml +# In apps/zopp-server/Cargo.toml +[dependencies] +# Email providers (both optional) +resend-rs = { version = "0.20", optional = true } +lettre = { version = "0.11", features = ["tokio1-rustls", "smtp-transport"], optional = true } + +# Random number generation for verification codes +rand = "0.9" + +[features] +email-resend = ["resend-rs"] +email-smtp = ["lettre"] + +# Default: SMTP for maximum compatibility +default = ["email-smtp"] +``` + +--- + +## 2. Database Schema + +### New Table: `email_verifications` + +**SQLite:** +```sql +CREATE TABLE IF NOT EXISTS email_verifications ( + id TEXT PRIMARY KEY NOT NULL, -- UUID string + email TEXT NOT NULL, -- Email being verified (lowercased) + code TEXT NOT NULL, -- 6-digit verification code + 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 +); + +CREATE INDEX idx_email_verifications_email ON email_verifications(email); +CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at); +``` + +**PostgreSQL:** +```sql +CREATE TABLE IF NOT EXISTS email_verifications ( + id UUID PRIMARY KEY NOT NULL, + email TEXT NOT NULL, + code TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX idx_email_verifications_email ON email_verifications(email); +CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at); +``` + +### New Column: `principals.verified` + +```sql +-- SQLite +ALTER TABLE principals ADD COLUMN verified INTEGER NOT NULL DEFAULT 1; + +-- PostgreSQL +ALTER TABLE principals ADD COLUMN verified BOOLEAN NOT NULL DEFAULT TRUE; +``` + +--- + +## 3. Storage Trait Extensions + +```rust +/// Email verification record +pub struct EmailVerification { + pub id: EmailVerificationId, + pub email: String, + pub code: String, + pub attempts: i32, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +/// Parameters for creating an email verification +pub struct CreateEmailVerificationParams { + pub email: String, + pub code: String, + pub expires_at: DateTime, +} + +// New Store trait methods +async fn create_email_verification(&self, params: &CreateEmailVerificationParams) -> Result; +async fn get_email_verification(&self, email: &str) -> Result; +async fn increment_email_verification_attempts(&self, id: &EmailVerificationId) -> Result; +async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError>; +async fn cleanup_expired_email_verifications(&self) -> Result; +async fn count_recent_email_verifications(&self, email: &str) -> Result; +async fn mark_principal_verified(&self, principal_id: &PrincipalId) -> Result<(), StoreError>; +``` + +--- + +## 4. Server Configuration + +### Config Structure + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + pub email: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailConfig { + pub verification_required: bool, + pub provider: EmailProviderConfig, + pub from_address: String, + pub from_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum EmailProviderConfig { + Resend { api_key: String }, + Smtp { + host: String, + port: u16, + username: Option, + password: Option, + use_tls: bool, + }, +} +``` + +### 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 (works with Gmail, AWS SES SMTP, Sendgrid, etc.) +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" +``` + +--- + +## 5. Proto Changes + +```protobuf +// Modified JoinResponse +message JoinResponse { + string user_id = 1; + string principal_id = 2; + repeated Workspace workspaces = 3; + bool verification_required = 4; // NEW +} + +// New RPCs +message VerifyEmailRequest { + string email = 1; + string code = 2; + string principal_id = 3; +} + +message VerifyEmailResponse { + bool success = 1; + string message = 2; + int32 attempts_remaining = 3; +} + +message ResendVerificationRequest { + string email = 1; +} + +message ResendVerificationResponse { + bool success = 1; + string message = 2; +} + +service Zopp { + // ... existing RPCs ... + rpc VerifyEmail(VerifyEmailRequest) returns (VerifyEmailResponse); + rpc ResendVerification(ResendVerificationRequest) returns (ResendVerificationResponse); +} +``` + +--- + +## 6. Modified Join Flow + +``` +Before: +User → Join RPC → Create User+Principal → Save credentials → Done + +After (verification enabled): +User → Join RPC → Create User+Principal(unverified) → Send email → Return verification_required=true + → CLI prompts for code → VerifyEmail RPC → Mark principal verified → Save credentials → Done +``` + +--- + +## 7. Email Templates + +### Text Version +``` +Welcome to Zopp! + +Your verification code is: {CODE} + +This code will expire in 15 minutes. + +If you didn't request this code, please ignore this email. + +-- +Zopp Security Team +``` + +### HTML Version +```html + + + + + + +
+

Welcome to Zopp!

+

Your verification code is:

+
{CODE}
+

This code will expire in 15 minutes.

+
+ + +``` + +--- + +## 8. Security Considerations + +### Rate Limiting +- Max 5 verification attempts per code +- Max 3 verification codes per email per hour +- Automatic cleanup of expired verifications (background task) + +### Code Generation +- Cryptographically secure random 6-digit codes (000000-999999) +- Constant-time comparison to prevent timing attacks +- Codes expire after 15 minutes + +### Privacy +- Don't reveal if email exists in timing or error messages +- Constant-time operations for verification lookups +- Hash email addresses in logs + +--- + +## 9. Implementation Checklist + +### Phase 1: Database & Storage (16 tasks) +- [x] Create SQLite migration `20260121000001_add_email_verification.sql` +- [x] Create PostgreSQL migration `20260121000001_add_email_verification.sql` +- [x] Create SQLite migration `20260121000002_add_principal_verified.sql` +- [x] Create PostgreSQL migration `20260121000002_add_principal_verified.sql` +- [x] Add `EmailVerification` struct and methods to `zopp-storage` trait +- [x] Implement email verification methods in SQLite store +- [x] Implement email verification methods in PostgreSQL store +- [ ] Run `sqlx prepare` for both stores +- [ ] Unit tests: Storage `create_email_verification` +- [ ] Unit tests: Storage `get_email_verification` +- [ ] Unit tests: Storage `increment_verification_attempts` +- [ ] Unit tests: Storage `mark_principal_verified` +- [ ] Unit tests: Storage `delete_email_verification` +- [ ] Unit tests: Storage `cleanup_expired_verifications` +- [ ] Unit tests: Verification code expiration logic +- [ ] Unit tests: Attempt counting and limits + +### Phase 2: Configuration (7 tasks) +- [ ] Create `apps/zopp-server/src/config.rs` with `ServerConfig` +- [ ] Add config validation (fail if verification enabled but no provider) +- [ ] Add environment variable parsing +- [ ] Unit tests: Config environment variable parsing +- [ ] Unit tests: Config validation (missing provider) +- [ ] Unit tests: Config validation (invalid SMTP config) +- [ ] Unit tests: Config defaults and optional fields + +### Phase 3: Email Providers (15 tasks) +- [ ] Add Resend and SMTP dependencies to `Cargo.toml` with features +- [ ] Create `apps/zopp-server/src/email/mod.rs` with `EmailProvider` trait +- [ ] Create `apps/zopp-server/src/email/templates.rs` with email templates +- [ ] Implement Resend provider (`email/resend.rs`) +- [ ] Implement SMTP provider (`email/smtp.rs`) +- [ ] Add provider factory function with validation +- [ ] Create code generation utility (cryptographically secure 6-digit) +- [ ] Unit tests: Email templates text generation +- [ ] Unit tests: Email templates HTML generation +- [ ] Unit tests: Code generation format validation (6 digits) +- [ ] Unit tests: Code generation randomness (no duplicates in sample) +- [ ] Unit tests: Resend provider with mocked API +- [ ] Unit tests: SMTP provider with mocked transport +- [ ] Unit tests: Provider factory selection logic +- [ ] Unit tests: Provider factory error on invalid config + +### Phase 4: Proto Changes (4 tasks) +- [ ] Update `zopp.proto` with `VerifyEmailRequest/Response` +- [ ] Update `zopp.proto` with `ResendVerificationRequest/Response` +- [ ] Add `verification_required` field to `JoinResponse` +- [ ] Regenerate proto code (`cargo build`) + +### Phase 5: Server Handlers (14 tasks) +- [ ] Modify join handler to support verification flow +- [ ] Implement `verify_email` handler with rate limiting +- [ ] Implement `resend_verification` handler with rate limiting +- [ ] Add background cleanup task for expired verifications +- [ ] Add authentication bypass for `verify_email` RPC +- [ ] Unit tests: Rate limiting logic (5 attempts per code) +- [ ] Unit tests: Rate limiting (3 codes per hour per email) +- [ ] Unit tests: Verification code validation (constant-time comparison) +- [ ] Unit tests: Background cleanup task removes expired verifications +- [ ] Unit tests: Join handler with verification enabled +- [ ] Unit tests: Join handler with verification disabled +- [ ] Unit tests: VerifyEmail handler success case +- [ ] Unit tests: VerifyEmail handler wrong code +- [ ] Unit tests: ResendVerification handler + +### Phase 6: CLI Changes (3 tasks) +- [ ] Modify CLI `cmd_join` to handle verification flow +- [ ] Add code input prompt and retry logic +- [ ] Add resend option during verification + +### Phase 7: E2E Tests (8 tasks) +- [ ] E2E test: Join with verification enabled (happy path) +- [ ] E2E test: Join with verification disabled +- [ ] E2E test: Failed verification (wrong code) +- [ ] E2E test: Expired verification code +- [ ] E2E test: Too many attempts (rate limiting) +- [ ] E2E test: Resend verification code +- [ ] E2E test: Workspace invite with verification +- [ ] E2E test: Bootstrap invite (should bypass verification) + +### Phase 8: Documentation (4 tasks) +- [ ] Update `CLAUDE.md` with email configuration +- [ ] Create `docs/docs/guides/email-verification.md` +- [ ] Update `docs/docs/reference/cli/join.md` +- [ ] Add troubleshooting guide for email delivery issues + +### Phase 9: PR & Review (4 tasks) +- [ ] Run full test suite +- [ ] Run clippy and fmt +- [ ] Create PR and monitor CI +- [ ] Address Cubic review comments + +--- + +## 10. Backwards Compatibility + +- **Verification can be disabled**: `ZOPP_EMAIL_VERIFICATION_REQUIRED=false` +- **Existing principals automatically marked as verified** in migration +- **Bootstrap invites work without verification** (server setup unchanged) +- **No breaking changes to existing APIs** + +--- + +## 11. Files Created/Modified So Far + +### Created +- `crates/zopp-store-sqlite/migrations/20260121000001_add_email_verification.sql` +- `crates/zopp-store-sqlite/migrations/20260121000002_add_principal_verified.sql` +- `crates/zopp-store-postgres/migrations/20260121000001_add_email_verification.sql` +- `crates/zopp-store-postgres/migrations/20260121000002_add_principal_verified.sql` + +### Modified +- `crates/zopp-storage/src/lib.rs` - Added `EmailVerification`, `EmailVerificationId`, `CreateEmailVerificationParams`, and new trait methods +- `crates/zopp-store-sqlite/src/lib.rs` - Implemented email verification methods, updated `Principal` queries to include `verified` +- `crates/zopp-store-postgres/src/lib.rs` - Implemented email verification methods, updated `Principal` queries to include `verified` + +--- + +## 12. Next Steps + +1. **Run `sqlx prepare`** for PostgreSQL (requires Docker container) +2. **Create server configuration module** (`apps/zopp-server/src/config.rs`) +3. **Add email provider dependencies** to `Cargo.toml` +4. **Implement email provider trait and implementations** +5. **Update proto definitions** +6. **Implement server handlers** +7. **Update CLI join command** +8. **Write tests** +9. **Write documentation** +10. **Create PR** + +--- + +## 13. Related Issues + +- Issue #46: Feature: Implement email verification for new principals +- ListWorkspaces exposes workspaces without principal KEK access (mentioned as related in issue) + +--- + +## 14. Test Philosophy + +Per `TESTING.md`: +- **Use real implementations** - Tests use real SQLite/PostgreSQL, not mocks +- **Mock only for external services** - Mock Resend API and SMTP transport +- **100% coverage goal** - Every new function has unit tests +- **E2E for user-facing features** - Full join flow with verification +- **Security-critical code** - Constant-time comparison, rate limiting well-tested diff --git a/session-ses_423c.md b/session-ses_423c.md new file mode 100644 index 00000000..a7a48594 --- /dev/null +++ b/session-ses_423c.md @@ -0,0 +1,10799 @@ +# Issue 46 investigation + +**Session ID:** ses_423c1cfbcffe69pTjlXGcunWXi +**Created:** 1/20/2026, 1:30:21 PM +**Updated:** 1/20/2026, 4:58:36 PM + +--- + +## User + +tackle issue 46 + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 6.6s) + +_Thinking:_ + +The user wants me to tackle issue 46. I need to find out what issue 46 is first. Since this is a git repository, I should check if there's a GitHub issue #46 or if there are issues tracked elsewhere. + +I'm in plan mode, so I should: +1. Find and read issue 46 +2. Understand what needs to be done +3. Research the codebase to understand the context +4. Create a comprehensive plan +5. Present it to the user + +Let me start by checking if this is a GitHub repository and fetching issue 46. + +I'll help you tackle issue 46. Let me first fetch the details of this issue. + +``` +Tool: bash + +**Input:** +```json +{ + "command": "gh issue view 46 --json title,body,labels,state,author", + "description": "Fetch details of issue 46" +} +``` +**Output:** +``` +{"author":{"id":"MDQ6VXNlcjc3NjQyOTM=","is_bot":false,"login":"vieiralucas","name":"Lucas Vieira"},"body":"## Summary\n\nCurrently, when a user joins with an email address, there's no verification that they actually own that email. This allows anyone to claim any email address and potentially gain access to workspaces associated with that email's user account.\n\n## Current Behavior\n\n1. User A creates account with `victim@example.com` via CLI\n2. User A creates workspaces, gets invited to workspaces\n3. Attacker joins with `victim@example.com` via web UI (no verification)\n4. Attacker is now the same \"user\" and can see workspace metadata (see related issue about ListWorkspaces bug)\n\n## Expected Behavior\n\n1. When joining with an email, send a verification code/link to that email\n2. User must confirm ownership before the principal is activated\n3. Until verified, the principal should not be linked to any existing user account\n\n## Proposed Implementation\n\n### Option A: Email verification code\n1. User enters email during join\n2. Server sends 6-digit code to email\n3. User enters code to complete join\n4. Principal is created and linked to user\n\n### Option B: Magic link\n1. User enters email during join\n2. Server sends magic link to email\n3. Clicking link completes the join process\n\n### Option C: OAuth/SSO\n- Support \"Login with Google/GitHub/etc\" for verified email ownership\n\n## Security Considerations\n\n- Rate limit verification attempts\n- Expire verification codes/links after short period (15 min)\n- Don't reveal whether email already exists (timing attacks)\n- Consider requiring re-verification for sensitive operations\n\n## Related Issues\n\n- ListWorkspaces exposes workspaces without principal KEK access (the combination of both bugs is worse than either alone)","labels":[{"id":"LA_kwDOQJJric8AAAACN8wZyQ","name":"enhancement","description":"New feature or request","color":"a2eeef"},{"id":"LA_kwDOQJJric8AAAACVXLUhA","name":"security","description":"","color":"ededed"}],"state":"OPEN","title":"Feature: Implement email verification for new principals"} + +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 97.3s) + +_Thinking:_ + +Alright, so issue 46 is about implementing email verification for new principals. This is a security issue because currently anyone can claim any email address without verification. + +The issue proposes three options: +- Option A: Email verification code (6-digit) +- Option B: Magic link +- Option C: OAuth/SSO + +Let me understand the current codebase structure to plan this properly. I need to: +1. Understand how the join flow currently works +2. Understand the server and CLI architecture +3. Decide which option to implement (or ask the user) +4. Plan the implementation + +Let me start by exploring the current join implementation. + +I can see this is a security enhancement to add email verification for new principals. Currently, anyone can claim any email address without proof of ownership, which is a significant security issue. + +Let me explore the current join flow to understand the implementation better: + +``` +Tool: task + +**Input:** +```json +{ + "subagent_type": "explore", + "description": "Explore join flow implementation", + "prompt": "Find and analyze the current \"join\" flow implementation. I need to understand:\n\n1. How the CLI `zopp join` command works (find the command implementation)\n2. How the server's Join RPC is implemented\n3. What the Join request/response proto messages contain\n4. How users are created and principals are linked to users\n5. Any existing email handling code\n\nPlease provide:\n- File paths and line numbers for key functions\n- Current flow diagram (step by step)\n- Any existing email infrastructure (SMTP, email sending, etc.)\n\nSearch thoroughly - check apps/zopp-cli, apps/zopp-server, crates/zopp-proto for join-related code." +} +``` +**Output:** +``` +Perfect! Now I have a comprehensive understanding. Let me create a detailed analysis document: + +## Analysis: Join Flow Implementation + +Based on my thorough search of the codebase, here's a complete analysis of the current join flow implementation: + +--- + +### 1. CLI `zopp join` Command Implementation + +**File:** `/Users/lucas/code/faisca/zopp/apps/zopp-cli/src/commands/join.rs` + +**Key Function:** `cmd_join` (lines 6-181) + +**How it works:** +1. **Principal name**: Uses provided name or defaults to hostname (lines 15-18) +2. **Key generation**: Generates Ed25519 signing keypair and X25519 encryption keypair (lines 20-25) +3. **Connect to server**: Establishes gRPC connection (line 27) +4. **Invite processing** (lines 29-49): + - Checks if invite has `inv_` prefix (workspace invite with KEK) + - For workspace invites: decodes secret, computes SHA256 hash for server lookup + - For bootstrap invites: uses token directly (legacy) +5. **Fetch invite metadata**: Calls `GetInvite` RPC to retrieve invite details (lines 52-57) +6. **KEK handling** (lines 60-96): + - **Workspace invites** (KEK present): + - Decrypt KEK using invite secret as DEK + - Re-wrap KEK for the new principal using ECDH + - **Bootstrap invites** (no KEK): Pass empty KEK fields +7. **Join RPC call** (lines 98-110): Sends JoinRequest with email, principal name, keys, and wrapped KEK +8. **Save credentials** (lines 122-178): + - Stores Ed25519 and X25519 private keys either in system keychain or config file + - Saves config to `~/.zopp/config.json` + +**CLI definition:** `/Users/lucas/code/faisca/zopp/apps/zopp-cli/src/cli.rs` (lines 27-38) + +--- + +### 2. Server Join RPC Handler + +**File:** `/Users/lucas/code/faisca/zopp/apps/zopp-server/src/handlers/auth.rs` + +**Key Function:** `join` (lines 15-149) + +**Step-by-step flow:** + +1. **Email normalization** (line 22): Converts email to lowercase for consistent comparison + +2. **Invite validation** (lines 24-37): + - Fetches invite by token (hash lookup) + - Checks if invite is expired + +3. **User creation** (lines 40-109): + - **Attempts to create new user** with principal and workspace memberships (lines 40-56) + - **If user already exists** (`AlreadyExists` error) AND invite has workspaces (lines 60-107): + - This is a workspace invite for an existing user + - Fetch existing user by email (lines 62-66) + - Create NEW principal for existing user (lines 69-87) + - Add user to workspace memberships (lines 90-104) + - **Other errors**: Return internal error (line 108) + +4. **Store wrapped KEK** (lines 111-128): + - For workspace invites with KEK data, store the wrapped KEK for the principal + - Calls `add_workspace_principal` to link principal to workspace with encrypted KEK + +5. **Return response** (lines 130-148): + - Builds list of workspaces from invite + - Returns user_id, principal_id, and workspace list + +**Routing:** `/Users/lucas/code/faisca/zopp/apps/zopp-server/src/handlers/mod.rs` (line 41-42) + +--- + +### 3. Proto Messages + +**File:** `/Users/lucas/code/faisca/zopp/crates/zopp-proto/proto/zopp.proto` + +**JoinRequest** (lines 129-138): +```protobuf +message JoinRequest { + string invite_token = 1; // SHA256 hash of invite secret (for lookup) + string email = 2; // User's email + string principal_name = 3; // Device/principal name + bytes public_key = 4; // Ed25519 for authentication + bytes x25519_public_key = 5; // X25519 for encryption (ECDH) + 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 +} +``` + +**JoinResponse** (lines 140-144): +```protobuf +message JoinResponse { + string user_id = 1; + string principal_id = 2; + repeated Workspace workspaces = 3; +} +``` + +--- + +### 4. User and Principal Creation + +**Storage Layer:** `/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs` + +**CreateUserParams** (lines 224-230): +```rust +pub struct CreateUserParams { + pub email: String, + pub principal: Option, // Atomically create principal + pub workspace_ids: Vec, // User-level workspace memberships +} +``` + +**CreatePrincipalData** (lines 233-239): +```rust +pub struct CreatePrincipalData { + pub name: String, + pub public_key: Vec, // Ed25519 for authentication + pub x25519_public_key: Option>, // X25519 for encryption (ECDH) + pub is_service: bool, // Service principal flag +} +``` + +**User record** (lines 291-296): +```rust +pub struct User { + pub id: UserId, + pub email: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} +``` + +**Storage implementations:** +- SQLite: `/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs` (lines 65-177) +- PostgreSQL: `/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs` (lines 44-141) + +**Key behavior:** +- `create_user` checks if email already exists (returns `AlreadyExists` error if so) +- Atomically creates user + principal in a transaction when principal data is provided +- Links user to workspaces via workspace_ids + +--- + +### 5. Email Handling + +**Current state: NO email sending infrastructure exists** + +Findings from comprehensive search: +- ✅ Email is stored in the `users` table as a unique identifier +- ✅ Email is used for user lookup (`get_user_by_email`) +- ✅ Email is normalized to lowercase for consistency +- ❌ NO SMTP configuration +- ❌ NO email sending libraries (no `lettre`, `sendgrid`, etc.) +- ❌ NO email templates +- ❌ NO email queue/job system + +The email field is currently used purely for: +1. User identification and lookup +2. Group membership (AddGroupMember uses email) +3. User permissions (SetUserWorkspacePermission uses email) + +--- + +### 6. Current Join Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CLI: zopp join │ +│ │ +│ 1. Generate Ed25519 + X25519 keypairs │ +│ 2. Parse invite code (inv_XXXXX or legacy) │ +│ 3. Call GetInvite RPC (unauthenticated) │ +│ 4. If workspace invite: │ +│ - Decrypt KEK using invite secret │ +│ - Re-wrap KEK for new principal (ECDH) │ +│ 5. Call Join RPC with: │ +│ - email │ +│ - principal_name │ +│ - public keys (Ed25519 + X25519) │ +│ - wrapped KEK (if workspace invite) │ +└──────────────┬──────────────────────────────────────────────────┘ + │ + ▼ gRPC Join Request +┌─────────────────────────────────────────────────────────────────┐ +│ Server: Join Handler │ +│ │ +│ 1. Normalize email to lowercase │ +│ 2. Validate invite: │ +│ - Fetch by token (SHA256 hash lookup) │ +│ - Check not expired │ +│ 3. Attempt to create user: │ +│ ┌───────────────────────────────────────────┐ │ +│ │ Case A: User does NOT exist │ │ +│ │ - Create user + principal atomically │ │ +│ │ - Link to workspaces from invite │ │ +│ │ - Store wrapped KEK (if workspace invite)│ │ +│ └───────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────┐ │ +│ │ Case B: User exists + workspace invite │ │ +│ │ - Fetch existing user by email │ │ +│ │ - Create NEW principal for existing user │ │ +│ │ - Add user to workspaces (if not member) │ │ +│ │ - Store wrapped KEK for new principal │ │ +│ └───────────────────────────────────────────┘ │ +│ 4. Return JoinResponse: │ +│ - user_id │ +│ - principal_id │ +│ - workspaces (from invite) │ +└──────────────┬──────────────────────────────────────────────────┘ + │ + ▼ JoinResponse +┌─────────────────────────────────────────────────────────────────┐ +│ CLI: Save Credentials │ +│ │ +│ 1. Store private keys (keychain or file) │ +│ 2. Save config to ~/.zopp/config.json │ +│ 3. Display success message │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +### 7. Invite Types + +Based on the code, there are **two types of invites**: + +**A. Bootstrap Invite** (Server Setup) +- `workspace_ids: []` (empty) +- `kek_encrypted: None` +- `created_by_user_id: None` (server-created) +- Used for initial server setup +- No KEK to process +- See test: `/Users/lucas/code/faisca/zopp/apps/zopp-server/src/tests.rs` (line 1078) + +**B. Workspace Invite** +- `workspace_ids: [workspace_id]` +- `kek_encrypted: Some(encrypted_kek)` +- `created_by_user_id: Some(user_id)` (created by admin) +- Grants access to existing workspace +- KEK must be decrypted and re-wrapped + +--- + +### 8. Key Security Properties + +1. **Zero-knowledge**: Server never sees plaintext invite secret (only SHA256 hash) +2. **Client-side crypto**: All KEK unwrapping/wrapping happens in CLI +3. **Email as identifier**: Email is the user identity, not a communication channel +4. **Principal isolation**: Each device/credential gets its own principal +5. **Workspace access control**: Join handler checks RBAC permissions for invite creation + +--- + +### Summary + +The current join flow is **fully functional** for its intended purpose: +- ✅ Supports both bootstrap invites (new server) and workspace invites (existing server) +- ✅ Handles new users and existing users (multi-device support) +- ✅ Zero-knowledge encryption (server never sees secrets) +- ✅ Email-based user identification + +**What's missing:** +- ❌ NO email verification +- ❌ NO email notifications +- ❌ NO SMTP infrastructure +- ❌ Email is purely an identifier, not a communication method + +If you want to add email-based invites with verification links, you'll need to: +1. Add SMTP configuration to server +2. Create email templates +3. Add email sending on invite creation +4. Implement verification token flow +5. Modify join flow to verify email ownership + + +session_id: ses_423c18ceeffegIp75mowGxUWFw + +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 140.1s) + +_Thinking:_ + +Great! Now I have a comprehensive understanding of the current join flow. Let me think about the implementation options: + +1. **Option A: Email verification code (6-digit)** + - Pros: Simple UX, works well for CLI + - Cons: Requires storing verification state, timing attacks possible + +2. **Option B: Magic link** + - Pros: Better UX for web, more secure (token-based) + - Cons: CLI-first tool - users would need to switch from CLI to browser + +3. **Option C: OAuth/SSO** + - Pros: Industry standard, delegated trust + - Cons: Major implementation effort, requires OAuth providers + +Given that zopp is a **CLI-first** tool, Option A (verification code) seems most appropriate. Option B (magic link) would require users to open a browser in the middle of a CLI flow, which breaks the experience. + +Now let me think about what needs to be implemented: + +1. **Email infrastructure** + - SMTP configuration (server side) + - Email sending library (likely `lettre` for Rust) + - Email templates + - Configuration for SMTP server, credentials, etc. + +2. **Verification flow** + - New database table: `email_verifications` (email, code, expires_at, attempts) + - Generate 6-digit code + - Send email with code + - Verify code submission + - Rate limiting + +3. **Modified join flow** + - Join request triggers verification email + - Create "pending principal" state + - User submits verification code + - Complete join after verification + +4. **Proto changes** + - New RPC: `VerifyEmail` or similar + - Modified `Join` flow to support verification + +5. **CLI changes** + - After initial join, prompt for verification code + - Submit code to server + - Complete join process + +6. **Security considerations** + - Rate limit verification attempts + - Expire codes after 15 minutes + - Avoid leaking whether an email exists through timing differences + - Use cryptographically secure random generation for codes + +I should check with the user about their preferences before moving forward with implementation. + +Perfect! Now I have a comprehensive understanding of the current join flow. Let me present the situation and ask you some key questions before creating the implementation plan. + +## Current State + +The join flow is fully functional for its intended purpose (user + principal creation), but has **NO email infrastructure**: +- ✅ Email is used as a unique identifier for users +- ❌ No SMTP configuration or email sending capability +- ❌ No verification that users own the email addresses they claim + +## Implementation Options + +Given that zopp is a **CLI-first** tool, I'd like your input on the approach: + +``` +Tool: question + +**Input:** +```json +{ + "questions": [ + { + "question": "Which email verification approach should we implement?", + "header": "Verification Approach", + "options": [ + { + "label": "6-digit code (Recommended)", + "description": "User receives code via email, enters it in CLI. Best for CLI-first workflow. Simple implementation." + }, + { + "label": "Magic link", + "description": "User clicks link in email to verify. Requires opening browser during CLI flow. Better for web UI in future." + }, + { + "label": "Both (code + link)", + "description": "Support both methods. More flexible but more complex. User chooses their preferred method." + } + ] + }, + { + "question": "Should verification be required for all joins, or only certain scenarios?", + "header": "Verification Scope", + "options": [ + { + "label": "Always required (Recommended)", + "description": "Every new principal must verify email ownership. Most secure, prevents email hijacking completely." + }, + { + "label": "Required for new users only", + "description": "Only first principal per email needs verification. Additional devices trusted. Less friction for multi-device." + }, + { + "label": "Configurable by server admin", + "description": "Server admin can enable/disable verification. Useful for testing or private deployments." + } + ] + }, + { + "question": "How should we handle SMTP configuration?", + "header": "SMTP Configuration", + "options": [ + { + "label": "Environment variables", + "description": "SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, etc. Standard approach for production." + }, + { + "label": "CLI flags + env vars", + "description": "Allow both --smtp-host flags and environment variables. More flexible." + }, + { + "label": "Config file + env vars", + "description": "Add SMTP section to server config file (TOML/YAML). Environment variables override. Most production-ready." + } + ] + }, + { + "question": "Should we add optional 3rd-party email providers (Sendgrid, Postmark, etc.)?", + "header": "Email Providers", + "options": [ + { + "label": "SMTP only (Recommended)", + "description": "Keep it simple, support standard SMTP. Works with any email service. Less dependencies." + }, + { + "label": "Add Sendgrid/Postmark APIs", + "description": "Support popular email APIs for better reliability and deliverability. More dependencies." + } + ] + } + ] +} +``` +**Output:** +``` +User has answered your questions: "Which email verification approach should we implement?"="6-digit code (Recommended)", "Should verification be required for all joins, or only certain scenarios?"="Configurable by server admin", "How should we handle SMTP configuration?"="so i was actually thinking of providing 3 options. resend, ses and smtp", "Should we add optional 3rd-party email providers (Sendgrid, Postmark, etc.)?"="resend, ses and smtp". You can now continue with the user's answers in mind. +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 11.1s) + +_Thinking:_ + +Great! The user wants: +1. 6-digit code verification (best for CLI) +2. Configurable by server admin (can enable/disable) +3. Support for 3 email providers: Resend, AWS SES, and SMTP + +This is a good approach - gives flexibility for different deployment scenarios: +- **Resend**: Popular modern API, simple, developer-friendly +- **AWS SES**: Enterprise-grade, cost-effective, already integrated for many orgs +- **SMTP**: Universal fallback, works with any email service + +Let me create a comprehensive implementation plan. + +Excellent! That's a great approach - supporting **Resend, AWS SES, and SMTP** gives maximum flexibility for different deployment scenarios. Let me create a comprehensive implementation plan. + +``` +Tool: todowrite + +**Input:** +```json +{ + "todos": [ + { + "id": "1", + "content": "Research and document email provider APIs (Resend, SES, SMTP)", + "status": "pending", + "priority": "high" + }, + { + "id": "2", + "content": "Design database schema for email verification", + "status": "pending", + "priority": "high" + }, + { + "id": "3", + "content": "Design proto changes for verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "4", + "content": "Plan server configuration structure", + "status": "pending", + "priority": "high" + }, + { + "id": "5", + "content": "Create detailed implementation plan", + "status": "pending", + "priority": "high" + } + ] +} +``` +**Output:** +``` +[ + { + "id": "1", + "content": "Research and document email provider APIs (Resend, SES, SMTP)", + "status": "pending", + "priority": "high" + }, + { + "id": "2", + "content": "Design database schema for email verification", + "status": "pending", + "priority": "high" + }, + { + "id": "3", + "content": "Design proto changes for verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "4", + "content": "Plan server configuration structure", + "status": "pending", + "priority": "high" + }, + { + "id": "5", + "content": "Create detailed implementation plan", + "status": "pending", + "priority": "high" + } +] +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 3.6s) + +_Thinking:_ + +Let me start researching the email provider APIs to understand what we need. + +``` +Tool: todowrite + +**Input:** +```json +{ + "todos": [ + { + "id": "1", + "content": "Research and document email provider APIs (Resend, SES, SMTP)", + "status": "in_progress", + "priority": "high" + }, + { + "id": "2", + "content": "Design database schema for email verification", + "status": "pending", + "priority": "high" + }, + { + "id": "3", + "content": "Design proto changes for verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "4", + "content": "Plan server configuration structure", + "status": "pending", + "priority": "high" + }, + { + "id": "5", + "content": "Create detailed implementation plan", + "status": "pending", + "priority": "high" + } + ] +} +``` +**Output:** +``` +[ + { + "id": "1", + "content": "Research and document email provider APIs (Resend, SES, SMTP)", + "status": "in_progress", + "priority": "high" + }, + { + "id": "2", + "content": "Design database schema for email verification", + "status": "pending", + "priority": "high" + }, + { + "id": "3", + "content": "Design proto changes for verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "4", + "content": "Plan server configuration structure", + "status": "pending", + "priority": "high" + }, + { + "id": "5", + "content": "Create detailed implementation plan", + "status": "pending", + "priority": "high" + } +] +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 8.3s) + +_Thinking:_ + +Let me research each email provider's Rust library and API: + +1. **Resend**: I should check if there's a Rust client for Resend +2. **AWS SES**: AWS has Rust SDKs, I should check the SES one +3. **SMTP**: `lettre` is the standard Rust SMTP library + +Let me fetch information about each. + +``` +Tool: webfetch + +**Input:** +```json +{ + "url": "https://docs.rs/resend-rs/latest/resend_rs/", + "format": "markdown" +} +``` +**Output:** +``` +resend\_rs - Rust + + + +[Docs.rs](/) + +- [resend-rs-0.20.0](# "Resend's Official Rust SDK.") + + - resend-rs 0.20.0 + - [Permalink](/resend-rs/0.20.0/resend_rs/ "Get a link to this specific version") + - [Docs.rs crate page](/crate/resend-rs/latest "See resend-rs in docs.rs") + - [MIT](https://spdx.org/licenses/MIT) + + - Links + - [Homepage](https://resend.com/docs/send-with-rust) + - [Repository](https://github.com/resend/resend-rust) + - [crates.io](https://crates.io/crates/resend-rs "See resend-rs in crates.io") + - [Source](/crate/resend-rs/latest/source/ "Browse source of resend-rs-0.20.0") + + - Owners + - [AntoniosBarotsis](https://crates.io/users/AntoniosBarotsis) + + - Dependencies + - - [ecow ^0.2.4 *normal*](/ecow/^0.2.4/) + - [getrandom ^0.3.4 *normal*](/getrandom/^0.3.4/) + - [governor ^0.10.0 *normal*](/governor/^0.10.0/) + - [maybe-async ^0.2.10 *normal*](/maybe-async/^0.2.10/) + - [rand ^0.9.2 *normal*](/rand/^0.9.2/) + - [reqwest ^0.13 *normal*](/reqwest/^0.13/) + - [serde ^1.0 *normal*](/serde/^1.0/) + - [serde\_json ^1.0.145 *normal*](/serde_json/^1.0.145/) + - [thiserror ^2.0 *normal*](/thiserror/^2.0/) + - [anyhow ^1.0 *dev*](/anyhow/^1.0/) + - [axum ^0.8.6 *dev*](/axum/^0.8.6/) + - [http-body-util ^0.1.3 *dev*](/http-body-util/^0.1.3/) + - [jiff ^0.2.13 *dev*](/jiff/^0.2.13/) + - [regex ^1.12.2 *dev*](/regex/^1.12.2/) + - [scraper ^0.25.0 *dev*](/scraper/^0.25.0/) + - [strum ^0.27 *dev*](/strum/^0.27/) + - [svix ^1.77.0 *dev*](/svix/^1.77.0/) + - [tokio ^1.45.0 *dev*](/tokio/^1.45.0/) + - [tokio-shared-rt ^0.1.0 *dev*](/tokio-shared-rt/^0.1.0/) + + + - Versions + + - [**51.46%** of the crate is documented](/crate/resend-rs/latest) + +- [Platform](#) + - [aarch64-apple-darwin](/crate/resend-rs/latest/target-redirect/aarch64-apple-darwin/resend_rs/) + - [i686-pc-windows-msvc](/crate/resend-rs/latest/target-redirect/i686-pc-windows-msvc/resend_rs/) + - [x86\_64-pc-windows-msvc](/crate/resend-rs/latest/target-redirect/x86_64-pc-windows-msvc/resend_rs/) + - [x86\_64-unknown-linux-gnu](/crate/resend-rs/latest/target-redirect/resend_rs/) +- [Feature flags](/crate/resend-rs/latest/features "Browse available feature flags of resend-rs-0.20.0") + +- [docs.rs](#) + - [About docs.rs](/about) + - [Badges](/about/badges) + - [Builds](/about/builds) + - [Metadata](/about/metadata) + - [Shorthand URLs](/about/redirections) + - [Download](/about/download) + - [Rustdoc JSON](/about/rustdoc-json) + - [Build queue](/releases/queue) + - [Privacy policy](https://foundation.rust-lang.org/policies/privacy-policy/#docs.rs) + +- [Rust](#) + - [Rust website](https://www.rust-lang.org/) + - [The Book](https://doc.rust-lang.org/book/) + - [Standard Library API Reference](https://doc.rust-lang.org/std/) + - [Rust by Example](https://doc.rust-lang.org/rust-by-example/) + - [The Cargo Guide](https://doc.rust-lang.org/cargo/guide/) + - [Clippy Documentation](https://doc.rust-lang.org/nightly/clippy) + +## [Crate resend\_rs](#) + +## [resend\_rs](../resend_rs/index.html)0.20.0 + +- [All Items](all.html) + +### [Sections](#) + +- [resend-rs](#resend-rs "resend-rs") + - [Documentation](#documentation "Documentation") + - [Features](#features "Features") + - [Variables](#variables "Variables") + - [Rate Limits](#rate-limits "Rate Limits") + - [Examples](#examples "Examples") + +### [Crate Items](#modules) + +- [Modules](#modules "Modules") +- [Macros](#macros "Macros") +- [Structs](#structs "Structs") +- [Enums](#enums "Enums") +- [Type Aliases](#types "Type Aliases") + +# Crate resend\_rs Copy item path + +[Source](../src/resend_rs/lib.rs.html#1-288) + +Expand description + +### [§](#resend-rs)resend-rs + +[![Build Status](https://img.shields.io/github/actions/workflow/status/resend/resend-rust/ci.yml)](https://github.com/resend/resend-rust/actions/workflows/ci.yml) [![Crate Docs](https://img.shields.io/docsrs/resend-rs)](https://docs.rs/resend-rs) [![Crate Version](https://img.shields.io/crates/v/resend-rs)](https://crates.io/crates/resend-rs) + +A minimal [Resend](https://resend.com) client. + +Add with: + +``` +cargo add resend-rs +cargo add tokio -F macros,rt-multi-thread +``` + +Emails are sent via the `Resend` client which provides both a synchronous and asynchronous send method. The two are mutually exclusive and accessible via the `blocking` feature. The crate uses [reqwest](https://github.com/seanmonstar/reqwest) and [serde](https://github.com/serde-rs/serde) internally. + +#### [§](#documentation)Documentation + +Crate documentation is available in [docsrs](https://docs.rs/resend-rs). Example usage is available in the [get started guide](https://resend.com/docs/send-with-rust) on the Resend website, you can also find examples in the [API reference](https://resend.com/docs/api-reference/). + +#### [§](#features)Features + +- `blocking` to enable the blocking client. +- `native-tls` to use system-native TLS. **Enabled by default**. +- `rustls-tls` to use TLS backed by `rustls`. +- WASM support (see [example](https://github.com/resend/resend-rust/tree/main/examples/cf-worker)) + +#### [§](#variables)Variables + +- `RESEND_API_KEY` to enable `impl Default` for a `Resend` client (Required). +- `RESEND_BASE_URL` to override the default base address: `https://api.resend.com` (Optional). +- `RESEND_RATE_LIMIT` to set the maximum amount of requests you can send per second. By default, this is 9 (Resend defaults to 10). See [the docs](https://docs.rs/resend-rs/latest/resend_rs/#rate-limits) for more information. + +> WARNING: Rate limiting only works when using the async version (default) of the crate + +#### [§](#rate-limits)Rate Limits + +Resend implements rate limiting on their API which can sometimes get in the way of whatever you are trying to do. This crate handles that in 2 ways: + +- Firstly *all* requests made by the [`Resend`](struct.Resend.html "struct resend_rs::Resend") client are automatically rate limited to 9 req/1.1s to avoid collisions with the 10 req/s limit that Resend imposes at the time of writing this. Note that this can be changed by changing the `RESEND_RATE_LIMIT` environment variable (by default it is set to `9`). + + Note that the client can be safely cloned as well as used in async/parallel contexts and the rate limit will work as intended. The only exception to this is creating 2 clients via the [`Resend::new`](struct.Resend.html#method.new "associated function resend_rs::Resend::new") or [`Resend::with_client`](struct.Resend.html#method.with_client "associated function resend_rs::Resend::with_client") methods which should be avoided, use `.clone()` instead. + +- Secondly, a couple of helper methods as well as macros are implemented in the [`rate_limit`](rate_limit/index.html "mod resend_rs::rate_limit") module that allow catching rate limit errors and retrying the request instead of failing. + + These were implemented to handle cases where this crate is used in a horizontally scaled environment and thus needs to work on different machines at the same time in which case the internal rate limits alone cannot guarantee that there will be no rate limit errors. + + As long as only one program is interacting with the Resend servers on your behalf, this module does not need to be used. + + +#### [§](#examples)Examples + +``` +use resend_rs::types::{CreateEmailBaseOptions, Tag}; +use resend_rs::{Resend, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + let resend = Resend::default(); + + let from = "Acme "; + let to = ["delivered@resend.dev"]; + let subject = "Hello World!"; + + let email = CreateEmailBaseOptions::new(from, to, subject) + .with_text("Hello World!") + .with_tag(Tag::new("hello", "world")); + + let id = resend.emails.send(email).await?.id; + println!("id: {id}"); + Ok(()) +} +``` + +## Modules[§](#modules) + +[events](events/index.html "mod resend_rs::events") + +Parsing for Resend’s Events. + +[idempotent](idempotent/index.html "mod resend_rs::idempotent") + +A few helpers for adding idempotency keys to requests that support them. + +[list\_opts](list_opts/index.html "mod resend_rs::list_opts") + +[rate\_limit](rate_limit/index.html "mod resend_rs::rate_limit") + +Helper methods for retrying requests in case of a rate limit error. + +[services](services/index.html "mod resend_rs::services") + +`Resend` API services. + +[types](types/index.html "mod resend_rs::types") + +Request and response types. + +## Macros[§](#macros) + +[json](macro.json.html "macro resend_rs::json") + +Construct a `serde_json::Value` from a JSON literal. + +[retry](macro.retry.html "macro resend_rs::retry") + +Equivalent to [`send_with_retry`](rate_limit/fn.send_with_retry.html "fn resend_rs::rate_limit::send_with_retry"). + +[retry\_opts](macro.retry_opts.html "macro resend_rs::retry_opts") + +Equivalent to [`send_with_retry_opts`](rate_limit/fn.send_with_retry_opts.html "fn resend_rs::rate_limit::send_with_retry_opts"). + +## Structs[§](#structs) + +[Config](struct.Config.html "struct resend_rs::Config") + +Configuration for `Resend` client. + +[ConfigBuilder](struct.ConfigBuilder.html "struct resend_rs::ConfigBuilder") + +Convenience builder for [`Config`](struct.Config.html "struct resend_rs::Config"). + +[Resend](struct.Resend.html "struct resend_rs::Resend") + +The [Resend](https://resend.com) client. + +## Enums[§](#enums) + +[Error](enum.Error.html "enum resend_rs::Error") + +Error type for operations of a [`Resend`](struct.Resend.html "struct resend_rs::Resend") client. + +[Value](enum.Value.html "enum resend_rs::Value") + +Represents any valid JSON value. + +## Type Aliases[§](#types) + +[Result](type.Result.html "type resend_rs::Result") + +Specialized [`Result`](https://doc.rust-lang.org/nightly/core/result/enum.Result.html "enum core::result::Result") type for an [`Error`](enum.Error.html "enum resend_rs::Error"). +``` +``` + +``` +Tool: webfetch + +**Input:** +```json +{ + "url": "https://docs.rs/aws-sdk-sesv2/latest/aws_sdk_sesv2/", + "format": "markdown" +} +``` +**Output:** +``` +aws\_sdk\_sesv2 - Rust + + + +[Docs.rs](/) + +- [aws-sdk-sesv2-1.111.0](# "AWS SDK for Amazon Simple Email Service") + + - aws-sdk-sesv2 1.111.0 + - [Permalink](/aws-sdk-sesv2/1.111.0/aws_sdk_sesv2/ "Get a link to this specific version") + - [Docs.rs crate page](/crate/aws-sdk-sesv2/latest "See aws-sdk-sesv2 in docs.rs") + - [Apache-2.0](https://spdx.org/licenses/Apache-2.0) + + - Links + - [Repository](https://github.com/awslabs/aws-sdk-rust) + - [crates.io](https://crates.io/crates/aws-sdk-sesv2 "See aws-sdk-sesv2 in crates.io") + - [Source](/crate/aws-sdk-sesv2/latest/source/ "Browse source of aws-sdk-sesv2-1.111.0") + + - Owners + - [github:awslabs:rust-sdk-owners](https://crates.io/teams/github:awslabs:rust-sdk-owners) + - [aws-sdk-rust-ci](https://crates.io/users/aws-sdk-rust-ci) + + - Dependencies + - - [aws-credential-types ^1.2.11 *normal*](/aws-credential-types/^1.2.11/) + - [aws-runtime ^1.5.18 *normal*](/aws-runtime/^1.5.18/) + - [aws-smithy-async ^1.2.7 *normal*](/aws-smithy-async/^1.2.7/) + - [aws-smithy-http ^0.62.6 *normal*](/aws-smithy-http/^0.62.6/) + - [aws-smithy-json ^0.61.9 *normal*](/aws-smithy-json/^0.61.9/) + - [aws-smithy-observability ^0.2.0 *normal*](/aws-smithy-observability/^0.2.0/) + - [aws-smithy-runtime ^1.9.8 *normal*](/aws-smithy-runtime/^1.9.8/) + - [aws-smithy-runtime-api ^1.10.0 *normal*](/aws-smithy-runtime-api/^1.10.0/) + - [aws-smithy-types ^1.3.6 *normal*](/aws-smithy-types/^1.3.6/) + - [aws-types ^1.3.11 *normal*](/aws-types/^1.3.11/) + - [bytes ^1.4.0 *normal*](/bytes/^1.4.0/) + - [fastrand ^2.0.0 *normal*](/fastrand/^2.0.0/) + - [http ^0.2.9 *normal*](/http/^0.2.9/) + - [regex-lite ^0.1.5 *normal*](/regex-lite/^0.1.5/) + - [tracing ^0.1 *normal*](/tracing/^0.1/) + - [aws-config ^1.8.12 *dev*](/aws-config/^1.8.12/) + - [aws-credential-types ^1.2.11 *dev*](/aws-credential-types/^1.2.11/) + - [proptest ^1 *dev*](/proptest/^1/) + - [tokio ^1.23.1 *dev*](/tokio/^1.23.1/) + + + - Versions + + - [**100%** of the crate is documented](/crate/aws-sdk-sesv2/latest) + +- [Platform](#) + - [x86\_64-unknown-linux-gnu](/crate/aws-sdk-sesv2/latest/target-redirect/aws_sdk_sesv2/) +- [Feature flags](/crate/aws-sdk-sesv2/latest/features "Browse available feature flags of aws-sdk-sesv2-1.111.0") + +- [docs.rs](#) + - [About docs.rs](/about) + - [Badges](/about/badges) + - [Builds](/about/builds) + - [Metadata](/about/metadata) + - [Shorthand URLs](/about/redirections) + - [Download](/about/download) + - [Rustdoc JSON](/about/rustdoc-json) + - [Build queue](/releases/queue) + - [Privacy policy](https://foundation.rust-lang.org/policies/privacy-policy/#docs.rs) + +- [Rust](#) + - [Rust website](https://www.rust-lang.org/) + - [The Book](https://doc.rust-lang.org/book/) + - [Standard Library API Reference](https://doc.rust-lang.org/std/) + - [Rust by Example](https://doc.rust-lang.org/rust-by-example/) + - [The Cargo Guide](https://doc.rust-lang.org/cargo/guide/) + - [Clippy Documentation](https://doc.rust-lang.org/nightly/clippy) + +## [Crate aws\_sdk\_sesv2](#) + +## [aws\_sdk\_sesv2](../aws_sdk_sesv2/index.html)1.111.0 + +- [All Items](all.html) + +### [Sections](#) + +- [Getting Started](#getting-started "Getting Started") +- [Using the SDK](#using-the-sdk "Using the SDK") +- [Getting Help](#getting-help "Getting Help") +- [Crate Organization](#crate-organization "Crate Organization") + +### [Crate Items](#modules) + +- [Modules](#modules "Modules") +- [Structs](#structs "Structs") +- [Enums](#enums "Enums") + +# Crate aws\_sdk\_sesv2 Copy item path + +[Source](../src/aws_sdk_sesv2/lib.rs.html#1-211) + +Expand description + +[Amazon SES](http://aws.amazon.com/ses) is an Amazon Web Services service that you can use to send email messages to your customers. + +If you’re new to Amazon SES API v2, you might find it helpful to review the [Amazon Simple Email Service Developer Guide](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/). The *Amazon SES Developer Guide* provides information and code samples that demonstrate how to use Amazon SES API v2 features programmatically. + +### [§](#getting-started)Getting Started + +> Examples are available for many services and operations, check out the [usage examples](https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/rustv1). + +The SDK provides one crate per AWS service. You must add [Tokio](https://crates.io/crates/tokio) as a dependency within your Rust project to execute asynchronous code. To add `aws-sdk-sesv2` to your project, add the following to your **Cargo.toml** file: + +``` +[dependencies] +aws-config = { version = "1.1.7", features = ["behavior-version-latest"] } +aws-sdk-sesv2 = "1.111.0" +tokio = { version = "1", features = ["full"] } +``` + +Then in code, a client can be created with the following: + +``` +use aws_sdk_sesv2 as sesv2; + +#[::tokio::main] +async fn main() -> Result<(), sesv2::Error> { + let config = aws_config::load_from_env().await; + let client = aws_sdk_sesv2::Client::new(&config); + + // ... make some calls with the client + + Ok(()) +} +``` + +See the [client documentation](https://docs.rs/aws-sdk-sesv2/latest/aws_sdk_sesv2/client/struct.Client.html) for information on what calls can be made, and the inputs and outputs for each of those calls. + +### [§](#using-the-sdk)Using the SDK + +Until the SDK is released, we will be adding information about using the SDK to the [Developer Guide](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/welcome.html). Feel free to suggest additional sections for the guide by opening an issue and describing what you are trying to do. + +### [§](#getting-help)Getting Help + +- [GitHub discussions](https://github.com/awslabs/aws-sdk-rust/discussions) - For ideas, RFCs & general questions +- [GitHub issues](https://github.com/awslabs/aws-sdk-rust/issues/new/choose) - For bug reports & feature requests +- [Generated Docs (latest version)](https://awslabs.github.io/aws-sdk-rust/) +- [Usage examples](https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/rustv1) + +## [§](#crate-organization)Crate Organization + +The entry point for most customers will be [`Client`](struct.Client.html "struct aws_sdk_sesv2::Client"), which exposes one method for each API offered by Amazon Simple Email Service. The return value of each of these methods is a “fluent builder”, where the different inputs for that API are added by builder-style function call chaining, followed by calling `send()` to get a [`Future`](https://doc.rust-lang.org/nightly/core/future/future/trait.Future.html "trait core::future::future::Future") that will result in either a successful output or a [`SdkError`](error/type.SdkError.html "type aws_sdk_sesv2::error::SdkError"). + +Some of these API inputs may be structs or enums to provide more complex structured information. These structs and enums live in [`types`](types/index.html "mod aws_sdk_sesv2::types"). There are some simpler types for representing data such as date times or binary blobs that live in [`primitives`](primitives/index.html "mod aws_sdk_sesv2::primitives"). + +All types required to configure a client via the [`Config`](struct.Config.html "struct aws_sdk_sesv2::Config") struct live in [`config`](config/index.html "mod aws_sdk_sesv2::config"). + +The [`operation`](operation/index.html "mod aws_sdk_sesv2::operation") module has a submodule for every API, and in each submodule is the input, output, and error type for that API, as well as builders to construct each of those. + +There is a top-level [`Error`](enum.Error.html "enum aws_sdk_sesv2::Error") type that encompasses all the errors that the client can return. Any other error type can be converted to this `Error` type via the [`From`](https://doc.rust-lang.org/nightly/core/convert/trait.From.html "trait core::convert::From") trait. + +The other modules within this crate are not required for normal usage. + +## Modules[§](#modules) + +[client](client/index.html "mod aws_sdk_sesv2::client") + +Client for calling Amazon Simple Email Service. + +[config](config/index.html "mod aws_sdk_sesv2::config") + +Configuration for Amazon Simple Email Service. + +[error](error/index.html "mod aws_sdk_sesv2::error") + +Common errors and error handling utilities. + +[meta](meta/index.html "mod aws_sdk_sesv2::meta") + +Information about this crate. + +[operation](operation/index.html "mod aws_sdk_sesv2::operation") + +All operations that this crate can perform. + +[primitives](primitives/index.html "mod aws_sdk_sesv2::primitives") + +Primitives such as `Blob` or `DateTime` used by other types. + +[types](types/index.html "mod aws_sdk_sesv2::types") + +Data structures used by operation inputs/outputs. + +## Structs[§](#structs) + +[Client](struct.Client.html "struct aws_sdk_sesv2::Client") + +Client for Amazon Simple Email Service + +[Config](struct.Config.html "struct aws_sdk_sesv2::Config") + +Configuration for a aws\_sdk\_sesv2 service client. + +## Enums[§](#enums) + +[Error](enum.Error.html "enum aws_sdk_sesv2::Error") + +All possible error types for this service. +``` +``` + +``` +Tool: webfetch + +**Input:** +```json +{ + "url": "https://docs.rs/lettre/latest/lettre/", + "format": "markdown" +} +``` +**Output:** +``` +lettre - Rust + + + +[Docs.rs](/) + +- [lettre-0.11.19](# "Email client") + + - lettre 0.11.19 + - [Permalink](/lettre/0.11.19/lettre/ "Get a link to this specific version") + - [Docs.rs crate page](/crate/lettre/latest "See lettre in docs.rs") + - [MIT](https://spdx.org/licenses/MIT) + + - Links + - [Homepage](https://lettre.rs) + - [Repository](https://github.com/lettre/lettre) + - [crates.io](https://crates.io/crates/lettre "See lettre in crates.io") + - [Source](/crate/lettre/latest/source/ "Browse source of lettre-0.11.19") + + - Owners + - [amousset](https://crates.io/users/amousset) + - [github:lettre:release](https://crates.io/teams/github:lettre:release) + + - Dependencies + - - [async-std ^1.8 *normal* *optional*](/async-std/^1.8/) + - [async-trait ^0.1 *normal* *optional*](/async-trait/^0.1/) + - [base64 ^0.22 *normal* *optional*](/base64/^0.22/) + - [boring ^4 *normal* *optional*](/boring/^4/) + - [chumsky ^0.9 *normal*](/chumsky/^0.9/) + - [ed25519-dalek ^2 *normal* *optional*](/ed25519-dalek/^2/) + - [email-encoding ^0.4 *normal* *optional*](/email-encoding/^0.4/) + - [email\_address ^0.2.1 *normal*](/email_address/^0.2.1/) + - [fastrand ^2.0 *normal* *optional*](/fastrand/^2.0/) + - [futures-io ^0.3.7 *normal* *optional*](/futures-io/^0.3.7/) + - [futures-rustls ^0.26 *normal* *optional*](/futures-rustls/^0.26/) + - [futures-util ^0.3.7 *normal* *optional*](/futures-util/^0.3.7/) + - [hostname ^0.4 *normal* *optional*](/hostname/^0.4/) + - [httpdate ^1 *normal* *optional*](/httpdate/^1/) + - [idna ^1 *normal*](/idna/^1/) + - [mime ^0.3.4 *normal* *optional*](/mime/^0.3.4/) + - [native-tls ^0.2.9 *normal* *optional*](/native-tls/^0.2.9/) + - [nom ^8 *normal* *optional*](/nom/^8/) + - [percent-encoding ^2.3 *normal* *optional*](/percent-encoding/^2.3/) + - [quoted\_printable ^0.5 *normal* *optional*](/quoted_printable/^0.5/) + - [rsa ^0.9 *normal* *optional*](/rsa/^0.9/) + - [rustls ^0.23.18 *normal* *optional*](/rustls/^0.23.18/) + - [rustls-native-certs ^0.8 *normal* *optional*](/rustls-native-certs/^0.8/) + - [rustls-platform-verifier ^0.6.0 *normal* *optional*](/rustls-platform-verifier/^0.6.0/) + - [serde ^1.0.110 *normal* *optional*](/serde/^1.0.110/) + - [serde\_json ^1 *normal* *optional*](/serde_json/^1/) + - [sha2 ^0.10 *normal* *optional*](/sha2/^0.10/) + - [socket2 ^0.6 *normal* *optional*](/socket2/^0.6/) + - [tokio-boring ^4 *normal* *optional*](/tokio-boring/^4/) + - [tokio ^1 *normal* *optional*](/tokio/^1/) + - [tokio-native-tls ^0.3 *normal* *optional*](/tokio-native-tls/^0.3/) + - [tokio-rustls ^0.26 *normal* *optional*](/tokio-rustls/^0.26/) + - [tracing ^0.1.16 *normal* *optional*](/tracing/^0.1.16/) + - [url ^2.4 *normal* *optional*](/url/^2.4/) + - [uuid ^1 *normal* *optional*](/uuid/^1/) + - [webpki-roots ^1.0.0 *normal* *optional*](/webpki-roots/^1.0.0/) + - [async-std ^1.8 *dev*](/async-std/^1.8/) + - [criterion ^0.5 *dev*](/criterion/^0.5/) + - [glob ^0.3 *dev*](/glob/^0.3/) + - [maud ^0.26 *dev*](/maud/^0.26/) + - [pretty\_assertions ^1 *dev*](/pretty_assertions/^1/) + - [serde\_json ^1 *dev*](/serde_json/^1/) + - [tokio ^1 *dev*](/tokio/^1/) + - [tracing ^0.1.16 *dev*](/tracing/^0.1.16/) + - [tracing-subscriber ^0.3 *dev*](/tracing-subscriber/^0.3/) + - [walkdir ^2 *dev*](/walkdir/^2/) + - [web-time ^1.1.0 *normal* *optional*](/web-time/^1.1.0/) + + + - Versions + + - [**93.07%** of the crate is documented](/crate/lettre/latest) + +- [Platform](#) + - [x86\_64-unknown-linux-gnu](/crate/lettre/latest/target-redirect/lettre/) +- [Feature flags](/crate/lettre/latest/features "Browse available feature flags of lettre-0.11.19") + +- [docs.rs](#) + - [About docs.rs](/about) + - [Badges](/about/badges) + - [Builds](/about/builds) + - [Metadata](/about/metadata) + - [Shorthand URLs](/about/redirections) + - [Download](/about/download) + - [Rustdoc JSON](/about/rustdoc-json) + - [Build queue](/releases/queue) + - [Privacy policy](https://foundation.rust-lang.org/policies/privacy-policy/#docs.rs) + +- [Rust](#) + - [Rust website](https://www.rust-lang.org/) + - [The Book](https://doc.rust-lang.org/book/) + - [Standard Library API Reference](https://doc.rust-lang.org/std/) + - [Rust by Example](https://doc.rust-lang.org/rust-by-example/) + - [The Cargo Guide](https://doc.rust-lang.org/cargo/guide/) + - [Clippy Documentation](https://doc.rust-lang.org/nightly/clippy) + +## [Crate lettre](#) + +[![logo](https://avatars0.githubusercontent.com/u/15113230?v=4)](../lettre/index.html) + +## [lettre](../lettre/index.html)0.11.19 + +- [All Items](all.html) + +### [Sections](#) + +- [Features](#features "Features") + - [Typed message builder](#typed-message-builder "Typed message builder") + - [SMTP transport](#smtp-transport "SMTP transport") + - [Sendmail transport](#sendmail-transport "Sendmail transport") + - [File transport](#file-transport "File transport") + - [Async execution runtimes](#async-execution-runtimes "Async execution runtimes") + - [Misc features](#misc-features "Misc features") + +### [Crate Items](#reexports) + +- [Re-exports](#reexports "Re-exports") +- [Modules](#modules "Modules") +- [Structs](#structs "Structs") +- [Traits](#traits "Traits") + +# Crate lettre Copy item path + +[Source](../src/lettre/lib.rs.html#1-384) + +Expand description + +Lettre is an email library that allows creating and sending messages. It provides: + +- An easy to use email builder +- Pluggable email transports +- Unicode support +- Secure defaults +- Async support + +Lettre requires Rust 1.74 or newer. + +### [§](#features)Features + +This section lists each lettre feature and briefly explains it. More info about each module can be found in the corresponding module page. + +Features with `📫` near them are enabled by default. + +#### [§](#typed-message-builder)Typed message builder + +*Strongly typed [`message`](message/index.html "mod lettre::message") builder* + +- **builder** 📫: Enable the [`Message`](struct.Message.html "struct lettre::Message") builder +- **hostname** 📫: Try to use the actual system hostname in the `Message-ID` header + +#### [§](#smtp-transport)SMTP transport + +*Send emails using [`SMTP`](transport/smtp/index.html "mod lettre::transport::smtp")* + +- **smtp-transport** 📫: Enable the SMTP transport +- **pool** 📫: Connection pool for SMTP transport +- **hostname** 📫: Try to use the actual system hostname for the SMTP `CLIENTID` + +##### [§](#smtp-over-tls-via-the-native-tls-crate)SMTP over TLS via the native-tls crate + +*Secure SMTP connections using TLS from the `native-tls` crate* + +Uses schannel on Windows, Security-Framework on macOS, and OpenSSL on all other platforms. + +- **native-tls** 📫: TLS support for the synchronous version of the API +- **tokio1-native-tls**: TLS support for the `tokio1` async version of the API + +NOTE: native-tls isn’t supported with `async-std` + +###### [§](#building-lettre-with-openssl)Building lettre with OpenSSL + +When building lettre with native-tls on a system that makes use of OpenSSL, the following packages will need to be installed in order for the build and the compiled program to run properly. + +Distro + +Build-time packages + +Runtime packages + +Debian + +`pkg-config`, `libssl-dev` + +`libssl3`, `ca-certificates` + +Alpine Linux + +`pkgconf`, `openssl-dev` + +`libssl3`, `ca-certificates` + +##### [§](#smtp-over-tls-via-the-boring-crate-boring-tls)SMTP over TLS via the boring crate (Boring TLS) + +*Secure SMTP connections using TLS from the `boring-tls` crate* + +- **boring-tls**: TLS support for the synchronous version of the API +- **tokio1-boring-tls**: TLS support for the `tokio1` async version of the API + +NOTE: boring-tls isn’t supported with `async-std` + +##### [§](#smtp-over-tls-via-the-rustls-crate)SMTP over TLS via the rustls crate + +*Secure SMTP connections using TLS from the `rustls` crate* + +- **rustls**: TLS support for the synchronous version of the API +- **tokio1-rustls**: TLS support for the `tokio1` async version of the API +- **async-std1-rustls**: TLS support for the `async-std1` async version of the API + +###### [§](#rustls-crypto-backends)rustls crypto backends + +*The crypto implementation to use with rustls* + +When the `rustls` feature is enabled, one of the following crypto backends MUST also be enabled. + +- **aws-lc-rs**: use [AWS-LC](https://github.com/aws/aws-lc) (via [`aws-lc-rs`](https://crates.io/crates/aws-lc-rs)) as the `rustls` crypto backend +- **ring**: use [`ring`](https://crates.io/crates/ring) as the `rustls` crypto backend + +When enabling `aws-lc-rs`, the `fips` feature can also be enabled to have rustls use the FIPS certified module of AWS-LC. + +`aws-lc-rs` may require cmake on some platforms to compile. `fips` always requires cmake and the Go compiler to compile. + +###### [§](#rustls-certificate-verification-backend)rustls certificate verification backend + +*The TLS certificate verification backend to use with rustls* + +When the `rustls` feature is enabled, one of the following verification backends MUST also be enabled. + +- **rustls-platform-verifier**: verify TLS certificate using the OS’s native certificate store (see [`rustls-platform-verifier`](https://crates.io/crates/rustls-platform-verifier)) +- **rustls-native-certs**: verify TLS certificates using the platform’s native certificate store (see [`rustls-native-certs`](https://crates.io/crates/rustls-native-certs)) - when in doubt use `rustls-platform-verifier` +- **webpki-roots**: verify TLS certificates against Mozilla’s root certificates (see [`webpki-roots`](https://crates.io/crates/webpki-roots)) + +The following packages will need to be installed in order for the build stage and the compiled program to run properly. + +Verification backend + +Distro + +Build-time packages + +Runtime packages + +`rustls-platform-verifier` + +Debian + +none + +`ca-certificates` + +`rustls-platform-verifier` + +Alpine Linux + +none + +`ca-certificates` + +`rustls-native-certs` + +Debian + +none + +`ca-certificates` + +`rustls-native-certs` + +Alpine Linux + +none + +`ca-certificates` + +`webpki-roots` + +any + +none + +none + +#### [§](#sendmail-transport)Sendmail transport + +*Send emails using the [`sendmail`](transport/sendmail/index.html "mod lettre::transport::sendmail") command* + +- **sendmail-transport**: Enable the `sendmail` transport + +#### [§](#file-transport)File transport + +*Save emails as an `.eml` [`file`](transport/file/index.html "mod lettre::transport::file")* + +- **file-transport**: Enable the file transport (saves emails into an `.eml` file) +- **file-transport-envelope**: Allow writing the envelope into a JSON file (additionally saves envelopes into a `.json` file) + +#### [§](#async-execution-runtimes)Async execution runtimes + +*Use [tokio](https://docs.rs/tokio/1) or [async-std](https://docs.rs/async-std/1) as an async execution runtime for sending emails* + +The correct runtime version must be chosen in order for lettre to work correctly. For example, when sending emails from a Tokio 1.x context, the Tokio 1.x executor ([`Tokio1Executor`](struct.Tokio1Executor.html "struct lettre::Tokio1Executor")) must be used. Using a different version (for example Tokio 0.2.x), or async-std, would result in a runtime panic. + +- **tokio1**: Allow to asynchronously send emails using [Tokio 1.x](https://docs.rs/tokio/1) +- **async-std1**: Allow to asynchronously send emails using [async-std 1.x](https://docs.rs/async-std/1) + +NOTE: native-tls isn’t supported with `async-std` + +#### [§](#misc-features)Misc features + +*Additional features* + +- **serde**: Serialization/Deserialization of entities +- **tracing**: Logging using the `tracing` crate +- **mime03**: Allow creating a [`ContentType`](message/header/struct.ContentType.html "struct lettre::message::header::ContentType") from an existing [mime 0.3](https://docs.rs/mime/0.3) `Mime` struct +- **dkim**: Add support for signing email with DKIM +- **web**: WebAssembly support using the `web-time` crate for time operations + +## Re-exports[§](#reexports) + +`pub use crate::address::[Address](address/struct.Address.html "struct lettre::address::Address");` + +`pub use crate::transport::smtp::[AsyncSmtpTransport](transport/smtp/struct.AsyncSmtpTransport.html "struct lettre::transport::smtp::AsyncSmtpTransport");``smtp-transport` and (`tokio1` or `async-std1`) + +`pub use crate::transport::smtp::[SmtpTransport](transport/smtp/struct.SmtpTransport.html "struct lettre::transport::smtp::SmtpTransport");``smtp-transport` + +## Modules[§](#modules) + +[address](address/index.html "mod lettre::address") + +Email addresses + +[error](error/index.html "mod lettre::error") + +Error type for email messages + +[message](message/index.html "mod lettre::message")`builder` + +Provides a strongly typed way to build emails + +[transport](transport/index.html "mod lettre::transport") + +Transports for sending emails + +## Structs[§](#structs) + +[AsyncFileTransport](struct.AsyncFileTransport.html "struct lettre::AsyncFileTransport")`tokio1` or `async-std1` + +Asynchronously writes the content and the envelope information to a file + +[AsyncSendmailTransport](struct.AsyncSendmailTransport.html "struct lettre::AsyncSendmailTransport")`tokio1` or `async-std1` + +Asynchronously sends emails using the `sendmail` command + +[AsyncStd1Executor](struct.AsyncStd1Executor.html "struct lettre::AsyncStd1Executor")`async-std1` + +Async [`Executor`](trait.Executor.html "trait lettre::Executor") using `async-std` `1.x` + +[FileTransport](struct.FileTransport.html "struct lettre::FileTransport")`file-transport` + +Writes the content and the envelope information to a file + +[Message](struct.Message.html "struct lettre::Message")`builder` + +Email message which can be formatted + +[SendmailTransport](struct.SendmailTransport.html "struct lettre::SendmailTransport")`sendmail-transport` + +Sends emails using the `sendmail` command + +[Tokio1Executor](struct.Tokio1Executor.html "struct lettre::Tokio1Executor")`tokio1` + +Async [`Executor`](trait.Executor.html "trait lettre::Executor") using `tokio` `1.x` + +## Traits[§](#traits) + +[AsyncTransport](trait.AsyncTransport.html "trait lettre::AsyncTransport")`tokio1` or `async-std1` + +Async Transport method for emails + +[Executor](trait.Executor.html "trait lettre::Executor")`tokio1` or `async-std1` + +Async executor abstraction trait + +[Transport](trait.Transport.html "trait lettre::Transport") + +Blocking Transport method for emails +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 11.5s) + +_Thinking:_ + +Perfect! I now have information about all three email providers. Let me summarize what I found: + +1. **Resend** (`resend-rs` crate 0.20.0): + - Official Rust SDK + - Simple API, async by default (with blocking feature) + - Built-in rate limiting (9 req/1.1s by default) + - Supports environment variable `RESEND_API_KEY` + - Very simple to use: just create client with API key and send + +2. **AWS SES** (`aws-sdk-sesv2` crate 1.111.0): + - Official AWS SDK + - More complex setup (AWS credentials, region config) + - Very robust and enterprise-ready + - Requires AWS configuration (`aws-config`) + - Async by default with tokio + +3. **SMTP** (`lettre` crate 0.11.19): + - Most flexible, works with any SMTP server + - Supports multiple TLS backends (native-tls, rustls, boring-tls) + - Both sync and async APIs + - Feature-rich (connection pooling, DKIM, etc.) + - Requires SMTP server configuration + +Now let me continue with the plan design. + +``` +Tool: todowrite + +**Input:** +```json +{ + "todos": [ + { + "id": "1", + "content": "Research and document email provider APIs (Resend, SES, SMTP)", + "status": "completed", + "priority": "high" + }, + { + "id": "2", + "content": "Design database schema for email verification", + "status": "in_progress", + "priority": "high" + }, + { + "id": "3", + "content": "Design proto changes for verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "4", + "content": "Plan server configuration structure", + "status": "pending", + "priority": "high" + }, + { + "id": "5", + "content": "Create detailed implementation plan", + "status": "pending", + "priority": "high" + } + ] +} +``` +**Output:** +``` +[ + { + "id": "1", + "content": "Research and document email provider APIs (Resend, SES, SMTP)", + "status": "completed", + "priority": "high" + }, + { + "id": "2", + "content": "Design database schema for email verification", + "status": "in_progress", + "priority": "high" + }, + { + "id": "3", + "content": "Design proto changes for verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "4", + "content": "Plan server configuration structure", + "status": "pending", + "priority": "high" + }, + { + "id": "5", + "content": "Create detailed implementation plan", + "status": "pending", + "priority": "high" + } +] +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 6.6s) + +_Thinking:_ + +Now let me design the database schema for email verification. I need to check the current database schemas to understand the structure. + +``` +Tool: glob + +**Input:** +```json +{ + "pattern": "**/*.sql" +} +``` +**Output:** +``` +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260116000001_add_export_code.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260116000002_add_verification_salt.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260114000001_add_self_invites.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260115000001_add_principal_exports.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260115000001_add_principal_exports.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260116000002_add_verification_salt.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260116000001_add_export_code.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260114000001_add_self_invites.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260110000001_add_audit_logs.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260110000001_add_audit_logs.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260105000002_add_groups.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260105000003_add_user_permissions.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260105000001_add_rbac.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260105000002_add_groups.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260105000003_add_user_permissions.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260105000001_add_rbac.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20251227000001_add_environment_version.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20251026125600_init.sql +/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20251230171100_init.sql +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 3.4s) + +_Thinking:_ + +Let me look at the initial migration to understand the database schema structure. + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20251026125600_init.sql" +} +``` +**Output:** +``` + +00001| PRAGMA foreign_keys = ON; +00002| +00003| CREATE TABLE IF NOT EXISTS users ( +00004| id TEXT PRIMARY KEY NOT NULL, -- UUID string +00005| email TEXT NOT NULL UNIQUE, +00006| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00007| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')) +00008| ); +00009| +00010| CREATE TRIGGER IF NOT EXISTS users_updated_at AFTER UPDATE ON users +00011| BEGIN +00012| UPDATE users SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; +00013| END; +00014| +00015| CREATE TABLE IF NOT EXISTS principals ( +00016| id TEXT PRIMARY KEY NOT NULL, -- UUID string +00017| user_id TEXT REFERENCES users(id) ON DELETE CASCADE, -- NULL for service accounts +00018| name TEXT NOT NULL, -- Unique per user, not globally +00019| public_key BLOB NOT NULL, +00020| x25519_public_key BLOB, -- X25519 for encryption (ECDH) +00021| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00022| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')) +00023| ); +00024| +00025| -- Principal names are unique per user (alice can have "laptop", bob can also have "laptop") +00026| CREATE UNIQUE INDEX principals_user_name_unique ON principals(user_id, name) WHERE user_id IS NOT NULL; +00027| +00028| CREATE TRIGGER IF NOT EXISTS principals_updated_at AFTER UPDATE ON principals +00029| BEGIN +00030| UPDATE principals SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; +00031| END; +00032| +00033| CREATE TABLE IF NOT EXISTS workspaces ( +00034| id TEXT PRIMARY KEY NOT NULL, -- UUID string +00035| name TEXT NOT NULL UNIQUE, -- Globally unique workspace names +00036| owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, +00037| kdf_salt BLOB NOT NULL, +00038| kdf_m_cost_kib INTEGER NOT NULL, +00039| kdf_t_cost INTEGER NOT NULL, +00040| kdf_p_cost INTEGER NOT NULL, +00041| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00042| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')) +00043| ); +00044| +00045| CREATE TRIGGER IF NOT EXISTS workspaces_updated_at AFTER UPDATE ON workspaces +00046| BEGIN +00047| UPDATE workspaces SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; +00048| END; +00049| +00050| CREATE TABLE IF NOT EXISTS workspace_members ( +00051| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00052| user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, +00053| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00054| PRIMARY KEY (workspace_id, user_id) +00055| ); +00056| +00057| CREATE TABLE IF NOT EXISTS workspace_principals ( +00058| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00059| principal_id TEXT NOT NULL REFERENCES principals(id) ON DELETE CASCADE, +00060| ephemeral_pub BLOB NOT NULL, -- Ephemeral X25519 public key for KEK wrapping +00061| kek_wrapped BLOB NOT NULL, -- Workspace KEK wrapped for this principal +00062| kek_nonce BLOB NOT NULL, -- 24-byte nonce for KEK wrapping +00063| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00064| PRIMARY KEY (workspace_id, principal_id) +00065| ); +00066| +00067| CREATE TABLE IF NOT EXISTS invites ( +00068| id TEXT PRIMARY KEY NOT NULL, -- UUID string +00069| token TEXT NOT NULL UNIQUE, +00070| kek_encrypted BLOB, -- Workspace KEK encrypted with invite secret +00071| kek_nonce BLOB, -- 24-byte nonce for KEK encryption +00072| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00073| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00074| expires_at TEXT NOT NULL, +00075| created_by_user_id TEXT REFERENCES users(id) ON DELETE CASCADE, -- NULL for server-created invites +00076| revoked INTEGER NOT NULL DEFAULT 0 -- boolean: 0 = active, 1 = revoked +00077| ); +00078| +00079| CREATE TRIGGER IF NOT EXISTS invites_updated_at AFTER UPDATE ON invites +00080| BEGIN +00081| UPDATE invites SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; +00082| END; +00083| +00084| CREATE TABLE IF NOT EXISTS invite_workspaces ( +00085| invite_id TEXT NOT NULL REFERENCES invites(id) ON DELETE CASCADE, +00086| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00087| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00088| PRIMARY KEY (invite_id, workspace_id) +00089| ); +00090| +00091| CREATE TABLE IF NOT EXISTS projects ( +00092| id TEXT PRIMARY KEY NOT NULL, -- UUID string +00093| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00094| name TEXT NOT NULL, +00095| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00096| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00097| UNIQUE(workspace_id, name) +00098| ); +00099| +00100| CREATE TRIGGER IF NOT EXISTS projects_updated_at AFTER UPDATE ON projects +00101| BEGIN +00102| UPDATE projects SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; +00103| END; +00104| +00105| CREATE TABLE IF NOT EXISTS environments ( +00106| id TEXT PRIMARY KEY NOT NULL, -- UUID string +00107| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00108| project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, +00109| name TEXT NOT NULL, +00110| dek_wrapped BLOB NOT NULL, +00111| dek_nonce BLOB NOT NULL, +00112| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00113| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00114| UNIQUE(workspace_id, project_id, name) +00115| ); +00116| +00117| CREATE TRIGGER IF NOT EXISTS environments_updated_at AFTER UPDATE ON environments +00118| BEGIN +00119| UPDATE environments SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; +00120| END; +00121| +00122| CREATE TABLE IF NOT EXISTS secrets ( +00123| id TEXT PRIMARY KEY NOT NULL, -- UUID string +00124| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00125| env_id TEXT NOT NULL REFERENCES environments(id) ON DELETE CASCADE, +00126| key_name TEXT NOT NULL, +00127| nonce BLOB NOT NULL, +00128| ciphertext BLOB NOT NULL, +00129| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00130| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), +00131| UNIQUE(workspace_id, env_id, key_name) +00132| ); +00133| +00134| CREATE TRIGGER IF NOT EXISTS secrets_updated_at AFTER UPDATE ON secrets +00135| BEGIN +00136| UPDATE secrets SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; +00137| END; +00138| + +(End of file - total 138 lines) + +``` +``` + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20251230171100_init.sql" +} +``` +**Output:** +``` + +00001| CREATE TABLE IF NOT EXISTS users ( +00002| id UUID PRIMARY KEY NOT NULL, +00003| email TEXT NOT NULL UNIQUE, +00004| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00005| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +00006| ); +00007| +00008| CREATE TABLE IF NOT EXISTS principals ( +00009| id UUID PRIMARY KEY NOT NULL, +00010| user_id UUID REFERENCES users(id) ON DELETE CASCADE, -- NULL for service accounts +00011| name TEXT NOT NULL, +00012| public_key BYTEA NOT NULL, +00013| x25519_public_key BYTEA, -- X25519 for encryption (ECDH) +00014| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00015| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +00016| ); +00017| +00018| -- Principal names are unique per user +00019| CREATE UNIQUE INDEX principals_user_name_unique ON principals(user_id, name) WHERE user_id IS NOT NULL; +00020| +00021| CREATE TABLE IF NOT EXISTS workspaces ( +00022| id UUID PRIMARY KEY NOT NULL, +00023| name TEXT NOT NULL UNIQUE, +00024| owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, +00025| kdf_salt BYTEA NOT NULL, +00026| kdf_m_cost_kib INTEGER NOT NULL, +00027| kdf_t_cost INTEGER NOT NULL, +00028| kdf_p_cost INTEGER NOT NULL, +00029| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00030| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +00031| ); +00032| +00033| CREATE TABLE IF NOT EXISTS workspace_members ( +00034| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00035| user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, +00036| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00037| PRIMARY KEY (workspace_id, user_id) +00038| ); +00039| +00040| CREATE TABLE IF NOT EXISTS workspace_principals ( +00041| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00042| principal_id UUID NOT NULL REFERENCES principals(id) ON DELETE CASCADE, +00043| ephemeral_pub BYTEA NOT NULL, +00044| kek_wrapped BYTEA NOT NULL, +00045| kek_nonce BYTEA NOT NULL, +00046| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00047| PRIMARY KEY (workspace_id, principal_id) +00048| ); +00049| +00050| CREATE TABLE IF NOT EXISTS invites ( +00051| id UUID PRIMARY KEY NOT NULL, +00052| token TEXT NOT NULL UNIQUE, +00053| kek_encrypted BYTEA, +00054| kek_nonce BYTEA, +00055| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00056| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00057| expires_at TIMESTAMPTZ NOT NULL, +00058| created_by_user_id UUID REFERENCES users(id) ON DELETE CASCADE, -- NULL for server-created invites +00059| revoked BOOLEAN NOT NULL DEFAULT FALSE +00060| ); +00061| +00062| CREATE TABLE IF NOT EXISTS invite_workspaces ( +00063| invite_id UUID NOT NULL REFERENCES invites(id) ON DELETE CASCADE, +00064| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00065| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00066| PRIMARY KEY (invite_id, workspace_id) +00067| ); +00068| +00069| CREATE TABLE IF NOT EXISTS projects ( +00070| id UUID PRIMARY KEY NOT NULL, +00071| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00072| name TEXT NOT NULL, +00073| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00074| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00075| UNIQUE(workspace_id, name) +00076| ); +00077| +00078| CREATE TABLE IF NOT EXISTS environments ( +00079| id UUID PRIMARY KEY NOT NULL, +00080| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00081| project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, +00082| name TEXT NOT NULL, +00083| dek_wrapped BYTEA NOT NULL, +00084| dek_nonce BYTEA NOT NULL, +00085| version BIGINT NOT NULL DEFAULT 0, +00086| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00087| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00088| UNIQUE(workspace_id, project_id, name) +00089| ); +00090| +00091| CREATE TABLE IF NOT EXISTS secrets ( +00092| id UUID PRIMARY KEY NOT NULL, +00093| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, +00094| env_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE, +00095| key_name TEXT NOT NULL, +00096| nonce BYTEA NOT NULL, +00097| ciphertext BYTEA NOT NULL, +00098| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00099| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), +00100| UNIQUE(workspace_id, env_id, key_name) +00101| ); +00102| +00103| -- Triggers for updated_at columns +00104| CREATE OR REPLACE FUNCTION update_updated_at_column() +00105| RETURNS TRIGGER AS $$ +00106| BEGIN +00107| NEW.updated_at = NOW(); +00108| RETURN NEW; +00109| END; +00110| $$ language 'plpgsql'; +00111| +00112| CREATE TRIGGER users_updated_at BEFORE UPDATE ON users +00113| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +00114| +00115| CREATE TRIGGER principals_updated_at BEFORE UPDATE ON principals +00116| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +00117| +00118| CREATE TRIGGER workspaces_updated_at BEFORE UPDATE ON workspaces +00119| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +00120| +00121| CREATE TRIGGER invites_updated_at BEFORE UPDATE ON invites +00122| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +00123| +00124| CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects +00125| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +00126| +00127| CREATE TRIGGER environments_updated_at BEFORE UPDATE ON environments +00128| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +00129| +00130| CREATE TRIGGER secrets_updated_at BEFORE UPDATE ON secrets +00131| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +00132| + +(End of file - total 132 lines) + +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 8.7s) + +_Thinking:_ + +Perfect! Now I understand the database structure. I need to design a table for email verification that will store:- email (to verify)- verification code (6-digit)- created_at timestamp- expires_at timestamp (15 minutes from creation) +- attempts (rate limiting) +- verified (boolean, if completed) + +Let me now create a comprehensive implementation plan based on all the research I've done. + +``` +Tool: todowrite + +**Input:** +```json +{ + "todos": [ + { + "id": "1", + "content": "Research and document email provider APIs (Resend, SES, SMTP)", + "status": "completed", + "priority": "high" + }, + { + "id": "2", + "content": "Design database schema for email verification", + "status": "completed", + "priority": "high" + }, + { + "id": "3", + "content": "Design proto changes for verification flow", + "status": "completed", + "priority": "high" + }, + { + "id": "4", + "content": "Plan server configuration structure", + "status": "completed", + "priority": "high" + }, + { + "id": "5", + "content": "Create detailed implementation plan", + "status": "completed", + "priority": "high" + } + ] +} +``` +**Output:** +``` +[ + { + "id": "1", + "content": "Research and document email provider APIs (Resend, SES, SMTP)", + "status": "completed", + "priority": "high" + }, + { + "id": "2", + "content": "Design database schema for email verification", + "status": "completed", + "priority": "high" + }, + { + "id": "3", + "content": "Design proto changes for verification flow", + "status": "completed", + "priority": "high" + }, + { + "id": "4", + "content": "Plan server configuration structure", + "status": "completed", + "priority": "high" + }, + { + "id": "5", + "content": "Create detailed implementation plan", + "status": "completed", + "priority": "high" + } +] +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 85.1s) + +_Thinking:_ + +Great! Now let me create a comprehensive implementation plan document to present to the user. + +Perfect! I've completed the research and design phase. Here's a comprehensive implementation plan for issue #46: + +--- + +# Implementation Plan: Email Verification for New Principals (Issue #46) + +## Executive Summary + +Implement email verification using **6-digit codes** with support for **3 email providers** (Resend, AWS SES, SMTP). Email verification will be **configurable by server admin** to allow disabling for development/private deployments. + +--- + +## 1. Email Provider Libraries + +### Chosen Crates + +| Provider | Crate | Version | Notes | +|----------|-------|---------|-------| +| **Resend** | `resend-rs` | 0.20.0 | Official SDK, simple API, built-in rate limiting | +| **AWS SES** | `aws-sdk-sesv2` | 1.111.0 | Official AWS SDK, requires AWS config | +| **SMTP** | `lettre` | 0.11.19 | Universal fallback, feature-rich | + +### Dependencies to Add + +```toml +# In apps/zopp-server/Cargo.toml +[dependencies] +# Email providers (all optional, at least one must be enabled) +resend-rs = { version = "0.20", optional = true } +aws-sdk-sesv2 = { version = "1.111", optional = true } +aws-config = { version = "1.8", optional = true } +lettre = { version = "0.11", features = ["tokio1-rustls", "smtp-transport"], optional = true } + +# Random number generation for verification codes +rand = "0.9" +``` + +### Feature Flags + +```toml +[features] +# Email provider features (mutually exclusive selection) +email-resend = ["resend-rs"] +email-ses = ["aws-sdk-sesv2", "aws-config"] +email-smtp = ["lettre"] + +# Default: SMTP for maximum compatibility +default = ["email-smtp"] +``` + +--- + +## 2. Database Schema + +### New Table: `email_verifications` + +**SQLite** (`crates/zopp-store-sqlite/migrations/20260121000001_add_email_verification.sql`): + +```sql +CREATE TABLE IF NOT EXISTS email_verifications ( + id TEXT PRIMARY KEY NOT NULL, -- UUID + email TEXT NOT NULL, -- Email being verified (lowercased) + code TEXT NOT NULL, -- 6-digit code + 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 + verified INTEGER NOT NULL DEFAULT 0 -- 0 = pending, 1 = verified +); + +-- Index for lookup by email +CREATE INDEX idx_email_verifications_email ON email_verifications(email); + +-- Index for cleanup of expired verifications +CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at); +``` + +**PostgreSQL** (`crates/zopp-store-postgres/migrations/20260121000001_add_email_verification.sql`): + +```sql +CREATE TABLE IF NOT EXISTS email_verifications ( + id UUID PRIMARY KEY NOT NULL, + email TEXT NOT NULL, + code TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + verified BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX idx_email_verifications_email ON email_verifications(email); +CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at); +``` + +### Storage Trait Extensions + +**File:** `crates/zopp-storage/src/lib.rs` + +```rust +// New structs +pub struct EmailVerification { + pub id: String, // UUID + pub email: String, + pub code: String, + pub attempts: i32, + pub created_at: DateTime, + pub expires_at: DateTime, + pub verified: bool, +} + +// New methods for Store trait +pub trait Store: Send + Sync { + // ... existing methods ... + + // Email verification + async fn create_email_verification(&self, email: &str, code: &str, expires_at: DateTime) -> Result; + async fn get_email_verification(&self, email: &str) -> Result, StoreError>; + async fn increment_verification_attempts(&self, id: &str) -> Result<(), StoreError>; + async fn mark_verification_verified(&self, id: &str) -> Result<(), StoreError>; + async fn delete_email_verification(&self, id: &str) -> Result<(), StoreError>; + async fn cleanup_expired_verifications(&self) -> Result; // Returns # deleted +} +``` + +--- + +## 3. Server Configuration + +### Config Structure + +**File:** `apps/zopp-server/src/config.rs` (new file) + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + /// Email verification settings + pub email: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EmailConfig { + /// Whether email verification is required for new principals + pub verification_required: bool, + + /// Email provider configuration + pub provider: EmailProviderConfig, + + /// "From" address for verification emails + pub from_address: String, + + /// "From" name (e.g., "Zopp Security") + pub from_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum EmailProviderConfig { + Resend { api_key: String }, + Ses { region: String }, // Uses AWS default credential chain + Smtp { + host: String, + port: u16, + username: Option, + password: Option, + use_tls: bool, + }, +} +``` + +### Environment Variables (Alternative to Config File) + +```bash +# Enable/disable verification +ZOPP_EMAIL_VERIFICATION_REQUIRED=true + +# Provider selection +ZOPP_EMAIL_PROVIDER=resend # or "ses" or "smtp" + +# Resend +RESEND_API_KEY=re_... + +# AWS SES (uses AWS SDK default credential chain) +AWS_REGION=us-east-1 + +# SMTP +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=user@example.com +SMTP_PASSWORD=secret +SMTP_USE_TLS=true + +# Sender configuration +ZOPP_EMAIL_FROM=noreply@zopp.dev +ZOPP_EMAIL_FROM_NAME="Zopp Security" +``` + +--- + +## 4. Proto Changes + +### Modified `JoinRequest` / `JoinResponse` + +**File:** `crates/zopp-proto/proto/zopp.proto` + +```protobuf +// Modified Join flow: now returns verification_required flag +message JoinResponse { + string user_id = 1; + string principal_id = 2; + repeated Workspace workspaces = 3; + bool verification_required = 4; // NEW: if true, client must call VerifyEmail +} + +// New RPC: Verify email with 6-digit code +message VerifyEmailRequest { + string email = 1; // Email address + string code = 2; // 6-digit code + string principal_id = 3; // Principal to activate +} + +message VerifyEmailResponse { + bool success = 1; + string message = 2; // Error message if !success + int32 attempts_remaining = 3; // Remaining attempts before lockout +} + +// New RPC: Resend verification code +message ResendVerificationRequest { + string email = 1; +} + +message ResendVerificationResponse { + bool success = 1; + string message = 2; +} +``` + +### Service Definition + +```protobuf +service Zopp { + // ... existing RPCs ... + + // Email verification (unauthenticated - called before principal is active) + rpc VerifyEmail(VerifyEmailRequest) returns (VerifyEmailResponse); + rpc ResendVerification(ResendVerificationRequest) returns (ResendVerificationResponse); +} +``` + +--- + +## 5. Modified Join Flow + +### Current Join Flow +``` +User → Join RPC → Create User + Principal → Return credentials → Done +``` + +### New Join Flow (with verification enabled) + +``` +1. User → Join RPC + ↓ +2. Server creates User + Principal (INACTIVE state) + ↓ +3. Server generates 6-digit code + ↓ +4. Server sends verification email + ↓ +5. Server returns JoinResponse { verification_required: true } + ↓ +6. CLI prompts user for verification code + ↓ +7. User → VerifyEmail RPC with code + ↓ +8. Server validates code + ↓ +9. Server marks principal as ACTIVE + ↓ +10. User can now use zopp normally +``` + +### Database Changes for Principal State + +**Migration:** `20260121000002_add_principal_verified.sql` + +```sql +-- SQLite +ALTER TABLE principals ADD COLUMN verified INTEGER NOT NULL DEFAULT 1; + +-- Set existing principals as verified (backwards compatibility) +UPDATE principals SET verified = 1; + +-- PostgreSQL +ALTER TABLE principals ADD COLUMN verified BOOLEAN NOT NULL DEFAULT TRUE; +``` + +--- + +## 6. Email Service Implementation + +### Abstraction Layer + +**File:** `apps/zopp-server/src/email/mod.rs` (new module) + +```rust +use async_trait::async_trait; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum EmailError { + #[error("Failed to send email: {0}")] + SendError(String), + + #[error("Provider not configured")] + NotConfigured, +} + +#[async_trait] +pub trait EmailProvider: Send + Sync { + async fn send_verification_email(&self, to: &str, code: &str) -> Result<(), EmailError>; +} + +// Provider implementations +#[cfg(feature = "email-resend")] +pub mod resend; + +#[cfg(feature = "email-ses")] +pub mod ses; + +#[cfg(feature = "email-smtp")] +pub mod smtp; + +pub fn create_provider(config: &EmailProviderConfig) -> Result, EmailError> { + match config { + #[cfg(feature = "email-resend")] + EmailProviderConfig::Resend { api_key } => { + Ok(Box::new(resend::ResendProvider::new(api_key))) + } + + #[cfg(feature = "email-ses")] + EmailProviderConfig::Ses { region } => { + Ok(Box::new(ses::SesProvider::new(region))) + } + + #[cfg(feature = "email-smtp")] + EmailProviderConfig::Smtp { host, port, username, password, use_tls } => { + Ok(Box::new(smtp::SmtpProvider::new(host, *port, username, password, *use_tls))) + } + + _ => Err(EmailError::NotConfigured), + } +} +``` + +### Email Templates + +**File:** `apps/zopp-server/src/email/templates.rs` (new file) + +```rust +pub fn verification_email_text(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 + ) +} + +pub fn verification_email_html(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.

+ +
+ +"#, + code + ) +} +``` + +--- + +## 7. CLI Changes + +### Modified Join Command + +**File:** `apps/zopp-cli/src/commands/join.rs` + +```rust +// After Join RPC returns +if response.verification_required { + println!("✉️ Verification email sent to {}", email); + println!("Please enter the 6-digit code:"); + + loop { + let code = read_code_from_stdin()?; // Helper function + + let verify_response = client + .verify_email(VerifyEmailRequest { + email: email.clone(), + code, + principal_id: response.principal_id.clone(), + }) + .await? + .into_inner(); + + if verify_response.success { + println!("✓ Email verified successfully!"); + break; + } else { + eprintln!("✗ {}", verify_response.message); + if verify_response.attempts_remaining > 0 { + eprintln!("You have {} attempts remaining.", verify_response.attempts_remaining); + } else { + return Err("Too many failed attempts. Please request a new code.".into()); + } + } + } +} + +// ... continue with saving credentials ... +``` + +--- + +## 8. Security Considerations + +### Rate Limiting + +1. **Max 5 verification attempts** per code +2. **Max 3 verification codes** per email per hour +3. **Exponential backoff** after failed attempts +4. **Automatic cleanup** of expired verifications (background task) + +### Code Generation + +- **Cryptographically secure random** 6-digit codes (000000-999999) +- **Constant-time comparison** to prevent timing attacks +- **Codes expire after 15 minutes** + +### Privacy + +- **Don't reveal if email exists** in timing or error messages +- **Constant-time operations** for verification lookups +- **Hash email addresses** in logs (don't log plaintext emails) + +--- + +## 9. Implementation Checklist + +This is the step-by-step task list for implementation: + +### Phase 1: Database & Storage (Both SQLite + PostgreSQL) +- [ ] Create migration `20260121000001_add_email_verification.sql` (SQLite) +- [ ] Create migration `20260121000001_add_email_verification.sql` (PostgreSQL) +- [ ] Create migration `20260121000002_add_principal_verified.sql` (SQLite) +- [ ] Create migration `20260121000002_add_principal_verified.sql` (PostgreSQL) +- [ ] Add `EmailVerification` struct to `zopp-storage/src/lib.rs` +- [ ] Add email verification methods to `Store` trait +- [ ] Implement methods in SQLite store (`zopp-store-sqlite`) +- [ ] Implement methods in PostgreSQL store (`zopp-store-postgres`) +- [ ] Run `sqlx prepare` for both stores +- [ ] Unit tests for storage methods + +### Phase 2: Configuration +- [ ] Create `apps/zopp-server/src/config.rs` +- [ ] Add `ServerConfig`, `EmailConfig`, `EmailProviderConfig` structs +- [ ] Add environment variable parsing +- [ ] Add config file loading (TOML/YAML) +- [ ] Add validation for config (at least one provider configured) + +### Phase 3: Email Providers +- [ ] Add dependencies to `Cargo.toml` with feature flags +- [ ] Create `apps/zopp-server/src/email/mod.rs` +- [ ] Create `apps/zopp-server/src/email/templates.rs` +- [ ] Implement Resend provider (`email/resend.rs`) +- [ ] Implement AWS SES provider (`email/ses.rs`) +- [ ] Implement SMTP provider (`email/smtp.rs`) +- [ ] Add provider factory function +- [ ] Unit tests for email templates + +### Phase 4: Proto Changes +- [ ] Update `zopp.proto` with new messages +- [ ] Add `VerifyEmailRequest` / `VerifyEmailResponse` +- [ ] Add `ResendVerificationRequest` / `ResendVerificationResponse` +- [ ] Add `verification_required` field to `JoinResponse` +- [ ] Add `verified` field to principal proto (if exposed) +- [ ] Regenerate proto code (`cargo build`) + +### Phase 5: Server Handlers +- [ ] Modify `join` handler to support verification flow + - Generate verification code + - Send email + - Create principal in unverified state (if required) + - Return `verification_required: true` +- [ ] Implement `verify_email` handler + - Validate code + - Check expiration + - Check attempts + - Mark principal as verified + - Rate limiting +- [ ] Implement `resend_verification` handler + - Check rate limits (max 3 per hour) + - Generate new code + - Send email +- [ ] Add background cleanup task for expired verifications +- [ ] Add authentication bypass for verify_email RPC (unauthenticated) + +### Phase 6: CLI Changes +- [ ] Modify `cmd_join` to handle verification flow +- [ ] Add code input prompt +- [ ] Add retry logic for failed verification +- [ ] Add `resend` option during verification +- [ ] User-friendly error messages +- [ ] Show remaining attempts + +### Phase 7: Testing +- [ ] E2E test: Join with verification enabled +- [ ] E2E test: Join with verification disabled (backwards compat) +- [ ] E2E test: Failed verification (wrong code) +- [ ] E2E test: Expired verification code +- [ ] E2E test: Too many attempts +- [ ] E2E test: Resend verification code +- [ ] E2E test: Workspace invite with verification +- [ ] Unit tests for code generation +- [ ] Unit tests for email providers (mocked SMTP) +- [ ] Integration test: Full verification flow + +### Phase 8: Documentation +- [ ] Update `CLAUDE.md` with email configuration instructions +- [ ] Create `docs/docs/guides/email-verification.md` +- [ ] Update `docs/docs/reference/cli/join.md` with verification steps +- [ ] Add environment variable reference to docs +- [ ] Add troubleshooting guide for email delivery issues +- [ ] Update `DEMO.md` if verification affects demo flow + +### Phase 9: PR & Review +- [ ] Run full test suite (`cargo test --workspace --all-features`) +- [ ] Run clippy (`cargo clippy --workspace --all-targets --all-features`) +- [ ] Run fmt (`cargo fmt --all`) +- [ ] Create PR with comprehensive description +- [ ] Wait for CI (ignore docker builds) +- [ ] Address Cubic review comments +- [ ] Iterate until approval + +--- + +## 10. Backwards Compatibility + +- **Verification is disabled by default** (no breaking changes) +- **Existing principals are marked as verified** in migration +- **Bootstrap invites work without verification** (server setup flow unchanged) +- **Configuration is optional** (server runs without email config) + +--- + +## 11. Future Enhancements (Out of Scope) + +- OAuth/SSO providers (Google, GitHub, etc.) +- Magic link verification (alternative to code) +- Multi-factor authentication (TOTP) +- Email change verification (for existing users) +- SMS verification (Twilio, etc.) + +--- + +## Questions for You + +Before I proceed with implementation, I have a few questions: + +1. **Default verification state**: Should verification be **enabled by default** for new deployments, or **disabled by default** (opt-in)? + +2. **Email provider preference**: Which provider should be the default in our documentation/examples? + - Resend (simplest, modern) + - AWS SES (enterprise, cost-effective) + - SMTP (universal compatibility) + +3. **Error handling**: When verification is enabled but email provider is not configured, should the server: + - Fail to start (strict validation) + - Start but reject joins with clear error message + - Fall back to no verification (with warning log) + +4. **Code complexity**: 6-digit codes give us 1 million combinations. With 5 attempts per code and 15-minute expiration, this seems secure. Do you want additional security measures (e.g., email rate limiting, IP rate limiting)? + +Let me know your preferences and I'll proceed with implementation! + +--- + +## User + +actually lets drop ses as its too complex for now, one can always use ses via smtp + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 25.2s) + +_Thinking:_ + +The user wants to drop AWS SES support and keep only Resend and SMTP, noting that SES can be used via SMTP if needed. This simplifies the implementation significantly. + +I should: +1. Acknowledge the change +2. Update the plan to remove SES +3. Ask the remaining questions to finalize the plan +4. Wait for user confirmation before proceeding to implementation + +I'm still in plan mode, so I should NOT make any edits or changes. Just present the updated plan and get answers to the remaining questions. + +Perfect! Dropping SES makes sense - it adds complexity and you're right that SES can always be used via SMTP if needed. Let me update the plan accordingly. + +## Updated Implementation Plan - Key Changes + +### 1. Email Providers (Simplified) + +| Provider | Crate | Version | Use Case | +|----------|-------|---------|----------| +| **Resend** | `resend-rs` | 0.20.0 | Modern, simple API, built-in rate limiting | +| **SMTP** | `lettre` | 0.11.19 | Universal fallback, works with any provider (Gmail, SES, Sendgrid, etc.) | + +### 2. Updated Dependencies + +```toml +# In apps/zopp-server/Cargo.toml +[dependencies] +# Email providers (both optional) +resend-rs = { version = "0.20", optional = true } +lettre = { version = "0.11", features = ["tokio1-rustls", "smtp-transport"], optional = true } + +# Random number generation for verification codes +rand = "0.9" +``` + +### 3. Updated Feature Flags + +```toml +[features] +email-resend = ["resend-rs"] +email-smtp = ["lettre"] + +# Default: SMTP for maximum compatibility +default = ["email-smtp"] +``` + +### 4. Simplified Config + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum EmailProviderConfig { + Resend { api_key: String }, + Smtp { + host: String, + port: u16, + username: Option, + password: Option, + use_tls: bool, + }, +} +``` + +### 5. Simplified Environment Variables + +```bash +# Provider selection +ZOPP_EMAIL_PROVIDER=resend # or "smtp" + +# Resend +RESEND_API_KEY=re_... + +# SMTP (works with Gmail, AWS SES SMTP, Sendgrid, etc.) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USERNAME=user@example.com +SMTP_PASSWORD=secret +SMTP_USE_TLS=true +``` + +### 6. Updated Implementation Checklist + +**Phase 3: Email Providers** now becomes: +- [ ] Add dependencies to `Cargo.toml` with feature flags (Resend + SMTP only) +- [ ] Create `apps/zopp-server/src/email/mod.rs` +- [ ] Create `apps/zopp-server/src/email/templates.rs` +- [ ] Implement Resend provider (`email/resend.rs`) +- [ ] Implement SMTP provider (`email/smtp.rs`) +- [ ] Add provider factory function +- [ ] Unit tests for email templates + +--- + +## Questions Before Implementation + +Now that we've simplified to just Resend + SMTP, I need your decisions on these key points: + +**1. Default verification state for new deployments:** + - **Option A**: Disabled by default (opt-in) - backwards compatible, no surprises + - **Option B**: Enabled by default - more secure, requires email config on first run + +**2. Email provider for documentation/examples:** + - **Option A**: Resend (simplest to set up, one env var) + - **Option B**: SMTP (universal, works with any provider) + - **Option C**: Show both in docs + +**3. Server behavior when verification is enabled but no email provider configured:** + - **Option A**: Fail to start with clear error (strict, forces correct config) + - **Option B**: Start but reject joins with helpful error message + - **Option C**: Start with warning log, allow joins without verification + +**4. Rate limiting complexity:** + - **Option A**: Basic (5 attempts per code, 3 codes per hour per email) + - **Option B**: Advanced (also track by IP, exponential backoff, CAPTCHA-like delays) + +My recommendations: +- Question 1: **Option A** (disabled by default - no breaking changes) +- Question 2: **Option C** (show both - Resend for quick setup, SMTP for flexibility) +- Question 3: **Option B** (start but reject joins - better DX for discovering misconfiguration) +- Question 4: **Option A** (basic rate limiting - sufficient for most attacks) + +What are your preferences? + +--- + +## User + +1: B\ + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 10.2s) + +_Thinking:_ + +The user answered: +1. B - Enabled by default (more secure, requires email config on first run) + +They only answered question 1 so far. I should note this and ask for the remaining questions, or proceed if they want me to make reasonable defaults for the others. + +Also, I see a system reminder that I'm in "Plan Mode" - I should be in READ-ONLY phase, only planning and analyzing, not making any edits. This is good - I was already in planning mode anyway, creating the implementation plan. + +Let me acknowledge their answer to question 1 and ask about the remaining questions, or offer to proceed with my recommendations for questions 2-4. + +Got it! **Verification enabled by default** - more secure out of the box. + +That's a good choice for security. It means new deployments will be secure by default, and users who want to disable verification (for testing/private deployments) can explicitly opt out. + +For the remaining questions (2-4), would you like to decide on those as well, or should I proceed with my recommendations? + +**My recommendations recap:** +- **Question 2** (Docs): Show both Resend and SMTP examples +- **Question 3** (Missing config): Start server but reject joins with clear error message +- **Question 4** (Rate limiting): Basic (5 attempts per code, 3 codes/hour per email) + +Or if you have different preferences for any of these, let me know! + +Once you confirm, I'll finalize the complete implementation plan and we can move to execution. + +--- + +## User + +2: c + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 9.8s) + +_Thinking:_ + +The user answered question 2 with "c", which means Option C: Show both Resend and SMTP examples in documentation. + +So far we have: +- Question 1: B (Enabled by default) +- Question 2: C (Show both in docs) + +The user hasn't answered questions 3 and 4 yet. I should wait for their input on those, or ask if they want to go with my recommendations. + +Also, I notice the system reminder is telling me I'm in "Plan Mode" and should NOT make any edits. The user said "tackle issue 46" which I interpreted as implementing it, but the system is reminding me I'm in read-only mode. I should continue planning and not execute until the user explicitly tells me to implement. + +Perfect! **Question 2: C** - Show both Resend and SMTP in docs. That gives users flexibility. + +So far we have: +- **Question 1**: B - Verification enabled by default ✓ +- **Question 2**: C - Show both Resend and SMTP in docs ✓ + +For the remaining questions: + +**Question 3** - Server behavior when verification enabled but no email provider configured: + - **Option A**: Fail to start with clear error (strict) + - **Option B**: Start but reject joins with helpful error message + - **Option C**: Start with warning, allow joins without verification + +**Question 4** - Rate limiting complexity: + - **Option A**: Basic (5 attempts per code, 3 codes/hour per email) + - **Option B**: Advanced (IP tracking, exponential backoff, etc.) + +What are your preferences for 3 and 4? + +--- + +## User + +3: A. 4: A + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 30.6s) + +_Thinking:_ + +The user has answered all the questions: +1. Question 1: B - Verification enabled by default +2. Question 2: C - Show both Resend and SMTP in docs +3. Question 3: A - Fail to start with clear error if verification enabled but no email provider configured +4. Question 4: A - Basic rate limiting (5 attempts per code, 3 codes/hour per email) + +Now I need to finalize the complete implementation plan with these decisions incorporated. The system reminder is telling me I'm in plan mode and should NOT make any edits or modifications - only read, analyze, and plan. + +Let me create a comprehensive final plan that's ready for execution. + +Perfect! All decisions made: +- **Question 1**: B - Verification enabled by default ✓ +- **Question 2**: C - Show both Resend and SMTP in docs ✓ +- **Question 3**: A - Fail to start if verification enabled but no email provider configured ✓ +- **Question 4**: A - Basic rate limiting ✓ + +Let me finalize the complete implementation plan with these decisions incorporated: + +--- + +# Final Implementation Plan: Email Verification (Issue #46) + +## Configuration Summary + +1. ✅ **Verification enabled by default** - secure out of the box +2. ✅ **Two email providers**: Resend + SMTP (dropped AWS SES) +3. ✅ **Strict validation** - server fails to start if verification enabled but no provider configured +4. ✅ **Basic rate limiting** - 5 attempts per code, 3 codes per hour per email +5. ✅ **6-digit codes** - expire in 15 minutes +6. ✅ **Configurable** - can be disabled via `ZOPP_EMAIL_VERIFICATION_REQUIRED=false` + +--- + +## Implementation Task List + +``` +Tool: todowrite + +**Input:** +```json +{ + "todos": [ + { + "id": "phase1-1", + "content": "Create SQLite migration 20260121000001_add_email_verification.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-2", + "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-3", + "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-4", + "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-5", + "content": "Add EmailVerification struct and methods to zopp-storage trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-6", + "content": "Implement email verification methods in SQLite store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-7", + "content": "Implement email verification methods in PostgreSQL store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-8", + "content": "Run sqlx prepare for both stores", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-9", + "content": "Write unit tests for storage methods", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase2-1", + "content": "Create apps/zopp-server/src/config.rs with ServerConfig", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-2", + "content": "Add config validation (fail if verification enabled but no provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-3", + "content": "Add environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-1", + "content": "Add Resend and SMTP dependencies to Cargo.toml with features", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-2", + "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-3", + "content": "Create apps/zopp-server/src/email/templates.rs with email templates", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-4", + "content": "Implement Resend provider (email/resend.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-5", + "content": "Implement SMTP provider (email/smtp.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-6", + "content": "Add provider factory function with validation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-1", + "content": "Update zopp.proto with VerifyEmailRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-2", + "content": "Update zopp.proto with ResendVerificationRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-3", + "content": "Add verification_required field to JoinResponse", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-4", + "content": "Regenerate proto code (cargo build)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-1", + "content": "Modify join handler to support verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-2", + "content": "Implement verify_email handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-3", + "content": "Implement resend_verification handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-4", + "content": "Add background cleanup task for expired verifications", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase5-5", + "content": "Add authentication bypass for verify_email RPC", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-1", + "content": "Modify CLI cmd_join to handle verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-2", + "content": "Add code input prompt and retry logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-3", + "content": "Add resend option during verification", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-1", + "content": "E2E test: Join with verification enabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-2", + "content": "E2E test: Join with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-3", + "content": "E2E test: Failed verification (wrong code)", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-4", + "content": "E2E test: Expired verification code", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-5", + "content": "E2E test: Too many attempts", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-6", + "content": "E2E test: Resend verification code", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-1", + "content": "Update CLAUDE.md with email configuration", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-2", + "content": "Create docs/docs/guides/email-verification.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-3", + "content": "Update docs/docs/reference/cli/join.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase9-1", + "content": "Run full test suite", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-2", + "content": "Run clippy and fmt", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-3", + "content": "Create PR and monitor CI", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-4", + "content": "Address Cubic review comments", + "status": "pending", + "priority": "high" + } + ] +} +``` +**Output:** +``` +[ + { + "id": "phase1-1", + "content": "Create SQLite migration 20260121000001_add_email_verification.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-2", + "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-3", + "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-4", + "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-5", + "content": "Add EmailVerification struct and methods to zopp-storage trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-6", + "content": "Implement email verification methods in SQLite store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-7", + "content": "Implement email verification methods in PostgreSQL store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-8", + "content": "Run sqlx prepare for both stores", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-9", + "content": "Write unit tests for storage methods", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase2-1", + "content": "Create apps/zopp-server/src/config.rs with ServerConfig", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-2", + "content": "Add config validation (fail if verification enabled but no provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-3", + "content": "Add environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-1", + "content": "Add Resend and SMTP dependencies to Cargo.toml with features", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-2", + "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-3", + "content": "Create apps/zopp-server/src/email/templates.rs with email templates", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-4", + "content": "Implement Resend provider (email/resend.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-5", + "content": "Implement SMTP provider (email/smtp.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-6", + "content": "Add provider factory function with validation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-1", + "content": "Update zopp.proto with VerifyEmailRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-2", + "content": "Update zopp.proto with ResendVerificationRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-3", + "content": "Add verification_required field to JoinResponse", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-4", + "content": "Regenerate proto code (cargo build)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-1", + "content": "Modify join handler to support verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-2", + "content": "Implement verify_email handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-3", + "content": "Implement resend_verification handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-4", + "content": "Add background cleanup task for expired verifications", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase5-5", + "content": "Add authentication bypass for verify_email RPC", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-1", + "content": "Modify CLI cmd_join to handle verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-2", + "content": "Add code input prompt and retry logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-3", + "content": "Add resend option during verification", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-1", + "content": "E2E test: Join with verification enabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-2", + "content": "E2E test: Join with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-3", + "content": "E2E test: Failed verification (wrong code)", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-4", + "content": "E2E test: Expired verification code", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-5", + "content": "E2E test: Too many attempts", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-6", + "content": "E2E test: Resend verification code", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-1", + "content": "Update CLAUDE.md with email configuration", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-2", + "content": "Create docs/docs/guides/email-verification.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-3", + "content": "Update docs/docs/reference/cli/join.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase9-1", + "content": "Run full test suite", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-2", + "content": "Run clippy and fmt", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-3", + "content": "Create PR and monitor CI", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-4", + "content": "Address Cubic review comments", + "status": "pending", + "priority": "high" + } +] +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 26.9s) + +_Thinking:_ + +Good! I've created the todo list with all the implementation tasks. Now let me provide a final summary of the plan with all the key design decisions, implementation details, and a clear specification that can be used for execution. + +Perfect! I've created a comprehensive task list with 43 tasks across 9 phases. Here's the final implementation plan: + +--- + +## Final Implementation Specification + +### Design Decisions Summary + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| **Verification default** | Enabled | Secure by default | +| **Email providers** | Resend + SMTP | Resend for simplicity, SMTP for universality (dropped SES) | +| **Config validation** | Fail to start | Strict - prevents misconfiguration | +| **Rate limiting** | Basic | 5 attempts/code, 3 codes/hour per email | +| **Code format** | 6-digit | 1M combinations, CLI-friendly | +| **Code expiration** | 15 minutes | Balance security vs UX | + +--- + +### Key Technical Specifications + +#### 1. New Database Tables + +**`email_verifications`** +- Stores verification codes with expiration +- Tracks attempts to prevent brute force +- Indexed by email for fast lookup +- Indexed by expires_at for cleanup + +**`principals.verified`** (new column) +- BOOLEAN/INTEGER flag +- Existing principals marked as verified (backwards compat) +- New principals start as unverified (if verification enabled) + +#### 2. Configuration Structure + +```bash +# Core settings +ZOPP_EMAIL_VERIFICATION_REQUIRED=true # enabled by default + +# Provider: Resend +ZOPP_EMAIL_PROVIDER=resend +RESEND_API_KEY=re_... + +# Provider: SMTP (works with Gmail, AWS SES SMTP, Sendgrid, etc.) +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" +``` + +#### 3. New gRPC RPCs (Unauthenticated) + +```protobuf +rpc VerifyEmail(VerifyEmailRequest) returns (VerifyEmailResponse); +rpc ResendVerification(ResendVerificationRequest) returns (ResendVerificationResponse); +``` + +#### 4. Modified Join Flow + +``` +Before: +User → Join RPC → Create User+Principal → Save credentials → Done + +After (verification enabled): +User → Join RPC → Create User+Principal(unverified) → Send email → Return verification_required=true + → CLI prompts for code → VerifyEmail RPC → Mark principal verified → Save credentials → Done +``` + +#### 5. Security Features + +- **Cryptographically secure** 6-digit codes (using `rand` crate) +- **Constant-time comparison** to prevent timing attacks +- **Rate limiting**: Max 5 attempts per code, max 3 codes per hour per email +- **Automatic cleanup** of expired verifications (background task) +- **No email enumeration**: Same response time whether email exists or not + +#### 6. Backwards Compatibility + +- ✅ Verification can be disabled: `ZOPP_EMAIL_VERIFICATION_REQUIRED=false` +- ✅ Existing principals automatically marked as verified in migration +- ✅ Bootstrap invites work without verification (server setup unchanged) +- ✅ No breaking changes to existing APIs + +--- + +## Plan Summary + +**Total Tasks**: 43 tasks across 9 phases +- **Phase 1**: Database migrations & storage layer (9 tasks) +- **Phase 2**: Configuration (3 tasks) +- **Phase 3**: Email providers (6 tasks) +- **Phase 4**: Proto changes (4 tasks) +- **Phase 5**: Server handlers (5 tasks) +- **Phase 6**: CLI changes (3 tasks) +- **Phase 7**: E2E testing (6 tasks) +- **Phase 8**: Documentation (3 tasks) +- **Phase 9**: PR & Review (4 tasks) + +--- + +## Ready to Proceed? + +The plan is complete and ready for execution. The implementation will: + +1. ✅ Add email verification with 6-digit codes +2. ✅ Support Resend and SMTP providers +3. ✅ Enable verification by default (configurable) +4. ✅ Fail server start if misconfigured +5. ✅ Maintain full backwards compatibility +6. ✅ Include comprehensive tests and documentation + +**Shall I proceed with implementation?** Just let me know and I'll start executing the tasks in order! + +--- + +## User + +no unit tests? only e2e? + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 16.0s) + +_Thinking:_ + +The user is asking about unit tests - they noticed I only have E2E tests in the plan but no unit tests. Let me review what I included: + +In Phase 1, I have: +- "Write unit tests for storage methods" (task phase1-9) + +But looking at the rest of the plan, I don't see unit tests for: +- Email providers (only mentioned "Unit tests for email templates" but I don't see it in the task list!) +- Code generation +- Rate limiting logic +- Email sending logic +- Configuration parsing/validation + +The user is right to call this out. According to TESTING.md, the project aims for high test coverage with both unit tests AND E2E tests. I should add comprehensive unit test tasks. + +Let me identify what needs unit tests: +1. Storage layer (already have this - phase1-9) +2. Email templates (I mentioned this in the text but didn't add to task list!) +3. Code generation (cryptographically secure random codes) +4. Rate limiting logic +5. Email provider implementations (mocked) +6. Configuration parsing and validation +7. Verification code validation logic (constant-time comparison) +8. Email sending abstraction layer + +I need to update the plan to include proper unit test tasks. + +You're absolutely right! I mentioned unit tests in the plan text but didn't include nearly enough in the actual task list. According to `TESTING.md`, the project aims for high test coverage with both unit tests AND E2E tests. + +Looking at what I have, I only included: +- `phase1-9`: Unit tests for storage methods + +But I'm missing unit tests for many critical components: + +### Missing Unit Tests + +1. **Email templates** - Test text and HTML generation +2. **Code generation** - Test cryptographic randomness, format validation +3. **Rate limiting logic** - Test attempt tracking, expiration logic +4. **Email provider implementations** - Test with mocked SMTP/Resend APIs +5. **Configuration parsing** - Test env var parsing, validation +6. **Configuration validation** - Test fail-fast when misconfigured +7. **Verification code validation** - Test constant-time comparison +8. **Email provider factory** - Test provider selection logic +9. **Background cleanup task** - Test expired verification cleanup + +Should I add comprehensive unit test tasks to the plan? Something like: + +**Additional Phase 1 tasks:** +- Unit tests for storage methods ✓ (already have) +- Unit tests for verification code expiration logic +- Unit tests for attempt counting + +**Additional Phase 3 tasks:** +- Unit tests for email templates (text + HTML) +- Unit tests for code generation (format, randomness) +- Unit tests for Resend provider (mocked API) +- Unit tests for SMTP provider (mocked transport) +- Unit tests for provider factory + +**Additional Phase 5 tasks:** +- Unit tests for rate limiting logic +- Unit tests for verification code validation (constant-time comparison) +- Unit tests for background cleanup task + +Would you like me to update the task list with these unit test tasks? + +--- + +## User + +yes + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 37.9s) + +_Thinking:_ + +The user is asking me to add comprehensive unit tests to the plan. I'm in PLAN MODE which means I should NOT execute anything, only plan and update the task list. + +I should update the todo list to include all the missing unit tests I identified. Let me organize them properly by phase. + +Perfect! Let me update the task list with comprehensive unit tests: + +``` +Tool: todowrite + +**Input:** +```json +{ + "todos": [ + { + "id": "phase1-1", + "content": "Create SQLite migration 20260121000001_add_email_verification.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-2", + "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-3", + "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-4", + "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-5", + "content": "Add EmailVerification struct and methods to zopp-storage trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-6", + "content": "Implement email verification methods in SQLite store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-7", + "content": "Implement email verification methods in PostgreSQL store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-8", + "content": "Run sqlx prepare for both stores", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-9", + "content": "Unit tests: Storage create_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-10", + "content": "Unit tests: Storage get_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-11", + "content": "Unit tests: Storage increment_verification_attempts", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-12", + "content": "Unit tests: Storage mark_verification_verified", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-13", + "content": "Unit tests: Storage delete_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-14", + "content": "Unit tests: Storage cleanup_expired_verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-15", + "content": "Unit tests: Verification code expiration logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-16", + "content": "Unit tests: Attempt counting and limits", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-1", + "content": "Create apps/zopp-server/src/config.rs with ServerConfig", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-2", + "content": "Add config validation (fail if verification enabled but no provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-3", + "content": "Add environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-4", + "content": "Unit tests: Config environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-5", + "content": "Unit tests: Config validation (missing provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-6", + "content": "Unit tests: Config validation (invalid SMTP config)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-7", + "content": "Unit tests: Config defaults and optional fields", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-1", + "content": "Add Resend and SMTP dependencies to Cargo.toml with features", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-2", + "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-3", + "content": "Create apps/zopp-server/src/email/templates.rs with email templates", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-4", + "content": "Implement Resend provider (email/resend.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-5", + "content": "Implement SMTP provider (email/smtp.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-6", + "content": "Add provider factory function with validation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-7", + "content": "Create code generation utility (cryptographically secure 6-digit)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-8", + "content": "Unit tests: Email templates text generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-9", + "content": "Unit tests: Email templates HTML generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-10", + "content": "Unit tests: Code generation format validation (6 digits)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-11", + "content": "Unit tests: Code generation randomness (no duplicates in sample)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-12", + "content": "Unit tests: Resend provider with mocked API", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-13", + "content": "Unit tests: SMTP provider with mocked transport", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-14", + "content": "Unit tests: Provider factory selection logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-15", + "content": "Unit tests: Provider factory error on invalid config", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-1", + "content": "Update zopp.proto with VerifyEmailRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-2", + "content": "Update zopp.proto with ResendVerificationRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-3", + "content": "Add verification_required field to JoinResponse", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-4", + "content": "Regenerate proto code (cargo build)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-1", + "content": "Modify join handler to support verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-2", + "content": "Implement verify_email handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-3", + "content": "Implement resend_verification handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-4", + "content": "Add background cleanup task for expired verifications", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase5-5", + "content": "Add authentication bypass for verify_email RPC", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-6", + "content": "Unit tests: Rate limiting logic (5 attempts per code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-7", + "content": "Unit tests: Rate limiting (3 codes per hour per email)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-8", + "content": "Unit tests: Verification code validation (constant-time comparison)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-9", + "content": "Unit tests: Background cleanup task removes expired verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-10", + "content": "Unit tests: Join handler with verification enabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-11", + "content": "Unit tests: Join handler with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-12", + "content": "Unit tests: VerifyEmail handler success case", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-13", + "content": "Unit tests: VerifyEmail handler wrong code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-14", + "content": "Unit tests: ResendVerification handler", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-1", + "content": "Modify CLI cmd_join to handle verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-2", + "content": "Add code input prompt and retry logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-3", + "content": "Add resend option during verification", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-1", + "content": "E2E test: Join with verification enabled (happy path)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-2", + "content": "E2E test: Join with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-3", + "content": "E2E test: Failed verification (wrong code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-4", + "content": "E2E test: Expired verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-5", + "content": "E2E test: Too many attempts (rate limiting)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-6", + "content": "E2E test: Resend verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-7", + "content": "E2E test: Workspace invite with verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-8", + "content": "E2E test: Bootstrap invite (should bypass verification)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase8-1", + "content": "Update CLAUDE.md with email configuration", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-2", + "content": "Create docs/docs/guides/email-verification.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-3", + "content": "Update docs/docs/reference/cli/join.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-4", + "content": "Add troubleshooting guide for email delivery issues", + "status": "pending", + "priority": "low" + }, + { + "id": "phase9-1", + "content": "Run full test suite", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-2", + "content": "Run clippy and fmt", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-3", + "content": "Create PR and monitor CI", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-4", + "content": "Address Cubic review comments", + "status": "pending", + "priority": "high" + } + ] +} +``` +**Output:** +``` +[ + { + "id": "phase1-1", + "content": "Create SQLite migration 20260121000001_add_email_verification.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-2", + "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-3", + "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-4", + "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-5", + "content": "Add EmailVerification struct and methods to zopp-storage trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-6", + "content": "Implement email verification methods in SQLite store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-7", + "content": "Implement email verification methods in PostgreSQL store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-8", + "content": "Run sqlx prepare for both stores", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-9", + "content": "Unit tests: Storage create_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-10", + "content": "Unit tests: Storage get_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-11", + "content": "Unit tests: Storage increment_verification_attempts", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-12", + "content": "Unit tests: Storage mark_verification_verified", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-13", + "content": "Unit tests: Storage delete_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-14", + "content": "Unit tests: Storage cleanup_expired_verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-15", + "content": "Unit tests: Verification code expiration logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-16", + "content": "Unit tests: Attempt counting and limits", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-1", + "content": "Create apps/zopp-server/src/config.rs with ServerConfig", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-2", + "content": "Add config validation (fail if verification enabled but no provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-3", + "content": "Add environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-4", + "content": "Unit tests: Config environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-5", + "content": "Unit tests: Config validation (missing provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-6", + "content": "Unit tests: Config validation (invalid SMTP config)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-7", + "content": "Unit tests: Config defaults and optional fields", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-1", + "content": "Add Resend and SMTP dependencies to Cargo.toml with features", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-2", + "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-3", + "content": "Create apps/zopp-server/src/email/templates.rs with email templates", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-4", + "content": "Implement Resend provider (email/resend.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-5", + "content": "Implement SMTP provider (email/smtp.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-6", + "content": "Add provider factory function with validation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-7", + "content": "Create code generation utility (cryptographically secure 6-digit)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-8", + "content": "Unit tests: Email templates text generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-9", + "content": "Unit tests: Email templates HTML generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-10", + "content": "Unit tests: Code generation format validation (6 digits)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-11", + "content": "Unit tests: Code generation randomness (no duplicates in sample)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-12", + "content": "Unit tests: Resend provider with mocked API", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-13", + "content": "Unit tests: SMTP provider with mocked transport", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-14", + "content": "Unit tests: Provider factory selection logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-15", + "content": "Unit tests: Provider factory error on invalid config", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-1", + "content": "Update zopp.proto with VerifyEmailRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-2", + "content": "Update zopp.proto with ResendVerificationRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-3", + "content": "Add verification_required field to JoinResponse", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-4", + "content": "Regenerate proto code (cargo build)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-1", + "content": "Modify join handler to support verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-2", + "content": "Implement verify_email handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-3", + "content": "Implement resend_verification handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-4", + "content": "Add background cleanup task for expired verifications", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase5-5", + "content": "Add authentication bypass for verify_email RPC", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-6", + "content": "Unit tests: Rate limiting logic (5 attempts per code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-7", + "content": "Unit tests: Rate limiting (3 codes per hour per email)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-8", + "content": "Unit tests: Verification code validation (constant-time comparison)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-9", + "content": "Unit tests: Background cleanup task removes expired verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-10", + "content": "Unit tests: Join handler with verification enabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-11", + "content": "Unit tests: Join handler with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-12", + "content": "Unit tests: VerifyEmail handler success case", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-13", + "content": "Unit tests: VerifyEmail handler wrong code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-14", + "content": "Unit tests: ResendVerification handler", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-1", + "content": "Modify CLI cmd_join to handle verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-2", + "content": "Add code input prompt and retry logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-3", + "content": "Add resend option during verification", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-1", + "content": "E2E test: Join with verification enabled (happy path)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-2", + "content": "E2E test: Join with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-3", + "content": "E2E test: Failed verification (wrong code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-4", + "content": "E2E test: Expired verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-5", + "content": "E2E test: Too many attempts (rate limiting)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-6", + "content": "E2E test: Resend verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-7", + "content": "E2E test: Workspace invite with verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-8", + "content": "E2E test: Bootstrap invite (should bypass verification)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase8-1", + "content": "Update CLAUDE.md with email configuration", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-2", + "content": "Create docs/docs/guides/email-verification.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-3", + "content": "Update docs/docs/reference/cli/join.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-4", + "content": "Add troubleshooting guide for email delivery issues", + "status": "pending", + "priority": "low" + }, + { + "id": "phase9-1", + "content": "Run full test suite", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-2", + "content": "Run clippy and fmt", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-3", + "content": "Create PR and monitor CI", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-4", + "content": "Address Cubic review comments", + "status": "pending", + "priority": "high" + } +] +``` +``` + +--- + +## Assistant (Plan · claude-sonnet-4-5 · 24.6s) + +_Thinking:_ + +Great! I've updated the task list with comprehensive unit tests. Let me summarize the changes for the user. + +Excellent! I've updated the plan with comprehensive unit tests. Here's the summary: + +## Updated Implementation Plan - Now with Comprehensive Testing + +**Total Tasks**: 76 tasks (was 43) across 9 phases + +### Test Coverage Breakdown + +#### Phase 1: Database & Storage (16 tasks, +7 unit tests) +- 8 implementation tasks +- **8 unit test tasks**: + - All storage methods (create, get, increment, mark, delete, cleanup) + - Expiration logic + - Attempt counting and limits + +#### Phase 2: Configuration (7 tasks, +4 unit tests) +- 3 implementation tasks +- **4 unit test tasks**: + - Environment variable parsing + - Validation (missing provider) + - Validation (invalid SMTP config) + - Defaults and optional fields + +#### Phase 3: Email Providers (15 tasks, +8 unit tests) +- 7 implementation tasks +- **8 unit test tasks**: + - Email templates (text + HTML) + - Code generation (format validation, randomness) + - Resend provider (mocked API) + - SMTP provider (mocked transport) + - Provider factory (selection logic, error handling) + +#### Phase 4: Proto Changes (4 tasks) +- No unit tests needed (code generation) + +#### Phase 5: Server Handlers (14 tasks, +9 unit tests) +- 5 implementation tasks +- **9 unit test tasks**: + - Rate limiting (5 attempts per code, 3 codes per hour) + - Constant-time code comparison + - Background cleanup task + - Join handler (enabled/disabled) + - VerifyEmail handler (success/wrong code) + - ResendVerification handler + +#### Phase 6: CLI Changes (3 tasks) +- 3 implementation tasks +- E2E tests will cover CLI behavior + +#### Phase 7: E2E Tests (8 tasks, +2 additional scenarios) +- Join with verification enabled/disabled +- Failed verification +- Expired code +- Too many attempts +- Resend verification +- **Workspace invite with verification** +- **Bootstrap invite (should bypass verification)** + +#### Phase 8: Documentation (4 tasks) +- CLAUDE.md updates +- Guide creation +- CLI reference +- Troubleshooting + +#### Phase 9: PR & Review (4 tasks) +- Test suite +- Clippy/fmt +- PR creation +- Cubic review + +--- + +## Test Philosophy (per TESTING.md) + +✅ **Use real implementations** - Tests use real SQLite/PostgreSQL, not mocks +✅ **Mock only for external services** - Mock Resend API and SMTP transport +✅ **100% coverage goal** - Every new function has unit tests +✅ **E2E for user-facing features** - Full join flow with verification +✅ **Security-critical code** - Constant-time comparison, rate limiting well-tested + +--- + +## Key Testing Additions + +### Security-Critical Tests +- **Constant-time code comparison** - Prevent timing attacks +- **Rate limiting** - Both per-code and per-email-per-hour +- **Code generation randomness** - Ensure no predictable patterns + +### Provider Tests +- **Mocked external APIs** - Test error handling without hitting real services +- **Template validation** - Ensure emails render correctly + +### Storage Tests +- **Both SQLite and PostgreSQL** - Full coverage of both backends +- **Expiration logic** - Ensure cleanup works correctly +- **Attempt tracking** - Verify rate limiting at storage layer + +--- + +## Ready to Proceed? + +The plan is now complete with **76 comprehensive tasks** including: +- **29 unit test tasks** covering all critical logic +- **8 E2E test tasks** covering user-facing flows +- Full implementation of email verification with Resend + SMTP + +The testing approach follows the project's `TESTING.md` guidelines: +- Real implementations where possible +- Mocks only for external services +- Aiming for high coverage +- Both unit and E2E tests + +**Shall I proceed with implementation, or do you have any other questions/concerns about the plan?** + +--- + +## User + +proceed + +--- + +## Assistant (Build · claude-opus-4-5 · 31.9s) + + +The user has approved the plan and my operational mode has changed to build mode. I can now start implementing the email verification feature. + +Let me start executing the tasks in order, beginning with Phase 1: Database & Storage. + +I'll start with: +1. Create SQLite migration for email_verifications table +2. Create PostgreSQL migration for email_verifications table +3. Create SQLite migration for principal verified column +4. Create PostgreSQL migration for principal verified column + +Let me first check the current migration files to understand the naming convention and latest migration number. + + +Let's begin implementing the email verification feature! I'll start with Phase 1: Database migrations and storage layer. + +``` +Tool: todowrite + +**Input:** +```json +{ + "todos": [ + { + "id": "phase1-1", + "content": "Create SQLite migration 20260121000001_add_email_verification.sql", + "status": "in_progress", + "priority": "high" + }, + { + "id": "phase1-2", + "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-3", + "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-4", + "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-5", + "content": "Add EmailVerification struct and methods to zopp-storage trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-6", + "content": "Implement email verification methods in SQLite store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-7", + "content": "Implement email verification methods in PostgreSQL store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-8", + "content": "Run sqlx prepare for both stores", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-9", + "content": "Unit tests: Storage create_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-10", + "content": "Unit tests: Storage get_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-11", + "content": "Unit tests: Storage increment_verification_attempts", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-12", + "content": "Unit tests: Storage mark_verification_verified", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-13", + "content": "Unit tests: Storage delete_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-14", + "content": "Unit tests: Storage cleanup_expired_verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-15", + "content": "Unit tests: Verification code expiration logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-16", + "content": "Unit tests: Attempt counting and limits", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-1", + "content": "Create apps/zopp-server/src/config.rs with ServerConfig", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-2", + "content": "Add config validation (fail if verification enabled but no provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-3", + "content": "Add environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-4", + "content": "Unit tests: Config environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-5", + "content": "Unit tests: Config validation (missing provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-6", + "content": "Unit tests: Config validation (invalid SMTP config)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-7", + "content": "Unit tests: Config defaults and optional fields", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-1", + "content": "Add Resend and SMTP dependencies to Cargo.toml with features", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-2", + "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-3", + "content": "Create apps/zopp-server/src/email/templates.rs with email templates", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-4", + "content": "Implement Resend provider (email/resend.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-5", + "content": "Implement SMTP provider (email/smtp.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-6", + "content": "Add provider factory function with validation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-7", + "content": "Create code generation utility (cryptographically secure 6-digit)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-8", + "content": "Unit tests: Email templates text generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-9", + "content": "Unit tests: Email templates HTML generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-10", + "content": "Unit tests: Code generation format validation (6 digits)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-11", + "content": "Unit tests: Code generation randomness (no duplicates in sample)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-12", + "content": "Unit tests: Resend provider with mocked API", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-13", + "content": "Unit tests: SMTP provider with mocked transport", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-14", + "content": "Unit tests: Provider factory selection logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-15", + "content": "Unit tests: Provider factory error on invalid config", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-1", + "content": "Update zopp.proto with VerifyEmailRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-2", + "content": "Update zopp.proto with ResendVerificationRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-3", + "content": "Add verification_required field to JoinResponse", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-4", + "content": "Regenerate proto code (cargo build)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-1", + "content": "Modify join handler to support verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-2", + "content": "Implement verify_email handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-3", + "content": "Implement resend_verification handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-4", + "content": "Add background cleanup task for expired verifications", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase5-5", + "content": "Add authentication bypass for verify_email RPC", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-6", + "content": "Unit tests: Rate limiting logic (5 attempts per code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-7", + "content": "Unit tests: Rate limiting (3 codes per hour per email)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-8", + "content": "Unit tests: Verification code validation (constant-time comparison)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-9", + "content": "Unit tests: Background cleanup task removes expired verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-10", + "content": "Unit tests: Join handler with verification enabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-11", + "content": "Unit tests: Join handler with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-12", + "content": "Unit tests: VerifyEmail handler success case", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-13", + "content": "Unit tests: VerifyEmail handler wrong code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-14", + "content": "Unit tests: ResendVerification handler", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-1", + "content": "Modify CLI cmd_join to handle verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-2", + "content": "Add code input prompt and retry logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-3", + "content": "Add resend option during verification", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-1", + "content": "E2E test: Join with verification enabled (happy path)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-2", + "content": "E2E test: Join with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-3", + "content": "E2E test: Failed verification (wrong code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-4", + "content": "E2E test: Expired verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-5", + "content": "E2E test: Too many attempts (rate limiting)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-6", + "content": "E2E test: Resend verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-7", + "content": "E2E test: Workspace invite with verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-8", + "content": "E2E test: Bootstrap invite (should bypass verification)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase8-1", + "content": "Update CLAUDE.md with email configuration", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-2", + "content": "Create docs/docs/guides/email-verification.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-3", + "content": "Update docs/docs/reference/cli/join.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-4", + "content": "Add troubleshooting guide for email delivery issues", + "status": "pending", + "priority": "low" + }, + { + "id": "phase9-1", + "content": "Run full test suite", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-2", + "content": "Run clippy and fmt", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-3", + "content": "Create PR and monitor CI", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-4", + "content": "Address Cubic review comments", + "status": "pending", + "priority": "high" + } + ] +} +``` +**Output:** +``` +[ + { + "id": "phase1-1", + "content": "Create SQLite migration 20260121000001_add_email_verification.sql", + "status": "in_progress", + "priority": "high" + }, + { + "id": "phase1-2", + "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-3", + "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-4", + "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-5", + "content": "Add EmailVerification struct and methods to zopp-storage trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-6", + "content": "Implement email verification methods in SQLite store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-7", + "content": "Implement email verification methods in PostgreSQL store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-8", + "content": "Run sqlx prepare for both stores", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-9", + "content": "Unit tests: Storage create_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-10", + "content": "Unit tests: Storage get_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-11", + "content": "Unit tests: Storage increment_verification_attempts", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-12", + "content": "Unit tests: Storage mark_verification_verified", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-13", + "content": "Unit tests: Storage delete_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-14", + "content": "Unit tests: Storage cleanup_expired_verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-15", + "content": "Unit tests: Verification code expiration logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-16", + "content": "Unit tests: Attempt counting and limits", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-1", + "content": "Create apps/zopp-server/src/config.rs with ServerConfig", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-2", + "content": "Add config validation (fail if verification enabled but no provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-3", + "content": "Add environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-4", + "content": "Unit tests: Config environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-5", + "content": "Unit tests: Config validation (missing provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-6", + "content": "Unit tests: Config validation (invalid SMTP config)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-7", + "content": "Unit tests: Config defaults and optional fields", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-1", + "content": "Add Resend and SMTP dependencies to Cargo.toml with features", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-2", + "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-3", + "content": "Create apps/zopp-server/src/email/templates.rs with email templates", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-4", + "content": "Implement Resend provider (email/resend.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-5", + "content": "Implement SMTP provider (email/smtp.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-6", + "content": "Add provider factory function with validation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-7", + "content": "Create code generation utility (cryptographically secure 6-digit)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-8", + "content": "Unit tests: Email templates text generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-9", + "content": "Unit tests: Email templates HTML generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-10", + "content": "Unit tests: Code generation format validation (6 digits)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-11", + "content": "Unit tests: Code generation randomness (no duplicates in sample)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-12", + "content": "Unit tests: Resend provider with mocked API", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-13", + "content": "Unit tests: SMTP provider with mocked transport", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-14", + "content": "Unit tests: Provider factory selection logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-15", + "content": "Unit tests: Provider factory error on invalid config", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-1", + "content": "Update zopp.proto with VerifyEmailRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-2", + "content": "Update zopp.proto with ResendVerificationRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-3", + "content": "Add verification_required field to JoinResponse", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-4", + "content": "Regenerate proto code (cargo build)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-1", + "content": "Modify join handler to support verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-2", + "content": "Implement verify_email handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-3", + "content": "Implement resend_verification handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-4", + "content": "Add background cleanup task for expired verifications", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase5-5", + "content": "Add authentication bypass for verify_email RPC", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-6", + "content": "Unit tests: Rate limiting logic (5 attempts per code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-7", + "content": "Unit tests: Rate limiting (3 codes per hour per email)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-8", + "content": "Unit tests: Verification code validation (constant-time comparison)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-9", + "content": "Unit tests: Background cleanup task removes expired verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-10", + "content": "Unit tests: Join handler with verification enabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-11", + "content": "Unit tests: Join handler with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-12", + "content": "Unit tests: VerifyEmail handler success case", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-13", + "content": "Unit tests: VerifyEmail handler wrong code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-14", + "content": "Unit tests: ResendVerification handler", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-1", + "content": "Modify CLI cmd_join to handle verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-2", + "content": "Add code input prompt and retry logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-3", + "content": "Add resend option during verification", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-1", + "content": "E2E test: Join with verification enabled (happy path)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-2", + "content": "E2E test: Join with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-3", + "content": "E2E test: Failed verification (wrong code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-4", + "content": "E2E test: Expired verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-5", + "content": "E2E test: Too many attempts (rate limiting)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-6", + "content": "E2E test: Resend verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-7", + "content": "E2E test: Workspace invite with verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-8", + "content": "E2E test: Bootstrap invite (should bypass verification)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase8-1", + "content": "Update CLAUDE.md with email configuration", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-2", + "content": "Create docs/docs/guides/email-verification.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-3", + "content": "Update docs/docs/reference/cli/join.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-4", + "content": "Add troubleshooting guide for email delivery issues", + "status": "pending", + "priority": "low" + }, + { + "id": "phase9-1", + "content": "Run full test suite", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-2", + "content": "Run clippy and fmt", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-3", + "content": "Create PR and monitor CI", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-4", + "content": "Address Cubic review comments", + "status": "pending", + "priority": "high" + } +] +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 16.1s) + + +Now let me start implementing. First, I'll create the database migrations. Let me create all 4 migration files (SQLite and PostgreSQL for both email_verifications table and principal verified column). + + +``` +Tool: write + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260121000001_add_email_verification.sql", + "content": "-- Email verification table for storing verification codes\nCREATE TABLE IF NOT EXISTS email_verifications (\n id TEXT PRIMARY KEY NOT NULL, -- UUID string\n email TEXT NOT NULL, -- Email being verified (lowercased)\n code TEXT NOT NULL, -- 6-digit verification code\n attempts INTEGER NOT NULL DEFAULT 0, -- Failed verification attempts\n created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),\n expires_at TEXT NOT NULL -- 15 minutes from created_at\n);\n\n-- Index for lookup by email (most common query)\nCREATE INDEX idx_email_verifications_email ON email_verifications(email);\n\n-- Index for cleanup of expired verifications\nCREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at);\n" +} +``` +**Output:** +``` +Wrote file successfully. +``` +``` + +``` +Tool: write + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260121000001_add_email_verification.sql", + "content": "-- Email verification table for storing verification codes\nCREATE TABLE IF NOT EXISTS email_verifications (\n id UUID PRIMARY KEY NOT NULL,\n email TEXT NOT NULL, -- Email being verified (lowercased)\n code TEXT NOT NULL, -- 6-digit verification code\n attempts INTEGER NOT NULL DEFAULT 0, -- Failed verification attempts\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n expires_at TIMESTAMPTZ NOT NULL -- 15 minutes from created_at\n);\n\n-- Index for lookup by email (most common query)\nCREATE INDEX idx_email_verifications_email ON email_verifications(email);\n\n-- Index for cleanup of expired verifications\nCREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at);\n" +} +``` +**Output:** +``` +Wrote file successfully. +``` +``` + +``` +Tool: write + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260121000002_add_principal_verified.sql", + "content": "-- Add verified flag to principals table\n-- Existing principals are marked as verified (backwards compatibility)\nALTER TABLE principals ADD COLUMN verified INTEGER NOT NULL DEFAULT 1;\n" +} +``` +**Output:** +``` +Wrote file successfully. +``` +``` + +``` +Tool: write + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260121000002_add_principal_verified.sql", + "content": "-- Add verified flag to principals table\n-- Existing principals are marked as verified (backwards compatibility)\nALTER TABLE principals ADD COLUMN verified BOOLEAN NOT NULL DEFAULT TRUE;\n" +} +``` +**Output:** +``` +Wrote file successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 25.9s) + +``` +Tool: todowrite + +**Input:** +```json +{ + "todos": [ + { + "id": "phase1-1", + "content": "Create SQLite migration 20260121000001_add_email_verification.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-2", + "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-3", + "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-4", + "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-5", + "content": "Add EmailVerification struct and methods to zopp-storage trait", + "status": "in_progress", + "priority": "high" + }, + { + "id": "phase1-6", + "content": "Implement email verification methods in SQLite store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-7", + "content": "Implement email verification methods in PostgreSQL store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-8", + "content": "Run sqlx prepare for both stores", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-9", + "content": "Unit tests: Storage create_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-10", + "content": "Unit tests: Storage get_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-11", + "content": "Unit tests: Storage increment_verification_attempts", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-12", + "content": "Unit tests: Storage mark_verification_verified", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-13", + "content": "Unit tests: Storage delete_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-14", + "content": "Unit tests: Storage cleanup_expired_verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-15", + "content": "Unit tests: Verification code expiration logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-16", + "content": "Unit tests: Attempt counting and limits", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-1", + "content": "Create apps/zopp-server/src/config.rs with ServerConfig", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-2", + "content": "Add config validation (fail if verification enabled but no provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-3", + "content": "Add environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-4", + "content": "Unit tests: Config environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-5", + "content": "Unit tests: Config validation (missing provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-6", + "content": "Unit tests: Config validation (invalid SMTP config)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-7", + "content": "Unit tests: Config defaults and optional fields", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-1", + "content": "Add Resend and SMTP dependencies to Cargo.toml with features", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-2", + "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-3", + "content": "Create apps/zopp-server/src/email/templates.rs with email templates", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-4", + "content": "Implement Resend provider (email/resend.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-5", + "content": "Implement SMTP provider (email/smtp.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-6", + "content": "Add provider factory function with validation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-7", + "content": "Create code generation utility (cryptographically secure 6-digit)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-8", + "content": "Unit tests: Email templates text generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-9", + "content": "Unit tests: Email templates HTML generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-10", + "content": "Unit tests: Code generation format validation (6 digits)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-11", + "content": "Unit tests: Code generation randomness (no duplicates in sample)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-12", + "content": "Unit tests: Resend provider with mocked API", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-13", + "content": "Unit tests: SMTP provider with mocked transport", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-14", + "content": "Unit tests: Provider factory selection logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-15", + "content": "Unit tests: Provider factory error on invalid config", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-1", + "content": "Update zopp.proto with VerifyEmailRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-2", + "content": "Update zopp.proto with ResendVerificationRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-3", + "content": "Add verification_required field to JoinResponse", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-4", + "content": "Regenerate proto code (cargo build)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-1", + "content": "Modify join handler to support verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-2", + "content": "Implement verify_email handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-3", + "content": "Implement resend_verification handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-4", + "content": "Add background cleanup task for expired verifications", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase5-5", + "content": "Add authentication bypass for verify_email RPC", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-6", + "content": "Unit tests: Rate limiting logic (5 attempts per code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-7", + "content": "Unit tests: Rate limiting (3 codes per hour per email)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-8", + "content": "Unit tests: Verification code validation (constant-time comparison)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-9", + "content": "Unit tests: Background cleanup task removes expired verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-10", + "content": "Unit tests: Join handler with verification enabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-11", + "content": "Unit tests: Join handler with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-12", + "content": "Unit tests: VerifyEmail handler success case", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-13", + "content": "Unit tests: VerifyEmail handler wrong code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-14", + "content": "Unit tests: ResendVerification handler", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-1", + "content": "Modify CLI cmd_join to handle verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-2", + "content": "Add code input prompt and retry logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-3", + "content": "Add resend option during verification", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-1", + "content": "E2E test: Join with verification enabled (happy path)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-2", + "content": "E2E test: Join with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-3", + "content": "E2E test: Failed verification (wrong code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-4", + "content": "E2E test: Expired verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-5", + "content": "E2E test: Too many attempts (rate limiting)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-6", + "content": "E2E test: Resend verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-7", + "content": "E2E test: Workspace invite with verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-8", + "content": "E2E test: Bootstrap invite (should bypass verification)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase8-1", + "content": "Update CLAUDE.md with email configuration", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-2", + "content": "Create docs/docs/guides/email-verification.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-3", + "content": "Update docs/docs/reference/cli/join.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-4", + "content": "Add troubleshooting guide for email delivery issues", + "status": "pending", + "priority": "low" + }, + { + "id": "phase9-1", + "content": "Run full test suite", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-2", + "content": "Run clippy and fmt", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-3", + "content": "Create PR and monitor CI", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-4", + "content": "Address Cubic review comments", + "status": "pending", + "priority": "high" + } + ] +} +``` +**Output:** +``` +[ + { + "id": "phase1-1", + "content": "Create SQLite migration 20260121000001_add_email_verification.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-2", + "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-3", + "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-4", + "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-5", + "content": "Add EmailVerification struct and methods to zopp-storage trait", + "status": "in_progress", + "priority": "high" + }, + { + "id": "phase1-6", + "content": "Implement email verification methods in SQLite store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-7", + "content": "Implement email verification methods in PostgreSQL store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-8", + "content": "Run sqlx prepare for both stores", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-9", + "content": "Unit tests: Storage create_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-10", + "content": "Unit tests: Storage get_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-11", + "content": "Unit tests: Storage increment_verification_attempts", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-12", + "content": "Unit tests: Storage mark_verification_verified", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-13", + "content": "Unit tests: Storage delete_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-14", + "content": "Unit tests: Storage cleanup_expired_verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-15", + "content": "Unit tests: Verification code expiration logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-16", + "content": "Unit tests: Attempt counting and limits", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-1", + "content": "Create apps/zopp-server/src/config.rs with ServerConfig", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-2", + "content": "Add config validation (fail if verification enabled but no provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-3", + "content": "Add environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-4", + "content": "Unit tests: Config environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-5", + "content": "Unit tests: Config validation (missing provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-6", + "content": "Unit tests: Config validation (invalid SMTP config)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-7", + "content": "Unit tests: Config defaults and optional fields", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-1", + "content": "Add Resend and SMTP dependencies to Cargo.toml with features", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-2", + "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-3", + "content": "Create apps/zopp-server/src/email/templates.rs with email templates", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-4", + "content": "Implement Resend provider (email/resend.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-5", + "content": "Implement SMTP provider (email/smtp.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-6", + "content": "Add provider factory function with validation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-7", + "content": "Create code generation utility (cryptographically secure 6-digit)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-8", + "content": "Unit tests: Email templates text generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-9", + "content": "Unit tests: Email templates HTML generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-10", + "content": "Unit tests: Code generation format validation (6 digits)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-11", + "content": "Unit tests: Code generation randomness (no duplicates in sample)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-12", + "content": "Unit tests: Resend provider with mocked API", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-13", + "content": "Unit tests: SMTP provider with mocked transport", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-14", + "content": "Unit tests: Provider factory selection logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-15", + "content": "Unit tests: Provider factory error on invalid config", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-1", + "content": "Update zopp.proto with VerifyEmailRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-2", + "content": "Update zopp.proto with ResendVerificationRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-3", + "content": "Add verification_required field to JoinResponse", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-4", + "content": "Regenerate proto code (cargo build)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-1", + "content": "Modify join handler to support verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-2", + "content": "Implement verify_email handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-3", + "content": "Implement resend_verification handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-4", + "content": "Add background cleanup task for expired verifications", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase5-5", + "content": "Add authentication bypass for verify_email RPC", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-6", + "content": "Unit tests: Rate limiting logic (5 attempts per code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-7", + "content": "Unit tests: Rate limiting (3 codes per hour per email)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-8", + "content": "Unit tests: Verification code validation (constant-time comparison)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-9", + "content": "Unit tests: Background cleanup task removes expired verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-10", + "content": "Unit tests: Join handler with verification enabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-11", + "content": "Unit tests: Join handler with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-12", + "content": "Unit tests: VerifyEmail handler success case", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-13", + "content": "Unit tests: VerifyEmail handler wrong code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-14", + "content": "Unit tests: ResendVerification handler", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-1", + "content": "Modify CLI cmd_join to handle verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-2", + "content": "Add code input prompt and retry logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-3", + "content": "Add resend option during verification", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-1", + "content": "E2E test: Join with verification enabled (happy path)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-2", + "content": "E2E test: Join with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-3", + "content": "E2E test: Failed verification (wrong code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-4", + "content": "E2E test: Expired verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-5", + "content": "E2E test: Too many attempts (rate limiting)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-6", + "content": "E2E test: Resend verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-7", + "content": "E2E test: Workspace invite with verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-8", + "content": "E2E test: Bootstrap invite (should bypass verification)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase8-1", + "content": "Update CLAUDE.md with email configuration", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-2", + "content": "Create docs/docs/guides/email-verification.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-3", + "content": "Update docs/docs/reference/cli/join.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-4", + "content": "Add troubleshooting guide for email delivery issues", + "status": "pending", + "priority": "low" + }, + { + "id": "phase9-1", + "content": "Run full test suite", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-2", + "content": "Run clippy and fmt", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-3", + "content": "Create PR and monitor CI", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-4", + "content": "Address Cubic review comments", + "status": "pending", + "priority": "high" + } +] +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 5.9s) + + +Good progress! I've created all 4 migration files. Now I need to add the EmailVerification struct and methods to the storage trait. Let me first read the current storage trait to understand its structure. + + +Now let me read the storage trait to understand its structure before adding the email verification methods: + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs" +} +``` +**Output:** +``` + +00001| //! Storage abstraction for zopp. +00002| //! +00003| //! Backend crates (e.g., zopp-store-sqlite, zopp-store-postgres) implement this trait so +00004| //! `zopp-core` doesn't depend on any specific database engine or schema details. +00005| +00006| use chrono::{DateTime, Utc}; +00007| use std::str::FromStr; +00008| use thiserror::Error; +00009| use uuid::Uuid; +00010| +00011| /// Uniform error type for all storage backends. +00012| #[derive(Debug, Error)] +00013| pub enum StoreError { +00014| #[error("not found")] +00015| NotFound, +00016| #[error("already exists")] +00017| AlreadyExists, +00018| #[error("conflict")] +00019| Conflict, +00020| #[error("backend error: {0}")] +00021| Backend(String), +00022| } +00023| +00024| /// Strongly-typed identifiers & names (avoid mixing strings arbitrarily). +00025| #[derive(Clone, Debug, PartialEq, Eq, Hash)] +00026| pub struct UserId(pub Uuid); +00027| +00028| #[derive(Clone, Debug, PartialEq, Eq, Hash)] +00029| pub struct PrincipalId(pub Uuid); +00030| +00031| #[derive(Clone, Debug, PartialEq, Eq, Hash)] +00032| pub struct InviteId(pub Uuid); +00033| +00034| #[derive(Clone, Debug, PartialEq, Eq, Hash)] +00035| pub struct WorkspaceId(pub Uuid); +00036| +00037| #[derive(Clone, Debug, PartialEq, Eq, Hash)] +00038| pub struct ProjectId(pub Uuid); +00039| +00040| #[derive(Clone, Debug, PartialEq, Eq, Hash)] +00041| pub struct ProjectName(pub String); +00042| +00043| #[derive(Clone, Debug, PartialEq, Eq, Hash)] +00044| pub struct EnvironmentId(pub Uuid); +00045| +00046| #[derive(Clone, Debug, PartialEq, Eq, Hash)] +00047| pub struct EnvName(pub String); +00048| +00049| #[derive(Clone, Debug, PartialEq, Eq, Hash)] +00050| pub struct GroupId(pub Uuid); +00051| +00052| #[derive(Clone, Debug, PartialEq, Eq, Hash)] +00053| pub struct PrincipalExportId(pub Uuid); +00054| +00055| /// Role for RBAC permissions +00056| #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +00057| pub enum Role { +00058| Admin, +00059| Write, +00060| Read, +00061| } +00062| +00063| /// Error type for parsing Role from string +00064| #[derive(Debug, Clone, PartialEq, Eq)] +00065| pub struct ParseRoleError(pub String); +00066| +00067| impl std::fmt::Display for ParseRoleError { +00068| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +00069| write!(f, "invalid role: {}", self.0) +00070| } +00071| } +00072| +00073| impl std::error::Error for ParseRoleError {} +00074| +00075| impl FromStr for Role { +00076| type Err = ParseRoleError; +00077| +00078| fn from_str(s: &str) -> Result { +00079| match s { +00080| "admin" => Ok(Role::Admin), +00081| "write" => Ok(Role::Write), +00082| "read" => Ok(Role::Read), +00083| _ => Err(ParseRoleError(s.to_string())), +00084| } +00085| } +00086| } +00087| +00088| impl Role { +00089| pub fn as_str(&self) -> &'static str { +00090| match self { +00091| Role::Admin => "admin", +00092| Role::Write => "write", +00093| Role::Read => "read", +00094| } +00095| } +00096| +00097| /// Check if this role has at least the permissions of another role +00098| pub fn includes(&self, other: &Role) -> bool { +00099| match self { +00100| Role::Admin => true, // Admin includes all permissions +00101| Role::Write => matches!(other, Role::Write | Role::Read), +00102| Role::Read => matches!(other, Role::Read), +00103| } +00104| } +00105| } +00106| +00107| /// Workspace-level permission +00108| #[derive(Clone, Debug)] +00109| pub struct WorkspacePermission { +00110| pub workspace_id: WorkspaceId, +00111| pub principal_id: PrincipalId, +00112| pub role: Role, +00113| pub created_at: DateTime, +00114| } +00115| +00116| /// Project-level permission +00117| #[derive(Clone, Debug)] +00118| pub struct ProjectPermission { +00119| pub project_id: ProjectId, +00120| pub principal_id: PrincipalId, +00121| pub role: Role, +00122| pub created_at: DateTime, +00123| } +00124| +00125| /// Environment-level permission (principal) +00126| #[derive(Clone, Debug)] +00127| pub struct EnvironmentPermission { +00128| pub environment_id: EnvironmentId, +00129| pub principal_id: PrincipalId, +00130| pub role: Role, +00131| pub created_at: DateTime, +00132| } +00133| +00134| /// User workspace-level permission +00135| #[derive(Clone, Debug)] +00136| pub struct UserWorkspacePermission { +00137| pub workspace_id: WorkspaceId, +00138| pub user_id: UserId, +00139| pub role: Role, +00140| pub created_at: DateTime, +00141| } +00142| +00143| /// User project-level permission +00144| #[derive(Clone, Debug)] +00145| pub struct UserProjectPermission { +00146| pub project_id: ProjectId, +00147| pub user_id: UserId, +00148| pub role: Role, +00149| pub created_at: DateTime, +00150| } +00151| +00152| /// User environment-level permission +00153| #[derive(Clone, Debug)] +00154| pub struct UserEnvironmentPermission { +00155| pub environment_id: EnvironmentId, +00156| pub user_id: UserId, +00157| pub role: Role, +00158| pub created_at: DateTime, +00159| } +00160| +00161| /// Group record +00162| #[derive(Clone, Debug)] +00163| pub struct Group { +00164| pub id: GroupId, +00165| pub workspace_id: WorkspaceId, +00166| pub name: String, +00167| pub description: Option, +00168| pub created_at: DateTime, +00169| pub updated_at: DateTime, +00170| } +00171| +00172| /// Group membership record +00173| #[derive(Clone, Debug)] +00174| pub struct GroupMember { +00175| pub group_id: GroupId, +00176| pub user_id: UserId, +00177| pub created_at: DateTime, +00178| } +00179| +00180| /// Group workspace-level permission +00181| #[derive(Clone, Debug)] +00182| pub struct GroupWorkspacePermission { +00183| pub workspace_id: WorkspaceId, +00184| pub group_id: GroupId, +00185| pub role: Role, +00186| pub created_at: DateTime, +00187| } +00188| +00189| /// Group project-level permission +00190| #[derive(Clone, Debug)] +00191| pub struct GroupProjectPermission { +00192| pub project_id: ProjectId, +00193| pub group_id: GroupId, +00194| pub role: Role, +00195| pub created_at: DateTime, +00196| } +00197| +00198| /// Group environment-level permission +00199| #[derive(Clone, Debug)] +00200| pub struct GroupEnvironmentPermission { +00201| pub environment_id: EnvironmentId, +00202| pub group_id: GroupId, +00203| pub role: Role, +00204| pub created_at: DateTime, +00205| } +00206| +00207| /// Parameters for creating a group +00208| #[derive(Clone, Debug)] +00209| pub struct CreateGroupParams { +00210| pub workspace_id: WorkspaceId, +00211| pub name: String, +00212| pub description: Option, +00213| } +00214| +00215| /// Encrypted secret row (nonce + ciphertext); no plaintext in storage. +00216| #[derive(Clone, Debug)] +00217| pub struct SecretRow { +00218| pub nonce: Vec, // 24 bytes (XChaCha20 nonce) +00219| pub ciphertext: Vec, // AEAD ciphertext +00220| } +00221| +00222| /// Parameters for creating a user +00223| #[derive(Clone, Debug)] +00224| pub struct CreateUserParams { +00225| pub email: String, +00226| /// Optional principal to create atomically with the user +00227| pub principal: Option, +00228| /// Workspaces to add this user to (user-level membership) +00229| pub workspace_ids: Vec, +00230| } +00231| +00232| /// Principal data for atomic user creation +00233| #[derive(Clone, Debug)] +00234| pub struct CreatePrincipalData { +00235| pub name: String, +00236| pub public_key: Vec, // Ed25519 for authentication +00237| pub x25519_public_key: Option>, // X25519 for encryption (ECDH) +00238| pub is_service: bool, // Service principal (user_id will be NULL) +00239| } +00240| +00241| /// Parameters for creating a principal +00242| #[derive(Clone, Debug)] +00243| pub struct CreatePrincipalParams { +00244| pub user_id: Option, // None for service accounts +00245| pub name: String, +00246| pub public_key: Vec, // Ed25519 for authentication +00247| pub x25519_public_key: Option>, // X25519 for encryption (ECDH) +00248| } +00249| +00250| /// Parameters for creating a workspace +00251| #[derive(Clone, Debug)] +00252| pub struct CreateWorkspaceParams { +00253| pub id: WorkspaceId, // Client-generated workspace ID +00254| pub name: String, +00255| pub owner_user_id: UserId, +00256| pub kdf_salt: Vec, // >= 16 bytes +00257| pub m_cost_kib: u32, // memory cost (KiB) +00258| pub t_cost: u32, // iterations +00259| pub p_cost: u32, // parallelism +00260| } +00261| +00262| /// Parameters for creating an invite +00263| #[derive(Clone, Debug)] +00264| pub struct CreateInviteParams { +00265| pub workspace_ids: Vec, +00266| pub token: String, // Hash of invite secret (for lookup) +00267| pub kek_encrypted: Option>, // Workspace KEK encrypted with invite secret +00268| pub kek_nonce: Option>, // 24-byte nonce for KEK encryption +00269| pub expires_at: DateTime, +00270| pub created_by_user_id: Option, // None for server-created invites +00271| } +00272| +00273| /// Parameters for creating a project +00274| #[derive(Clone, Debug)] +00275| pub struct CreateProjectParams { +00276| pub workspace_id: WorkspaceId, +00277| pub name: String, +00278| } +00279| +00280| /// Parameters for creating an environment +00281| #[derive(Clone, Debug)] +00282| pub struct CreateEnvParams { +00283| pub project_id: ProjectId, +00284| pub name: String, +00285| pub dek_wrapped: Vec, // wrapped DEK +00286| pub dek_nonce: Vec, // 24-byte nonce used in wrapping +00287| } +00288| +00289| /// User record +00290| #[derive(Clone, Debug)] +00291| pub struct User { +00292| pub id: UserId, +00293| pub email: String, +00294| pub created_at: DateTime, +00295| pub updated_at: DateTime, +00296| } +00297| +00298| /// Principal (device or service account) record +00299| #[derive(Clone, Debug)] +00300| pub struct Principal { +00301| pub id: PrincipalId, +00302| pub user_id: Option, // None for service accounts +00303| pub name: String, +00304| pub public_key: Vec, // Ed25519 for authentication +00305| pub x25519_public_key: Option>, // X25519 for encryption (ECDH) +00306| pub created_at: DateTime, +00307| pub updated_at: DateTime, +00308| } +00309| +00310| /// Invite record +00311| #[derive(Clone, Debug)] +00312| pub struct Invite { +00313| pub id: InviteId, +00314| pub token: String, +00315| pub workspace_ids: Vec, +00316| pub kek_encrypted: Option>, // Workspace KEK encrypted with invite secret +00317| pub kek_nonce: Option>, // 24-byte nonce for KEK encryption +00318| pub created_at: DateTime, +00319| pub updated_at: DateTime, +00320| pub expires_at: DateTime, +00321| pub created_by_user_id: Option, // None for server-created invites +00322| } +00323| +00324| /// Workspace record +00325| #[derive(Clone, Debug)] +00326| pub struct Workspace { +00327| pub id: WorkspaceId, +00328| pub name: String, +00329| pub owner_user_id: UserId, +00330| pub kdf_salt: Vec, +00331| pub m_cost_kib: u32, +00332| pub t_cost: u32, +00333| pub p_cost: u32, +00334| pub created_at: DateTime, +00335| pub updated_at: DateTime, +00336| } +00337| +00338| /// Workspace-Principal junction with wrapped KEK +00339| #[derive(Clone, Debug)] +00340| pub struct WorkspacePrincipal { +00341| pub workspace_id: WorkspaceId, +00342| pub principal_id: PrincipalId, +00343| pub ephemeral_pub: Vec, // Ephemeral X25519 public key for wrapping +00344| pub kek_wrapped: Vec, // Workspace KEK wrapped for this principal +00345| pub kek_nonce: Vec, // 24-byte nonce for wrapping +00346| pub created_at: DateTime, +00347| } +00348| +00349| /// Parameters for adding a principal to a workspace with wrapped KEK +00350| #[derive(Clone, Debug)] +00351| pub struct AddWorkspacePrincipalParams { +00352| pub workspace_id: WorkspaceId, +00353| pub principal_id: PrincipalId, +00354| pub ephemeral_pub: Vec, +00355| pub kek_wrapped: Vec, +00356| pub kek_nonce: Vec, +00357| } +00358| +00359| /// Project record +00360| #[derive(Clone, Debug)] +00361| pub struct Project { +00362| pub id: ProjectId, +00363| pub workspace_id: WorkspaceId, +00364| pub name: String, +00365| pub created_at: DateTime, +00366| pub updated_at: DateTime, +00367| } +00368| +00369| /// Environment record +00370| #[derive(Clone, Debug)] +00371| pub struct Environment { +00372| pub id: EnvironmentId, +00373| pub project_id: ProjectId, +00374| pub name: String, +00375| pub dek_wrapped: Vec, +00376| pub dek_nonce: Vec, +00377| pub version: i64, // Monotonic version counter for change tracking +00378| pub created_at: DateTime, +00379| pub updated_at: DateTime, +00380| } +00381| +00382| /// Principal export record for multi-device transfer +00383| #[derive(Clone, Debug)] +00384| pub struct PrincipalExport { +00385| pub id: PrincipalExportId, +00386| pub export_code: String, // Public identifier for lookup (e.g., "exp_a7k9m2x4") +00387| pub token_hash: String, // Argon2id(passphrase, verification_salt) for verification +00388| pub verification_salt: Vec, // Salt for passphrase verification (separate from encryption) +00389| pub user_id: UserId, +00390| pub principal_id: PrincipalId, +00391| pub encrypted_data: Vec, // Encrypted principal JSON +00392| pub salt: Vec, // Argon2id salt for encryption key derivation +00393| pub nonce: Vec, // XChaCha20-Poly1305 nonce +00394| pub expires_at: DateTime, +00395| pub created_at: DateTime, +00396| pub consumed: bool, +00397| pub failed_attempts: i32, // Track failed passphrase attempts (delete after 3) +00398| } +00399| +00400| /// Parameters for creating a principal export +00401| #[derive(Clone, Debug)] +00402| pub struct CreatePrincipalExportParams { +00403| pub export_code: String, // Public identifier for lookup (e.g., "exp_a7k9m2x4") +00404| pub token_hash: String, // Argon2id(passphrase, verification_salt) for verification +00405| pub verification_salt: Vec, // Salt for passphrase verification (separate from encryption) +00406| pub user_id: UserId, +00407| pub principal_id: PrincipalId, +00408| pub encrypted_data: Vec, // Encrypted principal JSON +00409| pub salt: Vec, // Argon2id salt for encryption key derivation +00410| pub nonce: Vec, // XChaCha20-Poly1305 nonce +00411| pub expires_at: DateTime, +00412| } +00413| +00414| /// The storage trait `zopp-core` depends on. +00415| /// +00416| /// All methods that act on project/env/secrets are **scoped by workspace**. +00417| #[cfg_attr(feature = "test-support", mockall::automock)] +00418| #[async_trait::async_trait] +00419| pub trait Store: Send + Sync { +00420| // ───────────────────────────────────── Users ────────────────────────────────────────── +00421| +00422| /// Create a new user (returns generated ID, and optional principal ID if principal was provided). +00423| /// If params.principal is provided, atomically creates the user, principal, and adds principal to workspaces. +00424| async fn create_user( +00425| &self, +00426| params: &CreateUserParams, +00427| ) -> Result<(UserId, Option), StoreError>; +00428| +00429| /// Get user by email. +00430| async fn get_user_by_email(&self, email: &str) -> Result; +00431| +00432| /// Get user by ID. +00433| async fn get_user_by_id(&self, user_id: &UserId) -> Result; +00434| +00435| // ───────────────────────────────────── Principals ───────────────────────────────────── +00436| +00437| /// Create a new principal (device) for a user. +00438| async fn create_principal( +00439| &self, +00440| params: &CreatePrincipalParams, +00441| ) -> Result; +00442| +00443| /// Get principal by ID. +00444| async fn get_principal(&self, principal_id: &PrincipalId) -> Result; +00445| +00446| /// Rename a principal. +00447| async fn rename_principal( +00448| &self, +00449| principal_id: &PrincipalId, +00450| new_name: &str, +00451| ) -> Result<(), StoreError>; +00452| +00453| /// List all principals for a user. +00454| async fn list_principals(&self, user_id: &UserId) -> Result, StoreError>; +00455| +00456| // ───────────────────────────────────── Invites ──────────────────────────────────────── +00457| +00458| /// Create an invite token (returns generated ID and token). +00459| async fn create_invite(&self, params: &CreateInviteParams) -> Result; +00460| +00461| /// Get invite by token. +00462| async fn get_invite_by_token(&self, token: &str) -> Result; +00463| +00464| /// List all active invites for a user (None = server invites). +00465| async fn list_invites(&self, user_id: Option) -> Result, StoreError>; +00466| +00467| /// Revoke an invite. +00468| async fn revoke_invite(&self, invite_id: &InviteId) -> Result<(), StoreError>; +00469| +00470| // ───────────────────────────────────── Principal Exports ────────────────────────────── +00471| +00472| /// Create a principal export for multi-device transfer. +00473| async fn create_principal_export( +00474| &self, +00475| params: &CreatePrincipalExportParams, +00476| ) -> Result; +00477| +00478| /// Get principal export by export code. +00479| async fn get_principal_export_by_code( +00480| &self, +00481| export_code: &str, +00482| ) -> Result; +00483| +00484| /// Mark a principal export as consumed (can only be used once). +00485| async fn consume_principal_export( +00486| &self, +00487| export_id: &PrincipalExportId, +00488| ) -> Result<(), StoreError>; +00489| +00490| /// Increment failed attempts counter for a principal export. +00491| /// Returns the new failed_attempts count. +00492| async fn increment_export_failed_attempts( +00493| &self, +00494| export_id: &PrincipalExportId, +00495| ) -> Result; +00496| +00497| /// Delete a principal export (used after 3 failed attempts or manual cleanup). +00498| async fn delete_principal_export( +00499| &self, +00500| export_id: &PrincipalExportId, +00501| ) -> Result<(), StoreError>; +00502| +00503| // ───────────────────────────────────── Workspaces ───────────────────────────────────── +00504| +00505| /// Create a new workspace (returns its generated ID). +00506| async fn create_workspace( +00507| &self, +00508| params: &CreateWorkspaceParams, +00509| ) -> Result; +00510| +00511| /// List all workspaces that a principal has KEK access to. +00512| async fn list_workspaces( +00513| &self, +00514| principal_id: &PrincipalId, +00515| ) -> Result, StoreError>; +00516| +00517| /// Get workspace by ID. +00518| async fn get_workspace(&self, ws: &WorkspaceId) -> Result; +00519| +00520| /// Get workspace by name for a user (user must have access). +00521| async fn get_workspace_by_name( +00522| &self, +00523| user_id: &UserId, +00524| name: &str, +00525| ) -> Result; +00526| +00527| /// Get workspace by name for a principal (principal must have access). +00528| async fn get_workspace_by_name_for_principal( +00529| &self, +00530| principal_id: &PrincipalId, +00531| name: &str, +00532| ) -> Result; +00533| +00534| /// Add a principal to a workspace with wrapped KEK. +00535| async fn add_workspace_principal( +00536| &self, +00537| params: &AddWorkspacePrincipalParams, +00538| ) -> Result<(), StoreError>; +00539| +00540| /// Get workspace principal (to access wrapped KEK). +00541| async fn get_workspace_principal( +00542| &self, +00543| workspace_id: &WorkspaceId, +00544| principal_id: &PrincipalId, +00545| ) -> Result; +00546| +00547| /// List all principals in a workspace (with their wrapped KEKs). +00548| async fn list_workspace_principals( +00549| &self, +00550| workspace_id: &WorkspaceId, +00551| ) -> Result, StoreError>; +00552| +00553| /// Remove a principal from a workspace. +00554| async fn remove_workspace_principal( +00555| &self, +00556| workspace_id: &WorkspaceId, +00557| principal_id: &PrincipalId, +00558| ) -> Result<(), StoreError>; +00559| +00560| /// Remove all project permissions for a principal in a workspace. +00561| /// Returns the number of permissions removed. +00562| async fn remove_all_project_permissions_for_principal( +00563| &self, +00564| workspace_id: &WorkspaceId, +00565| principal_id: &PrincipalId, +00566| ) -> Result; +00567| +00568| /// Remove all environment permissions for a principal in a workspace. +00569| /// Returns the number of permissions removed. +00570| async fn remove_all_environment_permissions_for_principal( +00571| &self, +00572| workspace_id: &WorkspaceId, +00573| principal_id: &PrincipalId, +00574| ) -> Result; +00575| +00576| /// Add a user to a workspace (user-level membership). +00577| async fn add_user_to_workspace( +00578| &self, +00579| workspace_id: &WorkspaceId, +00580| user_id: &UserId, +00581| ) -> Result<(), StoreError>; +00582| +00583| // ───────────────────────────────────── Projects ─────────────────────────────────────── +00584| +00585| /// Create a project within a workspace (returns generated ID). +00586| async fn create_project(&self, params: &CreateProjectParams) -> Result; +00587| +00588| /// List all projects in a workspace. +00589| async fn list_projects(&self, workspace_id: &WorkspaceId) -> Result, StoreError>; +00590| +00591| /// Get a project by ID. +00592| async fn get_project(&self, project_id: &ProjectId) -> Result; +00593| +00594| /// Get a project by name within a workspace. +00595| async fn get_project_by_name( +00596| &self, +00597| workspace_id: &WorkspaceId, +00598| name: &str, +00599| ) -> Result; +00600| +00601| /// Delete a project (and all its environments and secrets). +00602| async fn delete_project(&self, project_id: &ProjectId) -> Result<(), StoreError>; +00603| +00604| // ─────────────────────────────────────── Environments ───────────────────────────────────── +00605| +00606| /// Create an environment within a project (returns generated ID). +00607| async fn create_env(&self, params: &CreateEnvParams) -> Result; +00608| +00609| /// List all environments in a project. +00610| async fn list_environments( +00611| &self, +00612| project_id: &ProjectId, +00613| ) -> Result, StoreError>; +00614| +00615| /// Get an environment by ID. +00616| async fn get_environment(&self, env_id: &EnvironmentId) -> Result; +00617| +00618| /// Get an environment by name within a project. +00619| async fn get_environment_by_name( +00620| &self, +00621| project_id: &ProjectId, +00622| name: &str, +00623| ) -> Result; +00624| +00625| /// Delete an environment (and all its secrets). +00626| async fn delete_environment(&self, env_id: &EnvironmentId) -> Result<(), StoreError>; +00627| +00628| // ────────────────────────────────────── Secrets ─────────────────────────────────────── +00629| +00630| /// Upsert a secret value (AEAD ciphertext + nonce) in an environment. +00631| /// Returns the new environment version after the update. +00632| async fn upsert_secret( +00633| &self, +00634| env_id: &EnvironmentId, +00635| key: &str, +00636| nonce: &[u8], // per-value 24B nonce +00637| ciphertext: &[u8], // AEAD ciphertext under DEK +00638| ) -> Result; +00639| +00640| /// Fetch a secret row (nonce + ciphertext). +00641| async fn get_secret(&self, env_id: &EnvironmentId, key: &str) -> Result; +00642| +00643| /// List all secret keys in an environment. +00644| async fn list_secret_keys(&self, env_id: &EnvironmentId) -> Result, StoreError>; +00645| +00646| /// Delete a secret from an environment. +00647| /// Returns the new environment version after the deletion. +00648| async fn delete_secret(&self, env_id: &EnvironmentId, key: &str) -> Result; +00649| +00650| /// Fetch the (wrapped_dek, dek_nonce) pair for an environment so core can unwrap it (legacy name-based). +00651| async fn get_env_wrap( +00652| &self, +00653| ws: &WorkspaceId, +00654| project: &ProjectName, +00655| env: &EnvName, +00656| ) -> Result<(Vec, Vec), StoreError>; +00657| +00658| // ────────────────────────────────────── RBAC Permissions ────────────────────────────────── +00659| +00660| /// Set workspace-level permission for a principal +00661| async fn set_workspace_permission( +00662| &self, +00663| workspace_id: &WorkspaceId, +00664| principal_id: &PrincipalId, +00665| role: Role, +00666| ) -> Result<(), StoreError>; +00667| +00668| /// Get workspace-level permission for a principal +00669| async fn get_workspace_permission( +00670| &self, +00671| workspace_id: &WorkspaceId, +00672| principal_id: &PrincipalId, +00673| ) -> Result; +00674| +00675| /// List all workspace permissions for a principal +00676| async fn list_workspace_permissions_for_principal( +00677| &self, +00678| principal_id: &PrincipalId, +00679| ) -> Result, StoreError>; +00680| +00681| /// List all principals with permissions on a workspace +00682| async fn list_workspace_permissions( +00683| &self, +00684| workspace_id: &WorkspaceId, +00685| ) -> Result, StoreError>; +00686| +00687| /// Remove workspace-level permission for a principal +00688| async fn remove_workspace_permission( +00689| &self, +00690| workspace_id: &WorkspaceId, +00691| principal_id: &PrincipalId, +00692| ) -> Result<(), StoreError>; +00693| +00694| /// Set project-level permission for a principal +00695| async fn set_project_permission( +00696| &self, +00697| project_id: &ProjectId, +00698| principal_id: &PrincipalId, +00699| role: Role, +00700| ) -> Result<(), StoreError>; +00701| +00702| /// Get project-level permission for a principal +00703| async fn get_project_permission( +00704| &self, +00705| project_id: &ProjectId, +00706| principal_id: &PrincipalId, +00707| ) -> Result; +00708| +00709| /// List all project permissions for a principal +00710| async fn list_project_permissions_for_principal( +00711| &self, +00712| principal_id: &PrincipalId, +00713| ) -> Result, StoreError>; +00714| +00715| /// List all principals with permissions on a project +00716| async fn list_project_permissions( +00717| &self, +00718| project_id: &ProjectId, +00719| ) -> Result, StoreError>; +00720| +00721| /// Remove project-level permission for a principal +00722| async fn remove_project_permission( +00723| &self, +00724| project_id: &ProjectId, +00725| principal_id: &PrincipalId, +00726| ) -> Result<(), StoreError>; +00727| +00728| /// Set environment-level permission for a principal +00729| async fn set_environment_permission( +00730| &self, +00731| environment_id: &EnvironmentId, +00732| principal_id: &PrincipalId, +00733| role: Role, +00734| ) -> Result<(), StoreError>; +00735| +00736| /// Get environment-level permission for a principal +00737| async fn get_environment_permission( +00738| &self, +00739| environment_id: &EnvironmentId, +00740| principal_id: &PrincipalId, +00741| ) -> Result; +00742| +00743| /// List all environment permissions for a principal +00744| async fn list_environment_permissions_for_principal( +00745| &self, +00746| principal_id: &PrincipalId, +00747| ) -> Result, StoreError>; +00748| +00749| /// List all principals with permissions on an environment +00750| async fn list_environment_permissions( +00751| &self, +00752| environment_id: &EnvironmentId, +00753| ) -> Result, StoreError>; +00754| +00755| /// Remove environment-level permission for a principal +00756| async fn remove_environment_permission( +00757| &self, +00758| environment_id: &EnvironmentId, +00759| principal_id: &PrincipalId, +00760| ) -> Result<(), StoreError>; +00761| +00762| // ────────────────────────────────────── User Permissions ──────────────────────────────────────── +00763| +00764| /// Set workspace-level permission for a user +00765| async fn set_user_workspace_permission( +00766| &self, +00767| workspace_id: &WorkspaceId, +00768| user_id: &UserId, +00769| role: Role, +00770| ) -> Result<(), StoreError>; +00771| +00772| /// Get workspace-level permission for a user +00773| async fn get_user_workspace_permission( +00774| &self, +00775| workspace_id: &WorkspaceId, +00776| user_id: &UserId, +00777| ) -> Result; +00778| +00779| /// List all user permissions on a workspace +00780| async fn list_user_workspace_permissions( +00781| &self, +00782| workspace_id: &WorkspaceId, +00783| ) -> Result, StoreError>; +00784| +00785| /// Remove workspace-level permission for a user +00786| async fn remove_user_workspace_permission( +00787| &self, +00788| workspace_id: &WorkspaceId, +00789| user_id: &UserId, +00790| ) -> Result<(), StoreError>; +00791| +00792| /// Set project-level permission for a user +00793| async fn set_user_project_permission( +00794| &self, +00795| project_id: &ProjectId, +00796| user_id: &UserId, +00797| role: Role, +00798| ) -> Result<(), StoreError>; +00799| +00800| /// Get project-level permission for a user +00801| async fn get_user_project_permission( +00802| &self, +00803| project_id: &ProjectId, +00804| user_id: &UserId, +00805| ) -> Result; +00806| +00807| /// List all user permissions on a project +00808| async fn list_user_project_permissions( +00809| &self, +00810| project_id: &ProjectId, +00811| ) -> Result, StoreError>; +00812| +00813| /// Remove project-level permission for a user +00814| async fn remove_user_project_permission( +00815| &self, +00816| project_id: &ProjectId, +00817| user_id: &UserId, +00818| ) -> Result<(), StoreError>; +00819| +00820| /// Set environment-level permission for a user +00821| async fn set_user_environment_permission( +00822| &self, +00823| environment_id: &EnvironmentId, +00824| user_id: &UserId, +00825| role: Role, +00826| ) -> Result<(), StoreError>; +00827| +00828| /// Get environment-level permission for a user +00829| async fn get_user_environment_permission( +00830| &self, +00831| environment_id: &EnvironmentId, +00832| user_id: &UserId, +00833| ) -> Result; +00834| +00835| /// List all user permissions on an environment +00836| async fn list_user_environment_permissions( +00837| &self, +00838| environment_id: &EnvironmentId, +00839| ) -> Result, StoreError>; +00840| +00841| /// Remove environment-level permission for a user +00842| async fn remove_user_environment_permission( +00843| &self, +00844| environment_id: &EnvironmentId, +00845| user_id: &UserId, +00846| ) -> Result<(), StoreError>; +00847| +00848| // ────────────────────────────────────── Groups ──────────────────────────────────────── +00849| +00850| /// Create a new group within a workspace +00851| async fn create_group(&self, params: &CreateGroupParams) -> Result; +00852| +00853| /// Get group by ID +00854| async fn get_group(&self, group_id: &GroupId) -> Result; +00855| +00856| /// Get group by name within a workspace +00857| async fn get_group_by_name( +00858| &self, +00859| workspace_id: &WorkspaceId, +00860| name: &str, +00861| ) -> Result; +00862| +00863| /// List all groups in a workspace +00864| async fn list_groups(&self, workspace_id: &WorkspaceId) -> Result, StoreError>; +00865| +00866| /// Update group description +00867| async fn update_group( +00868| &self, +00869| group_id: &GroupId, +00870| name: &str, +00871| description: Option, +00872| ) -> Result<(), StoreError>; +00873| +00874| /// Delete a group (and all its memberships and permissions) +00875| async fn delete_group(&self, group_id: &GroupId) -> Result<(), StoreError>; +00876| +00877| /// Add a user to a group +00878| async fn add_group_member( +00879| &self, +00880| group_id: &GroupId, +00881| user_id: &UserId, +00882| ) -> Result<(), StoreError>; +00883| +00884| /// Remove a user from a group +00885| async fn remove_group_member( +00886| &self, +00887| group_id: &GroupId, +00888| user_id: &UserId, +00889| ) -> Result<(), StoreError>; +00890| +00891| /// List all members of a group +00892| async fn list_group_members(&self, group_id: &GroupId) -> Result, StoreError>; +00893| +00894| /// List all groups a user belongs to +00895| async fn list_user_groups(&self, user_id: &UserId) -> Result, StoreError>; +00896| +00897| /// Set workspace-level permission for a group +00898| async fn set_group_workspace_permission( +00899| &self, +00900| workspace_id: &WorkspaceId, +00901| group_id: &GroupId, +00902| role: Role, +00903| ) -> Result<(), StoreError>; +00904| +00905| /// Get workspace-level permission for a group +00906| async fn get_group_workspace_permission( +00907| &self, +00908| workspace_id: &WorkspaceId, +00909| group_id: &GroupId, +00910| ) -> Result; +00911| +00912| /// List all workspace permissions for a group +00913| async fn list_group_workspace_permissions( +00914| &self, +00915| workspace_id: &WorkspaceId, +00916| ) -> Result, StoreError>; +00917| +00918| /// Remove workspace-level permission for a group +00919| async fn remove_group_workspace_permission( +00920| &self, +00921| workspace_id: &WorkspaceId, +00922| group_id: &GroupId, +00923| ) -> Result<(), StoreError>; +00924| +00925| /// Set project-level permission for a group +00926| async fn set_group_project_permission( +00927| &self, +00928| project_id: &ProjectId, +00929| group_id: &GroupId, +00930| role: Role, +00931| ) -> Result<(), StoreError>; +00932| +00933| /// Get project-level permission for a group +00934| async fn get_group_project_permission( +00935| &self, +00936| project_id: &ProjectId, +00937| group_id: &GroupId, +00938| ) -> Result; +00939| +00940| /// List all project permissions for a group +00941| async fn list_group_project_permissions( +00942| &self, +00943| project_id: &ProjectId, +00944| ) -> Result, StoreError>; +00945| +00946| /// Remove project-level permission for a group +00947| async fn remove_group_project_permission( +00948| &self, +00949| project_id: &ProjectId, +00950| group_id: &GroupId, +00951| ) -> Result<(), StoreError>; +00952| +00953| /// Set environment-level permission for a group +00954| async fn set_group_environment_permission( +00955| &self, +00956| environment_id: &EnvironmentId, +00957| group_id: &GroupId, +00958| role: Role, +00959| ) -> Result<(), StoreError>; +00960| +00961| /// Get environment-level permission for a group +00962| async fn get_group_environment_permission( +00963| &self, +00964| environment_id: &EnvironmentId, +00965| group_id: &GroupId, +00966| ) -> Result; +00967| +00968| /// List all environment permissions for a group +00969| async fn list_group_environment_permissions( +00970| &self, +00971| environment_id: &EnvironmentId, +00972| ) -> Result, StoreError>; +00973| +00974| /// Remove environment-level permission for a group +00975| async fn remove_group_environment_permission( +00976| &self, +00977| environment_id: &EnvironmentId, +00978| group_id: &GroupId, +00979| ) -> Result<(), StoreError>; +00980| } +00981| +00982| #[cfg(test)] +00983| mod tests { +00984| use super::*; +00985| +00986| // Tiny compile-time smoke test for trait object usage. +00987| struct NoopStore; +00988| #[async_trait::async_trait] +00989| impl Store for NoopStore { +00990| async fn create_user( +00991| &self, +00992| _params: &CreateUserParams, +00993| ) -> Result<(UserId, Option), StoreError> { +00994| let user_id = UserId(Uuid::new_v4()); +00995| let principal_id = _params +00996| .principal +00997| .as_ref() +00998| .map(|_| PrincipalId(Uuid::new_v4())); +00999| Ok((user_id, principal_id)) +01000| } +01001| +01002| async fn get_user_by_email(&self, _email: &str) -> Result { +01003| Err(StoreError::NotFound) +01004| } +01005| +01006| async fn get_user_by_id(&self, _user_id: &UserId) -> Result { +01007| Err(StoreError::NotFound) +01008| } +01009| +01010| async fn create_principal( +01011| &self, +01012| _params: &CreatePrincipalParams, +01013| ) -> Result { +01014| Ok(PrincipalId(Uuid::new_v4())) +01015| } +01016| +01017| async fn get_principal( +01018| &self, +01019| _principal_id: &PrincipalId, +01020| ) -> Result { +01021| Err(StoreError::NotFound) +01022| } +01023| +01024| async fn rename_principal( +01025| &self, +01026| _principal_id: &PrincipalId, +01027| _new_name: &str, +01028| ) -> Result<(), StoreError> { +01029| Ok(()) +01030| } +01031| +01032| async fn list_principals(&self, _user_id: &UserId) -> Result, StoreError> { +01033| Ok(vec![]) +01034| } +01035| +01036| async fn create_invite(&self, _params: &CreateInviteParams) -> Result { +01037| Ok(Invite { +01038| id: InviteId(Uuid::new_v4()), +01039| token: "test-token".to_string(), +01040| workspace_ids: vec![], +01041| kek_encrypted: None, +01042| kek_nonce: None, +01043| created_at: Utc::now(), +01044| updated_at: Utc::now(), +01045| expires_at: Utc::now(), +01046| created_by_user_id: _params.created_by_user_id.clone(), +01047| }) +01048| } +01049| +01050| async fn get_invite_by_token(&self, _token: &str) -> Result { +01051| Err(StoreError::NotFound) +01052| } +01053| +01054| async fn list_invites(&self, _user_id: Option) -> Result, StoreError> { +01055| Ok(vec![]) +01056| } +01057| +01058| async fn revoke_invite(&self, _invite_id: &InviteId) -> Result<(), StoreError> { +01059| Ok(()) +01060| } +01061| +01062| async fn create_principal_export( +01063| &self, +01064| params: &CreatePrincipalExportParams, +01065| ) -> Result { +01066| Ok(PrincipalExport { +01067| id: PrincipalExportId(Uuid::new_v4()), +01068| export_code: params.export_code.clone(), +01069| token_hash: params.token_hash.clone(), +01070| verification_salt: params.verification_salt.clone(), +01071| user_id: params.user_id.clone(), +01072| principal_id: params.principal_id.clone(), +01073| encrypted_data: params.encrypted_data.clone(), +01074| salt: params.salt.clone(), +01075| nonce: params.nonce.clone(), +01076| expires_at: params.expires_at, +01077| created_at: Utc::now(), +01078| consumed: false, +01079| failed_attempts: 0, +01080| }) +01081| } +01082| +01083| async fn get_principal_export_by_code( +01084| &self, +01085| _export_code: &str, +01086| ) -> Result { +01087| Err(StoreError::NotFound) +01088| } +01089| +01090| async fn consume_principal_export( +01091| &self, +01092| _export_id: &PrincipalExportId, +01093| ) -> Result<(), StoreError> { +01094| Ok(()) +01095| } +01096| +01097| async fn increment_export_failed_attempts( +01098| &self, +01099| _export_id: &PrincipalExportId, +01100| ) -> Result { +01101| Ok(1) +01102| } +01103| +01104| async fn delete_principal_export( +01105| &self, +01106| _export_id: &PrincipalExportId, +01107| ) -> Result<(), StoreError> { +01108| Ok(()) +01109| } +01110| +01111| async fn create_workspace( +01112| &self, +01113| _params: &CreateWorkspaceParams, +01114| ) -> Result { +01115| Ok(WorkspaceId(Uuid::new_v4())) +01116| } +01117| +01118| async fn list_workspaces( +01119| &self, +01120| _principal_id: &PrincipalId, +01121| ) -> Result, StoreError> { +01122| Ok(vec![]) +01123| } +01124| +01125| async fn get_workspace(&self, _ws: &WorkspaceId) -> Result { +01126| Err(StoreError::NotFound) +01127| } +01128| +01129| async fn get_workspace_by_name( +01130| &self, +01131| _user_id: &UserId, +01132| _name: &str, +01133| ) -> Result { +01134| Err(StoreError::NotFound) +01135| } +01136| +01137| async fn get_workspace_by_name_for_principal( +01138| &self, +01139| _principal_id: &PrincipalId, +01140| _name: &str, +01141| ) -> Result { +01142| Err(StoreError::NotFound) +01143| } +01144| +01145| async fn add_workspace_principal( +01146| &self, +01147| _params: &AddWorkspacePrincipalParams, +01148| ) -> Result<(), StoreError> { +01149| Ok(()) +01150| } +01151| +01152| async fn get_workspace_principal( +01153| &self, +01154| _workspace_id: &WorkspaceId, +01155| _principal_id: &PrincipalId, +01156| ) -> Result { +01157| Err(StoreError::NotFound) +01158| } +01159| +01160| async fn list_workspace_principals( +01161| &self, +01162| _workspace_id: &WorkspaceId, +01163| ) -> Result, StoreError> { +01164| Ok(vec![]) +01165| } +01166| +01167| async fn remove_workspace_principal( +01168| &self, +01169| _workspace_id: &WorkspaceId, +01170| _principal_id: &PrincipalId, +01171| ) -> Result<(), StoreError> { +01172| Ok(()) +01173| } +01174| +01175| async fn remove_all_project_permissions_for_principal( +01176| &self, +01177| _workspace_id: &WorkspaceId, +01178| _principal_id: &PrincipalId, +01179| ) -> Result { +01180| Ok(0) +01181| } +01182| +01183| async fn remove_all_environment_permissions_for_principal( +01184| &self, +01185| _workspace_id: &WorkspaceId, +01186| _principal_id: &PrincipalId, +01187| ) -> Result { +01188| Ok(0) +01189| } +01190| +01191| async fn add_user_to_workspace( +01192| &self, +01193| _workspace_id: &WorkspaceId, +01194| _user_id: &UserId, +01195| ) -> Result<(), StoreError> { +01196| Ok(()) +01197| } +01198| +01199| async fn create_project( +01200| &self, +01201| _params: &CreateProjectParams, +01202| ) -> Result { +01203| Ok(ProjectId(Uuid::new_v4())) +01204| } +01205| +01206| async fn list_projects( +01207| &self, +01208| _workspace_id: &WorkspaceId, +01209| ) -> Result, StoreError> { +01210| Ok(vec![]) +01211| } +01212| +01213| async fn get_project(&self, _project_id: &ProjectId) -> Result { +01214| Err(StoreError::NotFound) +01215| } +01216| +01217| async fn get_project_by_name( +01218| &self, +01219| _workspace_id: &WorkspaceId, +01220| _name: &str, +01221| ) -> Result { +01222| Err(StoreError::NotFound) +01223| } +01224| +01225| async fn delete_project(&self, _project_id: &ProjectId) -> Result<(), StoreError> { +01226| Ok(()) +01227| } +01228| +01229| async fn create_env(&self, _params: &CreateEnvParams) -> Result { +01230| Ok(EnvironmentId(Uuid::new_v4())) +01231| } +01232| +01233| async fn list_environments( +01234| &self, +01235| _project_id: &ProjectId, +01236| ) -> Result, StoreError> { +01237| Ok(vec![]) +01238| } +01239| +01240| async fn get_environment( +01241| &self, +01242| _env_id: &EnvironmentId, +01243| ) -> Result { +01244| Err(StoreError::NotFound) +01245| } +01246| +01247| async fn get_environment_by_name( +01248| &self, +01249| _project_id: &ProjectId, +01250| _name: &str, +01251| ) -> Result { +01252| Err(StoreError::NotFound) +01253| } +01254| +01255| async fn delete_environment(&self, _env_id: &EnvironmentId) -> Result<(), StoreError> { +01256| Ok(()) +01257| } +01258| +01259| async fn get_env_wrap( +01260| &self, +01261| _ws: &WorkspaceId, +01262| _project: &ProjectName, +01263| _env: &EnvName, +01264| ) -> Result<(Vec, Vec), StoreError> { +01265| Err(StoreError::NotFound) +01266| } +01267| +01268| async fn upsert_secret( +01269| &self, +01270| _env_id: &EnvironmentId, +01271| _key: &str, +01272| _nonce: &[u8], +01273| _ciphertext: &[u8], +01274| ) -> Result { +01275| Ok(1) +01276| } +01277| +01278| async fn get_secret( +01279| &self, +01280| _env_id: &EnvironmentId, +01281| _key: &str, +01282| ) -> Result { +01283| Err(StoreError::NotFound) +01284| } +01285| +01286| async fn list_secret_keys( +01287| &self, +01288| _env_id: &EnvironmentId, +01289| ) -> Result, StoreError> { +01290| Ok(vec![]) +01291| } +01292| +01293| async fn delete_secret( +01294| &self, +01295| _env_id: &EnvironmentId, +01296| _key: &str, +01297| ) -> Result { +01298| Ok(1) +01299| } +01300| +01301| async fn set_workspace_permission( +01302| &self, +01303| _workspace_id: &WorkspaceId, +01304| _principal_id: &PrincipalId, +01305| _role: Role, +01306| ) -> Result<(), StoreError> { +01307| Ok(()) +01308| } +01309| +01310| async fn get_workspace_permission( +01311| &self, +01312| _workspace_id: &WorkspaceId, +01313| _principal_id: &PrincipalId, +01314| ) -> Result { +01315| Err(StoreError::NotFound) +01316| } +01317| +01318| async fn list_workspace_permissions_for_principal( +01319| &self, +01320| _principal_id: &PrincipalId, +01321| ) -> Result, StoreError> { +01322| Ok(vec![]) +01323| } +01324| +01325| async fn list_workspace_permissions( +01326| &self, +01327| _workspace_id: &WorkspaceId, +01328| ) -> Result, StoreError> { +01329| Ok(vec![]) +01330| } +01331| +01332| async fn remove_workspace_permission( +01333| &self, +01334| _workspace_id: &WorkspaceId, +01335| _principal_id: &PrincipalId, +01336| ) -> Result<(), StoreError> { +01337| Ok(()) +01338| } +01339| +01340| async fn set_project_permission( +01341| &self, +01342| _project_id: &ProjectId, +01343| _principal_id: &PrincipalId, +01344| _role: Role, +01345| ) -> Result<(), StoreError> { +01346| Ok(()) +01347| } +01348| +01349| async fn get_project_permission( +01350| &self, +01351| _project_id: &ProjectId, +01352| _principal_id: &PrincipalId, +01353| ) -> Result { +01354| Err(StoreError::NotFound) +01355| } +01356| +01357| async fn list_project_permissions_for_principal( +01358| &self, +01359| _principal_id: &PrincipalId, +01360| ) -> Result, StoreError> { +01361| Ok(vec![]) +01362| } +01363| +01364| async fn list_project_permissions( +01365| &self, +01366| _project_id: &ProjectId, +01367| ) -> Result, StoreError> { +01368| Ok(vec![]) +01369| } +01370| +01371| async fn remove_project_permission( +01372| &self, +01373| _project_id: &ProjectId, +01374| _principal_id: &PrincipalId, +01375| ) -> Result<(), StoreError> { +01376| Ok(()) +01377| } +01378| +01379| async fn set_environment_permission( +01380| &self, +01381| _environment_id: &EnvironmentId, +01382| _principal_id: &PrincipalId, +01383| _role: Role, +01384| ) -> Result<(), StoreError> { +01385| Ok(()) +01386| } +01387| +01388| async fn get_environment_permission( +01389| &self, +01390| _environment_id: &EnvironmentId, +01391| _principal_id: &PrincipalId, +01392| ) -> Result { +01393| Err(StoreError::NotFound) +01394| } +01395| +01396| async fn list_environment_permissions_for_principal( +01397| &self, +01398| _principal_id: &PrincipalId, +01399| ) -> Result, StoreError> { +01400| Ok(vec![]) +01401| } +01402| +01403| async fn list_environment_permissions( +01404| &self, +01405| _environment_id: &EnvironmentId, +01406| ) -> Result, StoreError> { +01407| Ok(vec![]) +01408| } +01409| +01410| async fn remove_environment_permission( +01411| &self, +01412| _environment_id: &EnvironmentId, +01413| _principal_id: &PrincipalId, +01414| ) -> Result<(), StoreError> { +01415| Ok(()) +01416| } +01417| +01418| // User permissions +01419| async fn set_user_workspace_permission( +01420| &self, +01421| _workspace_id: &WorkspaceId, +01422| _user_id: &UserId, +01423| _role: Role, +01424| ) -> Result<(), StoreError> { +01425| Ok(()) +01426| } +01427| +01428| async fn get_user_workspace_permission( +01429| &self, +01430| _workspace_id: &WorkspaceId, +01431| _user_id: &UserId, +01432| ) -> Result { +01433| Err(StoreError::NotFound) +01434| } +01435| +01436| async fn list_user_workspace_permissions( +01437| &self, +01438| _workspace_id: &WorkspaceId, +01439| ) -> Result, StoreError> { +01440| Ok(vec![]) +01441| } +01442| +01443| async fn remove_user_workspace_permission( +01444| &self, +01445| _workspace_id: &WorkspaceId, +01446| _user_id: &UserId, +01447| ) -> Result<(), StoreError> { +01448| Ok(()) +01449| } +01450| +01451| async fn set_user_project_permission( +01452| &self, +01453| _project_id: &ProjectId, +01454| _user_id: &UserId, +01455| _role: Role, +01456| ) -> Result<(), StoreError> { +01457| Ok(()) +01458| } +01459| +01460| async fn get_user_project_permission( +01461| &self, +01462| _project_id: &ProjectId, +01463| _user_id: &UserId, +01464| ) -> Result { +01465| Err(StoreError::NotFound) +01466| } +01467| +01468| async fn list_user_project_permissions( +01469| &self, +01470| _project_id: &ProjectId, +01471| ) -> Result, StoreError> { +01472| Ok(vec![]) +01473| } +01474| +01475| async fn remove_user_project_permission( +01476| &self, +01477| _project_id: &ProjectId, +01478| _user_id: &UserId, +01479| ) -> Result<(), StoreError> { +01480| Ok(()) +01481| } +01482| +01483| async fn set_user_environment_permission( +01484| &self, +01485| _environment_id: &EnvironmentId, +01486| _user_id: &UserId, +01487| _role: Role, +01488| ) -> Result<(), StoreError> { +01489| Ok(()) +01490| } +01491| +01492| async fn get_user_environment_permission( +01493| &self, +01494| _environment_id: &EnvironmentId, +01495| _user_id: &UserId, +01496| ) -> Result { +01497| Err(StoreError::NotFound) +01498| } +01499| +01500| async fn list_user_environment_permissions( +01501| &self, +01502| _environment_id: &EnvironmentId, +01503| ) -> Result, StoreError> { +01504| Ok(vec![]) +01505| } +01506| +01507| async fn remove_user_environment_permission( +01508| &self, +01509| _environment_id: &EnvironmentId, +01510| _user_id: &UserId, +01511| ) -> Result<(), StoreError> { +01512| Ok(()) +01513| } +01514| +01515| async fn create_group(&self, _params: &CreateGroupParams) -> Result { +01516| Ok(GroupId(Uuid::new_v4())) +01517| } +01518| +01519| async fn get_group(&self, _group_id: &GroupId) -> Result { +01520| Err(StoreError::NotFound) +01521| } +01522| +01523| async fn get_group_by_name( +01524| &self, +01525| _workspace_id: &WorkspaceId, +01526| _name: &str, +01527| ) -> Result { +01528| Err(StoreError::NotFound) +01529| } +01530| +01531| async fn list_groups(&self, _workspace_id: &WorkspaceId) -> Result, StoreError> { +01532| Ok(vec![]) +01533| } +01534| +01535| async fn update_group( +01536| &self, +01537| _group_id: &GroupId, +01538| _name: &str, +01539| _description: Option, +01540| ) -> Result<(), StoreError> { +01541| Ok(()) +01542| } +01543| +01544| async fn delete_group(&self, _group_id: &GroupId) -> Result<(), StoreError> { +01545| Ok(()) +01546| } +01547| +01548| async fn add_group_member( +01549| &self, +01550| _group_id: &GroupId, +01551| _user_id: &UserId, +01552| ) -> Result<(), StoreError> { +01553| Ok(()) +01554| } +01555| +01556| async fn remove_group_member( +01557| &self, +01558| _group_id: &GroupId, +01559| _user_id: &UserId, +01560| ) -> Result<(), StoreError> { +01561| Ok(()) +01562| } +01563| +01564| async fn list_group_members( +01565| &self, +01566| _group_id: &GroupId, +01567| ) -> Result, StoreError> { +01568| Ok(vec![]) +01569| } +01570| +01571| async fn list_user_groups(&self, _user_id: &UserId) -> Result, StoreError> { +01572| Ok(vec![]) +01573| } +01574| +01575| async fn set_group_workspace_permission( +01576| &self, +01577| _workspace_id: &WorkspaceId, +01578| _group_id: &GroupId, +01579| _role: Role, +01580| ) -> Result<(), StoreError> { +01581| Ok(()) +01582| } +01583| +01584| async fn get_group_workspace_permission( +01585| &self, +01586| _workspace_id: &WorkspaceId, +01587| _group_id: &GroupId, +01588| ) -> Result { +01589| Err(StoreError::NotFound) +01590| } +01591| +01592| async fn list_group_workspace_permissions( +01593| &self, +01594| _workspace_id: &WorkspaceId, +01595| ) -> Result, StoreError> { +01596| Ok(vec![]) +01597| } +01598| +01599| async fn remove_group_workspace_permission( +01600| &self, +01601| _workspace_id: &WorkspaceId, +01602| _group_id: &GroupId, +01603| ) -> Result<(), StoreError> { +01604| Ok(()) +01605| } +01606| +01607| async fn set_group_project_permission( +01608| &self, +01609| _project_id: &ProjectId, +01610| _group_id: &GroupId, +01611| _role: Role, + +(Output truncated at 51200 bytes. Use 'offset' parameter to read beyond line 1611) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 11.1s) + + +The file is quite long. I see the pattern for the trait. I need to: + +1. Add a new `EmailVerificationId` type (similar to other ID types) +2. Add `EmailVerification` struct +3. Add `CreateEmailVerificationParams` struct +4. Add methods to the `Store` trait for email verification +5. Also need to update the `Principal` struct to include the `verified` field + +Let me also need to update the `NoopStore` test implementation. + +Let me add the email verification structures and methods to the storage trait. + + +Now let me add the email verification types and methods to the storage trait. I'll add the new types after the existing ID types, and add the methods to the Store trait: + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", + "oldString": "#[derive(Clone, Debug, PartialEq, Eq, Hash)]\npub struct PrincipalExportId(pub Uuid);", + "newString": "#[derive(Clone, Debug, PartialEq, Eq, Hash)]\npub struct PrincipalExportId(pub Uuid);\n\n#[derive(Clone, Debug, PartialEq, Eq, Hash)]\npub struct EmailVerificationId(pub Uuid);" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 6.9s) + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", + "oldString": "/// Principal (device or service account) record\n#[derive(Clone, Debug)]\npub struct Principal {\n pub id: PrincipalId,\n pub user_id: Option, // None for service accounts\n pub name: String,\n pub public_key: Vec, // Ed25519 for authentication\n pub x25519_public_key: Option>, // X25519 for encryption (ECDH)\n pub created_at: DateTime,\n pub updated_at: DateTime,\n}", + "newString": "/// Principal (device or service account) record\n#[derive(Clone, Debug)]\npub struct Principal {\n pub id: PrincipalId,\n pub user_id: Option, // None for service accounts\n pub name: String,\n pub public_key: Vec, // Ed25519 for authentication\n pub x25519_public_key: Option>, // X25519 for encryption (ECDH)\n pub verified: bool, // Email verification status\n pub created_at: DateTime,\n pub updated_at: DateTime,\n}" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 11.6s) + +Now let me add the EmailVerification struct and CreateEmailVerificationParams after the PrincipalExport struct: + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", + "oldString": "/// Parameters for creating a principal export\n#[derive(Clone, Debug)]\npub struct CreatePrincipalExportParams {\n pub export_code: String, // Public identifier for lookup (e.g., \"exp_a7k9m2x4\")\n pub token_hash: String, // Argon2id(passphrase, verification_salt) for verification\n pub verification_salt: Vec, // Salt for passphrase verification (separate from encryption)\n pub user_id: UserId,\n pub principal_id: PrincipalId,\n pub encrypted_data: Vec, // Encrypted principal JSON\n pub salt: Vec, // Argon2id salt for encryption key derivation\n pub nonce: Vec, // XChaCha20-Poly1305 nonce\n pub expires_at: DateTime,\n}", + "newString": "/// Parameters for creating a principal export\n#[derive(Clone, Debug)]\npub struct CreatePrincipalExportParams {\n pub export_code: String, // Public identifier for lookup (e.g., \"exp_a7k9m2x4\")\n pub token_hash: String, // Argon2id(passphrase, verification_salt) for verification\n pub verification_salt: Vec, // Salt for passphrase verification (separate from encryption)\n pub user_id: UserId,\n pub principal_id: PrincipalId,\n pub encrypted_data: Vec, // Encrypted principal JSON\n pub salt: Vec, // Argon2id salt for encryption key derivation\n pub nonce: Vec, // XChaCha20-Poly1305 nonce\n pub expires_at: DateTime,\n}\n\n/// Email verification record for verifying email ownership during join\n#[derive(Clone, Debug)]\npub struct EmailVerification {\n pub id: EmailVerificationId,\n pub email: String, // Email being verified (lowercased)\n pub code: String, // 6-digit verification code\n pub attempts: i32, // Failed verification attempts\n pub created_at: DateTime,\n pub expires_at: DateTime,\n}\n\n/// Parameters for creating an email verification\n#[derive(Clone, Debug)]\npub struct CreateEmailVerificationParams {\n pub email: String, // Email being verified (lowercased)\n pub code: String, // 6-digit verification code\n pub expires_at: DateTime, // When the code expires\n}" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 4.1s) + +Now I need to add the email verification methods to the Store trait. Let me find a good place to add them - after the Principal Exports section: + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", + "offset": 495, + "limit": 30 +} +``` +**Output:** +``` + +00496| async fn create_principal_export( +00497| &self, +00498| params: &CreatePrincipalExportParams, +00499| ) -> Result; +00500| +00501| /// Get principal export by export code. +00502| async fn get_principal_export_by_code( +00503| &self, +00504| export_code: &str, +00505| ) -> Result; +00506| +00507| /// Mark a principal export as consumed (can only be used once). +00508| async fn consume_principal_export( +00509| &self, +00510| export_id: &PrincipalExportId, +00511| ) -> Result<(), StoreError>; +00512| +00513| /// Increment failed attempts counter for a principal export. +00514| /// Returns the new failed_attempts count. +00515| async fn increment_export_failed_attempts( +00516| &self, +00517| export_id: &PrincipalExportId, +00518| ) -> Result; +00519| +00520| /// Delete a principal export (used after 3 failed attempts or manual cleanup). +00521| async fn delete_principal_export( +00522| &self, +00523| export_id: &PrincipalExportId, +00524| ) -> Result<(), StoreError>; +00525| + +(File has more lines. Use 'offset' parameter to read beyond line 525) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 9.9s) + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", + "oldString": " /// Delete a principal export (used after 3 failed attempts or manual cleanup).\n async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError>;\n\n // ───────────────────────────────────── Workspaces ─────────────────────────────────────", + "newString": " /// Delete a principal export (used after 3 failed attempts or manual cleanup).\n async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError>;\n\n // ───────────────────────────────────── Email Verification ──────────────────────────────\n\n /// Create an email verification record.\n async fn create_email_verification(\n &self,\n params: &CreateEmailVerificationParams,\n ) -> Result;\n\n /// Get the latest pending email verification for an email address.\n async fn get_email_verification(&self, email: &str) -> Result;\n\n /// Increment the failed attempts counter for an email verification.\n /// Returns the new attempts count.\n async fn increment_email_verification_attempts(\n &self,\n id: &EmailVerificationId,\n ) -> Result;\n\n /// Delete an email verification (used after successful verification or manual cleanup).\n async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError>;\n\n /// Delete all expired email verifications.\n /// Returns the number of deleted records.\n async fn cleanup_expired_email_verifications(&self) -> Result;\n\n /// Count the number of verification codes created for an email in the last hour.\n /// Used for rate limiting (max 3 codes per hour per email).\n async fn count_recent_email_verifications(&self, email: &str) -> Result;\n\n /// Mark a principal as verified.\n async fn mark_principal_verified(&self, principal_id: &PrincipalId) -> Result<(), StoreError>;\n\n // ───────────────────────────────────── Workspaces ─────────────────────────────────────" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 4.2s) + +Now I need to update the NoopStore test implementation to include these new methods. Let me find and update that section: + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", + "offset": 1015, + "limit": 50 +} +``` +**Output:** +``` + +01016| /// Get environment-level permission for a group +01017| async fn get_group_environment_permission( +01018| &self, +01019| environment_id: &EnvironmentId, +01020| group_id: &GroupId, +01021| ) -> Result; +01022| +01023| /// List all environment permissions for a group +01024| async fn list_group_environment_permissions( +01025| &self, +01026| environment_id: &EnvironmentId, +01027| ) -> Result, StoreError>; +01028| +01029| /// Remove environment-level permission for a group +01030| async fn remove_group_environment_permission( +01031| &self, +01032| environment_id: &EnvironmentId, +01033| group_id: &GroupId, +01034| ) -> Result<(), StoreError>; +01035| } +01036| +01037| #[cfg(test)] +01038| mod tests { +01039| use super::*; +01040| +01041| // Tiny compile-time smoke test for trait object usage. +01042| struct NoopStore; +01043| #[async_trait::async_trait] +01044| impl Store for NoopStore { +01045| async fn create_user( +01046| &self, +01047| _params: &CreateUserParams, +01048| ) -> Result<(UserId, Option), StoreError> { +01049| let user_id = UserId(Uuid::new_v4()); +01050| let principal_id = _params +01051| .principal +01052| .as_ref() +01053| .map(|_| PrincipalId(Uuid::new_v4())); +01054| Ok((user_id, principal_id)) +01055| } +01056| +01057| async fn get_user_by_email(&self, _email: &str) -> Result { +01058| Err(StoreError::NotFound) +01059| } +01060| +01061| async fn get_user_by_id(&self, _user_id: &UserId) -> Result { +01062| Err(StoreError::NotFound) +01063| } +01064| +01065| async fn create_principal( + +(File has more lines. Use 'offset' parameter to read beyond line 1065) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 4.1s) + +Let me find where the principal export methods end in the NoopStore implementation: + +``` +Tool: grep + +**Input:** +```json +{ + "pattern": "delete_principal_export", + "path": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs" +} +``` +**Output:** +``` +Found 2 matches +/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs: + Line 521: async fn delete_principal_export( + Line 1159: async fn delete_principal_export( +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 3.7s) + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", + "offset": 1155, + "limit": 40 +} +``` +**Output:** +``` + +01156| Ok(1) +01157| } +01158| +01159| async fn delete_principal_export( +01160| &self, +01161| _export_id: &PrincipalExportId, +01162| ) -> Result<(), StoreError> { +01163| Ok(()) +01164| } +01165| +01166| async fn create_workspace( +01167| &self, +01168| _params: &CreateWorkspaceParams, +01169| ) -> Result { +01170| Ok(WorkspaceId(Uuid::new_v4())) +01171| } +01172| +01173| async fn list_workspaces( +01174| &self, +01175| _principal_id: &PrincipalId, +01176| ) -> Result, StoreError> { +01177| Ok(vec![]) +01178| } +01179| +01180| async fn get_workspace(&self, _ws: &WorkspaceId) -> Result { +01181| Err(StoreError::NotFound) +01182| } +01183| +01184| async fn get_workspace_by_name( +01185| &self, +01186| _user_id: &UserId, +01187| _name: &str, +01188| ) -> Result { +01189| Err(StoreError::NotFound) +01190| } +01191| +01192| async fn get_workspace_by_name_for_principal( +01193| &self, +01194| _principal_id: &PrincipalId, +01195| _name: &str, + +(File has more lines. Use 'offset' parameter to read beyond line 1195) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 7.7s) + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", + "oldString": " async fn delete_principal_export(\n &self,\n _export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n Ok(())\n }\n\n async fn create_workspace(", + "newString": " async fn delete_principal_export(\n &self,\n _export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n Ok(())\n }\n\n async fn create_email_verification(\n &self,\n params: &CreateEmailVerificationParams,\n ) -> Result {\n Ok(EmailVerification {\n id: EmailVerificationId(Uuid::new_v4()),\n email: params.email.clone(),\n code: params.code.clone(),\n attempts: 0,\n created_at: Utc::now(),\n expires_at: params.expires_at,\n })\n }\n\n async fn get_email_verification(\n &self,\n _email: &str,\n ) -> Result {\n Err(StoreError::NotFound)\n }\n\n async fn increment_email_verification_attempts(\n &self,\n _id: &EmailVerificationId,\n ) -> Result {\n Ok(1)\n }\n\n async fn delete_email_verification(\n &self,\n _id: &EmailVerificationId,\n ) -> Result<(), StoreError> {\n Ok(())\n }\n\n async fn cleanup_expired_email_verifications(&self) -> Result {\n Ok(0)\n }\n\n async fn count_recent_email_verifications(&self, _email: &str) -> Result {\n Ok(0)\n }\n\n async fn mark_principal_verified(\n &self,\n _principal_id: &PrincipalId,\n ) -> Result<(), StoreError> {\n Ok(())\n }\n\n async fn create_workspace(" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 6545.3s) + +Now let me check if the code compiles to make sure the storage trait changes are correct: + +``` +Tool: bash + +**Input:** +```json +{ + "command": "cd /Users/lucas/code/faisca/zopp && cargo check --package zopp-storage 2>&1 | head -50", + "description": "Check zopp-storage compiles" +} +``` +**Output:** +``` + Checking zopp-storage v0.1.1 (/Users/lucas/code/faisca/zopp/crates/zopp-storage) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.92s + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 35.2s) + +``` +Tool: todowrite + +**Input:** +```json +{ + "todos": [ + { + "id": "phase1-1", + "content": "Create SQLite migration 20260121000001_add_email_verification.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-2", + "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-3", + "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-4", + "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-5", + "content": "Add EmailVerification struct and methods to zopp-storage trait", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-6", + "content": "Implement email verification methods in SQLite store", + "status": "in_progress", + "priority": "high" + }, + { + "id": "phase1-7", + "content": "Implement email verification methods in PostgreSQL store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-8", + "content": "Run sqlx prepare for both stores", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-9", + "content": "Unit tests: Storage create_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-10", + "content": "Unit tests: Storage get_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-11", + "content": "Unit tests: Storage increment_verification_attempts", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-12", + "content": "Unit tests: Storage mark_verification_verified", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-13", + "content": "Unit tests: Storage delete_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-14", + "content": "Unit tests: Storage cleanup_expired_verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-15", + "content": "Unit tests: Verification code expiration logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-16", + "content": "Unit tests: Attempt counting and limits", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-1", + "content": "Create apps/zopp-server/src/config.rs with ServerConfig", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-2", + "content": "Add config validation (fail if verification enabled but no provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-3", + "content": "Add environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-4", + "content": "Unit tests: Config environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-5", + "content": "Unit tests: Config validation (missing provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-6", + "content": "Unit tests: Config validation (invalid SMTP config)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-7", + "content": "Unit tests: Config defaults and optional fields", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-1", + "content": "Add Resend and SMTP dependencies to Cargo.toml with features", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-2", + "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-3", + "content": "Create apps/zopp-server/src/email/templates.rs with email templates", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-4", + "content": "Implement Resend provider (email/resend.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-5", + "content": "Implement SMTP provider (email/smtp.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-6", + "content": "Add provider factory function with validation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-7", + "content": "Create code generation utility (cryptographically secure 6-digit)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-8", + "content": "Unit tests: Email templates text generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-9", + "content": "Unit tests: Email templates HTML generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-10", + "content": "Unit tests: Code generation format validation (6 digits)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-11", + "content": "Unit tests: Code generation randomness (no duplicates in sample)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-12", + "content": "Unit tests: Resend provider with mocked API", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-13", + "content": "Unit tests: SMTP provider with mocked transport", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-14", + "content": "Unit tests: Provider factory selection logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-15", + "content": "Unit tests: Provider factory error on invalid config", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-1", + "content": "Update zopp.proto with VerifyEmailRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-2", + "content": "Update zopp.proto with ResendVerificationRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-3", + "content": "Add verification_required field to JoinResponse", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-4", + "content": "Regenerate proto code (cargo build)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-1", + "content": "Modify join handler to support verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-2", + "content": "Implement verify_email handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-3", + "content": "Implement resend_verification handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-4", + "content": "Add background cleanup task for expired verifications", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase5-5", + "content": "Add authentication bypass for verify_email RPC", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-6", + "content": "Unit tests: Rate limiting logic (5 attempts per code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-7", + "content": "Unit tests: Rate limiting (3 codes per hour per email)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-8", + "content": "Unit tests: Verification code validation (constant-time comparison)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-9", + "content": "Unit tests: Background cleanup task removes expired verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-10", + "content": "Unit tests: Join handler with verification enabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-11", + "content": "Unit tests: Join handler with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-12", + "content": "Unit tests: VerifyEmail handler success case", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-13", + "content": "Unit tests: VerifyEmail handler wrong code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-14", + "content": "Unit tests: ResendVerification handler", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-1", + "content": "Modify CLI cmd_join to handle verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-2", + "content": "Add code input prompt and retry logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-3", + "content": "Add resend option during verification", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-1", + "content": "E2E test: Join with verification enabled (happy path)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-2", + "content": "E2E test: Join with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-3", + "content": "E2E test: Failed verification (wrong code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-4", + "content": "E2E test: Expired verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-5", + "content": "E2E test: Too many attempts (rate limiting)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-6", + "content": "E2E test: Resend verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-7", + "content": "E2E test: Workspace invite with verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-8", + "content": "E2E test: Bootstrap invite (should bypass verification)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase8-1", + "content": "Update CLAUDE.md with email configuration", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-2", + "content": "Create docs/docs/guides/email-verification.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-3", + "content": "Update docs/docs/reference/cli/join.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-4", + "content": "Add troubleshooting guide for email delivery issues", + "status": "pending", + "priority": "low" + }, + { + "id": "phase9-1", + "content": "Run full test suite", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-2", + "content": "Run clippy and fmt", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-3", + "content": "Create PR and monitor CI", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-4", + "content": "Address Cubic review comments", + "status": "pending", + "priority": "high" + } + ] +} +``` +**Output:** +``` +[ + { + "id": "phase1-1", + "content": "Create SQLite migration 20260121000001_add_email_verification.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-2", + "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-3", + "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-4", + "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-5", + "content": "Add EmailVerification struct and methods to zopp-storage trait", + "status": "completed", + "priority": "high" + }, + { + "id": "phase1-6", + "content": "Implement email verification methods in SQLite store", + "status": "in_progress", + "priority": "high" + }, + { + "id": "phase1-7", + "content": "Implement email verification methods in PostgreSQL store", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-8", + "content": "Run sqlx prepare for both stores", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-9", + "content": "Unit tests: Storage create_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-10", + "content": "Unit tests: Storage get_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-11", + "content": "Unit tests: Storage increment_verification_attempts", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-12", + "content": "Unit tests: Storage mark_verification_verified", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-13", + "content": "Unit tests: Storage delete_email_verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-14", + "content": "Unit tests: Storage cleanup_expired_verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-15", + "content": "Unit tests: Verification code expiration logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase1-16", + "content": "Unit tests: Attempt counting and limits", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-1", + "content": "Create apps/zopp-server/src/config.rs with ServerConfig", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-2", + "content": "Add config validation (fail if verification enabled but no provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-3", + "content": "Add environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-4", + "content": "Unit tests: Config environment variable parsing", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-5", + "content": "Unit tests: Config validation (missing provider)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-6", + "content": "Unit tests: Config validation (invalid SMTP config)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase2-7", + "content": "Unit tests: Config defaults and optional fields", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-1", + "content": "Add Resend and SMTP dependencies to Cargo.toml with features", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-2", + "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-3", + "content": "Create apps/zopp-server/src/email/templates.rs with email templates", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-4", + "content": "Implement Resend provider (email/resend.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-5", + "content": "Implement SMTP provider (email/smtp.rs)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-6", + "content": "Add provider factory function with validation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-7", + "content": "Create code generation utility (cryptographically secure 6-digit)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-8", + "content": "Unit tests: Email templates text generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-9", + "content": "Unit tests: Email templates HTML generation", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-10", + "content": "Unit tests: Code generation format validation (6 digits)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-11", + "content": "Unit tests: Code generation randomness (no duplicates in sample)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-12", + "content": "Unit tests: Resend provider with mocked API", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-13", + "content": "Unit tests: SMTP provider with mocked transport", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-14", + "content": "Unit tests: Provider factory selection logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase3-15", + "content": "Unit tests: Provider factory error on invalid config", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-1", + "content": "Update zopp.proto with VerifyEmailRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-2", + "content": "Update zopp.proto with ResendVerificationRequest/Response", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-3", + "content": "Add verification_required field to JoinResponse", + "status": "pending", + "priority": "high" + }, + { + "id": "phase4-4", + "content": "Regenerate proto code (cargo build)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-1", + "content": "Modify join handler to support verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-2", + "content": "Implement verify_email handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-3", + "content": "Implement resend_verification handler with rate limiting", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-4", + "content": "Add background cleanup task for expired verifications", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase5-5", + "content": "Add authentication bypass for verify_email RPC", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-6", + "content": "Unit tests: Rate limiting logic (5 attempts per code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-7", + "content": "Unit tests: Rate limiting (3 codes per hour per email)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-8", + "content": "Unit tests: Verification code validation (constant-time comparison)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-9", + "content": "Unit tests: Background cleanup task removes expired verifications", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-10", + "content": "Unit tests: Join handler with verification enabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-11", + "content": "Unit tests: Join handler with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-12", + "content": "Unit tests: VerifyEmail handler success case", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-13", + "content": "Unit tests: VerifyEmail handler wrong code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase5-14", + "content": "Unit tests: ResendVerification handler", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-1", + "content": "Modify CLI cmd_join to handle verification flow", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-2", + "content": "Add code input prompt and retry logic", + "status": "pending", + "priority": "high" + }, + { + "id": "phase6-3", + "content": "Add resend option during verification", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase7-1", + "content": "E2E test: Join with verification enabled (happy path)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-2", + "content": "E2E test: Join with verification disabled", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-3", + "content": "E2E test: Failed verification (wrong code)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-4", + "content": "E2E test: Expired verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-5", + "content": "E2E test: Too many attempts (rate limiting)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-6", + "content": "E2E test: Resend verification code", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-7", + "content": "E2E test: Workspace invite with verification", + "status": "pending", + "priority": "high" + }, + { + "id": "phase7-8", + "content": "E2E test: Bootstrap invite (should bypass verification)", + "status": "pending", + "priority": "high" + }, + { + "id": "phase8-1", + "content": "Update CLAUDE.md with email configuration", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-2", + "content": "Create docs/docs/guides/email-verification.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-3", + "content": "Update docs/docs/reference/cli/join.md", + "status": "pending", + "priority": "medium" + }, + { + "id": "phase8-4", + "content": "Add troubleshooting guide for email delivery issues", + "status": "pending", + "priority": "low" + }, + { + "id": "phase9-1", + "content": "Run full test suite", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-2", + "content": "Run clippy and fmt", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-3", + "content": "Create PR and monitor CI", + "status": "pending", + "priority": "high" + }, + { + "id": "phase9-4", + "content": "Address Cubic review comments", + "status": "pending", + "priority": "high" + } +] +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 4.1s) + +Now let me implement the email verification methods in the SQLite store. First, let me read the current SQLite store implementation: + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", + "limit": 100 +} +``` +**Output:** +``` + +00001| use chrono::{DateTime, Utc}; +00002| use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; +00003| use std::str::FromStr; +00004| use uuid::Uuid; +00005| use zopp_audit::{ +00006| AuditAction, AuditEvent, AuditLog, AuditLogError, AuditLogFilter, AuditLogId, AuditResult, +00007| }; +00008| use zopp_storage::{ +00009| AddWorkspacePrincipalParams, CreateEnvParams, CreateInviteParams, CreatePrincipalExportParams, +00010| CreatePrincipalParams, CreateProjectParams, CreateUserParams, CreateWorkspaceParams, EnvName, +00011| Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal, +00012| PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role, +00013| SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission, +00014| UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal, +00015| }; +00016| +00017| static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); +00018| +00019| pub struct SqliteStore { +00020| pool: SqlitePool, +00021| } +00022| +00023| impl SqliteStore { +00024| /// `~/.zopp/store.db` (creates dir with 0700 perms on unix) +00025| pub async fn open_default() -> Result { +00026| let dir = dirs::home_dir() +00027| .ok_or_else(|| StoreError::Backend("no home dir".into()))? +00028| .join(".zopp"); +00029| std::fs::create_dir_all(&dir).map_err(|e| StoreError::Backend(e.to_string()))?; +00030| #[cfg(unix)] +00031| { +00032| use std::os::unix::fs::PermissionsExt; +00033| std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)) +00034| .map_err(|e| StoreError::Backend(e.to_string()))?; +00035| } +00036| let path = dir.join("store.db"); +00037| let url = format!("sqlite://{}", path.to_string_lossy()); +00038| Self::open(&url).await +00039| } +00040| +00041| pub async fn open_in_memory() -> Result { +00042| Self::open("sqlite::memory:").await +00043| } +00044| +00045| pub async fn open(url: &str) -> Result { +00046| let pool = SqlitePoolOptions::new() +00047| .max_connections(1) +00048| .connect(url) +00049| .await +00050| .map_err(|e| StoreError::Backend(e.to_string()))?; +00051| +00052| MIGRATOR +00053| .run(&pool) +00054| .await +00055| .map_err(|e| StoreError::Backend(e.to_string()))?; +00056| +00057| Ok(Self { pool }) +00058| } +00059| } +00060| +00061| #[async_trait::async_trait] +00062| impl Store for SqliteStore { +00063| // ───────────────────────────── Users ───────────────────────────── +00064| +00065| async fn create_user( +00066| &self, +00067| params: &CreateUserParams, +00068| ) -> Result<(UserId, Option), StoreError> { +00069| // Use transaction if we need to create principal or add to workspaces +00070| let needs_tx = params.principal.is_some() || !params.workspace_ids.is_empty(); +00071| +00072| if needs_tx { +00073| let mut tx = self +00074| .pool +00075| .begin() +00076| .await +00077| .map_err(|e| StoreError::Backend(e.to_string()))?; +00078| +00079| // First try to get existing user +00080| let existing_user = sqlx::query!("SELECT id FROM users WHERE email = ?", params.email) +00081| .fetch_optional(&mut *tx) +00082| .await +00083| .map_err(|e| StoreError::Backend(e.to_string()))?; +00084| +00085| let actual_user_id_str = if let Some(existing) = existing_user { +00086| // User already exists, use their ID +00087| existing.id +00088| } else { +00089| // Create new user +00090| let user_id = Uuid::now_v7(); +00091| let user_id_str = user_id.to_string(); +00092| sqlx::query!( +00093| "INSERT INTO users(id, email) VALUES(?, ?)", +00094| user_id_str, +00095| params.email +00096| ) +00097| .execute(&mut *tx) +00098| .await +00099| .map_err(|e| StoreError::Backend(e.to_string()))?; +00100| user_id_str + +(File has more lines. Use 'offset' parameter to read beyond line 100) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 4.2s) + +Let me look at the imports and find a similar method pattern to follow. Let me also check the Principal-related code to see how it handles the existing fields: + +``` +Tool: grep + +**Input:** +```json +{ + "pattern": "get_principal", + "path": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs" +} +``` +**Output:** +``` +Found 4 matches +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs: + Line 266: async fn get_principal(&self, principal_id: &PrincipalId) -> Result { + Line 608: async fn get_principal_export_by_code( + Line 3605: let initial = s.get_principal(&principal_id).await.unwrap(); + Line 3613: let after_update = s.get_principal(&principal_id).await.unwrap(); +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 3.4s) + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", + "offset": 265, + "limit": 60 +} +``` +**Output:** +``` + +00266| async fn get_principal(&self, principal_id: &PrincipalId) -> Result { +00267| let principal_id_str = principal_id.0.to_string(); +00268| let row = sqlx::query!( +00269| r#"SELECT id, user_id, name, public_key, x25519_public_key, +00270| created_at as "created_at: DateTime", +00271| updated_at as "updated_at: DateTime" +00272| FROM principals WHERE id = ?"#, +00273| principal_id_str +00274| ) +00275| .fetch_optional(&self.pool) +00276| .await +00277| .map_err(|e| StoreError::Backend(e.to_string()))?; +00278| +00279| match row { +00280| None => Err(StoreError::NotFound), +00281| Some(row) => { +00282| let id = +00283| Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?; +00284| let user_id = row +00285| .user_id +00286| .as_ref() +00287| .map(|id| Uuid::try_parse(id).map(UserId)) +00288| .transpose() +00289| .map_err(|e| StoreError::Backend(e.to_string()))?; +00290| Ok(Principal { +00291| id: PrincipalId(id), +00292| user_id, +00293| name: row.name, +00294| public_key: row.public_key, +00295| x25519_public_key: row.x25519_public_key, +00296| created_at: row.created_at, +00297| updated_at: row.updated_at, +00298| }) +00299| } +00300| } +00301| } +00302| +00303| async fn rename_principal( +00304| &self, +00305| principal_id: &PrincipalId, +00306| new_name: &str, +00307| ) -> Result<(), StoreError> { +00308| let principal_id_str = principal_id.0.to_string(); +00309| let result = sqlx::query!( +00310| "UPDATE principals SET name = ? WHERE id = ?", +00311| new_name, +00312| principal_id_str +00313| ) +00314| .execute(&self.pool) +00315| .await +00316| .map_err(|e| StoreError::Backend(e.to_string()))?; +00317| +00318| if result.rows_affected() == 0 { +00319| Err(StoreError::NotFound) +00320| } else { +00321| Ok(()) +00322| } +00323| } +00324| +00325| async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> { + +(File has more lines. Use 'offset' parameter to read beyond line 325) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 8.7s) + +I see that the `get_principal` method is missing the `verified` field. I need to: +1. Update the queries that select principals to include the `verified` field +2. Add the email verification methods +3. Update the imports to include the new types + +Let me first update the imports: + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", + "oldString": "use zopp_storage::{\n AddWorkspacePrincipalParams, CreateEnvParams, CreateInviteParams, CreatePrincipalExportParams,\n CreatePrincipalParams, CreateProjectParams, CreateUserParams, CreateWorkspaceParams, EnvName,\n Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal,\n PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role,\n SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission,\n UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal,\n};", + "newString": "use zopp_storage::{\n AddWorkspacePrincipalParams, CreateEmailVerificationParams, CreateEnvParams,\n CreateInviteParams, CreatePrincipalExportParams, CreatePrincipalParams, CreateProjectParams,\n CreateUserParams, CreateWorkspaceParams, EmailVerification, EmailVerificationId, EnvName,\n Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal,\n PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role,\n SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission,\n UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal,\n};" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 9.6s) + +Now let me update the `get_principal` method to include the `verified` field: + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", + "oldString": " async fn get_principal(&self, principal_id: &PrincipalId) -> Result {\n let principal_id_str = principal_id.0.to_string();\n let row = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM principals WHERE id = ?\"#,\n principal_id_str\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n match row {\n None => Err(StoreError::NotFound),\n Some(row) => {\n let id =\n Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?;\n let user_id = row\n .user_id\n .as_ref()\n .map(|id| Uuid::try_parse(id).map(UserId))\n .transpose()\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n Ok(Principal {\n id: PrincipalId(id),\n user_id,\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n }\n }\n }", + "newString": " async fn get_principal(&self, principal_id: &PrincipalId) -> Result {\n let principal_id_str = principal_id.0.to_string();\n let row = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, verified,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM principals WHERE id = ?\"#,\n principal_id_str\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n match row {\n None => Err(StoreError::NotFound),\n Some(row) => {\n let id =\n Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?;\n let user_id = row\n .user_id\n .as_ref()\n .map(|id| Uuid::try_parse(id).map(UserId))\n .transpose()\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n Ok(Principal {\n id: PrincipalId(id),\n user_id,\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n verified: row.verified != 0,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n }\n }\n }" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 3.4s) + +Now let me find and update the `list_principals` method: + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", + "offset": 325, + "limit": 50 +} +``` +**Output:** +``` + +00326| +00327| async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> { +00328| let user_id_str = user_id.0.to_string(); +00329| let rows = sqlx::query!( +00330| r#"SELECT id, user_id, name, public_key, x25519_public_key, +00331| created_at as "created_at: DateTime", +00332| updated_at as "updated_at: DateTime" +00333| FROM principals WHERE user_id = ?"#, +00334| user_id_str +00335| ) +00336| .fetch_all(&self.pool) +00337| .await +00338| .map_err(|e| StoreError::Backend(e.to_string()))?; +00339| +00340| let mut principals = Vec::with_capacity(rows.len()); +00341| for row in rows { +00342| let id = Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?; +00343| let user_id = row +00344| .user_id +00345| .as_ref() +00346| .map(|id| Uuid::try_parse(id).map(UserId)) +00347| .transpose() +00348| .map_err(|e| StoreError::Backend(e.to_string()))?; +00349| principals.push(Principal { +00350| id: PrincipalId(id), +00351| user_id, +00352| name: row.name, +00353| public_key: row.public_key, +00354| x25519_public_key: row.x25519_public_key, +00355| created_at: row.created_at, +00356| updated_at: row.updated_at, +00357| }); +00358| } +00359| Ok(principals) +00360| } +00361| +00362| // ───────────────────────────── Invites ───────────────────────────── +00363| +00364| async fn create_invite(&self, params: &CreateInviteParams) -> Result { +00365| let invite_id = Uuid::now_v7(); +00366| let invite_id_str = invite_id.to_string(); +00367| +00368| let created_by_user_id_str = params +00369| .created_by_user_id +00370| .as_ref() +00371| .map(|id| id.0.to_string()); +00372| +00373| sqlx::query!( +00374| "INSERT INTO invites(id, token, expires_at, created_by_user_id, kek_encrypted, kek_nonce) VALUES(?, ?, ?, ?, ?, ?)", +00375| invite_id_str, + +(File has more lines. Use 'offset' parameter to read beyond line 375) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 8.9s) + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", + "oldString": " async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> {\n let user_id_str = user_id.0.to_string();\n let rows = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM principals WHERE user_id = ?\"#,\n user_id_str\n )\n .fetch_all(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n let mut principals = Vec::with_capacity(rows.len());\n for row in rows {\n let id = Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?;\n let user_id = row\n .user_id\n .as_ref()\n .map(|id| Uuid::try_parse(id).map(UserId))\n .transpose()\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n principals.push(Principal {\n id: PrincipalId(id),\n user_id,\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n created_at: row.created_at,\n updated_at: row.updated_at,\n });\n }\n Ok(principals)\n }", + "newString": " async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> {\n let user_id_str = user_id.0.to_string();\n let rows = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, verified,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM principals WHERE user_id = ?\"#,\n user_id_str\n )\n .fetch_all(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n let mut principals = Vec::with_capacity(rows.len());\n for row in rows {\n let id = Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?;\n let user_id = row\n .user_id\n .as_ref()\n .map(|id| Uuid::try_parse(id).map(UserId))\n .transpose()\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n principals.push(Principal {\n id: PrincipalId(id),\n user_id,\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n verified: row.verified != 0,\n created_at: row.created_at,\n updated_at: row.updated_at,\n });\n }\n Ok(principals)\n }" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 3.9s) + +Now let me find where to add the email verification methods. Let me find the end of the principal exports section and check if there are any other method groups: + +``` +Tool: grep + +**Input:** +```json +{ + "pattern": "delete_principal_export", + "path": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs" +} +``` +**Output:** +``` +Found 1 matches +/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs: + Line 700: async fn delete_principal_export( +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 3.3s) + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", + "offset": 695, + "limit": 50 +} +``` +**Output:** +``` + +00696| Some(row) => Ok(row.failed_attempts as i32), +00697| } +00698| } +00699| +00700| async fn delete_principal_export( +00701| &self, +00702| export_id: &PrincipalExportId, +00703| ) -> Result<(), StoreError> { +00704| let export_id_str = export_id.0.to_string(); +00705| let result = sqlx::query!("DELETE FROM principal_exports WHERE id = ?", export_id_str) +00706| .execute(&self.pool) +00707| .await +00708| .map_err(|e| StoreError::Backend(e.to_string()))?; +00709| +00710| if result.rows_affected() == 0 { +00711| Err(StoreError::NotFound) +00712| } else { +00713| Ok(()) +00714| } +00715| } +00716| +00717| // ───────────────────────────── Workspaces ───────────────────────────── +00718| +00719| async fn create_workspace( +00720| &self, +00721| params: &CreateWorkspaceParams, +00722| ) -> Result { +00723| let ws_id_str = params.id.0.to_string(); +00724| let owner_user_id_str = params.owner_user_id.0.to_string(); +00725| let m_cost = params.m_cost_kib as i64; +00726| let t_cost = params.t_cost as i64; +00727| let p_cost = params.p_cost as i64; +00728| +00729| sqlx::query!( +00730| "INSERT INTO workspaces(id, name, owner_user_id, kdf_salt, kdf_m_cost_kib, kdf_t_cost, kdf_p_cost) +00731| VALUES(?, ?, ?, ?, ?, ?, ?)", +00732| ws_id_str, +00733| params.name, +00734| owner_user_id_str, +00735| params.kdf_salt, +00736| m_cost, +00737| t_cost, +00738| p_cost +00739| ) +00740| .execute(&self.pool) +00741| .await +00742| .map_err(|e| StoreError::Backend(e.to_string()))?; +00743| Ok(params.id.clone()) +00744| } +00745| + +(File has more lines. Use 'offset' parameter to read beyond line 745) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 18.1s) + +Now I'll add the email verification methods after the delete_principal_export method: + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", + "oldString": " async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n let export_id_str = export_id.0.to_string();\n let result = sqlx::query!(\"DELETE FROM principal_exports WHERE id = ?\", export_id_str)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Workspaces ─────────────────────────────", + "newString": " async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n let export_id_str = export_id.0.to_string();\n let result = sqlx::query!(\"DELETE FROM principal_exports WHERE id = ?\", export_id_str)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Email Verification ─────────────────────────────\n\n async fn create_email_verification(\n &self,\n params: &CreateEmailVerificationParams,\n ) -> Result {\n let id = Uuid::now_v7();\n let id_str = id.to_string();\n let email = params.email.to_lowercase();\n\n sqlx::query!(\n \"INSERT INTO email_verifications(id, email, code, expires_at) VALUES(?, ?, ?, ?)\",\n id_str,\n email,\n params.code,\n params.expires_at\n )\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(EmailVerification {\n id: EmailVerificationId(id),\n email,\n code: params.code.clone(),\n attempts: 0,\n created_at: Utc::now(),\n expires_at: params.expires_at,\n })\n }\n\n async fn get_email_verification(&self, email: &str) -> Result {\n let email_lower = email.to_lowercase();\n let row = sqlx::query!(\n r#\"SELECT id, email, code, attempts, \n created_at as \"created_at: DateTime\",\n expires_at as \"expires_at: DateTime\"\n FROM email_verifications \n WHERE email = ? \n ORDER BY created_at DESC \n LIMIT 1\"#,\n email_lower\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n match row {\n None => Err(StoreError::NotFound),\n Some(row) => {\n let id =\n Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?;\n Ok(EmailVerification {\n id: EmailVerificationId(id),\n email: row.email,\n code: row.code,\n attempts: row.attempts as i32,\n created_at: row.created_at,\n expires_at: row.expires_at,\n })\n }\n }\n }\n\n async fn increment_email_verification_attempts(\n &self,\n id: &EmailVerificationId,\n ) -> Result {\n let id_str = id.0.to_string();\n sqlx::query!(\n \"UPDATE email_verifications SET attempts = attempts + 1 WHERE id = ?\",\n id_str\n )\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n // Fetch the updated count\n let row = sqlx::query!(\"SELECT attempts FROM email_verifications WHERE id = ?\", id_str)\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n match row {\n None => Err(StoreError::NotFound),\n Some(row) => Ok(row.attempts as i32),\n }\n }\n\n async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError> {\n let id_str = id.0.to_string();\n let result = sqlx::query!(\"DELETE FROM email_verifications WHERE id = ?\", id_str)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n async fn cleanup_expired_email_verifications(&self) -> Result {\n let now = Utc::now();\n let result = sqlx::query!(\"DELETE FROM email_verifications WHERE expires_at < ?\", now)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(result.rows_affected())\n }\n\n async fn count_recent_email_verifications(&self, email: &str) -> Result {\n let email_lower = email.to_lowercase();\n let one_hour_ago = Utc::now() - chrono::Duration::hours(1);\n let row = sqlx::query!(\n \"SELECT COUNT(*) as count FROM email_verifications WHERE email = ? AND created_at > ?\",\n email_lower,\n one_hour_ago\n )\n .fetch_one(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(row.count as u64)\n }\n\n async fn mark_principal_verified(&self, principal_id: &PrincipalId) -> Result<(), StoreError> {\n let principal_id_str = principal_id.0.to_string();\n let result = sqlx::query!(\n \"UPDATE principals SET verified = 1 WHERE id = ?\",\n principal_id_str\n )\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Workspaces ─────────────────────────────" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 8.3s) + +Now let me check if the code compiles: + +``` +Tool: bash + +**Input:** +```json +{ + "command": "cd /Users/lucas/code/faisca/zopp && cargo check --package zopp-store-sqlite 2>&1 | head -80", + "description": "Check SQLite store compiles" +} +``` +**Output:** +``` + Checking zopp-storage v0.1.1 (/Users/lucas/code/faisca/zopp/crates/zopp-storage) + Checking zopp-audit v0.1.1 (/Users/lucas/code/faisca/zopp/crates/zopp-audit) + Checking zopp-store-sqlite v0.1.1 (/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite) +error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` + --> crates/zopp-store-sqlite/src/lib.rs:269:19 + | +269 | let row = sqlx::query!( + | ___________________^ +270 | | r#"SELECT id, user_id, name, public_key, x25519_public_key, verified, +271 | | created_at as "created_at: DateTime", +272 | | updated_at as "updated_at: DateTime" +273 | | FROM principals WHERE id = ?"#, +274 | | principal_id_str +275 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` + --> crates/zopp-store-sqlite/src/lib.rs:329:20 + | +329 | let rows = sqlx::query!( + | ____________________^ +330 | | r#"SELECT id, user_id, name, public_key, x25519_public_key, verified, +331 | | created_at as "created_at: DateTime", +332 | | updated_at as "updated_at: DateTime" +333 | | FROM principals WHERE user_id = ?"#, +334 | | user_id_str +335 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` + --> crates/zopp-store-sqlite/src/lib.rs:727:9 + | +727 | / sqlx::query!( +728 | | "INSERT INTO email_verifications(id, email, code, expires_at) VALUES(?, ?, ?, ?)", +729 | | id_str, +730 | | email, +731 | | params.code, +732 | | params.expires_at +733 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` + --> crates/zopp-store-sqlite/src/lib.rs:750:19 + | +750 | let row = sqlx::query!( + | ___________________^ +751 | | r#"SELECT id, email, code, attempts, +752 | | created_at as "created_at: DateTime", +753 | | expires_at as "expires_at: DateTime" +... | +758 | | email_lower +759 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` + --> crates/zopp-store-sqlite/src/lib.rs:786:9 + | +786 | / sqlx::query!( +787 | | "UPDATE email_verifications SET attempts = attempts + 1 WHERE id = ?", +788 | | id_str +789 | | ) + | |_________^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` + --> crates/zopp-store-sqlite/src/lib.rs:795:19 + | +795 | let row = sqlx::query!("SELECT attempts FROM email_verifications WHERE id = ?", id_str) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 4.1s) + +This is expected! The code is correct, but we need to run `sqlx prepare` to update the query cache. First, let me implement the PostgreSQL store as well, then run sqlx prepare for both. + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", + "limit": 50 +} +``` +**Output:** +``` + +00001| use chrono::{DateTime, Utc}; +00002| use sqlx::{postgres::PgPoolOptions, PgPool}; +00003| use std::str::FromStr; +00004| use uuid::Uuid; +00005| use zopp_audit::{ +00006| AuditAction, AuditEvent, AuditLog, AuditLogError, AuditLogFilter, AuditLogId, AuditResult, +00007| }; +00008| use zopp_storage::{ +00009| AddWorkspacePrincipalParams, CreateEnvParams, CreateInviteParams, CreatePrincipalExportParams, +00010| CreatePrincipalParams, CreateProjectParams, CreateUserParams, CreateWorkspaceParams, EnvName, +00011| Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal, +00012| PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role, +00013| SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission, +00014| UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal, +00015| }; +00016| +00017| static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); +00018| +00019| pub struct PostgresStore { +00020| pool: PgPool, +00021| } +00022| +00023| impl PostgresStore { +00024| pub async fn open(url: &str) -> Result { +00025| let pool = PgPoolOptions::new() +00026| .max_connections(10) +00027| .connect(url) +00028| .await +00029| .map_err(|e| StoreError::Backend(e.to_string()))?; +00030| +00031| MIGRATOR +00032| .run(&pool) +00033| .await +00034| .map_err(|e| StoreError::Backend(e.to_string()))?; +00035| +00036| Ok(Self { pool }) +00037| } +00038| } +00039| +00040| #[async_trait::async_trait] +00041| impl Store for PostgresStore { +00042| // ───────────────────────────── Users ───────────────────────────── +00043| +00044| async fn create_user( +00045| &self, +00046| params: &CreateUserParams, +00047| ) -> Result<(UserId, Option), StoreError> { +00048| let needs_tx = params.principal.is_some() || !params.workspace_ids.is_empty(); +00049| +00050| if needs_tx { + +(File has more lines. Use 'offset' parameter to read beyond line 50) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 6.8s) + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", + "oldString": "use zopp_storage::{\n AddWorkspacePrincipalParams, CreateEnvParams, CreateInviteParams, CreatePrincipalExportParams,\n CreatePrincipalParams, CreateProjectParams, CreateUserParams, CreateWorkspaceParams, EnvName,\n Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal,\n PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role,\n SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission,\n UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal,\n};", + "newString": "use zopp_storage::{\n AddWorkspacePrincipalParams, CreateEmailVerificationParams, CreateEnvParams,\n CreateInviteParams, CreatePrincipalExportParams, CreatePrincipalParams, CreateProjectParams,\n CreateUserParams, CreateWorkspaceParams, EmailVerification, EmailVerificationId, EnvName,\n Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal,\n PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role,\n SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission,\n UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal,\n};" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 4.4s) + +Now let me find and update the `get_principal` method in PostgreSQL store: + +``` +Tool: grep + +**Input:** +```json +{ + "pattern": "get_principal", + "path": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs" +} +``` +**Output:** +``` +Found 2 matches +/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs: + Line 210: async fn get_principal(&self, principal_id: &PrincipalId) -> Result { + Line 469: async fn get_principal_export_by_code( +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 3.3s) + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", + "offset": 208, + "limit": 75 +} +``` +**Output:** +``` + +00209| +00210| async fn get_principal(&self, principal_id: &PrincipalId) -> Result { +00211| let row = sqlx::query!( +00212| r#"SELECT id, user_id, name, public_key, x25519_public_key, created_at, updated_at +00213| FROM principals WHERE id = $1"#, +00214| principal_id.0 +00215| ) +00216| .fetch_optional(&self.pool) +00217| .await +00218| .map_err(|e| StoreError::Backend(e.to_string()))? +00219| .ok_or(StoreError::NotFound)?; +00220| +00221| Ok(Principal { +00222| id: PrincipalId(row.id), +00223| user_id: row.user_id.map(UserId), +00224| name: row.name, +00225| public_key: row.public_key, +00226| x25519_public_key: row.x25519_public_key, +00227| created_at: row.created_at, +00228| updated_at: row.updated_at, +00229| }) +00230| } +00231| +00232| async fn rename_principal( +00233| &self, +00234| principal_id: &PrincipalId, +00235| new_name: &str, +00236| ) -> Result<(), StoreError> { +00237| let result = sqlx::query!( +00238| "UPDATE principals SET name = $1 WHERE id = $2", +00239| new_name, +00240| principal_id.0 +00241| ) +00242| .execute(&self.pool) +00243| .await +00244| .map_err(|e| StoreError::Backend(e.to_string()))?; +00245| +00246| if result.rows_affected() == 0 { +00247| Err(StoreError::NotFound) +00248| } else { +00249| Ok(()) +00250| } +00251| } +00252| +00253| async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> { +00254| let rows = sqlx::query!( +00255| r#"SELECT id, user_id, name, public_key, x25519_public_key, created_at, updated_at +00256| FROM principals WHERE user_id = $1"#, +00257| user_id.0 +00258| ) +00259| .fetch_all(&self.pool) +00260| .await +00261| .map_err(|e| StoreError::Backend(e.to_string()))?; +00262| +00263| Ok(rows +00264| .into_iter() +00265| .map(|row| Principal { +00266| id: PrincipalId(row.id), +00267| user_id: row.user_id.map(UserId), +00268| name: row.name, +00269| public_key: row.public_key, +00270| x25519_public_key: row.x25519_public_key, +00271| created_at: row.created_at, +00272| updated_at: row.updated_at, +00273| }) +00274| .collect()) +00275| } +00276| +00277| // ───────────────────────────── Invites ───────────────────────────── +00278| +00279| async fn create_invite(&self, params: &CreateInviteParams) -> Result { +00280| let invite_id = Uuid::now_v7(); +00281| let created_by_user_id = params.created_by_user_id.as_ref().map(|id| id.0); +00282| +00283| let row = sqlx::query!( + +(File has more lines. Use 'offset' parameter to read beyond line 283) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 7.1s) + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", + "oldString": " async fn get_principal(&self, principal_id: &PrincipalId) -> Result {\n let row = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, created_at, updated_at\n FROM principals WHERE id = $1\"#,\n principal_id.0\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?\n .ok_or(StoreError::NotFound)?;\n\n Ok(Principal {\n id: PrincipalId(row.id),\n user_id: row.user_id.map(UserId),\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n }", + "newString": " async fn get_principal(&self, principal_id: &PrincipalId) -> Result {\n let row = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, verified, created_at, updated_at\n FROM principals WHERE id = $1\"#,\n principal_id.0\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?\n .ok_or(StoreError::NotFound)?;\n\n Ok(Principal {\n id: PrincipalId(row.id),\n user_id: row.user_id.map(UserId),\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n verified: row.verified,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n }" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 7.3s) + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", + "oldString": " async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> {\n let rows = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, created_at, updated_at\n FROM principals WHERE user_id = $1\"#,\n user_id.0\n )\n .fetch_all(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(rows\n .into_iter()\n .map(|row| Principal {\n id: PrincipalId(row.id),\n user_id: row.user_id.map(UserId),\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n .collect())\n }", + "newString": " async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> {\n let rows = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, verified, created_at, updated_at\n FROM principals WHERE user_id = $1\"#,\n user_id.0\n )\n .fetch_all(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(rows\n .into_iter()\n .map(|row| Principal {\n id: PrincipalId(row.id),\n user_id: row.user_id.map(UserId),\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n verified: row.verified,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n .collect())\n }" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 3.8s) + +Now let me find where to add the email verification methods in PostgreSQL store: + +``` +Tool: grep + +**Input:** +```json +{ + "pattern": "delete_principal_export", + "path": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs" +} +``` +**Output:** +``` +Found 1 matches +/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs: + Line 545: async fn delete_principal_export( +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 4.2s) + +``` +Tool: read + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", + "offset": 540, + "limit": 45 +} +``` +**Output:** +``` + +00541| Some(row) => Ok(row.failed_attempts), +00542| } +00543| } +00544| +00545| async fn delete_principal_export( +00546| &self, +00547| export_id: &PrincipalExportId, +00548| ) -> Result<(), StoreError> { +00549| let result = sqlx::query!("DELETE FROM principal_exports WHERE id = $1", export_id.0) +00550| .execute(&self.pool) +00551| .await +00552| .map_err(|e| StoreError::Backend(e.to_string()))?; +00553| +00554| if result.rows_affected() == 0 { +00555| Err(StoreError::NotFound) +00556| } else { +00557| Ok(()) +00558| } +00559| } +00560| +00561| // ───────────────────────────── Workspaces ───────────────────────────── +00562| +00563| async fn create_workspace( +00564| &self, +00565| params: &CreateWorkspaceParams, +00566| ) -> Result { +00567| let m_cost = params.m_cost_kib as i32; +00568| let t_cost = params.t_cost as i32; +00569| let p_cost = params.p_cost as i32; +00570| +00571| sqlx::query!( +00572| "INSERT INTO workspaces(id, name, owner_user_id, kdf_salt, kdf_m_cost_kib, kdf_t_cost, kdf_p_cost) +00573| VALUES($1, $2, $3, $4, $5, $6, $7)", +00574| params.id.0, +00575| params.name, +00576| params.owner_user_id.0, +00577| params.kdf_salt, +00578| m_cost, +00579| t_cost, +00580| p_cost +00581| ) +00582| .execute(&self.pool) +00583| .await +00584| .map_err(|e| StoreError::Backend(e.to_string()))?; +00585| Ok(params.id.clone()) + +(File has more lines. Use 'offset' parameter to read beyond line 585) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 16.9s) + +``` +Tool: edit + +**Input:** +```json +{ + "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", + "oldString": " async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n let result = sqlx::query!(\"DELETE FROM principal_exports WHERE id = $1\", export_id.0)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Workspaces ─────────────────────────────", + "newString": " async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n let result = sqlx::query!(\"DELETE FROM principal_exports WHERE id = $1\", export_id.0)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Email Verification ─────────────────────────────\n\n async fn create_email_verification(\n &self,\n params: &CreateEmailVerificationParams,\n ) -> Result {\n let id = Uuid::now_v7();\n let email = params.email.to_lowercase();\n\n let row = sqlx::query!(\n r#\"INSERT INTO email_verifications(id, email, code, expires_at) \n VALUES($1, $2, $3, $4) \n RETURNING id, email, code, attempts, created_at, expires_at\"#,\n id,\n email,\n params.code,\n params.expires_at\n )\n .fetch_one(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(EmailVerification {\n id: EmailVerificationId(row.id),\n email: row.email,\n code: row.code,\n attempts: row.attempts,\n created_at: row.created_at,\n expires_at: row.expires_at,\n })\n }\n\n async fn get_email_verification(&self, email: &str) -> Result {\n let email_lower = email.to_lowercase();\n let row = sqlx::query!(\n r#\"SELECT id, email, code, attempts, created_at, expires_at\n FROM email_verifications \n WHERE email = $1 \n ORDER BY created_at DESC \n LIMIT 1\"#,\n email_lower\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?\n .ok_or(StoreError::NotFound)?;\n\n Ok(EmailVerification {\n id: EmailVerificationId(row.id),\n email: row.email,\n code: row.code,\n attempts: row.attempts,\n created_at: row.created_at,\n expires_at: row.expires_at,\n })\n }\n\n async fn increment_email_verification_attempts(\n &self,\n id: &EmailVerificationId,\n ) -> Result {\n let row = sqlx::query!(\n r#\"UPDATE email_verifications \n SET attempts = attempts + 1 \n WHERE id = $1 \n RETURNING attempts\"#,\n id.0\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?\n .ok_or(StoreError::NotFound)?;\n\n Ok(row.attempts)\n }\n\n async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError> {\n let result = sqlx::query!(\"DELETE FROM email_verifications WHERE id = $1\", id.0)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n async fn cleanup_expired_email_verifications(&self) -> Result {\n let now = Utc::now();\n let result = sqlx::query!(\"DELETE FROM email_verifications WHERE expires_at < $1\", now)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(result.rows_affected())\n }\n\n async fn count_recent_email_verifications(&self, email: &str) -> Result {\n let email_lower = email.to_lowercase();\n let one_hour_ago = Utc::now() - chrono::Duration::hours(1);\n let row = sqlx::query!(\n r#\"SELECT COUNT(*) as \"count!\" \n FROM email_verifications \n WHERE email = $1 AND created_at > $2\"#,\n email_lower,\n one_hour_ago\n )\n .fetch_one(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(row.count as u64)\n }\n\n async fn mark_principal_verified(&self, principal_id: &PrincipalId) -> Result<(), StoreError> {\n let result = sqlx::query!(\n \"UPDATE principals SET verified = TRUE WHERE id = $1\",\n principal_id.0\n )\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Workspaces ─────────────────────────────" +} +``` +**Output:** +``` +Edit applied successfully. +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 972.6s) + +Now let me run sqlx prepare for both stores. First, let me create a temporary SQLite database and run migrations: + +``` +Tool: bash + +**Input:** +```json +{ + "command": "cd /Users/lucas/code/faisca/zopp && rm -f /tmp/zopp-prepare.db && export SQLX_OFFLINE=false && DATABASE_URL=sqlite:///tmp/zopp-prepare.db sqlx migrate run --source crates/zopp-store-sqlite/migrations 2>&1", + "description": "Run SQLite migrations for prepare" +} +``` +**Output:** +``` +error: error returned from database: (code: 14) unable to open database file + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 5.6s) + +``` +Tool: bash + +**Input:** +```json +{ + "command": "cd /Users/lucas/code/faisca/zopp && rm -f /tmp/zopp-prepare.db && export SQLX_OFFLINE=false && DATABASE_URL=\"sqlite:///tmp/zopp-prepare.db?mode=rwc\" sqlx migrate run --source crates/zopp-store-sqlite/migrations 2>&1", + "description": "Run SQLite migrations for prepare" +} +``` +**Output:** +``` +Applied 20251026125600/migrate init (1.324125ms) +Applied 20251227000001/migrate add environment version (788.541µs) +Applied 20260105000001/migrate add rbac (1.054375ms) +Applied 20260105000002/migrate add groups (1.188292ms) +Applied 20260105000003/migrate add user permissions (674.625µs) +Applied 20260110000001/migrate add audit logs (499.125µs) +Applied 20260114000001/migrate add self invites (641.917µs) +Applied 20260115000001/migrate add principal exports (444.584µs) +Applied 20260116000001/migrate add export code (718.75µs) +Applied 20260116000002/migrate add verification salt (552.292µs) +Applied 20260121000001/migrate add email verification (420.375µs) +Applied 20260121000002/migrate add principal verified (543.292µs) + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 3909.6s) + +``` +Tool: bash + +**Input:** +```json +{ + "command": "cd /Users/lucas/code/faisca/zopp && export SQLX_OFFLINE=false && DATABASE_URL=\"sqlite:///tmp/zopp-prepare.db?mode=rwc\" cargo sqlx prepare --package zopp-store-sqlite 2>&1", + "description": "Run sqlx prepare for SQLite" +} +``` +**Output:** +``` +error: unexpected argument '--package' found + + tip: to pass '--package' as a value, use '-- --package' + +Usage: cargo sqlx prepare [OPTIONS] [-- ...] + +For more information, try '--help'. + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5 · 10.0s) + +``` +Tool: bash + +**Input:** +```json +{ + "command": "cd /Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite && export SQLX_OFFLINE=false && DATABASE_URL=\"sqlite:///tmp/zopp-prepare.db?mode=rwc\" cargo sqlx prepare 2>&1", + "description": "Run sqlx prepare for SQLite" +} +``` +**Output:** +``` + Checking zopp-store-sqlite v0.1.1 (/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.24s +query data written to .sqlx in the current directory; please check this into version control + +``` +``` + +--- + +## Assistant (Build · claude-opus-4-5) + +SQLite prepare is complete. Now let me run PostgreSQL migrations and prepare: + +``` +Tool: bash + +**Input:** +```json +{ + "command": "docker ps --filter \"name=zopp-postgres\" --format \"{{.ID}}\"", + "description": "Check if PostgreSQL container exists" +} +``` +``` + +--- + From 8e84b907b231927b89642bc88241e2be839bb566 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 16:04:07 -0300 Subject: [PATCH 12/25] Remove session artifacts --- issue-46-email-verification.md | 451 -- session-ses_423c.md | 10799 ------------------------------- 2 files changed, 11250 deletions(-) delete mode 100644 issue-46-email-verification.md delete mode 100644 session-ses_423c.md diff --git a/issue-46-email-verification.md b/issue-46-email-verification.md deleted file mode 100644 index 852f5d21..00000000 --- a/issue-46-email-verification.md +++ /dev/null @@ -1,451 +0,0 @@ -# Implementation Plan: Email Verification for New Principals (Issue #46) - -## Executive Summary - -Implement email verification using **6-digit codes** with support for **2 email providers** (Resend, SMTP). Email verification will be **enabled by default** but configurable by server admin. - -## Design Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| **Verification default** | Enabled | Secure by default | -| **Email providers** | Resend + SMTP | Resend for simplicity, SMTP for universality | -| **Config validation** | Fail to start | Strict - prevents misconfiguration | -| **Rate limiting** | Basic | 5 attempts/code, 3 codes/hour per email | -| **Code format** | 6-digit | 1M combinations, CLI-friendly | -| **Code expiration** | 15 minutes | Balance security vs UX | - ---- - -## 1. Email Provider Libraries - -### Chosen Crates - -| Provider | Crate | Version | Notes | -|----------|-------|---------|-------| -| **Resend** | `resend-rs` | 0.20.0 | Official SDK, simple API, built-in rate limiting | -| **SMTP** | `lettre` | 0.11.19 | Universal fallback, works with any provider | - -### Dependencies to Add - -```toml -# In apps/zopp-server/Cargo.toml -[dependencies] -# Email providers (both optional) -resend-rs = { version = "0.20", optional = true } -lettre = { version = "0.11", features = ["tokio1-rustls", "smtp-transport"], optional = true } - -# Random number generation for verification codes -rand = "0.9" - -[features] -email-resend = ["resend-rs"] -email-smtp = ["lettre"] - -# Default: SMTP for maximum compatibility -default = ["email-smtp"] -``` - ---- - -## 2. Database Schema - -### New Table: `email_verifications` - -**SQLite:** -```sql -CREATE TABLE IF NOT EXISTS email_verifications ( - id TEXT PRIMARY KEY NOT NULL, -- UUID string - email TEXT NOT NULL, -- Email being verified (lowercased) - code TEXT NOT NULL, -- 6-digit verification code - 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 -); - -CREATE INDEX idx_email_verifications_email ON email_verifications(email); -CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at); -``` - -**PostgreSQL:** -```sql -CREATE TABLE IF NOT EXISTS email_verifications ( - id UUID PRIMARY KEY NOT NULL, - email TEXT NOT NULL, - code TEXT NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX idx_email_verifications_email ON email_verifications(email); -CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at); -``` - -### New Column: `principals.verified` - -```sql --- SQLite -ALTER TABLE principals ADD COLUMN verified INTEGER NOT NULL DEFAULT 1; - --- PostgreSQL -ALTER TABLE principals ADD COLUMN verified BOOLEAN NOT NULL DEFAULT TRUE; -``` - ---- - -## 3. Storage Trait Extensions - -```rust -/// Email verification record -pub struct EmailVerification { - pub id: EmailVerificationId, - pub email: String, - pub code: String, - pub attempts: i32, - pub created_at: DateTime, - pub expires_at: DateTime, -} - -/// Parameters for creating an email verification -pub struct CreateEmailVerificationParams { - pub email: String, - pub code: String, - pub expires_at: DateTime, -} - -// New Store trait methods -async fn create_email_verification(&self, params: &CreateEmailVerificationParams) -> Result; -async fn get_email_verification(&self, email: &str) -> Result; -async fn increment_email_verification_attempts(&self, id: &EmailVerificationId) -> Result; -async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError>; -async fn cleanup_expired_email_verifications(&self) -> Result; -async fn count_recent_email_verifications(&self, email: &str) -> Result; -async fn mark_principal_verified(&self, principal_id: &PrincipalId) -> Result<(), StoreError>; -``` - ---- - -## 4. Server Configuration - -### Config Structure - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerConfig { - pub email: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmailConfig { - pub verification_required: bool, - pub provider: EmailProviderConfig, - pub from_address: String, - pub from_name: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum EmailProviderConfig { - Resend { api_key: String }, - Smtp { - host: String, - port: u16, - username: Option, - password: Option, - use_tls: bool, - }, -} -``` - -### 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 (works with Gmail, AWS SES SMTP, Sendgrid, etc.) -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" -``` - ---- - -## 5. Proto Changes - -```protobuf -// Modified JoinResponse -message JoinResponse { - string user_id = 1; - string principal_id = 2; - repeated Workspace workspaces = 3; - bool verification_required = 4; // NEW -} - -// New RPCs -message VerifyEmailRequest { - string email = 1; - string code = 2; - string principal_id = 3; -} - -message VerifyEmailResponse { - bool success = 1; - string message = 2; - int32 attempts_remaining = 3; -} - -message ResendVerificationRequest { - string email = 1; -} - -message ResendVerificationResponse { - bool success = 1; - string message = 2; -} - -service Zopp { - // ... existing RPCs ... - rpc VerifyEmail(VerifyEmailRequest) returns (VerifyEmailResponse); - rpc ResendVerification(ResendVerificationRequest) returns (ResendVerificationResponse); -} -``` - ---- - -## 6. Modified Join Flow - -``` -Before: -User → Join RPC → Create User+Principal → Save credentials → Done - -After (verification enabled): -User → Join RPC → Create User+Principal(unverified) → Send email → Return verification_required=true - → CLI prompts for code → VerifyEmail RPC → Mark principal verified → Save credentials → Done -``` - ---- - -## 7. Email Templates - -### Text Version -``` -Welcome to Zopp! - -Your verification code is: {CODE} - -This code will expire in 15 minutes. - -If you didn't request this code, please ignore this email. - --- -Zopp Security Team -``` - -### HTML Version -```html - - - - - - -
-

Welcome to Zopp!

-

Your verification code is:

-
{CODE}
-

This code will expire in 15 minutes.

-
- - -``` - ---- - -## 8. Security Considerations - -### Rate Limiting -- Max 5 verification attempts per code -- Max 3 verification codes per email per hour -- Automatic cleanup of expired verifications (background task) - -### Code Generation -- Cryptographically secure random 6-digit codes (000000-999999) -- Constant-time comparison to prevent timing attacks -- Codes expire after 15 minutes - -### Privacy -- Don't reveal if email exists in timing or error messages -- Constant-time operations for verification lookups -- Hash email addresses in logs - ---- - -## 9. Implementation Checklist - -### Phase 1: Database & Storage (16 tasks) -- [x] Create SQLite migration `20260121000001_add_email_verification.sql` -- [x] Create PostgreSQL migration `20260121000001_add_email_verification.sql` -- [x] Create SQLite migration `20260121000002_add_principal_verified.sql` -- [x] Create PostgreSQL migration `20260121000002_add_principal_verified.sql` -- [x] Add `EmailVerification` struct and methods to `zopp-storage` trait -- [x] Implement email verification methods in SQLite store -- [x] Implement email verification methods in PostgreSQL store -- [ ] Run `sqlx prepare` for both stores -- [ ] Unit tests: Storage `create_email_verification` -- [ ] Unit tests: Storage `get_email_verification` -- [ ] Unit tests: Storage `increment_verification_attempts` -- [ ] Unit tests: Storage `mark_principal_verified` -- [ ] Unit tests: Storage `delete_email_verification` -- [ ] Unit tests: Storage `cleanup_expired_verifications` -- [ ] Unit tests: Verification code expiration logic -- [ ] Unit tests: Attempt counting and limits - -### Phase 2: Configuration (7 tasks) -- [ ] Create `apps/zopp-server/src/config.rs` with `ServerConfig` -- [ ] Add config validation (fail if verification enabled but no provider) -- [ ] Add environment variable parsing -- [ ] Unit tests: Config environment variable parsing -- [ ] Unit tests: Config validation (missing provider) -- [ ] Unit tests: Config validation (invalid SMTP config) -- [ ] Unit tests: Config defaults and optional fields - -### Phase 3: Email Providers (15 tasks) -- [ ] Add Resend and SMTP dependencies to `Cargo.toml` with features -- [ ] Create `apps/zopp-server/src/email/mod.rs` with `EmailProvider` trait -- [ ] Create `apps/zopp-server/src/email/templates.rs` with email templates -- [ ] Implement Resend provider (`email/resend.rs`) -- [ ] Implement SMTP provider (`email/smtp.rs`) -- [ ] Add provider factory function with validation -- [ ] Create code generation utility (cryptographically secure 6-digit) -- [ ] Unit tests: Email templates text generation -- [ ] Unit tests: Email templates HTML generation -- [ ] Unit tests: Code generation format validation (6 digits) -- [ ] Unit tests: Code generation randomness (no duplicates in sample) -- [ ] Unit tests: Resend provider with mocked API -- [ ] Unit tests: SMTP provider with mocked transport -- [ ] Unit tests: Provider factory selection logic -- [ ] Unit tests: Provider factory error on invalid config - -### Phase 4: Proto Changes (4 tasks) -- [ ] Update `zopp.proto` with `VerifyEmailRequest/Response` -- [ ] Update `zopp.proto` with `ResendVerificationRequest/Response` -- [ ] Add `verification_required` field to `JoinResponse` -- [ ] Regenerate proto code (`cargo build`) - -### Phase 5: Server Handlers (14 tasks) -- [ ] Modify join handler to support verification flow -- [ ] Implement `verify_email` handler with rate limiting -- [ ] Implement `resend_verification` handler with rate limiting -- [ ] Add background cleanup task for expired verifications -- [ ] Add authentication bypass for `verify_email` RPC -- [ ] Unit tests: Rate limiting logic (5 attempts per code) -- [ ] Unit tests: Rate limiting (3 codes per hour per email) -- [ ] Unit tests: Verification code validation (constant-time comparison) -- [ ] Unit tests: Background cleanup task removes expired verifications -- [ ] Unit tests: Join handler with verification enabled -- [ ] Unit tests: Join handler with verification disabled -- [ ] Unit tests: VerifyEmail handler success case -- [ ] Unit tests: VerifyEmail handler wrong code -- [ ] Unit tests: ResendVerification handler - -### Phase 6: CLI Changes (3 tasks) -- [ ] Modify CLI `cmd_join` to handle verification flow -- [ ] Add code input prompt and retry logic -- [ ] Add resend option during verification - -### Phase 7: E2E Tests (8 tasks) -- [ ] E2E test: Join with verification enabled (happy path) -- [ ] E2E test: Join with verification disabled -- [ ] E2E test: Failed verification (wrong code) -- [ ] E2E test: Expired verification code -- [ ] E2E test: Too many attempts (rate limiting) -- [ ] E2E test: Resend verification code -- [ ] E2E test: Workspace invite with verification -- [ ] E2E test: Bootstrap invite (should bypass verification) - -### Phase 8: Documentation (4 tasks) -- [ ] Update `CLAUDE.md` with email configuration -- [ ] Create `docs/docs/guides/email-verification.md` -- [ ] Update `docs/docs/reference/cli/join.md` -- [ ] Add troubleshooting guide for email delivery issues - -### Phase 9: PR & Review (4 tasks) -- [ ] Run full test suite -- [ ] Run clippy and fmt -- [ ] Create PR and monitor CI -- [ ] Address Cubic review comments - ---- - -## 10. Backwards Compatibility - -- **Verification can be disabled**: `ZOPP_EMAIL_VERIFICATION_REQUIRED=false` -- **Existing principals automatically marked as verified** in migration -- **Bootstrap invites work without verification** (server setup unchanged) -- **No breaking changes to existing APIs** - ---- - -## 11. Files Created/Modified So Far - -### Created -- `crates/zopp-store-sqlite/migrations/20260121000001_add_email_verification.sql` -- `crates/zopp-store-sqlite/migrations/20260121000002_add_principal_verified.sql` -- `crates/zopp-store-postgres/migrations/20260121000001_add_email_verification.sql` -- `crates/zopp-store-postgres/migrations/20260121000002_add_principal_verified.sql` - -### Modified -- `crates/zopp-storage/src/lib.rs` - Added `EmailVerification`, `EmailVerificationId`, `CreateEmailVerificationParams`, and new trait methods -- `crates/zopp-store-sqlite/src/lib.rs` - Implemented email verification methods, updated `Principal` queries to include `verified` -- `crates/zopp-store-postgres/src/lib.rs` - Implemented email verification methods, updated `Principal` queries to include `verified` - ---- - -## 12. Next Steps - -1. **Run `sqlx prepare`** for PostgreSQL (requires Docker container) -2. **Create server configuration module** (`apps/zopp-server/src/config.rs`) -3. **Add email provider dependencies** to `Cargo.toml` -4. **Implement email provider trait and implementations** -5. **Update proto definitions** -6. **Implement server handlers** -7. **Update CLI join command** -8. **Write tests** -9. **Write documentation** -10. **Create PR** - ---- - -## 13. Related Issues - -- Issue #46: Feature: Implement email verification for new principals -- ListWorkspaces exposes workspaces without principal KEK access (mentioned as related in issue) - ---- - -## 14. Test Philosophy - -Per `TESTING.md`: -- **Use real implementations** - Tests use real SQLite/PostgreSQL, not mocks -- **Mock only for external services** - Mock Resend API and SMTP transport -- **100% coverage goal** - Every new function has unit tests -- **E2E for user-facing features** - Full join flow with verification -- **Security-critical code** - Constant-time comparison, rate limiting well-tested diff --git a/session-ses_423c.md b/session-ses_423c.md deleted file mode 100644 index a7a48594..00000000 --- a/session-ses_423c.md +++ /dev/null @@ -1,10799 +0,0 @@ -# Issue 46 investigation - -**Session ID:** ses_423c1cfbcffe69pTjlXGcunWXi -**Created:** 1/20/2026, 1:30:21 PM -**Updated:** 1/20/2026, 4:58:36 PM - ---- - -## User - -tackle issue 46 - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 6.6s) - -_Thinking:_ - -The user wants me to tackle issue 46. I need to find out what issue 46 is first. Since this is a git repository, I should check if there's a GitHub issue #46 or if there are issues tracked elsewhere. - -I'm in plan mode, so I should: -1. Find and read issue 46 -2. Understand what needs to be done -3. Research the codebase to understand the context -4. Create a comprehensive plan -5. Present it to the user - -Let me start by checking if this is a GitHub repository and fetching issue 46. - -I'll help you tackle issue 46. Let me first fetch the details of this issue. - -``` -Tool: bash - -**Input:** -```json -{ - "command": "gh issue view 46 --json title,body,labels,state,author", - "description": "Fetch details of issue 46" -} -``` -**Output:** -``` -{"author":{"id":"MDQ6VXNlcjc3NjQyOTM=","is_bot":false,"login":"vieiralucas","name":"Lucas Vieira"},"body":"## Summary\n\nCurrently, when a user joins with an email address, there's no verification that they actually own that email. This allows anyone to claim any email address and potentially gain access to workspaces associated with that email's user account.\n\n## Current Behavior\n\n1. User A creates account with `victim@example.com` via CLI\n2. User A creates workspaces, gets invited to workspaces\n3. Attacker joins with `victim@example.com` via web UI (no verification)\n4. Attacker is now the same \"user\" and can see workspace metadata (see related issue about ListWorkspaces bug)\n\n## Expected Behavior\n\n1. When joining with an email, send a verification code/link to that email\n2. User must confirm ownership before the principal is activated\n3. Until verified, the principal should not be linked to any existing user account\n\n## Proposed Implementation\n\n### Option A: Email verification code\n1. User enters email during join\n2. Server sends 6-digit code to email\n3. User enters code to complete join\n4. Principal is created and linked to user\n\n### Option B: Magic link\n1. User enters email during join\n2. Server sends magic link to email\n3. Clicking link completes the join process\n\n### Option C: OAuth/SSO\n- Support \"Login with Google/GitHub/etc\" for verified email ownership\n\n## Security Considerations\n\n- Rate limit verification attempts\n- Expire verification codes/links after short period (15 min)\n- Don't reveal whether email already exists (timing attacks)\n- Consider requiring re-verification for sensitive operations\n\n## Related Issues\n\n- ListWorkspaces exposes workspaces without principal KEK access (the combination of both bugs is worse than either alone)","labels":[{"id":"LA_kwDOQJJric8AAAACN8wZyQ","name":"enhancement","description":"New feature or request","color":"a2eeef"},{"id":"LA_kwDOQJJric8AAAACVXLUhA","name":"security","description":"","color":"ededed"}],"state":"OPEN","title":"Feature: Implement email verification for new principals"} - -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 97.3s) - -_Thinking:_ - -Alright, so issue 46 is about implementing email verification for new principals. This is a security issue because currently anyone can claim any email address without verification. - -The issue proposes three options: -- Option A: Email verification code (6-digit) -- Option B: Magic link -- Option C: OAuth/SSO - -Let me understand the current codebase structure to plan this properly. I need to: -1. Understand how the join flow currently works -2. Understand the server and CLI architecture -3. Decide which option to implement (or ask the user) -4. Plan the implementation - -Let me start by exploring the current join implementation. - -I can see this is a security enhancement to add email verification for new principals. Currently, anyone can claim any email address without proof of ownership, which is a significant security issue. - -Let me explore the current join flow to understand the implementation better: - -``` -Tool: task - -**Input:** -```json -{ - "subagent_type": "explore", - "description": "Explore join flow implementation", - "prompt": "Find and analyze the current \"join\" flow implementation. I need to understand:\n\n1. How the CLI `zopp join` command works (find the command implementation)\n2. How the server's Join RPC is implemented\n3. What the Join request/response proto messages contain\n4. How users are created and principals are linked to users\n5. Any existing email handling code\n\nPlease provide:\n- File paths and line numbers for key functions\n- Current flow diagram (step by step)\n- Any existing email infrastructure (SMTP, email sending, etc.)\n\nSearch thoroughly - check apps/zopp-cli, apps/zopp-server, crates/zopp-proto for join-related code." -} -``` -**Output:** -``` -Perfect! Now I have a comprehensive understanding. Let me create a detailed analysis document: - -## Analysis: Join Flow Implementation - -Based on my thorough search of the codebase, here's a complete analysis of the current join flow implementation: - ---- - -### 1. CLI `zopp join` Command Implementation - -**File:** `/Users/lucas/code/faisca/zopp/apps/zopp-cli/src/commands/join.rs` - -**Key Function:** `cmd_join` (lines 6-181) - -**How it works:** -1. **Principal name**: Uses provided name or defaults to hostname (lines 15-18) -2. **Key generation**: Generates Ed25519 signing keypair and X25519 encryption keypair (lines 20-25) -3. **Connect to server**: Establishes gRPC connection (line 27) -4. **Invite processing** (lines 29-49): - - Checks if invite has `inv_` prefix (workspace invite with KEK) - - For workspace invites: decodes secret, computes SHA256 hash for server lookup - - For bootstrap invites: uses token directly (legacy) -5. **Fetch invite metadata**: Calls `GetInvite` RPC to retrieve invite details (lines 52-57) -6. **KEK handling** (lines 60-96): - - **Workspace invites** (KEK present): - - Decrypt KEK using invite secret as DEK - - Re-wrap KEK for the new principal using ECDH - - **Bootstrap invites** (no KEK): Pass empty KEK fields -7. **Join RPC call** (lines 98-110): Sends JoinRequest with email, principal name, keys, and wrapped KEK -8. **Save credentials** (lines 122-178): - - Stores Ed25519 and X25519 private keys either in system keychain or config file - - Saves config to `~/.zopp/config.json` - -**CLI definition:** `/Users/lucas/code/faisca/zopp/apps/zopp-cli/src/cli.rs` (lines 27-38) - ---- - -### 2. Server Join RPC Handler - -**File:** `/Users/lucas/code/faisca/zopp/apps/zopp-server/src/handlers/auth.rs` - -**Key Function:** `join` (lines 15-149) - -**Step-by-step flow:** - -1. **Email normalization** (line 22): Converts email to lowercase for consistent comparison - -2. **Invite validation** (lines 24-37): - - Fetches invite by token (hash lookup) - - Checks if invite is expired - -3. **User creation** (lines 40-109): - - **Attempts to create new user** with principal and workspace memberships (lines 40-56) - - **If user already exists** (`AlreadyExists` error) AND invite has workspaces (lines 60-107): - - This is a workspace invite for an existing user - - Fetch existing user by email (lines 62-66) - - Create NEW principal for existing user (lines 69-87) - - Add user to workspace memberships (lines 90-104) - - **Other errors**: Return internal error (line 108) - -4. **Store wrapped KEK** (lines 111-128): - - For workspace invites with KEK data, store the wrapped KEK for the principal - - Calls `add_workspace_principal` to link principal to workspace with encrypted KEK - -5. **Return response** (lines 130-148): - - Builds list of workspaces from invite - - Returns user_id, principal_id, and workspace list - -**Routing:** `/Users/lucas/code/faisca/zopp/apps/zopp-server/src/handlers/mod.rs` (line 41-42) - ---- - -### 3. Proto Messages - -**File:** `/Users/lucas/code/faisca/zopp/crates/zopp-proto/proto/zopp.proto` - -**JoinRequest** (lines 129-138): -```protobuf -message JoinRequest { - string invite_token = 1; // SHA256 hash of invite secret (for lookup) - string email = 2; // User's email - string principal_name = 3; // Device/principal name - bytes public_key = 4; // Ed25519 for authentication - bytes x25519_public_key = 5; // X25519 for encryption (ECDH) - 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 -} -``` - -**JoinResponse** (lines 140-144): -```protobuf -message JoinResponse { - string user_id = 1; - string principal_id = 2; - repeated Workspace workspaces = 3; -} -``` - ---- - -### 4. User and Principal Creation - -**Storage Layer:** `/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs` - -**CreateUserParams** (lines 224-230): -```rust -pub struct CreateUserParams { - pub email: String, - pub principal: Option, // Atomically create principal - pub workspace_ids: Vec, // User-level workspace memberships -} -``` - -**CreatePrincipalData** (lines 233-239): -```rust -pub struct CreatePrincipalData { - pub name: String, - pub public_key: Vec, // Ed25519 for authentication - pub x25519_public_key: Option>, // X25519 for encryption (ECDH) - pub is_service: bool, // Service principal flag -} -``` - -**User record** (lines 291-296): -```rust -pub struct User { - pub id: UserId, - pub email: String, - pub created_at: DateTime, - pub updated_at: DateTime, -} -``` - -**Storage implementations:** -- SQLite: `/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs` (lines 65-177) -- PostgreSQL: `/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs` (lines 44-141) - -**Key behavior:** -- `create_user` checks if email already exists (returns `AlreadyExists` error if so) -- Atomically creates user + principal in a transaction when principal data is provided -- Links user to workspaces via workspace_ids - ---- - -### 5. Email Handling - -**Current state: NO email sending infrastructure exists** - -Findings from comprehensive search: -- ✅ Email is stored in the `users` table as a unique identifier -- ✅ Email is used for user lookup (`get_user_by_email`) -- ✅ Email is normalized to lowercase for consistency -- ❌ NO SMTP configuration -- ❌ NO email sending libraries (no `lettre`, `sendgrid`, etc.) -- ❌ NO email templates -- ❌ NO email queue/job system - -The email field is currently used purely for: -1. User identification and lookup -2. Group membership (AddGroupMember uses email) -3. User permissions (SetUserWorkspacePermission uses email) - ---- - -### 6. Current Join Flow Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ CLI: zopp join │ -│ │ -│ 1. Generate Ed25519 + X25519 keypairs │ -│ 2. Parse invite code (inv_XXXXX or legacy) │ -│ 3. Call GetInvite RPC (unauthenticated) │ -│ 4. If workspace invite: │ -│ - Decrypt KEK using invite secret │ -│ - Re-wrap KEK for new principal (ECDH) │ -│ 5. Call Join RPC with: │ -│ - email │ -│ - principal_name │ -│ - public keys (Ed25519 + X25519) │ -│ - wrapped KEK (if workspace invite) │ -└──────────────┬──────────────────────────────────────────────────┘ - │ - ▼ gRPC Join Request -┌─────────────────────────────────────────────────────────────────┐ -│ Server: Join Handler │ -│ │ -│ 1. Normalize email to lowercase │ -│ 2. Validate invite: │ -│ - Fetch by token (SHA256 hash lookup) │ -│ - Check not expired │ -│ 3. Attempt to create user: │ -│ ┌───────────────────────────────────────────┐ │ -│ │ Case A: User does NOT exist │ │ -│ │ - Create user + principal atomically │ │ -│ │ - Link to workspaces from invite │ │ -│ │ - Store wrapped KEK (if workspace invite)│ │ -│ └───────────────────────────────────────────┘ │ -│ ┌───────────────────────────────────────────┐ │ -│ │ Case B: User exists + workspace invite │ │ -│ │ - Fetch existing user by email │ │ -│ │ - Create NEW principal for existing user │ │ -│ │ - Add user to workspaces (if not member) │ │ -│ │ - Store wrapped KEK for new principal │ │ -│ └───────────────────────────────────────────┘ │ -│ 4. Return JoinResponse: │ -│ - user_id │ -│ - principal_id │ -│ - workspaces (from invite) │ -└──────────────┬──────────────────────────────────────────────────┘ - │ - ▼ JoinResponse -┌─────────────────────────────────────────────────────────────────┐ -│ CLI: Save Credentials │ -│ │ -│ 1. Store private keys (keychain or file) │ -│ 2. Save config to ~/.zopp/config.json │ -│ 3. Display success message │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -### 7. Invite Types - -Based on the code, there are **two types of invites**: - -**A. Bootstrap Invite** (Server Setup) -- `workspace_ids: []` (empty) -- `kek_encrypted: None` -- `created_by_user_id: None` (server-created) -- Used for initial server setup -- No KEK to process -- See test: `/Users/lucas/code/faisca/zopp/apps/zopp-server/src/tests.rs` (line 1078) - -**B. Workspace Invite** -- `workspace_ids: [workspace_id]` -- `kek_encrypted: Some(encrypted_kek)` -- `created_by_user_id: Some(user_id)` (created by admin) -- Grants access to existing workspace -- KEK must be decrypted and re-wrapped - ---- - -### 8. Key Security Properties - -1. **Zero-knowledge**: Server never sees plaintext invite secret (only SHA256 hash) -2. **Client-side crypto**: All KEK unwrapping/wrapping happens in CLI -3. **Email as identifier**: Email is the user identity, not a communication channel -4. **Principal isolation**: Each device/credential gets its own principal -5. **Workspace access control**: Join handler checks RBAC permissions for invite creation - ---- - -### Summary - -The current join flow is **fully functional** for its intended purpose: -- ✅ Supports both bootstrap invites (new server) and workspace invites (existing server) -- ✅ Handles new users and existing users (multi-device support) -- ✅ Zero-knowledge encryption (server never sees secrets) -- ✅ Email-based user identification - -**What's missing:** -- ❌ NO email verification -- ❌ NO email notifications -- ❌ NO SMTP infrastructure -- ❌ Email is purely an identifier, not a communication method - -If you want to add email-based invites with verification links, you'll need to: -1. Add SMTP configuration to server -2. Create email templates -3. Add email sending on invite creation -4. Implement verification token flow -5. Modify join flow to verify email ownership - - -session_id: ses_423c18ceeffegIp75mowGxUWFw - -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 140.1s) - -_Thinking:_ - -Great! Now I have a comprehensive understanding of the current join flow. Let me think about the implementation options: - -1. **Option A: Email verification code (6-digit)** - - Pros: Simple UX, works well for CLI - - Cons: Requires storing verification state, timing attacks possible - -2. **Option B: Magic link** - - Pros: Better UX for web, more secure (token-based) - - Cons: CLI-first tool - users would need to switch from CLI to browser - -3. **Option C: OAuth/SSO** - - Pros: Industry standard, delegated trust - - Cons: Major implementation effort, requires OAuth providers - -Given that zopp is a **CLI-first** tool, Option A (verification code) seems most appropriate. Option B (magic link) would require users to open a browser in the middle of a CLI flow, which breaks the experience. - -Now let me think about what needs to be implemented: - -1. **Email infrastructure** - - SMTP configuration (server side) - - Email sending library (likely `lettre` for Rust) - - Email templates - - Configuration for SMTP server, credentials, etc. - -2. **Verification flow** - - New database table: `email_verifications` (email, code, expires_at, attempts) - - Generate 6-digit code - - Send email with code - - Verify code submission - - Rate limiting - -3. **Modified join flow** - - Join request triggers verification email - - Create "pending principal" state - - User submits verification code - - Complete join after verification - -4. **Proto changes** - - New RPC: `VerifyEmail` or similar - - Modified `Join` flow to support verification - -5. **CLI changes** - - After initial join, prompt for verification code - - Submit code to server - - Complete join process - -6. **Security considerations** - - Rate limit verification attempts - - Expire codes after 15 minutes - - Avoid leaking whether an email exists through timing differences - - Use cryptographically secure random generation for codes - -I should check with the user about their preferences before moving forward with implementation. - -Perfect! Now I have a comprehensive understanding of the current join flow. Let me present the situation and ask you some key questions before creating the implementation plan. - -## Current State - -The join flow is fully functional for its intended purpose (user + principal creation), but has **NO email infrastructure**: -- ✅ Email is used as a unique identifier for users -- ❌ No SMTP configuration or email sending capability -- ❌ No verification that users own the email addresses they claim - -## Implementation Options - -Given that zopp is a **CLI-first** tool, I'd like your input on the approach: - -``` -Tool: question - -**Input:** -```json -{ - "questions": [ - { - "question": "Which email verification approach should we implement?", - "header": "Verification Approach", - "options": [ - { - "label": "6-digit code (Recommended)", - "description": "User receives code via email, enters it in CLI. Best for CLI-first workflow. Simple implementation." - }, - { - "label": "Magic link", - "description": "User clicks link in email to verify. Requires opening browser during CLI flow. Better for web UI in future." - }, - { - "label": "Both (code + link)", - "description": "Support both methods. More flexible but more complex. User chooses their preferred method." - } - ] - }, - { - "question": "Should verification be required for all joins, or only certain scenarios?", - "header": "Verification Scope", - "options": [ - { - "label": "Always required (Recommended)", - "description": "Every new principal must verify email ownership. Most secure, prevents email hijacking completely." - }, - { - "label": "Required for new users only", - "description": "Only first principal per email needs verification. Additional devices trusted. Less friction for multi-device." - }, - { - "label": "Configurable by server admin", - "description": "Server admin can enable/disable verification. Useful for testing or private deployments." - } - ] - }, - { - "question": "How should we handle SMTP configuration?", - "header": "SMTP Configuration", - "options": [ - { - "label": "Environment variables", - "description": "SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, etc. Standard approach for production." - }, - { - "label": "CLI flags + env vars", - "description": "Allow both --smtp-host flags and environment variables. More flexible." - }, - { - "label": "Config file + env vars", - "description": "Add SMTP section to server config file (TOML/YAML). Environment variables override. Most production-ready." - } - ] - }, - { - "question": "Should we add optional 3rd-party email providers (Sendgrid, Postmark, etc.)?", - "header": "Email Providers", - "options": [ - { - "label": "SMTP only (Recommended)", - "description": "Keep it simple, support standard SMTP. Works with any email service. Less dependencies." - }, - { - "label": "Add Sendgrid/Postmark APIs", - "description": "Support popular email APIs for better reliability and deliverability. More dependencies." - } - ] - } - ] -} -``` -**Output:** -``` -User has answered your questions: "Which email verification approach should we implement?"="6-digit code (Recommended)", "Should verification be required for all joins, or only certain scenarios?"="Configurable by server admin", "How should we handle SMTP configuration?"="so i was actually thinking of providing 3 options. resend, ses and smtp", "Should we add optional 3rd-party email providers (Sendgrid, Postmark, etc.)?"="resend, ses and smtp". You can now continue with the user's answers in mind. -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 11.1s) - -_Thinking:_ - -Great! The user wants: -1. 6-digit code verification (best for CLI) -2. Configurable by server admin (can enable/disable) -3. Support for 3 email providers: Resend, AWS SES, and SMTP - -This is a good approach - gives flexibility for different deployment scenarios: -- **Resend**: Popular modern API, simple, developer-friendly -- **AWS SES**: Enterprise-grade, cost-effective, already integrated for many orgs -- **SMTP**: Universal fallback, works with any email service - -Let me create a comprehensive implementation plan. - -Excellent! That's a great approach - supporting **Resend, AWS SES, and SMTP** gives maximum flexibility for different deployment scenarios. Let me create a comprehensive implementation plan. - -``` -Tool: todowrite - -**Input:** -```json -{ - "todos": [ - { - "id": "1", - "content": "Research and document email provider APIs (Resend, SES, SMTP)", - "status": "pending", - "priority": "high" - }, - { - "id": "2", - "content": "Design database schema for email verification", - "status": "pending", - "priority": "high" - }, - { - "id": "3", - "content": "Design proto changes for verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "4", - "content": "Plan server configuration structure", - "status": "pending", - "priority": "high" - }, - { - "id": "5", - "content": "Create detailed implementation plan", - "status": "pending", - "priority": "high" - } - ] -} -``` -**Output:** -``` -[ - { - "id": "1", - "content": "Research and document email provider APIs (Resend, SES, SMTP)", - "status": "pending", - "priority": "high" - }, - { - "id": "2", - "content": "Design database schema for email verification", - "status": "pending", - "priority": "high" - }, - { - "id": "3", - "content": "Design proto changes for verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "4", - "content": "Plan server configuration structure", - "status": "pending", - "priority": "high" - }, - { - "id": "5", - "content": "Create detailed implementation plan", - "status": "pending", - "priority": "high" - } -] -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 3.6s) - -_Thinking:_ - -Let me start researching the email provider APIs to understand what we need. - -``` -Tool: todowrite - -**Input:** -```json -{ - "todos": [ - { - "id": "1", - "content": "Research and document email provider APIs (Resend, SES, SMTP)", - "status": "in_progress", - "priority": "high" - }, - { - "id": "2", - "content": "Design database schema for email verification", - "status": "pending", - "priority": "high" - }, - { - "id": "3", - "content": "Design proto changes for verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "4", - "content": "Plan server configuration structure", - "status": "pending", - "priority": "high" - }, - { - "id": "5", - "content": "Create detailed implementation plan", - "status": "pending", - "priority": "high" - } - ] -} -``` -**Output:** -``` -[ - { - "id": "1", - "content": "Research and document email provider APIs (Resend, SES, SMTP)", - "status": "in_progress", - "priority": "high" - }, - { - "id": "2", - "content": "Design database schema for email verification", - "status": "pending", - "priority": "high" - }, - { - "id": "3", - "content": "Design proto changes for verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "4", - "content": "Plan server configuration structure", - "status": "pending", - "priority": "high" - }, - { - "id": "5", - "content": "Create detailed implementation plan", - "status": "pending", - "priority": "high" - } -] -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 8.3s) - -_Thinking:_ - -Let me research each email provider's Rust library and API: - -1. **Resend**: I should check if there's a Rust client for Resend -2. **AWS SES**: AWS has Rust SDKs, I should check the SES one -3. **SMTP**: `lettre` is the standard Rust SMTP library - -Let me fetch information about each. - -``` -Tool: webfetch - -**Input:** -```json -{ - "url": "https://docs.rs/resend-rs/latest/resend_rs/", - "format": "markdown" -} -``` -**Output:** -``` -resend\_rs - Rust - - - -[Docs.rs](/) - -- [resend-rs-0.20.0](# "Resend's Official Rust SDK.") - - - resend-rs 0.20.0 - - [Permalink](/resend-rs/0.20.0/resend_rs/ "Get a link to this specific version") - - [Docs.rs crate page](/crate/resend-rs/latest "See resend-rs in docs.rs") - - [MIT](https://spdx.org/licenses/MIT) - - - Links - - [Homepage](https://resend.com/docs/send-with-rust) - - [Repository](https://github.com/resend/resend-rust) - - [crates.io](https://crates.io/crates/resend-rs "See resend-rs in crates.io") - - [Source](/crate/resend-rs/latest/source/ "Browse source of resend-rs-0.20.0") - - - Owners - - [AntoniosBarotsis](https://crates.io/users/AntoniosBarotsis) - - - Dependencies - - - [ecow ^0.2.4 *normal*](/ecow/^0.2.4/) - - [getrandom ^0.3.4 *normal*](/getrandom/^0.3.4/) - - [governor ^0.10.0 *normal*](/governor/^0.10.0/) - - [maybe-async ^0.2.10 *normal*](/maybe-async/^0.2.10/) - - [rand ^0.9.2 *normal*](/rand/^0.9.2/) - - [reqwest ^0.13 *normal*](/reqwest/^0.13/) - - [serde ^1.0 *normal*](/serde/^1.0/) - - [serde\_json ^1.0.145 *normal*](/serde_json/^1.0.145/) - - [thiserror ^2.0 *normal*](/thiserror/^2.0/) - - [anyhow ^1.0 *dev*](/anyhow/^1.0/) - - [axum ^0.8.6 *dev*](/axum/^0.8.6/) - - [http-body-util ^0.1.3 *dev*](/http-body-util/^0.1.3/) - - [jiff ^0.2.13 *dev*](/jiff/^0.2.13/) - - [regex ^1.12.2 *dev*](/regex/^1.12.2/) - - [scraper ^0.25.0 *dev*](/scraper/^0.25.0/) - - [strum ^0.27 *dev*](/strum/^0.27/) - - [svix ^1.77.0 *dev*](/svix/^1.77.0/) - - [tokio ^1.45.0 *dev*](/tokio/^1.45.0/) - - [tokio-shared-rt ^0.1.0 *dev*](/tokio-shared-rt/^0.1.0/) - - - - Versions - - - [**51.46%** of the crate is documented](/crate/resend-rs/latest) - -- [Platform](#) - - [aarch64-apple-darwin](/crate/resend-rs/latest/target-redirect/aarch64-apple-darwin/resend_rs/) - - [i686-pc-windows-msvc](/crate/resend-rs/latest/target-redirect/i686-pc-windows-msvc/resend_rs/) - - [x86\_64-pc-windows-msvc](/crate/resend-rs/latest/target-redirect/x86_64-pc-windows-msvc/resend_rs/) - - [x86\_64-unknown-linux-gnu](/crate/resend-rs/latest/target-redirect/resend_rs/) -- [Feature flags](/crate/resend-rs/latest/features "Browse available feature flags of resend-rs-0.20.0") - -- [docs.rs](#) - - [About docs.rs](/about) - - [Badges](/about/badges) - - [Builds](/about/builds) - - [Metadata](/about/metadata) - - [Shorthand URLs](/about/redirections) - - [Download](/about/download) - - [Rustdoc JSON](/about/rustdoc-json) - - [Build queue](/releases/queue) - - [Privacy policy](https://foundation.rust-lang.org/policies/privacy-policy/#docs.rs) - -- [Rust](#) - - [Rust website](https://www.rust-lang.org/) - - [The Book](https://doc.rust-lang.org/book/) - - [Standard Library API Reference](https://doc.rust-lang.org/std/) - - [Rust by Example](https://doc.rust-lang.org/rust-by-example/) - - [The Cargo Guide](https://doc.rust-lang.org/cargo/guide/) - - [Clippy Documentation](https://doc.rust-lang.org/nightly/clippy) - -## [Crate resend\_rs](#) - -## [resend\_rs](../resend_rs/index.html)0.20.0 - -- [All Items](all.html) - -### [Sections](#) - -- [resend-rs](#resend-rs "resend-rs") - - [Documentation](#documentation "Documentation") - - [Features](#features "Features") - - [Variables](#variables "Variables") - - [Rate Limits](#rate-limits "Rate Limits") - - [Examples](#examples "Examples") - -### [Crate Items](#modules) - -- [Modules](#modules "Modules") -- [Macros](#macros "Macros") -- [Structs](#structs "Structs") -- [Enums](#enums "Enums") -- [Type Aliases](#types "Type Aliases") - -# Crate resend\_rs Copy item path - -[Source](../src/resend_rs/lib.rs.html#1-288) - -Expand description - -### [§](#resend-rs)resend-rs - -[![Build Status](https://img.shields.io/github/actions/workflow/status/resend/resend-rust/ci.yml)](https://github.com/resend/resend-rust/actions/workflows/ci.yml) [![Crate Docs](https://img.shields.io/docsrs/resend-rs)](https://docs.rs/resend-rs) [![Crate Version](https://img.shields.io/crates/v/resend-rs)](https://crates.io/crates/resend-rs) - -A minimal [Resend](https://resend.com) client. - -Add with: - -``` -cargo add resend-rs -cargo add tokio -F macros,rt-multi-thread -``` - -Emails are sent via the `Resend` client which provides both a synchronous and asynchronous send method. The two are mutually exclusive and accessible via the `blocking` feature. The crate uses [reqwest](https://github.com/seanmonstar/reqwest) and [serde](https://github.com/serde-rs/serde) internally. - -#### [§](#documentation)Documentation - -Crate documentation is available in [docsrs](https://docs.rs/resend-rs). Example usage is available in the [get started guide](https://resend.com/docs/send-with-rust) on the Resend website, you can also find examples in the [API reference](https://resend.com/docs/api-reference/). - -#### [§](#features)Features - -- `blocking` to enable the blocking client. -- `native-tls` to use system-native TLS. **Enabled by default**. -- `rustls-tls` to use TLS backed by `rustls`. -- WASM support (see [example](https://github.com/resend/resend-rust/tree/main/examples/cf-worker)) - -#### [§](#variables)Variables - -- `RESEND_API_KEY` to enable `impl Default` for a `Resend` client (Required). -- `RESEND_BASE_URL` to override the default base address: `https://api.resend.com` (Optional). -- `RESEND_RATE_LIMIT` to set the maximum amount of requests you can send per second. By default, this is 9 (Resend defaults to 10). See [the docs](https://docs.rs/resend-rs/latest/resend_rs/#rate-limits) for more information. - -> WARNING: Rate limiting only works when using the async version (default) of the crate - -#### [§](#rate-limits)Rate Limits - -Resend implements rate limiting on their API which can sometimes get in the way of whatever you are trying to do. This crate handles that in 2 ways: - -- Firstly *all* requests made by the [`Resend`](struct.Resend.html "struct resend_rs::Resend") client are automatically rate limited to 9 req/1.1s to avoid collisions with the 10 req/s limit that Resend imposes at the time of writing this. Note that this can be changed by changing the `RESEND_RATE_LIMIT` environment variable (by default it is set to `9`). - - Note that the client can be safely cloned as well as used in async/parallel contexts and the rate limit will work as intended. The only exception to this is creating 2 clients via the [`Resend::new`](struct.Resend.html#method.new "associated function resend_rs::Resend::new") or [`Resend::with_client`](struct.Resend.html#method.with_client "associated function resend_rs::Resend::with_client") methods which should be avoided, use `.clone()` instead. - -- Secondly, a couple of helper methods as well as macros are implemented in the [`rate_limit`](rate_limit/index.html "mod resend_rs::rate_limit") module that allow catching rate limit errors and retrying the request instead of failing. - - These were implemented to handle cases where this crate is used in a horizontally scaled environment and thus needs to work on different machines at the same time in which case the internal rate limits alone cannot guarantee that there will be no rate limit errors. - - As long as only one program is interacting with the Resend servers on your behalf, this module does not need to be used. - - -#### [§](#examples)Examples - -``` -use resend_rs::types::{CreateEmailBaseOptions, Tag}; -use resend_rs::{Resend, Result}; - -#[tokio::main] -async fn main() -> Result<()> { - let resend = Resend::default(); - - let from = "Acme "; - let to = ["delivered@resend.dev"]; - let subject = "Hello World!"; - - let email = CreateEmailBaseOptions::new(from, to, subject) - .with_text("Hello World!") - .with_tag(Tag::new("hello", "world")); - - let id = resend.emails.send(email).await?.id; - println!("id: {id}"); - Ok(()) -} -``` - -## Modules[§](#modules) - -[events](events/index.html "mod resend_rs::events") - -Parsing for Resend’s Events. - -[idempotent](idempotent/index.html "mod resend_rs::idempotent") - -A few helpers for adding idempotency keys to requests that support them. - -[list\_opts](list_opts/index.html "mod resend_rs::list_opts") - -[rate\_limit](rate_limit/index.html "mod resend_rs::rate_limit") - -Helper methods for retrying requests in case of a rate limit error. - -[services](services/index.html "mod resend_rs::services") - -`Resend` API services. - -[types](types/index.html "mod resend_rs::types") - -Request and response types. - -## Macros[§](#macros) - -[json](macro.json.html "macro resend_rs::json") - -Construct a `serde_json::Value` from a JSON literal. - -[retry](macro.retry.html "macro resend_rs::retry") - -Equivalent to [`send_with_retry`](rate_limit/fn.send_with_retry.html "fn resend_rs::rate_limit::send_with_retry"). - -[retry\_opts](macro.retry_opts.html "macro resend_rs::retry_opts") - -Equivalent to [`send_with_retry_opts`](rate_limit/fn.send_with_retry_opts.html "fn resend_rs::rate_limit::send_with_retry_opts"). - -## Structs[§](#structs) - -[Config](struct.Config.html "struct resend_rs::Config") - -Configuration for `Resend` client. - -[ConfigBuilder](struct.ConfigBuilder.html "struct resend_rs::ConfigBuilder") - -Convenience builder for [`Config`](struct.Config.html "struct resend_rs::Config"). - -[Resend](struct.Resend.html "struct resend_rs::Resend") - -The [Resend](https://resend.com) client. - -## Enums[§](#enums) - -[Error](enum.Error.html "enum resend_rs::Error") - -Error type for operations of a [`Resend`](struct.Resend.html "struct resend_rs::Resend") client. - -[Value](enum.Value.html "enum resend_rs::Value") - -Represents any valid JSON value. - -## Type Aliases[§](#types) - -[Result](type.Result.html "type resend_rs::Result") - -Specialized [`Result`](https://doc.rust-lang.org/nightly/core/result/enum.Result.html "enum core::result::Result") type for an [`Error`](enum.Error.html "enum resend_rs::Error"). -``` -``` - -``` -Tool: webfetch - -**Input:** -```json -{ - "url": "https://docs.rs/aws-sdk-sesv2/latest/aws_sdk_sesv2/", - "format": "markdown" -} -``` -**Output:** -``` -aws\_sdk\_sesv2 - Rust - - - -[Docs.rs](/) - -- [aws-sdk-sesv2-1.111.0](# "AWS SDK for Amazon Simple Email Service") - - - aws-sdk-sesv2 1.111.0 - - [Permalink](/aws-sdk-sesv2/1.111.0/aws_sdk_sesv2/ "Get a link to this specific version") - - [Docs.rs crate page](/crate/aws-sdk-sesv2/latest "See aws-sdk-sesv2 in docs.rs") - - [Apache-2.0](https://spdx.org/licenses/Apache-2.0) - - - Links - - [Repository](https://github.com/awslabs/aws-sdk-rust) - - [crates.io](https://crates.io/crates/aws-sdk-sesv2 "See aws-sdk-sesv2 in crates.io") - - [Source](/crate/aws-sdk-sesv2/latest/source/ "Browse source of aws-sdk-sesv2-1.111.0") - - - Owners - - [github:awslabs:rust-sdk-owners](https://crates.io/teams/github:awslabs:rust-sdk-owners) - - [aws-sdk-rust-ci](https://crates.io/users/aws-sdk-rust-ci) - - - Dependencies - - - [aws-credential-types ^1.2.11 *normal*](/aws-credential-types/^1.2.11/) - - [aws-runtime ^1.5.18 *normal*](/aws-runtime/^1.5.18/) - - [aws-smithy-async ^1.2.7 *normal*](/aws-smithy-async/^1.2.7/) - - [aws-smithy-http ^0.62.6 *normal*](/aws-smithy-http/^0.62.6/) - - [aws-smithy-json ^0.61.9 *normal*](/aws-smithy-json/^0.61.9/) - - [aws-smithy-observability ^0.2.0 *normal*](/aws-smithy-observability/^0.2.0/) - - [aws-smithy-runtime ^1.9.8 *normal*](/aws-smithy-runtime/^1.9.8/) - - [aws-smithy-runtime-api ^1.10.0 *normal*](/aws-smithy-runtime-api/^1.10.0/) - - [aws-smithy-types ^1.3.6 *normal*](/aws-smithy-types/^1.3.6/) - - [aws-types ^1.3.11 *normal*](/aws-types/^1.3.11/) - - [bytes ^1.4.0 *normal*](/bytes/^1.4.0/) - - [fastrand ^2.0.0 *normal*](/fastrand/^2.0.0/) - - [http ^0.2.9 *normal*](/http/^0.2.9/) - - [regex-lite ^0.1.5 *normal*](/regex-lite/^0.1.5/) - - [tracing ^0.1 *normal*](/tracing/^0.1/) - - [aws-config ^1.8.12 *dev*](/aws-config/^1.8.12/) - - [aws-credential-types ^1.2.11 *dev*](/aws-credential-types/^1.2.11/) - - [proptest ^1 *dev*](/proptest/^1/) - - [tokio ^1.23.1 *dev*](/tokio/^1.23.1/) - - - - Versions - - - [**100%** of the crate is documented](/crate/aws-sdk-sesv2/latest) - -- [Platform](#) - - [x86\_64-unknown-linux-gnu](/crate/aws-sdk-sesv2/latest/target-redirect/aws_sdk_sesv2/) -- [Feature flags](/crate/aws-sdk-sesv2/latest/features "Browse available feature flags of aws-sdk-sesv2-1.111.0") - -- [docs.rs](#) - - [About docs.rs](/about) - - [Badges](/about/badges) - - [Builds](/about/builds) - - [Metadata](/about/metadata) - - [Shorthand URLs](/about/redirections) - - [Download](/about/download) - - [Rustdoc JSON](/about/rustdoc-json) - - [Build queue](/releases/queue) - - [Privacy policy](https://foundation.rust-lang.org/policies/privacy-policy/#docs.rs) - -- [Rust](#) - - [Rust website](https://www.rust-lang.org/) - - [The Book](https://doc.rust-lang.org/book/) - - [Standard Library API Reference](https://doc.rust-lang.org/std/) - - [Rust by Example](https://doc.rust-lang.org/rust-by-example/) - - [The Cargo Guide](https://doc.rust-lang.org/cargo/guide/) - - [Clippy Documentation](https://doc.rust-lang.org/nightly/clippy) - -## [Crate aws\_sdk\_sesv2](#) - -## [aws\_sdk\_sesv2](../aws_sdk_sesv2/index.html)1.111.0 - -- [All Items](all.html) - -### [Sections](#) - -- [Getting Started](#getting-started "Getting Started") -- [Using the SDK](#using-the-sdk "Using the SDK") -- [Getting Help](#getting-help "Getting Help") -- [Crate Organization](#crate-organization "Crate Organization") - -### [Crate Items](#modules) - -- [Modules](#modules "Modules") -- [Structs](#structs "Structs") -- [Enums](#enums "Enums") - -# Crate aws\_sdk\_sesv2 Copy item path - -[Source](../src/aws_sdk_sesv2/lib.rs.html#1-211) - -Expand description - -[Amazon SES](http://aws.amazon.com/ses) is an Amazon Web Services service that you can use to send email messages to your customers. - -If you’re new to Amazon SES API v2, you might find it helpful to review the [Amazon Simple Email Service Developer Guide](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/). The *Amazon SES Developer Guide* provides information and code samples that demonstrate how to use Amazon SES API v2 features programmatically. - -### [§](#getting-started)Getting Started - -> Examples are available for many services and operations, check out the [usage examples](https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/rustv1). - -The SDK provides one crate per AWS service. You must add [Tokio](https://crates.io/crates/tokio) as a dependency within your Rust project to execute asynchronous code. To add `aws-sdk-sesv2` to your project, add the following to your **Cargo.toml** file: - -``` -[dependencies] -aws-config = { version = "1.1.7", features = ["behavior-version-latest"] } -aws-sdk-sesv2 = "1.111.0" -tokio = { version = "1", features = ["full"] } -``` - -Then in code, a client can be created with the following: - -``` -use aws_sdk_sesv2 as sesv2; - -#[::tokio::main] -async fn main() -> Result<(), sesv2::Error> { - let config = aws_config::load_from_env().await; - let client = aws_sdk_sesv2::Client::new(&config); - - // ... make some calls with the client - - Ok(()) -} -``` - -See the [client documentation](https://docs.rs/aws-sdk-sesv2/latest/aws_sdk_sesv2/client/struct.Client.html) for information on what calls can be made, and the inputs and outputs for each of those calls. - -### [§](#using-the-sdk)Using the SDK - -Until the SDK is released, we will be adding information about using the SDK to the [Developer Guide](https://docs.aws.amazon.com/sdk-for-rust/latest/dg/welcome.html). Feel free to suggest additional sections for the guide by opening an issue and describing what you are trying to do. - -### [§](#getting-help)Getting Help - -- [GitHub discussions](https://github.com/awslabs/aws-sdk-rust/discussions) - For ideas, RFCs & general questions -- [GitHub issues](https://github.com/awslabs/aws-sdk-rust/issues/new/choose) - For bug reports & feature requests -- [Generated Docs (latest version)](https://awslabs.github.io/aws-sdk-rust/) -- [Usage examples](https://github.com/awsdocs/aws-doc-sdk-examples/tree/main/rustv1) - -## [§](#crate-organization)Crate Organization - -The entry point for most customers will be [`Client`](struct.Client.html "struct aws_sdk_sesv2::Client"), which exposes one method for each API offered by Amazon Simple Email Service. The return value of each of these methods is a “fluent builder”, where the different inputs for that API are added by builder-style function call chaining, followed by calling `send()` to get a [`Future`](https://doc.rust-lang.org/nightly/core/future/future/trait.Future.html "trait core::future::future::Future") that will result in either a successful output or a [`SdkError`](error/type.SdkError.html "type aws_sdk_sesv2::error::SdkError"). - -Some of these API inputs may be structs or enums to provide more complex structured information. These structs and enums live in [`types`](types/index.html "mod aws_sdk_sesv2::types"). There are some simpler types for representing data such as date times or binary blobs that live in [`primitives`](primitives/index.html "mod aws_sdk_sesv2::primitives"). - -All types required to configure a client via the [`Config`](struct.Config.html "struct aws_sdk_sesv2::Config") struct live in [`config`](config/index.html "mod aws_sdk_sesv2::config"). - -The [`operation`](operation/index.html "mod aws_sdk_sesv2::operation") module has a submodule for every API, and in each submodule is the input, output, and error type for that API, as well as builders to construct each of those. - -There is a top-level [`Error`](enum.Error.html "enum aws_sdk_sesv2::Error") type that encompasses all the errors that the client can return. Any other error type can be converted to this `Error` type via the [`From`](https://doc.rust-lang.org/nightly/core/convert/trait.From.html "trait core::convert::From") trait. - -The other modules within this crate are not required for normal usage. - -## Modules[§](#modules) - -[client](client/index.html "mod aws_sdk_sesv2::client") - -Client for calling Amazon Simple Email Service. - -[config](config/index.html "mod aws_sdk_sesv2::config") - -Configuration for Amazon Simple Email Service. - -[error](error/index.html "mod aws_sdk_sesv2::error") - -Common errors and error handling utilities. - -[meta](meta/index.html "mod aws_sdk_sesv2::meta") - -Information about this crate. - -[operation](operation/index.html "mod aws_sdk_sesv2::operation") - -All operations that this crate can perform. - -[primitives](primitives/index.html "mod aws_sdk_sesv2::primitives") - -Primitives such as `Blob` or `DateTime` used by other types. - -[types](types/index.html "mod aws_sdk_sesv2::types") - -Data structures used by operation inputs/outputs. - -## Structs[§](#structs) - -[Client](struct.Client.html "struct aws_sdk_sesv2::Client") - -Client for Amazon Simple Email Service - -[Config](struct.Config.html "struct aws_sdk_sesv2::Config") - -Configuration for a aws\_sdk\_sesv2 service client. - -## Enums[§](#enums) - -[Error](enum.Error.html "enum aws_sdk_sesv2::Error") - -All possible error types for this service. -``` -``` - -``` -Tool: webfetch - -**Input:** -```json -{ - "url": "https://docs.rs/lettre/latest/lettre/", - "format": "markdown" -} -``` -**Output:** -``` -lettre - Rust - - - -[Docs.rs](/) - -- [lettre-0.11.19](# "Email client") - - - lettre 0.11.19 - - [Permalink](/lettre/0.11.19/lettre/ "Get a link to this specific version") - - [Docs.rs crate page](/crate/lettre/latest "See lettre in docs.rs") - - [MIT](https://spdx.org/licenses/MIT) - - - Links - - [Homepage](https://lettre.rs) - - [Repository](https://github.com/lettre/lettre) - - [crates.io](https://crates.io/crates/lettre "See lettre in crates.io") - - [Source](/crate/lettre/latest/source/ "Browse source of lettre-0.11.19") - - - Owners - - [amousset](https://crates.io/users/amousset) - - [github:lettre:release](https://crates.io/teams/github:lettre:release) - - - Dependencies - - - [async-std ^1.8 *normal* *optional*](/async-std/^1.8/) - - [async-trait ^0.1 *normal* *optional*](/async-trait/^0.1/) - - [base64 ^0.22 *normal* *optional*](/base64/^0.22/) - - [boring ^4 *normal* *optional*](/boring/^4/) - - [chumsky ^0.9 *normal*](/chumsky/^0.9/) - - [ed25519-dalek ^2 *normal* *optional*](/ed25519-dalek/^2/) - - [email-encoding ^0.4 *normal* *optional*](/email-encoding/^0.4/) - - [email\_address ^0.2.1 *normal*](/email_address/^0.2.1/) - - [fastrand ^2.0 *normal* *optional*](/fastrand/^2.0/) - - [futures-io ^0.3.7 *normal* *optional*](/futures-io/^0.3.7/) - - [futures-rustls ^0.26 *normal* *optional*](/futures-rustls/^0.26/) - - [futures-util ^0.3.7 *normal* *optional*](/futures-util/^0.3.7/) - - [hostname ^0.4 *normal* *optional*](/hostname/^0.4/) - - [httpdate ^1 *normal* *optional*](/httpdate/^1/) - - [idna ^1 *normal*](/idna/^1/) - - [mime ^0.3.4 *normal* *optional*](/mime/^0.3.4/) - - [native-tls ^0.2.9 *normal* *optional*](/native-tls/^0.2.9/) - - [nom ^8 *normal* *optional*](/nom/^8/) - - [percent-encoding ^2.3 *normal* *optional*](/percent-encoding/^2.3/) - - [quoted\_printable ^0.5 *normal* *optional*](/quoted_printable/^0.5/) - - [rsa ^0.9 *normal* *optional*](/rsa/^0.9/) - - [rustls ^0.23.18 *normal* *optional*](/rustls/^0.23.18/) - - [rustls-native-certs ^0.8 *normal* *optional*](/rustls-native-certs/^0.8/) - - [rustls-platform-verifier ^0.6.0 *normal* *optional*](/rustls-platform-verifier/^0.6.0/) - - [serde ^1.0.110 *normal* *optional*](/serde/^1.0.110/) - - [serde\_json ^1 *normal* *optional*](/serde_json/^1/) - - [sha2 ^0.10 *normal* *optional*](/sha2/^0.10/) - - [socket2 ^0.6 *normal* *optional*](/socket2/^0.6/) - - [tokio-boring ^4 *normal* *optional*](/tokio-boring/^4/) - - [tokio ^1 *normal* *optional*](/tokio/^1/) - - [tokio-native-tls ^0.3 *normal* *optional*](/tokio-native-tls/^0.3/) - - [tokio-rustls ^0.26 *normal* *optional*](/tokio-rustls/^0.26/) - - [tracing ^0.1.16 *normal* *optional*](/tracing/^0.1.16/) - - [url ^2.4 *normal* *optional*](/url/^2.4/) - - [uuid ^1 *normal* *optional*](/uuid/^1/) - - [webpki-roots ^1.0.0 *normal* *optional*](/webpki-roots/^1.0.0/) - - [async-std ^1.8 *dev*](/async-std/^1.8/) - - [criterion ^0.5 *dev*](/criterion/^0.5/) - - [glob ^0.3 *dev*](/glob/^0.3/) - - [maud ^0.26 *dev*](/maud/^0.26/) - - [pretty\_assertions ^1 *dev*](/pretty_assertions/^1/) - - [serde\_json ^1 *dev*](/serde_json/^1/) - - [tokio ^1 *dev*](/tokio/^1/) - - [tracing ^0.1.16 *dev*](/tracing/^0.1.16/) - - [tracing-subscriber ^0.3 *dev*](/tracing-subscriber/^0.3/) - - [walkdir ^2 *dev*](/walkdir/^2/) - - [web-time ^1.1.0 *normal* *optional*](/web-time/^1.1.0/) - - - - Versions - - - [**93.07%** of the crate is documented](/crate/lettre/latest) - -- [Platform](#) - - [x86\_64-unknown-linux-gnu](/crate/lettre/latest/target-redirect/lettre/) -- [Feature flags](/crate/lettre/latest/features "Browse available feature flags of lettre-0.11.19") - -- [docs.rs](#) - - [About docs.rs](/about) - - [Badges](/about/badges) - - [Builds](/about/builds) - - [Metadata](/about/metadata) - - [Shorthand URLs](/about/redirections) - - [Download](/about/download) - - [Rustdoc JSON](/about/rustdoc-json) - - [Build queue](/releases/queue) - - [Privacy policy](https://foundation.rust-lang.org/policies/privacy-policy/#docs.rs) - -- [Rust](#) - - [Rust website](https://www.rust-lang.org/) - - [The Book](https://doc.rust-lang.org/book/) - - [Standard Library API Reference](https://doc.rust-lang.org/std/) - - [Rust by Example](https://doc.rust-lang.org/rust-by-example/) - - [The Cargo Guide](https://doc.rust-lang.org/cargo/guide/) - - [Clippy Documentation](https://doc.rust-lang.org/nightly/clippy) - -## [Crate lettre](#) - -[![logo](https://avatars0.githubusercontent.com/u/15113230?v=4)](../lettre/index.html) - -## [lettre](../lettre/index.html)0.11.19 - -- [All Items](all.html) - -### [Sections](#) - -- [Features](#features "Features") - - [Typed message builder](#typed-message-builder "Typed message builder") - - [SMTP transport](#smtp-transport "SMTP transport") - - [Sendmail transport](#sendmail-transport "Sendmail transport") - - [File transport](#file-transport "File transport") - - [Async execution runtimes](#async-execution-runtimes "Async execution runtimes") - - [Misc features](#misc-features "Misc features") - -### [Crate Items](#reexports) - -- [Re-exports](#reexports "Re-exports") -- [Modules](#modules "Modules") -- [Structs](#structs "Structs") -- [Traits](#traits "Traits") - -# Crate lettre Copy item path - -[Source](../src/lettre/lib.rs.html#1-384) - -Expand description - -Lettre is an email library that allows creating and sending messages. It provides: - -- An easy to use email builder -- Pluggable email transports -- Unicode support -- Secure defaults -- Async support - -Lettre requires Rust 1.74 or newer. - -### [§](#features)Features - -This section lists each lettre feature and briefly explains it. More info about each module can be found in the corresponding module page. - -Features with `📫` near them are enabled by default. - -#### [§](#typed-message-builder)Typed message builder - -*Strongly typed [`message`](message/index.html "mod lettre::message") builder* - -- **builder** 📫: Enable the [`Message`](struct.Message.html "struct lettre::Message") builder -- **hostname** 📫: Try to use the actual system hostname in the `Message-ID` header - -#### [§](#smtp-transport)SMTP transport - -*Send emails using [`SMTP`](transport/smtp/index.html "mod lettre::transport::smtp")* - -- **smtp-transport** 📫: Enable the SMTP transport -- **pool** 📫: Connection pool for SMTP transport -- **hostname** 📫: Try to use the actual system hostname for the SMTP `CLIENTID` - -##### [§](#smtp-over-tls-via-the-native-tls-crate)SMTP over TLS via the native-tls crate - -*Secure SMTP connections using TLS from the `native-tls` crate* - -Uses schannel on Windows, Security-Framework on macOS, and OpenSSL on all other platforms. - -- **native-tls** 📫: TLS support for the synchronous version of the API -- **tokio1-native-tls**: TLS support for the `tokio1` async version of the API - -NOTE: native-tls isn’t supported with `async-std` - -###### [§](#building-lettre-with-openssl)Building lettre with OpenSSL - -When building lettre with native-tls on a system that makes use of OpenSSL, the following packages will need to be installed in order for the build and the compiled program to run properly. - -Distro - -Build-time packages - -Runtime packages - -Debian - -`pkg-config`, `libssl-dev` - -`libssl3`, `ca-certificates` - -Alpine Linux - -`pkgconf`, `openssl-dev` - -`libssl3`, `ca-certificates` - -##### [§](#smtp-over-tls-via-the-boring-crate-boring-tls)SMTP over TLS via the boring crate (Boring TLS) - -*Secure SMTP connections using TLS from the `boring-tls` crate* - -- **boring-tls**: TLS support for the synchronous version of the API -- **tokio1-boring-tls**: TLS support for the `tokio1` async version of the API - -NOTE: boring-tls isn’t supported with `async-std` - -##### [§](#smtp-over-tls-via-the-rustls-crate)SMTP over TLS via the rustls crate - -*Secure SMTP connections using TLS from the `rustls` crate* - -- **rustls**: TLS support for the synchronous version of the API -- **tokio1-rustls**: TLS support for the `tokio1` async version of the API -- **async-std1-rustls**: TLS support for the `async-std1` async version of the API - -###### [§](#rustls-crypto-backends)rustls crypto backends - -*The crypto implementation to use with rustls* - -When the `rustls` feature is enabled, one of the following crypto backends MUST also be enabled. - -- **aws-lc-rs**: use [AWS-LC](https://github.com/aws/aws-lc) (via [`aws-lc-rs`](https://crates.io/crates/aws-lc-rs)) as the `rustls` crypto backend -- **ring**: use [`ring`](https://crates.io/crates/ring) as the `rustls` crypto backend - -When enabling `aws-lc-rs`, the `fips` feature can also be enabled to have rustls use the FIPS certified module of AWS-LC. - -`aws-lc-rs` may require cmake on some platforms to compile. `fips` always requires cmake and the Go compiler to compile. - -###### [§](#rustls-certificate-verification-backend)rustls certificate verification backend - -*The TLS certificate verification backend to use with rustls* - -When the `rustls` feature is enabled, one of the following verification backends MUST also be enabled. - -- **rustls-platform-verifier**: verify TLS certificate using the OS’s native certificate store (see [`rustls-platform-verifier`](https://crates.io/crates/rustls-platform-verifier)) -- **rustls-native-certs**: verify TLS certificates using the platform’s native certificate store (see [`rustls-native-certs`](https://crates.io/crates/rustls-native-certs)) - when in doubt use `rustls-platform-verifier` -- **webpki-roots**: verify TLS certificates against Mozilla’s root certificates (see [`webpki-roots`](https://crates.io/crates/webpki-roots)) - -The following packages will need to be installed in order for the build stage and the compiled program to run properly. - -Verification backend - -Distro - -Build-time packages - -Runtime packages - -`rustls-platform-verifier` - -Debian - -none - -`ca-certificates` - -`rustls-platform-verifier` - -Alpine Linux - -none - -`ca-certificates` - -`rustls-native-certs` - -Debian - -none - -`ca-certificates` - -`rustls-native-certs` - -Alpine Linux - -none - -`ca-certificates` - -`webpki-roots` - -any - -none - -none - -#### [§](#sendmail-transport)Sendmail transport - -*Send emails using the [`sendmail`](transport/sendmail/index.html "mod lettre::transport::sendmail") command* - -- **sendmail-transport**: Enable the `sendmail` transport - -#### [§](#file-transport)File transport - -*Save emails as an `.eml` [`file`](transport/file/index.html "mod lettre::transport::file")* - -- **file-transport**: Enable the file transport (saves emails into an `.eml` file) -- **file-transport-envelope**: Allow writing the envelope into a JSON file (additionally saves envelopes into a `.json` file) - -#### [§](#async-execution-runtimes)Async execution runtimes - -*Use [tokio](https://docs.rs/tokio/1) or [async-std](https://docs.rs/async-std/1) as an async execution runtime for sending emails* - -The correct runtime version must be chosen in order for lettre to work correctly. For example, when sending emails from a Tokio 1.x context, the Tokio 1.x executor ([`Tokio1Executor`](struct.Tokio1Executor.html "struct lettre::Tokio1Executor")) must be used. Using a different version (for example Tokio 0.2.x), or async-std, would result in a runtime panic. - -- **tokio1**: Allow to asynchronously send emails using [Tokio 1.x](https://docs.rs/tokio/1) -- **async-std1**: Allow to asynchronously send emails using [async-std 1.x](https://docs.rs/async-std/1) - -NOTE: native-tls isn’t supported with `async-std` - -#### [§](#misc-features)Misc features - -*Additional features* - -- **serde**: Serialization/Deserialization of entities -- **tracing**: Logging using the `tracing` crate -- **mime03**: Allow creating a [`ContentType`](message/header/struct.ContentType.html "struct lettre::message::header::ContentType") from an existing [mime 0.3](https://docs.rs/mime/0.3) `Mime` struct -- **dkim**: Add support for signing email with DKIM -- **web**: WebAssembly support using the `web-time` crate for time operations - -## Re-exports[§](#reexports) - -`pub use crate::address::[Address](address/struct.Address.html "struct lettre::address::Address");` - -`pub use crate::transport::smtp::[AsyncSmtpTransport](transport/smtp/struct.AsyncSmtpTransport.html "struct lettre::transport::smtp::AsyncSmtpTransport");``smtp-transport` and (`tokio1` or `async-std1`) - -`pub use crate::transport::smtp::[SmtpTransport](transport/smtp/struct.SmtpTransport.html "struct lettre::transport::smtp::SmtpTransport");``smtp-transport` - -## Modules[§](#modules) - -[address](address/index.html "mod lettre::address") - -Email addresses - -[error](error/index.html "mod lettre::error") - -Error type for email messages - -[message](message/index.html "mod lettre::message")`builder` - -Provides a strongly typed way to build emails - -[transport](transport/index.html "mod lettre::transport") - -Transports for sending emails - -## Structs[§](#structs) - -[AsyncFileTransport](struct.AsyncFileTransport.html "struct lettre::AsyncFileTransport")`tokio1` or `async-std1` - -Asynchronously writes the content and the envelope information to a file - -[AsyncSendmailTransport](struct.AsyncSendmailTransport.html "struct lettre::AsyncSendmailTransport")`tokio1` or `async-std1` - -Asynchronously sends emails using the `sendmail` command - -[AsyncStd1Executor](struct.AsyncStd1Executor.html "struct lettre::AsyncStd1Executor")`async-std1` - -Async [`Executor`](trait.Executor.html "trait lettre::Executor") using `async-std` `1.x` - -[FileTransport](struct.FileTransport.html "struct lettre::FileTransport")`file-transport` - -Writes the content and the envelope information to a file - -[Message](struct.Message.html "struct lettre::Message")`builder` - -Email message which can be formatted - -[SendmailTransport](struct.SendmailTransport.html "struct lettre::SendmailTransport")`sendmail-transport` - -Sends emails using the `sendmail` command - -[Tokio1Executor](struct.Tokio1Executor.html "struct lettre::Tokio1Executor")`tokio1` - -Async [`Executor`](trait.Executor.html "trait lettre::Executor") using `tokio` `1.x` - -## Traits[§](#traits) - -[AsyncTransport](trait.AsyncTransport.html "trait lettre::AsyncTransport")`tokio1` or `async-std1` - -Async Transport method for emails - -[Executor](trait.Executor.html "trait lettre::Executor")`tokio1` or `async-std1` - -Async executor abstraction trait - -[Transport](trait.Transport.html "trait lettre::Transport") - -Blocking Transport method for emails -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 11.5s) - -_Thinking:_ - -Perfect! I now have information about all three email providers. Let me summarize what I found: - -1. **Resend** (`resend-rs` crate 0.20.0): - - Official Rust SDK - - Simple API, async by default (with blocking feature) - - Built-in rate limiting (9 req/1.1s by default) - - Supports environment variable `RESEND_API_KEY` - - Very simple to use: just create client with API key and send - -2. **AWS SES** (`aws-sdk-sesv2` crate 1.111.0): - - Official AWS SDK - - More complex setup (AWS credentials, region config) - - Very robust and enterprise-ready - - Requires AWS configuration (`aws-config`) - - Async by default with tokio - -3. **SMTP** (`lettre` crate 0.11.19): - - Most flexible, works with any SMTP server - - Supports multiple TLS backends (native-tls, rustls, boring-tls) - - Both sync and async APIs - - Feature-rich (connection pooling, DKIM, etc.) - - Requires SMTP server configuration - -Now let me continue with the plan design. - -``` -Tool: todowrite - -**Input:** -```json -{ - "todos": [ - { - "id": "1", - "content": "Research and document email provider APIs (Resend, SES, SMTP)", - "status": "completed", - "priority": "high" - }, - { - "id": "2", - "content": "Design database schema for email verification", - "status": "in_progress", - "priority": "high" - }, - { - "id": "3", - "content": "Design proto changes for verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "4", - "content": "Plan server configuration structure", - "status": "pending", - "priority": "high" - }, - { - "id": "5", - "content": "Create detailed implementation plan", - "status": "pending", - "priority": "high" - } - ] -} -``` -**Output:** -``` -[ - { - "id": "1", - "content": "Research and document email provider APIs (Resend, SES, SMTP)", - "status": "completed", - "priority": "high" - }, - { - "id": "2", - "content": "Design database schema for email verification", - "status": "in_progress", - "priority": "high" - }, - { - "id": "3", - "content": "Design proto changes for verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "4", - "content": "Plan server configuration structure", - "status": "pending", - "priority": "high" - }, - { - "id": "5", - "content": "Create detailed implementation plan", - "status": "pending", - "priority": "high" - } -] -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 6.6s) - -_Thinking:_ - -Now let me design the database schema for email verification. I need to check the current database schemas to understand the structure. - -``` -Tool: glob - -**Input:** -```json -{ - "pattern": "**/*.sql" -} -``` -**Output:** -``` -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260116000001_add_export_code.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260116000002_add_verification_salt.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260114000001_add_self_invites.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260115000001_add_principal_exports.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260115000001_add_principal_exports.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260116000002_add_verification_salt.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260116000001_add_export_code.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260114000001_add_self_invites.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260110000001_add_audit_logs.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260110000001_add_audit_logs.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260105000002_add_groups.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260105000003_add_user_permissions.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260105000001_add_rbac.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260105000002_add_groups.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260105000003_add_user_permissions.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260105000001_add_rbac.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20251227000001_add_environment_version.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20251026125600_init.sql -/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20251230171100_init.sql -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 3.4s) - -_Thinking:_ - -Let me look at the initial migration to understand the database schema structure. - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20251026125600_init.sql" -} -``` -**Output:** -``` - -00001| PRAGMA foreign_keys = ON; -00002| -00003| CREATE TABLE IF NOT EXISTS users ( -00004| id TEXT PRIMARY KEY NOT NULL, -- UUID string -00005| email TEXT NOT NULL UNIQUE, -00006| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00007| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')) -00008| ); -00009| -00010| CREATE TRIGGER IF NOT EXISTS users_updated_at AFTER UPDATE ON users -00011| BEGIN -00012| UPDATE users SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; -00013| END; -00014| -00015| CREATE TABLE IF NOT EXISTS principals ( -00016| id TEXT PRIMARY KEY NOT NULL, -- UUID string -00017| user_id TEXT REFERENCES users(id) ON DELETE CASCADE, -- NULL for service accounts -00018| name TEXT NOT NULL, -- Unique per user, not globally -00019| public_key BLOB NOT NULL, -00020| x25519_public_key BLOB, -- X25519 for encryption (ECDH) -00021| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00022| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')) -00023| ); -00024| -00025| -- Principal names are unique per user (alice can have "laptop", bob can also have "laptop") -00026| CREATE UNIQUE INDEX principals_user_name_unique ON principals(user_id, name) WHERE user_id IS NOT NULL; -00027| -00028| CREATE TRIGGER IF NOT EXISTS principals_updated_at AFTER UPDATE ON principals -00029| BEGIN -00030| UPDATE principals SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; -00031| END; -00032| -00033| CREATE TABLE IF NOT EXISTS workspaces ( -00034| id TEXT PRIMARY KEY NOT NULL, -- UUID string -00035| name TEXT NOT NULL UNIQUE, -- Globally unique workspace names -00036| owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, -00037| kdf_salt BLOB NOT NULL, -00038| kdf_m_cost_kib INTEGER NOT NULL, -00039| kdf_t_cost INTEGER NOT NULL, -00040| kdf_p_cost INTEGER NOT NULL, -00041| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00042| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')) -00043| ); -00044| -00045| CREATE TRIGGER IF NOT EXISTS workspaces_updated_at AFTER UPDATE ON workspaces -00046| BEGIN -00047| UPDATE workspaces SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; -00048| END; -00049| -00050| CREATE TABLE IF NOT EXISTS workspace_members ( -00051| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00052| user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, -00053| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00054| PRIMARY KEY (workspace_id, user_id) -00055| ); -00056| -00057| CREATE TABLE IF NOT EXISTS workspace_principals ( -00058| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00059| principal_id TEXT NOT NULL REFERENCES principals(id) ON DELETE CASCADE, -00060| ephemeral_pub BLOB NOT NULL, -- Ephemeral X25519 public key for KEK wrapping -00061| kek_wrapped BLOB NOT NULL, -- Workspace KEK wrapped for this principal -00062| kek_nonce BLOB NOT NULL, -- 24-byte nonce for KEK wrapping -00063| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00064| PRIMARY KEY (workspace_id, principal_id) -00065| ); -00066| -00067| CREATE TABLE IF NOT EXISTS invites ( -00068| id TEXT PRIMARY KEY NOT NULL, -- UUID string -00069| token TEXT NOT NULL UNIQUE, -00070| kek_encrypted BLOB, -- Workspace KEK encrypted with invite secret -00071| kek_nonce BLOB, -- 24-byte nonce for KEK encryption -00072| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00073| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00074| expires_at TEXT NOT NULL, -00075| created_by_user_id TEXT REFERENCES users(id) ON DELETE CASCADE, -- NULL for server-created invites -00076| revoked INTEGER NOT NULL DEFAULT 0 -- boolean: 0 = active, 1 = revoked -00077| ); -00078| -00079| CREATE TRIGGER IF NOT EXISTS invites_updated_at AFTER UPDATE ON invites -00080| BEGIN -00081| UPDATE invites SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; -00082| END; -00083| -00084| CREATE TABLE IF NOT EXISTS invite_workspaces ( -00085| invite_id TEXT NOT NULL REFERENCES invites(id) ON DELETE CASCADE, -00086| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00087| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00088| PRIMARY KEY (invite_id, workspace_id) -00089| ); -00090| -00091| CREATE TABLE IF NOT EXISTS projects ( -00092| id TEXT PRIMARY KEY NOT NULL, -- UUID string -00093| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00094| name TEXT NOT NULL, -00095| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00096| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00097| UNIQUE(workspace_id, name) -00098| ); -00099| -00100| CREATE TRIGGER IF NOT EXISTS projects_updated_at AFTER UPDATE ON projects -00101| BEGIN -00102| UPDATE projects SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; -00103| END; -00104| -00105| CREATE TABLE IF NOT EXISTS environments ( -00106| id TEXT PRIMARY KEY NOT NULL, -- UUID string -00107| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00108| project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, -00109| name TEXT NOT NULL, -00110| dek_wrapped BLOB NOT NULL, -00111| dek_nonce BLOB NOT NULL, -00112| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00113| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00114| UNIQUE(workspace_id, project_id, name) -00115| ); -00116| -00117| CREATE TRIGGER IF NOT EXISTS environments_updated_at AFTER UPDATE ON environments -00118| BEGIN -00119| UPDATE environments SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; -00120| END; -00121| -00122| CREATE TABLE IF NOT EXISTS secrets ( -00123| id TEXT PRIMARY KEY NOT NULL, -- UUID string -00124| workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00125| env_id TEXT NOT NULL REFERENCES environments(id) ON DELETE CASCADE, -00126| key_name TEXT NOT NULL, -00127| nonce BLOB NOT NULL, -00128| ciphertext BLOB NOT NULL, -00129| created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00130| updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), -00131| UNIQUE(workspace_id, env_id, key_name) -00132| ); -00133| -00134| CREATE TRIGGER IF NOT EXISTS secrets_updated_at AFTER UPDATE ON secrets -00135| BEGIN -00136| UPDATE secrets SET updated_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.id; -00137| END; -00138| - -(End of file - total 138 lines) - -``` -``` - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20251230171100_init.sql" -} -``` -**Output:** -``` - -00001| CREATE TABLE IF NOT EXISTS users ( -00002| id UUID PRIMARY KEY NOT NULL, -00003| email TEXT NOT NULL UNIQUE, -00004| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00005| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -00006| ); -00007| -00008| CREATE TABLE IF NOT EXISTS principals ( -00009| id UUID PRIMARY KEY NOT NULL, -00010| user_id UUID REFERENCES users(id) ON DELETE CASCADE, -- NULL for service accounts -00011| name TEXT NOT NULL, -00012| public_key BYTEA NOT NULL, -00013| x25519_public_key BYTEA, -- X25519 for encryption (ECDH) -00014| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00015| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -00016| ); -00017| -00018| -- Principal names are unique per user -00019| CREATE UNIQUE INDEX principals_user_name_unique ON principals(user_id, name) WHERE user_id IS NOT NULL; -00020| -00021| CREATE TABLE IF NOT EXISTS workspaces ( -00022| id UUID PRIMARY KEY NOT NULL, -00023| name TEXT NOT NULL UNIQUE, -00024| owner_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -00025| kdf_salt BYTEA NOT NULL, -00026| kdf_m_cost_kib INTEGER NOT NULL, -00027| kdf_t_cost INTEGER NOT NULL, -00028| kdf_p_cost INTEGER NOT NULL, -00029| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00030| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -00031| ); -00032| -00033| CREATE TABLE IF NOT EXISTS workspace_members ( -00034| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00035| user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -00036| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00037| PRIMARY KEY (workspace_id, user_id) -00038| ); -00039| -00040| CREATE TABLE IF NOT EXISTS workspace_principals ( -00041| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00042| principal_id UUID NOT NULL REFERENCES principals(id) ON DELETE CASCADE, -00043| ephemeral_pub BYTEA NOT NULL, -00044| kek_wrapped BYTEA NOT NULL, -00045| kek_nonce BYTEA NOT NULL, -00046| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00047| PRIMARY KEY (workspace_id, principal_id) -00048| ); -00049| -00050| CREATE TABLE IF NOT EXISTS invites ( -00051| id UUID PRIMARY KEY NOT NULL, -00052| token TEXT NOT NULL UNIQUE, -00053| kek_encrypted BYTEA, -00054| kek_nonce BYTEA, -00055| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00056| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00057| expires_at TIMESTAMPTZ NOT NULL, -00058| created_by_user_id UUID REFERENCES users(id) ON DELETE CASCADE, -- NULL for server-created invites -00059| revoked BOOLEAN NOT NULL DEFAULT FALSE -00060| ); -00061| -00062| CREATE TABLE IF NOT EXISTS invite_workspaces ( -00063| invite_id UUID NOT NULL REFERENCES invites(id) ON DELETE CASCADE, -00064| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00065| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00066| PRIMARY KEY (invite_id, workspace_id) -00067| ); -00068| -00069| CREATE TABLE IF NOT EXISTS projects ( -00070| id UUID PRIMARY KEY NOT NULL, -00071| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00072| name TEXT NOT NULL, -00073| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00074| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00075| UNIQUE(workspace_id, name) -00076| ); -00077| -00078| CREATE TABLE IF NOT EXISTS environments ( -00079| id UUID PRIMARY KEY NOT NULL, -00080| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00081| project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, -00082| name TEXT NOT NULL, -00083| dek_wrapped BYTEA NOT NULL, -00084| dek_nonce BYTEA NOT NULL, -00085| version BIGINT NOT NULL DEFAULT 0, -00086| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00087| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00088| UNIQUE(workspace_id, project_id, name) -00089| ); -00090| -00091| CREATE TABLE IF NOT EXISTS secrets ( -00092| id UUID PRIMARY KEY NOT NULL, -00093| workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, -00094| env_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE, -00095| key_name TEXT NOT NULL, -00096| nonce BYTEA NOT NULL, -00097| ciphertext BYTEA NOT NULL, -00098| created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00099| updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -00100| UNIQUE(workspace_id, env_id, key_name) -00101| ); -00102| -00103| -- Triggers for updated_at columns -00104| CREATE OR REPLACE FUNCTION update_updated_at_column() -00105| RETURNS TRIGGER AS $$ -00106| BEGIN -00107| NEW.updated_at = NOW(); -00108| RETURN NEW; -00109| END; -00110| $$ language 'plpgsql'; -00111| -00112| CREATE TRIGGER users_updated_at BEFORE UPDATE ON users -00113| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -00114| -00115| CREATE TRIGGER principals_updated_at BEFORE UPDATE ON principals -00116| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -00117| -00118| CREATE TRIGGER workspaces_updated_at BEFORE UPDATE ON workspaces -00119| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -00120| -00121| CREATE TRIGGER invites_updated_at BEFORE UPDATE ON invites -00122| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -00123| -00124| CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects -00125| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -00126| -00127| CREATE TRIGGER environments_updated_at BEFORE UPDATE ON environments -00128| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -00129| -00130| CREATE TRIGGER secrets_updated_at BEFORE UPDATE ON secrets -00131| FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -00132| - -(End of file - total 132 lines) - -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 8.7s) - -_Thinking:_ - -Perfect! Now I understand the database structure. I need to design a table for email verification that will store:- email (to verify)- verification code (6-digit)- created_at timestamp- expires_at timestamp (15 minutes from creation) -- attempts (rate limiting) -- verified (boolean, if completed) - -Let me now create a comprehensive implementation plan based on all the research I've done. - -``` -Tool: todowrite - -**Input:** -```json -{ - "todos": [ - { - "id": "1", - "content": "Research and document email provider APIs (Resend, SES, SMTP)", - "status": "completed", - "priority": "high" - }, - { - "id": "2", - "content": "Design database schema for email verification", - "status": "completed", - "priority": "high" - }, - { - "id": "3", - "content": "Design proto changes for verification flow", - "status": "completed", - "priority": "high" - }, - { - "id": "4", - "content": "Plan server configuration structure", - "status": "completed", - "priority": "high" - }, - { - "id": "5", - "content": "Create detailed implementation plan", - "status": "completed", - "priority": "high" - } - ] -} -``` -**Output:** -``` -[ - { - "id": "1", - "content": "Research and document email provider APIs (Resend, SES, SMTP)", - "status": "completed", - "priority": "high" - }, - { - "id": "2", - "content": "Design database schema for email verification", - "status": "completed", - "priority": "high" - }, - { - "id": "3", - "content": "Design proto changes for verification flow", - "status": "completed", - "priority": "high" - }, - { - "id": "4", - "content": "Plan server configuration structure", - "status": "completed", - "priority": "high" - }, - { - "id": "5", - "content": "Create detailed implementation plan", - "status": "completed", - "priority": "high" - } -] -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 85.1s) - -_Thinking:_ - -Great! Now let me create a comprehensive implementation plan document to present to the user. - -Perfect! I've completed the research and design phase. Here's a comprehensive implementation plan for issue #46: - ---- - -# Implementation Plan: Email Verification for New Principals (Issue #46) - -## Executive Summary - -Implement email verification using **6-digit codes** with support for **3 email providers** (Resend, AWS SES, SMTP). Email verification will be **configurable by server admin** to allow disabling for development/private deployments. - ---- - -## 1. Email Provider Libraries - -### Chosen Crates - -| Provider | Crate | Version | Notes | -|----------|-------|---------|-------| -| **Resend** | `resend-rs` | 0.20.0 | Official SDK, simple API, built-in rate limiting | -| **AWS SES** | `aws-sdk-sesv2` | 1.111.0 | Official AWS SDK, requires AWS config | -| **SMTP** | `lettre` | 0.11.19 | Universal fallback, feature-rich | - -### Dependencies to Add - -```toml -# In apps/zopp-server/Cargo.toml -[dependencies] -# Email providers (all optional, at least one must be enabled) -resend-rs = { version = "0.20", optional = true } -aws-sdk-sesv2 = { version = "1.111", optional = true } -aws-config = { version = "1.8", optional = true } -lettre = { version = "0.11", features = ["tokio1-rustls", "smtp-transport"], optional = true } - -# Random number generation for verification codes -rand = "0.9" -``` - -### Feature Flags - -```toml -[features] -# Email provider features (mutually exclusive selection) -email-resend = ["resend-rs"] -email-ses = ["aws-sdk-sesv2", "aws-config"] -email-smtp = ["lettre"] - -# Default: SMTP for maximum compatibility -default = ["email-smtp"] -``` - ---- - -## 2. Database Schema - -### New Table: `email_verifications` - -**SQLite** (`crates/zopp-store-sqlite/migrations/20260121000001_add_email_verification.sql`): - -```sql -CREATE TABLE IF NOT EXISTS email_verifications ( - id TEXT PRIMARY KEY NOT NULL, -- UUID - email TEXT NOT NULL, -- Email being verified (lowercased) - code TEXT NOT NULL, -- 6-digit code - 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 - verified INTEGER NOT NULL DEFAULT 0 -- 0 = pending, 1 = verified -); - --- Index for lookup by email -CREATE INDEX idx_email_verifications_email ON email_verifications(email); - --- Index for cleanup of expired verifications -CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at); -``` - -**PostgreSQL** (`crates/zopp-store-postgres/migrations/20260121000001_add_email_verification.sql`): - -```sql -CREATE TABLE IF NOT EXISTS email_verifications ( - id UUID PRIMARY KEY NOT NULL, - email TEXT NOT NULL, - code TEXT NOT NULL, - attempts INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL, - verified BOOLEAN NOT NULL DEFAULT FALSE -); - -CREATE INDEX idx_email_verifications_email ON email_verifications(email); -CREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at); -``` - -### Storage Trait Extensions - -**File:** `crates/zopp-storage/src/lib.rs` - -```rust -// New structs -pub struct EmailVerification { - pub id: String, // UUID - pub email: String, - pub code: String, - pub attempts: i32, - pub created_at: DateTime, - pub expires_at: DateTime, - pub verified: bool, -} - -// New methods for Store trait -pub trait Store: Send + Sync { - // ... existing methods ... - - // Email verification - async fn create_email_verification(&self, email: &str, code: &str, expires_at: DateTime) -> Result; - async fn get_email_verification(&self, email: &str) -> Result, StoreError>; - async fn increment_verification_attempts(&self, id: &str) -> Result<(), StoreError>; - async fn mark_verification_verified(&self, id: &str) -> Result<(), StoreError>; - async fn delete_email_verification(&self, id: &str) -> Result<(), StoreError>; - async fn cleanup_expired_verifications(&self) -> Result; // Returns # deleted -} -``` - ---- - -## 3. Server Configuration - -### Config Structure - -**File:** `apps/zopp-server/src/config.rs` (new file) - -```rust -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerConfig { - /// Email verification settings - pub email: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EmailConfig { - /// Whether email verification is required for new principals - pub verification_required: bool, - - /// Email provider configuration - pub provider: EmailProviderConfig, - - /// "From" address for verification emails - pub from_address: String, - - /// "From" name (e.g., "Zopp Security") - pub from_name: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum EmailProviderConfig { - Resend { api_key: String }, - Ses { region: String }, // Uses AWS default credential chain - Smtp { - host: String, - port: u16, - username: Option, - password: Option, - use_tls: bool, - }, -} -``` - -### Environment Variables (Alternative to Config File) - -```bash -# Enable/disable verification -ZOPP_EMAIL_VERIFICATION_REQUIRED=true - -# Provider selection -ZOPP_EMAIL_PROVIDER=resend # or "ses" or "smtp" - -# Resend -RESEND_API_KEY=re_... - -# AWS SES (uses AWS SDK default credential chain) -AWS_REGION=us-east-1 - -# SMTP -SMTP_HOST=smtp.example.com -SMTP_PORT=587 -SMTP_USERNAME=user@example.com -SMTP_PASSWORD=secret -SMTP_USE_TLS=true - -# Sender configuration -ZOPP_EMAIL_FROM=noreply@zopp.dev -ZOPP_EMAIL_FROM_NAME="Zopp Security" -``` - ---- - -## 4. Proto Changes - -### Modified `JoinRequest` / `JoinResponse` - -**File:** `crates/zopp-proto/proto/zopp.proto` - -```protobuf -// Modified Join flow: now returns verification_required flag -message JoinResponse { - string user_id = 1; - string principal_id = 2; - repeated Workspace workspaces = 3; - bool verification_required = 4; // NEW: if true, client must call VerifyEmail -} - -// New RPC: Verify email with 6-digit code -message VerifyEmailRequest { - string email = 1; // Email address - string code = 2; // 6-digit code - string principal_id = 3; // Principal to activate -} - -message VerifyEmailResponse { - bool success = 1; - string message = 2; // Error message if !success - int32 attempts_remaining = 3; // Remaining attempts before lockout -} - -// New RPC: Resend verification code -message ResendVerificationRequest { - string email = 1; -} - -message ResendVerificationResponse { - bool success = 1; - string message = 2; -} -``` - -### Service Definition - -```protobuf -service Zopp { - // ... existing RPCs ... - - // Email verification (unauthenticated - called before principal is active) - rpc VerifyEmail(VerifyEmailRequest) returns (VerifyEmailResponse); - rpc ResendVerification(ResendVerificationRequest) returns (ResendVerificationResponse); -} -``` - ---- - -## 5. Modified Join Flow - -### Current Join Flow -``` -User → Join RPC → Create User + Principal → Return credentials → Done -``` - -### New Join Flow (with verification enabled) - -``` -1. User → Join RPC - ↓ -2. Server creates User + Principal (INACTIVE state) - ↓ -3. Server generates 6-digit code - ↓ -4. Server sends verification email - ↓ -5. Server returns JoinResponse { verification_required: true } - ↓ -6. CLI prompts user for verification code - ↓ -7. User → VerifyEmail RPC with code - ↓ -8. Server validates code - ↓ -9. Server marks principal as ACTIVE - ↓ -10. User can now use zopp normally -``` - -### Database Changes for Principal State - -**Migration:** `20260121000002_add_principal_verified.sql` - -```sql --- SQLite -ALTER TABLE principals ADD COLUMN verified INTEGER NOT NULL DEFAULT 1; - --- Set existing principals as verified (backwards compatibility) -UPDATE principals SET verified = 1; - --- PostgreSQL -ALTER TABLE principals ADD COLUMN verified BOOLEAN NOT NULL DEFAULT TRUE; -``` - ---- - -## 6. Email Service Implementation - -### Abstraction Layer - -**File:** `apps/zopp-server/src/email/mod.rs` (new module) - -```rust -use async_trait::async_trait; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum EmailError { - #[error("Failed to send email: {0}")] - SendError(String), - - #[error("Provider not configured")] - NotConfigured, -} - -#[async_trait] -pub trait EmailProvider: Send + Sync { - async fn send_verification_email(&self, to: &str, code: &str) -> Result<(), EmailError>; -} - -// Provider implementations -#[cfg(feature = "email-resend")] -pub mod resend; - -#[cfg(feature = "email-ses")] -pub mod ses; - -#[cfg(feature = "email-smtp")] -pub mod smtp; - -pub fn create_provider(config: &EmailProviderConfig) -> Result, EmailError> { - match config { - #[cfg(feature = "email-resend")] - EmailProviderConfig::Resend { api_key } => { - Ok(Box::new(resend::ResendProvider::new(api_key))) - } - - #[cfg(feature = "email-ses")] - EmailProviderConfig::Ses { region } => { - Ok(Box::new(ses::SesProvider::new(region))) - } - - #[cfg(feature = "email-smtp")] - EmailProviderConfig::Smtp { host, port, username, password, use_tls } => { - Ok(Box::new(smtp::SmtpProvider::new(host, *port, username, password, *use_tls))) - } - - _ => Err(EmailError::NotConfigured), - } -} -``` - -### Email Templates - -**File:** `apps/zopp-server/src/email/templates.rs` (new file) - -```rust -pub fn verification_email_text(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 - ) -} - -pub fn verification_email_html(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.

- -
- -"#, - code - ) -} -``` - ---- - -## 7. CLI Changes - -### Modified Join Command - -**File:** `apps/zopp-cli/src/commands/join.rs` - -```rust -// After Join RPC returns -if response.verification_required { - println!("✉️ Verification email sent to {}", email); - println!("Please enter the 6-digit code:"); - - loop { - let code = read_code_from_stdin()?; // Helper function - - let verify_response = client - .verify_email(VerifyEmailRequest { - email: email.clone(), - code, - principal_id: response.principal_id.clone(), - }) - .await? - .into_inner(); - - if verify_response.success { - println!("✓ Email verified successfully!"); - break; - } else { - eprintln!("✗ {}", verify_response.message); - if verify_response.attempts_remaining > 0 { - eprintln!("You have {} attempts remaining.", verify_response.attempts_remaining); - } else { - return Err("Too many failed attempts. Please request a new code.".into()); - } - } - } -} - -// ... continue with saving credentials ... -``` - ---- - -## 8. Security Considerations - -### Rate Limiting - -1. **Max 5 verification attempts** per code -2. **Max 3 verification codes** per email per hour -3. **Exponential backoff** after failed attempts -4. **Automatic cleanup** of expired verifications (background task) - -### Code Generation - -- **Cryptographically secure random** 6-digit codes (000000-999999) -- **Constant-time comparison** to prevent timing attacks -- **Codes expire after 15 minutes** - -### Privacy - -- **Don't reveal if email exists** in timing or error messages -- **Constant-time operations** for verification lookups -- **Hash email addresses** in logs (don't log plaintext emails) - ---- - -## 9. Implementation Checklist - -This is the step-by-step task list for implementation: - -### Phase 1: Database & Storage (Both SQLite + PostgreSQL) -- [ ] Create migration `20260121000001_add_email_verification.sql` (SQLite) -- [ ] Create migration `20260121000001_add_email_verification.sql` (PostgreSQL) -- [ ] Create migration `20260121000002_add_principal_verified.sql` (SQLite) -- [ ] Create migration `20260121000002_add_principal_verified.sql` (PostgreSQL) -- [ ] Add `EmailVerification` struct to `zopp-storage/src/lib.rs` -- [ ] Add email verification methods to `Store` trait -- [ ] Implement methods in SQLite store (`zopp-store-sqlite`) -- [ ] Implement methods in PostgreSQL store (`zopp-store-postgres`) -- [ ] Run `sqlx prepare` for both stores -- [ ] Unit tests for storage methods - -### Phase 2: Configuration -- [ ] Create `apps/zopp-server/src/config.rs` -- [ ] Add `ServerConfig`, `EmailConfig`, `EmailProviderConfig` structs -- [ ] Add environment variable parsing -- [ ] Add config file loading (TOML/YAML) -- [ ] Add validation for config (at least one provider configured) - -### Phase 3: Email Providers -- [ ] Add dependencies to `Cargo.toml` with feature flags -- [ ] Create `apps/zopp-server/src/email/mod.rs` -- [ ] Create `apps/zopp-server/src/email/templates.rs` -- [ ] Implement Resend provider (`email/resend.rs`) -- [ ] Implement AWS SES provider (`email/ses.rs`) -- [ ] Implement SMTP provider (`email/smtp.rs`) -- [ ] Add provider factory function -- [ ] Unit tests for email templates - -### Phase 4: Proto Changes -- [ ] Update `zopp.proto` with new messages -- [ ] Add `VerifyEmailRequest` / `VerifyEmailResponse` -- [ ] Add `ResendVerificationRequest` / `ResendVerificationResponse` -- [ ] Add `verification_required` field to `JoinResponse` -- [ ] Add `verified` field to principal proto (if exposed) -- [ ] Regenerate proto code (`cargo build`) - -### Phase 5: Server Handlers -- [ ] Modify `join` handler to support verification flow - - Generate verification code - - Send email - - Create principal in unverified state (if required) - - Return `verification_required: true` -- [ ] Implement `verify_email` handler - - Validate code - - Check expiration - - Check attempts - - Mark principal as verified - - Rate limiting -- [ ] Implement `resend_verification` handler - - Check rate limits (max 3 per hour) - - Generate new code - - Send email -- [ ] Add background cleanup task for expired verifications -- [ ] Add authentication bypass for verify_email RPC (unauthenticated) - -### Phase 6: CLI Changes -- [ ] Modify `cmd_join` to handle verification flow -- [ ] Add code input prompt -- [ ] Add retry logic for failed verification -- [ ] Add `resend` option during verification -- [ ] User-friendly error messages -- [ ] Show remaining attempts - -### Phase 7: Testing -- [ ] E2E test: Join with verification enabled -- [ ] E2E test: Join with verification disabled (backwards compat) -- [ ] E2E test: Failed verification (wrong code) -- [ ] E2E test: Expired verification code -- [ ] E2E test: Too many attempts -- [ ] E2E test: Resend verification code -- [ ] E2E test: Workspace invite with verification -- [ ] Unit tests for code generation -- [ ] Unit tests for email providers (mocked SMTP) -- [ ] Integration test: Full verification flow - -### Phase 8: Documentation -- [ ] Update `CLAUDE.md` with email configuration instructions -- [ ] Create `docs/docs/guides/email-verification.md` -- [ ] Update `docs/docs/reference/cli/join.md` with verification steps -- [ ] Add environment variable reference to docs -- [ ] Add troubleshooting guide for email delivery issues -- [ ] Update `DEMO.md` if verification affects demo flow - -### Phase 9: PR & Review -- [ ] Run full test suite (`cargo test --workspace --all-features`) -- [ ] Run clippy (`cargo clippy --workspace --all-targets --all-features`) -- [ ] Run fmt (`cargo fmt --all`) -- [ ] Create PR with comprehensive description -- [ ] Wait for CI (ignore docker builds) -- [ ] Address Cubic review comments -- [ ] Iterate until approval - ---- - -## 10. Backwards Compatibility - -- **Verification is disabled by default** (no breaking changes) -- **Existing principals are marked as verified** in migration -- **Bootstrap invites work without verification** (server setup flow unchanged) -- **Configuration is optional** (server runs without email config) - ---- - -## 11. Future Enhancements (Out of Scope) - -- OAuth/SSO providers (Google, GitHub, etc.) -- Magic link verification (alternative to code) -- Multi-factor authentication (TOTP) -- Email change verification (for existing users) -- SMS verification (Twilio, etc.) - ---- - -## Questions for You - -Before I proceed with implementation, I have a few questions: - -1. **Default verification state**: Should verification be **enabled by default** for new deployments, or **disabled by default** (opt-in)? - -2. **Email provider preference**: Which provider should be the default in our documentation/examples? - - Resend (simplest, modern) - - AWS SES (enterprise, cost-effective) - - SMTP (universal compatibility) - -3. **Error handling**: When verification is enabled but email provider is not configured, should the server: - - Fail to start (strict validation) - - Start but reject joins with clear error message - - Fall back to no verification (with warning log) - -4. **Code complexity**: 6-digit codes give us 1 million combinations. With 5 attempts per code and 15-minute expiration, this seems secure. Do you want additional security measures (e.g., email rate limiting, IP rate limiting)? - -Let me know your preferences and I'll proceed with implementation! - ---- - -## User - -actually lets drop ses as its too complex for now, one can always use ses via smtp - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 25.2s) - -_Thinking:_ - -The user wants to drop AWS SES support and keep only Resend and SMTP, noting that SES can be used via SMTP if needed. This simplifies the implementation significantly. - -I should: -1. Acknowledge the change -2. Update the plan to remove SES -3. Ask the remaining questions to finalize the plan -4. Wait for user confirmation before proceeding to implementation - -I'm still in plan mode, so I should NOT make any edits or changes. Just present the updated plan and get answers to the remaining questions. - -Perfect! Dropping SES makes sense - it adds complexity and you're right that SES can always be used via SMTP if needed. Let me update the plan accordingly. - -## Updated Implementation Plan - Key Changes - -### 1. Email Providers (Simplified) - -| Provider | Crate | Version | Use Case | -|----------|-------|---------|----------| -| **Resend** | `resend-rs` | 0.20.0 | Modern, simple API, built-in rate limiting | -| **SMTP** | `lettre` | 0.11.19 | Universal fallback, works with any provider (Gmail, SES, Sendgrid, etc.) | - -### 2. Updated Dependencies - -```toml -# In apps/zopp-server/Cargo.toml -[dependencies] -# Email providers (both optional) -resend-rs = { version = "0.20", optional = true } -lettre = { version = "0.11", features = ["tokio1-rustls", "smtp-transport"], optional = true } - -# Random number generation for verification codes -rand = "0.9" -``` - -### 3. Updated Feature Flags - -```toml -[features] -email-resend = ["resend-rs"] -email-smtp = ["lettre"] - -# Default: SMTP for maximum compatibility -default = ["email-smtp"] -``` - -### 4. Simplified Config - -```rust -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum EmailProviderConfig { - Resend { api_key: String }, - Smtp { - host: String, - port: u16, - username: Option, - password: Option, - use_tls: bool, - }, -} -``` - -### 5. Simplified Environment Variables - -```bash -# Provider selection -ZOPP_EMAIL_PROVIDER=resend # or "smtp" - -# Resend -RESEND_API_KEY=re_... - -# SMTP (works with Gmail, AWS SES SMTP, Sendgrid, etc.) -SMTP_HOST=smtp.example.com -SMTP_PORT=587 -SMTP_USERNAME=user@example.com -SMTP_PASSWORD=secret -SMTP_USE_TLS=true -``` - -### 6. Updated Implementation Checklist - -**Phase 3: Email Providers** now becomes: -- [ ] Add dependencies to `Cargo.toml` with feature flags (Resend + SMTP only) -- [ ] Create `apps/zopp-server/src/email/mod.rs` -- [ ] Create `apps/zopp-server/src/email/templates.rs` -- [ ] Implement Resend provider (`email/resend.rs`) -- [ ] Implement SMTP provider (`email/smtp.rs`) -- [ ] Add provider factory function -- [ ] Unit tests for email templates - ---- - -## Questions Before Implementation - -Now that we've simplified to just Resend + SMTP, I need your decisions on these key points: - -**1. Default verification state for new deployments:** - - **Option A**: Disabled by default (opt-in) - backwards compatible, no surprises - - **Option B**: Enabled by default - more secure, requires email config on first run - -**2. Email provider for documentation/examples:** - - **Option A**: Resend (simplest to set up, one env var) - - **Option B**: SMTP (universal, works with any provider) - - **Option C**: Show both in docs - -**3. Server behavior when verification is enabled but no email provider configured:** - - **Option A**: Fail to start with clear error (strict, forces correct config) - - **Option B**: Start but reject joins with helpful error message - - **Option C**: Start with warning log, allow joins without verification - -**4. Rate limiting complexity:** - - **Option A**: Basic (5 attempts per code, 3 codes per hour per email) - - **Option B**: Advanced (also track by IP, exponential backoff, CAPTCHA-like delays) - -My recommendations: -- Question 1: **Option A** (disabled by default - no breaking changes) -- Question 2: **Option C** (show both - Resend for quick setup, SMTP for flexibility) -- Question 3: **Option B** (start but reject joins - better DX for discovering misconfiguration) -- Question 4: **Option A** (basic rate limiting - sufficient for most attacks) - -What are your preferences? - ---- - -## User - -1: B\ - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 10.2s) - -_Thinking:_ - -The user answered: -1. B - Enabled by default (more secure, requires email config on first run) - -They only answered question 1 so far. I should note this and ask for the remaining questions, or proceed if they want me to make reasonable defaults for the others. - -Also, I see a system reminder that I'm in "Plan Mode" - I should be in READ-ONLY phase, only planning and analyzing, not making any edits. This is good - I was already in planning mode anyway, creating the implementation plan. - -Let me acknowledge their answer to question 1 and ask about the remaining questions, or offer to proceed with my recommendations for questions 2-4. - -Got it! **Verification enabled by default** - more secure out of the box. - -That's a good choice for security. It means new deployments will be secure by default, and users who want to disable verification (for testing/private deployments) can explicitly opt out. - -For the remaining questions (2-4), would you like to decide on those as well, or should I proceed with my recommendations? - -**My recommendations recap:** -- **Question 2** (Docs): Show both Resend and SMTP examples -- **Question 3** (Missing config): Start server but reject joins with clear error message -- **Question 4** (Rate limiting): Basic (5 attempts per code, 3 codes/hour per email) - -Or if you have different preferences for any of these, let me know! - -Once you confirm, I'll finalize the complete implementation plan and we can move to execution. - ---- - -## User - -2: c - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 9.8s) - -_Thinking:_ - -The user answered question 2 with "c", which means Option C: Show both Resend and SMTP examples in documentation. - -So far we have: -- Question 1: B (Enabled by default) -- Question 2: C (Show both in docs) - -The user hasn't answered questions 3 and 4 yet. I should wait for their input on those, or ask if they want to go with my recommendations. - -Also, I notice the system reminder is telling me I'm in "Plan Mode" and should NOT make any edits. The user said "tackle issue 46" which I interpreted as implementing it, but the system is reminding me I'm in read-only mode. I should continue planning and not execute until the user explicitly tells me to implement. - -Perfect! **Question 2: C** - Show both Resend and SMTP in docs. That gives users flexibility. - -So far we have: -- **Question 1**: B - Verification enabled by default ✓ -- **Question 2**: C - Show both Resend and SMTP in docs ✓ - -For the remaining questions: - -**Question 3** - Server behavior when verification enabled but no email provider configured: - - **Option A**: Fail to start with clear error (strict) - - **Option B**: Start but reject joins with helpful error message - - **Option C**: Start with warning, allow joins without verification - -**Question 4** - Rate limiting complexity: - - **Option A**: Basic (5 attempts per code, 3 codes/hour per email) - - **Option B**: Advanced (IP tracking, exponential backoff, etc.) - -What are your preferences for 3 and 4? - ---- - -## User - -3: A. 4: A - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 30.6s) - -_Thinking:_ - -The user has answered all the questions: -1. Question 1: B - Verification enabled by default -2. Question 2: C - Show both Resend and SMTP in docs -3. Question 3: A - Fail to start with clear error if verification enabled but no email provider configured -4. Question 4: A - Basic rate limiting (5 attempts per code, 3 codes/hour per email) - -Now I need to finalize the complete implementation plan with these decisions incorporated. The system reminder is telling me I'm in plan mode and should NOT make any edits or modifications - only read, analyze, and plan. - -Let me create a comprehensive final plan that's ready for execution. - -Perfect! All decisions made: -- **Question 1**: B - Verification enabled by default ✓ -- **Question 2**: C - Show both Resend and SMTP in docs ✓ -- **Question 3**: A - Fail to start if verification enabled but no email provider configured ✓ -- **Question 4**: A - Basic rate limiting ✓ - -Let me finalize the complete implementation plan with these decisions incorporated: - ---- - -# Final Implementation Plan: Email Verification (Issue #46) - -## Configuration Summary - -1. ✅ **Verification enabled by default** - secure out of the box -2. ✅ **Two email providers**: Resend + SMTP (dropped AWS SES) -3. ✅ **Strict validation** - server fails to start if verification enabled but no provider configured -4. ✅ **Basic rate limiting** - 5 attempts per code, 3 codes per hour per email -5. ✅ **6-digit codes** - expire in 15 minutes -6. ✅ **Configurable** - can be disabled via `ZOPP_EMAIL_VERIFICATION_REQUIRED=false` - ---- - -## Implementation Task List - -``` -Tool: todowrite - -**Input:** -```json -{ - "todos": [ - { - "id": "phase1-1", - "content": "Create SQLite migration 20260121000001_add_email_verification.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-2", - "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-3", - "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-4", - "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-5", - "content": "Add EmailVerification struct and methods to zopp-storage trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-6", - "content": "Implement email verification methods in SQLite store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-7", - "content": "Implement email verification methods in PostgreSQL store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-8", - "content": "Run sqlx prepare for both stores", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-9", - "content": "Write unit tests for storage methods", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase2-1", - "content": "Create apps/zopp-server/src/config.rs with ServerConfig", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-2", - "content": "Add config validation (fail if verification enabled but no provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-3", - "content": "Add environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-1", - "content": "Add Resend and SMTP dependencies to Cargo.toml with features", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-2", - "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-3", - "content": "Create apps/zopp-server/src/email/templates.rs with email templates", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-4", - "content": "Implement Resend provider (email/resend.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-5", - "content": "Implement SMTP provider (email/smtp.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-6", - "content": "Add provider factory function with validation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-1", - "content": "Update zopp.proto with VerifyEmailRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-2", - "content": "Update zopp.proto with ResendVerificationRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-3", - "content": "Add verification_required field to JoinResponse", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-4", - "content": "Regenerate proto code (cargo build)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-1", - "content": "Modify join handler to support verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-2", - "content": "Implement verify_email handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-3", - "content": "Implement resend_verification handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-4", - "content": "Add background cleanup task for expired verifications", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase5-5", - "content": "Add authentication bypass for verify_email RPC", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-1", - "content": "Modify CLI cmd_join to handle verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-2", - "content": "Add code input prompt and retry logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-3", - "content": "Add resend option during verification", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-1", - "content": "E2E test: Join with verification enabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-2", - "content": "E2E test: Join with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-3", - "content": "E2E test: Failed verification (wrong code)", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-4", - "content": "E2E test: Expired verification code", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-5", - "content": "E2E test: Too many attempts", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-6", - "content": "E2E test: Resend verification code", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-1", - "content": "Update CLAUDE.md with email configuration", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-2", - "content": "Create docs/docs/guides/email-verification.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-3", - "content": "Update docs/docs/reference/cli/join.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase9-1", - "content": "Run full test suite", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-2", - "content": "Run clippy and fmt", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-3", - "content": "Create PR and monitor CI", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-4", - "content": "Address Cubic review comments", - "status": "pending", - "priority": "high" - } - ] -} -``` -**Output:** -``` -[ - { - "id": "phase1-1", - "content": "Create SQLite migration 20260121000001_add_email_verification.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-2", - "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-3", - "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-4", - "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-5", - "content": "Add EmailVerification struct and methods to zopp-storage trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-6", - "content": "Implement email verification methods in SQLite store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-7", - "content": "Implement email verification methods in PostgreSQL store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-8", - "content": "Run sqlx prepare for both stores", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-9", - "content": "Write unit tests for storage methods", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase2-1", - "content": "Create apps/zopp-server/src/config.rs with ServerConfig", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-2", - "content": "Add config validation (fail if verification enabled but no provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-3", - "content": "Add environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-1", - "content": "Add Resend and SMTP dependencies to Cargo.toml with features", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-2", - "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-3", - "content": "Create apps/zopp-server/src/email/templates.rs with email templates", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-4", - "content": "Implement Resend provider (email/resend.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-5", - "content": "Implement SMTP provider (email/smtp.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-6", - "content": "Add provider factory function with validation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-1", - "content": "Update zopp.proto with VerifyEmailRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-2", - "content": "Update zopp.proto with ResendVerificationRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-3", - "content": "Add verification_required field to JoinResponse", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-4", - "content": "Regenerate proto code (cargo build)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-1", - "content": "Modify join handler to support verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-2", - "content": "Implement verify_email handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-3", - "content": "Implement resend_verification handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-4", - "content": "Add background cleanup task for expired verifications", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase5-5", - "content": "Add authentication bypass for verify_email RPC", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-1", - "content": "Modify CLI cmd_join to handle verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-2", - "content": "Add code input prompt and retry logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-3", - "content": "Add resend option during verification", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-1", - "content": "E2E test: Join with verification enabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-2", - "content": "E2E test: Join with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-3", - "content": "E2E test: Failed verification (wrong code)", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-4", - "content": "E2E test: Expired verification code", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-5", - "content": "E2E test: Too many attempts", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-6", - "content": "E2E test: Resend verification code", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-1", - "content": "Update CLAUDE.md with email configuration", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-2", - "content": "Create docs/docs/guides/email-verification.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-3", - "content": "Update docs/docs/reference/cli/join.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase9-1", - "content": "Run full test suite", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-2", - "content": "Run clippy and fmt", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-3", - "content": "Create PR and monitor CI", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-4", - "content": "Address Cubic review comments", - "status": "pending", - "priority": "high" - } -] -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 26.9s) - -_Thinking:_ - -Good! I've created the todo list with all the implementation tasks. Now let me provide a final summary of the plan with all the key design decisions, implementation details, and a clear specification that can be used for execution. - -Perfect! I've created a comprehensive task list with 43 tasks across 9 phases. Here's the final implementation plan: - ---- - -## Final Implementation Specification - -### Design Decisions Summary - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| **Verification default** | Enabled | Secure by default | -| **Email providers** | Resend + SMTP | Resend for simplicity, SMTP for universality (dropped SES) | -| **Config validation** | Fail to start | Strict - prevents misconfiguration | -| **Rate limiting** | Basic | 5 attempts/code, 3 codes/hour per email | -| **Code format** | 6-digit | 1M combinations, CLI-friendly | -| **Code expiration** | 15 minutes | Balance security vs UX | - ---- - -### Key Technical Specifications - -#### 1. New Database Tables - -**`email_verifications`** -- Stores verification codes with expiration -- Tracks attempts to prevent brute force -- Indexed by email for fast lookup -- Indexed by expires_at for cleanup - -**`principals.verified`** (new column) -- BOOLEAN/INTEGER flag -- Existing principals marked as verified (backwards compat) -- New principals start as unverified (if verification enabled) - -#### 2. Configuration Structure - -```bash -# Core settings -ZOPP_EMAIL_VERIFICATION_REQUIRED=true # enabled by default - -# Provider: Resend -ZOPP_EMAIL_PROVIDER=resend -RESEND_API_KEY=re_... - -# Provider: SMTP (works with Gmail, AWS SES SMTP, Sendgrid, etc.) -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" -``` - -#### 3. New gRPC RPCs (Unauthenticated) - -```protobuf -rpc VerifyEmail(VerifyEmailRequest) returns (VerifyEmailResponse); -rpc ResendVerification(ResendVerificationRequest) returns (ResendVerificationResponse); -``` - -#### 4. Modified Join Flow - -``` -Before: -User → Join RPC → Create User+Principal → Save credentials → Done - -After (verification enabled): -User → Join RPC → Create User+Principal(unverified) → Send email → Return verification_required=true - → CLI prompts for code → VerifyEmail RPC → Mark principal verified → Save credentials → Done -``` - -#### 5. Security Features - -- **Cryptographically secure** 6-digit codes (using `rand` crate) -- **Constant-time comparison** to prevent timing attacks -- **Rate limiting**: Max 5 attempts per code, max 3 codes per hour per email -- **Automatic cleanup** of expired verifications (background task) -- **No email enumeration**: Same response time whether email exists or not - -#### 6. Backwards Compatibility - -- ✅ Verification can be disabled: `ZOPP_EMAIL_VERIFICATION_REQUIRED=false` -- ✅ Existing principals automatically marked as verified in migration -- ✅ Bootstrap invites work without verification (server setup unchanged) -- ✅ No breaking changes to existing APIs - ---- - -## Plan Summary - -**Total Tasks**: 43 tasks across 9 phases -- **Phase 1**: Database migrations & storage layer (9 tasks) -- **Phase 2**: Configuration (3 tasks) -- **Phase 3**: Email providers (6 tasks) -- **Phase 4**: Proto changes (4 tasks) -- **Phase 5**: Server handlers (5 tasks) -- **Phase 6**: CLI changes (3 tasks) -- **Phase 7**: E2E testing (6 tasks) -- **Phase 8**: Documentation (3 tasks) -- **Phase 9**: PR & Review (4 tasks) - ---- - -## Ready to Proceed? - -The plan is complete and ready for execution. The implementation will: - -1. ✅ Add email verification with 6-digit codes -2. ✅ Support Resend and SMTP providers -3. ✅ Enable verification by default (configurable) -4. ✅ Fail server start if misconfigured -5. ✅ Maintain full backwards compatibility -6. ✅ Include comprehensive tests and documentation - -**Shall I proceed with implementation?** Just let me know and I'll start executing the tasks in order! - ---- - -## User - -no unit tests? only e2e? - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 16.0s) - -_Thinking:_ - -The user is asking about unit tests - they noticed I only have E2E tests in the plan but no unit tests. Let me review what I included: - -In Phase 1, I have: -- "Write unit tests for storage methods" (task phase1-9) - -But looking at the rest of the plan, I don't see unit tests for: -- Email providers (only mentioned "Unit tests for email templates" but I don't see it in the task list!) -- Code generation -- Rate limiting logic -- Email sending logic -- Configuration parsing/validation - -The user is right to call this out. According to TESTING.md, the project aims for high test coverage with both unit tests AND E2E tests. I should add comprehensive unit test tasks. - -Let me identify what needs unit tests: -1. Storage layer (already have this - phase1-9) -2. Email templates (I mentioned this in the text but didn't add to task list!) -3. Code generation (cryptographically secure random codes) -4. Rate limiting logic -5. Email provider implementations (mocked) -6. Configuration parsing and validation -7. Verification code validation logic (constant-time comparison) -8. Email sending abstraction layer - -I need to update the plan to include proper unit test tasks. - -You're absolutely right! I mentioned unit tests in the plan text but didn't include nearly enough in the actual task list. According to `TESTING.md`, the project aims for high test coverage with both unit tests AND E2E tests. - -Looking at what I have, I only included: -- `phase1-9`: Unit tests for storage methods - -But I'm missing unit tests for many critical components: - -### Missing Unit Tests - -1. **Email templates** - Test text and HTML generation -2. **Code generation** - Test cryptographic randomness, format validation -3. **Rate limiting logic** - Test attempt tracking, expiration logic -4. **Email provider implementations** - Test with mocked SMTP/Resend APIs -5. **Configuration parsing** - Test env var parsing, validation -6. **Configuration validation** - Test fail-fast when misconfigured -7. **Verification code validation** - Test constant-time comparison -8. **Email provider factory** - Test provider selection logic -9. **Background cleanup task** - Test expired verification cleanup - -Should I add comprehensive unit test tasks to the plan? Something like: - -**Additional Phase 1 tasks:** -- Unit tests for storage methods ✓ (already have) -- Unit tests for verification code expiration logic -- Unit tests for attempt counting - -**Additional Phase 3 tasks:** -- Unit tests for email templates (text + HTML) -- Unit tests for code generation (format, randomness) -- Unit tests for Resend provider (mocked API) -- Unit tests for SMTP provider (mocked transport) -- Unit tests for provider factory - -**Additional Phase 5 tasks:** -- Unit tests for rate limiting logic -- Unit tests for verification code validation (constant-time comparison) -- Unit tests for background cleanup task - -Would you like me to update the task list with these unit test tasks? - ---- - -## User - -yes - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 37.9s) - -_Thinking:_ - -The user is asking me to add comprehensive unit tests to the plan. I'm in PLAN MODE which means I should NOT execute anything, only plan and update the task list. - -I should update the todo list to include all the missing unit tests I identified. Let me organize them properly by phase. - -Perfect! Let me update the task list with comprehensive unit tests: - -``` -Tool: todowrite - -**Input:** -```json -{ - "todos": [ - { - "id": "phase1-1", - "content": "Create SQLite migration 20260121000001_add_email_verification.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-2", - "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-3", - "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-4", - "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-5", - "content": "Add EmailVerification struct and methods to zopp-storage trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-6", - "content": "Implement email verification methods in SQLite store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-7", - "content": "Implement email verification methods in PostgreSQL store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-8", - "content": "Run sqlx prepare for both stores", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-9", - "content": "Unit tests: Storage create_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-10", - "content": "Unit tests: Storage get_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-11", - "content": "Unit tests: Storage increment_verification_attempts", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-12", - "content": "Unit tests: Storage mark_verification_verified", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-13", - "content": "Unit tests: Storage delete_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-14", - "content": "Unit tests: Storage cleanup_expired_verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-15", - "content": "Unit tests: Verification code expiration logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-16", - "content": "Unit tests: Attempt counting and limits", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-1", - "content": "Create apps/zopp-server/src/config.rs with ServerConfig", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-2", - "content": "Add config validation (fail if verification enabled but no provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-3", - "content": "Add environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-4", - "content": "Unit tests: Config environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-5", - "content": "Unit tests: Config validation (missing provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-6", - "content": "Unit tests: Config validation (invalid SMTP config)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-7", - "content": "Unit tests: Config defaults and optional fields", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-1", - "content": "Add Resend and SMTP dependencies to Cargo.toml with features", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-2", - "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-3", - "content": "Create apps/zopp-server/src/email/templates.rs with email templates", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-4", - "content": "Implement Resend provider (email/resend.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-5", - "content": "Implement SMTP provider (email/smtp.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-6", - "content": "Add provider factory function with validation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-7", - "content": "Create code generation utility (cryptographically secure 6-digit)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-8", - "content": "Unit tests: Email templates text generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-9", - "content": "Unit tests: Email templates HTML generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-10", - "content": "Unit tests: Code generation format validation (6 digits)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-11", - "content": "Unit tests: Code generation randomness (no duplicates in sample)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-12", - "content": "Unit tests: Resend provider with mocked API", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-13", - "content": "Unit tests: SMTP provider with mocked transport", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-14", - "content": "Unit tests: Provider factory selection logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-15", - "content": "Unit tests: Provider factory error on invalid config", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-1", - "content": "Update zopp.proto with VerifyEmailRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-2", - "content": "Update zopp.proto with ResendVerificationRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-3", - "content": "Add verification_required field to JoinResponse", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-4", - "content": "Regenerate proto code (cargo build)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-1", - "content": "Modify join handler to support verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-2", - "content": "Implement verify_email handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-3", - "content": "Implement resend_verification handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-4", - "content": "Add background cleanup task for expired verifications", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase5-5", - "content": "Add authentication bypass for verify_email RPC", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-6", - "content": "Unit tests: Rate limiting logic (5 attempts per code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-7", - "content": "Unit tests: Rate limiting (3 codes per hour per email)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-8", - "content": "Unit tests: Verification code validation (constant-time comparison)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-9", - "content": "Unit tests: Background cleanup task removes expired verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-10", - "content": "Unit tests: Join handler with verification enabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-11", - "content": "Unit tests: Join handler with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-12", - "content": "Unit tests: VerifyEmail handler success case", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-13", - "content": "Unit tests: VerifyEmail handler wrong code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-14", - "content": "Unit tests: ResendVerification handler", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-1", - "content": "Modify CLI cmd_join to handle verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-2", - "content": "Add code input prompt and retry logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-3", - "content": "Add resend option during verification", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-1", - "content": "E2E test: Join with verification enabled (happy path)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-2", - "content": "E2E test: Join with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-3", - "content": "E2E test: Failed verification (wrong code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-4", - "content": "E2E test: Expired verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-5", - "content": "E2E test: Too many attempts (rate limiting)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-6", - "content": "E2E test: Resend verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-7", - "content": "E2E test: Workspace invite with verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-8", - "content": "E2E test: Bootstrap invite (should bypass verification)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase8-1", - "content": "Update CLAUDE.md with email configuration", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-2", - "content": "Create docs/docs/guides/email-verification.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-3", - "content": "Update docs/docs/reference/cli/join.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-4", - "content": "Add troubleshooting guide for email delivery issues", - "status": "pending", - "priority": "low" - }, - { - "id": "phase9-1", - "content": "Run full test suite", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-2", - "content": "Run clippy and fmt", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-3", - "content": "Create PR and monitor CI", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-4", - "content": "Address Cubic review comments", - "status": "pending", - "priority": "high" - } - ] -} -``` -**Output:** -``` -[ - { - "id": "phase1-1", - "content": "Create SQLite migration 20260121000001_add_email_verification.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-2", - "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-3", - "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-4", - "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-5", - "content": "Add EmailVerification struct and methods to zopp-storage trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-6", - "content": "Implement email verification methods in SQLite store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-7", - "content": "Implement email verification methods in PostgreSQL store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-8", - "content": "Run sqlx prepare for both stores", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-9", - "content": "Unit tests: Storage create_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-10", - "content": "Unit tests: Storage get_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-11", - "content": "Unit tests: Storage increment_verification_attempts", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-12", - "content": "Unit tests: Storage mark_verification_verified", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-13", - "content": "Unit tests: Storage delete_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-14", - "content": "Unit tests: Storage cleanup_expired_verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-15", - "content": "Unit tests: Verification code expiration logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-16", - "content": "Unit tests: Attempt counting and limits", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-1", - "content": "Create apps/zopp-server/src/config.rs with ServerConfig", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-2", - "content": "Add config validation (fail if verification enabled but no provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-3", - "content": "Add environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-4", - "content": "Unit tests: Config environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-5", - "content": "Unit tests: Config validation (missing provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-6", - "content": "Unit tests: Config validation (invalid SMTP config)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-7", - "content": "Unit tests: Config defaults and optional fields", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-1", - "content": "Add Resend and SMTP dependencies to Cargo.toml with features", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-2", - "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-3", - "content": "Create apps/zopp-server/src/email/templates.rs with email templates", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-4", - "content": "Implement Resend provider (email/resend.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-5", - "content": "Implement SMTP provider (email/smtp.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-6", - "content": "Add provider factory function with validation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-7", - "content": "Create code generation utility (cryptographically secure 6-digit)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-8", - "content": "Unit tests: Email templates text generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-9", - "content": "Unit tests: Email templates HTML generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-10", - "content": "Unit tests: Code generation format validation (6 digits)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-11", - "content": "Unit tests: Code generation randomness (no duplicates in sample)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-12", - "content": "Unit tests: Resend provider with mocked API", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-13", - "content": "Unit tests: SMTP provider with mocked transport", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-14", - "content": "Unit tests: Provider factory selection logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-15", - "content": "Unit tests: Provider factory error on invalid config", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-1", - "content": "Update zopp.proto with VerifyEmailRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-2", - "content": "Update zopp.proto with ResendVerificationRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-3", - "content": "Add verification_required field to JoinResponse", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-4", - "content": "Regenerate proto code (cargo build)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-1", - "content": "Modify join handler to support verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-2", - "content": "Implement verify_email handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-3", - "content": "Implement resend_verification handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-4", - "content": "Add background cleanup task for expired verifications", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase5-5", - "content": "Add authentication bypass for verify_email RPC", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-6", - "content": "Unit tests: Rate limiting logic (5 attempts per code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-7", - "content": "Unit tests: Rate limiting (3 codes per hour per email)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-8", - "content": "Unit tests: Verification code validation (constant-time comparison)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-9", - "content": "Unit tests: Background cleanup task removes expired verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-10", - "content": "Unit tests: Join handler with verification enabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-11", - "content": "Unit tests: Join handler with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-12", - "content": "Unit tests: VerifyEmail handler success case", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-13", - "content": "Unit tests: VerifyEmail handler wrong code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-14", - "content": "Unit tests: ResendVerification handler", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-1", - "content": "Modify CLI cmd_join to handle verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-2", - "content": "Add code input prompt and retry logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-3", - "content": "Add resend option during verification", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-1", - "content": "E2E test: Join with verification enabled (happy path)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-2", - "content": "E2E test: Join with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-3", - "content": "E2E test: Failed verification (wrong code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-4", - "content": "E2E test: Expired verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-5", - "content": "E2E test: Too many attempts (rate limiting)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-6", - "content": "E2E test: Resend verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-7", - "content": "E2E test: Workspace invite with verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-8", - "content": "E2E test: Bootstrap invite (should bypass verification)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase8-1", - "content": "Update CLAUDE.md with email configuration", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-2", - "content": "Create docs/docs/guides/email-verification.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-3", - "content": "Update docs/docs/reference/cli/join.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-4", - "content": "Add troubleshooting guide for email delivery issues", - "status": "pending", - "priority": "low" - }, - { - "id": "phase9-1", - "content": "Run full test suite", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-2", - "content": "Run clippy and fmt", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-3", - "content": "Create PR and monitor CI", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-4", - "content": "Address Cubic review comments", - "status": "pending", - "priority": "high" - } -] -``` -``` - ---- - -## Assistant (Plan · claude-sonnet-4-5 · 24.6s) - -_Thinking:_ - -Great! I've updated the task list with comprehensive unit tests. Let me summarize the changes for the user. - -Excellent! I've updated the plan with comprehensive unit tests. Here's the summary: - -## Updated Implementation Plan - Now with Comprehensive Testing - -**Total Tasks**: 76 tasks (was 43) across 9 phases - -### Test Coverage Breakdown - -#### Phase 1: Database & Storage (16 tasks, +7 unit tests) -- 8 implementation tasks -- **8 unit test tasks**: - - All storage methods (create, get, increment, mark, delete, cleanup) - - Expiration logic - - Attempt counting and limits - -#### Phase 2: Configuration (7 tasks, +4 unit tests) -- 3 implementation tasks -- **4 unit test tasks**: - - Environment variable parsing - - Validation (missing provider) - - Validation (invalid SMTP config) - - Defaults and optional fields - -#### Phase 3: Email Providers (15 tasks, +8 unit tests) -- 7 implementation tasks -- **8 unit test tasks**: - - Email templates (text + HTML) - - Code generation (format validation, randomness) - - Resend provider (mocked API) - - SMTP provider (mocked transport) - - Provider factory (selection logic, error handling) - -#### Phase 4: Proto Changes (4 tasks) -- No unit tests needed (code generation) - -#### Phase 5: Server Handlers (14 tasks, +9 unit tests) -- 5 implementation tasks -- **9 unit test tasks**: - - Rate limiting (5 attempts per code, 3 codes per hour) - - Constant-time code comparison - - Background cleanup task - - Join handler (enabled/disabled) - - VerifyEmail handler (success/wrong code) - - ResendVerification handler - -#### Phase 6: CLI Changes (3 tasks) -- 3 implementation tasks -- E2E tests will cover CLI behavior - -#### Phase 7: E2E Tests (8 tasks, +2 additional scenarios) -- Join with verification enabled/disabled -- Failed verification -- Expired code -- Too many attempts -- Resend verification -- **Workspace invite with verification** -- **Bootstrap invite (should bypass verification)** - -#### Phase 8: Documentation (4 tasks) -- CLAUDE.md updates -- Guide creation -- CLI reference -- Troubleshooting - -#### Phase 9: PR & Review (4 tasks) -- Test suite -- Clippy/fmt -- PR creation -- Cubic review - ---- - -## Test Philosophy (per TESTING.md) - -✅ **Use real implementations** - Tests use real SQLite/PostgreSQL, not mocks -✅ **Mock only for external services** - Mock Resend API and SMTP transport -✅ **100% coverage goal** - Every new function has unit tests -✅ **E2E for user-facing features** - Full join flow with verification -✅ **Security-critical code** - Constant-time comparison, rate limiting well-tested - ---- - -## Key Testing Additions - -### Security-Critical Tests -- **Constant-time code comparison** - Prevent timing attacks -- **Rate limiting** - Both per-code and per-email-per-hour -- **Code generation randomness** - Ensure no predictable patterns - -### Provider Tests -- **Mocked external APIs** - Test error handling without hitting real services -- **Template validation** - Ensure emails render correctly - -### Storage Tests -- **Both SQLite and PostgreSQL** - Full coverage of both backends -- **Expiration logic** - Ensure cleanup works correctly -- **Attempt tracking** - Verify rate limiting at storage layer - ---- - -## Ready to Proceed? - -The plan is now complete with **76 comprehensive tasks** including: -- **29 unit test tasks** covering all critical logic -- **8 E2E test tasks** covering user-facing flows -- Full implementation of email verification with Resend + SMTP - -The testing approach follows the project's `TESTING.md` guidelines: -- Real implementations where possible -- Mocks only for external services -- Aiming for high coverage -- Both unit and E2E tests - -**Shall I proceed with implementation, or do you have any other questions/concerns about the plan?** - ---- - -## User - -proceed - ---- - -## Assistant (Build · claude-opus-4-5 · 31.9s) - - -The user has approved the plan and my operational mode has changed to build mode. I can now start implementing the email verification feature. - -Let me start executing the tasks in order, beginning with Phase 1: Database & Storage. - -I'll start with: -1. Create SQLite migration for email_verifications table -2. Create PostgreSQL migration for email_verifications table -3. Create SQLite migration for principal verified column -4. Create PostgreSQL migration for principal verified column - -Let me first check the current migration files to understand the naming convention and latest migration number. - - -Let's begin implementing the email verification feature! I'll start with Phase 1: Database migrations and storage layer. - -``` -Tool: todowrite - -**Input:** -```json -{ - "todos": [ - { - "id": "phase1-1", - "content": "Create SQLite migration 20260121000001_add_email_verification.sql", - "status": "in_progress", - "priority": "high" - }, - { - "id": "phase1-2", - "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-3", - "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-4", - "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-5", - "content": "Add EmailVerification struct and methods to zopp-storage trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-6", - "content": "Implement email verification methods in SQLite store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-7", - "content": "Implement email verification methods in PostgreSQL store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-8", - "content": "Run sqlx prepare for both stores", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-9", - "content": "Unit tests: Storage create_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-10", - "content": "Unit tests: Storage get_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-11", - "content": "Unit tests: Storage increment_verification_attempts", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-12", - "content": "Unit tests: Storage mark_verification_verified", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-13", - "content": "Unit tests: Storage delete_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-14", - "content": "Unit tests: Storage cleanup_expired_verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-15", - "content": "Unit tests: Verification code expiration logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-16", - "content": "Unit tests: Attempt counting and limits", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-1", - "content": "Create apps/zopp-server/src/config.rs with ServerConfig", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-2", - "content": "Add config validation (fail if verification enabled but no provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-3", - "content": "Add environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-4", - "content": "Unit tests: Config environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-5", - "content": "Unit tests: Config validation (missing provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-6", - "content": "Unit tests: Config validation (invalid SMTP config)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-7", - "content": "Unit tests: Config defaults and optional fields", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-1", - "content": "Add Resend and SMTP dependencies to Cargo.toml with features", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-2", - "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-3", - "content": "Create apps/zopp-server/src/email/templates.rs with email templates", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-4", - "content": "Implement Resend provider (email/resend.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-5", - "content": "Implement SMTP provider (email/smtp.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-6", - "content": "Add provider factory function with validation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-7", - "content": "Create code generation utility (cryptographically secure 6-digit)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-8", - "content": "Unit tests: Email templates text generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-9", - "content": "Unit tests: Email templates HTML generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-10", - "content": "Unit tests: Code generation format validation (6 digits)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-11", - "content": "Unit tests: Code generation randomness (no duplicates in sample)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-12", - "content": "Unit tests: Resend provider with mocked API", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-13", - "content": "Unit tests: SMTP provider with mocked transport", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-14", - "content": "Unit tests: Provider factory selection logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-15", - "content": "Unit tests: Provider factory error on invalid config", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-1", - "content": "Update zopp.proto with VerifyEmailRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-2", - "content": "Update zopp.proto with ResendVerificationRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-3", - "content": "Add verification_required field to JoinResponse", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-4", - "content": "Regenerate proto code (cargo build)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-1", - "content": "Modify join handler to support verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-2", - "content": "Implement verify_email handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-3", - "content": "Implement resend_verification handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-4", - "content": "Add background cleanup task for expired verifications", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase5-5", - "content": "Add authentication bypass for verify_email RPC", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-6", - "content": "Unit tests: Rate limiting logic (5 attempts per code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-7", - "content": "Unit tests: Rate limiting (3 codes per hour per email)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-8", - "content": "Unit tests: Verification code validation (constant-time comparison)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-9", - "content": "Unit tests: Background cleanup task removes expired verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-10", - "content": "Unit tests: Join handler with verification enabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-11", - "content": "Unit tests: Join handler with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-12", - "content": "Unit tests: VerifyEmail handler success case", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-13", - "content": "Unit tests: VerifyEmail handler wrong code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-14", - "content": "Unit tests: ResendVerification handler", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-1", - "content": "Modify CLI cmd_join to handle verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-2", - "content": "Add code input prompt and retry logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-3", - "content": "Add resend option during verification", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-1", - "content": "E2E test: Join with verification enabled (happy path)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-2", - "content": "E2E test: Join with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-3", - "content": "E2E test: Failed verification (wrong code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-4", - "content": "E2E test: Expired verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-5", - "content": "E2E test: Too many attempts (rate limiting)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-6", - "content": "E2E test: Resend verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-7", - "content": "E2E test: Workspace invite with verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-8", - "content": "E2E test: Bootstrap invite (should bypass verification)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase8-1", - "content": "Update CLAUDE.md with email configuration", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-2", - "content": "Create docs/docs/guides/email-verification.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-3", - "content": "Update docs/docs/reference/cli/join.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-4", - "content": "Add troubleshooting guide for email delivery issues", - "status": "pending", - "priority": "low" - }, - { - "id": "phase9-1", - "content": "Run full test suite", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-2", - "content": "Run clippy and fmt", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-3", - "content": "Create PR and monitor CI", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-4", - "content": "Address Cubic review comments", - "status": "pending", - "priority": "high" - } - ] -} -``` -**Output:** -``` -[ - { - "id": "phase1-1", - "content": "Create SQLite migration 20260121000001_add_email_verification.sql", - "status": "in_progress", - "priority": "high" - }, - { - "id": "phase1-2", - "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-3", - "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-4", - "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-5", - "content": "Add EmailVerification struct and methods to zopp-storage trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-6", - "content": "Implement email verification methods in SQLite store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-7", - "content": "Implement email verification methods in PostgreSQL store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-8", - "content": "Run sqlx prepare for both stores", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-9", - "content": "Unit tests: Storage create_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-10", - "content": "Unit tests: Storage get_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-11", - "content": "Unit tests: Storage increment_verification_attempts", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-12", - "content": "Unit tests: Storage mark_verification_verified", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-13", - "content": "Unit tests: Storage delete_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-14", - "content": "Unit tests: Storage cleanup_expired_verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-15", - "content": "Unit tests: Verification code expiration logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-16", - "content": "Unit tests: Attempt counting and limits", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-1", - "content": "Create apps/zopp-server/src/config.rs with ServerConfig", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-2", - "content": "Add config validation (fail if verification enabled but no provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-3", - "content": "Add environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-4", - "content": "Unit tests: Config environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-5", - "content": "Unit tests: Config validation (missing provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-6", - "content": "Unit tests: Config validation (invalid SMTP config)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-7", - "content": "Unit tests: Config defaults and optional fields", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-1", - "content": "Add Resend and SMTP dependencies to Cargo.toml with features", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-2", - "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-3", - "content": "Create apps/zopp-server/src/email/templates.rs with email templates", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-4", - "content": "Implement Resend provider (email/resend.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-5", - "content": "Implement SMTP provider (email/smtp.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-6", - "content": "Add provider factory function with validation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-7", - "content": "Create code generation utility (cryptographically secure 6-digit)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-8", - "content": "Unit tests: Email templates text generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-9", - "content": "Unit tests: Email templates HTML generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-10", - "content": "Unit tests: Code generation format validation (6 digits)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-11", - "content": "Unit tests: Code generation randomness (no duplicates in sample)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-12", - "content": "Unit tests: Resend provider with mocked API", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-13", - "content": "Unit tests: SMTP provider with mocked transport", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-14", - "content": "Unit tests: Provider factory selection logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-15", - "content": "Unit tests: Provider factory error on invalid config", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-1", - "content": "Update zopp.proto with VerifyEmailRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-2", - "content": "Update zopp.proto with ResendVerificationRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-3", - "content": "Add verification_required field to JoinResponse", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-4", - "content": "Regenerate proto code (cargo build)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-1", - "content": "Modify join handler to support verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-2", - "content": "Implement verify_email handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-3", - "content": "Implement resend_verification handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-4", - "content": "Add background cleanup task for expired verifications", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase5-5", - "content": "Add authentication bypass for verify_email RPC", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-6", - "content": "Unit tests: Rate limiting logic (5 attempts per code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-7", - "content": "Unit tests: Rate limiting (3 codes per hour per email)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-8", - "content": "Unit tests: Verification code validation (constant-time comparison)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-9", - "content": "Unit tests: Background cleanup task removes expired verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-10", - "content": "Unit tests: Join handler with verification enabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-11", - "content": "Unit tests: Join handler with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-12", - "content": "Unit tests: VerifyEmail handler success case", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-13", - "content": "Unit tests: VerifyEmail handler wrong code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-14", - "content": "Unit tests: ResendVerification handler", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-1", - "content": "Modify CLI cmd_join to handle verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-2", - "content": "Add code input prompt and retry logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-3", - "content": "Add resend option during verification", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-1", - "content": "E2E test: Join with verification enabled (happy path)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-2", - "content": "E2E test: Join with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-3", - "content": "E2E test: Failed verification (wrong code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-4", - "content": "E2E test: Expired verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-5", - "content": "E2E test: Too many attempts (rate limiting)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-6", - "content": "E2E test: Resend verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-7", - "content": "E2E test: Workspace invite with verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-8", - "content": "E2E test: Bootstrap invite (should bypass verification)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase8-1", - "content": "Update CLAUDE.md with email configuration", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-2", - "content": "Create docs/docs/guides/email-verification.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-3", - "content": "Update docs/docs/reference/cli/join.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-4", - "content": "Add troubleshooting guide for email delivery issues", - "status": "pending", - "priority": "low" - }, - { - "id": "phase9-1", - "content": "Run full test suite", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-2", - "content": "Run clippy and fmt", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-3", - "content": "Create PR and monitor CI", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-4", - "content": "Address Cubic review comments", - "status": "pending", - "priority": "high" - } -] -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 16.1s) - - -Now let me start implementing. First, I'll create the database migrations. Let me create all 4 migration files (SQLite and PostgreSQL for both email_verifications table and principal verified column). - - -``` -Tool: write - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260121000001_add_email_verification.sql", - "content": "-- Email verification table for storing verification codes\nCREATE TABLE IF NOT EXISTS email_verifications (\n id TEXT PRIMARY KEY NOT NULL, -- UUID string\n email TEXT NOT NULL, -- Email being verified (lowercased)\n code TEXT NOT NULL, -- 6-digit verification code\n attempts INTEGER NOT NULL DEFAULT 0, -- Failed verification attempts\n created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),\n expires_at TEXT NOT NULL -- 15 minutes from created_at\n);\n\n-- Index for lookup by email (most common query)\nCREATE INDEX idx_email_verifications_email ON email_verifications(email);\n\n-- Index for cleanup of expired verifications\nCREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at);\n" -} -``` -**Output:** -``` -Wrote file successfully. -``` -``` - -``` -Tool: write - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260121000001_add_email_verification.sql", - "content": "-- Email verification table for storing verification codes\nCREATE TABLE IF NOT EXISTS email_verifications (\n id UUID PRIMARY KEY NOT NULL,\n email TEXT NOT NULL, -- Email being verified (lowercased)\n code TEXT NOT NULL, -- 6-digit verification code\n attempts INTEGER NOT NULL DEFAULT 0, -- Failed verification attempts\n created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n expires_at TIMESTAMPTZ NOT NULL -- 15 minutes from created_at\n);\n\n-- Index for lookup by email (most common query)\nCREATE INDEX idx_email_verifications_email ON email_verifications(email);\n\n-- Index for cleanup of expired verifications\nCREATE INDEX idx_email_verifications_expires_at ON email_verifications(expires_at);\n" -} -``` -**Output:** -``` -Wrote file successfully. -``` -``` - -``` -Tool: write - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/migrations/20260121000002_add_principal_verified.sql", - "content": "-- Add verified flag to principals table\n-- Existing principals are marked as verified (backwards compatibility)\nALTER TABLE principals ADD COLUMN verified INTEGER NOT NULL DEFAULT 1;\n" -} -``` -**Output:** -``` -Wrote file successfully. -``` -``` - -``` -Tool: write - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/migrations/20260121000002_add_principal_verified.sql", - "content": "-- Add verified flag to principals table\n-- Existing principals are marked as verified (backwards compatibility)\nALTER TABLE principals ADD COLUMN verified BOOLEAN NOT NULL DEFAULT TRUE;\n" -} -``` -**Output:** -``` -Wrote file successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 25.9s) - -``` -Tool: todowrite - -**Input:** -```json -{ - "todos": [ - { - "id": "phase1-1", - "content": "Create SQLite migration 20260121000001_add_email_verification.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-2", - "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-3", - "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-4", - "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-5", - "content": "Add EmailVerification struct and methods to zopp-storage trait", - "status": "in_progress", - "priority": "high" - }, - { - "id": "phase1-6", - "content": "Implement email verification methods in SQLite store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-7", - "content": "Implement email verification methods in PostgreSQL store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-8", - "content": "Run sqlx prepare for both stores", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-9", - "content": "Unit tests: Storage create_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-10", - "content": "Unit tests: Storage get_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-11", - "content": "Unit tests: Storage increment_verification_attempts", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-12", - "content": "Unit tests: Storage mark_verification_verified", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-13", - "content": "Unit tests: Storage delete_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-14", - "content": "Unit tests: Storage cleanup_expired_verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-15", - "content": "Unit tests: Verification code expiration logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-16", - "content": "Unit tests: Attempt counting and limits", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-1", - "content": "Create apps/zopp-server/src/config.rs with ServerConfig", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-2", - "content": "Add config validation (fail if verification enabled but no provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-3", - "content": "Add environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-4", - "content": "Unit tests: Config environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-5", - "content": "Unit tests: Config validation (missing provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-6", - "content": "Unit tests: Config validation (invalid SMTP config)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-7", - "content": "Unit tests: Config defaults and optional fields", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-1", - "content": "Add Resend and SMTP dependencies to Cargo.toml with features", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-2", - "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-3", - "content": "Create apps/zopp-server/src/email/templates.rs with email templates", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-4", - "content": "Implement Resend provider (email/resend.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-5", - "content": "Implement SMTP provider (email/smtp.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-6", - "content": "Add provider factory function with validation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-7", - "content": "Create code generation utility (cryptographically secure 6-digit)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-8", - "content": "Unit tests: Email templates text generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-9", - "content": "Unit tests: Email templates HTML generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-10", - "content": "Unit tests: Code generation format validation (6 digits)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-11", - "content": "Unit tests: Code generation randomness (no duplicates in sample)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-12", - "content": "Unit tests: Resend provider with mocked API", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-13", - "content": "Unit tests: SMTP provider with mocked transport", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-14", - "content": "Unit tests: Provider factory selection logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-15", - "content": "Unit tests: Provider factory error on invalid config", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-1", - "content": "Update zopp.proto with VerifyEmailRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-2", - "content": "Update zopp.proto with ResendVerificationRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-3", - "content": "Add verification_required field to JoinResponse", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-4", - "content": "Regenerate proto code (cargo build)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-1", - "content": "Modify join handler to support verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-2", - "content": "Implement verify_email handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-3", - "content": "Implement resend_verification handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-4", - "content": "Add background cleanup task for expired verifications", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase5-5", - "content": "Add authentication bypass for verify_email RPC", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-6", - "content": "Unit tests: Rate limiting logic (5 attempts per code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-7", - "content": "Unit tests: Rate limiting (3 codes per hour per email)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-8", - "content": "Unit tests: Verification code validation (constant-time comparison)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-9", - "content": "Unit tests: Background cleanup task removes expired verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-10", - "content": "Unit tests: Join handler with verification enabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-11", - "content": "Unit tests: Join handler with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-12", - "content": "Unit tests: VerifyEmail handler success case", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-13", - "content": "Unit tests: VerifyEmail handler wrong code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-14", - "content": "Unit tests: ResendVerification handler", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-1", - "content": "Modify CLI cmd_join to handle verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-2", - "content": "Add code input prompt and retry logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-3", - "content": "Add resend option during verification", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-1", - "content": "E2E test: Join with verification enabled (happy path)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-2", - "content": "E2E test: Join with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-3", - "content": "E2E test: Failed verification (wrong code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-4", - "content": "E2E test: Expired verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-5", - "content": "E2E test: Too many attempts (rate limiting)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-6", - "content": "E2E test: Resend verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-7", - "content": "E2E test: Workspace invite with verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-8", - "content": "E2E test: Bootstrap invite (should bypass verification)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase8-1", - "content": "Update CLAUDE.md with email configuration", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-2", - "content": "Create docs/docs/guides/email-verification.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-3", - "content": "Update docs/docs/reference/cli/join.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-4", - "content": "Add troubleshooting guide for email delivery issues", - "status": "pending", - "priority": "low" - }, - { - "id": "phase9-1", - "content": "Run full test suite", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-2", - "content": "Run clippy and fmt", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-3", - "content": "Create PR and monitor CI", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-4", - "content": "Address Cubic review comments", - "status": "pending", - "priority": "high" - } - ] -} -``` -**Output:** -``` -[ - { - "id": "phase1-1", - "content": "Create SQLite migration 20260121000001_add_email_verification.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-2", - "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-3", - "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-4", - "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-5", - "content": "Add EmailVerification struct and methods to zopp-storage trait", - "status": "in_progress", - "priority": "high" - }, - { - "id": "phase1-6", - "content": "Implement email verification methods in SQLite store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-7", - "content": "Implement email verification methods in PostgreSQL store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-8", - "content": "Run sqlx prepare for both stores", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-9", - "content": "Unit tests: Storage create_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-10", - "content": "Unit tests: Storage get_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-11", - "content": "Unit tests: Storage increment_verification_attempts", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-12", - "content": "Unit tests: Storage mark_verification_verified", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-13", - "content": "Unit tests: Storage delete_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-14", - "content": "Unit tests: Storage cleanup_expired_verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-15", - "content": "Unit tests: Verification code expiration logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-16", - "content": "Unit tests: Attempt counting and limits", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-1", - "content": "Create apps/zopp-server/src/config.rs with ServerConfig", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-2", - "content": "Add config validation (fail if verification enabled but no provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-3", - "content": "Add environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-4", - "content": "Unit tests: Config environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-5", - "content": "Unit tests: Config validation (missing provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-6", - "content": "Unit tests: Config validation (invalid SMTP config)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-7", - "content": "Unit tests: Config defaults and optional fields", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-1", - "content": "Add Resend and SMTP dependencies to Cargo.toml with features", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-2", - "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-3", - "content": "Create apps/zopp-server/src/email/templates.rs with email templates", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-4", - "content": "Implement Resend provider (email/resend.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-5", - "content": "Implement SMTP provider (email/smtp.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-6", - "content": "Add provider factory function with validation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-7", - "content": "Create code generation utility (cryptographically secure 6-digit)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-8", - "content": "Unit tests: Email templates text generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-9", - "content": "Unit tests: Email templates HTML generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-10", - "content": "Unit tests: Code generation format validation (6 digits)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-11", - "content": "Unit tests: Code generation randomness (no duplicates in sample)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-12", - "content": "Unit tests: Resend provider with mocked API", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-13", - "content": "Unit tests: SMTP provider with mocked transport", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-14", - "content": "Unit tests: Provider factory selection logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-15", - "content": "Unit tests: Provider factory error on invalid config", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-1", - "content": "Update zopp.proto with VerifyEmailRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-2", - "content": "Update zopp.proto with ResendVerificationRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-3", - "content": "Add verification_required field to JoinResponse", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-4", - "content": "Regenerate proto code (cargo build)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-1", - "content": "Modify join handler to support verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-2", - "content": "Implement verify_email handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-3", - "content": "Implement resend_verification handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-4", - "content": "Add background cleanup task for expired verifications", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase5-5", - "content": "Add authentication bypass for verify_email RPC", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-6", - "content": "Unit tests: Rate limiting logic (5 attempts per code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-7", - "content": "Unit tests: Rate limiting (3 codes per hour per email)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-8", - "content": "Unit tests: Verification code validation (constant-time comparison)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-9", - "content": "Unit tests: Background cleanup task removes expired verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-10", - "content": "Unit tests: Join handler with verification enabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-11", - "content": "Unit tests: Join handler with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-12", - "content": "Unit tests: VerifyEmail handler success case", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-13", - "content": "Unit tests: VerifyEmail handler wrong code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-14", - "content": "Unit tests: ResendVerification handler", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-1", - "content": "Modify CLI cmd_join to handle verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-2", - "content": "Add code input prompt and retry logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-3", - "content": "Add resend option during verification", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-1", - "content": "E2E test: Join with verification enabled (happy path)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-2", - "content": "E2E test: Join with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-3", - "content": "E2E test: Failed verification (wrong code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-4", - "content": "E2E test: Expired verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-5", - "content": "E2E test: Too many attempts (rate limiting)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-6", - "content": "E2E test: Resend verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-7", - "content": "E2E test: Workspace invite with verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-8", - "content": "E2E test: Bootstrap invite (should bypass verification)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase8-1", - "content": "Update CLAUDE.md with email configuration", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-2", - "content": "Create docs/docs/guides/email-verification.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-3", - "content": "Update docs/docs/reference/cli/join.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-4", - "content": "Add troubleshooting guide for email delivery issues", - "status": "pending", - "priority": "low" - }, - { - "id": "phase9-1", - "content": "Run full test suite", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-2", - "content": "Run clippy and fmt", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-3", - "content": "Create PR and monitor CI", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-4", - "content": "Address Cubic review comments", - "status": "pending", - "priority": "high" - } -] -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 5.9s) - - -Good progress! I've created all 4 migration files. Now I need to add the EmailVerification struct and methods to the storage trait. Let me first read the current storage trait to understand its structure. - - -Now let me read the storage trait to understand its structure before adding the email verification methods: - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs" -} -``` -**Output:** -``` - -00001| //! Storage abstraction for zopp. -00002| //! -00003| //! Backend crates (e.g., zopp-store-sqlite, zopp-store-postgres) implement this trait so -00004| //! `zopp-core` doesn't depend on any specific database engine or schema details. -00005| -00006| use chrono::{DateTime, Utc}; -00007| use std::str::FromStr; -00008| use thiserror::Error; -00009| use uuid::Uuid; -00010| -00011| /// Uniform error type for all storage backends. -00012| #[derive(Debug, Error)] -00013| pub enum StoreError { -00014| #[error("not found")] -00015| NotFound, -00016| #[error("already exists")] -00017| AlreadyExists, -00018| #[error("conflict")] -00019| Conflict, -00020| #[error("backend error: {0}")] -00021| Backend(String), -00022| } -00023| -00024| /// Strongly-typed identifiers & names (avoid mixing strings arbitrarily). -00025| #[derive(Clone, Debug, PartialEq, Eq, Hash)] -00026| pub struct UserId(pub Uuid); -00027| -00028| #[derive(Clone, Debug, PartialEq, Eq, Hash)] -00029| pub struct PrincipalId(pub Uuid); -00030| -00031| #[derive(Clone, Debug, PartialEq, Eq, Hash)] -00032| pub struct InviteId(pub Uuid); -00033| -00034| #[derive(Clone, Debug, PartialEq, Eq, Hash)] -00035| pub struct WorkspaceId(pub Uuid); -00036| -00037| #[derive(Clone, Debug, PartialEq, Eq, Hash)] -00038| pub struct ProjectId(pub Uuid); -00039| -00040| #[derive(Clone, Debug, PartialEq, Eq, Hash)] -00041| pub struct ProjectName(pub String); -00042| -00043| #[derive(Clone, Debug, PartialEq, Eq, Hash)] -00044| pub struct EnvironmentId(pub Uuid); -00045| -00046| #[derive(Clone, Debug, PartialEq, Eq, Hash)] -00047| pub struct EnvName(pub String); -00048| -00049| #[derive(Clone, Debug, PartialEq, Eq, Hash)] -00050| pub struct GroupId(pub Uuid); -00051| -00052| #[derive(Clone, Debug, PartialEq, Eq, Hash)] -00053| pub struct PrincipalExportId(pub Uuid); -00054| -00055| /// Role for RBAC permissions -00056| #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -00057| pub enum Role { -00058| Admin, -00059| Write, -00060| Read, -00061| } -00062| -00063| /// Error type for parsing Role from string -00064| #[derive(Debug, Clone, PartialEq, Eq)] -00065| pub struct ParseRoleError(pub String); -00066| -00067| impl std::fmt::Display for ParseRoleError { -00068| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { -00069| write!(f, "invalid role: {}", self.0) -00070| } -00071| } -00072| -00073| impl std::error::Error for ParseRoleError {} -00074| -00075| impl FromStr for Role { -00076| type Err = ParseRoleError; -00077| -00078| fn from_str(s: &str) -> Result { -00079| match s { -00080| "admin" => Ok(Role::Admin), -00081| "write" => Ok(Role::Write), -00082| "read" => Ok(Role::Read), -00083| _ => Err(ParseRoleError(s.to_string())), -00084| } -00085| } -00086| } -00087| -00088| impl Role { -00089| pub fn as_str(&self) -> &'static str { -00090| match self { -00091| Role::Admin => "admin", -00092| Role::Write => "write", -00093| Role::Read => "read", -00094| } -00095| } -00096| -00097| /// Check if this role has at least the permissions of another role -00098| pub fn includes(&self, other: &Role) -> bool { -00099| match self { -00100| Role::Admin => true, // Admin includes all permissions -00101| Role::Write => matches!(other, Role::Write | Role::Read), -00102| Role::Read => matches!(other, Role::Read), -00103| } -00104| } -00105| } -00106| -00107| /// Workspace-level permission -00108| #[derive(Clone, Debug)] -00109| pub struct WorkspacePermission { -00110| pub workspace_id: WorkspaceId, -00111| pub principal_id: PrincipalId, -00112| pub role: Role, -00113| pub created_at: DateTime, -00114| } -00115| -00116| /// Project-level permission -00117| #[derive(Clone, Debug)] -00118| pub struct ProjectPermission { -00119| pub project_id: ProjectId, -00120| pub principal_id: PrincipalId, -00121| pub role: Role, -00122| pub created_at: DateTime, -00123| } -00124| -00125| /// Environment-level permission (principal) -00126| #[derive(Clone, Debug)] -00127| pub struct EnvironmentPermission { -00128| pub environment_id: EnvironmentId, -00129| pub principal_id: PrincipalId, -00130| pub role: Role, -00131| pub created_at: DateTime, -00132| } -00133| -00134| /// User workspace-level permission -00135| #[derive(Clone, Debug)] -00136| pub struct UserWorkspacePermission { -00137| pub workspace_id: WorkspaceId, -00138| pub user_id: UserId, -00139| pub role: Role, -00140| pub created_at: DateTime, -00141| } -00142| -00143| /// User project-level permission -00144| #[derive(Clone, Debug)] -00145| pub struct UserProjectPermission { -00146| pub project_id: ProjectId, -00147| pub user_id: UserId, -00148| pub role: Role, -00149| pub created_at: DateTime, -00150| } -00151| -00152| /// User environment-level permission -00153| #[derive(Clone, Debug)] -00154| pub struct UserEnvironmentPermission { -00155| pub environment_id: EnvironmentId, -00156| pub user_id: UserId, -00157| pub role: Role, -00158| pub created_at: DateTime, -00159| } -00160| -00161| /// Group record -00162| #[derive(Clone, Debug)] -00163| pub struct Group { -00164| pub id: GroupId, -00165| pub workspace_id: WorkspaceId, -00166| pub name: String, -00167| pub description: Option, -00168| pub created_at: DateTime, -00169| pub updated_at: DateTime, -00170| } -00171| -00172| /// Group membership record -00173| #[derive(Clone, Debug)] -00174| pub struct GroupMember { -00175| pub group_id: GroupId, -00176| pub user_id: UserId, -00177| pub created_at: DateTime, -00178| } -00179| -00180| /// Group workspace-level permission -00181| #[derive(Clone, Debug)] -00182| pub struct GroupWorkspacePermission { -00183| pub workspace_id: WorkspaceId, -00184| pub group_id: GroupId, -00185| pub role: Role, -00186| pub created_at: DateTime, -00187| } -00188| -00189| /// Group project-level permission -00190| #[derive(Clone, Debug)] -00191| pub struct GroupProjectPermission { -00192| pub project_id: ProjectId, -00193| pub group_id: GroupId, -00194| pub role: Role, -00195| pub created_at: DateTime, -00196| } -00197| -00198| /// Group environment-level permission -00199| #[derive(Clone, Debug)] -00200| pub struct GroupEnvironmentPermission { -00201| pub environment_id: EnvironmentId, -00202| pub group_id: GroupId, -00203| pub role: Role, -00204| pub created_at: DateTime, -00205| } -00206| -00207| /// Parameters for creating a group -00208| #[derive(Clone, Debug)] -00209| pub struct CreateGroupParams { -00210| pub workspace_id: WorkspaceId, -00211| pub name: String, -00212| pub description: Option, -00213| } -00214| -00215| /// Encrypted secret row (nonce + ciphertext); no plaintext in storage. -00216| #[derive(Clone, Debug)] -00217| pub struct SecretRow { -00218| pub nonce: Vec, // 24 bytes (XChaCha20 nonce) -00219| pub ciphertext: Vec, // AEAD ciphertext -00220| } -00221| -00222| /// Parameters for creating a user -00223| #[derive(Clone, Debug)] -00224| pub struct CreateUserParams { -00225| pub email: String, -00226| /// Optional principal to create atomically with the user -00227| pub principal: Option, -00228| /// Workspaces to add this user to (user-level membership) -00229| pub workspace_ids: Vec, -00230| } -00231| -00232| /// Principal data for atomic user creation -00233| #[derive(Clone, Debug)] -00234| pub struct CreatePrincipalData { -00235| pub name: String, -00236| pub public_key: Vec, // Ed25519 for authentication -00237| pub x25519_public_key: Option>, // X25519 for encryption (ECDH) -00238| pub is_service: bool, // Service principal (user_id will be NULL) -00239| } -00240| -00241| /// Parameters for creating a principal -00242| #[derive(Clone, Debug)] -00243| pub struct CreatePrincipalParams { -00244| pub user_id: Option, // None for service accounts -00245| pub name: String, -00246| pub public_key: Vec, // Ed25519 for authentication -00247| pub x25519_public_key: Option>, // X25519 for encryption (ECDH) -00248| } -00249| -00250| /// Parameters for creating a workspace -00251| #[derive(Clone, Debug)] -00252| pub struct CreateWorkspaceParams { -00253| pub id: WorkspaceId, // Client-generated workspace ID -00254| pub name: String, -00255| pub owner_user_id: UserId, -00256| pub kdf_salt: Vec, // >= 16 bytes -00257| pub m_cost_kib: u32, // memory cost (KiB) -00258| pub t_cost: u32, // iterations -00259| pub p_cost: u32, // parallelism -00260| } -00261| -00262| /// Parameters for creating an invite -00263| #[derive(Clone, Debug)] -00264| pub struct CreateInviteParams { -00265| pub workspace_ids: Vec, -00266| pub token: String, // Hash of invite secret (for lookup) -00267| pub kek_encrypted: Option>, // Workspace KEK encrypted with invite secret -00268| pub kek_nonce: Option>, // 24-byte nonce for KEK encryption -00269| pub expires_at: DateTime, -00270| pub created_by_user_id: Option, // None for server-created invites -00271| } -00272| -00273| /// Parameters for creating a project -00274| #[derive(Clone, Debug)] -00275| pub struct CreateProjectParams { -00276| pub workspace_id: WorkspaceId, -00277| pub name: String, -00278| } -00279| -00280| /// Parameters for creating an environment -00281| #[derive(Clone, Debug)] -00282| pub struct CreateEnvParams { -00283| pub project_id: ProjectId, -00284| pub name: String, -00285| pub dek_wrapped: Vec, // wrapped DEK -00286| pub dek_nonce: Vec, // 24-byte nonce used in wrapping -00287| } -00288| -00289| /// User record -00290| #[derive(Clone, Debug)] -00291| pub struct User { -00292| pub id: UserId, -00293| pub email: String, -00294| pub created_at: DateTime, -00295| pub updated_at: DateTime, -00296| } -00297| -00298| /// Principal (device or service account) record -00299| #[derive(Clone, Debug)] -00300| pub struct Principal { -00301| pub id: PrincipalId, -00302| pub user_id: Option, // None for service accounts -00303| pub name: String, -00304| pub public_key: Vec, // Ed25519 for authentication -00305| pub x25519_public_key: Option>, // X25519 for encryption (ECDH) -00306| pub created_at: DateTime, -00307| pub updated_at: DateTime, -00308| } -00309| -00310| /// Invite record -00311| #[derive(Clone, Debug)] -00312| pub struct Invite { -00313| pub id: InviteId, -00314| pub token: String, -00315| pub workspace_ids: Vec, -00316| pub kek_encrypted: Option>, // Workspace KEK encrypted with invite secret -00317| pub kek_nonce: Option>, // 24-byte nonce for KEK encryption -00318| pub created_at: DateTime, -00319| pub updated_at: DateTime, -00320| pub expires_at: DateTime, -00321| pub created_by_user_id: Option, // None for server-created invites -00322| } -00323| -00324| /// Workspace record -00325| #[derive(Clone, Debug)] -00326| pub struct Workspace { -00327| pub id: WorkspaceId, -00328| pub name: String, -00329| pub owner_user_id: UserId, -00330| pub kdf_salt: Vec, -00331| pub m_cost_kib: u32, -00332| pub t_cost: u32, -00333| pub p_cost: u32, -00334| pub created_at: DateTime, -00335| pub updated_at: DateTime, -00336| } -00337| -00338| /// Workspace-Principal junction with wrapped KEK -00339| #[derive(Clone, Debug)] -00340| pub struct WorkspacePrincipal { -00341| pub workspace_id: WorkspaceId, -00342| pub principal_id: PrincipalId, -00343| pub ephemeral_pub: Vec, // Ephemeral X25519 public key for wrapping -00344| pub kek_wrapped: Vec, // Workspace KEK wrapped for this principal -00345| pub kek_nonce: Vec, // 24-byte nonce for wrapping -00346| pub created_at: DateTime, -00347| } -00348| -00349| /// Parameters for adding a principal to a workspace with wrapped KEK -00350| #[derive(Clone, Debug)] -00351| pub struct AddWorkspacePrincipalParams { -00352| pub workspace_id: WorkspaceId, -00353| pub principal_id: PrincipalId, -00354| pub ephemeral_pub: Vec, -00355| pub kek_wrapped: Vec, -00356| pub kek_nonce: Vec, -00357| } -00358| -00359| /// Project record -00360| #[derive(Clone, Debug)] -00361| pub struct Project { -00362| pub id: ProjectId, -00363| pub workspace_id: WorkspaceId, -00364| pub name: String, -00365| pub created_at: DateTime, -00366| pub updated_at: DateTime, -00367| } -00368| -00369| /// Environment record -00370| #[derive(Clone, Debug)] -00371| pub struct Environment { -00372| pub id: EnvironmentId, -00373| pub project_id: ProjectId, -00374| pub name: String, -00375| pub dek_wrapped: Vec, -00376| pub dek_nonce: Vec, -00377| pub version: i64, // Monotonic version counter for change tracking -00378| pub created_at: DateTime, -00379| pub updated_at: DateTime, -00380| } -00381| -00382| /// Principal export record for multi-device transfer -00383| #[derive(Clone, Debug)] -00384| pub struct PrincipalExport { -00385| pub id: PrincipalExportId, -00386| pub export_code: String, // Public identifier for lookup (e.g., "exp_a7k9m2x4") -00387| pub token_hash: String, // Argon2id(passphrase, verification_salt) for verification -00388| pub verification_salt: Vec, // Salt for passphrase verification (separate from encryption) -00389| pub user_id: UserId, -00390| pub principal_id: PrincipalId, -00391| pub encrypted_data: Vec, // Encrypted principal JSON -00392| pub salt: Vec, // Argon2id salt for encryption key derivation -00393| pub nonce: Vec, // XChaCha20-Poly1305 nonce -00394| pub expires_at: DateTime, -00395| pub created_at: DateTime, -00396| pub consumed: bool, -00397| pub failed_attempts: i32, // Track failed passphrase attempts (delete after 3) -00398| } -00399| -00400| /// Parameters for creating a principal export -00401| #[derive(Clone, Debug)] -00402| pub struct CreatePrincipalExportParams { -00403| pub export_code: String, // Public identifier for lookup (e.g., "exp_a7k9m2x4") -00404| pub token_hash: String, // Argon2id(passphrase, verification_salt) for verification -00405| pub verification_salt: Vec, // Salt for passphrase verification (separate from encryption) -00406| pub user_id: UserId, -00407| pub principal_id: PrincipalId, -00408| pub encrypted_data: Vec, // Encrypted principal JSON -00409| pub salt: Vec, // Argon2id salt for encryption key derivation -00410| pub nonce: Vec, // XChaCha20-Poly1305 nonce -00411| pub expires_at: DateTime, -00412| } -00413| -00414| /// The storage trait `zopp-core` depends on. -00415| /// -00416| /// All methods that act on project/env/secrets are **scoped by workspace**. -00417| #[cfg_attr(feature = "test-support", mockall::automock)] -00418| #[async_trait::async_trait] -00419| pub trait Store: Send + Sync { -00420| // ───────────────────────────────────── Users ────────────────────────────────────────── -00421| -00422| /// Create a new user (returns generated ID, and optional principal ID if principal was provided). -00423| /// If params.principal is provided, atomically creates the user, principal, and adds principal to workspaces. -00424| async fn create_user( -00425| &self, -00426| params: &CreateUserParams, -00427| ) -> Result<(UserId, Option), StoreError>; -00428| -00429| /// Get user by email. -00430| async fn get_user_by_email(&self, email: &str) -> Result; -00431| -00432| /// Get user by ID. -00433| async fn get_user_by_id(&self, user_id: &UserId) -> Result; -00434| -00435| // ───────────────────────────────────── Principals ───────────────────────────────────── -00436| -00437| /// Create a new principal (device) for a user. -00438| async fn create_principal( -00439| &self, -00440| params: &CreatePrincipalParams, -00441| ) -> Result; -00442| -00443| /// Get principal by ID. -00444| async fn get_principal(&self, principal_id: &PrincipalId) -> Result; -00445| -00446| /// Rename a principal. -00447| async fn rename_principal( -00448| &self, -00449| principal_id: &PrincipalId, -00450| new_name: &str, -00451| ) -> Result<(), StoreError>; -00452| -00453| /// List all principals for a user. -00454| async fn list_principals(&self, user_id: &UserId) -> Result, StoreError>; -00455| -00456| // ───────────────────────────────────── Invites ──────────────────────────────────────── -00457| -00458| /// Create an invite token (returns generated ID and token). -00459| async fn create_invite(&self, params: &CreateInviteParams) -> Result; -00460| -00461| /// Get invite by token. -00462| async fn get_invite_by_token(&self, token: &str) -> Result; -00463| -00464| /// List all active invites for a user (None = server invites). -00465| async fn list_invites(&self, user_id: Option) -> Result, StoreError>; -00466| -00467| /// Revoke an invite. -00468| async fn revoke_invite(&self, invite_id: &InviteId) -> Result<(), StoreError>; -00469| -00470| // ───────────────────────────────────── Principal Exports ────────────────────────────── -00471| -00472| /// Create a principal export for multi-device transfer. -00473| async fn create_principal_export( -00474| &self, -00475| params: &CreatePrincipalExportParams, -00476| ) -> Result; -00477| -00478| /// Get principal export by export code. -00479| async fn get_principal_export_by_code( -00480| &self, -00481| export_code: &str, -00482| ) -> Result; -00483| -00484| /// Mark a principal export as consumed (can only be used once). -00485| async fn consume_principal_export( -00486| &self, -00487| export_id: &PrincipalExportId, -00488| ) -> Result<(), StoreError>; -00489| -00490| /// Increment failed attempts counter for a principal export. -00491| /// Returns the new failed_attempts count. -00492| async fn increment_export_failed_attempts( -00493| &self, -00494| export_id: &PrincipalExportId, -00495| ) -> Result; -00496| -00497| /// Delete a principal export (used after 3 failed attempts or manual cleanup). -00498| async fn delete_principal_export( -00499| &self, -00500| export_id: &PrincipalExportId, -00501| ) -> Result<(), StoreError>; -00502| -00503| // ───────────────────────────────────── Workspaces ───────────────────────────────────── -00504| -00505| /// Create a new workspace (returns its generated ID). -00506| async fn create_workspace( -00507| &self, -00508| params: &CreateWorkspaceParams, -00509| ) -> Result; -00510| -00511| /// List all workspaces that a principal has KEK access to. -00512| async fn list_workspaces( -00513| &self, -00514| principal_id: &PrincipalId, -00515| ) -> Result, StoreError>; -00516| -00517| /// Get workspace by ID. -00518| async fn get_workspace(&self, ws: &WorkspaceId) -> Result; -00519| -00520| /// Get workspace by name for a user (user must have access). -00521| async fn get_workspace_by_name( -00522| &self, -00523| user_id: &UserId, -00524| name: &str, -00525| ) -> Result; -00526| -00527| /// Get workspace by name for a principal (principal must have access). -00528| async fn get_workspace_by_name_for_principal( -00529| &self, -00530| principal_id: &PrincipalId, -00531| name: &str, -00532| ) -> Result; -00533| -00534| /// Add a principal to a workspace with wrapped KEK. -00535| async fn add_workspace_principal( -00536| &self, -00537| params: &AddWorkspacePrincipalParams, -00538| ) -> Result<(), StoreError>; -00539| -00540| /// Get workspace principal (to access wrapped KEK). -00541| async fn get_workspace_principal( -00542| &self, -00543| workspace_id: &WorkspaceId, -00544| principal_id: &PrincipalId, -00545| ) -> Result; -00546| -00547| /// List all principals in a workspace (with their wrapped KEKs). -00548| async fn list_workspace_principals( -00549| &self, -00550| workspace_id: &WorkspaceId, -00551| ) -> Result, StoreError>; -00552| -00553| /// Remove a principal from a workspace. -00554| async fn remove_workspace_principal( -00555| &self, -00556| workspace_id: &WorkspaceId, -00557| principal_id: &PrincipalId, -00558| ) -> Result<(), StoreError>; -00559| -00560| /// Remove all project permissions for a principal in a workspace. -00561| /// Returns the number of permissions removed. -00562| async fn remove_all_project_permissions_for_principal( -00563| &self, -00564| workspace_id: &WorkspaceId, -00565| principal_id: &PrincipalId, -00566| ) -> Result; -00567| -00568| /// Remove all environment permissions for a principal in a workspace. -00569| /// Returns the number of permissions removed. -00570| async fn remove_all_environment_permissions_for_principal( -00571| &self, -00572| workspace_id: &WorkspaceId, -00573| principal_id: &PrincipalId, -00574| ) -> Result; -00575| -00576| /// Add a user to a workspace (user-level membership). -00577| async fn add_user_to_workspace( -00578| &self, -00579| workspace_id: &WorkspaceId, -00580| user_id: &UserId, -00581| ) -> Result<(), StoreError>; -00582| -00583| // ───────────────────────────────────── Projects ─────────────────────────────────────── -00584| -00585| /// Create a project within a workspace (returns generated ID). -00586| async fn create_project(&self, params: &CreateProjectParams) -> Result; -00587| -00588| /// List all projects in a workspace. -00589| async fn list_projects(&self, workspace_id: &WorkspaceId) -> Result, StoreError>; -00590| -00591| /// Get a project by ID. -00592| async fn get_project(&self, project_id: &ProjectId) -> Result; -00593| -00594| /// Get a project by name within a workspace. -00595| async fn get_project_by_name( -00596| &self, -00597| workspace_id: &WorkspaceId, -00598| name: &str, -00599| ) -> Result; -00600| -00601| /// Delete a project (and all its environments and secrets). -00602| async fn delete_project(&self, project_id: &ProjectId) -> Result<(), StoreError>; -00603| -00604| // ─────────────────────────────────────── Environments ───────────────────────────────────── -00605| -00606| /// Create an environment within a project (returns generated ID). -00607| async fn create_env(&self, params: &CreateEnvParams) -> Result; -00608| -00609| /// List all environments in a project. -00610| async fn list_environments( -00611| &self, -00612| project_id: &ProjectId, -00613| ) -> Result, StoreError>; -00614| -00615| /// Get an environment by ID. -00616| async fn get_environment(&self, env_id: &EnvironmentId) -> Result; -00617| -00618| /// Get an environment by name within a project. -00619| async fn get_environment_by_name( -00620| &self, -00621| project_id: &ProjectId, -00622| name: &str, -00623| ) -> Result; -00624| -00625| /// Delete an environment (and all its secrets). -00626| async fn delete_environment(&self, env_id: &EnvironmentId) -> Result<(), StoreError>; -00627| -00628| // ────────────────────────────────────── Secrets ─────────────────────────────────────── -00629| -00630| /// Upsert a secret value (AEAD ciphertext + nonce) in an environment. -00631| /// Returns the new environment version after the update. -00632| async fn upsert_secret( -00633| &self, -00634| env_id: &EnvironmentId, -00635| key: &str, -00636| nonce: &[u8], // per-value 24B nonce -00637| ciphertext: &[u8], // AEAD ciphertext under DEK -00638| ) -> Result; -00639| -00640| /// Fetch a secret row (nonce + ciphertext). -00641| async fn get_secret(&self, env_id: &EnvironmentId, key: &str) -> Result; -00642| -00643| /// List all secret keys in an environment. -00644| async fn list_secret_keys(&self, env_id: &EnvironmentId) -> Result, StoreError>; -00645| -00646| /// Delete a secret from an environment. -00647| /// Returns the new environment version after the deletion. -00648| async fn delete_secret(&self, env_id: &EnvironmentId, key: &str) -> Result; -00649| -00650| /// Fetch the (wrapped_dek, dek_nonce) pair for an environment so core can unwrap it (legacy name-based). -00651| async fn get_env_wrap( -00652| &self, -00653| ws: &WorkspaceId, -00654| project: &ProjectName, -00655| env: &EnvName, -00656| ) -> Result<(Vec, Vec), StoreError>; -00657| -00658| // ────────────────────────────────────── RBAC Permissions ────────────────────────────────── -00659| -00660| /// Set workspace-level permission for a principal -00661| async fn set_workspace_permission( -00662| &self, -00663| workspace_id: &WorkspaceId, -00664| principal_id: &PrincipalId, -00665| role: Role, -00666| ) -> Result<(), StoreError>; -00667| -00668| /// Get workspace-level permission for a principal -00669| async fn get_workspace_permission( -00670| &self, -00671| workspace_id: &WorkspaceId, -00672| principal_id: &PrincipalId, -00673| ) -> Result; -00674| -00675| /// List all workspace permissions for a principal -00676| async fn list_workspace_permissions_for_principal( -00677| &self, -00678| principal_id: &PrincipalId, -00679| ) -> Result, StoreError>; -00680| -00681| /// List all principals with permissions on a workspace -00682| async fn list_workspace_permissions( -00683| &self, -00684| workspace_id: &WorkspaceId, -00685| ) -> Result, StoreError>; -00686| -00687| /// Remove workspace-level permission for a principal -00688| async fn remove_workspace_permission( -00689| &self, -00690| workspace_id: &WorkspaceId, -00691| principal_id: &PrincipalId, -00692| ) -> Result<(), StoreError>; -00693| -00694| /// Set project-level permission for a principal -00695| async fn set_project_permission( -00696| &self, -00697| project_id: &ProjectId, -00698| principal_id: &PrincipalId, -00699| role: Role, -00700| ) -> Result<(), StoreError>; -00701| -00702| /// Get project-level permission for a principal -00703| async fn get_project_permission( -00704| &self, -00705| project_id: &ProjectId, -00706| principal_id: &PrincipalId, -00707| ) -> Result; -00708| -00709| /// List all project permissions for a principal -00710| async fn list_project_permissions_for_principal( -00711| &self, -00712| principal_id: &PrincipalId, -00713| ) -> Result, StoreError>; -00714| -00715| /// List all principals with permissions on a project -00716| async fn list_project_permissions( -00717| &self, -00718| project_id: &ProjectId, -00719| ) -> Result, StoreError>; -00720| -00721| /// Remove project-level permission for a principal -00722| async fn remove_project_permission( -00723| &self, -00724| project_id: &ProjectId, -00725| principal_id: &PrincipalId, -00726| ) -> Result<(), StoreError>; -00727| -00728| /// Set environment-level permission for a principal -00729| async fn set_environment_permission( -00730| &self, -00731| environment_id: &EnvironmentId, -00732| principal_id: &PrincipalId, -00733| role: Role, -00734| ) -> Result<(), StoreError>; -00735| -00736| /// Get environment-level permission for a principal -00737| async fn get_environment_permission( -00738| &self, -00739| environment_id: &EnvironmentId, -00740| principal_id: &PrincipalId, -00741| ) -> Result; -00742| -00743| /// List all environment permissions for a principal -00744| async fn list_environment_permissions_for_principal( -00745| &self, -00746| principal_id: &PrincipalId, -00747| ) -> Result, StoreError>; -00748| -00749| /// List all principals with permissions on an environment -00750| async fn list_environment_permissions( -00751| &self, -00752| environment_id: &EnvironmentId, -00753| ) -> Result, StoreError>; -00754| -00755| /// Remove environment-level permission for a principal -00756| async fn remove_environment_permission( -00757| &self, -00758| environment_id: &EnvironmentId, -00759| principal_id: &PrincipalId, -00760| ) -> Result<(), StoreError>; -00761| -00762| // ────────────────────────────────────── User Permissions ──────────────────────────────────────── -00763| -00764| /// Set workspace-level permission for a user -00765| async fn set_user_workspace_permission( -00766| &self, -00767| workspace_id: &WorkspaceId, -00768| user_id: &UserId, -00769| role: Role, -00770| ) -> Result<(), StoreError>; -00771| -00772| /// Get workspace-level permission for a user -00773| async fn get_user_workspace_permission( -00774| &self, -00775| workspace_id: &WorkspaceId, -00776| user_id: &UserId, -00777| ) -> Result; -00778| -00779| /// List all user permissions on a workspace -00780| async fn list_user_workspace_permissions( -00781| &self, -00782| workspace_id: &WorkspaceId, -00783| ) -> Result, StoreError>; -00784| -00785| /// Remove workspace-level permission for a user -00786| async fn remove_user_workspace_permission( -00787| &self, -00788| workspace_id: &WorkspaceId, -00789| user_id: &UserId, -00790| ) -> Result<(), StoreError>; -00791| -00792| /// Set project-level permission for a user -00793| async fn set_user_project_permission( -00794| &self, -00795| project_id: &ProjectId, -00796| user_id: &UserId, -00797| role: Role, -00798| ) -> Result<(), StoreError>; -00799| -00800| /// Get project-level permission for a user -00801| async fn get_user_project_permission( -00802| &self, -00803| project_id: &ProjectId, -00804| user_id: &UserId, -00805| ) -> Result; -00806| -00807| /// List all user permissions on a project -00808| async fn list_user_project_permissions( -00809| &self, -00810| project_id: &ProjectId, -00811| ) -> Result, StoreError>; -00812| -00813| /// Remove project-level permission for a user -00814| async fn remove_user_project_permission( -00815| &self, -00816| project_id: &ProjectId, -00817| user_id: &UserId, -00818| ) -> Result<(), StoreError>; -00819| -00820| /// Set environment-level permission for a user -00821| async fn set_user_environment_permission( -00822| &self, -00823| environment_id: &EnvironmentId, -00824| user_id: &UserId, -00825| role: Role, -00826| ) -> Result<(), StoreError>; -00827| -00828| /// Get environment-level permission for a user -00829| async fn get_user_environment_permission( -00830| &self, -00831| environment_id: &EnvironmentId, -00832| user_id: &UserId, -00833| ) -> Result; -00834| -00835| /// List all user permissions on an environment -00836| async fn list_user_environment_permissions( -00837| &self, -00838| environment_id: &EnvironmentId, -00839| ) -> Result, StoreError>; -00840| -00841| /// Remove environment-level permission for a user -00842| async fn remove_user_environment_permission( -00843| &self, -00844| environment_id: &EnvironmentId, -00845| user_id: &UserId, -00846| ) -> Result<(), StoreError>; -00847| -00848| // ────────────────────────────────────── Groups ──────────────────────────────────────── -00849| -00850| /// Create a new group within a workspace -00851| async fn create_group(&self, params: &CreateGroupParams) -> Result; -00852| -00853| /// Get group by ID -00854| async fn get_group(&self, group_id: &GroupId) -> Result; -00855| -00856| /// Get group by name within a workspace -00857| async fn get_group_by_name( -00858| &self, -00859| workspace_id: &WorkspaceId, -00860| name: &str, -00861| ) -> Result; -00862| -00863| /// List all groups in a workspace -00864| async fn list_groups(&self, workspace_id: &WorkspaceId) -> Result, StoreError>; -00865| -00866| /// Update group description -00867| async fn update_group( -00868| &self, -00869| group_id: &GroupId, -00870| name: &str, -00871| description: Option, -00872| ) -> Result<(), StoreError>; -00873| -00874| /// Delete a group (and all its memberships and permissions) -00875| async fn delete_group(&self, group_id: &GroupId) -> Result<(), StoreError>; -00876| -00877| /// Add a user to a group -00878| async fn add_group_member( -00879| &self, -00880| group_id: &GroupId, -00881| user_id: &UserId, -00882| ) -> Result<(), StoreError>; -00883| -00884| /// Remove a user from a group -00885| async fn remove_group_member( -00886| &self, -00887| group_id: &GroupId, -00888| user_id: &UserId, -00889| ) -> Result<(), StoreError>; -00890| -00891| /// List all members of a group -00892| async fn list_group_members(&self, group_id: &GroupId) -> Result, StoreError>; -00893| -00894| /// List all groups a user belongs to -00895| async fn list_user_groups(&self, user_id: &UserId) -> Result, StoreError>; -00896| -00897| /// Set workspace-level permission for a group -00898| async fn set_group_workspace_permission( -00899| &self, -00900| workspace_id: &WorkspaceId, -00901| group_id: &GroupId, -00902| role: Role, -00903| ) -> Result<(), StoreError>; -00904| -00905| /// Get workspace-level permission for a group -00906| async fn get_group_workspace_permission( -00907| &self, -00908| workspace_id: &WorkspaceId, -00909| group_id: &GroupId, -00910| ) -> Result; -00911| -00912| /// List all workspace permissions for a group -00913| async fn list_group_workspace_permissions( -00914| &self, -00915| workspace_id: &WorkspaceId, -00916| ) -> Result, StoreError>; -00917| -00918| /// Remove workspace-level permission for a group -00919| async fn remove_group_workspace_permission( -00920| &self, -00921| workspace_id: &WorkspaceId, -00922| group_id: &GroupId, -00923| ) -> Result<(), StoreError>; -00924| -00925| /// Set project-level permission for a group -00926| async fn set_group_project_permission( -00927| &self, -00928| project_id: &ProjectId, -00929| group_id: &GroupId, -00930| role: Role, -00931| ) -> Result<(), StoreError>; -00932| -00933| /// Get project-level permission for a group -00934| async fn get_group_project_permission( -00935| &self, -00936| project_id: &ProjectId, -00937| group_id: &GroupId, -00938| ) -> Result; -00939| -00940| /// List all project permissions for a group -00941| async fn list_group_project_permissions( -00942| &self, -00943| project_id: &ProjectId, -00944| ) -> Result, StoreError>; -00945| -00946| /// Remove project-level permission for a group -00947| async fn remove_group_project_permission( -00948| &self, -00949| project_id: &ProjectId, -00950| group_id: &GroupId, -00951| ) -> Result<(), StoreError>; -00952| -00953| /// Set environment-level permission for a group -00954| async fn set_group_environment_permission( -00955| &self, -00956| environment_id: &EnvironmentId, -00957| group_id: &GroupId, -00958| role: Role, -00959| ) -> Result<(), StoreError>; -00960| -00961| /// Get environment-level permission for a group -00962| async fn get_group_environment_permission( -00963| &self, -00964| environment_id: &EnvironmentId, -00965| group_id: &GroupId, -00966| ) -> Result; -00967| -00968| /// List all environment permissions for a group -00969| async fn list_group_environment_permissions( -00970| &self, -00971| environment_id: &EnvironmentId, -00972| ) -> Result, StoreError>; -00973| -00974| /// Remove environment-level permission for a group -00975| async fn remove_group_environment_permission( -00976| &self, -00977| environment_id: &EnvironmentId, -00978| group_id: &GroupId, -00979| ) -> Result<(), StoreError>; -00980| } -00981| -00982| #[cfg(test)] -00983| mod tests { -00984| use super::*; -00985| -00986| // Tiny compile-time smoke test for trait object usage. -00987| struct NoopStore; -00988| #[async_trait::async_trait] -00989| impl Store for NoopStore { -00990| async fn create_user( -00991| &self, -00992| _params: &CreateUserParams, -00993| ) -> Result<(UserId, Option), StoreError> { -00994| let user_id = UserId(Uuid::new_v4()); -00995| let principal_id = _params -00996| .principal -00997| .as_ref() -00998| .map(|_| PrincipalId(Uuid::new_v4())); -00999| Ok((user_id, principal_id)) -01000| } -01001| -01002| async fn get_user_by_email(&self, _email: &str) -> Result { -01003| Err(StoreError::NotFound) -01004| } -01005| -01006| async fn get_user_by_id(&self, _user_id: &UserId) -> Result { -01007| Err(StoreError::NotFound) -01008| } -01009| -01010| async fn create_principal( -01011| &self, -01012| _params: &CreatePrincipalParams, -01013| ) -> Result { -01014| Ok(PrincipalId(Uuid::new_v4())) -01015| } -01016| -01017| async fn get_principal( -01018| &self, -01019| _principal_id: &PrincipalId, -01020| ) -> Result { -01021| Err(StoreError::NotFound) -01022| } -01023| -01024| async fn rename_principal( -01025| &self, -01026| _principal_id: &PrincipalId, -01027| _new_name: &str, -01028| ) -> Result<(), StoreError> { -01029| Ok(()) -01030| } -01031| -01032| async fn list_principals(&self, _user_id: &UserId) -> Result, StoreError> { -01033| Ok(vec![]) -01034| } -01035| -01036| async fn create_invite(&self, _params: &CreateInviteParams) -> Result { -01037| Ok(Invite { -01038| id: InviteId(Uuid::new_v4()), -01039| token: "test-token".to_string(), -01040| workspace_ids: vec![], -01041| kek_encrypted: None, -01042| kek_nonce: None, -01043| created_at: Utc::now(), -01044| updated_at: Utc::now(), -01045| expires_at: Utc::now(), -01046| created_by_user_id: _params.created_by_user_id.clone(), -01047| }) -01048| } -01049| -01050| async fn get_invite_by_token(&self, _token: &str) -> Result { -01051| Err(StoreError::NotFound) -01052| } -01053| -01054| async fn list_invites(&self, _user_id: Option) -> Result, StoreError> { -01055| Ok(vec![]) -01056| } -01057| -01058| async fn revoke_invite(&self, _invite_id: &InviteId) -> Result<(), StoreError> { -01059| Ok(()) -01060| } -01061| -01062| async fn create_principal_export( -01063| &self, -01064| params: &CreatePrincipalExportParams, -01065| ) -> Result { -01066| Ok(PrincipalExport { -01067| id: PrincipalExportId(Uuid::new_v4()), -01068| export_code: params.export_code.clone(), -01069| token_hash: params.token_hash.clone(), -01070| verification_salt: params.verification_salt.clone(), -01071| user_id: params.user_id.clone(), -01072| principal_id: params.principal_id.clone(), -01073| encrypted_data: params.encrypted_data.clone(), -01074| salt: params.salt.clone(), -01075| nonce: params.nonce.clone(), -01076| expires_at: params.expires_at, -01077| created_at: Utc::now(), -01078| consumed: false, -01079| failed_attempts: 0, -01080| }) -01081| } -01082| -01083| async fn get_principal_export_by_code( -01084| &self, -01085| _export_code: &str, -01086| ) -> Result { -01087| Err(StoreError::NotFound) -01088| } -01089| -01090| async fn consume_principal_export( -01091| &self, -01092| _export_id: &PrincipalExportId, -01093| ) -> Result<(), StoreError> { -01094| Ok(()) -01095| } -01096| -01097| async fn increment_export_failed_attempts( -01098| &self, -01099| _export_id: &PrincipalExportId, -01100| ) -> Result { -01101| Ok(1) -01102| } -01103| -01104| async fn delete_principal_export( -01105| &self, -01106| _export_id: &PrincipalExportId, -01107| ) -> Result<(), StoreError> { -01108| Ok(()) -01109| } -01110| -01111| async fn create_workspace( -01112| &self, -01113| _params: &CreateWorkspaceParams, -01114| ) -> Result { -01115| Ok(WorkspaceId(Uuid::new_v4())) -01116| } -01117| -01118| async fn list_workspaces( -01119| &self, -01120| _principal_id: &PrincipalId, -01121| ) -> Result, StoreError> { -01122| Ok(vec![]) -01123| } -01124| -01125| async fn get_workspace(&self, _ws: &WorkspaceId) -> Result { -01126| Err(StoreError::NotFound) -01127| } -01128| -01129| async fn get_workspace_by_name( -01130| &self, -01131| _user_id: &UserId, -01132| _name: &str, -01133| ) -> Result { -01134| Err(StoreError::NotFound) -01135| } -01136| -01137| async fn get_workspace_by_name_for_principal( -01138| &self, -01139| _principal_id: &PrincipalId, -01140| _name: &str, -01141| ) -> Result { -01142| Err(StoreError::NotFound) -01143| } -01144| -01145| async fn add_workspace_principal( -01146| &self, -01147| _params: &AddWorkspacePrincipalParams, -01148| ) -> Result<(), StoreError> { -01149| Ok(()) -01150| } -01151| -01152| async fn get_workspace_principal( -01153| &self, -01154| _workspace_id: &WorkspaceId, -01155| _principal_id: &PrincipalId, -01156| ) -> Result { -01157| Err(StoreError::NotFound) -01158| } -01159| -01160| async fn list_workspace_principals( -01161| &self, -01162| _workspace_id: &WorkspaceId, -01163| ) -> Result, StoreError> { -01164| Ok(vec![]) -01165| } -01166| -01167| async fn remove_workspace_principal( -01168| &self, -01169| _workspace_id: &WorkspaceId, -01170| _principal_id: &PrincipalId, -01171| ) -> Result<(), StoreError> { -01172| Ok(()) -01173| } -01174| -01175| async fn remove_all_project_permissions_for_principal( -01176| &self, -01177| _workspace_id: &WorkspaceId, -01178| _principal_id: &PrincipalId, -01179| ) -> Result { -01180| Ok(0) -01181| } -01182| -01183| async fn remove_all_environment_permissions_for_principal( -01184| &self, -01185| _workspace_id: &WorkspaceId, -01186| _principal_id: &PrincipalId, -01187| ) -> Result { -01188| Ok(0) -01189| } -01190| -01191| async fn add_user_to_workspace( -01192| &self, -01193| _workspace_id: &WorkspaceId, -01194| _user_id: &UserId, -01195| ) -> Result<(), StoreError> { -01196| Ok(()) -01197| } -01198| -01199| async fn create_project( -01200| &self, -01201| _params: &CreateProjectParams, -01202| ) -> Result { -01203| Ok(ProjectId(Uuid::new_v4())) -01204| } -01205| -01206| async fn list_projects( -01207| &self, -01208| _workspace_id: &WorkspaceId, -01209| ) -> Result, StoreError> { -01210| Ok(vec![]) -01211| } -01212| -01213| async fn get_project(&self, _project_id: &ProjectId) -> Result { -01214| Err(StoreError::NotFound) -01215| } -01216| -01217| async fn get_project_by_name( -01218| &self, -01219| _workspace_id: &WorkspaceId, -01220| _name: &str, -01221| ) -> Result { -01222| Err(StoreError::NotFound) -01223| } -01224| -01225| async fn delete_project(&self, _project_id: &ProjectId) -> Result<(), StoreError> { -01226| Ok(()) -01227| } -01228| -01229| async fn create_env(&self, _params: &CreateEnvParams) -> Result { -01230| Ok(EnvironmentId(Uuid::new_v4())) -01231| } -01232| -01233| async fn list_environments( -01234| &self, -01235| _project_id: &ProjectId, -01236| ) -> Result, StoreError> { -01237| Ok(vec![]) -01238| } -01239| -01240| async fn get_environment( -01241| &self, -01242| _env_id: &EnvironmentId, -01243| ) -> Result { -01244| Err(StoreError::NotFound) -01245| } -01246| -01247| async fn get_environment_by_name( -01248| &self, -01249| _project_id: &ProjectId, -01250| _name: &str, -01251| ) -> Result { -01252| Err(StoreError::NotFound) -01253| } -01254| -01255| async fn delete_environment(&self, _env_id: &EnvironmentId) -> Result<(), StoreError> { -01256| Ok(()) -01257| } -01258| -01259| async fn get_env_wrap( -01260| &self, -01261| _ws: &WorkspaceId, -01262| _project: &ProjectName, -01263| _env: &EnvName, -01264| ) -> Result<(Vec, Vec), StoreError> { -01265| Err(StoreError::NotFound) -01266| } -01267| -01268| async fn upsert_secret( -01269| &self, -01270| _env_id: &EnvironmentId, -01271| _key: &str, -01272| _nonce: &[u8], -01273| _ciphertext: &[u8], -01274| ) -> Result { -01275| Ok(1) -01276| } -01277| -01278| async fn get_secret( -01279| &self, -01280| _env_id: &EnvironmentId, -01281| _key: &str, -01282| ) -> Result { -01283| Err(StoreError::NotFound) -01284| } -01285| -01286| async fn list_secret_keys( -01287| &self, -01288| _env_id: &EnvironmentId, -01289| ) -> Result, StoreError> { -01290| Ok(vec![]) -01291| } -01292| -01293| async fn delete_secret( -01294| &self, -01295| _env_id: &EnvironmentId, -01296| _key: &str, -01297| ) -> Result { -01298| Ok(1) -01299| } -01300| -01301| async fn set_workspace_permission( -01302| &self, -01303| _workspace_id: &WorkspaceId, -01304| _principal_id: &PrincipalId, -01305| _role: Role, -01306| ) -> Result<(), StoreError> { -01307| Ok(()) -01308| } -01309| -01310| async fn get_workspace_permission( -01311| &self, -01312| _workspace_id: &WorkspaceId, -01313| _principal_id: &PrincipalId, -01314| ) -> Result { -01315| Err(StoreError::NotFound) -01316| } -01317| -01318| async fn list_workspace_permissions_for_principal( -01319| &self, -01320| _principal_id: &PrincipalId, -01321| ) -> Result, StoreError> { -01322| Ok(vec![]) -01323| } -01324| -01325| async fn list_workspace_permissions( -01326| &self, -01327| _workspace_id: &WorkspaceId, -01328| ) -> Result, StoreError> { -01329| Ok(vec![]) -01330| } -01331| -01332| async fn remove_workspace_permission( -01333| &self, -01334| _workspace_id: &WorkspaceId, -01335| _principal_id: &PrincipalId, -01336| ) -> Result<(), StoreError> { -01337| Ok(()) -01338| } -01339| -01340| async fn set_project_permission( -01341| &self, -01342| _project_id: &ProjectId, -01343| _principal_id: &PrincipalId, -01344| _role: Role, -01345| ) -> Result<(), StoreError> { -01346| Ok(()) -01347| } -01348| -01349| async fn get_project_permission( -01350| &self, -01351| _project_id: &ProjectId, -01352| _principal_id: &PrincipalId, -01353| ) -> Result { -01354| Err(StoreError::NotFound) -01355| } -01356| -01357| async fn list_project_permissions_for_principal( -01358| &self, -01359| _principal_id: &PrincipalId, -01360| ) -> Result, StoreError> { -01361| Ok(vec![]) -01362| } -01363| -01364| async fn list_project_permissions( -01365| &self, -01366| _project_id: &ProjectId, -01367| ) -> Result, StoreError> { -01368| Ok(vec![]) -01369| } -01370| -01371| async fn remove_project_permission( -01372| &self, -01373| _project_id: &ProjectId, -01374| _principal_id: &PrincipalId, -01375| ) -> Result<(), StoreError> { -01376| Ok(()) -01377| } -01378| -01379| async fn set_environment_permission( -01380| &self, -01381| _environment_id: &EnvironmentId, -01382| _principal_id: &PrincipalId, -01383| _role: Role, -01384| ) -> Result<(), StoreError> { -01385| Ok(()) -01386| } -01387| -01388| async fn get_environment_permission( -01389| &self, -01390| _environment_id: &EnvironmentId, -01391| _principal_id: &PrincipalId, -01392| ) -> Result { -01393| Err(StoreError::NotFound) -01394| } -01395| -01396| async fn list_environment_permissions_for_principal( -01397| &self, -01398| _principal_id: &PrincipalId, -01399| ) -> Result, StoreError> { -01400| Ok(vec![]) -01401| } -01402| -01403| async fn list_environment_permissions( -01404| &self, -01405| _environment_id: &EnvironmentId, -01406| ) -> Result, StoreError> { -01407| Ok(vec![]) -01408| } -01409| -01410| async fn remove_environment_permission( -01411| &self, -01412| _environment_id: &EnvironmentId, -01413| _principal_id: &PrincipalId, -01414| ) -> Result<(), StoreError> { -01415| Ok(()) -01416| } -01417| -01418| // User permissions -01419| async fn set_user_workspace_permission( -01420| &self, -01421| _workspace_id: &WorkspaceId, -01422| _user_id: &UserId, -01423| _role: Role, -01424| ) -> Result<(), StoreError> { -01425| Ok(()) -01426| } -01427| -01428| async fn get_user_workspace_permission( -01429| &self, -01430| _workspace_id: &WorkspaceId, -01431| _user_id: &UserId, -01432| ) -> Result { -01433| Err(StoreError::NotFound) -01434| } -01435| -01436| async fn list_user_workspace_permissions( -01437| &self, -01438| _workspace_id: &WorkspaceId, -01439| ) -> Result, StoreError> { -01440| Ok(vec![]) -01441| } -01442| -01443| async fn remove_user_workspace_permission( -01444| &self, -01445| _workspace_id: &WorkspaceId, -01446| _user_id: &UserId, -01447| ) -> Result<(), StoreError> { -01448| Ok(()) -01449| } -01450| -01451| async fn set_user_project_permission( -01452| &self, -01453| _project_id: &ProjectId, -01454| _user_id: &UserId, -01455| _role: Role, -01456| ) -> Result<(), StoreError> { -01457| Ok(()) -01458| } -01459| -01460| async fn get_user_project_permission( -01461| &self, -01462| _project_id: &ProjectId, -01463| _user_id: &UserId, -01464| ) -> Result { -01465| Err(StoreError::NotFound) -01466| } -01467| -01468| async fn list_user_project_permissions( -01469| &self, -01470| _project_id: &ProjectId, -01471| ) -> Result, StoreError> { -01472| Ok(vec![]) -01473| } -01474| -01475| async fn remove_user_project_permission( -01476| &self, -01477| _project_id: &ProjectId, -01478| _user_id: &UserId, -01479| ) -> Result<(), StoreError> { -01480| Ok(()) -01481| } -01482| -01483| async fn set_user_environment_permission( -01484| &self, -01485| _environment_id: &EnvironmentId, -01486| _user_id: &UserId, -01487| _role: Role, -01488| ) -> Result<(), StoreError> { -01489| Ok(()) -01490| } -01491| -01492| async fn get_user_environment_permission( -01493| &self, -01494| _environment_id: &EnvironmentId, -01495| _user_id: &UserId, -01496| ) -> Result { -01497| Err(StoreError::NotFound) -01498| } -01499| -01500| async fn list_user_environment_permissions( -01501| &self, -01502| _environment_id: &EnvironmentId, -01503| ) -> Result, StoreError> { -01504| Ok(vec![]) -01505| } -01506| -01507| async fn remove_user_environment_permission( -01508| &self, -01509| _environment_id: &EnvironmentId, -01510| _user_id: &UserId, -01511| ) -> Result<(), StoreError> { -01512| Ok(()) -01513| } -01514| -01515| async fn create_group(&self, _params: &CreateGroupParams) -> Result { -01516| Ok(GroupId(Uuid::new_v4())) -01517| } -01518| -01519| async fn get_group(&self, _group_id: &GroupId) -> Result { -01520| Err(StoreError::NotFound) -01521| } -01522| -01523| async fn get_group_by_name( -01524| &self, -01525| _workspace_id: &WorkspaceId, -01526| _name: &str, -01527| ) -> Result { -01528| Err(StoreError::NotFound) -01529| } -01530| -01531| async fn list_groups(&self, _workspace_id: &WorkspaceId) -> Result, StoreError> { -01532| Ok(vec![]) -01533| } -01534| -01535| async fn update_group( -01536| &self, -01537| _group_id: &GroupId, -01538| _name: &str, -01539| _description: Option, -01540| ) -> Result<(), StoreError> { -01541| Ok(()) -01542| } -01543| -01544| async fn delete_group(&self, _group_id: &GroupId) -> Result<(), StoreError> { -01545| Ok(()) -01546| } -01547| -01548| async fn add_group_member( -01549| &self, -01550| _group_id: &GroupId, -01551| _user_id: &UserId, -01552| ) -> Result<(), StoreError> { -01553| Ok(()) -01554| } -01555| -01556| async fn remove_group_member( -01557| &self, -01558| _group_id: &GroupId, -01559| _user_id: &UserId, -01560| ) -> Result<(), StoreError> { -01561| Ok(()) -01562| } -01563| -01564| async fn list_group_members( -01565| &self, -01566| _group_id: &GroupId, -01567| ) -> Result, StoreError> { -01568| Ok(vec![]) -01569| } -01570| -01571| async fn list_user_groups(&self, _user_id: &UserId) -> Result, StoreError> { -01572| Ok(vec![]) -01573| } -01574| -01575| async fn set_group_workspace_permission( -01576| &self, -01577| _workspace_id: &WorkspaceId, -01578| _group_id: &GroupId, -01579| _role: Role, -01580| ) -> Result<(), StoreError> { -01581| Ok(()) -01582| } -01583| -01584| async fn get_group_workspace_permission( -01585| &self, -01586| _workspace_id: &WorkspaceId, -01587| _group_id: &GroupId, -01588| ) -> Result { -01589| Err(StoreError::NotFound) -01590| } -01591| -01592| async fn list_group_workspace_permissions( -01593| &self, -01594| _workspace_id: &WorkspaceId, -01595| ) -> Result, StoreError> { -01596| Ok(vec![]) -01597| } -01598| -01599| async fn remove_group_workspace_permission( -01600| &self, -01601| _workspace_id: &WorkspaceId, -01602| _group_id: &GroupId, -01603| ) -> Result<(), StoreError> { -01604| Ok(()) -01605| } -01606| -01607| async fn set_group_project_permission( -01608| &self, -01609| _project_id: &ProjectId, -01610| _group_id: &GroupId, -01611| _role: Role, - -(Output truncated at 51200 bytes. Use 'offset' parameter to read beyond line 1611) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 11.1s) - - -The file is quite long. I see the pattern for the trait. I need to: - -1. Add a new `EmailVerificationId` type (similar to other ID types) -2. Add `EmailVerification` struct -3. Add `CreateEmailVerificationParams` struct -4. Add methods to the `Store` trait for email verification -5. Also need to update the `Principal` struct to include the `verified` field - -Let me also need to update the `NoopStore` test implementation. - -Let me add the email verification structures and methods to the storage trait. - - -Now let me add the email verification types and methods to the storage trait. I'll add the new types after the existing ID types, and add the methods to the Store trait: - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", - "oldString": "#[derive(Clone, Debug, PartialEq, Eq, Hash)]\npub struct PrincipalExportId(pub Uuid);", - "newString": "#[derive(Clone, Debug, PartialEq, Eq, Hash)]\npub struct PrincipalExportId(pub Uuid);\n\n#[derive(Clone, Debug, PartialEq, Eq, Hash)]\npub struct EmailVerificationId(pub Uuid);" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 6.9s) - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", - "oldString": "/// Principal (device or service account) record\n#[derive(Clone, Debug)]\npub struct Principal {\n pub id: PrincipalId,\n pub user_id: Option, // None for service accounts\n pub name: String,\n pub public_key: Vec, // Ed25519 for authentication\n pub x25519_public_key: Option>, // X25519 for encryption (ECDH)\n pub created_at: DateTime,\n pub updated_at: DateTime,\n}", - "newString": "/// Principal (device or service account) record\n#[derive(Clone, Debug)]\npub struct Principal {\n pub id: PrincipalId,\n pub user_id: Option, // None for service accounts\n pub name: String,\n pub public_key: Vec, // Ed25519 for authentication\n pub x25519_public_key: Option>, // X25519 for encryption (ECDH)\n pub verified: bool, // Email verification status\n pub created_at: DateTime,\n pub updated_at: DateTime,\n}" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 11.6s) - -Now let me add the EmailVerification struct and CreateEmailVerificationParams after the PrincipalExport struct: - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", - "oldString": "/// Parameters for creating a principal export\n#[derive(Clone, Debug)]\npub struct CreatePrincipalExportParams {\n pub export_code: String, // Public identifier for lookup (e.g., \"exp_a7k9m2x4\")\n pub token_hash: String, // Argon2id(passphrase, verification_salt) for verification\n pub verification_salt: Vec, // Salt for passphrase verification (separate from encryption)\n pub user_id: UserId,\n pub principal_id: PrincipalId,\n pub encrypted_data: Vec, // Encrypted principal JSON\n pub salt: Vec, // Argon2id salt for encryption key derivation\n pub nonce: Vec, // XChaCha20-Poly1305 nonce\n pub expires_at: DateTime,\n}", - "newString": "/// Parameters for creating a principal export\n#[derive(Clone, Debug)]\npub struct CreatePrincipalExportParams {\n pub export_code: String, // Public identifier for lookup (e.g., \"exp_a7k9m2x4\")\n pub token_hash: String, // Argon2id(passphrase, verification_salt) for verification\n pub verification_salt: Vec, // Salt for passphrase verification (separate from encryption)\n pub user_id: UserId,\n pub principal_id: PrincipalId,\n pub encrypted_data: Vec, // Encrypted principal JSON\n pub salt: Vec, // Argon2id salt for encryption key derivation\n pub nonce: Vec, // XChaCha20-Poly1305 nonce\n pub expires_at: DateTime,\n}\n\n/// Email verification record for verifying email ownership during join\n#[derive(Clone, Debug)]\npub struct EmailVerification {\n pub id: EmailVerificationId,\n pub email: String, // Email being verified (lowercased)\n pub code: String, // 6-digit verification code\n pub attempts: i32, // Failed verification attempts\n pub created_at: DateTime,\n pub expires_at: DateTime,\n}\n\n/// Parameters for creating an email verification\n#[derive(Clone, Debug)]\npub struct CreateEmailVerificationParams {\n pub email: String, // Email being verified (lowercased)\n pub code: String, // 6-digit verification code\n pub expires_at: DateTime, // When the code expires\n}" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 4.1s) - -Now I need to add the email verification methods to the Store trait. Let me find a good place to add them - after the Principal Exports section: - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", - "offset": 495, - "limit": 30 -} -``` -**Output:** -``` - -00496| async fn create_principal_export( -00497| &self, -00498| params: &CreatePrincipalExportParams, -00499| ) -> Result; -00500| -00501| /// Get principal export by export code. -00502| async fn get_principal_export_by_code( -00503| &self, -00504| export_code: &str, -00505| ) -> Result; -00506| -00507| /// Mark a principal export as consumed (can only be used once). -00508| async fn consume_principal_export( -00509| &self, -00510| export_id: &PrincipalExportId, -00511| ) -> Result<(), StoreError>; -00512| -00513| /// Increment failed attempts counter for a principal export. -00514| /// Returns the new failed_attempts count. -00515| async fn increment_export_failed_attempts( -00516| &self, -00517| export_id: &PrincipalExportId, -00518| ) -> Result; -00519| -00520| /// Delete a principal export (used after 3 failed attempts or manual cleanup). -00521| async fn delete_principal_export( -00522| &self, -00523| export_id: &PrincipalExportId, -00524| ) -> Result<(), StoreError>; -00525| - -(File has more lines. Use 'offset' parameter to read beyond line 525) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 9.9s) - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", - "oldString": " /// Delete a principal export (used after 3 failed attempts or manual cleanup).\n async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError>;\n\n // ───────────────────────────────────── Workspaces ─────────────────────────────────────", - "newString": " /// Delete a principal export (used after 3 failed attempts or manual cleanup).\n async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError>;\n\n // ───────────────────────────────────── Email Verification ──────────────────────────────\n\n /// Create an email verification record.\n async fn create_email_verification(\n &self,\n params: &CreateEmailVerificationParams,\n ) -> Result;\n\n /// Get the latest pending email verification for an email address.\n async fn get_email_verification(&self, email: &str) -> Result;\n\n /// Increment the failed attempts counter for an email verification.\n /// Returns the new attempts count.\n async fn increment_email_verification_attempts(\n &self,\n id: &EmailVerificationId,\n ) -> Result;\n\n /// Delete an email verification (used after successful verification or manual cleanup).\n async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError>;\n\n /// Delete all expired email verifications.\n /// Returns the number of deleted records.\n async fn cleanup_expired_email_verifications(&self) -> Result;\n\n /// Count the number of verification codes created for an email in the last hour.\n /// Used for rate limiting (max 3 codes per hour per email).\n async fn count_recent_email_verifications(&self, email: &str) -> Result;\n\n /// Mark a principal as verified.\n async fn mark_principal_verified(&self, principal_id: &PrincipalId) -> Result<(), StoreError>;\n\n // ───────────────────────────────────── Workspaces ─────────────────────────────────────" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 4.2s) - -Now I need to update the NoopStore test implementation to include these new methods. Let me find and update that section: - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", - "offset": 1015, - "limit": 50 -} -``` -**Output:** -``` - -01016| /// Get environment-level permission for a group -01017| async fn get_group_environment_permission( -01018| &self, -01019| environment_id: &EnvironmentId, -01020| group_id: &GroupId, -01021| ) -> Result; -01022| -01023| /// List all environment permissions for a group -01024| async fn list_group_environment_permissions( -01025| &self, -01026| environment_id: &EnvironmentId, -01027| ) -> Result, StoreError>; -01028| -01029| /// Remove environment-level permission for a group -01030| async fn remove_group_environment_permission( -01031| &self, -01032| environment_id: &EnvironmentId, -01033| group_id: &GroupId, -01034| ) -> Result<(), StoreError>; -01035| } -01036| -01037| #[cfg(test)] -01038| mod tests { -01039| use super::*; -01040| -01041| // Tiny compile-time smoke test for trait object usage. -01042| struct NoopStore; -01043| #[async_trait::async_trait] -01044| impl Store for NoopStore { -01045| async fn create_user( -01046| &self, -01047| _params: &CreateUserParams, -01048| ) -> Result<(UserId, Option), StoreError> { -01049| let user_id = UserId(Uuid::new_v4()); -01050| let principal_id = _params -01051| .principal -01052| .as_ref() -01053| .map(|_| PrincipalId(Uuid::new_v4())); -01054| Ok((user_id, principal_id)) -01055| } -01056| -01057| async fn get_user_by_email(&self, _email: &str) -> Result { -01058| Err(StoreError::NotFound) -01059| } -01060| -01061| async fn get_user_by_id(&self, _user_id: &UserId) -> Result { -01062| Err(StoreError::NotFound) -01063| } -01064| -01065| async fn create_principal( - -(File has more lines. Use 'offset' parameter to read beyond line 1065) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 4.1s) - -Let me find where the principal export methods end in the NoopStore implementation: - -``` -Tool: grep - -**Input:** -```json -{ - "pattern": "delete_principal_export", - "path": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs" -} -``` -**Output:** -``` -Found 2 matches -/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs: - Line 521: async fn delete_principal_export( - Line 1159: async fn delete_principal_export( -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 3.7s) - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", - "offset": 1155, - "limit": 40 -} -``` -**Output:** -``` - -01156| Ok(1) -01157| } -01158| -01159| async fn delete_principal_export( -01160| &self, -01161| _export_id: &PrincipalExportId, -01162| ) -> Result<(), StoreError> { -01163| Ok(()) -01164| } -01165| -01166| async fn create_workspace( -01167| &self, -01168| _params: &CreateWorkspaceParams, -01169| ) -> Result { -01170| Ok(WorkspaceId(Uuid::new_v4())) -01171| } -01172| -01173| async fn list_workspaces( -01174| &self, -01175| _principal_id: &PrincipalId, -01176| ) -> Result, StoreError> { -01177| Ok(vec![]) -01178| } -01179| -01180| async fn get_workspace(&self, _ws: &WorkspaceId) -> Result { -01181| Err(StoreError::NotFound) -01182| } -01183| -01184| async fn get_workspace_by_name( -01185| &self, -01186| _user_id: &UserId, -01187| _name: &str, -01188| ) -> Result { -01189| Err(StoreError::NotFound) -01190| } -01191| -01192| async fn get_workspace_by_name_for_principal( -01193| &self, -01194| _principal_id: &PrincipalId, -01195| _name: &str, - -(File has more lines. Use 'offset' parameter to read beyond line 1195) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 7.7s) - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-storage/src/lib.rs", - "oldString": " async fn delete_principal_export(\n &self,\n _export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n Ok(())\n }\n\n async fn create_workspace(", - "newString": " async fn delete_principal_export(\n &self,\n _export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n Ok(())\n }\n\n async fn create_email_verification(\n &self,\n params: &CreateEmailVerificationParams,\n ) -> Result {\n Ok(EmailVerification {\n id: EmailVerificationId(Uuid::new_v4()),\n email: params.email.clone(),\n code: params.code.clone(),\n attempts: 0,\n created_at: Utc::now(),\n expires_at: params.expires_at,\n })\n }\n\n async fn get_email_verification(\n &self,\n _email: &str,\n ) -> Result {\n Err(StoreError::NotFound)\n }\n\n async fn increment_email_verification_attempts(\n &self,\n _id: &EmailVerificationId,\n ) -> Result {\n Ok(1)\n }\n\n async fn delete_email_verification(\n &self,\n _id: &EmailVerificationId,\n ) -> Result<(), StoreError> {\n Ok(())\n }\n\n async fn cleanup_expired_email_verifications(&self) -> Result {\n Ok(0)\n }\n\n async fn count_recent_email_verifications(&self, _email: &str) -> Result {\n Ok(0)\n }\n\n async fn mark_principal_verified(\n &self,\n _principal_id: &PrincipalId,\n ) -> Result<(), StoreError> {\n Ok(())\n }\n\n async fn create_workspace(" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 6545.3s) - -Now let me check if the code compiles to make sure the storage trait changes are correct: - -``` -Tool: bash - -**Input:** -```json -{ - "command": "cd /Users/lucas/code/faisca/zopp && cargo check --package zopp-storage 2>&1 | head -50", - "description": "Check zopp-storage compiles" -} -``` -**Output:** -``` - Checking zopp-storage v0.1.1 (/Users/lucas/code/faisca/zopp/crates/zopp-storage) - Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.92s - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 35.2s) - -``` -Tool: todowrite - -**Input:** -```json -{ - "todos": [ - { - "id": "phase1-1", - "content": "Create SQLite migration 20260121000001_add_email_verification.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-2", - "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-3", - "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-4", - "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-5", - "content": "Add EmailVerification struct and methods to zopp-storage trait", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-6", - "content": "Implement email verification methods in SQLite store", - "status": "in_progress", - "priority": "high" - }, - { - "id": "phase1-7", - "content": "Implement email verification methods in PostgreSQL store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-8", - "content": "Run sqlx prepare for both stores", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-9", - "content": "Unit tests: Storage create_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-10", - "content": "Unit tests: Storage get_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-11", - "content": "Unit tests: Storage increment_verification_attempts", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-12", - "content": "Unit tests: Storage mark_verification_verified", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-13", - "content": "Unit tests: Storage delete_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-14", - "content": "Unit tests: Storage cleanup_expired_verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-15", - "content": "Unit tests: Verification code expiration logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-16", - "content": "Unit tests: Attempt counting and limits", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-1", - "content": "Create apps/zopp-server/src/config.rs with ServerConfig", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-2", - "content": "Add config validation (fail if verification enabled but no provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-3", - "content": "Add environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-4", - "content": "Unit tests: Config environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-5", - "content": "Unit tests: Config validation (missing provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-6", - "content": "Unit tests: Config validation (invalid SMTP config)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-7", - "content": "Unit tests: Config defaults and optional fields", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-1", - "content": "Add Resend and SMTP dependencies to Cargo.toml with features", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-2", - "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-3", - "content": "Create apps/zopp-server/src/email/templates.rs with email templates", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-4", - "content": "Implement Resend provider (email/resend.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-5", - "content": "Implement SMTP provider (email/smtp.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-6", - "content": "Add provider factory function with validation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-7", - "content": "Create code generation utility (cryptographically secure 6-digit)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-8", - "content": "Unit tests: Email templates text generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-9", - "content": "Unit tests: Email templates HTML generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-10", - "content": "Unit tests: Code generation format validation (6 digits)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-11", - "content": "Unit tests: Code generation randomness (no duplicates in sample)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-12", - "content": "Unit tests: Resend provider with mocked API", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-13", - "content": "Unit tests: SMTP provider with mocked transport", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-14", - "content": "Unit tests: Provider factory selection logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-15", - "content": "Unit tests: Provider factory error on invalid config", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-1", - "content": "Update zopp.proto with VerifyEmailRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-2", - "content": "Update zopp.proto with ResendVerificationRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-3", - "content": "Add verification_required field to JoinResponse", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-4", - "content": "Regenerate proto code (cargo build)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-1", - "content": "Modify join handler to support verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-2", - "content": "Implement verify_email handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-3", - "content": "Implement resend_verification handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-4", - "content": "Add background cleanup task for expired verifications", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase5-5", - "content": "Add authentication bypass for verify_email RPC", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-6", - "content": "Unit tests: Rate limiting logic (5 attempts per code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-7", - "content": "Unit tests: Rate limiting (3 codes per hour per email)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-8", - "content": "Unit tests: Verification code validation (constant-time comparison)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-9", - "content": "Unit tests: Background cleanup task removes expired verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-10", - "content": "Unit tests: Join handler with verification enabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-11", - "content": "Unit tests: Join handler with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-12", - "content": "Unit tests: VerifyEmail handler success case", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-13", - "content": "Unit tests: VerifyEmail handler wrong code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-14", - "content": "Unit tests: ResendVerification handler", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-1", - "content": "Modify CLI cmd_join to handle verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-2", - "content": "Add code input prompt and retry logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-3", - "content": "Add resend option during verification", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-1", - "content": "E2E test: Join with verification enabled (happy path)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-2", - "content": "E2E test: Join with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-3", - "content": "E2E test: Failed verification (wrong code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-4", - "content": "E2E test: Expired verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-5", - "content": "E2E test: Too many attempts (rate limiting)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-6", - "content": "E2E test: Resend verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-7", - "content": "E2E test: Workspace invite with verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-8", - "content": "E2E test: Bootstrap invite (should bypass verification)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase8-1", - "content": "Update CLAUDE.md with email configuration", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-2", - "content": "Create docs/docs/guides/email-verification.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-3", - "content": "Update docs/docs/reference/cli/join.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-4", - "content": "Add troubleshooting guide for email delivery issues", - "status": "pending", - "priority": "low" - }, - { - "id": "phase9-1", - "content": "Run full test suite", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-2", - "content": "Run clippy and fmt", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-3", - "content": "Create PR and monitor CI", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-4", - "content": "Address Cubic review comments", - "status": "pending", - "priority": "high" - } - ] -} -``` -**Output:** -``` -[ - { - "id": "phase1-1", - "content": "Create SQLite migration 20260121000001_add_email_verification.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-2", - "content": "Create PostgreSQL migration 20260121000001_add_email_verification.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-3", - "content": "Create SQLite migration 20260121000002_add_principal_verified.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-4", - "content": "Create PostgreSQL migration 20260121000002_add_principal_verified.sql", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-5", - "content": "Add EmailVerification struct and methods to zopp-storage trait", - "status": "completed", - "priority": "high" - }, - { - "id": "phase1-6", - "content": "Implement email verification methods in SQLite store", - "status": "in_progress", - "priority": "high" - }, - { - "id": "phase1-7", - "content": "Implement email verification methods in PostgreSQL store", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-8", - "content": "Run sqlx prepare for both stores", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-9", - "content": "Unit tests: Storage create_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-10", - "content": "Unit tests: Storage get_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-11", - "content": "Unit tests: Storage increment_verification_attempts", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-12", - "content": "Unit tests: Storage mark_verification_verified", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-13", - "content": "Unit tests: Storage delete_email_verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-14", - "content": "Unit tests: Storage cleanup_expired_verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-15", - "content": "Unit tests: Verification code expiration logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase1-16", - "content": "Unit tests: Attempt counting and limits", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-1", - "content": "Create apps/zopp-server/src/config.rs with ServerConfig", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-2", - "content": "Add config validation (fail if verification enabled but no provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-3", - "content": "Add environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-4", - "content": "Unit tests: Config environment variable parsing", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-5", - "content": "Unit tests: Config validation (missing provider)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-6", - "content": "Unit tests: Config validation (invalid SMTP config)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase2-7", - "content": "Unit tests: Config defaults and optional fields", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-1", - "content": "Add Resend and SMTP dependencies to Cargo.toml with features", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-2", - "content": "Create apps/zopp-server/src/email/mod.rs with EmailProvider trait", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-3", - "content": "Create apps/zopp-server/src/email/templates.rs with email templates", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-4", - "content": "Implement Resend provider (email/resend.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-5", - "content": "Implement SMTP provider (email/smtp.rs)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-6", - "content": "Add provider factory function with validation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-7", - "content": "Create code generation utility (cryptographically secure 6-digit)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-8", - "content": "Unit tests: Email templates text generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-9", - "content": "Unit tests: Email templates HTML generation", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-10", - "content": "Unit tests: Code generation format validation (6 digits)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-11", - "content": "Unit tests: Code generation randomness (no duplicates in sample)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-12", - "content": "Unit tests: Resend provider with mocked API", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-13", - "content": "Unit tests: SMTP provider with mocked transport", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-14", - "content": "Unit tests: Provider factory selection logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase3-15", - "content": "Unit tests: Provider factory error on invalid config", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-1", - "content": "Update zopp.proto with VerifyEmailRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-2", - "content": "Update zopp.proto with ResendVerificationRequest/Response", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-3", - "content": "Add verification_required field to JoinResponse", - "status": "pending", - "priority": "high" - }, - { - "id": "phase4-4", - "content": "Regenerate proto code (cargo build)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-1", - "content": "Modify join handler to support verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-2", - "content": "Implement verify_email handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-3", - "content": "Implement resend_verification handler with rate limiting", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-4", - "content": "Add background cleanup task for expired verifications", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase5-5", - "content": "Add authentication bypass for verify_email RPC", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-6", - "content": "Unit tests: Rate limiting logic (5 attempts per code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-7", - "content": "Unit tests: Rate limiting (3 codes per hour per email)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-8", - "content": "Unit tests: Verification code validation (constant-time comparison)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-9", - "content": "Unit tests: Background cleanup task removes expired verifications", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-10", - "content": "Unit tests: Join handler with verification enabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-11", - "content": "Unit tests: Join handler with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-12", - "content": "Unit tests: VerifyEmail handler success case", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-13", - "content": "Unit tests: VerifyEmail handler wrong code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase5-14", - "content": "Unit tests: ResendVerification handler", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-1", - "content": "Modify CLI cmd_join to handle verification flow", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-2", - "content": "Add code input prompt and retry logic", - "status": "pending", - "priority": "high" - }, - { - "id": "phase6-3", - "content": "Add resend option during verification", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase7-1", - "content": "E2E test: Join with verification enabled (happy path)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-2", - "content": "E2E test: Join with verification disabled", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-3", - "content": "E2E test: Failed verification (wrong code)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-4", - "content": "E2E test: Expired verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-5", - "content": "E2E test: Too many attempts (rate limiting)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-6", - "content": "E2E test: Resend verification code", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-7", - "content": "E2E test: Workspace invite with verification", - "status": "pending", - "priority": "high" - }, - { - "id": "phase7-8", - "content": "E2E test: Bootstrap invite (should bypass verification)", - "status": "pending", - "priority": "high" - }, - { - "id": "phase8-1", - "content": "Update CLAUDE.md with email configuration", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-2", - "content": "Create docs/docs/guides/email-verification.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-3", - "content": "Update docs/docs/reference/cli/join.md", - "status": "pending", - "priority": "medium" - }, - { - "id": "phase8-4", - "content": "Add troubleshooting guide for email delivery issues", - "status": "pending", - "priority": "low" - }, - { - "id": "phase9-1", - "content": "Run full test suite", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-2", - "content": "Run clippy and fmt", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-3", - "content": "Create PR and monitor CI", - "status": "pending", - "priority": "high" - }, - { - "id": "phase9-4", - "content": "Address Cubic review comments", - "status": "pending", - "priority": "high" - } -] -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 4.1s) - -Now let me implement the email verification methods in the SQLite store. First, let me read the current SQLite store implementation: - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", - "limit": 100 -} -``` -**Output:** -``` - -00001| use chrono::{DateTime, Utc}; -00002| use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; -00003| use std::str::FromStr; -00004| use uuid::Uuid; -00005| use zopp_audit::{ -00006| AuditAction, AuditEvent, AuditLog, AuditLogError, AuditLogFilter, AuditLogId, AuditResult, -00007| }; -00008| use zopp_storage::{ -00009| AddWorkspacePrincipalParams, CreateEnvParams, CreateInviteParams, CreatePrincipalExportParams, -00010| CreatePrincipalParams, CreateProjectParams, CreateUserParams, CreateWorkspaceParams, EnvName, -00011| Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal, -00012| PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role, -00013| SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission, -00014| UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal, -00015| }; -00016| -00017| static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); -00018| -00019| pub struct SqliteStore { -00020| pool: SqlitePool, -00021| } -00022| -00023| impl SqliteStore { -00024| /// `~/.zopp/store.db` (creates dir with 0700 perms on unix) -00025| pub async fn open_default() -> Result { -00026| let dir = dirs::home_dir() -00027| .ok_or_else(|| StoreError::Backend("no home dir".into()))? -00028| .join(".zopp"); -00029| std::fs::create_dir_all(&dir).map_err(|e| StoreError::Backend(e.to_string()))?; -00030| #[cfg(unix)] -00031| { -00032| use std::os::unix::fs::PermissionsExt; -00033| std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)) -00034| .map_err(|e| StoreError::Backend(e.to_string()))?; -00035| } -00036| let path = dir.join("store.db"); -00037| let url = format!("sqlite://{}", path.to_string_lossy()); -00038| Self::open(&url).await -00039| } -00040| -00041| pub async fn open_in_memory() -> Result { -00042| Self::open("sqlite::memory:").await -00043| } -00044| -00045| pub async fn open(url: &str) -> Result { -00046| let pool = SqlitePoolOptions::new() -00047| .max_connections(1) -00048| .connect(url) -00049| .await -00050| .map_err(|e| StoreError::Backend(e.to_string()))?; -00051| -00052| MIGRATOR -00053| .run(&pool) -00054| .await -00055| .map_err(|e| StoreError::Backend(e.to_string()))?; -00056| -00057| Ok(Self { pool }) -00058| } -00059| } -00060| -00061| #[async_trait::async_trait] -00062| impl Store for SqliteStore { -00063| // ───────────────────────────── Users ───────────────────────────── -00064| -00065| async fn create_user( -00066| &self, -00067| params: &CreateUserParams, -00068| ) -> Result<(UserId, Option), StoreError> { -00069| // Use transaction if we need to create principal or add to workspaces -00070| let needs_tx = params.principal.is_some() || !params.workspace_ids.is_empty(); -00071| -00072| if needs_tx { -00073| let mut tx = self -00074| .pool -00075| .begin() -00076| .await -00077| .map_err(|e| StoreError::Backend(e.to_string()))?; -00078| -00079| // First try to get existing user -00080| let existing_user = sqlx::query!("SELECT id FROM users WHERE email = ?", params.email) -00081| .fetch_optional(&mut *tx) -00082| .await -00083| .map_err(|e| StoreError::Backend(e.to_string()))?; -00084| -00085| let actual_user_id_str = if let Some(existing) = existing_user { -00086| // User already exists, use their ID -00087| existing.id -00088| } else { -00089| // Create new user -00090| let user_id = Uuid::now_v7(); -00091| let user_id_str = user_id.to_string(); -00092| sqlx::query!( -00093| "INSERT INTO users(id, email) VALUES(?, ?)", -00094| user_id_str, -00095| params.email -00096| ) -00097| .execute(&mut *tx) -00098| .await -00099| .map_err(|e| StoreError::Backend(e.to_string()))?; -00100| user_id_str - -(File has more lines. Use 'offset' parameter to read beyond line 100) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 4.2s) - -Let me look at the imports and find a similar method pattern to follow. Let me also check the Principal-related code to see how it handles the existing fields: - -``` -Tool: grep - -**Input:** -```json -{ - "pattern": "get_principal", - "path": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs" -} -``` -**Output:** -``` -Found 4 matches -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs: - Line 266: async fn get_principal(&self, principal_id: &PrincipalId) -> Result { - Line 608: async fn get_principal_export_by_code( - Line 3605: let initial = s.get_principal(&principal_id).await.unwrap(); - Line 3613: let after_update = s.get_principal(&principal_id).await.unwrap(); -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 3.4s) - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", - "offset": 265, - "limit": 60 -} -``` -**Output:** -``` - -00266| async fn get_principal(&self, principal_id: &PrincipalId) -> Result { -00267| let principal_id_str = principal_id.0.to_string(); -00268| let row = sqlx::query!( -00269| r#"SELECT id, user_id, name, public_key, x25519_public_key, -00270| created_at as "created_at: DateTime", -00271| updated_at as "updated_at: DateTime" -00272| FROM principals WHERE id = ?"#, -00273| principal_id_str -00274| ) -00275| .fetch_optional(&self.pool) -00276| .await -00277| .map_err(|e| StoreError::Backend(e.to_string()))?; -00278| -00279| match row { -00280| None => Err(StoreError::NotFound), -00281| Some(row) => { -00282| let id = -00283| Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?; -00284| let user_id = row -00285| .user_id -00286| .as_ref() -00287| .map(|id| Uuid::try_parse(id).map(UserId)) -00288| .transpose() -00289| .map_err(|e| StoreError::Backend(e.to_string()))?; -00290| Ok(Principal { -00291| id: PrincipalId(id), -00292| user_id, -00293| name: row.name, -00294| public_key: row.public_key, -00295| x25519_public_key: row.x25519_public_key, -00296| created_at: row.created_at, -00297| updated_at: row.updated_at, -00298| }) -00299| } -00300| } -00301| } -00302| -00303| async fn rename_principal( -00304| &self, -00305| principal_id: &PrincipalId, -00306| new_name: &str, -00307| ) -> Result<(), StoreError> { -00308| let principal_id_str = principal_id.0.to_string(); -00309| let result = sqlx::query!( -00310| "UPDATE principals SET name = ? WHERE id = ?", -00311| new_name, -00312| principal_id_str -00313| ) -00314| .execute(&self.pool) -00315| .await -00316| .map_err(|e| StoreError::Backend(e.to_string()))?; -00317| -00318| if result.rows_affected() == 0 { -00319| Err(StoreError::NotFound) -00320| } else { -00321| Ok(()) -00322| } -00323| } -00324| -00325| async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> { - -(File has more lines. Use 'offset' parameter to read beyond line 325) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 8.7s) - -I see that the `get_principal` method is missing the `verified` field. I need to: -1. Update the queries that select principals to include the `verified` field -2. Add the email verification methods -3. Update the imports to include the new types - -Let me first update the imports: - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", - "oldString": "use zopp_storage::{\n AddWorkspacePrincipalParams, CreateEnvParams, CreateInviteParams, CreatePrincipalExportParams,\n CreatePrincipalParams, CreateProjectParams, CreateUserParams, CreateWorkspaceParams, EnvName,\n Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal,\n PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role,\n SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission,\n UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal,\n};", - "newString": "use zopp_storage::{\n AddWorkspacePrincipalParams, CreateEmailVerificationParams, CreateEnvParams,\n CreateInviteParams, CreatePrincipalExportParams, CreatePrincipalParams, CreateProjectParams,\n CreateUserParams, CreateWorkspaceParams, EmailVerification, EmailVerificationId, EnvName,\n Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal,\n PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role,\n SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission,\n UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal,\n};" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 9.6s) - -Now let me update the `get_principal` method to include the `verified` field: - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", - "oldString": " async fn get_principal(&self, principal_id: &PrincipalId) -> Result {\n let principal_id_str = principal_id.0.to_string();\n let row = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM principals WHERE id = ?\"#,\n principal_id_str\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n match row {\n None => Err(StoreError::NotFound),\n Some(row) => {\n let id =\n Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?;\n let user_id = row\n .user_id\n .as_ref()\n .map(|id| Uuid::try_parse(id).map(UserId))\n .transpose()\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n Ok(Principal {\n id: PrincipalId(id),\n user_id,\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n }\n }\n }", - "newString": " async fn get_principal(&self, principal_id: &PrincipalId) -> Result {\n let principal_id_str = principal_id.0.to_string();\n let row = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, verified,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM principals WHERE id = ?\"#,\n principal_id_str\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n match row {\n None => Err(StoreError::NotFound),\n Some(row) => {\n let id =\n Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?;\n let user_id = row\n .user_id\n .as_ref()\n .map(|id| Uuid::try_parse(id).map(UserId))\n .transpose()\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n Ok(Principal {\n id: PrincipalId(id),\n user_id,\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n verified: row.verified != 0,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n }\n }\n }" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 3.4s) - -Now let me find and update the `list_principals` method: - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", - "offset": 325, - "limit": 50 -} -``` -**Output:** -``` - -00326| -00327| async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> { -00328| let user_id_str = user_id.0.to_string(); -00329| let rows = sqlx::query!( -00330| r#"SELECT id, user_id, name, public_key, x25519_public_key, -00331| created_at as "created_at: DateTime", -00332| updated_at as "updated_at: DateTime" -00333| FROM principals WHERE user_id = ?"#, -00334| user_id_str -00335| ) -00336| .fetch_all(&self.pool) -00337| .await -00338| .map_err(|e| StoreError::Backend(e.to_string()))?; -00339| -00340| let mut principals = Vec::with_capacity(rows.len()); -00341| for row in rows { -00342| let id = Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?; -00343| let user_id = row -00344| .user_id -00345| .as_ref() -00346| .map(|id| Uuid::try_parse(id).map(UserId)) -00347| .transpose() -00348| .map_err(|e| StoreError::Backend(e.to_string()))?; -00349| principals.push(Principal { -00350| id: PrincipalId(id), -00351| user_id, -00352| name: row.name, -00353| public_key: row.public_key, -00354| x25519_public_key: row.x25519_public_key, -00355| created_at: row.created_at, -00356| updated_at: row.updated_at, -00357| }); -00358| } -00359| Ok(principals) -00360| } -00361| -00362| // ───────────────────────────── Invites ───────────────────────────── -00363| -00364| async fn create_invite(&self, params: &CreateInviteParams) -> Result { -00365| let invite_id = Uuid::now_v7(); -00366| let invite_id_str = invite_id.to_string(); -00367| -00368| let created_by_user_id_str = params -00369| .created_by_user_id -00370| .as_ref() -00371| .map(|id| id.0.to_string()); -00372| -00373| sqlx::query!( -00374| "INSERT INTO invites(id, token, expires_at, created_by_user_id, kek_encrypted, kek_nonce) VALUES(?, ?, ?, ?, ?, ?)", -00375| invite_id_str, - -(File has more lines. Use 'offset' parameter to read beyond line 375) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 8.9s) - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", - "oldString": " async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> {\n let user_id_str = user_id.0.to_string();\n let rows = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM principals WHERE user_id = ?\"#,\n user_id_str\n )\n .fetch_all(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n let mut principals = Vec::with_capacity(rows.len());\n for row in rows {\n let id = Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?;\n let user_id = row\n .user_id\n .as_ref()\n .map(|id| Uuid::try_parse(id).map(UserId))\n .transpose()\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n principals.push(Principal {\n id: PrincipalId(id),\n user_id,\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n created_at: row.created_at,\n updated_at: row.updated_at,\n });\n }\n Ok(principals)\n }", - "newString": " async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> {\n let user_id_str = user_id.0.to_string();\n let rows = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, verified,\n created_at as \"created_at: DateTime\",\n updated_at as \"updated_at: DateTime\"\n FROM principals WHERE user_id = ?\"#,\n user_id_str\n )\n .fetch_all(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n let mut principals = Vec::with_capacity(rows.len());\n for row in rows {\n let id = Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?;\n let user_id = row\n .user_id\n .as_ref()\n .map(|id| Uuid::try_parse(id).map(UserId))\n .transpose()\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n principals.push(Principal {\n id: PrincipalId(id),\n user_id,\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n verified: row.verified != 0,\n created_at: row.created_at,\n updated_at: row.updated_at,\n });\n }\n Ok(principals)\n }" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 3.9s) - -Now let me find where to add the email verification methods. Let me find the end of the principal exports section and check if there are any other method groups: - -``` -Tool: grep - -**Input:** -```json -{ - "pattern": "delete_principal_export", - "path": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs" -} -``` -**Output:** -``` -Found 1 matches -/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs: - Line 700: async fn delete_principal_export( -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 3.3s) - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", - "offset": 695, - "limit": 50 -} -``` -**Output:** -``` - -00696| Some(row) => Ok(row.failed_attempts as i32), -00697| } -00698| } -00699| -00700| async fn delete_principal_export( -00701| &self, -00702| export_id: &PrincipalExportId, -00703| ) -> Result<(), StoreError> { -00704| let export_id_str = export_id.0.to_string(); -00705| let result = sqlx::query!("DELETE FROM principal_exports WHERE id = ?", export_id_str) -00706| .execute(&self.pool) -00707| .await -00708| .map_err(|e| StoreError::Backend(e.to_string()))?; -00709| -00710| if result.rows_affected() == 0 { -00711| Err(StoreError::NotFound) -00712| } else { -00713| Ok(()) -00714| } -00715| } -00716| -00717| // ───────────────────────────── Workspaces ───────────────────────────── -00718| -00719| async fn create_workspace( -00720| &self, -00721| params: &CreateWorkspaceParams, -00722| ) -> Result { -00723| let ws_id_str = params.id.0.to_string(); -00724| let owner_user_id_str = params.owner_user_id.0.to_string(); -00725| let m_cost = params.m_cost_kib as i64; -00726| let t_cost = params.t_cost as i64; -00727| let p_cost = params.p_cost as i64; -00728| -00729| sqlx::query!( -00730| "INSERT INTO workspaces(id, name, owner_user_id, kdf_salt, kdf_m_cost_kib, kdf_t_cost, kdf_p_cost) -00731| VALUES(?, ?, ?, ?, ?, ?, ?)", -00732| ws_id_str, -00733| params.name, -00734| owner_user_id_str, -00735| params.kdf_salt, -00736| m_cost, -00737| t_cost, -00738| p_cost -00739| ) -00740| .execute(&self.pool) -00741| .await -00742| .map_err(|e| StoreError::Backend(e.to_string()))?; -00743| Ok(params.id.clone()) -00744| } -00745| - -(File has more lines. Use 'offset' parameter to read beyond line 745) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 18.1s) - -Now I'll add the email verification methods after the delete_principal_export method: - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite/src/lib.rs", - "oldString": " async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n let export_id_str = export_id.0.to_string();\n let result = sqlx::query!(\"DELETE FROM principal_exports WHERE id = ?\", export_id_str)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Workspaces ─────────────────────────────", - "newString": " async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n let export_id_str = export_id.0.to_string();\n let result = sqlx::query!(\"DELETE FROM principal_exports WHERE id = ?\", export_id_str)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Email Verification ─────────────────────────────\n\n async fn create_email_verification(\n &self,\n params: &CreateEmailVerificationParams,\n ) -> Result {\n let id = Uuid::now_v7();\n let id_str = id.to_string();\n let email = params.email.to_lowercase();\n\n sqlx::query!(\n \"INSERT INTO email_verifications(id, email, code, expires_at) VALUES(?, ?, ?, ?)\",\n id_str,\n email,\n params.code,\n params.expires_at\n )\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(EmailVerification {\n id: EmailVerificationId(id),\n email,\n code: params.code.clone(),\n attempts: 0,\n created_at: Utc::now(),\n expires_at: params.expires_at,\n })\n }\n\n async fn get_email_verification(&self, email: &str) -> Result {\n let email_lower = email.to_lowercase();\n let row = sqlx::query!(\n r#\"SELECT id, email, code, attempts, \n created_at as \"created_at: DateTime\",\n expires_at as \"expires_at: DateTime\"\n FROM email_verifications \n WHERE email = ? \n ORDER BY created_at DESC \n LIMIT 1\"#,\n email_lower\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n match row {\n None => Err(StoreError::NotFound),\n Some(row) => {\n let id =\n Uuid::try_parse(&row.id).map_err(|e| StoreError::Backend(e.to_string()))?;\n Ok(EmailVerification {\n id: EmailVerificationId(id),\n email: row.email,\n code: row.code,\n attempts: row.attempts as i32,\n created_at: row.created_at,\n expires_at: row.expires_at,\n })\n }\n }\n }\n\n async fn increment_email_verification_attempts(\n &self,\n id: &EmailVerificationId,\n ) -> Result {\n let id_str = id.0.to_string();\n sqlx::query!(\n \"UPDATE email_verifications SET attempts = attempts + 1 WHERE id = ?\",\n id_str\n )\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n // Fetch the updated count\n let row = sqlx::query!(\"SELECT attempts FROM email_verifications WHERE id = ?\", id_str)\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n match row {\n None => Err(StoreError::NotFound),\n Some(row) => Ok(row.attempts as i32),\n }\n }\n\n async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError> {\n let id_str = id.0.to_string();\n let result = sqlx::query!(\"DELETE FROM email_verifications WHERE id = ?\", id_str)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n async fn cleanup_expired_email_verifications(&self) -> Result {\n let now = Utc::now();\n let result = sqlx::query!(\"DELETE FROM email_verifications WHERE expires_at < ?\", now)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(result.rows_affected())\n }\n\n async fn count_recent_email_verifications(&self, email: &str) -> Result {\n let email_lower = email.to_lowercase();\n let one_hour_ago = Utc::now() - chrono::Duration::hours(1);\n let row = sqlx::query!(\n \"SELECT COUNT(*) as count FROM email_verifications WHERE email = ? AND created_at > ?\",\n email_lower,\n one_hour_ago\n )\n .fetch_one(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(row.count as u64)\n }\n\n async fn mark_principal_verified(&self, principal_id: &PrincipalId) -> Result<(), StoreError> {\n let principal_id_str = principal_id.0.to_string();\n let result = sqlx::query!(\n \"UPDATE principals SET verified = 1 WHERE id = ?\",\n principal_id_str\n )\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Workspaces ─────────────────────────────" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 8.3s) - -Now let me check if the code compiles: - -``` -Tool: bash - -**Input:** -```json -{ - "command": "cd /Users/lucas/code/faisca/zopp && cargo check --package zopp-store-sqlite 2>&1 | head -80", - "description": "Check SQLite store compiles" -} -``` -**Output:** -``` - Checking zopp-storage v0.1.1 (/Users/lucas/code/faisca/zopp/crates/zopp-storage) - Checking zopp-audit v0.1.1 (/Users/lucas/code/faisca/zopp/crates/zopp-audit) - Checking zopp-store-sqlite v0.1.1 (/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite) -error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` - --> crates/zopp-store-sqlite/src/lib.rs:269:19 - | -269 | let row = sqlx::query!( - | ___________________^ -270 | | r#"SELECT id, user_id, name, public_key, x25519_public_key, verified, -271 | | created_at as "created_at: DateTime", -272 | | updated_at as "updated_at: DateTime" -273 | | FROM principals WHERE id = ?"#, -274 | | principal_id_str -275 | | ) - | |_________^ - | - = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) - -error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` - --> crates/zopp-store-sqlite/src/lib.rs:329:20 - | -329 | let rows = sqlx::query!( - | ____________________^ -330 | | r#"SELECT id, user_id, name, public_key, x25519_public_key, verified, -331 | | created_at as "created_at: DateTime", -332 | | updated_at as "updated_at: DateTime" -333 | | FROM principals WHERE user_id = ?"#, -334 | | user_id_str -335 | | ) - | |_________^ - | - = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) - -error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` - --> crates/zopp-store-sqlite/src/lib.rs:727:9 - | -727 | / sqlx::query!( -728 | | "INSERT INTO email_verifications(id, email, code, expires_at) VALUES(?, ?, ?, ?)", -729 | | id_str, -730 | | email, -731 | | params.code, -732 | | params.expires_at -733 | | ) - | |_________^ - | - = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) - -error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` - --> crates/zopp-store-sqlite/src/lib.rs:750:19 - | -750 | let row = sqlx::query!( - | ___________________^ -751 | | r#"SELECT id, email, code, attempts, -752 | | created_at as "created_at: DateTime", -753 | | expires_at as "expires_at: DateTime" -... | -758 | | email_lower -759 | | ) - | |_________^ - | - = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) - -error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` - --> crates/zopp-store-sqlite/src/lib.rs:786:9 - | -786 | / sqlx::query!( -787 | | "UPDATE email_verifications SET attempts = attempts + 1 WHERE id = ?", -788 | | id_str -789 | | ) - | |_________^ - | - = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) - -error: `SQLX_OFFLINE=true` but there is no cached data for this query, run `cargo sqlx prepare` to update the query cache or unset `SQLX_OFFLINE` - --> crates/zopp-store-sqlite/src/lib.rs:795:19 - | -795 | let row = sqlx::query!("SELECT attempts FROM email_verifications WHERE id = ?", id_str) - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - | - = note: this error originates in the macro `$crate::sqlx_macros::expand_query` which comes from the expansion of the macro `sqlx::query` (in Nightly builds, run with -Z macro-backtrace for more info) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 4.1s) - -This is expected! The code is correct, but we need to run `sqlx prepare` to update the query cache. First, let me implement the PostgreSQL store as well, then run sqlx prepare for both. - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", - "limit": 50 -} -``` -**Output:** -``` - -00001| use chrono::{DateTime, Utc}; -00002| use sqlx::{postgres::PgPoolOptions, PgPool}; -00003| use std::str::FromStr; -00004| use uuid::Uuid; -00005| use zopp_audit::{ -00006| AuditAction, AuditEvent, AuditLog, AuditLogError, AuditLogFilter, AuditLogId, AuditResult, -00007| }; -00008| use zopp_storage::{ -00009| AddWorkspacePrincipalParams, CreateEnvParams, CreateInviteParams, CreatePrincipalExportParams, -00010| CreatePrincipalParams, CreateProjectParams, CreateUserParams, CreateWorkspaceParams, EnvName, -00011| Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal, -00012| PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role, -00013| SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission, -00014| UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal, -00015| }; -00016| -00017| static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); -00018| -00019| pub struct PostgresStore { -00020| pool: PgPool, -00021| } -00022| -00023| impl PostgresStore { -00024| pub async fn open(url: &str) -> Result { -00025| let pool = PgPoolOptions::new() -00026| .max_connections(10) -00027| .connect(url) -00028| .await -00029| .map_err(|e| StoreError::Backend(e.to_string()))?; -00030| -00031| MIGRATOR -00032| .run(&pool) -00033| .await -00034| .map_err(|e| StoreError::Backend(e.to_string()))?; -00035| -00036| Ok(Self { pool }) -00037| } -00038| } -00039| -00040| #[async_trait::async_trait] -00041| impl Store for PostgresStore { -00042| // ───────────────────────────── Users ───────────────────────────── -00043| -00044| async fn create_user( -00045| &self, -00046| params: &CreateUserParams, -00047| ) -> Result<(UserId, Option), StoreError> { -00048| let needs_tx = params.principal.is_some() || !params.workspace_ids.is_empty(); -00049| -00050| if needs_tx { - -(File has more lines. Use 'offset' parameter to read beyond line 50) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 6.8s) - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", - "oldString": "use zopp_storage::{\n AddWorkspacePrincipalParams, CreateEnvParams, CreateInviteParams, CreatePrincipalExportParams,\n CreatePrincipalParams, CreateProjectParams, CreateUserParams, CreateWorkspaceParams, EnvName,\n Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal,\n PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role,\n SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission,\n UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal,\n};", - "newString": "use zopp_storage::{\n AddWorkspacePrincipalParams, CreateEmailVerificationParams, CreateEnvParams,\n CreateInviteParams, CreatePrincipalExportParams, CreatePrincipalParams, CreateProjectParams,\n CreateUserParams, CreateWorkspaceParams, EmailVerification, EmailVerificationId, EnvName,\n Environment, EnvironmentId, EnvironmentPermission, Invite, InviteId, Principal,\n PrincipalExport, PrincipalExportId, PrincipalId, ProjectName, ProjectPermission, Role,\n SecretRow, Store, StoreError, User, UserEnvironmentPermission, UserId, UserProjectPermission,\n UserWorkspacePermission, Workspace, WorkspaceId, WorkspacePermission, WorkspacePrincipal,\n};" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 4.4s) - -Now let me find and update the `get_principal` method in PostgreSQL store: - -``` -Tool: grep - -**Input:** -```json -{ - "pattern": "get_principal", - "path": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs" -} -``` -**Output:** -``` -Found 2 matches -/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs: - Line 210: async fn get_principal(&self, principal_id: &PrincipalId) -> Result { - Line 469: async fn get_principal_export_by_code( -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 3.3s) - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", - "offset": 208, - "limit": 75 -} -``` -**Output:** -``` - -00209| -00210| async fn get_principal(&self, principal_id: &PrincipalId) -> Result { -00211| let row = sqlx::query!( -00212| r#"SELECT id, user_id, name, public_key, x25519_public_key, created_at, updated_at -00213| FROM principals WHERE id = $1"#, -00214| principal_id.0 -00215| ) -00216| .fetch_optional(&self.pool) -00217| .await -00218| .map_err(|e| StoreError::Backend(e.to_string()))? -00219| .ok_or(StoreError::NotFound)?; -00220| -00221| Ok(Principal { -00222| id: PrincipalId(row.id), -00223| user_id: row.user_id.map(UserId), -00224| name: row.name, -00225| public_key: row.public_key, -00226| x25519_public_key: row.x25519_public_key, -00227| created_at: row.created_at, -00228| updated_at: row.updated_at, -00229| }) -00230| } -00231| -00232| async fn rename_principal( -00233| &self, -00234| principal_id: &PrincipalId, -00235| new_name: &str, -00236| ) -> Result<(), StoreError> { -00237| let result = sqlx::query!( -00238| "UPDATE principals SET name = $1 WHERE id = $2", -00239| new_name, -00240| principal_id.0 -00241| ) -00242| .execute(&self.pool) -00243| .await -00244| .map_err(|e| StoreError::Backend(e.to_string()))?; -00245| -00246| if result.rows_affected() == 0 { -00247| Err(StoreError::NotFound) -00248| } else { -00249| Ok(()) -00250| } -00251| } -00252| -00253| async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> { -00254| let rows = sqlx::query!( -00255| r#"SELECT id, user_id, name, public_key, x25519_public_key, created_at, updated_at -00256| FROM principals WHERE user_id = $1"#, -00257| user_id.0 -00258| ) -00259| .fetch_all(&self.pool) -00260| .await -00261| .map_err(|e| StoreError::Backend(e.to_string()))?; -00262| -00263| Ok(rows -00264| .into_iter() -00265| .map(|row| Principal { -00266| id: PrincipalId(row.id), -00267| user_id: row.user_id.map(UserId), -00268| name: row.name, -00269| public_key: row.public_key, -00270| x25519_public_key: row.x25519_public_key, -00271| created_at: row.created_at, -00272| updated_at: row.updated_at, -00273| }) -00274| .collect()) -00275| } -00276| -00277| // ───────────────────────────── Invites ───────────────────────────── -00278| -00279| async fn create_invite(&self, params: &CreateInviteParams) -> Result { -00280| let invite_id = Uuid::now_v7(); -00281| let created_by_user_id = params.created_by_user_id.as_ref().map(|id| id.0); -00282| -00283| let row = sqlx::query!( - -(File has more lines. Use 'offset' parameter to read beyond line 283) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 7.1s) - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", - "oldString": " async fn get_principal(&self, principal_id: &PrincipalId) -> Result {\n let row = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, created_at, updated_at\n FROM principals WHERE id = $1\"#,\n principal_id.0\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?\n .ok_or(StoreError::NotFound)?;\n\n Ok(Principal {\n id: PrincipalId(row.id),\n user_id: row.user_id.map(UserId),\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n }", - "newString": " async fn get_principal(&self, principal_id: &PrincipalId) -> Result {\n let row = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, verified, created_at, updated_at\n FROM principals WHERE id = $1\"#,\n principal_id.0\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?\n .ok_or(StoreError::NotFound)?;\n\n Ok(Principal {\n id: PrincipalId(row.id),\n user_id: row.user_id.map(UserId),\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n verified: row.verified,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n }" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 7.3s) - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", - "oldString": " async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> {\n let rows = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, created_at, updated_at\n FROM principals WHERE user_id = $1\"#,\n user_id.0\n )\n .fetch_all(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(rows\n .into_iter()\n .map(|row| Principal {\n id: PrincipalId(row.id),\n user_id: row.user_id.map(UserId),\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n .collect())\n }", - "newString": " async fn list_principals(&self, user_id: &UserId) -> Result, StoreError> {\n let rows = sqlx::query!(\n r#\"SELECT id, user_id, name, public_key, x25519_public_key, verified, created_at, updated_at\n FROM principals WHERE user_id = $1\"#,\n user_id.0\n )\n .fetch_all(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(rows\n .into_iter()\n .map(|row| Principal {\n id: PrincipalId(row.id),\n user_id: row.user_id.map(UserId),\n name: row.name,\n public_key: row.public_key,\n x25519_public_key: row.x25519_public_key,\n verified: row.verified,\n created_at: row.created_at,\n updated_at: row.updated_at,\n })\n .collect())\n }" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 3.8s) - -Now let me find where to add the email verification methods in PostgreSQL store: - -``` -Tool: grep - -**Input:** -```json -{ - "pattern": "delete_principal_export", - "path": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs" -} -``` -**Output:** -``` -Found 1 matches -/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs: - Line 545: async fn delete_principal_export( -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 4.2s) - -``` -Tool: read - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", - "offset": 540, - "limit": 45 -} -``` -**Output:** -``` - -00541| Some(row) => Ok(row.failed_attempts), -00542| } -00543| } -00544| -00545| async fn delete_principal_export( -00546| &self, -00547| export_id: &PrincipalExportId, -00548| ) -> Result<(), StoreError> { -00549| let result = sqlx::query!("DELETE FROM principal_exports WHERE id = $1", export_id.0) -00550| .execute(&self.pool) -00551| .await -00552| .map_err(|e| StoreError::Backend(e.to_string()))?; -00553| -00554| if result.rows_affected() == 0 { -00555| Err(StoreError::NotFound) -00556| } else { -00557| Ok(()) -00558| } -00559| } -00560| -00561| // ───────────────────────────── Workspaces ───────────────────────────── -00562| -00563| async fn create_workspace( -00564| &self, -00565| params: &CreateWorkspaceParams, -00566| ) -> Result { -00567| let m_cost = params.m_cost_kib as i32; -00568| let t_cost = params.t_cost as i32; -00569| let p_cost = params.p_cost as i32; -00570| -00571| sqlx::query!( -00572| "INSERT INTO workspaces(id, name, owner_user_id, kdf_salt, kdf_m_cost_kib, kdf_t_cost, kdf_p_cost) -00573| VALUES($1, $2, $3, $4, $5, $6, $7)", -00574| params.id.0, -00575| params.name, -00576| params.owner_user_id.0, -00577| params.kdf_salt, -00578| m_cost, -00579| t_cost, -00580| p_cost -00581| ) -00582| .execute(&self.pool) -00583| .await -00584| .map_err(|e| StoreError::Backend(e.to_string()))?; -00585| Ok(params.id.clone()) - -(File has more lines. Use 'offset' parameter to read beyond line 585) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 16.9s) - -``` -Tool: edit - -**Input:** -```json -{ - "filePath": "/Users/lucas/code/faisca/zopp/crates/zopp-store-postgres/src/lib.rs", - "oldString": " async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n let result = sqlx::query!(\"DELETE FROM principal_exports WHERE id = $1\", export_id.0)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Workspaces ─────────────────────────────", - "newString": " async fn delete_principal_export(\n &self,\n export_id: &PrincipalExportId,\n ) -> Result<(), StoreError> {\n let result = sqlx::query!(\"DELETE FROM principal_exports WHERE id = $1\", export_id.0)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Email Verification ─────────────────────────────\n\n async fn create_email_verification(\n &self,\n params: &CreateEmailVerificationParams,\n ) -> Result {\n let id = Uuid::now_v7();\n let email = params.email.to_lowercase();\n\n let row = sqlx::query!(\n r#\"INSERT INTO email_verifications(id, email, code, expires_at) \n VALUES($1, $2, $3, $4) \n RETURNING id, email, code, attempts, created_at, expires_at\"#,\n id,\n email,\n params.code,\n params.expires_at\n )\n .fetch_one(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(EmailVerification {\n id: EmailVerificationId(row.id),\n email: row.email,\n code: row.code,\n attempts: row.attempts,\n created_at: row.created_at,\n expires_at: row.expires_at,\n })\n }\n\n async fn get_email_verification(&self, email: &str) -> Result {\n let email_lower = email.to_lowercase();\n let row = sqlx::query!(\n r#\"SELECT id, email, code, attempts, created_at, expires_at\n FROM email_verifications \n WHERE email = $1 \n ORDER BY created_at DESC \n LIMIT 1\"#,\n email_lower\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?\n .ok_or(StoreError::NotFound)?;\n\n Ok(EmailVerification {\n id: EmailVerificationId(row.id),\n email: row.email,\n code: row.code,\n attempts: row.attempts,\n created_at: row.created_at,\n expires_at: row.expires_at,\n })\n }\n\n async fn increment_email_verification_attempts(\n &self,\n id: &EmailVerificationId,\n ) -> Result {\n let row = sqlx::query!(\n r#\"UPDATE email_verifications \n SET attempts = attempts + 1 \n WHERE id = $1 \n RETURNING attempts\"#,\n id.0\n )\n .fetch_optional(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?\n .ok_or(StoreError::NotFound)?;\n\n Ok(row.attempts)\n }\n\n async fn delete_email_verification(&self, id: &EmailVerificationId) -> Result<(), StoreError> {\n let result = sqlx::query!(\"DELETE FROM email_verifications WHERE id = $1\", id.0)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n async fn cleanup_expired_email_verifications(&self) -> Result {\n let now = Utc::now();\n let result = sqlx::query!(\"DELETE FROM email_verifications WHERE expires_at < $1\", now)\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(result.rows_affected())\n }\n\n async fn count_recent_email_verifications(&self, email: &str) -> Result {\n let email_lower = email.to_lowercase();\n let one_hour_ago = Utc::now() - chrono::Duration::hours(1);\n let row = sqlx::query!(\n r#\"SELECT COUNT(*) as \"count!\" \n FROM email_verifications \n WHERE email = $1 AND created_at > $2\"#,\n email_lower,\n one_hour_ago\n )\n .fetch_one(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n Ok(row.count as u64)\n }\n\n async fn mark_principal_verified(&self, principal_id: &PrincipalId) -> Result<(), StoreError> {\n let result = sqlx::query!(\n \"UPDATE principals SET verified = TRUE WHERE id = $1\",\n principal_id.0\n )\n .execute(&self.pool)\n .await\n .map_err(|e| StoreError::Backend(e.to_string()))?;\n\n if result.rows_affected() == 0 {\n Err(StoreError::NotFound)\n } else {\n Ok(())\n }\n }\n\n // ───────────────────────────── Workspaces ─────────────────────────────" -} -``` -**Output:** -``` -Edit applied successfully. -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 972.6s) - -Now let me run sqlx prepare for both stores. First, let me create a temporary SQLite database and run migrations: - -``` -Tool: bash - -**Input:** -```json -{ - "command": "cd /Users/lucas/code/faisca/zopp && rm -f /tmp/zopp-prepare.db && export SQLX_OFFLINE=false && DATABASE_URL=sqlite:///tmp/zopp-prepare.db sqlx migrate run --source crates/zopp-store-sqlite/migrations 2>&1", - "description": "Run SQLite migrations for prepare" -} -``` -**Output:** -``` -error: error returned from database: (code: 14) unable to open database file - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 5.6s) - -``` -Tool: bash - -**Input:** -```json -{ - "command": "cd /Users/lucas/code/faisca/zopp && rm -f /tmp/zopp-prepare.db && export SQLX_OFFLINE=false && DATABASE_URL=\"sqlite:///tmp/zopp-prepare.db?mode=rwc\" sqlx migrate run --source crates/zopp-store-sqlite/migrations 2>&1", - "description": "Run SQLite migrations for prepare" -} -``` -**Output:** -``` -Applied 20251026125600/migrate init (1.324125ms) -Applied 20251227000001/migrate add environment version (788.541µs) -Applied 20260105000001/migrate add rbac (1.054375ms) -Applied 20260105000002/migrate add groups (1.188292ms) -Applied 20260105000003/migrate add user permissions (674.625µs) -Applied 20260110000001/migrate add audit logs (499.125µs) -Applied 20260114000001/migrate add self invites (641.917µs) -Applied 20260115000001/migrate add principal exports (444.584µs) -Applied 20260116000001/migrate add export code (718.75µs) -Applied 20260116000002/migrate add verification salt (552.292µs) -Applied 20260121000001/migrate add email verification (420.375µs) -Applied 20260121000002/migrate add principal verified (543.292µs) - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 3909.6s) - -``` -Tool: bash - -**Input:** -```json -{ - "command": "cd /Users/lucas/code/faisca/zopp && export SQLX_OFFLINE=false && DATABASE_URL=\"sqlite:///tmp/zopp-prepare.db?mode=rwc\" cargo sqlx prepare --package zopp-store-sqlite 2>&1", - "description": "Run sqlx prepare for SQLite" -} -``` -**Output:** -``` -error: unexpected argument '--package' found - - tip: to pass '--package' as a value, use '-- --package' - -Usage: cargo sqlx prepare [OPTIONS] [-- ...] - -For more information, try '--help'. - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5 · 10.0s) - -``` -Tool: bash - -**Input:** -```json -{ - "command": "cd /Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite && export SQLX_OFFLINE=false && DATABASE_URL=\"sqlite:///tmp/zopp-prepare.db?mode=rwc\" cargo sqlx prepare 2>&1", - "description": "Run sqlx prepare for SQLite" -} -``` -**Output:** -``` - Checking zopp-store-sqlite v0.1.1 (/Users/lucas/code/faisca/zopp/crates/zopp-store-sqlite) - Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.24s -query data written to .sqlx in the current directory; please check this into version control - -``` -``` - ---- - -## Assistant (Build · claude-opus-4-5) - -SQLite prepare is complete. Now let me run PostgreSQL migrations and prepare: - -``` -Tool: bash - -**Input:** -```json -{ - "command": "docker ps --filter \"name=zopp-postgres\" --format \"{{.ID}}\"", - "description": "Check if PostgreSQL container exists" -} -``` -``` - ---- - From ede0db66f2fd8743566850a8221520b1241cbd3c Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 16:36:56 -0300 Subject: [PATCH 13/25] Remove unused mailin-embedded dependency Removed mock_smtp module and mailin-embedded dependency since we use MailHog for email testing. mailin-embedded was pulling in aws-lc-rs via rustls which conflicted with kube's ring-based rustls configuration, causing K8s tests to fail. --- Cargo.lock | 140 +--------------- apps/e2e-tests/Cargo.toml | 3 +- apps/e2e-tests/tests/common/mock_smtp.rs | 203 ----------------------- apps/e2e-tests/tests/common/mod.rs | 3 - 4 files changed, 2 insertions(+), 347 deletions(-) delete mode 100644 apps/e2e-tests/tests/common/mock_smtp.rs diff --git a/Cargo.lock b/Cargo.lock index ceed217e..4a7ef614 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -251,28 +251,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "aws-lc-rs" -version = "1.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "axum" version = "0.7.9" @@ -390,15 +368,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64-compat" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a8d4d2746f89841e49230dd26917df1876050f95abafafbe34f47cb534b88d7" -dependencies = [ - "byteorder", -] - [[package]] name = "base64ct" version = "1.8.0" @@ -452,12 +421,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "bufstream-fresh" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c431e5d450eceb6f5096c371f502946ae1cc65407935bc2cae8f1d625a2035f" - [[package]] name = "bumpalo" version = "3.19.0" @@ -489,8 +452,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "739eb0f94557554b3ca9a86d2d37bebd49c5e6d0c1d2bda35ba5bdac830befc2" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -605,15 +566,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" -[[package]] -name = "cmake" -version = "0.1.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" -dependencies = [ - "cc", -] - [[package]] name = "codee" version = "0.3.5" @@ -976,12 +928,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -994,7 +940,6 @@ version = "0.1.0" dependencies = [ "k8s-openapi", "kube", - "mailin-embedded", "nix", "paste", "regex", @@ -1246,12 +1191,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.31" @@ -1983,16 +1922,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.81" @@ -2417,7 +2346,7 @@ dependencies = [ "httpdate", "idna", "mime", - "nom 8.0.0", + "nom", "percent-encoding", "quoted_printable", "rustls", @@ -2505,34 +2434,6 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" -[[package]] -name = "mailin" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261c858a4b0f914d12dd9da38b9e55a7d991cd6c3e369f503344c4f1c2137fc4" -dependencies = [ - "base64-compat", - "either", - "log", - "nom 7.1.3", - "ternop", -] - -[[package]] -name = "mailin-embedded" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c60f4b1d7bed01be9f6fa08ad92c50f1d53c61e1461a058b94197046bfc5844f" -dependencies = [ - "bufstream-fresh", - "cfg-if", - "log", - "mailin", - "rustls", - "rustls-pemfile", - "scoped_threadpool", -] - [[package]] name = "manyhow" version = "0.11.4" @@ -2630,12 +2531,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "mio" version = "1.1.0" @@ -2731,16 +2626,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nom" version = "8.0.0" @@ -3766,7 +3651,6 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -3788,15 +3672,6 @@ dependencies = [ "security-framework 3.5.1", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.13.2" @@ -3812,7 +3687,6 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3882,12 +3756,6 @@ dependencies = [ "syn", ] -[[package]] -name = "scoped_threadpool" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8" - [[package]] name = "scopeguard" version = "1.2.0" @@ -4677,12 +4545,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" -[[package]] -name = "ternop" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d4ae32d0a4605a89c28534371b056919c12e7a070ee07505af75130ff030111" - [[package]] name = "thiserror" version = "1.0.69" diff --git a/apps/e2e-tests/Cargo.toml b/apps/e2e-tests/Cargo.toml index 0163b488..8b713ca5 100644 --- a/apps/e2e-tests/Cargo.toml +++ b/apps/e2e-tests/Cargo.toml @@ -21,8 +21,7 @@ nix = { version = "0.29", features = ["signal"] } # For backend_test! macro name generation paste = "1.0" -# For mock SMTP server in email verification tests -mailin-embedded = "0.8" +# For email parsing in tests regex = "1" [[test]] diff --git a/apps/e2e-tests/tests/common/mock_smtp.rs b/apps/e2e-tests/tests/common/mock_smtp.rs deleted file mode 100644 index 030c4be0..00000000 --- a/apps/e2e-tests/tests/common/mock_smtp.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Mock SMTP server for E2E testing email verification. -//! -//! Uses mailin-embedded to create a real SMTP server that captures emails, -//! allowing tests to retrieve verification codes from the email body. -//! -//! Note: This module is kept for potential future use but currently tests use -//! MailHog (via docker-compose.test.yaml) for email capture. - -#![allow(dead_code)] - -use mailin_embedded::response::{OK, START_DATA}; -use mailin_embedded::{Handler, Response, Server}; -use regex::Regex; -use std::net::TcpListener; -use std::sync::{Arc, Mutex}; -use std::thread; - -/// Captured email data -#[derive(Debug, Clone)] -pub struct CapturedEmail { - pub from: String, - pub to: Vec, - pub data: String, -} - -/// Handler that captures emails -#[derive(Clone)] -struct EmailCapture { - emails: Arc>>, - current_from: Arc>, - current_to: Arc>>, - current_data: Arc>>, -} - -impl Handler for EmailCapture { - fn helo(&mut self, _ip: std::net::IpAddr, _domain: &str) -> Response { - OK - } - - fn mail(&mut self, _ip: std::net::IpAddr, _domain: &str, from: &str) -> Response { - *self.current_from.lock().unwrap() = from.to_string(); - self.current_to.lock().unwrap().clear(); - self.current_data.lock().unwrap().clear(); - OK - } - - fn rcpt(&mut self, to: &str) -> Response { - self.current_to.lock().unwrap().push(to.to_string()); - OK - } - - fn data_start( - &mut self, - _domain: &str, - _from: &str, - _is8bit: bool, - _to: &[String], - ) -> Response { - START_DATA - } - - fn data(&mut self, buf: &[u8]) -> std::io::Result<()> { - // Accumulate data chunks - email content comes in multiple calls - self.current_data.lock().unwrap().extend_from_slice(buf); - Ok(()) - } - - fn data_end(&mut self) -> Response { - // Now we have the complete email, store it - let data = String::from_utf8_lossy(&self.current_data.lock().unwrap()).to_string(); - let from = self.current_from.lock().unwrap().clone(); - let to = self.current_to.lock().unwrap().clone(); - - self.emails - .lock() - .unwrap() - .push(CapturedEmail { from, to, data }); - - // Clear for next email - self.current_data.lock().unwrap().clear(); - - OK - } -} - -/// Mock SMTP server that captures emails for testing -pub struct MockSmtpServer { - port: u16, - emails: Arc>>, - shutdown_handle: Option>, -} - -impl MockSmtpServer { - /// Start a new mock SMTP server on a random available port - pub fn start() -> Result> { - // Find an available port - let listener = TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - drop(listener); // Release the port so mailin can use it - - let emails: Arc>> = Arc::new(Mutex::new(Vec::new())); - let emails_clone = emails.clone(); - - let handle = thread::spawn(move || { - let handler = EmailCapture { - emails: emails_clone, - current_from: Arc::new(Mutex::new(String::new())), - current_to: Arc::new(Mutex::new(Vec::new())), - current_data: Arc::new(Mutex::new(Vec::new())), - }; - - let mut server = Server::new(handler); - server - .with_addr(format!("127.0.0.1:{}", port)) - .expect("Failed to set server address"); - - // This blocks until the server is stopped - // For tests, the server will be dropped when the test ends - let _ = server.serve(); - }); - - // Give the server a moment to start - std::thread::sleep(std::time::Duration::from_millis(100)); - - Ok(Self { - port, - emails, - shutdown_handle: Some(handle), - }) - } - - /// Get the port the server is listening on - pub fn port(&self) -> u16 { - self.port - } - - /// Get all captured emails - pub fn get_emails(&self) -> Vec { - self.emails.lock().unwrap().clone() - } - - /// Get the latest email sent to a specific address - pub fn get_email_for(&self, to_email: &str) -> Option { - self.emails - .lock() - .unwrap() - .iter() - .rev() - .find(|e| e.to.iter().any(|t| t.contains(to_email))) - .cloned() - } - - /// Extract verification code from the latest email to an address. - /// Looks for a 6-digit code in the email body. - pub fn get_verification_code(&self, to_email: &str) -> Option { - let email = self.get_email_for(to_email)?; - - // Look for 6-digit code in the email body - let re = Regex::new(r"\b(\d{6})\b").ok()?; - re.captures(&email.data) - .and_then(|cap| cap.get(1)) - .map(|m| m.as_str().to_string()) - } - - /// Clear all captured emails - pub fn clear(&self) { - self.emails.lock().unwrap().clear(); - } - - /// Wait for at least one email to arrive (with timeout) - pub fn wait_for_email(&self, timeout_ms: u64) -> bool { - let start = std::time::Instant::now(); - let timeout = std::time::Duration::from_millis(timeout_ms); - - while start.elapsed() < timeout { - if !self.emails.lock().unwrap().is_empty() { - return true; - } - std::thread::sleep(std::time::Duration::from_millis(50)); - } - false - } -} - -impl Drop for MockSmtpServer { - fn drop(&mut self) { - // The server thread will end when the test process ends - // We don't need to explicitly stop it for tests - if let Some(handle) = self.shutdown_handle.take() { - // Don't wait for the thread - it's blocking on accept() - drop(handle); - } - } -} - -#[cfg(test)] -mod tests { - #[test] - fn test_mock_smtp_starts() { - let server = super::MockSmtpServer::start().expect("Failed to start mock SMTP"); - assert!(server.port() > 0); - } -} diff --git a/apps/e2e-tests/tests/common/mod.rs b/apps/e2e-tests/tests/common/mod.rs index f83009b6..ef8c80e8 100644 --- a/apps/e2e-tests/tests/common/mod.rs +++ b/apps/e2e-tests/tests/common/mod.rs @@ -2,7 +2,6 @@ pub mod harness; pub mod mailhog; -pub mod mock_smtp; pub mod utils; // Re-export for tests that use the new infrastructure @@ -12,6 +11,4 @@ pub use harness::*; #[allow(unused_imports)] pub use mailhog::*; #[allow(unused_imports)] -pub use mock_smtp::*; -#[allow(unused_imports)] pub use utils::*; From 97191b0971596af7b47c84795f6a6a499e06cf22 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 16:54:04 -0300 Subject: [PATCH 14/25] Fix invite token placeholder to match test expectations --- apps/zopp-web/src/pages/register.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/zopp-web/src/pages/register.rs b/apps/zopp-web/src/pages/register.rs index 707358dd..03d84db7 100644 --- a/apps/zopp-web/src/pages/register.rs +++ b/apps/zopp-web/src/pages/register.rs @@ -245,7 +245,7 @@ pub fn RegisterPage() -> impl IntoView { Date: Sun, 25 Jan 2026 16:58:21 -0300 Subject: [PATCH 15/25] Add DATABASE_URL to web-e2e test environment --- .github/workflows/web-e2e.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/web-e2e.yaml b/.github/workflows/web-e2e.yaml index c05c6678..6ab714bc 100644 --- a/.github/workflows/web-e2e.yaml +++ b/.github/workflows/web-e2e.yaml @@ -127,6 +127,8 @@ jobs: 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 From ae4970ec8c25f81465b72fed935f0565be09bb80 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 17:18:59 -0300 Subject: [PATCH 16/25] Increase operator sync timeout in e2e test to debug CI failure --- apps/e2e-tests/tests/k8s.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 { From 210b3b99d52d2cd7bd1a75b4129268fdc76be652 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 17:20:17 -0300 Subject: [PATCH 17/25] Fix web-e2e tests for email verification flow - Add getVerificationCodeFromMailHog helper function - Update setupTestData to handle email verification when MAILHOG_API_URL is set - Use --verification-code flag for CLI join command - Increase maxBuffer to prevent ENOBUFS errors - Also increase operator sync timeout for debugging CI --- apps/zopp-web/tests/fixtures/test-setup.ts | 83 ++++++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/apps/zopp-web/tests/fixtures/test-setup.ts b/apps/zopp-web/tests/fixtures/test-setup.ts index e28e60db..02e48b74 100644 --- a/apps/zopp-web/tests/fixtures/test-setup.ts +++ b/apps/zopp-web/tests/fixtures/test-setup.ts @@ -22,6 +22,43 @@ 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 { + const response = await fetch(`${apiUrl}/messages`); + 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 +181,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'); From c7300b691e5c076ed1a2aad33a69cc2350d2b4bf Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 17:41:39 -0300 Subject: [PATCH 18/25] Fix join-verification tests with ambiguous locators Use getByRole('heading') instead of getByText(/verification code/i) which was matching multiple elements. Also use exact placeholder match. --- apps/zopp-web/tests/join-verification.spec.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/zopp-web/tests/join-verification.spec.ts b/apps/zopp-web/tests/join-verification.spec.ts index 3638c038..3ce56b53 100644 --- a/apps/zopp-web/tests/join-verification.spec.ts +++ b/apps/zopp-web/tests/join-verification.spec.ts @@ -197,9 +197,9 @@ test.describe('Join with Email Verification', () => { // Submit the form await page.getByRole('button', { name: /Create Principal/i }).click(); - // Should show verification code input - await expect(page.getByText(/verification code/i)).toBeVisible({ timeout: 10000 }); - await expect(page.getByPlaceholder(/Enter.*code/i)).toBeVisible(); + // 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 }) => { @@ -218,15 +218,15 @@ test.describe('Join with Email Verification', () => { // Submit the form await page.getByRole('button', { name: /Create Principal/i }).click(); - // Wait for verification code input to appear - await expect(page.getByText(/verification code/i)).toBeVisible({ timeout: 10000 }); + // 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(/Enter.*code/i).fill(verificationCode); + await page.getByPlaceholder('123456').fill(verificationCode); // Submit verification await page.getByRole('button', { name: /Verify/i }).click(); @@ -251,11 +251,11 @@ test.describe('Join with Email Verification', () => { // Submit the form await page.getByRole('button', { name: /Create Principal/i }).click(); - // Wait for verification code input to appear - await expect(page.getByText(/verification code/i)).toBeVisible({ timeout: 10000 }); + // 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(/Enter.*code/i).fill('000000'); + await page.getByPlaceholder('123456').fill('000000'); // Submit verification await page.getByRole('button', { name: /Verify/i }).click(); @@ -283,11 +283,11 @@ test.describe('Join with Email Verification', () => { // Submit the form await page.getByRole('button', { name: /Create Principal/i }).click(); - // Wait for verification code input - await expect(page.getByText(/verification code/i)).toBeVisible({ timeout: 10000 }); + // 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(/Enter.*code/i).fill('000000'); + await page.getByPlaceholder('123456').fill('000000'); await page.getByRole('button', { name: /Verify/i }).click(); // Should show error @@ -297,8 +297,8 @@ test.describe('Join with Email Verification', () => { const verificationCode = await getVerificationCodeFromMailhog(testEmail); // Clear and enter correct code - await page.getByPlaceholder(/Enter.*code/i).clear(); - await page.getByPlaceholder(/Enter.*code/i).fill(verificationCode); + await page.getByPlaceholder('123456').clear(); + await page.getByPlaceholder('123456').fill(verificationCode); // Submit again await page.getByRole('button', { name: /Verify/i }).click(); From d7832a435b95bbc90c9ea0798a089c7c8b25d510 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 18:24:43 -0300 Subject: [PATCH 19/25] Address Cubic re-review feedback Fixes 4 issues identified by Cubic AI reviewer: P2 (Security/Correctness): - consume_invite now filters revoked=FALSE to prevent consuming revoked invites in both PostgreSQL and SQLite stores - Add fetch timeout/abort to test-setup.ts getVerificationCodeFromMailHog to honor overall timeout instead of hanging on stalled requests - Fix verification UI to use correct signals when complete_registration_impl fails after successful verification, preventing stuck loading state P3 (Test quality): - Add assert!() around matches!() calls in SQLite tests to actually verify error types instead of silently passing Also updates sqlx metadata for both stores. --- apps/zopp-web/src/pages/register.rs | 5 +- apps/zopp-web/tests/fixtures/test-setup.ts | 6 ++- ...54a9d175b03062ba69652f292e895f6bbe9a.json} | 4 +- ...5b263d8a33ed95cfb08fe97c2e8b552c44a6.json} | 4 +- crates/zopp-store-postgres/src/lib.rs | 10 ++-- ...9e8211fc31b5dc372416fb8138b347a4c5e7.json} | 4 +- ...0311539fe50d24b19604b05316c2b37cb8cc.json} | 4 +- crates/zopp-store-sqlite/src/lib.rs | 51 ++++++++++--------- 8 files changed, 48 insertions(+), 40 deletions(-) rename crates/zopp-store-postgres/.sqlx/{query-4ed1f63d30d7a812224d49f5db2d3068bf65761764c9429d4d923ace5850a91a.json => query-48006c0df77c817a62bb009e804454a9d175b03062ba69652f292e895f6bbe9a.json} (73%) rename crates/zopp-store-postgres/.sqlx/{query-8ee895804b3dbc55ed08e7d66dfbf58ed26b7ee5eac3a8b56a9611154ba23932.json => query-969d06a49a9a693001ec8fb20baa5b263d8a33ed95cfb08fe97c2e8b552c44a6.json} (64%) rename crates/zopp-store-sqlite/.sqlx/{query-a8c0c502c39e88507def64c9cca7d56e039664dbf1cf4c08836c84990c93198d.json => query-8fb3c267898d969b5956f25e59729e8211fc31b5dc372416fb8138b347a4c5e7.json} (61%) rename crates/zopp-store-sqlite/.sqlx/{query-22c3a135e3f231678a4865e17cd4dd46eebdf150ec1e4c666eaa0284a722a686.json => query-eb64192a9ca526531a1a97092a000311539fe50d24b19604b05316c2b37cb8cc.json} (64%) diff --git a/apps/zopp-web/src/pages/register.rs b/apps/zopp-web/src/pages/register.rs index 03d84db7..59f4d62b 100644 --- a/apps/zopp-web/src/pages/register.rs +++ b/apps/zopp-web/src/pages/register.rs @@ -142,8 +142,8 @@ pub fn RegisterPage() -> impl IntoView { pending.email, pending.device_name, auth_clone, - set_error, - set_loading, + set_verification_error, + set_verifying, ) .await { @@ -152,6 +152,7 @@ pub fn RegisterPage() -> impl IntoView { 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) { diff --git a/apps/zopp-web/tests/fixtures/test-setup.ts b/apps/zopp-web/tests/fixtures/test-setup.ts index 02e48b74..49277faf 100644 --- a/apps/zopp-web/tests/fixtures/test-setup.ts +++ b/apps/zopp-web/tests/fixtures/test-setup.ts @@ -31,7 +31,11 @@ async function getVerificationCodeFromMailHog(apiUrl: string, toEmail: string, t while (Date.now() - startTime < timeoutMs) { try { - const response = await fetch(`${apiUrl}/messages`); + // 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(); diff --git a/crates/zopp-store-postgres/.sqlx/query-4ed1f63d30d7a812224d49f5db2d3068bf65761764c9429d4d923ace5850a91a.json b/crates/zopp-store-postgres/.sqlx/query-48006c0df77c817a62bb009e804454a9d175b03062ba69652f292e895f6bbe9a.json similarity index 73% rename from crates/zopp-store-postgres/.sqlx/query-4ed1f63d30d7a812224d49f5db2d3068bf65761764c9429d4d923ace5850a91a.json rename to crates/zopp-store-postgres/.sqlx/query-48006c0df77c817a62bb009e804454a9d175b03062ba69652f292e895f6bbe9a.json index 27ceac46..eda96033 100644 --- a/crates/zopp-store-postgres/.sqlx/query-4ed1f63d30d7a812224d49f5db2d3068bf65761764c9429d4d923ace5850a91a.json +++ b/crates/zopp-store-postgres/.sqlx/query-48006c0df77c817a62bb009e804454a9d175b03062ba69652f292e895f6bbe9a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM invites WHERE token = $1) as \"exists!: bool\"", + "query": "SELECT EXISTS(SELECT 1 FROM invites WHERE token = $1 AND revoked = FALSE) as \"exists!: bool\"", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "4ed1f63d30d7a812224d49f5db2d3068bf65761764c9429d4d923ace5850a91a" + "hash": "48006c0df77c817a62bb009e804454a9d175b03062ba69652f292e895f6bbe9a" } diff --git a/crates/zopp-store-postgres/.sqlx/query-8ee895804b3dbc55ed08e7d66dfbf58ed26b7ee5eac3a8b56a9611154ba23932.json b/crates/zopp-store-postgres/.sqlx/query-969d06a49a9a693001ec8fb20baa5b263d8a33ed95cfb08fe97c2e8b552c44a6.json similarity index 64% rename from crates/zopp-store-postgres/.sqlx/query-8ee895804b3dbc55ed08e7d66dfbf58ed26b7ee5eac3a8b56a9611154ba23932.json rename to crates/zopp-store-postgres/.sqlx/query-969d06a49a9a693001ec8fb20baa5b263d8a33ed95cfb08fe97c2e8b552c44a6.json index 865f748f..cdaf09a2 100644 --- a/crates/zopp-store-postgres/.sqlx/query-8ee895804b3dbc55ed08e7d66dfbf58ed26b7ee5eac3a8b56a9611154ba23932.json +++ b/crates/zopp-store-postgres/.sqlx/query-969d06a49a9a693001ec8fb20baa5b263d8a33ed95cfb08fe97c2e8b552c44a6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE invites SET consumed = TRUE WHERE token = $1 AND consumed = FALSE", + "query": "UPDATE invites SET consumed = TRUE WHERE token = $1 AND consumed = FALSE AND revoked = FALSE", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "8ee895804b3dbc55ed08e7d66dfbf58ed26b7ee5eac3a8b56a9611154ba23932" + "hash": "969d06a49a9a693001ec8fb20baa5b263d8a33ed95cfb08fe97c2e8b552c44a6" } diff --git a/crates/zopp-store-postgres/src/lib.rs b/crates/zopp-store-postgres/src/lib.rs index b7600c55..efb803cd 100644 --- a/crates/zopp-store-postgres/src/lib.rs +++ b/crates/zopp-store-postgres/src/lib.rs @@ -435,10 +435,10 @@ impl Store for PostgresStore { } async fn consume_invite(&self, token: &str) -> Result<(), StoreError> { - // Atomically consume invite only if not already consumed + // 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", + "UPDATE invites SET consumed = TRUE WHERE token = $1 AND consumed = FALSE AND revoked = FALSE", token ) .execute(&self.pool) @@ -446,10 +446,10 @@ impl Store for PostgresStore { .map_err(|e| StoreError::Backend(e.to_string()))?; if result.rows_affected() == 0 { - // Either token doesn't exist or invite was already consumed + // 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) as \"exists!: bool\"", + "SELECT EXISTS(SELECT 1 FROM invites WHERE token = $1 AND revoked = FALSE) as \"exists!: bool\"", token ) .fetch_one(&self.pool) @@ -459,7 +459,7 @@ impl Store for PostgresStore { if exists { Err(StoreError::AlreadyExists) // Invite was already consumed } else { - Err(StoreError::NotFound) // Token doesn't exist + Err(StoreError::NotFound) // Token doesn't exist or is revoked } } else { Ok(()) diff --git a/crates/zopp-store-sqlite/.sqlx/query-a8c0c502c39e88507def64c9cca7d56e039664dbf1cf4c08836c84990c93198d.json b/crates/zopp-store-sqlite/.sqlx/query-8fb3c267898d969b5956f25e59729e8211fc31b5dc372416fb8138b347a4c5e7.json similarity index 61% rename from crates/zopp-store-sqlite/.sqlx/query-a8c0c502c39e88507def64c9cca7d56e039664dbf1cf4c08836c84990c93198d.json rename to crates/zopp-store-sqlite/.sqlx/query-8fb3c267898d969b5956f25e59729e8211fc31b5dc372416fb8138b347a4c5e7.json index 914ba3c3..a71e421e 100644 --- a/crates/zopp-store-sqlite/.sqlx/query-a8c0c502c39e88507def64c9cca7d56e039664dbf1cf4c08836c84990c93198d.json +++ b/crates/zopp-store-sqlite/.sqlx/query-8fb3c267898d969b5956f25e59729e8211fc31b5dc372416fb8138b347a4c5e7.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT token FROM invites WHERE token = ?", + "query": "SELECT token FROM invites WHERE token = ? AND revoked = 0", "describe": { "columns": [ { @@ -16,5 +16,5 @@ false ] }, - "hash": "a8c0c502c39e88507def64c9cca7d56e039664dbf1cf4c08836c84990c93198d" + "hash": "8fb3c267898d969b5956f25e59729e8211fc31b5dc372416fb8138b347a4c5e7" } diff --git a/crates/zopp-store-sqlite/.sqlx/query-22c3a135e3f231678a4865e17cd4dd46eebdf150ec1e4c666eaa0284a722a686.json b/crates/zopp-store-sqlite/.sqlx/query-eb64192a9ca526531a1a97092a000311539fe50d24b19604b05316c2b37cb8cc.json similarity index 64% rename from crates/zopp-store-sqlite/.sqlx/query-22c3a135e3f231678a4865e17cd4dd46eebdf150ec1e4c666eaa0284a722a686.json rename to crates/zopp-store-sqlite/.sqlx/query-eb64192a9ca526531a1a97092a000311539fe50d24b19604b05316c2b37cb8cc.json index 4ede4b65..d12802b5 100644 --- a/crates/zopp-store-sqlite/.sqlx/query-22c3a135e3f231678a4865e17cd4dd46eebdf150ec1e4c666eaa0284a722a686.json +++ b/crates/zopp-store-sqlite/.sqlx/query-eb64192a9ca526531a1a97092a000311539fe50d24b19604b05316c2b37cb8cc.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE invites SET consumed = 1 WHERE token = ? AND consumed = 0", + "query": "UPDATE invites SET consumed = 1 WHERE token = ? AND consumed = 0 AND revoked = 0", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "22c3a135e3f231678a4865e17cd4dd46eebdf150ec1e4c666eaa0284a722a686" + "hash": "eb64192a9ca526531a1a97092a000311539fe50d24b19604b05316c2b37cb8cc" } diff --git a/crates/zopp-store-sqlite/src/lib.rs b/crates/zopp-store-sqlite/src/lib.rs index 963dca01..95c4f029 100644 --- a/crates/zopp-store-sqlite/src/lib.rs +++ b/crates/zopp-store-sqlite/src/lib.rs @@ -578,10 +578,10 @@ impl Store for SqliteStore { } async fn consume_invite(&self, token: &str) -> Result<(), StoreError> { - // Atomically consume invite only if not already consumed + // 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", + "UPDATE invites SET consumed = 1 WHERE token = ? AND consumed = 0 AND revoked = 0", token ) .execute(&self.pool) @@ -589,17 +589,20 @@ impl Store for SqliteStore { .map_err(|e| StoreError::Backend(e.to_string()))?; if result.rows_affected() == 0 { - // Either token doesn't exist or invite was already consumed + // 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 = ?", token) - .fetch_optional(&self.pool) - .await - .map_err(|e| StoreError::Backend(e.to_string()))?; + 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 + Err(StoreError::NotFound) // Token doesn't exist or is revoked } } else { Ok(()) @@ -3451,7 +3454,7 @@ mod tests { .await .unwrap_err(); - matches!(err, StoreError::AlreadyExists); + assert!(matches!(err, StoreError::AlreadyExists)); } #[tokio::test] @@ -3518,7 +3521,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] @@ -3597,7 +3600,7 @@ mod tests { }) .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -3642,7 +3645,7 @@ mod tests { }) .await .unwrap_err(); - matches!(err, StoreError::AlreadyExists); + assert!(matches!(err, StoreError::AlreadyExists)); } #[tokio::test] @@ -3961,7 +3964,7 @@ mod tests { .get_workspace_permission(&ws, &principal_id) .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -4059,7 +4062,7 @@ mod tests { .get_user_by_email("notfound@example.com") .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -4102,7 +4105,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] @@ -4142,12 +4145,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] @@ -4237,7 +4240,7 @@ mod tests { .get_user_workspace_permission(&ws, &user_id) .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -4291,7 +4294,7 @@ mod tests { .get_workspace_principal(&ws, &principal_id) .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } // ─────────────────────────── Email Verification Tests ─────────────────────────── @@ -4328,7 +4331,7 @@ mod tests { .get_email_verification("nonexistent@example.com") .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -4384,7 +4387,7 @@ mod tests { .get_email_verification("test@example.com") .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -4393,7 +4396,7 @@ mod tests { let fake_id = zopp_storage::EmailVerificationId(uuid::Uuid::now_v7()); let err = s.delete_email_verification(&fake_id).await.unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] @@ -4427,7 +4430,7 @@ mod tests { .get_email_verification("expired@example.com") .await .unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); // Valid one should still exist s.get_email_verification("valid@example.com").await.unwrap(); @@ -4470,7 +4473,7 @@ mod tests { let fake_id = zopp_storage::UserId(uuid::Uuid::now_v7()); let err = s.mark_user_verified(&fake_id).await.unwrap_err(); - matches!(err, StoreError::NotFound); + assert!(matches!(err, StoreError::NotFound)); } #[tokio::test] From 2f4251f3ecd515e0128fdc63be534ed73c5c7fae Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 18:34:37 -0300 Subject: [PATCH 20/25] Fix minor issues from Cubic review P3 fixes: - Update harness.rs comment to accurately state MailHog is required (no fallback to mock SMTP exists) - Use non-numeric "XXXXXX" as invalid verification code in test to guarantee it never matches a real generated code --- apps/e2e-tests/tests/common/harness.rs | 3 +-- apps/e2e-tests/tests/email_verification.rs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/e2e-tests/tests/common/harness.rs b/apps/e2e-tests/tests/common/harness.rs index f8171388..023a96e4 100644 --- a/apps/e2e-tests/tests/common/harness.rs +++ b/apps/e2e-tests/tests/common/harness.rs @@ -232,8 +232,7 @@ impl TestHarness { } /// Create a new test harness with email verification enabled. - /// Uses MailHog if available (via docker-compose.test.yaml), otherwise falls back - /// to built-in mock SMTP server. + /// 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, diff --git a/apps/e2e-tests/tests/email_verification.rs b/apps/e2e-tests/tests/email_verification.rs index d37bc8e7..9f2ed031 100644 --- a/apps/e2e-tests/tests/email_verification.rs +++ b/apps/e2e-tests/tests/email_verification.rs @@ -74,7 +74,7 @@ async fn run_test_invalid_verification_code( "--principal", &alice.principal(), "--verification-code", - "000000", // Invalid code + "XXXXXX", // Non-numeric code - guaranteed to be invalid ]); // Join should fail because verification code is invalid From 2a3c4c925f45c8643b837355fd1d114fbc774ebc Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 18:42:50 -0300 Subject: [PATCH 21/25] Address Cubic re-review feedback (round 4) P2 fixes: - verification-setup.ts: Check response.ok before returning true from waitForServer to ensure server is actually ready - verification.rs: Send email BEFORE updating DB in resend flow so old code is preserved if email sending fails - harness.rs: Propagate MailHog clear errors instead of ignoring them to fail fast on cleanup issues that could cause flaky tests P3 fixes: - email_verification.rs: Fix remaining "000000" to use "XXXXXX" --- apps/e2e-tests/tests/common/harness.rs | 3 +- apps/e2e-tests/tests/email_verification.rs | 2 +- apps/zopp-server/src/handlers/verification.rs | 29 ++++++++++--------- .../tests/fixtures/verification-setup.ts | 10 +++++-- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/apps/e2e-tests/tests/common/harness.rs b/apps/e2e-tests/tests/common/harness.rs index 023a96e4..133e7870 100644 --- a/apps/e2e-tests/tests/common/harness.rs +++ b/apps/e2e-tests/tests/common/harness.rs @@ -266,7 +266,8 @@ impl TestHarness { mailhog_smtp_port, mailhog_api_port ); // Clear any existing emails from previous test runs - let _ = mailhog_client.clear().await; + // 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, diff --git a/apps/e2e-tests/tests/email_verification.rs b/apps/e2e-tests/tests/email_verification.rs index 9f2ed031..0de08424 100644 --- a/apps/e2e-tests/tests/email_verification.rs +++ b/apps/e2e-tests/tests/email_verification.rs @@ -136,7 +136,7 @@ async fn run_test_full_verification_flow( "--principal", &bob.principal(), "--verification-code", - "000000", + "XXXXXX", // Non-numeric code - guaranteed to be invalid ]); assert!(result.failed(), "First join should fail with wrong code"); diff --git a/apps/zopp-server/src/handlers/verification.rs b/apps/zopp-server/src/handlers/verification.rs index c5027dab..c2a12059 100644 --- a/apps/zopp-server/src/handlers/verification.rs +++ b/apps/zopp-server/src/handlers/verification.rs @@ -317,20 +317,8 @@ pub async fn resend_verification( // Generate new verification code let code = generate_verification_code(); - // Update verification record with new code (upsert preserves invite_token) - let expires_at = Utc::now() + chrono::Duration::minutes(15); - server - .store - .create_email_verification(&CreateEmailVerificationParams { - email: email.clone(), - code: code.clone(), - invite_token: existing.invite_token, // Preserve the original invite token - expires_at, - }) - .await - .map_err(|e| Status::internal(format!("Failed to update verification: {}", e)))?; - - // Send verification email + // 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( @@ -348,6 +336,19 @@ pub async fn resend_verification( })); } + // Email sent successfully - now update verification record with new code + let expires_at = Utc::now() + chrono::Duration::minutes(15); + server + .store + .create_email_verification(&CreateEmailVerificationParams { + email: email.clone(), + code: code.clone(), + 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-web/tests/fixtures/verification-setup.ts b/apps/zopp-web/tests/fixtures/verification-setup.ts index 377a580c..e28b2b1d 100644 --- a/apps/zopp-web/tests/fixtures/verification-setup.ts +++ b/apps/zopp-web/tests/fixtures/verification-setup.ts @@ -40,12 +40,16 @@ async function waitForServer(url: string, timeoutMs: number = 30000): Promise controller.abort(), 2000); - await fetch(url, { signal: controller.signal }); + const response = await fetch(url, { signal: controller.signal }); clearTimeout(timeoutId); - return true; + // Only return true if server responds successfully + if (response.ok) { + return true; + } } catch { - await new Promise(resolve => setTimeout(resolve, 200)); + // Retry on network error } + await new Promise(resolve => setTimeout(resolve, 200)); } return false; } From 7aafacf3f601bb38e234c86aefd38c880cead059 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 18:55:34 -0300 Subject: [PATCH 22/25] Update CLAUDE.md with detailed Cubic review workflow --- CLAUDE.md | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 367401c8..ecd5936d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,12 +287,30 @@ 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 +2. **Address issues**: Fix any issues Cubic identifies, commit and push +3. **Incremental review**: Cubic automatically reviews only the new changes + - Check the CI check output: "AI review completed with X review. Y issues found across Z files" + - If issues found, fix them and push again + - If 0 issues found, the incremental changes are good +4. **Request full re-review**: Once incremental reviews pass, comment `@cubic-dev-ai Please do a full re-review of the PR.` +5. **Iterate**: Repeat until full review passes with 0 issues or acceptable issues + +### 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 +326,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 From ebd5bf700b9b914fcc4ee49f3e61ae54011ffac0 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 18:57:39 -0300 Subject: [PATCH 23/25] Fix auth.rs email-before-store and verification-setup grpcWebUrl P2 fixes from Cubic full re-review: - auth.rs: Send verification email BEFORE storing record, matching the resend flow fix. Prevents storing record when email fails. - verification-setup.ts: Use server port for grpcWebUrl since Envoy isn't started in this test setup. Also update CLAUDE.md with clearer Cubic workflow instructions. --- CLAUDE.md | 14 ++++---- apps/zopp-server/src/handlers/auth.rs | 32 ++++++++++--------- .../tests/fixtures/verification-setup.ts | 2 +- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ecd5936d..ca75775c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -294,14 +294,16 @@ When creating PRs and working through CI: ### Cubic Review Workflow -1. **Initial full review**: When PR is created, Cubic does a full review +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 - - Check the CI check output: "AI review completed with X review. Y issues found across Z files" - - If issues found, fix them and push again - - If 0 issues found, the incremental changes are good +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. **Iterate**: Repeat until full review passes with 0 issues or acceptable issues +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 diff --git a/apps/zopp-server/src/handlers/auth.rs b/apps/zopp-server/src/handlers/auth.rs index 4395f620..caa523ad 100644 --- a/apps/zopp-server/src/handlers/auth.rs +++ b/apps/zopp-server/src/handlers/auth.rs @@ -110,21 +110,9 @@ pub async fn join( // Generate verification code let code = generate_verification_code(); - // Store verification record (expires in 15 minutes) - // 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: code.clone(), - invite_token: req.invite_token.clone(), - expires_at, - }) - .await - .map_err(|e| Status::internal(format!("Failed to create verification: {}", e)))?; - - // Send verification email - fail if email cannot be sent + // 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 @@ -139,6 +127,20 @@ pub async fn join( Status::internal(format!("Failed to send verification email: {}", e)) })?; } + + // Email sent successfully - now store verification record (expires in 15 minutes) + // 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: code.clone(), + 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 diff --git a/apps/zopp-web/tests/fixtures/verification-setup.ts b/apps/zopp-web/tests/fixtures/verification-setup.ts index e28b2b1d..38483d7f 100644 --- a/apps/zopp-web/tests/fixtures/verification-setup.ts +++ b/apps/zopp-web/tests/fixtures/verification-setup.ts @@ -148,7 +148,7 @@ export async function setupVerificationTest(): Promise return { serverUrl: `http://127.0.0.1:${serverPort}`, - grpcWebUrl: `http://127.0.0.1:${envoyPort}`, // Would be Envoy port if running + grpcWebUrl: `http://127.0.0.1:${serverPort}`, // Use server port directly since Envoy isn't running testDir, inviteToken, mockSmtp, From f7eb1f938f3a023fde80850058f74b2a2b5f5c55 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 19:28:54 -0300 Subject: [PATCH 24/25] Hash verification codes with Argon2id for zero-knowledge storage - Add generic argon2_hash and argon2_hash_raw functions to zopp-crypto - Refactor CLI to use zopp-crypto for key derivation and verification hash - Change storage schema from 'code' to 'code_hash' in both SQLite and Postgres - Update server handlers to hash codes before storage and comparison - Fix MailHog client to use v1 API for delete (v2 doesn't support DELETE) - Add create_unverified_test_user helper for testing unverified principal flows - Update E2E tests to use has_verification_record since plaintext codes are no longer stored --- Cargo.lock | 1 + apps/e2e-tests/tests/common/harness.rs | 31 +++---- apps/e2e-tests/tests/common/mailhog.rs | 7 +- apps/e2e-tests/tests/email_verification.rs | 13 ++- apps/zopp-cli/src/commands/principal.rs | 32 +------ apps/zopp-server/src/handlers/auth.rs | 7 +- apps/zopp-server/src/handlers/verification.rs | 14 ++- apps/zopp-server/src/tests.rs | 50 +++++++++-- .../tests/fixtures/verification-setup.ts | 12 +-- crates/zopp-crypto/Cargo.toml | 1 + crates/zopp-crypto/src/lib.rs | 89 +++++++++++++++++++ crates/zopp-storage/src/lib.rs | 8 +- ...d205cc7935dfea6c0ecf0bff2c30cd6fe2ee.json} | 6 +- ...69e4a57a1d746852f82dc15d1dcecb2a98e3.json} | 6 +- .../20260125000001_add_email_verification.sql | 2 +- crates/zopp-store-postgres/src/lib.rs | 14 +-- ...4d98d9d2f26dfa7c8b8d03423b5b72f6074c.json} | 4 +- ...992eee13b46b857db3ceea73f64c92d9b2aa.json} | 6 +- .../20260125000001_add_email_verification.sql | 2 +- crates/zopp-store-sqlite/src/lib.rs | 37 ++++---- 20 files changed, 235 insertions(+), 107 deletions(-) rename crates/zopp-store-postgres/.sqlx/{query-2dbbe181a67af9408456fae3da6581dcf7b02e9f911837df9704cbc15dce4095.json => query-a02edea0da760db52281d22369aad205cc7935dfea6c0ecf0bff2c30cd6fe2ee.json} (77%) rename crates/zopp-store-postgres/.sqlx/{query-b75b1dae312512d71b77625a1032c9b94f2925fd48ae2b8c20a37236dc4b4b44.json => query-b5a6aeb9f1b5049260372030776069e4a57a1d746852f82dc15d1dcecb2a98e3.json} (59%) rename crates/zopp-store-sqlite/.sqlx/{query-f34c7ab913fc98c5dff31e7cf8f9067d0259ed1e9697ccb2982b830b30bc24f7.json => query-8f2351064450a6136d5835fd545e4d98d9d2f26dfa7c8b8d03423b5b72f6074c.json} (58%) rename crates/zopp-store-sqlite/.sqlx/{query-f3a0db47f07176db3474f0e28b914d0301e3f7e2726683b008c668eafc7b0c8e.json => query-a4fdca8cd0f9cb6c6586725f6185992eee13b46b857db3ceea73f64c92d9b2aa.json} (70%) diff --git a/Cargo.lock b/Cargo.lock index 4a7ef614..fa4c704a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5985,6 +5985,7 @@ dependencies = [ "argon2", "chacha20poly1305", "getrandom 0.2.16", + "hex", "js-sys", "rand_core 0.6.4", "sha2", diff --git a/apps/e2e-tests/tests/common/harness.rs b/apps/e2e-tests/tests/common/harness.rs index 133e7870..82dc32bb 100644 --- a/apps/e2e-tests/tests/common/harness.rs +++ b/apps/e2e-tests/tests/common/harness.rs @@ -612,9 +612,10 @@ impl TestHarness { }) } - /// Get the latest verification code for an email from the database. - /// This is used for E2E testing email verification flows. - pub fn get_verification_code(&self, email: &str) -> Result> { + /// 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 @@ -625,7 +626,7 @@ impl TestHarness { .arg("-A") // unaligned output .arg("-c") .arg(format!( - "SELECT code FROM email_verifications WHERE email = '{}' ORDER BY created_at DESC LIMIT 1", + "SELECT COUNT(*) FROM email_verifications WHERE email = '{}'", escaped_email )) .output()?; @@ -636,11 +637,11 @@ impl TestHarness { ); } - let code = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if code.is_empty() { - return Err(format!("No verification code found for email: {}", email).into()); - } - Ok(code) + 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 @@ -656,7 +657,7 @@ impl TestHarness { .arg(db_path) .arg("-noheader") .arg(format!( - "SELECT code FROM email_verifications WHERE email = '{}' ORDER BY created_at DESC LIMIT 1;", + "SELECT COUNT(*) FROM email_verifications WHERE email = '{}';", escaped_email )) .output()?; @@ -669,11 +670,11 @@ impl TestHarness { .into()); } - let code = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if code.is_empty() { - return Err(format!("No verification code found for email: {}", email).into()); - } - Ok(code) + let count: i32 = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap_or(0); + Ok(count > 0) } } } diff --git a/apps/e2e-tests/tests/common/mailhog.rs b/apps/e2e-tests/tests/common/mailhog.rs index 93ec70ca..bae4fee0 100644 --- a/apps/e2e-tests/tests/common/mailhog.rs +++ b/apps/e2e-tests/tests/common/mailhog.rs @@ -140,10 +140,9 @@ impl MailHogClient { /// Clear all messages pub async fn clear(&self) -> Result<(), Box> { - self.client - .delete(format!("{}/messages", self.api_url)) - .send() - .await?; + // 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(()) } diff --git a/apps/e2e-tests/tests/email_verification.rs b/apps/e2e-tests/tests/email_verification.rs index 0de08424..feeaa91d 100644 --- a/apps/e2e-tests/tests/email_verification.rs +++ b/apps/e2e-tests/tests/email_verification.rs @@ -108,11 +108,16 @@ async fn run_test_invalid_verification_code( ); println!(" Retrieved verification code from email: {}", code); - println!(" Test 3: Database code should match email code..."); + println!( + " Test 3: Database should have a verification record (hash stored, not plaintext)..." + ); - let db_code = harness.get_verification_code(&alice.email())?; - assert_eq!(code, db_code, "Email code should match database code"); - println!(" Database code matches: {}", db_code); + 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(()) 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-server/src/handlers/auth.rs b/apps/zopp-server/src/handlers/auth.rs index caa523ad..e08f7c6c 100644 --- a/apps/zopp-server/src/handlers/auth.rs +++ b/apps/zopp-server/src/handlers/auth.rs @@ -4,6 +4,7 @@ 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, }; @@ -129,13 +130,17 @@ pub async fn join( } // 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: code.clone(), + code_hash, invite_token: req.invite_token.clone(), expires_at, }) diff --git a/apps/zopp-server/src/handlers/verification.rs b/apps/zopp-server/src/handlers/verification.rs index c2a12059..8196a15a 100644 --- a/apps/zopp-server/src/handlers/verification.rs +++ b/apps/zopp-server/src/handlers/verification.rs @@ -2,6 +2,7 @@ use chrono::Utc; use tonic::{Request, Response, Status}; +use zopp_crypto::argon2_hash; use zopp_storage::{CreateEmailVerificationParams, Store}; use crate::email::generate_verification_code; @@ -95,9 +96,12 @@ pub async fn verify_email( })); } - // Verify the code using constant-time comparison + // 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(req.code.as_bytes(), verification.code.as_bytes()).into(); + subtle::ConstantTimeEq::ct_eq(submitted_hash.as_bytes(), verification.code_hash.as_bytes()) + .into(); if !code_matches { // Increment attempts @@ -336,13 +340,15 @@ pub async fn resend_verification( })); } - // Email sent successfully - now update verification record with new code + // 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: code.clone(), + code_hash, invite_token: existing.invite_token, // Preserve the original invite token expires_at, }) diff --git a/apps/zopp-server/src/tests.rs b/apps/zopp-server/src/tests.rs index d8527f35..a40e95ca 100644 --- a/apps/zopp-server/src/tests.rs +++ b/apps/zopp-server/src/tests.rs @@ -65,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, @@ -92,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, @@ -1211,9 +1248,9 @@ mod handler_tests { // Server with verification required let server = create_test_server_with_verification().await; - // Create user with unverified principal (default state) + // Create unverified user with principal let (_user_id, principal_id, signing_key) = - create_test_user(&server, "test@example.com", "laptop").await; + 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( @@ -1305,9 +1342,9 @@ mod handler_tests { // Server with verification required let server = create_test_server_with_verification().await; - // Create user with unverified principal + // Create unverified user with principal let (_user_id, principal_id, signing_key) = - create_test_user(&server, "test@example.com", "laptop").await; + create_unverified_test_user(&server, "test@example.com", "laptop").await; // Try to list workspaces - should be blocked let request = create_signed_request( @@ -1338,7 +1375,8 @@ mod handler_tests { // Create unverified principal let (_, unverified_principal_id, unverified_signing_key) = - create_test_user(&server, "unverified@example.com", "unverified-laptop").await; + create_unverified_test_user(&server, "unverified@example.com", "unverified-laptop") + .await; // Add unverified user to workspace let unverified_user = server diff --git a/apps/zopp-web/tests/fixtures/verification-setup.ts b/apps/zopp-web/tests/fixtures/verification-setup.ts index 38483d7f..25b8a873 100644 --- a/apps/zopp-web/tests/fixtures/verification-setup.ts +++ b/apps/zopp-web/tests/fixtures/verification-setup.ts @@ -189,22 +189,22 @@ export async function teardownVerificationTest(ctx: VerificationTestContext): Pr } /** - * Get verification code from the database directly. - * This is a fallback if mock SMTP doesn't capture the email. + * 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 getVerificationCodeFromDb(dbPath: string, email: string): string | null { +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 code FROM email_verifications WHERE email = '${escapedEmail}' ORDER BY created_at DESC LIMIT 1;`], + [dbPath, `SELECT COUNT(*) FROM email_verifications WHERE email = '${escapedEmail}';`], { encoding: 'utf-8' } ).trim(); - return result || null; + return parseInt(result, 10) > 0; } catch { - return null; + return false; } } 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-storage/src/lib.rs b/crates/zopp-storage/src/lib.rs index 109456cc..8629f08e 100644 --- a/crates/zopp-storage/src/lib.rs +++ b/crates/zopp-storage/src/lib.rs @@ -421,7 +421,7 @@ pub struct CreatePrincipalExportParams { pub struct EmailVerification { pub id: EmailVerificationId, pub email: String, // Email being verified (lowercased, unique) - pub code: String, // 6-digit verification code + pub code_hash: String, // SHA-256 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, @@ -432,8 +432,8 @@ pub struct EmailVerification { #[derive(Clone, Debug)] pub struct CreateEmailVerificationParams { pub email: String, // Email being verified (lowercased) - pub code: String, // 6-digit verification code - pub invite_token: String, // Invite token to consume on success + pub code_hash: String, // SHA-256 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 } @@ -1177,7 +1177,7 @@ mod tests { Ok(EmailVerification { id: EmailVerificationId(Uuid::new_v4()), email: params.email.clone(), - code: params.code.clone(), + code_hash: params.code_hash.clone(), invite_token: params.invite_token.clone(), attempts: 0, created_at: Utc::now(), diff --git a/crates/zopp-store-postgres/.sqlx/query-2dbbe181a67af9408456fae3da6581dcf7b02e9f911837df9704cbc15dce4095.json b/crates/zopp-store-postgres/.sqlx/query-a02edea0da760db52281d22369aad205cc7935dfea6c0ecf0bff2c30cd6fe2ee.json similarity index 77% rename from crates/zopp-store-postgres/.sqlx/query-2dbbe181a67af9408456fae3da6581dcf7b02e9f911837df9704cbc15dce4095.json rename to crates/zopp-store-postgres/.sqlx/query-a02edea0da760db52281d22369aad205cc7935dfea6c0ecf0bff2c30cd6fe2ee.json index fd9ee021..7be7670a 100644 --- a/crates/zopp-store-postgres/.sqlx/query-2dbbe181a67af9408456fae3da6581dcf7b02e9f911837df9704cbc15dce4095.json +++ b/crates/zopp-store-postgres/.sqlx/query-a02edea0da760db52281d22369aad205cc7935dfea6c0ecf0bff2c30cd6fe2ee.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, email, code, invite_token, attempts, created_at, expires_at\n FROM email_verifications\n WHERE email = $1", + "query": "SELECT id, email, code_hash, invite_token, attempts, created_at, expires_at\n FROM email_verifications\n WHERE email = $1", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "code", + "name": "code_hash", "type_info": "Text" }, { @@ -54,5 +54,5 @@ false ] }, - "hash": "2dbbe181a67af9408456fae3da6581dcf7b02e9f911837df9704cbc15dce4095" + "hash": "a02edea0da760db52281d22369aad205cc7935dfea6c0ecf0bff2c30cd6fe2ee" } diff --git a/crates/zopp-store-postgres/.sqlx/query-b75b1dae312512d71b77625a1032c9b94f2925fd48ae2b8c20a37236dc4b4b44.json b/crates/zopp-store-postgres/.sqlx/query-b5a6aeb9f1b5049260372030776069e4a57a1d746852f82dc15d1dcecb2a98e3.json similarity index 59% rename from crates/zopp-store-postgres/.sqlx/query-b75b1dae312512d71b77625a1032c9b94f2925fd48ae2b8c20a37236dc4b4b44.json rename to crates/zopp-store-postgres/.sqlx/query-b5a6aeb9f1b5049260372030776069e4a57a1d746852f82dc15d1dcecb2a98e3.json index eb717aa6..ff936973 100644 --- a/crates/zopp-store-postgres/.sqlx/query-b75b1dae312512d71b77625a1032c9b94f2925fd48ae2b8c20a37236dc4b4b44.json +++ b/crates/zopp-store-postgres/.sqlx/query-b5a6aeb9f1b5049260372030776069e4a57a1d746852f82dc15d1dcecb2a98e3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO email_verifications(id, email, code, invite_token, expires_at)\n VALUES($1, $2, $3, $4, $5)\n ON CONFLICT (email) DO UPDATE SET\n id = EXCLUDED.id,\n code = EXCLUDED.code,\n invite_token = EXCLUDED.invite_token,\n expires_at = EXCLUDED.expires_at,\n attempts = 0,\n created_at = NOW()\n RETURNING id, email, code, invite_token, attempts, created_at, expires_at", + "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": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "code", + "name": "code_hash", "type_info": "Text" }, { @@ -58,5 +58,5 @@ false ] }, - "hash": "b75b1dae312512d71b77625a1032c9b94f2925fd48ae2b8c20a37236dc4b4b44" + "hash": "b5a6aeb9f1b5049260372030776069e4a57a1d746852f82dc15d1dcecb2a98e3" } diff --git a/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql b/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql index d69ea96c..9698b2b4 100644 --- a/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql +++ b/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql @@ -16,7 +16,7 @@ ALTER TABLE invites ADD COLUMN consumed BOOLEAN NOT NULL DEFAULT FALSE; CREATE TABLE IF NOT EXISTS email_verifications ( id UUID PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE, -- Email being verified (lowercased, unique) - code TEXT NOT NULL, -- 6-digit verification code + code_hash TEXT NOT NULL, -- SHA-256 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(), diff --git a/crates/zopp-store-postgres/src/lib.rs b/crates/zopp-store-postgres/src/lib.rs index efb803cd..b9f0e998 100644 --- a/crates/zopp-store-postgres/src/lib.rs +++ b/crates/zopp-store-postgres/src/lib.rs @@ -611,19 +611,19 @@ impl Store for PostgresStore { // 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, invite_token, expires_at) + 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 = EXCLUDED.code, + code_hash = EXCLUDED.code_hash, invite_token = EXCLUDED.invite_token, expires_at = EXCLUDED.expires_at, attempts = 0, created_at = NOW() - RETURNING id, email, code, invite_token, attempts, created_at, expires_at"#, + RETURNING id, email, code_hash, invite_token, attempts, created_at, expires_at"#, id, email, - params.code, + params.code_hash, params.invite_token, params.expires_at ) @@ -634,7 +634,7 @@ impl Store for PostgresStore { Ok(EmailVerification { id: EmailVerificationId(row.id), email: row.email, - code: row.code, + code_hash: row.code_hash, invite_token: row.invite_token, attempts: row.attempts, created_at: row.created_at, @@ -646,7 +646,7 @@ impl Store for PostgresStore { 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, invite_token, attempts, created_at, expires_at + r#"SELECT id, email, code_hash, invite_token, attempts, created_at, expires_at FROM email_verifications WHERE email = $1"#, email_lower @@ -659,7 +659,7 @@ impl Store for PostgresStore { Ok(EmailVerification { id: EmailVerificationId(row.id), email: row.email, - code: row.code, + code_hash: row.code_hash, invite_token: row.invite_token, attempts: row.attempts, created_at: row.created_at, diff --git a/crates/zopp-store-sqlite/.sqlx/query-f34c7ab913fc98c5dff31e7cf8f9067d0259ed1e9697ccb2982b830b30bc24f7.json b/crates/zopp-store-sqlite/.sqlx/query-8f2351064450a6136d5835fd545e4d98d9d2f26dfa7c8b8d03423b5b72f6074c.json similarity index 58% rename from crates/zopp-store-sqlite/.sqlx/query-f34c7ab913fc98c5dff31e7cf8f9067d0259ed1e9697ccb2982b830b30bc24f7.json rename to crates/zopp-store-sqlite/.sqlx/query-8f2351064450a6136d5835fd545e4d98d9d2f26dfa7c8b8d03423b5b72f6074c.json index 4a9d9ca4..71613e48 100644 --- a/crates/zopp-store-sqlite/.sqlx/query-f34c7ab913fc98c5dff31e7cf8f9067d0259ed1e9697ccb2982b830b30bc24f7.json +++ b/crates/zopp-store-sqlite/.sqlx/query-8f2351064450a6136d5835fd545e4d98d9d2f26dfa7c8b8d03423b5b72f6074c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT OR REPLACE INTO email_verifications(id, email, code, invite_token, expires_at) VALUES(?, ?, ?, ?, ?)", + "query": "INSERT OR REPLACE INTO email_verifications(id, email, code_hash, invite_token, expires_at) VALUES(?, ?, ?, ?, ?)", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "f34c7ab913fc98c5dff31e7cf8f9067d0259ed1e9697ccb2982b830b30bc24f7" + "hash": "8f2351064450a6136d5835fd545e4d98d9d2f26dfa7c8b8d03423b5b72f6074c" } diff --git a/crates/zopp-store-sqlite/.sqlx/query-f3a0db47f07176db3474f0e28b914d0301e3f7e2726683b008c668eafc7b0c8e.json b/crates/zopp-store-sqlite/.sqlx/query-a4fdca8cd0f9cb6c6586725f6185992eee13b46b857db3ceea73f64c92d9b2aa.json similarity index 70% rename from crates/zopp-store-sqlite/.sqlx/query-f3a0db47f07176db3474f0e28b914d0301e3f7e2726683b008c668eafc7b0c8e.json rename to crates/zopp-store-sqlite/.sqlx/query-a4fdca8cd0f9cb6c6586725f6185992eee13b46b857db3ceea73f64c92d9b2aa.json index f399008a..9aeab57f 100644 --- a/crates/zopp-store-sqlite/.sqlx/query-f3a0db47f07176db3474f0e28b914d0301e3f7e2726683b008c668eafc7b0c8e.json +++ b/crates/zopp-store-sqlite/.sqlx/query-a4fdca8cd0f9cb6c6586725f6185992eee13b46b857db3ceea73f64c92d9b2aa.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, email, code, invite_token, attempts,\n created_at as \"created_at: DateTime\",\n expires_at as \"expires_at: DateTime\"\n FROM email_verifications\n WHERE email = ?", + "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": [ { @@ -14,7 +14,7 @@ "type_info": "Text" }, { - "name": "code", + "name": "code_hash", "ordinal": 2, "type_info": "Text" }, @@ -52,5 +52,5 @@ false ] }, - "hash": "f3a0db47f07176db3474f0e28b914d0301e3f7e2726683b008c668eafc7b0c8e" + "hash": "a4fdca8cd0f9cb6c6586725f6185992eee13b46b857db3ceea73f64c92d9b2aa" } diff --git a/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql b/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql index 0e88aa81..3e864827 100644 --- a/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql +++ b/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql @@ -15,7 +15,7 @@ ALTER TABLE invites ADD COLUMN consumed INTEGER NOT NULL DEFAULT 0; 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 TEXT NOT NULL, -- 6-digit verification code + code_hash TEXT NOT NULL, -- SHA-256 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')), diff --git a/crates/zopp-store-sqlite/src/lib.rs b/crates/zopp-store-sqlite/src/lib.rs index 95c4f029..a5ba3ca6 100644 --- a/crates/zopp-store-sqlite/src/lib.rs +++ b/crates/zopp-store-sqlite/src/lib.rs @@ -773,11 +773,12 @@ impl Store for SqliteStore { 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, invite_token, expires_at) VALUES(?, ?, ?, ?, ?)", + "INSERT OR REPLACE INTO email_verifications(id, email, code_hash, invite_token, expires_at) VALUES(?, ?, ?, ?, ?)", id_str, email, - params.code, + params.code_hash, params.invite_token, params.expires_at ) @@ -788,7 +789,7 @@ impl Store for SqliteStore { Ok(EmailVerification { id: EmailVerificationId(id), email, - code: params.code.clone(), + code_hash: params.code_hash.clone(), invite_token: params.invite_token.clone(), attempts: 0, created_at: Utc::now(), @@ -800,7 +801,7 @@ impl Store for SqliteStore { 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, invite_token, attempts, + r#"SELECT id, email, code_hash, invite_token, attempts, created_at as "created_at: DateTime", expires_at as "expires_at: DateTime" FROM email_verifications @@ -819,7 +820,7 @@ impl Store for SqliteStore { Ok(EmailVerification { id: EmailVerificationId(id), email: row.email, - code: row.code, + code_hash: row.code_hash, invite_token: row.invite_token, attempts: row.attempts as i32, created_at: row.created_at, @@ -4305,14 +4306,18 @@ mod tests { let params = zopp_storage::CreateEmailVerificationParams { email: "Test@Example.com".to_string(), - code: "123456".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, "123456"); + assert_eq!( + verification.code_hash, + "e150a1ec81e8e93e1eae2c3a77e66ec6dbd6a3b460f89c1d08aecf422ee401a0" + ); assert_eq!(verification.invite_token, "test-token"); assert_eq!(verification.attempts, 0); @@ -4340,7 +4345,8 @@ mod tests { let params = zopp_storage::CreateEmailVerificationParams { email: "test@example.com".to_string(), - code: "123456".to_string(), + code_hash: "e150a1ec81e8e93e1eae2c3a77e66ec6dbd6a3b460f89c1d08aecf422ee401a0" + .to_string(), // SHA256("123456") invite_token: "test-token".to_string(), expires_at: chrono::Utc::now() + chrono::Duration::minutes(15), }; @@ -4372,7 +4378,8 @@ mod tests { let params = zopp_storage::CreateEmailVerificationParams { email: "test@example.com".to_string(), - code: "123456".to_string(), + code_hash: "e150a1ec81e8e93e1eae2c3a77e66ec6dbd6a3b460f89c1d08aecf422ee401a0" + .to_string(), // SHA256("123456") invite_token: "test-token".to_string(), expires_at: chrono::Utc::now() + chrono::Duration::minutes(15), }; @@ -4406,7 +4413,7 @@ mod tests { // Create an expired verification let expired_params = zopp_storage::CreateEmailVerificationParams { email: "expired@example.com".to_string(), - code: "111111".to_string(), + code_hash: "hash111111".to_string(), invite_token: "expired-token".to_string(), expires_at: chrono::Utc::now() - chrono::Duration::minutes(1), // Already expired }; @@ -4415,7 +4422,7 @@ mod tests { // Create a valid verification let valid_params = zopp_storage::CreateEmailVerificationParams { email: "valid@example.com".to_string(), - code: "222222".to_string(), + code_hash: "hash222222".to_string(), invite_token: "valid-token".to_string(), expires_at: chrono::Utc::now() + chrono::Duration::minutes(15), }; @@ -4483,7 +4490,7 @@ mod tests { // Create first verification let params1 = zopp_storage::CreateEmailVerificationParams { email: "test@example.com".to_string(), - code: "111111".to_string(), + code_hash: "hash111111".to_string(), invite_token: "token1".to_string(), expires_at: chrono::Utc::now() + chrono::Duration::minutes(15), }; @@ -4492,16 +4499,16 @@ mod tests { // Create second verification for same email - should upsert let params2 = zopp_storage::CreateEmailVerificationParams { email: "test@example.com".to_string(), - code: "222222".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 + // 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, "222222"); + assert_eq!(got.code_hash, "hash222222"); assert_eq!(got.invite_token, "token2"); } } From d9e309a705cb58d7af748a31c3cfc6ffa74fc0b2 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Sun, 25 Jan 2026 19:53:59 -0300 Subject: [PATCH 25/25] Fix Cubic review issues: update comments and error handling - Update comments from 'SHA-256 hash' to 'Argon2id hash' in storage structs - Update migration comments to reflect Argon2id usage - Fix auth.rs to properly handle storage errors vs NotFound when checking for existing verification records --- apps/zopp-server/src/handlers/auth.rs | 11 ++++++++++- crates/zopp-storage/src/lib.rs | 4 ++-- .../20260125000001_add_email_verification.sql | 2 +- .../20260125000001_add_email_verification.sql | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/zopp-server/src/handlers/auth.rs b/apps/zopp-server/src/handlers/auth.rs index e08f7c6c..e9c334c0 100644 --- a/apps/zopp-server/src/handlers/auth.rs +++ b/apps/zopp-server/src/handlers/auth.rs @@ -98,7 +98,16 @@ pub async fn join( // 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 = server.store.get_email_verification(&email).await.ok(); + 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 diff --git a/crates/zopp-storage/src/lib.rs b/crates/zopp-storage/src/lib.rs index 8629f08e..f11f30ae 100644 --- a/crates/zopp-storage/src/lib.rs +++ b/crates/zopp-storage/src/lib.rs @@ -421,7 +421,7 @@ pub struct CreatePrincipalExportParams { pub struct EmailVerification { pub id: EmailVerificationId, pub email: String, // Email being verified (lowercased, unique) - pub code_hash: String, // SHA-256 hash of verification code (hex-encoded, zero-knowledge) + 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, @@ -432,7 +432,7 @@ pub struct EmailVerification { #[derive(Clone, Debug)] pub struct CreateEmailVerificationParams { pub email: String, // Email being verified (lowercased) - pub code_hash: String, // SHA-256 hash of verification code (hex-encoded, zero-knowledge) + 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 } diff --git a/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql b/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql index 9698b2b4..5dd507a1 100644 --- a/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql +++ b/crates/zopp-store-postgres/migrations/20260125000001_add_email_verification.sql @@ -16,7 +16,7 @@ ALTER TABLE invites ADD COLUMN consumed BOOLEAN NOT NULL DEFAULT FALSE; 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, -- SHA-256 hash of verification code (zero-knowledge) + 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(), diff --git a/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql b/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql index 3e864827..44483bd6 100644 --- a/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql +++ b/crates/zopp-store-sqlite/migrations/20260125000001_add_email_verification.sql @@ -15,7 +15,7 @@ ALTER TABLE invites ADD COLUMN consumed INTEGER NOT NULL DEFAULT 0; 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, -- SHA-256 hash of verification code (zero-knowledge) + 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')),