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

@@ -7,6 +7,8 @@ const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const trustedDevices = require('../../../model/trustedDevices/trustedDevices.model')
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const accountDeletion = require('../../../auth/accountDeletion')
const { refuse: demoRefuse } = require('../../../demo/locks')
const registries = require('../../../modules/registries')
const announceJobs = require('../../../model/announceJobs/announceJobs.model')
const emailConfig = require('../../../model/emailConfig/emailConfig.model')
@@ -1028,19 +1030,21 @@ async function updateUser(req, res) {
}
}
// The same procedure a member's own deletion runs (auth/accountDeletion.js):
// modules unlink their game links first, a staff name is reserved, and the
// account's owner is mailed. The refusals keep this route's 400s.
async function deleteUser(req, res) {
const id = Number(req.params.id)
try {
if (id === req.user.id) {
return res.status(400).json({ message: 'You cannot delete your own account' })
}
const target = await users.getById(id)
if (!target) return res.status(404).json({ message: 'Not found' })
if (target.role === 'admin' && (await users.countAdmins()) <= 1) {
return res.status(400).json({ message: 'Cannot delete the last admin' })
const result = await accountDeletion.deleteAccount(id, { by: 'staff', req })
if (!result.ok) {
if (result.reason === 'not_found') return res.status(404).json({ message: 'Not found' })
if (result.reason === 'last_admin') return res.status(400).json({ message: 'Cannot delete the last admin' })
if (result.reason === 'demo') return demoRefuse(res)
}
await users.remove(id)
await activity.log({ req, action: 'user.delete', detail: { id } })
return res.json({ id })
} catch (err) {
log.error('deleteUser', err)

View File

@@ -318,7 +318,7 @@ async function getContentReports(req, res) {
}
const teamId = Number(req.query.teamId) || undefined
return res.json({
reports: await contentReports.queue({ status, teamId, limit, offset }),
reports: await contentReports.queue({ status, teamId, limit, offset, viewer: req.user }),
openCount: await contentReports.openCount(),
})
} catch (err) {

View File

@@ -18,7 +18,9 @@ const trustedDevices = require('../../../model/trustedDevices/trustedDevices.mod
const recoveryCodes = require('../../../model/recoveryCodes/recoveryCodes.model')
const sessionService = require('../../../auth/session.service')
const { establishTrust } = require('./trustDevice.helper')
const { setAuthCookie, setTrustCookie, clearTrustCookie } = require('../../../auth/token')
const { setAuthCookie, setTrustCookie, clearTrustCookie, clearAuthCookie } = require('../../../auth/token')
const accountDeletion = require('../../../auth/accountDeletion')
const { refuse: demoRefuse } = require('../../../demo/locks')
const usernamePolicy = require('../../../auth/usernamePolicy')
const loginProtection = require('../../../middleware/loginProtection')
const botScore = require('../../../middleware/botScore')
@@ -538,7 +540,88 @@ async function generateRecoveryCodes(req, res) {
}
}
// ── Deleting your own account (ACCOUNT_DELETION.md §4.1) ───────────────────
//
// DELETE /account. The proof is the password, always (AD7): signing in with
// Google, Discord or Steam is a convenience on an account that already has its
// own password. A two-factor code or a recovery code is asked for too when 2FA is
// on, and the typed username guards against a mis-tap on a phone.
//
// **A wrong password or code is 400, not 401** (AD12). Every self-service
// password check here answers 400, and the app's TokenAuthenticator reads a 401
// on a signed-in call as an expired session: it would spend the refresh token and
// replay the DELETE, making two password attempts out of one typo.
//
// Each refusal carries a `code` the screens switch on; the words are for a
// client that does not know the code.
async function deleteAccount(req, res) {
try {
const raw = await users.getRawById(req.user.id)
if (!raw) return res.status(401).json({ message: 'Unauthorized' })
const typed = String(req.body.username || '').trim()
if (typed.toLowerCase() !== String(raw.username).toLowerCase()) {
return res.status(400).json({ code: 'username_mismatch', message: 'Type your username exactly as it is shown.' })
}
if (!raw.password_hash) {
return res.status(409).json({
code: 'password_required',
message: 'This account has no password yet. Set one above, then delete the account.',
})
}
const passwordOk = await users.validatePassword(raw, req.body.password || '')
if (!passwordOk) {
// Credential-guessing on an open session, like a wrong current password.
loginProtection.recordFailure(req.ip)
botScore.recordLoginFailure(req.ip)
log.warn('deleteAccount wrong password', { id: raw.id, ip: req.ip })
return res.status(400).json({ code: 'wrong_password', message: 'Your password is incorrect.' })
}
if (raw.totp_enabled) {
const code = String(req.body.code || '').trim()
const recovery = String(req.body.recoveryCode || '').trim()
if (!code && !recovery) {
return res.status(400).json({ code: 'code_required', message: 'Enter a code from your authenticator app, or a recovery code.' })
}
// A recovery code is spent here even though the account is about to go:
// the deletion can still be refused below, and a code that worked once must
// not work twice.
const passed = code ? totp.verifyCode(raw.totp_secret, code) : await recoveryCodes.consumeForUser(raw.id, recovery)
if (!passed) {
loginProtection.recordFailure(req.ip)
botScore.recordLoginFailure(req.ip)
log.warn('deleteAccount wrong second factor', { id: raw.id, ip: req.ip, recovery: Boolean(!code) })
return res.status(400).json({ code: 'wrong_code', message: 'That code is not valid. Try again.' })
}
}
const result = await accountDeletion.deleteAccount(raw.id, { by: 'self', req })
if (!result.ok) {
if (result.reason === 'last_admin') {
return res.status(409).json({
code: 'last_admin',
message: 'You are the only admin. Make someone else an admin before you delete your account.',
})
}
if (result.reason === 'demo') return demoRefuse(res)
return res.status(401).json({ message: 'Unauthorized' })
}
// The server has already dropped every session, trusted device and push
// registration with the row. These two cookies are this browser's copies.
clearAuthCookie(req, res)
clearTrustCookie(req, res)
return res.json({ deleted: true })
} catch (err) {
log.error('deleteAccount', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
deleteAccount,
changeEmail,
resendEmailVerification,
cancelEmailChange,

View File

@@ -79,6 +79,33 @@ meRouter.patch(
account.changePassword,
)
// Deleting your own account (ACCOUNT_DELETION.md §4.1). Behind the login backoff
// and bot guards as well as the change limiter, because a wrong password here is
// credential-guessing exactly as it is on the recovery-codes route.
meRouter.delete(
'/account',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Delete the current account (self, any role)'
// #swagger.description = 'Deletes the account at once (no grace period). Requires the typed username (case-insensitive), the account password, and — when two-factor sign-in is on — an authenticator code or a recovery code. Before the row goes, every installed game module unlinks the account’s game links through its own unlink (MODULE_API 1.20.0 onUserDeleting); a module that cannot finish never stops the deletion. Every session, trusted device, push registration, linked sign-in and game link goes with the account. What the member wrote stays and other members see it as "[deleted player]"; staff keep the name and the activity log for moderation. A deleted staff account’s username can never be registered again. A wrong password or code is a 400 (not 401), so a native client’s refresh-on-401 never replays it. Refused with 403 DEMO_LOCKED for the demo’s shared logins.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteAccountRequest" } } } } */
/* #swagger.responses[200] = { description: 'Deleted; this browser’s session and trust cookies are cleared', content: { "application/json": { schema: { type: "object", properties: { deleted: { type: "boolean", example: true } } } } } } */
/* #swagger.responses[400] = { description: 'username_mismatch, wrong_password, code_required or wrong_code', content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteAccountRefusal" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Demo login (DEMO_LOCKED)', content: { "application/json": { schema: { $ref: "#/components/schemas/DemoLocked" } } } } */
/* #swagger.responses[409] = { description: 'password_required (the account has no password yet) or last_admin', content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteAccountRefusal" } } } } */
/* #swagger.responses[429] = { description: 'Too many attempts (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
backoffGuard,
slowLogin,
accountChangeLimiter,
body('username').isString().trim().isLength({ min: 1, max: 64 }),
body('password').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
body('code').optional({ values: 'falsy' }).isString().trim().isLength({ max: 16 }),
body('recoveryCode').optional({ values: 'falsy' }).isString().trim().isLength({ max: 64 }),
validate,
account.deleteAccount,
)
// Email address (engagement Phase 1b). The change is STAGED and only a tokened
// link installs it, so these three routes never alter the address that is
// currently receiving mail. The confirm half is public and lives at

View File

@@ -82,6 +82,9 @@ async function resolveForum(req) {
async function viewerFor(ctx, user) {
return {
userId: user.id,
// The account role, for whose name a deleted author shows as (displayAuthor):
// an editor is not forum staff and still sees it (AD6).
role: user.role,
isStaff: ctx.staff,
windowMinutes: await forumSettings.editWindowMinutes(),
}
@@ -122,7 +125,7 @@ async function listThreads(req, res) {
const ctx = await resolveForum(req)
if (!ctx) return res.status(404).json({ message: 'Not found' })
return res.json({
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate }),
threads: await forum.listThreads(ctx.team.id, { canModerate: ctx.canModerate, viewer: { role: req.user.role } }),
// Two capabilities, not one. Phase 4 had a single `canPost` because there
// was a single kind of thread to post; phase 5 opened discussion to every
// participant while announcements stayed with the leaders, so a client that
@@ -334,7 +337,7 @@ async function listGrants(req, res) {
const authority = await grants.authorityFor(team.id, req.user)
if (!authority.may) return res.status(403).json({ message: 'Not a leader of this Team' })
return res.json({
guests: await grants.forumGuests(team.id),
guests: await grants.forumGuests(team.id, req.user),
cap: await grants.grantCap(),
as: authority.as,
})