feat(account): self-service account deletion, MODULE_API 1.20.0 #232

Merged
whitlocktech merged 1 commits from feature/account-deletion into main 2026-10-29 20:34:55 -05:00
50 changed files with 2103 additions and 69 deletions

View File

@@ -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) =>

View File

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

View File

@@ -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 (
<section
aria-labelledby="delete-account-heading"
style={{ borderTop: `1px solid ${DANGER}`, paddingTop: 26, marginTop: 40 }}
>
<h2 id="delete-account-heading" className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: DANGER }}>
Delete my account
</h2>
<div className="sans" style={{ color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 640 }}>
<p style={{ marginTop: 0 }}>
Your account is deleted straight away and cannot be restored. These go with it:
</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20 }}>
{WHAT_GOES.map((line) => <li key={line}>{line}</li>)}
</ul>
<p style={{ margin: '0 0 6px' }}>What stays:</p>
<ul style={{ margin: '0 0 12px', paddingLeft: 20 }}>
{WHAT_STAYS.map((line) => <li key={line}>{line}</li>)}
</ul>
{isStaffRole(account.role) && <p style={{ margin: '0 0 12px' }}>{STAFF_NAME_NOTE}</p>}
</div>
{noPassword && (
<p className="sans" style={{ color: DANGER, fontSize: '0.86rem' }}>
This account has no password yet. Set one in the Password section above first; the password is how you
confirm a deletion.
</p>
)}
{!open && (
<div>
<button
type="button"
onClick={() => setOpen(true)}
disabled={demoLocked || noPassword}
className="btn btn-sq"
style={{ borderColor: DANGER, color: DANGER }}
>
Delete my account…
</button>
{demoLocked && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '8px 0 0' }}>
This is turned off on the demo site.
</p>
)}
</div>
)}
{open && (
<form
onSubmit={confirm}
style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 360, padding: 16, border: `1px solid ${DANGER}`, borderRadius: 10 }}
>
<label>
<span className="field-label">
Type <strong style={{ color: 'var(--head)' }}>{account.username}</strong> to confirm
</span>
<input
type="text"
value={typed}
onChange={(e) => setTyped(e.target.value)}
className="input"
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
/>
</label>
<label>
<span className="field-label">Password</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" autoComplete="current-password" />
</label>
{needsCode && (
<label>
<span className="field-label">{useRecovery ? 'Recovery code' : 'Authenticator code'}</span>
<input
type="text"
inputMode={useRecovery ? 'text' : 'numeric'}
autoComplete="one-time-code"
value={code}
onChange={(e) => setCode(e.target.value)}
className="input"
/>
<button
type="button"
className="pill"
style={{ marginTop: 8 }}
onClick={() => { setUseRecovery((v) => !v); setCode('') }}
>
{useRecovery ? 'Use an authenticator code instead' : 'Use a recovery code instead'}
</button>
</label>
)}
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<button
type="submit"
disabled={!ready || busy}
className="btn btn-sq"
style={{ background: ready ? DANGER : undefined, borderColor: DANGER, color: ready ? '#1a1a1a' : DANGER }}
>
{busy ? 'Deleting…' : 'Delete my account'}
</button>
<button type="button" onClick={close} disabled={busy} className="pill">Cancel</button>
</div>
{error && <p role="alert" className="sans" style={{ margin: 0, color: DANGER, fontSize: '0.85rem' }}>{error}</p>}
</form>
)}
</section>
)
}

View File

@@ -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.'
}

View File

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

27
client/src/lib/notice.js Normal file
View File

@@ -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.
}
}
}

View File

@@ -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'})`,
}

View File

@@ -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'}`)

View File

@@ -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
</h2>
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 14px' }}>
{thread.type === 'announcement' ? 'Announcement · ' : ''}
{thread.author}
{thread.authorDeleted && ' (account removed)'}
{markDeleted(thread.author, thread.authorDeleted)}
{thread.locked && ' · locked'}
</p>
@@ -448,8 +449,7 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
return (
<article style={{ marginBottom: 16 }}>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 2px' }}>
{post.author}
{post.authorDeleted && ' (account removed)'}
{markDeleted(post.author, post.authorDeleted)}
{post.editedAt && ' · edited'}
{post.status === 'hidden' && ' · hidden'}
</p>

View File

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

View File

@@ -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 && <EmailAddressPanel account={account} reload={load} />}
<LinkedAccounts />
{/* The same section the player portal shows: every member can delete
themself, staff included (ACCOUNT_DELETION.md §1). */}
{account && <DeleteAccountPanel account={account} />}
</section>
)
}

View File

@@ -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 }) {
<span>
<a href={t.url} target="_blank" rel="noopener noreferrer" className="link-accent">{t.filename}</a>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.uploader || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{markDeleted(t.uploader, t.uploaderDeleted) || 'unknown'} · {t.mimetype} · {bytes(t.byteSize)}
{t.deleted && ' · removed'}
</span>
</span>
@@ -95,7 +96,7 @@ function TargetCell({ report }) {
<span>
<strong>{t.title}</strong>
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.type} by {t.author || 'unknown'}
{t.type} by {markDeleted(t.author, t.authorDeleted) || 'unknown'}
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
@@ -105,7 +106,7 @@ function TargetCell({ report }) {
<span>
{t.excerpt || <em className="dim">(no text)</em>}
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{t.author || 'unknown'} in “{t.threadTitle}”
{markDeleted(t.author, t.authorDeleted) || 'unknown'} in “{t.threadTitle}”
{t.status !== 'visible' && ` · ${t.status}`}
</span>
</span>
@@ -192,13 +193,13 @@ export default function ContentReports() {
<span className="badge">{REASON_LABEL[r.reason] || r.reason}</span>
</td>
<td className="adm-td dim" style={{ maxWidth: 260 }}>{r.detail || '—'}</td>
<td className="adm-td dim">{r.reporter}</td>
<td className="adm-td dim">{markDeleted(r.reporter, r.reporterDeleted)}</td>
<td className="adm-td dim" title={dateTime(r.createdAt)}>{ago(r.createdAt)}</td>
<td className="adm-td">
<span className="badge" style={STATUS_STYLE[r.status]}>{STATUS_LABEL[r.status] || r.status}</span>
{r.handledBy && (
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
{r.handledBy}
{markDeleted(r.handledBy, r.handledByDeleted)}
{r.handledNote ? ` — ${r.handledNote}` : ''}
</span>
)}

View File

@@ -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 }) {
<td className="adm-td dim">{dateTime(r.created_at)}</td>
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.action}</td>
<td className="adm-td dim">{r.target_type} #{r.target_id}</td>
<td className="adm-td">{r.actor_username || '—'}</td>
<td className="adm-td">{rowName(r.actor_user_id, r.actor_username)}</td>
<td className="adm-td">
{/* The distinction the whole ledger exists to preserve. */}
<Pill tone={r.actor_role === 'staff' ? 'warn' : 'ok'}>{r.actor_role}</Pill>

View File

@@ -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() {
)}
<LinkedAccounts />
<ActiveDevices />
<DeleteAccountPanel account={account} />
</>
)}
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12861,6 +12861,102 @@
"bearerAuth": []
}
]
},
"delete": {
"tags": [
"Auth · Me"
],
"summary": "Delete the current account (self, any role)",
"description": "Deletes the account at once (no grace period). Requires the typed username (case-insensitive), the account password, and — when two-factor sign-in is on — an authenticator code or a recovery code. Before the row goes, every installed game module unlinks the account’s game links through its own unlink (MODULE_API 1.20.0 onUserDeleting); a module that cannot finish never stops the deletion. Every session, trusted device, push registration, linked sign-in and game link goes with the account. What the member wrote stays and other members see it as \"[deleted player]\"; staff keep the name and the activity log for moderation. A deleted staff account’s username can never be registered again. A wrong password or code is a 400 (not 401), so a native client’s refresh-on-401 never replays it. Refused with 403 DEMO_LOCKED for the demo’s shared logins.",
"responses": {
"200": {
"description": "Deleted; this browser’s session and trust cookies are cleared",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"deleted": {
"type": "boolean",
"example": true
}
}
}
}
}
},
"400": {
"description": "username_mismatch, wrong_password, code_required or wrong_code",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteAccountRefusal"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Demo login (DEMO_LOCKED)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DemoLocked"
}
}
}
},
"409": {
"description": "password_required (the account has no password yet) or last_admin",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteAccountRefusal"
}
}
}
},
"429": {
"description": "Too many attempts (rate limited)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteAccountRequest"
}
}
}
}
}
},
"/api/v1/auth/me/account/email": {
@@ -21590,6 +21686,148 @@
}
}
},
"DeleteAccountRequest": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"required": {
"type": "array",
"example": [
"username",
"password"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "The confirm step of deleting your own account (ACCOUNT_DELETION.md §4.1). The password is always required (AD7); `code` or `recoveryCode` only when two-factor sign-in is on."
},
"properties": {
"type": "object",
"properties": {
"username": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "wanderer"
},
"description": {
"type": "string",
"example": "The account's username, typed to confirm; compared ignoring case."
}
}
},
"password": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "password"
}
}
},
"code": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "123456"
},
"description": {
"type": "string",
"example": "A current authenticator code."
}
}
},
"recoveryCode": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "ABCD-EFGH"
},
"description": {
"type": "string",
"example": "A single-use recovery code, instead of `code`."
}
}
}
}
}
}
},
"DeleteAccountRefusal": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"code": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"username_mismatch",
"wrong_password",
"code_required",
"wrong_code",
"password_required",
"last_admin"
],
"items": {
"type": "string"
}
}
}
},
"message": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Your password is incorrect."
}
}
}
}
}
}
},
"ChangePasswordRequest": {
"type": "object",
"properties": {
@@ -23811,7 +24049,7 @@
},
"description": {
"type": "string",
"example": "Username snapshot; \"[deleted account]\" once the account is gone."
"example": "Username snapshot. Staff see it even after the account is deleted, with `reporterDeleted` true; anyone else would see \"[deleted player]\" (ACCOUNT_DELETION.md AD5, AD6)."
}
}
},
@@ -23918,6 +24156,19 @@
}
}
},
"handledByDeleted": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"handledNote": {
"type": "object",
"properties": {
@@ -24124,6 +24375,23 @@
}
}
},
"authorDeleted": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "The author's account was deleted; `author` is the stored name, shown to staff (ACCOUNT_DELETION.md AD6)."
}
}
},
"uploader": {
"type": "object",
"properties": {
@@ -24137,6 +24405,19 @@
}
}
},
"uploaderDeleted": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"nullable": {
"type": "boolean",
"example": true
}
}
},
"excerpt": {
"type": "object",
"properties": {

View File

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

View File

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

View File

@@ -192,7 +192,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [{
id: 3, team_id: 1, post_id: 80, uploader_username: 'someone', filename: 'a1b2.png',
id: 3, team_id: 1, post_id: 80, uploader_user_id: 12, uploader_username: 'someone', filename: 'a1b2.png',
mimetype: 'image/png', byte_size: 184320, created_at: new Date(), deleted_at: null,
}])
@@ -201,6 +201,7 @@ test('an upload report carries uploader, size and the SNIFFED type', async () =>
// load-bearing rather than bookkeeping.
assert.equal(item.target.kind, 'upload')
assert.equal(item.target.uploader, 'someone')
assert.equal(item.target.uploaderDeleted, false)
assert.equal(item.target.byteSize, 184320)
assert.equal(item.target.mimetype, 'image/png')
assert.equal(item.target.url, '/uploads/a1b2.png')
@@ -234,17 +235,64 @@ test('a report whose target is already gone still lists, with a null target', as
assert.equal(item.target, null)
})
test('a deleted reporter still shows as somebody, and is marked deleted', async () => {
test('a deleted reporter with no snapshot still shows as somebody, and is marked deleted', async () => {
patch(reportsDb, 'list', async () => [row({ reporter_user_id: null, reporter_username: null })])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({})
assert.equal(item.reporter, '[deleted account]')
const [item] = await reports.queue({ viewer: { role: 'moderator' } })
assert.equal(item.reporter, '[deleted player]')
assert.equal(item.reporterDeleted, true)
})
// ACCOUNT_DELETION.md AD6: staff keep the stored name of a deleted account, for
// moderation, and the flag says which it is. Every snapshot in a report row goes
// through the one rule — reporter, handler, and the target's author.
test('staff see a deleted account’s stored names in the queue, each marked deleted', async () => {
patch(reportsDb, 'list', async () => [row({
reporter_user_id: null, reporter_username: 'Ravenna',
status: 'actioned', handled_by: null, handled_username: 'OldMod', handled_at: new Date(),
})])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [{
id: 80, thread_id: 5, author_user_id: null, author_username: 'Thorne', status: 'hidden',
body_html: '<p>x</p>', created_at: new Date(), team_id: 1, thread_title: 'Raid night',
}])
for (const role of ['admin', 'editor', 'moderator']) {
// eslint-disable-next-line no-await-in-loop
const [item] = await reports.queue({ viewer: { role } })
assert.equal(item.reporter, 'Ravenna', role)
assert.equal(item.reporterDeleted, true)
assert.equal(item.handledBy, 'OldMod')
assert.equal(item.handledByDeleted, true)
assert.equal(item.target.author, 'Thorne')
assert.equal(item.target.authorDeleted, true)
}
// Nothing but a staff viewer sees those names, so a caller that forgot to say
// who is reading cannot leak them.
const [blind] = await reports.queue({})
assert.equal(blind.reporter, '[deleted player]')
assert.equal(blind.handledBy, '[deleted player]')
assert.equal(blind.target.author, '[deleted player]')
})
test('an open report has no handler, which is not a deleted handler', async () => {
patch(reportsDb, 'list', async () => [row()])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({ viewer: { role: 'admin' } })
assert.equal(item.handledBy, null)
assert.equal(item.handledByDeleted, false)
assert.equal(item.reporter, 'wanderer')
assert.equal(item.reporterDeleted, false)
})
// ── handling ───────────────────────────────────────────────────────────────
test('handling records who decided, when, and why', async () => {

View File

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

View File

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

View File

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

View File

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

View File

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