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

@@ -523,6 +523,15 @@ function buildApi(record) {
// never writes a module's tables. Called from the seed script only, never
// by the running server, and only with DEMO_MODE on.
onDemoSeed: hook('onDemoSeed'),
// API 1.20.0 (ACCOUNT_DELETION.md §4.1, AD10, AD11). Core telling the module
// an account is about to be deleted, dispatched from auth/accountDeletion.js
// BEFORE the row goes, so the module can still read the person's links and
// undo them through its own unlink — a cascade is not an unlink, and for UO
// the link lives on the shard. It cannot veto: a throw or a hook past core's
// time limit is logged and the deletion goes ahead, because a member's
// deletion request must be honoured. Whatever the module could not finish is
// its own pending work.
onUserDeleting: hook('onUserDeleting'),
}
}
@@ -898,7 +907,12 @@ function load(tierRouters) {
tables: new Set(),
called: new Set(),
hooks: {
onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onDemoSeed: null,
onBoot: null,
onShutdown: null,
onIdentityLinking: null,
onIdentityLinked: null,
onDemoSeed: null,
onUserDeleting: null,
},
client: null,
ctx: null,
@@ -1007,7 +1021,7 @@ function refuseSecond(id, dir, holder) {
staged: null,
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onUserDeleting: null },
client: null,
ctx: null,
state: 'startup_failed',
@@ -1186,6 +1200,23 @@ function identityHooks(name) {
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
}
/**
* The `onUserDeleting` hooks, in registration order (API 1.20.0,
* ACCOUNT_DELETION.md AD10).
*
* `started` only, like the identity hooks above: a disabled module, or one whose
* `onBoot` threw, is not serving and holds no link it could undo now. Empty in a
* process that never loaded modules, so a deletion there is core's alone.
*
* @returns {{id: string, hook: Function}[]}
*/
function userDeletingHooks() {
if (!loaded) return []
return [...modules.values()]
.filter((r) => r.state === 'started' && r.hooks.onUserDeleting)
.map((r) => ({ id: r.id, hook: r.hooks.onUserDeleting }))
}
/**
* Every installed module's `demoLocks`, compiled, for the demo lock in
* demo/locks.js (API 1.18.0).
@@ -1361,6 +1392,7 @@ module.exports = {
bootable,
shutdownHooks,
identityHooks,
userDeletingHooks,
demoLocks,
demoSeedHooks,
stopHook,