diff --git a/client/src/api/client.js b/client/src/api/client.js
index bfa41b6..8f1d9aa 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -117,6 +117,10 @@ export const api = {
// The change endpoints re-issue the session cookie server-side, so the caller
// stays signed in.
myAccount: () => req('/auth/me/account'),
+ // Delete the signed-in account (ACCOUNT_DELETION.md §4.1). The password always,
+ // and `code` or `recoveryCode` when two-factor is on; refusals carry a `code`.
+ deleteMyAccount: ({ username, password, code, recoveryCode }) =>
+ req('/auth/me/account', { method: 'DELETE', body: { username, password, code, recoveryCode } }),
changeUsername: (username) =>
req('/auth/me/account/username', { method: 'PATCH', body: { username } }),
changePassword: (newPassword, currentPassword) =>
diff --git a/client/src/components/DemoToast.jsx b/client/src/components/DemoToast.jsx
index 5ca6a5a..dba913d 100644
--- a/client/src/components/DemoToast.jsx
+++ b/client/src/components/DemoToast.jsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { onDemoLocked } from '../lib/demoNotice.js'
+import { onNotice } from '../lib/notice.js'
// The one toast for a write the public demo refused (DEMO_MODE.md §3.1). Fed
// by api/client.js through lib/demoNotice.js, so every screen — and every
@@ -11,6 +12,9 @@ export default function DemoToast() {
const [message, setMessage] = useState(null)
useEffect(() => onDemoLocked((text) => setMessage(text)), [])
+ // Also the site's one passing notice (lib/notice.js), on any deployment: the
+ // account page's "Your account has been deleted." after it has navigated home.
+ useEffect(() => onNotice((text) => setMessage(text)), [])
useEffect(() => {
if (!message) return undefined
diff --git a/client/src/components/security/DeleteAccountPanel.jsx b/client/src/components/security/DeleteAccountPanel.jsx
new file mode 100644
index 0000000..fdaca2d
--- /dev/null
+++ b/client/src/components/security/DeleteAccountPanel.jsx
@@ -0,0 +1,178 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAuth } from '../../contexts/AuthContext.jsx'
+import { useSite } from '../../contexts/SiteContext.jsx'
+import { api } from '../../api/client.js'
+import { showNotice } from '../../lib/notice.js'
+import {
+ WHAT_GOES, WHAT_STAYS, STAFF_NAME_NOTE, isStaffRole, usernameMatches, isSharedDemoLogin, refusalMessage,
+} from '../../lib/accountDeletion.js'
+
+// "Delete my account" (docs/website/ACCOUNT_DELETION.md §4.2). Last on the
+// Account page and set apart from the sections above it.
+//
+// The account goes at once — there is no grace period (AD4) — so the safeguard
+// is the confirm step: the typed username, the password, and a two-factor code
+// when two-factor is on. Delete stays off until the username matches.
+//
+// On success the server has already dropped every session. This screen sends
+// the browser home, re-reads the session (which is now gone) and says so.
+
+const DANGER = '#d98b84'
+
+export default function DeleteAccountPanel({ account }) {
+ const { refresh } = useAuth()
+ const { settings } = useSite()
+ const navigate = useNavigate()
+
+ const [open, setOpen] = useState(false)
+ const [typed, setTyped] = useState('')
+ const [password, setPassword] = useState('')
+ const [code, setCode] = useState('')
+ const [useRecovery, setUseRecovery] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+
+ const demoLocked = isSharedDemoLogin(settings, account.username)
+ const noPassword = account.has_password === false
+ const needsCode = Boolean(account.totp_enabled)
+ const ready = usernameMatches(typed, account.username) && password.length > 0 && (!needsCode || code.trim().length > 0)
+
+ function close() {
+ setOpen(false)
+ setTyped('')
+ setPassword('')
+ setCode('')
+ setUseRecovery(false)
+ setError('')
+ }
+
+ async function confirm(e) {
+ e.preventDefault()
+ if (!ready || busy) return
+ setBusy(true)
+ setError('')
+ try {
+ await api.deleteMyAccount({
+ username: typed.trim(),
+ password,
+ ...(needsCode ? (useRecovery ? { recoveryCode: code.trim() } : { code: code.trim() }) : {}),
+ })
+ navigate('/', { replace: true })
+ showNotice('Your account has been deleted.')
+ await refresh()
+ } catch (err) {
+ setError(refusalMessage(err))
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+ Delete my account
+
+
+
+ Your account is deleted straight away and cannot be restored. These go with it:
+
+
+ {WHAT_GOES.map((line) => {line} )}
+
+
What stays:
+
+ {WHAT_STAYS.map((line) => {line} )}
+
+ {isStaffRole(account.role) &&
{STAFF_NAME_NOTE}
}
+
+
+ {noPassword && (
+
+ This account has no password yet. Set one in the Password section above first; the password is how you
+ confirm a deletion.
+
+ )}
+
+ {!open && (
+
+
setOpen(true)}
+ disabled={demoLocked || noPassword}
+ className="btn btn-sq"
+ style={{ borderColor: DANGER, color: DANGER }}
+ >
+ Delete my account…
+
+ {demoLocked && (
+
+ This is turned off on the demo site.
+
+ )}
+
+ )}
+
+ {open && (
+
+ )}
+
+ )
+}
diff --git a/client/src/lib/accountDeletion.js b/client/src/lib/accountDeletion.js
new file mode 100644
index 0000000..e41dff7
--- /dev/null
+++ b/client/src/lib/accountDeletion.js
@@ -0,0 +1,69 @@
+// What the Account page's "Delete my account" section says and decides
+// (docs/website/ACCOUNT_DELETION.md §4.1, §4.2). Plain JS with tests, so the
+// words and the confirm rule can be checked without a browser.
+//
+// The server is the guard: it checks the username, the password and the code
+// again whatever this file decides. What is here only keeps a mis-tap from
+// reaching it and turns its refusal codes into sentences.
+
+/** What goes with the account. The same list as BACKEND_DESIGN.md's. */
+export const WHAT_GOES = [
+ 'your sign-in, password, email address, two-factor settings and recovery codes',
+ 'every signed-in device, trusted device and push registration',
+ 'your linked sign-ins, such as Google, Discord or Steam',
+ 'your game-account links, and anything this site granted in the game through them',
+ 'your notification settings and inbox',
+]
+
+/** What stays, and who sees what of it. */
+export const WHAT_STAYS = [
+ 'What you wrote where others read it, such as forum posts, stays. Other members see it as written by “[deleted player]”.',
+ 'Reports you filed and appeals you made stay as moderation records. Staff can still see who it was, and the activity log keeps its IP addresses.',
+ 'Event results stay with the score and the in-game name, with no site account behind them.',
+ 'A game account stays on the game server. Only its link to this site goes.',
+]
+
+/** Said only to staff: their name is never free again (AD9). */
+export const STAFF_NAME_NOTE =
+ 'You are staff, so your username can never be used again, by anyone, once this account is deleted.'
+
+/** True for every role but player. */
+export const isStaffRole = (role) => Boolean(role) && role !== 'player'
+
+/** The confirm button is live once the typed name matches, ignoring case. */
+export function usernameMatches(typed, username) {
+ return typeof typed === 'string'
+ && typeof username === 'string'
+ && typed.trim().length > 0
+ && typed.trim().toLowerCase() === username.toLowerCase()
+}
+
+/**
+ * True when the signed-in account is one of the demo's shared logins, which the
+ * server refuses to delete. The section still shows (Play's reviewer must find
+ * it); only its button is off.
+ */
+export function isSharedDemoLogin(settings, username) {
+ const logins = settings?.demo?.logins
+ if (!Array.isArray(logins) || typeof username !== 'string') return false
+ const name = username.toLowerCase()
+ return logins.some((l) => typeof l?.username === 'string' && l.username.toLowerCase() === name)
+}
+
+const BY_CODE = {
+ username_mismatch: 'Type your username exactly as it is shown.',
+ wrong_password: 'Your password is incorrect.',
+ code_required: 'Enter a code from your authenticator app, or a recovery code.',
+ wrong_code: 'That code is not valid. Try again.',
+ password_required: 'This account has no password yet. Set one in the Password section above, then delete the account.',
+ last_admin: 'You are the only admin. Make someone else an admin before you delete your account.',
+ DEMO_LOCKED: 'This is turned off on the demo site.',
+}
+
+/** A refusal from DELETE /auth/me/account, as one sentence. */
+export function refusalMessage(err) {
+ const code = err?.body?.code
+ if (code && BY_CODE[code]) return BY_CODE[code]
+ if (err?.status === 429) return 'Too many attempts. Wait a few minutes and try again.'
+ return err?.message || 'Could not delete your account. Try again.'
+}
diff --git a/client/src/lib/deletedAccount.js b/client/src/lib/deletedAccount.js
new file mode 100644
index 0000000..01568a1
--- /dev/null
+++ b/client/src/lib/deletedAccount.js
@@ -0,0 +1,25 @@
+// How a deleted account's name is shown (ACCOUNT_DELETION.md AD5, AD6).
+//
+// The server decides WHAT name a viewer gets (server/src/utils/displayAuthor.js):
+// a member is sent "[deleted player]", staff are sent the stored name and a
+// `…Deleted` flag. All this file does is mark the staff case, so a moderator
+// reading "Ravenna" knows that account no longer exists.
+
+export const DELETED_PLAYER = '[deleted player]'
+
+/** "Ravenna (deleted account)" for staff; the placeholder is left as it is. */
+export function markDeleted(name, deleted) {
+ if (!name) return name
+ return deleted && name !== DELETED_PLAYER ? `${name} (deleted account)` : name
+}
+
+/**
+ * The same for an admin view that is handed raw ledger rows (`actor_user_id`,
+ * `actor_username`): an id that is NULL beside a snapshot that is not is a
+ * deleted account, and nothing else clears those ids. Only an explicit null
+ * counts — a row that does not carry the id column at all says nothing.
+ */
+export function rowName(userId, username, fallback = '—') {
+ if (!username) return fallback
+ return markDeleted(username, userId === null)
+}
diff --git a/client/src/lib/notice.js b/client/src/lib/notice.js
new file mode 100644
index 0000000..738c89d
--- /dev/null
+++ b/client/src/lib/notice.js
@@ -0,0 +1,27 @@
+// A one-line notice for the site-wide toast (components/DemoToast.jsx), for the
+// rare moment a screen has to say something after it has navigated away — the
+// account page cannot show "Your account has been deleted." on itself, because
+// the account it would be shown on no longer exists.
+//
+// The same shape as lib/demoNotice.js and drawn by the same toast, so the site
+// has one place a passing message appears.
+
+const listeners = new Set()
+
+/** Subscribe to notices. Returns the unsubscribe function. */
+export function onNotice(fn) {
+ listeners.add(fn)
+ return () => listeners.delete(fn)
+}
+
+/** Show one notice. */
+export function showNotice(message) {
+ if (typeof message !== 'string' || !message) return
+ for (const fn of listeners) {
+ try {
+ fn(message)
+ } catch {
+ // A broken listener must not stop the caller.
+ }
+ }
+}
diff --git a/client/src/lib/teamAdmin.js b/client/src/lib/teamAdmin.js
index fd7d6e7..a1f5ba3 100644
--- a/client/src/lib/teamAdmin.js
+++ b/client/src/lib/teamAdmin.js
@@ -7,6 +7,8 @@
// for two hours", and those two produce almost the same page. Getting that
// wording right is logic, not markup.
+import { markDeleted, rowName } from './deletedAccount.js'
+
/** Tones the screen uses. Names, not colours — the view maps them. */
export const TONE = { ok: 'ok', warn: 'warn', bad: 'bad', idle: 'idle' }
@@ -91,7 +93,7 @@ export const GATED_NOTE =
/** A one-line description of a queued request, for the approval queue. */
export function describeRequest(request = {}) {
const payload = parsePayload(request.payload)
- const who = request.requested_username || 'a deleted user'
+ const who = rowName(request.requested_by, request.requested_username, 'a deleted user')
switch (request.action) {
case 'unhide':
return `${who} asks to publish “${request.team_name}”`
@@ -133,7 +135,7 @@ export function leadershipOf(member = {}) {
return {
isLeader: granted,
overridden: true,
- note: `${granted ? 'Granted' : 'Denied'} by ${member.leaderOverride.by || 'a deleted user'}`
+ note: `${granted ? 'Granted' : 'Denied'} by ${markDeleted(member.leaderOverride.by, member.leaderOverride.byDeleted) || 'a deleted user'}`
+ `${member.leaderOverride.reason ? ` — ${member.leaderOverride.reason}` : ''}`
+ ` (the game says ${member.isLeaderSynced ? 'leader' : 'not a leader'})`,
}
diff --git a/client/src/lib/teamForum.js b/client/src/lib/teamForum.js
index b52ae3a..0f11086 100644
--- a/client/src/lib/teamForum.js
+++ b/client/src/lib/teamForum.js
@@ -12,6 +12,8 @@
// `created_at` when the write arrives. A client that granted one would be
// deciding a time-bounded permission against the clock of the party it bounds.
+import { markDeleted } from './deletedAccount.js'
+
export const REPORT_REASONS = [
['abuse', 'Abusive or harassing'],
['spam', 'Spam'],
@@ -75,7 +77,7 @@ export function stripToText(html) {
export function threadSummary(thread) {
const parts = []
if (thread.type === 'announcement') parts.push('Announcement')
- parts.push(thread.author)
+ parts.push(markDeleted(thread.author, thread.authorDeleted))
if (thread.type === 'discussion' && thread.postCount > 1) {
const replies = thread.postCount - 1
parts.push(`${replies} ${replies === 1 ? 'reply' : 'replies'}`)
diff --git a/client/src/modules/TeamForumPanel.jsx b/client/src/modules/TeamForumPanel.jsx
index 5b2ea9f..f7291f2 100644
--- a/client/src/modules/TeamForumPanel.jsx
+++ b/client/src/modules/TeamForumPanel.jsx
@@ -5,6 +5,8 @@ import { api } from '../api/client.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../lib/teamForum.js'
+// A member is sent "[deleted player]"; staff the stored name, which this marks.
+import { markDeleted } from '../lib/deletedAccount.js'
// Core's Team forum, rendered into a second slot a MODULE declares
// (TEAMS.md Part 5, and the phase 3 amendment to §3.4).
@@ -332,8 +334,7 @@ function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, o
{thread.type === 'announcement' ? 'Announcement · ' : ''}
- {thread.author}
- {thread.authorDeleted && ' (account removed)'}
+ {markDeleted(thread.author, thread.authorDeleted)}
{thread.locked && ' · locked'}
@@ -448,8 +449,7 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
return (
- {post.author}
- {post.authorDeleted && ' (account removed)'}
+ {markDeleted(post.author, post.authorDeleted)}
{post.editedAt && ' · edited'}
{post.status === 'hidden' && ' · hidden'}
diff --git a/client/src/modules/version.js b/client/src/modules/version.js
index 138f1b4..f9deb23 100644
--- a/client/src/modules/version.js
+++ b/client/src/modules/version.js
@@ -11,6 +11,9 @@
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
+// 1.20.0 — `api.onUserDeleting` (ACCOUNT_DELETION.md §4.1, AD10): a module
+// unlinks a person's game links before their account is deleted. Server-side
+// only. This file bumps for the reason at the top.
// 1.19.0 — an event action may declare `demoRefuses(params)` (DEMO_MODE.md §3.4,
// DM18): the demo refuses a step at save. Server-side only; the refusal reaches
// the browser as an ordinary DEMO_LOCKED answer. This file bumps for the reason at the top.
@@ -105,4 +108,4 @@
// but the two halves state ONE version: a module declares a single coreApi range
// and is served one chunk, so a client that claimed 1.0.0 while the server
// answered 1.1.0 would be two answers to one question.
-export const MODULE_API_VERSION = '1.19.0'
+export const MODULE_API_VERSION = '1.20.0'
diff --git a/client/src/routes/admin/views/AccountAdmin.jsx b/client/src/routes/admin/views/AccountAdmin.jsx
index 0745ce7..181ea8b 100644
--- a/client/src/routes/admin/views/AccountAdmin.jsx
+++ b/client/src/routes/admin/views/AccountAdmin.jsx
@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../../components/security/RecoveryCodesDisp
import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
+import DeleteAccountPanel from '../../../components/security/DeleteAccountPanel.jsx'
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
import { api } from '../../../api/client.js'
@@ -315,6 +316,10 @@ export default function AccountAdmin() {
{account && }
+
+ {/* The same section the player portal shows: every member can delete
+ themself, staff included (ACCOUNT_DELETION.md §1). */}
+ {account && }
)
}
diff --git a/client/src/routes/admin/views/ContentReports.jsx b/client/src/routes/admin/views/ContentReports.jsx
index 9f780a6..d0d8fbc 100644
--- a/client/src/routes/admin/views/ContentReports.jsx
+++ b/client/src/routes/admin/views/ContentReports.jsx
@@ -4,6 +4,7 @@ import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
+import { markDeleted } from '../../../lib/deletedAccount.js'
// The member-raised content-report queue (TEAMS.md §5.6).
//
@@ -84,7 +85,7 @@ function TargetCell({ report }) {
{t.filename}
- {t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
+ {markDeleted(t.uploader, t.uploaderDeleted) || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{t.deleted && ' · removed'}
@@ -95,7 +96,7 @@ function TargetCell({ report }) {
{t.title}
- {t.type} by {t.author || 'unknown'}
+ {t.type} by {markDeleted(t.author, t.authorDeleted) || 'unknown'}
{t.status !== 'visible' && ` · ${t.status}`}
@@ -105,7 +106,7 @@ function TargetCell({ report }) {
{t.excerpt || (no text) }
- {t.author || 'unknown'} in “{t.threadTitle}”
+ {markDeleted(t.author, t.authorDeleted) || 'unknown'} in “{t.threadTitle}”
{t.status !== 'visible' && ` · ${t.status}`}
@@ -192,13 +193,13 @@ export default function ContentReports() {
{REASON_LABEL[r.reason] || r.reason}
{r.detail || '—'}
- {r.reporter}
+ {markDeleted(r.reporter, r.reporterDeleted)}
{ago(r.createdAt)}
{STATUS_LABEL[r.status] || r.status}
{r.handledBy && (
- {r.handledBy}
+ {markDeleted(r.handledBy, r.handledByDeleted)}
{r.handledNote ? ` — ${r.handledNote}` : ''}
)}
diff --git a/client/src/routes/admin/views/TeamsAdmin.jsx b/client/src/routes/admin/views/TeamsAdmin.jsx
index 00f9d8b..2282312 100644
--- a/client/src/routes/admin/views/TeamsAdmin.jsx
+++ b/client/src/routes/admin/views/TeamsAdmin.jsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
+import { rowName } from '../../../lib/deletedAccount.js'
import {
freshnessOf, statusOf, gateLabelFor, describeRequest, leadershipOf, GATED_NOTE,
} from '../../../lib/teamAdmin.js'
@@ -331,7 +332,7 @@ function ForumLedger({ team, onClose }) {
{dateTime(r.created_at)}
{r.action}
{r.target_type} #{r.target_id}
- {r.actor_username || '—'}
+ {rowName(r.actor_user_id, r.actor_username)}
{/* The distinction the whole ledger exists to preserve. */}
{r.actor_role}
diff --git a/client/src/routes/player/PlayerAccount.jsx b/client/src/routes/player/PlayerAccount.jsx
index 8817414..d8de1a2 100644
--- a/client/src/routes/player/PlayerAccount.jsx
+++ b/client/src/routes/player/PlayerAccount.jsx
@@ -5,6 +5,7 @@ import RecoveryCodesDisplay from '../../components/security/RecoveryCodesDisplay
import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.jsx'
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
+import DeleteAccountPanel from '../../components/security/DeleteAccountPanel.jsx'
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
@@ -422,6 +423,7 @@ export default function PlayerAccount() {
)}
+
>
)}
diff --git a/client/test/accountDeletion.test.js b/client/test/accountDeletion.test.js
new file mode 100644
index 0000000..d9e2f1c
--- /dev/null
+++ b/client/test/accountDeletion.test.js
@@ -0,0 +1,80 @@
+// The Account page's "Delete my account" section (client/src/lib/accountDeletion.js)
+// and how a deleted account's name is marked for staff (lib/deletedAccount.js).
+// docs/website/ACCOUNT_DELETION.md §4.1, §4.2, AD5, AD6.
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ usernameMatches, isSharedDemoLogin, refusalMessage, isStaffRole, WHAT_GOES, WHAT_STAYS,
+} from '../src/lib/accountDeletion.js'
+import { DELETED_PLAYER, markDeleted, rowName } from '../src/lib/deletedAccount.js'
+import { describeRequest, leadershipOf } from '../src/lib/teamAdmin.js'
+import { threadSummary } from '../src/lib/teamForum.js'
+
+// ── the confirm step ───────────────────────────────────────────────────────
+
+test('Delete is live only once the typed username matches, ignoring case and spaces', () => {
+ assert.equal(usernameMatches('Thorne', 'Thorne'), true)
+ assert.equal(usernameMatches(' thorne ', 'Thorne'), true)
+ assert.equal(usernameMatches('Thorn', 'Thorne'), false)
+ assert.equal(usernameMatches('', 'Thorne'), false)
+ assert.equal(usernameMatches(' ', 'Thorne'), false)
+ assert.equal(usernameMatches(undefined, 'Thorne'), false)
+})
+
+test('the demo’s shared logins are recognised from the published list, and nothing else is', () => {
+ const settings = { demo: { logins: [{ username: 'demo-player' }, { username: 'play-review' }] } }
+ assert.equal(isSharedDemoLogin(settings, 'Demo-Player'), true)
+ assert.equal(isSharedDemoLogin(settings, 'alice'), false)
+ // Off the demo there is no `demo` block, so nobody is a shared login.
+ assert.equal(isSharedDemoLogin({}, 'demo-player'), false)
+ assert.equal(isSharedDemoLogin(undefined, 'demo-player'), false)
+})
+
+test('every refusal code the route answers has its own sentence', () => {
+ for (const code of ['username_mismatch', 'wrong_password', 'code_required', 'wrong_code', 'password_required', 'last_admin', 'DEMO_LOCKED']) {
+ const msg = refusalMessage({ status: 400, body: { code }, message: 'server words' })
+ assert.notEqual(msg, 'server words', code)
+ assert.ok(msg.length > 10, code)
+ }
+ assert.match(refusalMessage({ status: 429, body: null }), /Too many attempts/)
+ assert.equal(refusalMessage({ status: 500, message: 'Internal Server Error' }), 'Internal Server Error')
+ assert.match(refusalMessage(null), /Could not delete/)
+})
+
+test('the screen says what goes and what stays, and that staff names are kept forever', () => {
+ assert.ok(WHAT_GOES.some((l) => /game-account links/.test(l)))
+ assert.ok(WHAT_STAYS.some((l) => l.includes('[deleted player]')))
+ assert.equal(isStaffRole('moderator'), true)
+ assert.equal(isStaffRole('player'), false)
+})
+
+// ── staff see a deleted account's name, marked ─────────────────────────────
+
+test('a deleted account’s stored name is marked; the placeholder is left alone', () => {
+ assert.equal(markDeleted('Ravenna', true), 'Ravenna (deleted account)')
+ assert.equal(markDeleted('Ravenna', false), 'Ravenna')
+ assert.equal(markDeleted(DELETED_PLAYER, true), DELETED_PLAYER)
+ assert.equal(markDeleted(null, true), null)
+})
+
+test('a raw ledger row is a deleted account only when its id is an explicit null', () => {
+ assert.equal(rowName(null, 'OldMod'), 'OldMod (deleted account)')
+ assert.equal(rowName(4, 'OldMod'), 'OldMod')
+ assert.equal(rowName(undefined, 'OldMod'), 'OldMod')
+ assert.equal(rowName(null, null), '—')
+})
+
+test('Admin → Teams marks a deleted requester and a deleted override author', () => {
+ assert.match(
+ describeRequest({ action: 'unhide', requested_by: null, requested_username: 'mod1', team_name: 'X' }),
+ /^mod1 \(deleted account\) asks/,
+ )
+ const l = leadershipOf({ isLeaderSynced: true, leaderOverride: { effect: 'deny', by: 'mod1', byDeleted: true } })
+ assert.match(l.note, /Denied by mod1 \(deleted account\)/)
+})
+
+test('a forum thread by a deleted account is marked in the list for staff', () => {
+ assert.match(threadSummary({ type: 'discussion', author: 'Ravenna', authorDeleted: true, postCount: 1 }), /^Ravenna \(deleted account\)/)
+ assert.equal(threadSummary({ type: 'discussion', author: DELETED_PLAYER, authorDeleted: true, postCount: 1 }), DELETED_PLAYER)
+})
diff --git a/server/db/schema.sql b/server/db/schema.sql
index 7dd569e..3ca41fc 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -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,
diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json
index eba8631..e73b79b 100644
--- a/server/engagement-triggers.json
+++ b/server/engagement-triggers.json
@@ -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",
diff --git a/server/routes.guards.json b/server/routes.guards.json
index 0c23f9e..ed1681d 100644
--- a/server/routes.guards.json
+++ b/server/routes.guards.json
@@ -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",
diff --git a/server/routes.manifest.json b/server/routes.manifest.json
index 9425200..465c840 100644
--- a/server/routes.manifest.json
+++ b/server/routes.manifest.json
@@ -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"
diff --git a/server/src/auth/accountDeletion.js b/server/src/auth/accountDeletion.js
new file mode 100644
index 0000000..e447955
--- /dev/null
+++ b/server/src/auth/accountDeletion.js
@@ -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}
+ * | {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 }
diff --git a/server/src/demo/locks.js b/server/src/demo/locks.js
index 470c7e6..4d381d6 100644
--- a/server/src/demo/locks.js
+++ b/server/src/demo/locks.js
@@ -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.
diff --git a/server/src/engagement/templateSeeds.js b/server/src/engagement/templateSeeds.js
index 510e8f7..1bbaab7 100644
--- a/server/src/engagement/templateSeeds.js
+++ b/server/src/engagement/templateSeeds.js
@@ -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',
diff --git a/server/src/model/reports/contentReports.db.js b/server/src/model/reports/contentReports.db.js
index 8194714..c233f46 100644
--- a/server/src/model/reports/contentReports.db.js
+++ b/server/src/model/reports/contentReports.db.js
@@ -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,
)
diff --git a/server/src/model/reports/contentReports.model.js b/server/src/model/reports/contentReports.model.js
index 49fd435..3b120fa 100644
--- a/server/src/model/reports/contentReports.model.js
+++ b/server/src/model/reports/contentReports.model.js
@@ -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 = {
diff --git a/server/src/model/teams/teamAccess.model.js b/server/src/model/teams/teamAccess.model.js
index 83cecfe..5fd8454 100644
--- a/server/src/model/teams/teamAccess.model.js
+++ b/server/src/model/teams/teamAccess.model.js
@@ -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,
}
})
diff --git a/server/src/model/teams/teamForum.model.js b/server/src/model/teams/teamForum.model.js
index daa9692..f2e29be 100644
--- a/server/src/model/teams/teamForum.model.js
+++ b/server/src/model/teams/teamForum.model.js
@@ -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
diff --git a/server/src/model/teams/teamGrants.model.js b/server/src/model/teams/teamGrants.model.js
index 8d2f446..f39b820 100644
--- a/server/src/model/teams/teamGrants.model.js
+++ b/server/src/model/teams/teamGrants.model.js
@@ -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,
}))
diff --git a/server/src/model/teams/teamNotify.db.js b/server/src/model/teams/teamNotify.db.js
index 32a8f2d..77755f9 100644
--- a/server/src/model/teams/teamNotify.db.js
+++ b/server/src/model/teams/teamNotify.db.js
@@ -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
diff --git a/server/src/model/users/users.db.js b/server/src/model/users/users.db.js
index 495c697..40c9b9b 100644
--- a/server/src/model/users/users.db.js
+++ b/server/src/model/users/users.db.js
@@ -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,
diff --git a/server/src/model/users/users.model.js b/server/src/model/users/users.model.js
index 01adef8..2cf818b 100644
--- a/server/src/model/users/users.model.js
+++ b/server/src/model/users/users.model.js
@@ -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,
diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js
index 31a37ad..d41579d 100644
--- a/server/src/modules/loader.js
+++ b/server/src/modules/loader.js
@@ -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,
diff --git a/server/src/modules/version.js b/server/src/modules/version.js
index 6c69e52..479b222 100644
--- a/server/src/modules/version.js
+++ b/server/src/modules/version.js
@@ -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 }
diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js
index 04bed7e..e509177 100644
--- a/server/src/router/v1/admin/admin.controller.js
+++ b/server/src/router/v1/admin/admin.controller.js
@@ -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)
diff --git a/server/src/router/v1/admin/moderation.controller.js b/server/src/router/v1/admin/moderation.controller.js
index 711556a..b79d9b1 100644
--- a/server/src/router/v1/admin/moderation.controller.js
+++ b/server/src/router/v1/admin/moderation.controller.js
@@ -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) {
diff --git a/server/src/router/v1/auth/account.controller.js b/server/src/router/v1/auth/account.controller.js
index ec55202..57716f4 100644
--- a/server/src/router/v1/auth/account.controller.js
+++ b/server/src/router/v1/auth/account.controller.js
@@ -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,
diff --git a/server/src/router/v1/auth/me.routes.js b/server/src/router/v1/auth/me.routes.js
index ed4de8e..1c41fc4 100644
--- a/server/src/router/v1/auth/me.routes.js
+++ b/server/src/router/v1/auth/me.routes.js
@@ -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
diff --git a/server/src/router/v1/player/teamForum.controller.js b/server/src/router/v1/player/teamForum.controller.js
index ac3250a..955f0e6 100644
--- a/server/src/router/v1/player/teamForum.controller.js
+++ b/server/src/router/v1/player/teamForum.controller.js
@@ -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,
})
diff --git a/server/src/utils/db.js b/server/src/utils/db.js
index 755aba3..a53fb2f 100644
--- a/server/src/utils/db.js
+++ b/server/src/utils/db.js
@@ -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} fn
+ * @returns {Promise}
+ */
+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 }
diff --git a/server/src/utils/displayAuthor.js b/server/src/utils/displayAuthor.js
new file mode 100644
index 0000000..fcb40e7
--- /dev/null
+++ b/server/src/utils/displayAuthor.js
@@ -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 }
diff --git a/server/src/utils/mailer.js b/server/src/utils/mailer.js
index 523ec5c..ac1f871 100644
--- a/server/src/utils/mailer.js
+++ b/server/src/utils/mailer.js
@@ -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,
}
diff --git a/server/src/utils/teamDigestWorker.js b/server/src/utils/teamDigestWorker.js
index 28cf176..43f88b5 100644
--- a/server/src/utils/teamDigestWorker.js
+++ b/server/src/utils/teamDigestWorker.js
@@ -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),
})),
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 88c14eb..778d7f6 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -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": {
diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js
index c96d9bc..0191281 100644
--- a/server/swagger/swagger.js
+++ b/server/swagger/swagger.js
@@ -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 },
diff --git a/server/test/accountDeletion.test.js b/server/test/accountDeletion.test.js
new file mode 100644
index 0000000..c73babc
--- /dev/null
+++ b/server/test/accountDeletion.test.js
@@ -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')
+})
diff --git a/server/test/contentReports.test.js b/server/test/contentReports.test.js
index f905398..a7edb23 100644
--- a/server/test/contentReports.test.js
+++ b/server/test/contentReports.test.js
@@ -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: 'x
', 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 () => {
diff --git a/server/test/demoLocks.test.js b/server/test/demoLocks.test.js
index e0111fa..1a14fa6 100644
--- a/server/test/demoLocks.test.js
+++ b/server/test/demoLocks.test.js
@@ -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 () => {
diff --git a/server/test/displayAuthor.test.js b/server/test/displayAuthor.test.js
new file mode 100644
index 0000000..d566a73
--- /dev/null
+++ b/server/test/displayAuthor.test.js
@@ -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: 'Guide
',
+ 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: 'a
', created_at: new Date(), author_user_id: null, author_username: 'Ravenna', title: 'Raid night', type: 'discussion' },
+ { id: 2, thread_id: 5, body_html: 'b
', 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'])
+})
diff --git a/server/test/emailTemplates.test.js b/server/test/emailTemplates.test.js
index 1c4cfe8..7105ffa 100644
--- a/server/test/emailTemplates.test.js
+++ b/server/test/emailTemplates.test.js
@@ -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',
])
diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js
index fe37060..5892182 100644
--- a/server/test/eventModuleContract.test.js
+++ b/server/test/eventModuleContract.test.js
@@ -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', () => {
diff --git a/server/test/userDeletingHook.test.js b/server/test/userDeletingHook.test.js
new file mode 100644
index 0000000..0f7729b
--- /dev/null
+++ b/server/test/userDeletingHook.test.js
@@ -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)
+ }
+})