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

@@ -10,6 +10,7 @@ const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model'
const registries = require('../../../modules/registries')
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
const emailDedupe = require('../../../model/emailDedupe/emailDedupe.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const pushDispatch = require('../../../utils/pushDispatch')
const { cleanBody } = require('../../../utils/sanitizeHtml')
@@ -89,6 +90,60 @@ async function emailWarning() {
}
}
// Phase 1b — the de-duplication cleared some accounts' addresses so a UNIQUE
// index could go on (ENGAGEMENT.md Phase 1b / §0.6 finding 1). Same posture as
// the email warning above: narrow, self-clearing, and silent on the installs it
// does not concern.
//
// It has to be said out loud for the same reason G22 did. Nothing broke visibly —
// those users can still log in — but they can no longer receive password-reset or
// engagement mail, and they are the only people who can fix that, so somebody has
// to tell the operator to go and ask them.
//
// Never fails the dashboard.
async function emailDedupeWarning() {
try {
const n = await emailDedupe.countUnacknowledged()
if (!n) return null
return {
code: 'EMAIL_DEDUPE',
message:
`${n} account${n === 1 ? '' : 's'} shared an email address with another account and had it ` +
'cleared when addresses became unique. They can still sign in, but cannot receive password-reset ' +
'or notification email until they set a new address. Review who was affected and contact them.',
href: '/admin/users',
}
} catch (err) {
log.warn('dashboard email dedupe warning check failed', { message: err.message })
return null
}
}
// GET /admin/users/email-dedupe-report — who was cleared, and what they lost.
async function emailDedupeReport(req, res) {
try {
return res.json(await emailDedupe.list())
} catch (err) {
log.error('emailDedupeReport', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /admin/users/email-dedupe-report/acknowledge — dismiss the warning. The
// rows stay: the report is a record of what the upgrade did, and losing it would
// leave no way to answer "why does this user have no address?" later.
async function acknowledgeEmailDedupeReport(req, res) {
try {
const n = await emailDedupe.acknowledgeAll()
await activity.log({ req, action: 'admin.email_dedupe.acknowledge', detail: { count: n } })
log.info('email dedupe report acknowledged', { count: n, by: req.user.username })
return res.json({ ok: true, acknowledged: n })
} catch (err) {
log.error('acknowledgeEmailDedupeReport', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function dashboard(req, res) {
try {
return res.json({
@@ -101,7 +156,7 @@ async function dashboard(req, res) {
posts: await posts.counts(),
users: await users.count(),
},
warnings: [await emailWarning()].filter(Boolean),
warnings: [await emailWarning(), await emailDedupeWarning()].filter(Boolean),
recent_activity: await activity.list({ limit: 10 }),
})
} catch (err) {
@@ -562,6 +617,14 @@ async function updateSettings(req, res) {
// endpoint takes arbitrary keys either way, and an unrecognised value resolves
// to `disabled` on read — the module's gate fails closed, which is the right
// direction for "may this player mint a game account".
// Engagement Phase 1b verification gate: 'on'/'off' only, so a typo cannot land
// a value that reads as neither and silently resolves to off.
if (settings.EMAIL_VERIFICATION_KEY in updates) {
const v = updates[settings.EMAIL_VERIFICATION_KEY]
if (v !== 'on' && v !== 'off') {
return res.status(400).json({ message: 'Invalid email_verification_required value' })
}
}
// App Links toggle is a boolean stored as a 'true'/'false' string; accept a real
// boolean or those two strings and normalize, reject anything else.
if (settings.MOBILE_APP_LINKS_KEY in updates) {
@@ -847,13 +910,27 @@ async function createUser(req, res) {
if (await users.getRawByUsername(req.body.username)) {
return res.status(409).json({ message: 'Username already taken' })
}
const user = await users.createUser({
username: req.body.username,
password: req.body.password,
role: req.body.role || 'admin',
email: req.body.email || null,
status: req.body.status || 'active',
})
let user
try {
user = await users.createUser({
username: req.body.username,
password: req.body.password,
role: req.body.role || 'admin',
email: req.body.email || null,
status: req.body.status || 'active',
})
} catch (err) {
// Before Phase 1b this had no catch at all, so a duplicate address became
// an opaque 500 for an admin who could see nothing wrong with the form.
// An admin may be told the real reason: they can already list every account.
if (users.isDuplicateEmail(err)) {
return res.status(409).json({ message: 'Another account already uses that email address.' })
}
if (users.isDuplicateUsername(err)) {
return res.status(409).json({ message: 'Username already taken' })
}
throw err
}
await activity.log({
req,
action: 'user.create',
@@ -885,13 +962,25 @@ async function updateUser(req, res) {
return res.status(400).json({ message: 'Cannot demote the last admin' })
}
}
const user = await users.update(id, {
username: req.body.username,
password: req.body.password,
role: req.body.role,
email: req.body.email,
status: req.body.status,
})
let user
try {
user = await users.update(id, {
username: req.body.username,
password: req.body.password,
role: req.body.role,
email: req.body.email,
status: req.body.status,
})
} catch (err) {
// Same as createUser: an uncaught duplicate address was an opaque 500.
if (users.isDuplicateEmail(err)) {
return res.status(409).json({ message: 'Another account already uses that email address.' })
}
if (users.isDuplicateUsername(err)) {
return res.status(409).json({ message: 'Username already taken' })
}
throw err
}
await activity.log({ req, action: 'user.update', detail: { id } })
// Distinct audit trail for the security-sensitive fields (role & status),
// so a promotion/ban is greppable beyond the generic user.update entry.
@@ -1055,6 +1144,8 @@ module.exports = {
getUser,
createUser,
updateUser,
emailDedupeReport,
acknowledgeEmailDedupeReport,
deleteUser,
listUserTrustedDevices,
revokeUserTrustedDevice,