The design has claimed since migration 0010 that at most one account can hold a PROVEN email address, with ErrEmailTaken as the 409 a second verifier sees. Neither half ever shipped: no migration created users_verified_email_unique, and VerifyEmailOTP had no guard at all — the sentinel was defined but never returned, so two accounts could both verify one address. The damage is not cosmetic: the pre-session login resolves accounts BY verified email, so the duplicate decided which identity a mailed sign-in code belonged to. - Migration 0020 creates the partial unique index (lower(email) WHERE email_verified) the comments have been citing — the database-level backstop. - VerifyEmailOTP now refuses the take-over with ErrEmailTaken BEFORE consuming the code (the address, not the code, is the problem), charges no attempt, and maps a lost cross-user race (unique violation) to the same answer. - The verify handler answers 409 email_taken instead of a generic 500. Covered by the pgint suite (sequential double-verify refused with the code still live, a direct duplicate write still loses to the index, the refused account can still prove its own address) and a hermetic 409 case.
16 lines
1000 B
SQL
16 lines
1000 B
SQL
-- The verified-email uniqueness that the email-first login design has claimed
|
|
-- since 0010 but no migration ever actually created: at most one account may
|
|
-- hold a PROVEN address (email_verified), compared case-insensitively, so the
|
|
-- pre-session login door (UserByEmail on lower(email)) resolves at most one
|
|
-- identity. VerifyEmailOTP's ErrEmailTaken guard is the application-level
|
|
-- counterpart; this index is the database-level backstop behind it, so a
|
|
-- cross-user race that passes the guard still cannot write a second verified
|
|
-- row (it loses to a unique violation instead).
|
|
--
|
|
-- If an upgrade meets a database where the missing guard already produced two
|
|
-- verified rows for one address, CREATE UNIQUE INDEX refuses to build and names
|
|
-- the duplicated key in DETAIL. That is deliberate — no silent "unverify the
|
|
-- loser" surgery: only a human can say which holder keeps the address.
|
|
CREATE UNIQUE INDEX users_verified_email_unique ON users (lower(email))
|
|
WHERE email_verified;
|