feat(account): self-service account deletion, MODULE_API 1.20.0
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:
@@ -13,6 +13,7 @@ function sanitize(user) {
|
||||
// password may be omitted/null — an SSO-provisioned player has no password until
|
||||
// they set one (a null hash makes password login impossible, see validatePassword).
|
||||
async function createUser({ username, password, role = 'admin', email = null, status = 'active', emailVerified = false }) {
|
||||
await refuseReserved(username)
|
||||
const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null
|
||||
const id = await usersDb.insertUser({ username, passwordHash, role, email, status, emailVerified })
|
||||
return sanitize(await usersDb.findById(id))
|
||||
@@ -63,9 +64,36 @@ function isDuplicateEmail(err) {
|
||||
// and nothing newly falls through to a 500. Only the case we can positively
|
||||
// identify — email — is carved out.
|
||||
function isDuplicateUsername(err) {
|
||||
if (err && err.code === USERNAME_RESERVED) return true
|
||||
return isDuplicateKeyError(err) && !isDuplicateEmail(err)
|
||||
}
|
||||
|
||||
// ── Reserved usernames (ACCOUNT_DELETION.md AD9) ───────────────────────────
|
||||
//
|
||||
// A deleted staff account's name can never be taken again. The refusal is
|
||||
// raised HERE, in the two writes every username passes through, rather than in
|
||||
// each of the six routes that create or rename an account: registration, an
|
||||
// invite's acceptance, SSO provisioning, an admin creating or renaming a user,
|
||||
// and a self-service change. Each of them already answers a duplicate username,
|
||||
// and `isDuplicateUsername` above reports a reserved one as exactly that, so a
|
||||
// reserved name reads as a taken one everywhere — the same 409, the same words,
|
||||
// and SSO provisioning moves on to its next candidate.
|
||||
//
|
||||
// A check-then-write, so not atomic against a deletion committing in between.
|
||||
// That window is one request wide and needs a staff account deleted at the same
|
||||
// instant someone registers its name; the UNIQUE index still holds until the
|
||||
// deletion commits.
|
||||
const USERNAME_RESERVED = 'USERNAME_RESERVED'
|
||||
|
||||
async function refuseReserved(username) {
|
||||
if (typeof username !== 'string' || !username) return
|
||||
if (await usersDb.isUsernameReserved(username.trim())) {
|
||||
const err = new Error('username is reserved')
|
||||
err.code = USERNAME_RESERVED
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the raw row (incl. hash) — used by login only.
|
||||
async function getRawByUsername(username) {
|
||||
return usersDb.findByUsername(username)
|
||||
@@ -112,7 +140,10 @@ async function list() {
|
||||
|
||||
async function update(id, { username, password, role, email, status, emailVerified }) {
|
||||
const fields = {}
|
||||
if (username !== undefined) fields.username = username
|
||||
if (username !== undefined) {
|
||||
await refuseReserved(username)
|
||||
fields.username = username
|
||||
}
|
||||
if (role !== undefined) fields.role = role
|
||||
if (email !== undefined) fields.email = email
|
||||
if (status !== undefined) fields.status = status
|
||||
@@ -155,6 +186,15 @@ async function remove(id) {
|
||||
return usersDb.deleteUser(id)
|
||||
}
|
||||
|
||||
// The deletion's own write: the row goes, and a staff name is reserved in the
|
||||
// same transaction. Only auth/accountDeletion.js calls this; `remove` above is
|
||||
// for undoing an account this request itself just created (a lost invite race).
|
||||
async function removeReserving(id, reserveName = null) {
|
||||
return usersDb.deleteUserReserving(id, reserveName)
|
||||
}
|
||||
|
||||
const isUsernameReserved = (username) => usersDb.isUsernameReserved(username)
|
||||
|
||||
async function count() {
|
||||
return usersDb.countUsers()
|
||||
}
|
||||
@@ -186,6 +226,9 @@ module.exports = {
|
||||
invalidateSessions,
|
||||
setSessionCutoff,
|
||||
remove,
|
||||
removeReserving,
|
||||
isUsernameReserved,
|
||||
USERNAME_RESERVED,
|
||||
count,
|
||||
countAdmins,
|
||||
recordLogin,
|
||||
|
||||
Reference in New Issue
Block a user