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

@@ -192,7 +192,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [{
id: 3, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png',
id: 3, team_id: 1, post_id: 80, uploader_user_id: 12, uploader_username: 'someone', filename: 'a1b2.png',
mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null,
}])
@@ -201,6 +201,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
// load-bearing rather than bookkeeping.
assert.equal(item.target.kind, 'upload')
assert.equal(item.target.uploader, 'someone')
assert.equal(item.target.uploaderDeleted, false)
assert.equal(item.target.byteSize, 184320)
assert.equal(item.target.mimetype, 'image/png')
assert.equal(item.target.url, '/uploads/a1b2.png')
@@ -234,17 +235,64 @@ test('a report whose target is already gone still lists, with a null target', as
assert.equal(item.target, null)
})
test('a deleted reporter still shows as somebody, and is marked deleted', async () => {
test('a deleted reporter with no snapshot still shows as somebody, and is marked deleted', async () => {
patch(reportsDb, 'list', async () => [row({ reporter_user_id: null, reporter_username: null })])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({})
assert.equal(item.reporter, '[deleted account]')
const [item] = await reports.queue({ viewer: { role: 'moderator' } })
assert.equal(item.reporter, '[deleted player]')
assert.equal(item.reporterDeleted, true)
})
// ACCOUNT_DELETION.md AD6: staff keep the stored name of a deleted account, for
// moderation, and the flag says which it is. Every snapshot in a report row goes
// through the one rule — reporter, handler, and the target's author.
test('staff see a deleted account’s stored names in the queue, each marked deleted', async () => {
patch(reportsDb, 'list', async () => [row({
reporter_user_id: null, reporter_username: 'Ravenna',
status: 'actioned', handled_by: null, handled_username: 'OldMod', handled_at: new Date(),
})])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [{
id: 80, thread_id: 5, author_user_id: null, author_username: 'Thorne', status: 'hidden',
body_html: '<p>x</p>', created_at: new Date(), team_id: 1, thread_title: 'Raid night',
}])
for (const role of ['admin', 'editor', 'moderator']) {
// eslint-disable-next-line no-await-in-loop
const [item] = await reports.queue({ viewer: { role } })
assert.equal(item.reporter, 'Ravenna', role)
assert.equal(item.reporterDeleted, true)
assert.equal(item.handledBy, 'OldMod')
assert.equal(item.handledByDeleted, true)
assert.equal(item.target.author, 'Thorne')
assert.equal(item.target.authorDeleted, true)
}
// Nothing but a staff viewer sees those names, so a caller that forgot to say
// who is reading cannot leak them.
const [blind] = await reports.queue({})
assert.equal(blind.reporter, '[deleted player]')
assert.equal(blind.handledBy, '[deleted player]')
assert.equal(blind.target.author, '[deleted player]')
})
test('an open report has no handler, which is not a deleted handler', async () => {
patch(reportsDb, 'list', async () => [row()])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({ viewer: { role: 'admin' } })
assert.equal(item.handledBy, null)
assert.equal(item.handledByDeleted, false)
assert.equal(item.reporter, 'wanderer')
assert.equal(item.reporterDeleted, false)
})
// ── handling ───────────────────────────────────────────────────────────────
test('handling records who decided, when, and why', async () => {