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

@@ -493,6 +493,27 @@ const doc = {
username: { type: 'string', minLength: 3, maxLength: 32, example: 'newname' },
},
},
DeleteAccountRequest: {
type: 'object',
required: ['username', 'password'],
description: 'The confirm step of deleting your own account (ACCOUNT_DELETION.md §4.1). The password is always required (AD7); `code` or `recoveryCode` only when two-factor sign-in is on.',
properties: {
username: { type: 'string', example: 'wanderer', description: 'The account\'s username, typed to confirm; compared ignoring case.' },
password: { type: 'string', format: 'password' },
code: { type: 'string', example: '123456', description: 'A current authenticator code.' },
recoveryCode: { type: 'string', example: 'ABCD-EFGH', description: 'A single-use recovery code, instead of `code`.' },
},
},
DeleteAccountRefusal: {
type: 'object',
properties: {
code: {
type: 'string',
enum: ['username_mismatch', 'wrong_password', 'code_required', 'wrong_code', 'password_required', 'last_admin'],
},
message: { type: 'string', example: 'Your password is incorrect.' },
},
},
ChangePasswordRequest: {
type: 'object',
required: ['newPassword'],
@@ -844,12 +865,13 @@ const doc = {
targetType: { type: 'string', example: 'team_forum_post', description: 'team_forum_thread | team_forum_post | team_forum_upload' },
targetId: { type: 'integer', example: 812 },
teamId: { type: 'integer', nullable: true, example: 7, description: 'Denormalised so the queue can filter by Team.' },
reporter: { type: 'string', example: 'wanderer', description: 'Username snapshot; "[deleted account]" once the account is gone.' },
reporter: { type: 'string', example: 'wanderer', description: 'Username snapshot. Staff see it even after the account is deleted, with `reporterDeleted` true; anyone else would see "[deleted player]" (ACCOUNT_DELETION.md AD5, AD6).' },
reporterDeleted: { type: 'boolean', example: false },
reason: { type: 'string', enum: ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'], example: 'abuse' },
detail: { type: 'string', nullable: true, maxLength: 500, example: 'Personal attacks in the third paragraph.' },
status: { type: 'string', enum: ['open', 'reviewing', 'actioned', 'dismissed'], example: 'open' },
handledBy: { type: 'string', nullable: true, example: 'moderator1' },
handledByDeleted: { type: 'boolean', example: false },
handledNote: { type: 'string', nullable: true, example: 'Post hidden, author warned.' },
handledAt: { type: 'string', format: 'date-time', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
@@ -869,7 +891,9 @@ const doc = {
title: { type: 'string', nullable: true },
type: { type: 'string', nullable: true, enum: ['announcement', 'discussion'] },
author: { type: 'string', nullable: true, example: 'someone' },
authorDeleted: { type: 'boolean', nullable: true, description: 'The author\'s account was deleted; `author` is the stored name, shown to staff (ACCOUNT_DELETION.md AD6).' },
uploader: { type: 'string', nullable: true },
uploaderDeleted: { type: 'boolean', nullable: true },
excerpt: { type: 'string', nullable: true, description: 'Plain-text excerpt of the post body, capped at 300 characters.' },
status: { type: 'string', nullable: true, enum: ['visible', 'hidden', 'deleted'] },
filename: { type: 'string', nullable: true },