feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,20 @@ async function register(req, res) {
|
||||
role: 'player',
|
||||
})
|
||||
} catch (err) {
|
||||
// Two unique indexes, two different answers (§0.6 finding 2). Before Phase
|
||||
// 1b this branch caught both and told an email collision it was a username
|
||||
// one — the single field the user had NOT collided on.
|
||||
//
|
||||
// The email answer is deliberately generic and deliberately NOT scored: a
|
||||
// truthful "that address already has an account" makes account existence
|
||||
// queryable through a public form, and treating an honest typo on a
|
||||
// colleague's address as an attack would push a legitimate user toward an
|
||||
// IP ban. The real reason is logged and never returned — note the driver's
|
||||
// message embeds the address, which is a second reason it stays server-side.
|
||||
if (users.isDuplicateEmail(err)) {
|
||||
log.warn('register rejected: email already registered', { username: check.name, ip: req.ip })
|
||||
return res.status(400).json({ message: 'Registration failed. Please check your details and try again.' })
|
||||
}
|
||||
// The UNIQUE index is the source of truth for the uniqueness race — a
|
||||
// concurrent duplicate loses here and gets a clean 409.
|
||||
if (users.isDuplicateUsername(err)) {
|
||||
|
||||
Reference in New Issue
Block a user