docs(website): unique, changeable, verifiable email addresses (engagement Phase 1b)
Companion to website#<pr>. Records Phase 1b as built, and corrects two things
the plan got wrong before anyone builds on them.
ENGAGEMENT.md
- Phase 1b step 2 said to pin the index to a case-insensitive collation. Every
_ci collation MariaDB offers here is also accent-insensitive, so that index
would refuse jose@x.com once josé@x.com existed and the de-duplication would
have cleared a legitimate account's address. The as-built block records the
generated-column design that shipped instead, and the second-order version of
the same bug that a seeded fixture caught in the de-dupe query itself.
- §0.6 named two callers of isDuplicateUsername(). There are five, and the
three it omits fail worse than the two it names.
BACKEND_DESIGN.md — the users table (already stale: it predated the player
account work), plus email_verifications and email_dedupe_report, and the five
new routes.
UPGRADE_NOTES.md — an operator entry, because the de-duplication is the kind of
quiet change this file exists for: nothing breaks, and the affected users find
out the next time they try to reset a password.
api-route-inventory.json — regenerated from the manifest; still ungated.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,11 @@ server/
|
||||
token is its own authority, so it
|
||||
bypasses player_registration
|
||||
password.router.js (3) /auth/password/forgot + reset/:token
|
||||
emailVerify.router.js (2) /auth/email/verify/:token — public and
|
||||
token-gated like password.router: the
|
||||
link arrives in a mailbox, so the
|
||||
REQUEST half is at /auth/me/account/email
|
||||
and only the CONFIRM half is here
|
||||
session.router.js (2) POST /logout and GET /me — the two
|
||||
singletons owning no path segment, so
|
||||
mounted at the group root, LAST: the
|
||||
@@ -240,11 +245,43 @@ listed models/two utils. They're isolated in `middleware/` + one `activity` mode
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| username | VARCHAR(32) UNIQUE NOT NULL | |
|
||||
| password_hash | VARCHAR(72) NOT NULL | bcrypt; **never** returned by the API |
|
||||
| role | ENUM('admin','editor') NOT NULL DEFAULT 'admin' | room to grow |
|
||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
| last_login_at | DATETIME NULL | shown in user management |
|
||||
| username | VARCHAR(32) UNIQUE NOT NULL COLLATE utf8mb4_general_ci | the `_ci` collation is the case-insensitive uniqueness backstop |
|
||||
| password_hash | VARCHAR(72) **NULL** | bcrypt; **never** returned by the API. Nullable: an SSO-provisioned account has none until it sets one, and a NULL hash makes password login impossible |
|
||||
| role | ENUM('admin','editor','moderator','player') NOT NULL DEFAULT 'admin' | |
|
||||
| email | VARCHAR(255) NULL | the account's one contact address and the destination for password-reset mail. **Unique since engagement Phase 1b — but the index is on `email_norm`, never on this column** (below) |
|
||||
| email_norm | VARCHAR(255) COLLATE utf8mb4_bin **GENERATED** `AS (LOWER(email)) STORED`, UNIQUE | the uniqueness key. Every `_ci` collation MariaDB offers is also **accent**-insensitive, so a UNIQUE index on `email` would refuse `jose@x.com` once `josé@x.com` existed — two different mailboxes. `LOWER()` under `_bin` folds case without folding accents. Keeping the fold in a generated column rather than in application code means no caller can bypass it. Multiple NULLs stay legal, which is what lets the de-duplication clear an address without deleting an account |
|
||||
| email_verified | TINYINT(1) NOT NULL DEFAULT 0 | set only by opening a verification link (or by an invite, which proves the address by construction). SSO sets it from the IdP's actual `email_verified`/`verified` claim — **not** from the mere presence of an address, which is what it used to do |
|
||||
| email_pending | VARCHAR(255) NULL | an address requested but not yet proved. It does **not** displace `email`, so a mistyped address cannot silently redirect account-recovery mail. Deliberately **not** unique: a pending address reserves nothing, and the UNIQUE index above arbitrates at confirmation time |
|
||||
| status | ENUM('active','pending','disabled','banned') NOT NULL DEFAULT 'active' | lifecycle, independent of role; enforced in `requireAuth` + login |
|
||||
| totp_secret / totp_enabled | VARCHAR(64) NULL / TINYINT(1) | opt-in 2FA |
|
||||
| tokens_valid_after | DATETIME NULL | session-revocation cutoff; bumped on password change / "log out everywhere" |
|
||||
| created_at | DATETIME DEFAULT CURRENT_TIMESTAMP | also the **tie-break for de-duplication**: oldest account keeps a shared address |
|
||||
| last_login_at / last_login_ip | DATETIME NULL / VARCHAR(45) NULL | shown in user management |
|
||||
|
||||
### email_verifications *(engagement Phase 1b)*
|
||||
Same shape as `password_resets`, deliberately — an opaque random token whose **sha256 only** is stored, single-use, ~24h.
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| token_hash | CHAR(64) UNIQUE NOT NULL | sha256 of the opaque token; a DB read never yields a usable link |
|
||||
| user_id | INT NOT NULL FK→users(id) ON DELETE CASCADE | |
|
||||
| email | VARCHAR(255) NOT NULL | **the address this token proves.** On the row, not read from the user at confirm time: a token proves control of the address it was mailed to and nothing else, so a later request for a different address cannot be confirmed by an older link |
|
||||
| status | ENUM('pending','used') NOT NULL DEFAULT 'pending' | consumed atomically |
|
||||
| requested_ip | VARCHAR(64) NULL | audit only |
|
||||
| expires_at / created_at / used_at | DATETIME | |
|
||||
|
||||
### email_dedupe_report *(engagement Phase 1b)*
|
||||
Who lost an address when addresses became unique. Written by `schema.sql`'s migration in pure SQL — `ensureSchema()` runs that file statement-by-statement and there is no JS migration hook — and only ever read afterwards.
|
||||
| col | type | notes |
|
||||
|---|---|---|
|
||||
| id | INT PK AUTO_INCREMENT | |
|
||||
| user_id | INT NOT NULL, UNIQUE | the UNIQUE is what makes the migration's `INSERT IGNORE` strictly idempotent |
|
||||
| username | VARCHAR(32) NOT NULL | captured at clear time |
|
||||
| lost_address | VARCHAR(255) NOT NULL | the report is the only place this value survives |
|
||||
| cleared_at | DATETIME DEFAULT CURRENT_TIMESTAMP | |
|
||||
| acknowledged_at | DATETIME NULL | set when an admin dismisses the dashboard warning; rows are kept as the record of what the upgrade did |
|
||||
|
||||
No FK to `users`, on purpose — same reasoning as `posts.announce_job_id`: a constraint re-added on every boot is a constraint that can fail a boot, and this is a historical record rather than a live relation.
|
||||
|
||||
### posts — one table, four categories
|
||||
| col | type | notes |
|
||||
@@ -771,13 +808,18 @@ their own router level, and `/sso/:provider/link` carries `requireAuth` per rout
|
||||
| POST | `/login/totp` | — (rate-limited) | `{challenge, code? \| recoveryCode?, trustDevice?, deviceName?}` | complete 2FA with a TOTP **or** single-use recovery code. `trustDevice` sets the `rg_trust` cookie so future logins skip TOTP; at the device cap the session is still issued and the body carries `{trustLimitReached, devices}`. |
|
||||
| POST | `/logout` | cookie | — | clear cookie (the `rg_trust` trust cookie deliberately **survives** logout) |
|
||||
| GET | `/me` | cookie / bearer | — | current user (no hash) or 401 — client bootstraps auth state |
|
||||
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to **every active account** on the address; **always** returns the same generic 200 (no account enumeration). Email is non-unique, so several accounts may each get a link naming their username. Logs `account.password.reset.request`. |
|
||||
| POST | `/password/forgot` | — (rate-limited) | `{email}` | email a single-use, ~1h reset link to the active account on the address; **always** returns the same generic 200 (no account enumeration). Addresses are unique since Phase 1b, so this matches at most one account. Reset mail is deliberately **not** gated on `email_verified` — that gate governs opt-in engagement mail, and applying it to account recovery would lock out every user carrying an address from before verification existed. Logs `account.password.reset.request`. |
|
||||
| GET | `/password/reset/:token` | — | — | validate a link → `{username}` for the form, else 404 (never distinguishes expired/used/never-existed) |
|
||||
| POST | `/password/reset/:token` | — (rate-limited) | `{password}` | consume the single-use link, rotate the hash, and revoke **all** sessions (web cutoff + mobile refresh tokens). Does **not** sign the user in — they log in fresh (so a 2FA account still passes TOTP). Logs `account.password.reset.complete`. |
|
||||
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, status, totp_enabled, has_password`) |
|
||||
| GET | `/email/verify/:token` | — | — | validate an email-confirmation link → `{username, email}` for the page, else 404 |
|
||||
| POST | `/email/verify/:token` | — (rate-limited) | — | consume the single-use link, install `email_pending` as `email` and set `email_verified`. **Issues no session** — it proves control of a mailbox, not of an account. Unauthenticated on purpose: the link is opened from a mailbox, routinely on a device with no session, and the token is the proof. **Answers 404 for an unusable link AND for an address another account confirmed first**, deliberately — the two must be indistinguishable, or the endpoint becomes an oracle for which addresses hold accounts. Logs `account.email.verified`. |
|
||||
| GET | `/me/account` | cookie / bearer | — | full self account (`id, username, role, email, email_verified, email_pending, status, totp_enabled, has_password`) |
|
||||
| PATCH | `/me/account/username` | cookie / bearer (rate-limited) | `{username}` | change own username; re-issues the caller's session |
|
||||
| PATCH | `/me/account/password` | cookie / bearer (rate-limited) | `{newPassword, currentPassword?}` | change/set own password (current required unless the account has none); revokes other sessions, keeps the caller's |
|
||||
| POST | `/me/account/totp/setup` · `…/enable` · `…/disable` | cookie / bearer | `{code}` on enable/disable | self 2FA enrollment (disable needs a valid current code, not a password). **enable** returns the one-time `recoveryCodes`; **disable** clears the user's trusted devices + recovery codes |
|
||||
| PATCH | `/me/account/email` | cookie / bearer (rate-limited, **password step-up**) | `{email, currentPassword?}` | request an address. **Stages it in `email_pending`; `email` is untouched**, so the account keeps receiving password-reset mail at the address it already has until the emailed link is opened — a typo cannot redirect account recovery. `currentPassword` is required when the account has one (an address is where recovery lands); an SSO-provisioned account with a null hash is exempt, the same carve-out `/me/account/password` makes. Returns `{email_pending, emailed, reason}` — `emailed:false` is reported honestly rather than pretending, because the caller typed this address themselves and there is no enumeration reason to hide it. **429** past the per-user send ceiling |
|
||||
| POST | `/me/account/email/resend` | cookie / bearer (rate-limited) | — | re-send the link for the staged address; **400** when nothing is pending |
|
||||
| DELETE | `/me/account/email/pending` | cookie / bearer | — | abandon the staged address **and retire its outstanding links**, so a confirmation email already delivered can no longer install it |
|
||||
| GET | `/me/account/identities` · DELETE `…/:provider` | cookie / bearer | — | list / unlink own SSO identities |
|
||||
| GET | `/me/trusted-devices` | cookie / bearer | — | list own active trusted devices (never tokens) |
|
||||
| POST | `/me/trusted-devices` | cookie / bearer (rate-limited) | `{deviceName?}` | trust the current device; web gets an httpOnly `rg_trust` cookie, native gets `{trustToken}`. **409 `{error:'trusted_device_limit', devices}`** at the cap |
|
||||
|
||||
Reference in New Issue
Block a user