feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b) #167
Reference in New Issue
Block a user
No description provided.
Delete Branch "feature/unique-verifiable-email"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Engagement Phase 1b — design of record:
docs/website/ENGAGEMENT.mdPhase 1b (companion PR). Lands alone, between Phase 1 and Phase 2, and before any engagement mail exists — retrofitting uniqueness after a send log and a suppression list hold rows is strictly worse.Discharges §0.6.
The plan's index design was wrong, and would have destroyed data
Step 2 said to pin the UNIQUE index to a case-insensitive collation "for the same reason
usernamewas". The reasoning is right —Foo@x.comandfoo@x.comare one mailbox, and folding belongs in the index rather than in bypassable application code. The collation is not.Tested against the deployment's own MariaDB 11.8: under both
utf8mb4_general_ciand the server-defaultutf8mb4_uca1400_ai_ci,josé@x.comandjose@x.comcompare EQUAL. Every_cicollation available here is also accent-insensitive. Those are two different mailboxes. That index would refuse the second address forever, and the de-duplication below would have nulled a legitimate account's address and reported it as a duplicate that never was.The collations that would be exactly right (
utf8mb4_general1400_as_ci,utf8mb4_0900_as_ci) are MariaDB 11.4+ only, so pinning one just moves §0.6's "a UNIQUE email can stop a boot" to a different trigger.What shipped instead:
LOWER()under_binfolds case without folding accents — verified, not assumed. The fold still lives in the schema, which was the point of the original rule. Multiple NULLs stay legal, which is what lets the de-dupe clear an address without deleting an account. No FK referencesusers.email, so the STORED-generated-column trap from TEAMS.md phase 2 (ER_GENERATED_COLUMN_FUNCTION_IS_NOT_ALLOWEDonON DELETE SET NULL) does not apply.The same bug, one statement later
Written the obvious way, the de-dupe compares
LOWER(u2.email) = LOWER(u.email)— which uses the column's collation, and so over-folds even though the index does not. A seeded fixture caught it nullingjose@x.comas a "duplicate" ofjosé@x.com: the exact defect the index change was made to prevent, reintroduced immediately.So the migration adds
email_normbefore de-duplicating and groups on it — the two agree by construction rather than by a hand-matchedCOLLATEclause a later edit can get wrong. Order inschema.sqlis load-bearing and commented as such.What changed
Schema (all idempotent; after the first boot every statement matches zero rows):
UPDATE users SET email = NULL WHERE email = ''—''is a value, not an absence, so two accounts holding it would collide and stop the boot. Unreachable through today's routes; this runs against databases whose history we do not control.email_pending+email_norm— no index yet; a UNIQUE index here is precisely the ALTER that fails and takes the site down.onfresh /offupgrade.Telling the two constraints apart.
isDuplicateUsername()was a bareER_DUP_ENTRYtest. The violated index name is available only in the driver's message text — no structured field — so this reads it back out, with its own test. That message also embeds the bound parameters, so on an email collision it contains the address: a second reason these never reach a client.isDuplicateUsernameis now "duplicate and NOT email", so no call site newly falls through to a 500 on a database whose index carries an unexpected name.§0.6 named two call sites. There are five, and the three it omits fail worse:
auth.controllerregistersso.controllerprovisioninvite.controlleracceptadmin.controllercreateUseradmin.controllerupdateUserThe answers differ on purpose: a public form, an authenticated IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth.
Change-and-verify. A requested address is staged in
email_pending; only the tokened link installs it. The account keeps receiving password-reset mail at the address it already has, so a typo cannot silently redirect account recovery.currentPasswordrequired when the account has one, with the SSO carve-outchangePasswordalready makes.SSO now reads the IdP's actual
email_verified/verifiedclaim. Forward-only — existing rows keep their flag; retroactively demoting live users is the G22 mistake.Deliberate deviation
The plan says a "signed" link. Every comparable flow here (
user_invites,password_resets,mobile_refresh_tokens) uses an opaque random token with only its sha256 at rest.email_verificationsmatches them rather than introducing a second token mechanism for one caller.provisionSsoPlayernow returns{ user }or{ error }rather than the user or a bare null, and is exported for a test — the behaviour that matters is a count, not observable through the route handlers without stubbing most of the OAuth flow.Anti-enumeration, which is load-bearing
Confirming answers with the byte-identical 404 for: expired, already-used, superseded, and an address another account confirmed first. Distinguishing them would make the endpoint an oracle for which addresses hold accounts. There is a test per case asserting the same string.
Verification
1239 server tests, 288 client tests, all green. Swagger regenerated (6 paths added, 0 removed, 0 surviving path definitions changed — verified by set-comparison, not by reading the diff);
routes:manifest --checkclean at 210 routes.Walked on a live rig — real MariaDB 11.8 seeded with the pre-upgrade schema plus three accounts sharing an address, upgraded by booting the real server, with a real SMTP send into a mail catcher (which also discharges Phase 1's outstanding "no live SMTP send"):
josé@andjose@both survivedoffon the upgrade,onon a fresh installNEWMAIL@RIG.TESTrefused at registration as a duplicate ofnewmail@rig.test, whilenéwmail@rig.testregistered successfully beside it — which is the whole argument for the index change, demonstratedReviewer notes
email_verified. That gate governs opt-in engagement mail; applying it to account recovery would lock out every user carrying an address from before verification existed.email_verification_requiredyet (Phase 4/9 do). It is seeded now because the fresh-vs-upgrade distinction is only knowable at the migration that adds it.provisionSsoPlayercontradicts (§0.6 finding 3 records this). Out of this phase's scope — say the word and I will take it separately.docs/website/api-route-inventory.jsonis regenerated in the companion PR and is still ungated; it will drift again.AI disclosure
Written with Claude Code (Opus 5).