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

@@ -0,0 +1,80 @@
// The Account page's "Delete my account" section (client/src/lib/accountDeletion.js)
// and how a deleted account's name is marked for staff (lib/deletedAccount.js).
// docs/website/ACCOUNT_DELETION.md §4.1, §4.2, AD5, AD6.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
usernameMatches, isSharedDemoLogin, refusalMessage, isStaffRole, WHAT_GOES, WHAT_STAYS,
} from '../src/lib/accountDeletion.js'
import { DELETED_PLAYER, markDeleted, rowName } from '../src/lib/deletedAccount.js'
import { describeRequest, leadershipOf } from '../src/lib/teamAdmin.js'
import { threadSummary } from '../src/lib/teamForum.js'
// ── the confirm step ───────────────────────────────────────────────────────
test('Delete is live only once the typed username matches, ignoring case and spaces', () => {
assert.equal(usernameMatches('Thorne', 'Thorne'), true)
assert.equal(usernameMatches(' thorne ', 'Thorne'), true)
assert.equal(usernameMatches('Thorn', 'Thorne'), false)
assert.equal(usernameMatches('', 'Thorne'), false)
assert.equal(usernameMatches(' ', 'Thorne'), false)
assert.equal(usernameMatches(undefined, 'Thorne'), false)
})
test('the demo’s shared logins are recognised from the published list, and nothing else is', () => {
const settings = { demo: { logins: [{ username: 'demo-player' }, { username: 'play-review' }] } }
assert.equal(isSharedDemoLogin(settings, 'Demo-Player'), true)
assert.equal(isSharedDemoLogin(settings, 'alice'), false)
// Off the demo there is no `demo` block, so nobody is a shared login.
assert.equal(isSharedDemoLogin({}, 'demo-player'), false)
assert.equal(isSharedDemoLogin(undefined, 'demo-player'), false)
})
test('every refusal code the route answers has its own sentence', () => {
for (const code of ['username_mismatch', 'wrong_password', 'code_required', 'wrong_code', 'password_required', 'last_admin', 'DEMO_LOCKED']) {
const msg = refusalMessage({ status: 400, body: { code }, message: 'server words' })
assert.notEqual(msg, 'server words', code)
assert.ok(msg.length > 10, code)
}
assert.match(refusalMessage({ status: 429, body: null }), /Too many attempts/)
assert.equal(refusalMessage({ status: 500, message: 'Internal Server Error' }), 'Internal Server Error')
assert.match(refusalMessage(null), /Could not delete/)
})
test('the screen says what goes and what stays, and that staff names are kept forever', () => {
assert.ok(WHAT_GOES.some((l) => /game-account links/.test(l)))
assert.ok(WHAT_STAYS.some((l) => l.includes('[deleted player]')))
assert.equal(isStaffRole('moderator'), true)
assert.equal(isStaffRole('player'), false)
})
// ── staff see a deleted account's name, marked ─────────────────────────────
test('a deleted account’s stored name is marked; the placeholder is left alone', () => {
assert.equal(markDeleted('Ravenna', true), 'Ravenna (deleted account)')
assert.equal(markDeleted('Ravenna', false), 'Ravenna')
assert.equal(markDeleted(DELETED_PLAYER, true), DELETED_PLAYER)
assert.equal(markDeleted(null, true), null)
})
test('a raw ledger row is a deleted account only when its id is an explicit null', () => {
assert.equal(rowName(null, 'OldMod'), 'OldMod (deleted account)')
assert.equal(rowName(4, 'OldMod'), 'OldMod')
assert.equal(rowName(undefined, 'OldMod'), 'OldMod')
assert.equal(rowName(null, null), '—')
})
test('Admin → Teams marks a deleted requester and a deleted override author', () => {
assert.match(
describeRequest({ action: 'unhide', requested_by: null, requested_username: 'mod1', team_name: 'X' }),
/^mod1 \(deleted account\) asks/,
)
const l = leadershipOf({ isLeaderSynced: true, leaderOverride: { effect: 'deny', by: 'mod1', byDeleted: true } })
assert.match(l.note, /Denied by mod1 \(deleted account\)/)
})
test('a forum thread by a deleted account is marked in the list for staff', () => {
assert.match(threadSummary({ type: 'discussion', author: 'Ravenna', authorDeleted: true, postCount: 1 }), /^Ravenna \(deleted account\)/)
assert.equal(threadSummary({ type: 'discussion', author: DELETED_PLAYER, authorDeleted: true, postCount: 1 }), DELETED_PLAYER)
})