feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 10m34s

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:
2026-08-29 01:53:50 -05:00
parent c2e4df5b3d
commit fbb4b0bd91
44 changed files with 3024 additions and 59 deletions

View File

@@ -0,0 +1,87 @@
// Engagement Phase 1b — telling the two unique constraints on `users` apart.
//
// This is the piece the whole phase rests on: `users` grew a second unique index,
// and until Phase 1b the duplicate-key test could not tell which one fired. Every
// call site that creates or updates a user branches on these predicates, so a
// wrong answer here means a duplicate email reported as a taken username, an SSO
// sign-up retrying usernames against a conflict no username can clear, or an
// opaque 500 on the admin user form.
//
// The error strings below are VERBATIM from MariaDB 11.8 through the mariadb Node
// connector, captured against a real duplicate insert. The key name lives only in
// the message text — the driver exposes no structured field for it — which is
// exactly why this needs its own test: it is parsing, and parsing rots silently.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const users = require('../src/model/users/users.model')
const db = require('../src/utils/db')
after(() => db.close())
// Shaped exactly as the connector delivers them, including the trailing `sql:`
// section — which is also the reason these must never be echoed to a client: note
// the bound parameters, and therefore the address, are in the text.
function dupError(key, value) {
const err = new Error(
`(conn:60, no: 1062, SQLState: 23000) Duplicate entry '${value}' for key '${key}'\n` +
`sql: INSERT INTO users (username, email) VALUES (?, ?) - parameters:['someone','${value}']`,
)
err.code = 'ER_DUP_ENTRY'
err.errno = 1062
err.sqlState = '23000'
err.sqlMessage = `Duplicate entry '${value}' for key '${key}'`
return err
}
test('an email collision is reported as email, not username', () => {
const err = dupError('uq_users_email_norm', 'taken@example.com')
assert.equal(users.isDuplicateEmail(err), true)
assert.equal(users.isDuplicateUsername(err), false, 'must NOT masquerade as a username collision')
assert.equal(users.duplicateKey(err), 'uq_users_email_norm')
})
test('a username collision is still reported as username', () => {
const err = dupError('username', 'someone')
assert.equal(users.isDuplicateUsername(err), true)
assert.equal(users.isDuplicateEmail(err), false)
assert.equal(users.duplicateKey(err), 'username')
})
// The permissive fallback is deliberate. Only the case we can positively identify
// — email — is carved out; anything else keeps the pre-Phase-1b behaviour so no
// call site newly falls through to a 500 on a database whose index carries an
// unexpected name.
test('an unrecognised unique index keeps the old permissive behaviour', () => {
const err = dupError('some_other_uq', 'x')
assert.equal(users.isDuplicateUsername(err), true)
assert.equal(users.isDuplicateEmail(err), false)
})
test('a duplicate-key error the message does not name is treated as username', () => {
const err = new Error('Duplicate entry - no key clause here')
err.code = 'ER_DUP_ENTRY'
err.errno = 1062
assert.equal(users.duplicateKey(err), null)
assert.equal(users.isDuplicateUsername(err), true)
assert.equal(users.isDuplicateEmail(err), false)
})
test('non-duplicate errors are neither', () => {
for (const err of [null, undefined, new Error('boom'), { code: 'ER_NO_SUCH_TABLE' }]) {
assert.equal(users.isDuplicateUsername(err), false)
assert.equal(users.isDuplicateEmail(err), false)
assert.equal(users.duplicateKey(err), null)
}
})
// errno alone, with no `code`, is how some driver paths surface it.
test('errno 1062 without a code still counts', () => {
const err = new Error("Duplicate entry 'a@b.com' for key 'uq_users_email_norm'")
err.errno = 1062
assert.equal(users.isDuplicateEmail(err), true)
assert.equal(users.isDuplicateUsername(err), false)
})