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

@@ -40,6 +40,37 @@ async function query(sql, params) {
}
}
/**
* Run `fn(conn)` inside one transaction on one connection: committed when it
* resolves, rolled back when it throws (and the error rethrown).
*
* Every statement that must stand or fall together goes through the `conn` it is
* handed, never through `query()` above, which takes a different connection from
* the pool and so sits outside the transaction.
*
* @template T
* @param {(conn: import('mariadb').PoolConnection) => Promise<T>} fn
* @returns {Promise<T>}
*/
async function transaction(fn) {
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
const out = await fn(conn)
await conn.commit()
return out
} catch (err) {
try {
await conn.rollback()
} catch (rollbackErr) {
log.error('rollback failed', { message: rollbackErr.message })
}
throw err
} finally {
conn.release()
}
}
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
/**
@@ -106,4 +137,4 @@ async function close() {
await pool.end()
}
module.exports = { pool, query, ensureSchema, close }
module.exports = { pool, query, transaction, ensureSchema, close }