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

@@ -65,6 +65,22 @@ CREATE TABLE IF NOT EXISTS users (
UNIQUE KEY uq_users_email_norm (email_norm)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Usernames that can never be taken again (ACCOUNT_DELETION.md AD9). A deleted
-- STAFF account's name lands here in the same transaction that deletes the row,
-- so nobody can register "Ravenna" the day after the moderator Ravenna left and
-- speak with her name. A player's name is never added and is free at once.
--
-- There is no release path, by decision ("completely unavailable"). The column
-- carries the users.username collation so "reserved" means exactly what "taken"
-- means: the same case folding the UNIQUE index above applies. Registration, an
-- invite's acceptance, an admin creating or renaming an account and a username
-- change all refuse a reserved name with the answer they give a taken one.
CREATE TABLE IF NOT EXISTS reserved_usernames (
username_norm VARCHAR(32) NOT NULL COLLATE utf8mb4_general_ci PRIMARY KEY,
reason ENUM('deleted_staff') NOT NULL,
reserved_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
category ENUM('news','five_on_friday','newsletter','screenshot') NOT NULL,
@@ -1153,7 +1169,7 @@ CREATE TABLE IF NOT EXISTS team_forum_posts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
thread_id INT NOT NULL,
author_user_id INT NULL,
author_username VARCHAR(32) NULL, -- snapshot; renders as "[deleted account]" when both are gone
author_username VARCHAR(32) NULL, -- snapshot; with the id NULL it is a deleted account: "[deleted player]" to members, the name to staff (utils/displayAuthor.js)
body_html MEDIUMTEXT NOT NULL, -- sanitised on write via utils/forumHtml.js
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
edited_at DATETIME NULL,

View File

@@ -1,6 +1,6 @@
{
"_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.",
"moduleApiVersion": "1.19.0",
"moduleApiVersion": "1.20.0",
"triggers": [
{
"id": "event.phase.changed",

View File

@@ -1784,6 +1784,18 @@
"requireAuth"
]
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/account",
"handlers": 9,
"gates": [
"noindex",
"requireAuth",
"backoffGuard",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/account",

View File

@@ -733,6 +733,10 @@
"method": "GET",
"path": "/api/v1/auth/me"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/account"
},
{
"method": "GET",
"path": "/api/v1/auth/me/account"

View File

@@ -0,0 +1,174 @@
// ── Deleting an account (docs/website/ACCOUNT_DELETION.md §4.1) ────────────
//
// One procedure for both ways an account goes: a member deleting their own
// (`DELETE /auth/me/account`, by: 'self') and staff deleting it from the admin
// panel (`DELETE /admin/users/:id`, by: 'staff'). The two routes check different
// things before they get here — the member proves who they are, the admin is not
// allowed to delete themself — and from here on they do the same thing.
//
// In order:
//
// 1. **Before the delete.** Read what is needed afterwards (the address for the
// notice, the username, the role), then ask every started module's
// `onUserDeleting` hook in turn (MODULE_API 1.20.0). That is where game links
// are unlinked through each module's own unlink (AD10): a cascade is not an
// unlink, and for UO the link lives on the shard.
// 2. **One transaction.** A staff name is added to `reserved_usernames` (AD9)
// and the users row is deleted. The foreign keys do the rest: credentials,
// sessions and settings cascade, authored content is detached. Snapshot
// columns, the content and the activity log are NOT touched (AD5, AD6) —
// who sees which name is utils/displayAuthor's rule, applied when read.
// 3. **After commit**, fire-and-forget, each step on its own: a Teams
// reconcile, the activity-log entry, and the notice to the old address.
//
// **Nothing stops a deletion once it starts** (AD11). A hook that throws, or runs
// past HOOK_TIMEOUT_MS, is logged and the deletion goes ahead: Play requires the
// request to be honoured, and a game server that is down must not hold a
// member's request open. Whatever a module could not finish is its own pending
// work. The refusals below all come BEFORE step 1.
const users = require('../model/users/users.model')
const activity = require('../model/activity/activity.model')
const teamSync = require('../model/teams/teamSync.model')
const loader = require('../modules/loader')
const mailer = require('../utils/mailer')
const { isDemo, isDemoUsername } = require('../demo/mode')
const log = require('../utils/logger')('account-deletion')
const HOOK_TIMEOUT_MS = 10_000
const BY = ['self', 'staff']
function withTimeout(fn, ms) {
let timer
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`no answer within ${ms} ms`)), ms)
})
return Promise.race([Promise.resolve().then(fn), deadline]).finally(() => clearTimeout(timer))
}
/**
* Ask every started module's `onUserDeleting`, one at a time.
*
* In turn rather than at once so a module's log lines read in the order they ran,
* and because a site runs one module: there is nothing to parallelise. Never
* throws; returns what each module did, for the log.
*
* @returns {Promise<{module: string, ok: boolean, error?: string}[]>}
*/
async function runHooks(payload, timeoutMs) {
const frozen = Object.freeze({ userId: payload.userId, deletedBy: payload.deletedBy })
const outcomes = []
for (const { id, hook } of loader.userDeletingHooks()) {
try {
// eslint-disable-next-line no-await-in-loop
await withTimeout(() => hook(frozen), timeoutMs)
outcomes.push({ module: id, ok: true })
} catch (err) {
log.error('a module could not finish before an account was deleted; deleting anyway', {
module: id,
userId: frozen.userId,
message: err.message,
})
outcomes.push({ module: id, ok: false, error: err.message })
}
}
return outcomes
}
// "29 October 2026", in UTC so the date in the mail does not depend on the
// host's zone.
function formatDate(when) {
return when.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
}
/**
* Step 3. Each step in its own try, and the whole thing never rejects, so the
* caller may leave it un-awaited. Returned anyway, so a test has a moment at
* which the after-commit work has happened.
*/
async function afterCommit({ userId, by, req, email, username, reserved, when }) {
try {
teamSync.request({ reason: 'account deleted' })
} catch (err) {
log.warn('teams reconcile request failed after a deletion', { message: err.message })
}
// Never the username: the entry outlives the account, and the activity log is
// a record of what was done, not a second place the name is kept. A member
// deleting themself is no longer a user the row could name, so the entry
// carries no user id, only the IP the request came from (AD6).
try {
const action = by === 'self' ? 'user.self_delete' : 'user.delete'
const detail = { id: userId, ...(reserved ? { usernameReserved: true } : {}) }
if (by === 'self') {
await activity.log({ req: { ip: req ? req.ip : null }, action, detail })
} else {
await activity.log({ req, action, detail })
}
} catch (err) {
log.warn('activity entry failed after a deletion', { message: err.message })
}
if (email) {
try {
const sent = await mailer.sendAccountDeleted({ to: email, username, deletedOn: formatDate(when), by })
if (!sent.sent) log.info('deletion notice not sent (mail not configured)', { userId })
} catch (err) {
log.warn('deletion notice failed', { userId, message: err.message })
}
}
}
/**
* Delete one account.
*
* @param {number} userId
* @param {{by: 'self'|'staff', req?: object, hookTimeoutMs?: number}} opts
* @returns {Promise<
* | {ok: true, id: number, reserved: boolean, modules: object[], settled: Promise<void>}
* | {ok: false, reason: 'not_found' | 'demo' | 'last_admin'}
* >}
*/
async function deleteAccount(userId, { by, req = null, hookTimeoutMs = HOOK_TIMEOUT_MS } = {}) {
if (!BY.includes(by)) throw new Error(`deleteAccount: by must be one of ${BY.join(', ')}`)
const target = await users.getById(userId)
if (!target) return { ok: false, reason: 'not_found' }
// The demo's shared logins. The demo lock refuses both routes before they get
// here (demo/locks.js); this is the line behind it, so no future caller of the
// service can delete the account every visitor signs in as.
if (isDemo() && isDemoUsername(target.username)) return { ok: false, reason: 'demo' }
// A deployment with no admin left has nobody who can run it.
if (target.role === 'admin' && (await users.countAdmins()) <= 1) return { ok: false, reason: 'last_admin' }
// 1. Before the delete.
const modules = await runHooks({ userId, deletedBy: by }, hookTimeoutMs)
// 2. One transaction: reserve a staff name, delete the row.
const reserveName = target.role !== 'player' ? target.username : null
const removed = await users.removeReserving(userId, reserveName)
// Gone between the read above and now: somebody else's deletion won. Nothing
// of ours happened, so nothing after commit runs either.
if (!removed) return { ok: false, reason: 'not_found' }
log.info('account deleted', {
userId,
by,
reserved: Boolean(reserveName),
modules: modules.map((m) => `${m.module}:${m.ok ? 'ok' : 'failed'}`),
})
// 3. After commit.
const settled = afterCommit({
userId,
by,
req,
email: target.email || null,
username: target.username,
reserved: Boolean(reserveName),
when: new Date(),
})
return { ok: true, id: userId, reserved: Boolean(reserveName), modules, settled }
}
module.exports = { deleteAccount, HOOK_TIMEOUT_MS }

View File

@@ -109,6 +109,10 @@ const CORE_LOCKS = [
{ methods: ['POST'], path: '/auth/me/account/totp/disable', when: callerIsDemo },
{ methods: ['POST'], path: '/auth/me/account/recovery-codes/generate', when: callerIsDemo },
{ methods: ['DELETE'], path: '/auth/me/account/identities/:provider', when: callerIsDemo },
// ACCOUNT_DELETION.md §4.3: the screen stays visible on the demo, because
// Play's reviewer must be able to find it, and the shared logins cannot be
// deleted. An account an admin created on the demo can be.
{ methods: ['DELETE'], path: '/auth/me/account', when: callerIsDemo },
// DM17: the whole route, because a mobile access token carries no refresh-row
// id and "a session that is not the caller's" cannot be decided. Signing out
// (POST /auth/mobile/logout) revokes the caller's own row and stays open.

View File

@@ -157,6 +157,36 @@ const SEEDS = [
),
],
},
{
// ACCOUNT_DELETION.md §4.1 step 3: the one notice a person gets if somebody
// else used their unlocked phone. It goes to the address the account HAD,
// read before the row went, and there is no link in it because there is no
// account left for a link to reach.
key: 'auth.account-deleted',
name: 'Account deleted',
channel: 'email',
protected: true,
seedVersion: 1,
subject: 'Your {{siteName}} account was deleted',
variables: [
{ name: 'username', type: 'string', required: true, example: 'Darrow' },
{ name: 'deletedOn', type: 'string', required: true, example: '29 October 2026' },
{ name: 'byWhom', type: 'string', required: false, example: ' at your request' },
],
blocks: [
text('p1', 'The {{siteName}} account “{{username}}” was deleted on {{deletedOn}}{{byWhom}}.'),
text(
'p2',
'Its sign-in, email address, two-factor settings, linked sign-ins and game links are gone. ' +
'What it wrote where others read it stays, and other members see it as written by “[deleted player]”.',
),
text(
'p3',
'If you did not ask for this, contact the {{siteName}} staff at {{siteUrl}}. ' +
'A deleted account cannot be restored, but they can find out what happened.',
),
],
},
{
key: 'admin.contact-message',
name: 'Contact form message',

View File

@@ -114,7 +114,7 @@ async function handle(id, { status, handledBy, handledUsername, note }) {
async function threadsByIds(ids) {
if (!ids.length) return []
return query(
`SELECT id, team_id, title, type, status, created_username FROM team_forum_threads
`SELECT id, team_id, title, type, status, created_by, created_username FROM team_forum_threads
WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)

View File

@@ -26,6 +26,7 @@
const reportsDb = require('./contentReports.db')
const forumDb = require('../teams/teamForum.db')
const { displayAuthor, hasActor } = require('../../utils/displayAuthor')
const TARGET_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload']
const REASONS = ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other']
@@ -116,7 +117,7 @@ async function file({ team, actor, targetType, targetId, reason, detail }) {
* hide the pattern of a member deleting their own content the moment it is
* reported.
*/
async function queue({ status, teamId, limit, offset } = {}) {
async function queue({ status, teamId, limit, offset, viewer = null } = {}) {
const rows = await reportsDb.list({ status, teamId, limit, offset })
if (!rows.length) return []
@@ -132,9 +133,18 @@ async function queue({ status, teamId, limit, offset } = {}) {
const postMap = byId(posts)
const uploadMap = byId(uploads)
return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) }))
return rows.map((r) => ({
...publicReport(r, viewer),
target: describeTarget(r, { threadMap, postMap, uploadMap }, viewer),
}))
}
// A snapshot pair through displayAuthor's rule (ACCOUNT_DELETION.md AD5, AD6).
// The queue is staff-only by its route, so a staff viewer sees the stored name
// and the `*Deleted` flag marks it; the rule is still asked rather than assumed,
// so this file cannot become the place a member's view leaks a deleted name.
const shown = (userId, username, viewer) => displayAuthor({ userId, username }, viewer)
/**
* The reported content, resolved.
*
@@ -144,29 +154,33 @@ async function queue({ status, teamId, limit, offset } = {}) {
* than as an explicit null, and the queue's own contract says nullable. A client
* distinguishing "gone" from "not resolved yet" would get it wrong.
*/
function describeTarget(report, { threadMap, postMap, uploadMap }) {
function describeTarget(report, { threadMap, postMap, uploadMap }, viewer = null) {
const id = Number(report.target_id)
if (report.target_type === 'team_forum_thread') {
const t = threadMap.get(id)
if (!t) return null
const author = shown(t.created_by, t.created_username, viewer)
return {
kind: 'thread',
threadId: t.id,
title: t.title,
type: t.type,
status: t.status,
author: t.created_username,
author: author.name,
authorDeleted: author.deleted,
}
}
if (report.target_type === 'team_forum_post') {
const p = postMap.get(id)
if (!p) return null
const author = shown(p.author_user_id, p.author_username, viewer)
return {
kind: 'post',
postId: p.id,
threadId: p.thread_id,
threadTitle: p.thread_title,
author: p.author_username,
author: author.name,
authorDeleted: author.deleted,
status: p.status,
excerpt: excerpt(p.body_html),
createdAt: p.created_at,
@@ -178,11 +192,13 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) {
// §5.6's fourth rule: uploader, size and the SNIFFED type, without hunting.
// This is the payoff for §5.5.4's attribution table being load-bearing rather
// than bookkeeping.
const uploader = shown(u.uploader_user_id, u.uploader_username, viewer)
return {
kind: 'upload',
uploadId: u.id,
postId: u.post_id,
uploader: u.uploader_username,
uploader: uploader.name,
uploaderDeleted: uploader.deleted,
filename: u.filename,
url: `/uploads/${u.filename}`,
mimetype: u.mimetype,
@@ -194,18 +210,25 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) {
return null
}
function publicReport(row) {
function publicReport(row, viewer = null) {
const reporter = shown(row.reporter_user_id, row.reporter_username, viewer)
// Nobody has handled an open report, which is not the same as a deleted
// handler: only a pair that names someone goes through the rule.
const handler = hasActor(row.handled_by, row.handled_username)
? shown(row.handled_by, row.handled_username, viewer)
: null
return {
id: row.id,
targetType: row.target_type,
targetId: Number(row.target_id),
teamId: row.team_id,
reporter: row.reporter_username || '[deleted account]',
reporterDeleted: row.reporter_user_id == null,
reporter: reporter.name,
reporterDeleted: reporter.deleted,
reason: row.reason,
detail: row.detail,
status: row.status,
handledBy: row.handled_username,
handledBy: handler ? handler.name : null,
handledByDeleted: handler ? handler.deleted : false,
handledNote: row.handled_note,
handledAt: row.handled_at,
createdAt: row.created_at,
@@ -226,7 +249,7 @@ async function handle({ id, actor, status, note }) {
handledUsername: actor.username,
note,
})
return { ok: true, report: publicReport(await reportsDb.byId(id)) }
return { ok: true, report: publicReport(await reportsDb.byId(id), actor) }
}
module.exports = {

View File

@@ -112,7 +112,15 @@ async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) {
is_leader_synced: Boolean(m.is_leader),
is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader),
leader_override: override
? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at }
? {
effect: override.effect,
reason: override.reason,
by: override.actor_username,
// Staff-only (adminMember); a deleted actor keeps the stored name here,
// marked (ACCOUNT_DELETION.md AD6). The public roster never carries it.
byDeleted: override.actor_user_id == null && Boolean(override.actor_username),
at: override.created_at,
}
: null,
}
})

View File

@@ -21,6 +21,7 @@
const forumDb = require('./teamForum.db')
const forumSettings = require('./teamForumSettings.model')
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
const { displayAuthor } = require('../../utils/displayAuthor')
// Announcements are leader-authored and take no replies; discussion threads are
// member-authored and do. Both have been in the enum since 5a — what phase 5
@@ -42,8 +43,6 @@ const CREATABLE_TYPES_5A = ['announcement']
// look like it would open replies on an announcement.
const REPLYABLE_TYPES = ['discussion']
const DELETED_AUTHOR = '[deleted account]'
/**
* Moderation actions, and what each one does to the row.
*
@@ -74,13 +73,21 @@ const POST_ACTIONS = {
restore: { status: 'visible' },
}
function publicThread(row) {
/**
* One thread's list shape, for one viewer.
*
* `author` follows displayAuthor's rule: a deleted account's snapshot reads
* "[deleted player]" to a member and its stored name to staff, and
* `authorDeleted` says which case it is so the staff view can mark it.
*/
function publicThread(row, viewer = null) {
const author = displayAuthor({ userId: row.created_by, username: row.created_username }, viewer)
return {
id: row.id,
type: row.type,
title: row.title,
author: row.created_username || DELETED_AUTHOR,
authorDeleted: row.created_by == null,
author: author.name,
authorDeleted: author.deleted,
createdAt: row.created_at,
lastPostAt: row.last_post_at,
postCount: row.post_count,
@@ -129,10 +136,11 @@ function editability(row, { userId = null, isStaff = false, windowMinutes = 0, n
* said who is reading.
*/
function renderPost(row, mode, viewer) {
const author = displayAuthor({ userId: row.author_user_id, username: row.author_username }, viewer)
return {
id: row.id,
author: row.author_username || DELETED_AUTHOR,
authorDeleted: row.author_user_id == null,
author: author.name,
authorDeleted: author.deleted,
body: renderForumBody(row.body_html, mode),
createdAt: row.created_at,
editedAt: row.edited_at,
@@ -150,9 +158,9 @@ function renderPost(row, mode, viewer) {
* same call answers both audiences without a second endpoint that could disagree
* with this one.
*/
async function listThreads(teamId, { canModerate = false, limit = 50, offset = 0 } = {}) {
async function listThreads(teamId, { canModerate = false, viewer = null, limit = 50, offset = 0 } = {}) {
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
return rows.map(publicThread)
return rows.map((row) => publicThread(row, viewer))
}
/**
@@ -176,7 +184,7 @@ async function getThread(teamId, threadId, { canModerate = false, viewer } = {})
const mode = await forumSettings.imageMode()
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
return {
...publicThread(thread),
...publicThread(thread, viewer),
// A reply control is offered when the TYPE takes replies and the thread is
// open. Both halves are reported separately (`type`, `locked`) so the UI can
// say which one is why, but the decision itself is made here — a client that

View File

@@ -26,6 +26,7 @@ const teamsDb = require('./teams.db')
const access = require('./teamAccess.model')
const usersDb = require('../users/users.db')
const settingsDb = require('../settings/settings.db')
const { displayAuthor } = require('../../utils/displayAuthor')
// The per-Team ceiling on ACTIVE leader-issued grants. A leader admitting
// unlimited arbitrary accounts to a private space on the operator's host is a
@@ -136,19 +137,25 @@ async function revoke({ team, actor, userId, reason }) {
* The subtraction is the §3.2 "Forum guests" list: someone who is both a member
* and a grantee is a member, listed on the roster, and appears here not at all.
* Both facts stay true in the ledger; only the presentation picks one.
*
* **A grant whose account was deleted is not listed** (ACCOUNT_DELETION.md AD13).
* Deleting an account clears `user_id` and leaves the row, so the grant would
* otherwise sit here as a guest nobody can be: a grant is checked by user id, and
* there is no id left to match it. The staff grant ledger still shows it, with the
* name. Who GRANTED it follows displayAuthor's rule for `viewer`.
*/
async function forumGuests(teamId) {
async function forumGuests(teamId, viewer = null) {
const [grants, members] = await Promise.all([
accessDb.activeGrants(teamId),
teamsDb.membersByTeam(teamId, { includeDeparted: false }),
])
const memberUserIds = new Set(members.map((m) => m.user_id).filter((id) => id != null))
return grants
.filter((g) => g.user_id == null || !memberUserIds.has(g.user_id))
.filter((g) => g.user_id != null && !memberUserIds.has(g.user_id))
.map((g) => ({
userId: g.user_id,
username: g.username,
grantedBy: g.granted_username,
grantedBy: displayAuthor({ userId: g.granted_by, username: g.granted_username }, viewer).name,
grantedAt: g.granted_at,
reason: g.reason,
}))

View File

@@ -197,7 +197,7 @@ async function teamsWithForumActivitySince(since) {
*/
async function digestPostsSince(teamId, since, limit = 20) {
return query(
`SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_username,
`SELECT po.id, po.thread_id, po.body_html, po.created_at, po.author_user_id, po.author_username,
th.title, th.type
FROM team_forum_posts po
JOIN team_forum_threads th ON th.id = po.thread_id

View File

@@ -1,4 +1,4 @@
const { query } = require('../../utils/db')
const { query, transaction } = require('../../utils/db')
const PUBLIC_COLS =
'id, username, role, status, email, email_verified, email_pending, totp_enabled, created_at, last_login_at'
@@ -92,6 +92,31 @@ async function deleteUser(id) {
return query('DELETE FROM users WHERE id = ?', [id])
}
// Delete the account and, when `reserveName` is given, reserve it, as ONE
// transaction (ACCOUNT_DELETION.md §4.1 step 2): a staff name is never free for
// a moment between the two. `INSERT IGNORE` because a name already reserved
// (a second staff account renamed onto it cannot exist, but a hand-inserted row
// can) is the state wanted, not an error. Returns the rows deleted, 0 or 1.
async function deleteUserReserving(id, reserveName = null) {
return transaction(async (conn) => {
if (reserveName) {
await conn.query(
"INSERT IGNORE INTO reserved_usernames (username_norm, reason) VALUES (LOWER(?), 'deleted_staff')",
[reserveName],
)
}
const result = await conn.query('DELETE FROM users WHERE id = ?', [id])
return Number(result.affectedRows || 0)
})
}
// True when the name can never be taken again (AD9). The column's collation is
// users.username's, so this folds case exactly as the UNIQUE index does.
async function isUsernameReserved(username) {
const rows = await query('SELECT 1 FROM reserved_usernames WHERE username_norm = LOWER(?) LIMIT 1', [username])
return rows.length > 0
}
async function countUsers() {
const rows = await query('SELECT COUNT(*) AS c FROM users')
return Number(rows[0].c)
@@ -148,6 +173,8 @@ module.exports = {
listUsers,
updateUser,
deleteUser,
deleteUserReserving,
isUsernameReserved,
countUsers,
countAdmins,
touchLastLogin,

View File

@@ -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,

View File

@@ -523,6 +523,15 @@ function buildApi(record) {
// never writes a module's tables. Called from the seed script only, never
// by the running server, and only with DEMO_MODE on.
onDemoSeed: hook('onDemoSeed'),
// API 1.20.0 (ACCOUNT_DELETION.md §4.1, AD10, AD11). Core telling the module
// an account is about to be deleted, dispatched from auth/accountDeletion.js
// BEFORE the row goes, so the module can still read the person's links and
// undo them through its own unlink — a cascade is not an unlink, and for UO
// the link lives on the shard. It cannot veto: a throw or a hook past core's
// time limit is logged and the deletion goes ahead, because a member's
// deletion request must be honoured. Whatever the module could not finish is
// its own pending work.
onUserDeleting: hook('onUserDeleting'),
}
}
@@ -898,7 +907,12 @@ function load(tierRouters) {
tables: new Set(),
called: new Set(),
hooks: {
onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onDemoSeed: null,
onBoot: null,
onShutdown: null,
onIdentityLinking: null,
onIdentityLinked: null,
onDemoSeed: null,
onUserDeleting: null,
},
client: null,
ctx: null,
@@ -1007,7 +1021,7 @@ function refuseSecond(id, dir, holder) {
staged: null,
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null },
hooks: { onBoot: null, onShutdown: null, onIdentityLinking: null, onIdentityLinked: null, onUserDeleting: null },
client: null,
ctx: null,
state: 'startup_failed',
@@ -1186,6 +1200,23 @@ function identityHooks(name) {
.map((r) => ({ id: r.id, hook: r.hooks[name] }))
}
/**
* The `onUserDeleting` hooks, in registration order (API 1.20.0,
* ACCOUNT_DELETION.md AD10).
*
* `started` only, like the identity hooks above: a disabled module, or one whose
* `onBoot` threw, is not serving and holds no link it could undo now. Empty in a
* process that never loaded modules, so a deletion there is core's alone.
*
* @returns {{id: string, hook: Function}[]}
*/
function userDeletingHooks() {
if (!loaded) return []
return [...modules.values()]
.filter((r) => r.state === 'started' && r.hooks.onUserDeleting)
.map((r) => ({ id: r.id, hook: r.hooks.onUserDeleting }))
}
/**
* Every installed module's `demoLocks`, compiled, for the demo lock in
* demo/locks.js (API 1.18.0).
@@ -1361,6 +1392,7 @@ module.exports = {
bootable,
shutdownHooks,
identityHooks,
userDeletingHooks,
demoLocks,
demoSeedHooks,
stopHook,

View File

@@ -9,6 +9,19 @@
// Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and
// has nothing to say about a website module) and from any module's own version.
// 1.20.0 — a module hears that an account is about to be deleted
// (docs/website/ACCOUNT_DELETION.md §4.1, AD10, AD11). One addition, optional:
// • `api.onUserDeleting(async ({ userId, deletedBy }) => {})`, where
// `deletedBy` is 'self' | 'staff'. Called BEFORE the users row goes, while
// the module can still read the person's links, so it unlinks each game link
// through its own unlink and cleans up anything it keeps without a foreign
// key. It cannot veto: a throw, or running past core's ten seconds, is logged
// and the deletion goes ahead. A module that could not finish keeps its own
// record of what is left and retries.
// Additions only, so minor. Module-uo declares `^1.10.0` and registers none
// until its own PR: with no hook, a deletion is core's cascade alone, exactly as
// the admin route's delete was. USER_SAFETY.md's `reportTargets` moves to 1.21.0.
//
// 1.19.0 — the public demo refuses a step at SAVE (docs/website/DEMO_MODE.md
// §3.4, DM18). An event action may declare `demoRefuses(params)`, returning a
// sentence or null. With DEMO_MODE on, core asks it for every step when a
@@ -254,6 +267,6 @@
// an admin action a module performs belongs in core's one audit log, the
// extension slot needs the user its prefix names, and §2.7 forbids a module
// reading core's `APP_BASE_URL` for itself. Additions only, so minor.
const MODULE_API_VERSION = '1.19.0'
const MODULE_API_VERSION = '1.20.0'
module.exports = { MODULE_API_VERSION }

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,
})

View File

@@ -40,6 +40,37 @@ async function query(sql, params) {
}
}
/**
* Run `fn(conn)` inside one transaction on one connection: committed when it
* resolves, rolled back when it throws (and the error rethrown).
*
* Every statement that must stand or fall together goes through the `conn` it is
* handed, never through `query()` above, which takes a different connection from
* the pool and so sits outside the transaction.
*
* @template T
* @param {(conn: import('mariadb').PoolConnection) => Promise<T>} fn
* @returns {Promise<T>}
*/
async function transaction(fn) {
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
const out = await fn(conn)
await conn.commit()
return out
} catch (err) {
try {
await conn.rollback()
} catch (rollbackErr) {
log.error('rollback failed', { message: rollbackErr.message })
}
throw err
} finally {
conn.release()
}
}
const SCHEMA_PATH = path.join(__dirname, '..', '..', 'db', 'schema.sql')
/**
@@ -106,4 +137,4 @@ async function close() {
await pool.end()
}
module.exports = { pool, query, ensureSchema, close }
module.exports = { pool, query, transaction, ensureSchema, close }

View File

@@ -0,0 +1,52 @@
// ── Who a username snapshot shows as, to whom (ACCOUNT_DELETION.md AD5, AD6) ──
//
// Several tables keep a username next to a user id, on purpose, so a record still
// names someone after the account is gone (§3 gap 2): forum threads, posts and
// uploads, content reports, the forum's moderation ledger, forum grants, leader
// overrides and Team moderation requests. Deleting an account clears the id
// (ON DELETE SET NULL) and leaves the snapshot alone, and nothing else clears
// those ids. So **"id NULL, snapshot present" means a deleted account.**
//
// What a viewer sees then is a rule about the VIEWER, not a change to the row:
//
// • members, Team leaders included, and signed-out visitors see
// "[deleted player]" for every snapshot of a deleted account (AD5);
// • staff — admin, editor, moderator — see the stored name, marked as a
// deleted account, for moderation (AD6).
//
// One function, so the rule cannot drift between the eight places that render a
// snapshot. A caller with a pair that is legitimately empty (a report nobody has
// handled yet) asks `hasActor` first: an absent actor is not a deleted one.
const DELETED_PLAYER = '[deleted player]'
// Every staff role, which is wider than the forum's own "staff" (admin and
// moderator, who may moderate there). An editor cannot moderate a forum and is
// still staff for the question this file answers: may they see who it was.
const STAFF_ROLES = Object.freeze(['admin', 'editor', 'moderator'])
/** True when this viewer sees the stored name of a deleted account. */
function seesDeletedNames(viewer) {
return Boolean(viewer && STAFF_ROLES.includes(viewer.role))
}
/** True when the pair names anybody at all, deleted or not. */
function hasActor(userId, username) {
return userId != null || (username != null && username !== '')
}
/**
* One snapshot, shown to one viewer.
*
* @param {{userId: number|null|undefined, username: string|null|undefined}} who
* @param {{role?: string}|null|undefined} viewer the reader; null for a visitor
* @returns {{name: string, deleted: boolean}}
*/
function displayAuthor({ userId, username }, viewer) {
const deleted = userId == null
if (!deleted) return { name: username || DELETED_PLAYER, deleted: false }
if (username && seesDeletedNames(viewer)) return { name: username, deleted: true }
return { name: DELETED_PLAYER, deleted: true }
}
module.exports = { DELETED_PLAYER, STAFF_ROLES, seesDeletedNames, hasActor, displayAuthor }

View File

@@ -15,7 +15,7 @@
// it says is an `engagement_templates` row rendered by `engagement/templates.js`
// (§4.6.1), which an operator can edit and which falls back to the shipped seed.
//
// **The failure contracts are the point of this file.** Six call sites, five
// **The failure contracts are the point of this file.** Seven call sites, six
// senders, and each one degrades a specific way when mail is unconfigured. Those
// contracts are unchanged by the transport rewrite and are asserted in
// test/mailer.test.js: sendContactMessage returns a mailto fallback, sendInvite
@@ -344,6 +344,41 @@ async function sendEmailVerification({ to, verifyUrl, username }) {
}
}
/**
* Tell an address its account was deleted (ACCOUNT_DELETION.md §4.1 step 3).
*
* Sent after the row is gone, to the address read before it went, so nothing
* here can reach the account. `by` decides one phrase: a member deleting their
* own account reads "at your request", a staff deletion "by the site's staff".
*
* Returns { sent: false, reason: 'NOT_CONFIGURED' } when mail is unconfigured
* and throws on a send failure, like the senders above; the deletion's caller
* has already finished and only logs either.
*/
async function sendAccountDeleted({ to, username, deletedOn, by }) {
const built = await buildTransport()
if (!built) return { sent: false, reason: 'NOT_CONFIGURED' }
const { transport, config } = built
const byWhom = by === 'staff' ? ' by the site’s staff' : ' at your request'
const { subject, html, text } = await body('auth.account-deleted', { username, deletedOn, byWhom })
try {
await transport.sendMail({
from: fromHeader(config),
to,
replyTo: replyToFor(config),
subject,
text,
html,
})
await emailConfig.recordStatus({ status: 'connected', statusDetail: 'Account deletion notice send OK', lastVerifiedAt: new Date() })
return { sent: true }
} catch (err) {
log.error('account deletion notice send failed', err)
await emailConfig.recordStatus({ status: 'error', statusDetail: describeSendError(err, config) })
throw err
}
}
/**
* Send an ALREADY-RENDERED body to one address — the template editor's test send
* (§4.6.2, Phase 5b).
@@ -481,5 +516,6 @@ module.exports = {
sendInvite,
sendPasswordReset,
sendEmailVerification,
sendAccountDeleted,
PERMANENT_CODES,
}

View File

@@ -42,6 +42,7 @@ const templates = require('../engagement/templates')
const emailChannel = require('../engagement/emailChannel')
const mailer = require('./mailer')
const notify = require('./teamNotify')
const { DELETED_PLAYER } = require('./displayAuthor')
const log = require('./logger')('team-digest')
const INTERVAL_MS = Number(process.env.TEAM_DIGEST_INTERVAL_MS) || 24 * 60 * 60 * 1000
@@ -127,7 +128,10 @@ async function sendOne(team, recipient, rule, now) {
periodLabel: `${label}: ${posts.length} new post${posts.length === 1 ? '' : 's'}`,
intro: `Since your last digest, ${posts.length} new post${posts.length === 1 ? '' : 's'} in ${label}:`,
items: posts.map((p) => ({
heading: `${p.title} — ${p.author_username || 'someone'}`,
// A digest goes to members, who see a deleted author as "[deleted player]"
// (ACCOUNT_DELETION.md AD5). The post outlives its author on purpose; the
// name in it does not reach a member's inbox.
heading: `${p.title} — ${p.author_user_id === null ? DELETED_PLAYER : p.author_username || 'someone'}`,
excerpt: notify.excerpt(p.body_html),
url: notify.threadPath(team, p.thread_id),
})),

View File

@@ -12861,6 +12861,102 @@
"bearerAuth": []
}
]
},
"delete": {
"tags": [
"Auth · Me"
],
"summary": "Delete the current account (self, any role)",
"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.",
"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
}
}
}
}
}
},
"400": {
"description": "username_mismatch, wrong_password, code_required or wrong_code",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteAccountRefusal"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Demo login (DEMO_LOCKED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DemoLocked"
}
}
}
},
"409": {
"description": "password_required (the account has no password yet) or last_admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteAccountRefusal"
}
}
}
},
"429": {
"description": "Too many attempts (rate limited)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteAccountRequest"
}
}
}
}
}
},
"/api/v1/auth/me/account/email": {
@@ -21590,6 +21686,148 @@
}
}
},
"DeleteAccountRequest": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"required": {
"type": "array",
"example": [
"username",
"password"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "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": {
"type": "object",
"properties": {
"username": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "wanderer"
},
"description": {
"type": "string",
"example": "The account's username, typed to confirm; compared ignoring case."
}
}
},
"password": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "password"
}
}
},
"code": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "123456"
},
"description": {
"type": "string",
"example": "A current authenticator code."
}
}
},
"recoveryCode": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "ABCD-EFGH"
},
"description": {
"type": "string",
"example": "A single-use recovery code, instead of `code`."
}
}
}
}
}
}
},
"DeleteAccountRefusal": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"code": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"username_mismatch",
"wrong_password",
"code_required",
"wrong_code",
"password_required",
"last_admin"
],
"items": {
"type": "string"
}
}
}
},
"message": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Your password is incorrect."
}
}
}
}
}
}
},
"ChangePasswordRequest": {
"type": "object",
"properties": {
@@ -23811,7 +24049,7 @@
},
"description": {
"type": "string",
"example": "Username snapshot; \"[deleted account]\" once the account is gone."
"example": "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)."
}
}
},
@@ -23918,6 +24156,19 @@
}
}
},
"handledByDeleted": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"handledNote": {
"type": "object",
"properties": {
@@ -24124,6 +24375,23 @@
}
}
},
"authorDeleted": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "The author's account was deleted; `author` is the stored name, shown to staff (ACCOUNT_DELETION.md AD6)."
}
}
},
"uploader": {
"type": "object",
"properties": {
@@ -24137,6 +24405,19 @@
}
}
},
"uploaderDeleted": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"nullable": {
"type": "boolean",
"example": true
}
}
},
"excerpt": {
"type": "object",
"properties": {

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 },

View File

@@ -0,0 +1,389 @@
// ── Deleting an account (docs/website/ACCOUNT_DELETION.md §4.1) ────────────
//
// The service's procedure, the member's route and its refusals, the admin
// route going through the same procedure, and reserved staff usernames. Every
// model call is stubbed; the DB pool points at a dead port (test/_setup.js).
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const users = require('../src/model/users/users.model')
const usersDb = require('../src/model/users/users.db')
const activity = require('../src/model/activity/activity.model')
const teamSync = require('../src/model/teams/teamSync.model')
const recoveryCodes = require('../src/model/recoveryCodes/recoveryCodes.model')
const loader = require('../src/modules/loader')
const mailer = require('../src/utils/mailer')
const totp = require('../src/utils/totp')
const loginProtection = require('../src/middleware/loginProtection')
const botScore = require('../src/middleware/botScore')
const accountDeletion = require('../src/auth/accountDeletion')
const account = require('../src/router/v1/auth/account.controller')
const admin = require('../src/router/v1/admin/admin.controller')
const db = require('../src/utils/db')
after(() => db.close())
// ── stubbing ───────────────────────────────────────────────────────────────
const saved = []
function patch(obj, key, value) {
saved.push([obj, key, obj[key]])
obj[key] = value
}
afterEach(() => {
while (saved.length) {
const [obj, key, value] = saved.pop()
obj[key] = value
}
delete process.env.DEMO_MODE
})
// What the procedure did, in order.
let events
let logged
let mailed
const ACCOUNTS = {
7: { id: 7, username: 'Thorne', role: 'player', email: 'thorne@example.com' },
8: { id: 8, username: 'Ravenna', role: 'moderator', email: null },
9: { id: 9, username: 'boss', role: 'admin', email: 'boss@example.com' },
3: { id: 3, username: 'demo-player', role: 'player', email: null },
}
beforeEach(() => {
events = []
logged = []
mailed = []
patch(users, 'getById', async (id) => (ACCOUNTS[id] ? { ...ACCOUNTS[id] } : null))
patch(users, 'countAdmins', async () => 2)
patch(users, 'removeReserving', async (id, name) => {
events.push(['delete', id, name])
return ACCOUNTS[id] ? 1 : 0
})
patch(loader, 'userDeletingHooks', () => [])
patch(teamSync, 'request', (opts) => events.push(['teams', opts.reason]))
patch(activity, 'log', async (entry) => { logged.push(entry) })
patch(mailer, 'sendAccountDeleted', async (args) => {
mailed.push(args)
return { sent: true }
})
})
function mockRes() {
return {
statusCode: 200,
body: null,
cleared: [],
status(c) { this.statusCode = c; return this },
json(b) { this.body = b; return this },
clearCookie(name) { this.cleared.push(name); return this },
set() { return this },
}
}
// ── the service ────────────────────────────────────────────────────────────
test('modules are asked BEFORE the row goes, with who deleted it (AD10)', async () => {
patch(loader, 'userDeletingHooks', () => [
{ id: 'rust', hook: async (payload) => { events.push(['hook', payload]) } },
])
const result = await accountDeletion.deleteAccount(7, { by: 'self', req: { ip: '10.0.0.1' } })
await result.settled
assert.equal(result.ok, true)
assert.deepEqual(events.slice(0, 2), [
['hook', { userId: 7, deletedBy: 'self' }],
['delete', 7, null],
])
})
test('the hook payload is frozen, so a module cannot rewrite what the next one hears', async () => {
let seen
patch(loader, 'userDeletingHooks', () => [{ id: 'rust', hook: async (p) => { seen = p } }])
await (await accountDeletion.deleteAccount(7, { by: 'staff' })).settled
assert.equal(Object.isFrozen(seen), true)
assert.deepEqual(seen, { userId: 7, deletedBy: 'staff' })
})
test('a module hook that throws never stops the deletion (AD11)', async () => {
patch(loader, 'userDeletingHooks', () => [
{ id: 'uo', hook: async () => { throw new Error('sidecar down') } },
])
const result = await accountDeletion.deleteAccount(7, { by: 'self' })
await result.settled
assert.equal(result.ok, true)
assert.deepEqual(result.modules, [{ module: 'uo', ok: false, error: 'sidecar down' }])
assert.deepEqual(events.find((e) => e[0] === 'delete'), ['delete', 7, null])
})
test('a module hook that hangs is cut off at the time limit and the deletion goes ahead', async () => {
patch(loader, 'userDeletingHooks', () => [{ id: 'uo', hook: () => new Promise(() => {}) }])
const result = await accountDeletion.deleteAccount(7, { by: 'self', hookTimeoutMs: 20 })
await result.settled
assert.equal(result.ok, true)
assert.equal(result.modules[0].ok, false)
assert.match(result.modules[0].error, /no answer within 20 ms/)
assert.equal(accountDeletion.HOOK_TIMEOUT_MS, 10_000)
})
test('a staff account’s username is reserved in the deletion’s transaction; a player’s is not (AD9)', async () => {
const staff = await accountDeletion.deleteAccount(8, { by: 'staff', req: { user: { id: 9 } } })
await staff.settled
assert.equal(staff.reserved, true)
const player = await accountDeletion.deleteAccount(7, { by: 'self' })
await player.settled
assert.equal(player.reserved, false)
assert.deepEqual(events.filter((e) => e[0] === 'delete'), [['delete', 8, 'Ravenna'], ['delete', 7, null]])
})
test('the last admin is refused before any module is asked', async () => {
let asked = false
patch(users, 'countAdmins', async () => 1)
patch(loader, 'userDeletingHooks', () => [{ id: 'rust', hook: async () => { asked = true } }])
const result = await accountDeletion.deleteAccount(9, { by: 'self' })
assert.deepEqual(result, { ok: false, reason: 'last_admin' })
assert.equal(asked, false)
assert.equal(events.length, 0)
})
test('the demo’s shared logins are refused by the service too, behind the demo lock', async () => {
process.env.DEMO_MODE = 'true'
assert.deepEqual(await accountDeletion.deleteAccount(3, { by: 'staff' }), { ok: false, reason: 'demo' })
assert.equal(events.length, 0)
// Off the demo the same username is just a name.
delete process.env.DEMO_MODE
assert.equal((await accountDeletion.deleteAccount(3, { by: 'staff' })).ok, true)
})
test('an account that is already gone is not_found and nothing runs after it', async () => {
assert.deepEqual(await accountDeletion.deleteAccount(404, { by: 'self' }), { ok: false, reason: 'not_found' })
// Gone between the read and the delete: somebody else's deletion won.
patch(users, 'removeReserving', async () => 0)
assert.deepEqual(await accountDeletion.deleteAccount(7, { by: 'self' }), { ok: false, reason: 'not_found' })
assert.equal(logged.length, 0)
assert.equal(mailed.length, 0)
})
test('after commit: a Teams reconcile, an activity entry with no username, and the notice', async () => {
const result = await accountDeletion.deleteAccount(7, { by: 'self', req: { ip: '10.0.0.1', user: { id: 7 } } })
await result.settled
assert.deepEqual(events.find((e) => e[0] === 'teams'), ['teams', 'account deleted'])
// The member is gone, so the entry carries no user id — only the IP (AD6).
assert.equal(logged.length, 1)
assert.equal(logged[0].action, 'user.self_delete')
assert.deepEqual(logged[0].detail, { id: 7 })
assert.equal(logged[0].req.user, undefined)
assert.equal(logged[0].req.ip, '10.0.0.1')
assert.ok(!JSON.stringify(logged[0]).includes('Thorne'), 'the username is never logged')
assert.equal(mailed.length, 1)
assert.equal(mailed[0].to, 'thorne@example.com')
assert.equal(mailed[0].username, 'Thorne')
assert.equal(mailed[0].by, 'self')
assert.match(mailed[0].deletedOn, /^\d{1,2} [A-Z][a-z]+ \d{4}$/)
})
test('a staff deletion is logged as user.delete against the acting admin', async () => {
const req = { ip: '10.0.0.2', user: { id: 9 } }
await (await accountDeletion.deleteAccount(8, { by: 'staff', req })).settled
assert.equal(logged[0].action, 'user.delete')
assert.equal(logged[0].req, req)
assert.deepEqual(logged[0].detail, { id: 8, usernameReserved: true })
// No address on file, so no notice.
assert.equal(mailed.length, 0)
})
test('a failing mail or audit step after commit never rejects', async () => {
patch(mailer, 'sendAccountDeleted', async () => { throw new Error('smtp down') })
patch(activity, 'log', async () => { throw new Error('db gone') })
patch(teamSync, 'request', () => { throw new Error('sync broken') })
const result = await accountDeletion.deleteAccount(7, { by: 'self' })
await result.settled // resolves
assert.equal(result.ok, true)
})
test('by must say who deleted it', async () => {
await assert.rejects(() => accountDeletion.deleteAccount(7, {}), /by must be one of/)
})
// ── DELETE /auth/me/account ────────────────────────────────────────────────
const RAW = {
id: 7, username: 'Thorne', role: 'player', password_hash: '$2a$hash', totp_enabled: 0, totp_secret: null,
}
let failures
let deleted
function stubSelf(raw = RAW, { passwordOk = true } = {}) {
failures = 0
deleted = null
patch(users, 'getRawById', async () => (raw ? { ...raw } : null))
patch(users, 'validatePassword', async () => passwordOk)
patch(loginProtection, 'recordFailure', () => { failures += 1 })
patch(botScore, 'recordLoginFailure', () => {})
patch(accountDeletion, 'deleteAccount', async (id, opts) => {
deleted = { id, by: opts.by }
return { ok: true, id, settled: Promise.resolve() }
})
}
const selfReq = (body) => ({ body, user: { id: 7, username: 'Thorne' }, ip: '10.0.0.1', headers: {}, cookies: {} })
test('the typed username must match, ignoring case, before the password is even checked', async () => {
let checked = false
stubSelf()
patch(users, 'validatePassword', async () => { checked = true; return true })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorn', password: 'pw' }), res)
assert.equal(res.statusCode, 400)
assert.equal(res.body.code, 'username_mismatch')
assert.equal(checked, false)
assert.equal(deleted, null)
const ok = mockRes()
await account.deleteAccount(selfReq({ username: ' thorne ', password: 'pw' }), ok)
assert.equal(ok.statusCode, 200)
assert.deepEqual(ok.body, { deleted: true })
assert.deepEqual(deleted, { id: 7, by: 'self' })
})
test('an account with no password is told to set one first (AD7, 409 password_required)', async () => {
stubSelf({ ...RAW, password_hash: null })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne' }), res)
assert.equal(res.statusCode, 409)
assert.equal(res.body.code, 'password_required')
assert.equal(deleted, null)
})
test('a wrong password is a 400, never a 401, and counts as a failed login (AD12)', async () => {
stubSelf(RAW, { passwordOk: false })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'nope' }), res)
assert.equal(res.statusCode, 400)
assert.equal(res.body.code, 'wrong_password')
assert.equal(failures, 1)
assert.equal(deleted, null)
})
test('with two-factor on, a code is required, and a wrong one is a 400', async () => {
stubSelf({ ...RAW, totp_enabled: 1, totp_secret: 'SECRET' })
patch(totp, 'verifyCode', (secret, code) => secret === 'SECRET' && code === '123456')
const none = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), none)
assert.equal(none.statusCode, 400)
assert.equal(none.body.code, 'code_required')
const wrong = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', code: '000000' }), wrong)
assert.equal(wrong.statusCode, 400)
assert.equal(wrong.body.code, 'wrong_code')
assert.equal(failures, 1)
assert.equal(deleted, null)
const right = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', code: '123456' }), right)
assert.equal(right.statusCode, 200)
assert.deepEqual(deleted, { id: 7, by: 'self' })
})
test('a recovery code stands in for the authenticator, and is spent', async () => {
stubSelf({ ...RAW, totp_enabled: 1, totp_secret: 'SECRET' })
const spent = []
patch(recoveryCodes, 'consumeForUser', async (id, code) => { spent.push([id, code]); return code === 'ABCD-EFGH' })
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw', recoveryCode: 'ABCD-EFGH' }), res)
assert.equal(res.statusCode, 200)
assert.deepEqual(spent, [[7, 'ABCD-EFGH']])
})
test('success clears this browser’s session and trust cookies', async () => {
stubSelf()
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
const { COOKIE_NAME, TRUST_COOKIE_NAME } = require('../src/auth/token')
assert.deepEqual(res.cleared.sort(), [COOKIE_NAME, TRUST_COOKIE_NAME].sort())
})
test('the last admin deleting themself is a 409 with a code the screen can explain', async () => {
stubSelf({ ...RAW, role: 'admin' })
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'last_admin' }))
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
assert.equal(res.statusCode, 409)
assert.equal(res.body.code, 'last_admin')
})
test('a demo login reaching the handler gets the demo’s own refusal', async () => {
stubSelf()
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'demo' }))
const res = mockRes()
await account.deleteAccount(selfReq({ username: 'Thorne', password: 'pw' }), res)
assert.equal(res.statusCode, 403)
assert.equal(res.body.code, 'DEMO_LOCKED')
})
// ── DELETE /admin/users/:id ────────────────────────────────────────────────
test('the admin route runs the same procedure, by staff, and keeps its 400s', async () => {
let calledWith = null
patch(accountDeletion, 'deleteAccount', async (id, opts) => {
calledWith = { id, by: opts.by }
return { ok: true, id, settled: Promise.resolve() }
})
const res = mockRes()
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 }, ip: '10.0.0.2' }, res)
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body, { id: 8 })
assert.deepEqual(calledWith, { id: 8, by: 'staff' })
const self = mockRes()
await admin.deleteUser({ params: { id: '9' }, user: { id: 9 } }, self)
assert.equal(self.statusCode, 400)
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'last_admin' }))
const last = mockRes()
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 } }, last)
assert.equal(last.statusCode, 400)
assert.match(last.body.message, /last admin/)
patch(accountDeletion, 'deleteAccount', async () => ({ ok: false, reason: 'not_found' }))
const missing = mockRes()
await admin.deleteUser({ params: { id: '8' }, user: { id: 9 } }, missing)
assert.equal(missing.statusCode, 404)
})
// ── reserved usernames (AD9) ───────────────────────────────────────────────
test('a reserved name reads as a taken one wherever a username is written', async () => {
patch(usersDb, 'isUsernameReserved', async (name) => name.toLowerCase() === 'ravenna')
patch(usersDb, 'insertUser', async () => { throw new Error('must not insert') })
patch(usersDb, 'updateUser', async () => { throw new Error('must not update') })
const created = await users.createUser({ username: 'RAVENNA', password: 'pw12345678', role: 'player' }).catch((e) => e)
assert.equal(created.code, users.USERNAME_RESERVED)
assert.equal(users.isDuplicateUsername(created), true)
assert.equal(users.isDuplicateEmail(created), false)
const renamed = await users.update(7, { username: 'Ravenna' }).catch((e) => e)
assert.equal(users.isDuplicateUsername(renamed), true)
})
test('a self-service rename onto a reserved name answers the taken-name 409', async () => {
patch(usersDb, 'isUsernameReserved', async () => true)
const res = mockRes()
await account.changeUsername({ body: { username: 'Ravenna' }, user: { id: 7, username: 'Thorne' }, headers: {} }, res)
assert.equal(res.statusCode, 409)
assert.equal(res.body.message, 'That username is already taken.')
})
test('a name that is not reserved goes through untouched', async () => {
patch(usersDb, 'isUsernameReserved', async () => false)
let inserted = null
patch(usersDb, 'insertUser', async (row) => { inserted = row; return 70 })
patch(usersDb, 'findById', async () => ({ id: 70, username: 'Newcomer', role: 'player' }))
const user = await users.createUser({ username: 'Newcomer', role: 'player' })
assert.equal(user.id, 70)
assert.equal(inserted.username, 'Newcomer')
})

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 () => {

View File

@@ -234,6 +234,7 @@ const SELF_ROWS = [
['POST', '/auth/me/account/recovery-codes/generate'],
['DELETE', '/auth/me/account/identities/steam'],
['DELETE', '/auth/me/sessions/12'], // DM17
['DELETE', '/auth/me/account'], // ACCOUNT_DELETION.md §4.3
]
test('signed in as a shared login, the account cannot be changed or other sessions revoked', async () => {

View File

@@ -0,0 +1,159 @@
// ── Who a deleted account's snapshots show as (ACCOUNT_DELETION.md AD5, AD6) ──
//
// The rule itself, then each place a member can read a snapshot: forum threads
// and posts, the leader's guest list, and the Team digest mail. The Reports
// queue (staff-only) is covered in contentReports.test.js.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const { displayAuthor, DELETED_PLAYER, seesDeletedNames, hasActor } = require('../src/utils/displayAuthor')
const forum = require('../src/model/teams/teamForum.model')
const grants = require('../src/model/teams/teamGrants.model')
const accessDb = require('../src/model/teams/teamAccess.db')
const teamsDb = require('../src/model/teams/teams.db')
const db = require('../src/utils/db')
after(() => db.close())
const saved = []
function patch(obj, key, value) {
saved.push([obj, key, obj[key]])
obj[key] = value
}
afterEach(() => {
while (saved.length) {
const [obj, key, value] = saved.pop()
obj[key] = value
}
})
const MEMBER = { role: 'player' }
const STAFF = ['admin', 'editor', 'moderator'].map((role) => ({ role }))
// ── the rule ───────────────────────────────────────────────────────────────
test('a live account shows its name to everyone', () => {
for (const viewer of [null, MEMBER, ...STAFF]) {
assert.deepEqual(displayAuthor({ userId: 4, username: 'Thorne' }, viewer), { name: 'Thorne', deleted: false })
}
})
test('a deleted account reads "[deleted player]" to members and visitors (AD5)', () => {
assert.equal(DELETED_PLAYER, '[deleted player]')
for (const viewer of [null, undefined, MEMBER, { role: 'leader' }, {}]) {
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: DELETED_PLAYER, deleted: true })
}
})
test('staff — admin, editor and moderator — see the stored name, marked deleted (AD6)', () => {
for (const viewer of STAFF) {
assert.equal(seesDeletedNames(viewer), true)
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: 'Ravenna', deleted: true })
}
// With no snapshot at all there is no name to show anybody.
assert.deepEqual(displayAuthor({ userId: null, username: null }, STAFF[0]), { name: DELETED_PLAYER, deleted: true })
})
test('an absent actor is not a deleted one', () => {
assert.equal(hasActor(null, null), false)
assert.equal(hasActor(null, ''), false)
assert.equal(hasActor(null, 'OldMod'), true)
assert.equal(hasActor(5, null), true)
})
// ── forum threads and posts ────────────────────────────────────────────────
const threadRow = (over = {}) => ({
id: 5, type: 'discussion', title: 'Raid night', created_by: null, created_username: 'Ravenna',
created_at: new Date(), last_post_at: new Date(), post_count: 2, pinned: 0, locked: 0, status: 'visible', ...over,
})
const postRow = (over = {}) => ({
id: 80, thread_id: 5, author_user_id: null, author_username: 'Ravenna', body_html: '<p>Guide</p>',
created_at: new Date(), edited_at: null, status: 'visible', ...over,
})
test('a forum thread by a deleted account: "[deleted player]" to a member, the name to staff', () => {
assert.equal(forum.publicThread(threadRow(), MEMBER).author, DELETED_PLAYER)
assert.equal(forum.publicThread(threadRow()).author, DELETED_PLAYER) // no viewer said
assert.equal(forum.publicThread(threadRow(), MEMBER).authorDeleted, true)
for (const viewer of STAFF) {
const t = forum.publicThread(threadRow(), viewer)
assert.equal(t.author, 'Ravenna')
assert.equal(t.authorDeleted, true)
}
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).author, 'Ravenna')
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).authorDeleted, false)
})
test('a forum post by a deleted account follows the same rule, and is nobody’s to edit', () => {
const asMember = forum.renderPost(postRow(), 'disabled', { userId: 9, role: 'player', isStaff: false })
assert.equal(asMember.author, DELETED_PLAYER)
assert.equal(asMember.authorDeleted, true)
assert.equal(asMember.mine, false)
assert.equal(asMember.canEdit, false)
const asEditor = forum.renderPost(postRow(), 'disabled', { userId: 2, role: 'editor', isStaff: false })
assert.equal(asEditor.author, 'Ravenna')
assert.equal(asEditor.authorDeleted, true)
})
test('the thread list and a thread pass the viewer down to every row', async () => {
const forumDb = require('../src/model/teams/teamForum.db')
const forumSettings = require('../src/model/teams/teamForumSettings.model')
patch(forumDb, 'threadsByTeam', async () => [threadRow()])
patch(forumDb, 'threadById', async () => ({ ...threadRow(), team_id: 1 }))
patch(forumDb, 'postsByThread', async () => [postRow()])
patch(forumSettings, 'imageMode', async () => 'disabled')
const [listedForMember] = await forum.listThreads(1, { viewer: MEMBER })
assert.equal(listedForMember.author, DELETED_PLAYER)
const [listedForStaff] = await forum.listThreads(1, { viewer: { role: 'moderator' } })
assert.equal(listedForStaff.author, 'Ravenna')
const thread = await forum.getThread(1, 5, { viewer: { userId: 9, role: 'player' } })
assert.equal(thread.author, DELETED_PLAYER)
assert.equal(thread.posts[0].author, DELETED_PLAYER)
const staffThread = await forum.getThread(1, 5, { canModerate: true, viewer: { userId: 2, role: 'admin', isStaff: true } })
assert.equal(staffThread.author, 'Ravenna')
assert.equal(staffThread.posts[0].author, 'Ravenna')
})
// ── the leader's guest list ────────────────────────────────────────────────
test('a deleted account is not a forum guest, and a deleted granter reads "[deleted player]" (AD13)', async () => {
patch(accessDb, 'activeGrants', async () => [
{ user_id: 21, username: 'Guest', granted_by: null, granted_username: 'OldLeader', granted_at: new Date(), reason: null },
{ user_id: null, username: 'Ravenna', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
{ user_id: 22, username: 'Member', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
])
patch(teamsDb, 'membersByTeam', async () => [{ user_id: 22 }])
const guests = await grants.forumGuests(1, MEMBER)
assert.deepEqual(guests.map((g) => g.username), ['Guest'])
assert.equal(guests[0].grantedBy, DELETED_PLAYER)
const forStaff = await grants.forumGuests(1, { role: 'moderator' })
assert.equal(forStaff[0].grantedBy, 'OldLeader')
})
// ── the Team digest ────────────────────────────────────────────────────────
test('the digest mail names a deleted author "[deleted player]"', async () => {
const worker = require('../src/utils/teamDigestWorker')
const teamNotify = require('../src/model/teams/teamNotify.model')
const templates = require('../src/engagement/templates')
let items = null
patch(teamNotify, 'digestPostsSince', async () => [
{ id: 1, thread_id: 5, body_html: '<p>a</p>', created_at: new Date(), author_user_id: null, author_username: 'Ravenna', title: 'Raid night', type: 'discussion' },
{ id: 2, thread_id: 5, body_html: '<p>b</p>', created_at: new Date(), author_user_id: 4, author_username: 'Thorne', title: 'Raid night', type: 'discussion' },
])
patch(templates, 'renderByKey', async (key, values) => {
items = values.items
return null // stop before anything is sent
})
await worker.sendOne({ id: 1, slug: 'wolves', name: 'Iron Wolves' }, { user_id: 9, last_digest_at: null }, null, new Date())
assert.deepEqual(items.map((i) => i.heading), ['Raid night — [deleted player]', 'Raid night — Thorne'])
})

View File

@@ -299,12 +299,13 @@ test('every shipped template validates against the email registry', () => {
assert.equal(typeof seed.seedVersion, 'number')
assert.equal(['email', 'inapp'].includes(seed.channel), true)
}
// The five transactional bodies the system itself depends on are protected.
// The six transactional bodies the system itself depends on are protected.
const guarded = SEEDS.filter((s) => s.protected).map((s) => s.key)
assert.deepEqual(guarded, [
'auth.password-reset',
'auth.invite',
'auth.email-verify',
'auth.account-deleted',
'admin.contact-message',
'admin.test',
])

View File

@@ -112,7 +112,7 @@ test('the version a module declares against satisfies ^1.10.0', () => {
// 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the
// assertion that says it does. The number itself is pinned in version.js.
assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION)
assert.equal(MODULE_API_VERSION, '1.19.0')
assert.equal(MODULE_API_VERSION, '1.20.0')
})
test('a module registers actions, budgets, leases and option sources', () => {

View File

@@ -0,0 +1,87 @@
// ── api.onUserDeleting (MODULE_API 1.20.0, ACCOUNT_DELETION.md AD10) ───────
//
// Registration and collection only; what the deletion does with the hook is in
// accountDeletion.test.js. Each test builds a throwaway modules directory and
// re-requires the loader, as moduleLoader.test.js does.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, after } = require('node:test')
const assert = require('node:assert/strict')
const express = require('express')
const db = require('../src/utils/db')
const registries = require('../src/modules/registries')
after(() => db.close())
const emptyTiers = () => ({ public: express.Router(), admin: express.Router(), player: express.Router() })
function loadOne(server) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-deleting-'))
const dir = path.join(root, 'rust')
fs.mkdirSync(dir)
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
id: 'rust',
name: 'Rust',
version: '1.0.0',
coreApi: '^1.20.0',
server: 'index.js',
mounts: { admin: ['/rust'] },
}))
fs.writeFileSync(path.join(dir, 'index.js'), server)
process.env.MODULES_DIR = root
registries._reset()
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
loader.load(emptyTiers())
return loader
}
const stateOf = (loader) => loader.list().find((m) => m.id === 'rust')
test('onUserDeleting is collected once the module is started, and not before', async () => {
const loader = loadOne(`module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
api.onUserDeleting(async (p) => { global.__deleting = p })
}`)
assert.equal(stateOf(loader).state, 'registered')
// A registered module has not booted: it holds no link it could undo yet.
assert.deepEqual(loader.userDeletingHooks(), [])
loader.setState('rust', 'started')
const hooks = loader.userDeletingHooks()
assert.deepEqual(hooks.map((h) => h.id), ['rust'])
await hooks[0].hook({ userId: 7, deletedBy: 'self' })
assert.deepEqual(global.__deleting, { userId: 7, deletedBy: 'self' })
delete global.__deleting
loader.setState('rust', 'disabled')
assert.deepEqual(loader.userDeletingHooks(), [])
})
test('a module that registers no hook adds nothing', () => {
const loader = loadOne(`module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
}`)
loader.setState('rust', 'started')
assert.deepEqual(loader.userDeletingHooks(), [])
})
test('onUserDeleting twice, or with no function, is a registration error', () => {
for (const [body, expected] of [
['api.onUserDeleting(() => {}); api.onUserDeleting(() => {})', /onUserDeleting\(\) called twice/],
['api.onUserDeleting("later")', /onUserDeleting: expected a function/],
]) {
const loader = loadOne(`module.exports = (ctx, api) => {
api.registerRoutes({ admin: { '/rust': ctx.express.Router() } })
${body}
}`)
assert.equal(stateOf(loader).state, 'startup_failed')
assert.match(stateOf(loader).reason, expected)
}
})