BE-444: Fix email verification failing for uppercase signups#8901
BE-444: Fix email verification failing for uppercase signups#8901TimDiekmann wants to merge 8 commits into
Conversation
Signing up with a mixed-case email (e.g. `T@HASH.ai`) left the user stuck on the verification screen: the code was consumed but the account never unlocked. The cause was case-sensitive comparisons between Kratos `verifiable_addresses[].value` and a normalized email source, duplicated across frontend and backend. Introduce a single shared `normalizeEmail` helper (`@local/hash-isomorphic-utils/normalize`) and apply it at every comparison boundary (FE verification gate, allowlist, invitations, cleanup job). Normalize `user.emails` at the single `getUserFromEntity` funnel. Also removes the dead `req.primaryEmailVerified` machinery and adds unit + e2e coverage.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #8901 +/- ##
==========================================
+ Coverage 59.25% 59.38% +0.12%
==========================================
Files 1347 1347
Lines 131082 131077 -5
Branches 5946 5945 -1
==========================================
+ Hits 77677 77842 +165
+ Misses 52499 52325 -174
- Partials 906 910 +4
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The new cleanup-job unit test transitively imports `@local/hash-backend-utils` (and other workspace packages), which export from built `dist/`. `test:unit` only depended on `codegen`, so in CI those deps were never built and the import failed (`Cannot find package '@local/hash-backend-utils/error'`). This was a latent gap: no prior unit test crossed a package boundary into a dist-exported `@local` dependency. Adding `^build` builds upstream deps first (turbo-cached).
PR SummaryMedium Risk Overview Frontend normalizes both sides when deciding if the primary email is verified ( Removes unused Reviewed by Cursor Bugbot for commit 893fafc. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Pull request overview
Fixes a signup/verification edge case where mixed/uppercased emails could leave users stuck in the verification flow due to case-sensitive comparisons between Kratos trait emails and verifiable_addresses[].value. The PR introduces a shared normalization helper and applies it across the frontend verification gate and backend identity/email handling paths (including the cleanup job), plus adds regression tests.
Changes:
- Add shared
normalizeEmail(email) => email.trim().toLowerCase()helper in@local/hash-isomorphic-utilsand use it to make email comparisons case-insensitive across frontend + backend. - Harden backend identity/email handling (Kratos verified-email extraction, allowlist parsing, and
getUserFromEntitycanonicalization) and remove unusedreq.primaryEmailVerifiedplumbing. - Add unit + e2e coverage for uppercase signup verification and for the cleanup-job “verified despite casing mismatch” guard.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| turbo.json | Ensure unit tests depend on upstream builds (and codegen), supporting cross-package imports like the new shared helper. |
| tests/hash-playwright/tests/shared/test-users.ts | Add a new allowlisted test user for uppercase-email signup coverage. |
| tests/hash-playwright/tests/shared/get-kratos-verification-code.ts | Match Mailslurper recipient addresses case-insensitively when polling for Kratos codes. |
| tests/hash-playwright/tests/account/signup.spec.ts | Add an e2e regression test for signup with an uppercased email address. |
| libs/@local/hash-isomorphic-utils/src/normalize.ts | Introduce shared normalizeEmail helper used by frontend and backend for comparisons. |
| libs/@local/hash-isomorphic-utils/src/normalize.test.ts | Add unit tests pinning the normalizeEmail contract (trim + lowercase only). |
| apps/hash-frontend/src/pages/_app.page.tsx | Normalize email values when determining primary email verification status from Kratos session data. |
| apps/hash-frontend/src/lib/user-and-org.ts | Normalize email comparisons when deriving isPrimaryEmailAddressVerified. |
| apps/hash-api/src/shared/user-has-access-to-hash.ts | Normalize parsed allowlist entries to match canonicalized user.emails. |
| apps/hash-api/src/graph/knowledge/system-types/user.ts | Canonicalize User.emails at the single entity-to-User construction point; normalize email in Kratos identity lookup checks. |
| apps/hash-api/src/express.d.ts | Remove unused Request.primaryEmailVerified augmentation. |
| apps/hash-api/src/auth/ory-kratos.ts | Normalize verified email values extracted from Kratos identities. |
| apps/hash-api/src/auth/create-unverified-email-cleanup-job.ts | Make primary-email verification detection case-insensitive via shared verified-email extraction + normalization. |
| apps/hash-api/src/auth/create-unverified-email-cleanup-job.test.ts | Add unit tests guarding against erroneous cleanup of verified mixed-case identities. |
| apps/hash-api/src/auth/create-auth-handlers.ts | Remove dead computation/propagation of primaryEmailVerified and the unused verification check call. |
| .env.test | Add the new uppercase-signup test email to the allowlist for test runs. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
alex-e-leon
left a comment
There was a problem hiding this comment.
A few minor comments, but LGTM otherwise - might still be worth getting a review from someone more familiar with this code though.
- getUserFromEntity: model the email property's real nullability (property-level masking can drop it for non-owners) instead of coercing the result type + disabling the lint rule. - get-kratos-verification-code: reuse the shared `normalizeEmail` helper instead of an inline `.toLowerCase()`.
Co-authored-by: Ciaran Morinan <37743469+CiaranMn@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 3b52014. Configure here.
Drives the real invite/accept resolvers (not a reimplementation) to prove a user who signed up with a mixed-case email can be found, invited by a differently-cased email, and accept the invitation to become an org member.
| @@ -0,0 +1,201 @@ | |||
| import { beforeAll, describe, expect, it } from "vitest"; | |||
| return async () => { | ||
| await deleteKratosIdentity({ | ||
| kratosIdentityId: invitee.kratosIdentityId, | ||
| }); | ||
| await deleteKratosIdentity({ | ||
| kratosIdentityId: inviter.kratosIdentityId, | ||
| }); | ||
| await resetGraph(); | ||
| }; | ||
| }); |
Benchmark results
|
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2002 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 1001 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 3314 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 1526 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 2078 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 1033 | Flame Graph |
policy_resolution_medium
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 102 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 51 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 269 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 107 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 133 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 63 | Flame Graph |
policy_resolution_none
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 2 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 8 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 3 | Flame Graph |
policy_resolution_small
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| resolve_policies_for_actor | user: empty, selectivity: high, policies: 52 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: empty, selectivity: medium, policies: 25 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: high, policies: 94 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: seeded, selectivity: medium, policies: 26 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: high, policies: 66 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: low, policies: 1 | Flame Graph | |
| resolve_policies_for_actor | user: system, selectivity: medium, policies: 29 | Flame Graph |
read_scaling_complete
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id;one_depth | 1 entities | Flame Graph | |
| entity_by_id;one_depth | 10 entities | Flame Graph | |
| entity_by_id;one_depth | 25 entities | Flame Graph | |
| entity_by_id;one_depth | 5 entities | Flame Graph | |
| entity_by_id;one_depth | 50 entities | Flame Graph | |
| entity_by_id;two_depth | 1 entities | Flame Graph | |
| entity_by_id;two_depth | 10 entities | Flame Graph | |
| entity_by_id;two_depth | 25 entities | Flame Graph | |
| entity_by_id;two_depth | 5 entities | Flame Graph | |
| entity_by_id;two_depth | 50 entities | Flame Graph | |
| entity_by_id;zero_depth | 1 entities | Flame Graph | |
| entity_by_id;zero_depth | 10 entities | Flame Graph | |
| entity_by_id;zero_depth | 25 entities | Flame Graph | |
| entity_by_id;zero_depth | 5 entities | Flame Graph | |
| entity_by_id;zero_depth | 50 entities | Flame Graph |
read_scaling_linkless
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | 1 entities | Flame Graph | |
| entity_by_id | 10 entities | Flame Graph | |
| entity_by_id | 100 entities | Flame Graph | |
| entity_by_id | 1000 entities | Flame Graph | |
| entity_by_id | 10000 entities | Flame Graph |
representative_read_entity
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/block/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/playlist/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1
|
Flame Graph | |
| entity_by_id | entity type ID: https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1
|
Flame Graph |
representative_read_entity_type
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| get_entity_type_by_id | Account ID: bf5a9ef5-dc3b-43cf-a291-6210c0321eba
|
Flame Graph |
representative_read_multiple_entities
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| entity_by_property | traversal_paths=0 | 0 | |
| entity_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| entity_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=0 | 0 | |
| link_by_source_by_property | traversal_paths=255 | 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true | |
| link_by_source_by_property | traversal_paths=2 | 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true |
scenarios
| Function | Value | Mean | Flame graphs |
|---|---|---|---|
| full_test | query-limited | Flame Graph | |
| full_test | query-unlimited | Flame Graph | |
| linked_queries | query-limited | Flame Graph | |
| linked_queries | query-unlimited | Flame Graph |

🌟 What is the purpose of this PR?
Signing up with a mixed-case email (e.g.
T@HASH.ai) left the user stuck on the email-verification screen: Kratos delivered the code to the lowercased address and accepted it (consuming it), but the account never unlocked. The cause was case-sensitive email comparisons between Kratosverifiable_addresses[].value(the casing typed at signup) and a normalized/lowercased source — and that comparison logic was duplicated across the frontend and backend and had drifted.This PR makes all email comparisons case-insensitive via a single shared helper, fixing the reported signup flow and several adjacent latent bugs of the same class.
🔗 Related links
🔍 What does this change?
normalizeEmailhelper (@local/hash-isomorphic-utils/normalize,.trim().toLowerCase()) imported by both frontend and backend, so the comparison logic can no longer drift.getPrimaryEmailVerificationStatus(_app.page.tsx) andisPrimaryEmailAddressVerified(user-and-org.ts). This is what unblocks the stuck verification screen.getUserFromEntityfunnel and ingetVerifiedEmailsFromKratosIdentity, the allowlist on parse (user-has-access-to-hash.ts), andcheckEmailVerificationAndUsageStatus.isPrimaryEmailVerifiednow compares case-insensitively — previously a verified mixed-case account could be classed as unverified and archived + its Kratos identity deleted (data loss).req.primaryEmailVerifiedmachinery (computed increate-auth-handlers, never read anywhere;express.d.tsfield included).Pre-Merge Checklist 🚀
🚢 Has this modified a publishable library?
This PR:
📜 Does this require a change to the docs?
The changes in this PR:
🕸️ Does this require a change to the Turbo Graph?
The changes in this PR:
getUserFromEntity, the Kratos read helpers) rather than on write — the user entity still stores the email in its original casing. This is safe because every comparison path now normalizes (TS vianormalizeEmail, and the graph admin delete-by-email already lowercases both sides in SQL). Flagged so a future exact-match graph filter against the storedemailproperty doesn't get caught out.🐾 Next steps
createUser(would also cover any future stored-value exact-match query).🛡 What tests cover this?
signup.spec.ts): registers with an uppercased allowlisted email, verifies via Mailslurper, completes signup. Demonstrably failed pre-fix (stuck at the verify gate, then at completion).normalize.test.ts): pins thenormalizeEmailcontract (trim + lowercase, idempotent, no local-part canonicalization).create-unverified-email-cleanup-job.test.ts): guards the data-loss path — a verified mixed-case identity is recognized as verified.❓ How to test this?
Foo@Example.com.Automated:
turbo run test:unit --filter '@local/hash-isomorphic-utils' --filter '@apps/hash-api'and the Playwrightsignupspec onaccount-chromium.📹 Demo
n/a — backend + frontend logic fix; covered by the e2e regression test above.