feat(account): self-service account deletion, MODULE_API 1.20.0
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 49s
PR Checks / server-tests (pull_request) Successful in 6m14s

ACCOUNT_DELETION.md phase 2 (core): a member deletes their own account on
the website, and the admin route runs the same procedure.

- DELETE /api/v1/auth/me/account: typed username, password always (AD7),
  and a 2FA or recovery code when 2FA is on. A wrong password or code is
  400, not 401 (AD12), so the app's refresh-on-401 never replays it.
  409 password_required and last_admin; the demo's shared logins are
  locked.
- auth/accountDeletion.js: module hooks first, then one transaction that
  reserves a staff username (reserved_usernames, AD9) and deletes the row,
  then a Teams reconcile, the audit entry (no username) and a notice to
  the old address (new protected template auth.account-deleted).
- MODULE_API 1.20.0: api.onUserDeleting({ userId, deletedBy }), before the
  delete, 10 s limit, cannot veto (AD10, AD11).
- utils/displayAuthor.js: a deleted account's username snapshots read
  "[deleted player]" to members and the stored name, marked, to staff
  (AD5, AD6). Used by the forum, the Reports queue, the guest list and
  the Team digest. A deleted account drops off Forum guests (AD13).
- Reserved names refuse at every username write, answered as "taken".
- Web: Delete my account on the player and staff Account pages; staff
  views mark "(deleted account)".
- OpenAPI regenerated; route manifest and engagement manifest regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-29 18:32:19 -05:00
parent 222bcb02fc
commit d1ae8f943f
50 changed files with 2103 additions and 69 deletions

View File

@@ -1,4 +1,4 @@
const { query } = require('../../utils/db')
const { query, transaction } = require('../../utils/db')
const PUBLIC_COLS =
'id, username, role, status, email, email_verified, email_pending, totp_enabled, created_at, last_login_at'
@@ -92,6 +92,31 @@ async function deleteUser(id) {
return query('DELETE FROM users WHERE id = ?', [id])
}
// Delete the account and, when `reserveName` is given, reserve it, as ONE
// transaction (ACCOUNT_DELETION.md §4.1 step 2): a staff name is never free for
// a moment between the two. `INSERT IGNORE` because a name already reserved
// (a second staff account renamed onto it cannot exist, but a hand-inserted row
// can) is the state wanted, not an error. Returns the rows deleted, 0 or 1.
async function deleteUserReserving(id, reserveName = null) {
return transaction(async (conn) => {
if (reserveName) {
await conn.query(
"INSERT IGNORE INTO reserved_usernames (username_norm, reason) VALUES (LOWER(?), 'deleted_staff')",
[reserveName],
)
}
const result = await conn.query('DELETE FROM users WHERE id = ?', [id])
return Number(result.affectedRows || 0)
})
}
// True when the name can never be taken again (AD9). The column's collation is
// users.username's, so this folds case exactly as the UNIQUE index does.
async function isUsernameReserved(username) {
const rows = await query('SELECT 1 FROM reserved_usernames WHERE username_norm = LOWER(?) LIMIT 1', [username])
return rows.length > 0
}
async function countUsers() {
const rows = await query('SELECT COUNT(*) AS c FROM users')
return Number(rows[0].c)
@@ -148,6 +173,8 @@ module.exports = {
listUsers,
updateUser,
deleteUser,
deleteUserReserving,
isUsernameReserved,
countUsers,
countAdmins,
touchLastLogin,