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

@@ -499,6 +499,19 @@ const doc = {
username: { type: 'string', example: 'newplayer' },
role: { type: 'string', enum: ['admin', 'editor', 'moderator', 'player'], example: 'player' },
email: { type: 'string', format: 'email', nullable: true, example: 'player@example.com' },
email_verified: {
type: 'boolean',
description: 'Whether the address above has been proved by opening a confirmation link.',
example: true,
},
email_pending: {
type: 'string',
format: 'email',
nullable: true,
description:
'An address the user has requested but not yet confirmed. It does NOT replace `email` until the confirmation link is used.',
example: null,
},
status: { type: 'string', enum: ['active', 'disabled', 'banned', 'pending'], example: 'active' },
totp_enabled: { type: 'boolean', example: false },
has_password: {
@@ -508,6 +521,53 @@ const doc = {
},
},
},
// Engagement Phase 1b — the change-and-verify flow.
ChangeEmailRequest: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email', maxLength: 255, example: 'new@example.com' },
currentPassword: {
type: 'string',
format: 'password',
description:
'Required when the account has a password. An address is where account recovery lands, so changing it is re-authenticated; an SSO-provisioned account with no password is exempt.',
},
},
},
PendingEmail: {
type: 'object',
description:
'The address now awaiting confirmation. The account keeps its existing address until the emailed link is used.',
properties: {
email_pending: { type: 'string', format: 'email', example: 'new@example.com' },
emailed: {
type: 'boolean',
description: 'False when outbound email is not configured or the send failed; the address stays staged so a resend can succeed later.',
example: true,
},
reason: {
type: 'string',
nullable: true,
enum: ['NOT_CONFIGURED', 'SEND_FAILED', null],
description: 'Why nothing was sent, when `emailed` is false.',
example: null,
},
},
},
EmailDedupeEntry: {
type: 'object',
description:
'One account whose email address was cleared when addresses became unique, because an older account already held it.',
properties: {
id: { type: 'integer', example: 3 },
user_id: { type: 'integer', example: 42 },
username: { type: 'string', example: 'someplayer' },
lost_address: { type: 'string', format: 'email', example: 'shared@example.com' },
cleared_at: { type: 'string', format: 'date-time' },
acknowledged_at: { type: 'string', format: 'date-time', nullable: true },
},
},
OkFlag: {
type: 'object',
properties: { ok: { type: 'boolean', example: true } },