feat(safety): report, block and hide, MODULE_API 1.21.0
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 6m11s
PR Checks / client-build (pull_request) Successful in 49s

USER_SAFETY.md step 2 (core), with S11-S14 decided while building it.

- Reports: POST /player/reports takes any target type, core's forum
  types or a module's registered ones. One resolver
  (model/safety/targets.js) answers "what is this item, for this
  reader" and 404s an item the reader cannot see. Every report stores
  content_reports.snapshot; the queue shows a module item from it.
- Hide for everyone: POST /admin/moderation/reports/:id/hide hides the
  item through its own moderation (forum hide as staff, or the module's
  hide) and actions the report with actioned_with = 'hide'.
- Blocks (user_blocks) name an item, never a person, and land on the
  author's account first (S12); an unlinked game identity is blocked as
  itself and becomes an account block once linked (S14). Staff cannot
  be blocked; a block on someone later promoted stops applying (S8).
- Hide for me (user_hidden_items), /auth/me/blocks and /auth/me/hidden.
- Forum reads filter blocked authors and hidden items on the server and
  carry canBlock per thread and post.
- MODULE_API 1.21.0: api.registerReportTargets,
  api.registerGameIdentities, ctx.safety.forViewer and
  ctx.safety.staffAmong, so a module filters its own per-viewer feeds on
  the server (S11) and marks lines canBlock (S13).
- Web: Hide for me / Block author in the forum menus, a Blocked section
  on both account pages, Hide for everyone in the Reports queue.
- Tests, OpenAPI, route/guard/engagement manifests regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-30 05:41:00 -05:00
parent 7bd93b8bb0
commit 41a1e84b51
40 changed files with 3974 additions and 56 deletions

View File

@@ -154,6 +154,20 @@ export const api = {
generateRecoveryCodes: (currentPassword) =>
req('/auth/me/account/recovery-codes/generate', { method: 'POST', body: { currentPassword } }),
// Report, block and "Hide for me" (USER_SAFETY.md §4.1). A block and a report
// name an ITEM — `{ targetType, targetId }` — never a person: the server reads
// who wrote it. Saved on the account, so they apply on every device.
reportItem: ({ targetType, targetId, reason, detail }) =>
req('/player/reports', { method: 'POST', body: { targetType, targetId, reason, detail } }),
myBlocks: () => req('/auth/me/blocks'),
blockAuthor: ({ targetType, targetId }) => req('/auth/me/blocks', { method: 'POST', body: { targetType, targetId } }),
unblock: (id) => req(`/auth/me/blocks/${encodeURIComponent(id)}`, { method: 'DELETE' }),
myHidden: () => req('/auth/me/hidden'),
hideItem: ({ targetType, targetId }) => req('/auth/me/hidden', { method: 'POST', body: { targetType, targetId } }),
unhideItem: ({ targetType, targetId }) =>
req(`/auth/me/hidden/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`, { method: 'DELETE' }),
clearHidden: () => req('/auth/me/hidden', { method: 'DELETE' }),
// ----- settings (any authenticated account) -----
// Nav overrides for the layouts the caller's own role renders, and the theme
// catalog the appearance form is built from. A fifth group, not part of
@@ -656,6 +670,10 @@ export const api = {
},
handleContentReport: (id, body) =>
req(`/admin/moderation/reports/${id}/handle`, { method: 'POST', body }),
// "Hide for everyone" (USER_SAFETY.md §4.1): hides the item through its own
// moderation and actions the report.
hideReportedItem: (id, body = {}) =>
req(`/admin/moderation/reports/${id}/hide`, { method: 'POST', body }),
modRecent: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)

View File

@@ -0,0 +1,119 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from '../../api/client.js'
import { blockSubtitle } from '../../lib/userSafety.js'
// Account → Blocked (docs/website/USER_SAFETY.md §1, §4.1). The members and
// players this account has blocked, each with Unblock, and the items it hid for
// itself, with one "Show them again".
//
// Blocks and hides are saved on the account, so this list is the same one the
// app shows, and undoing one here undoes it everywhere. The role-agnostic
// /auth/me surface, so the same panel serves players and staff.
//
// Two kinds of block, and the panel names which: an account (a forum author, or
// a chat speaker whose game account is linked — S12) or a game identity nobody
// has linked yet (a Steam id). A block whose target has since become staff no
// longer applies (S8), and the row says so rather than the block vanishing.
export default function BlockedPanel() {
const [blocks, setBlocks] = useState(null)
const [hiddenCount, setHiddenCount] = useState(0)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
try {
const [b, h] = await Promise.all([api.myBlocks(), api.myHidden()])
setBlocks(b.blocks || [])
setHiddenCount((h.hidden || []).length)
} catch {
setError('Could not load your blocks.')
setBlocks([])
}
}, [])
useEffect(() => {
load()
}, [load])
async function unblock(block) {
setBusy(true)
setMsg('')
setError('')
try {
await api.unblock(block.id)
setMsg(block.name ? `${block.name} is unblocked.` : 'Unblocked.')
await load()
} catch {
setError('Could not unblock that.')
} finally {
setBusy(false)
}
}
async function showHidden() {
setBusy(true)
setMsg('')
setError('')
try {
await api.clearHidden()
setMsg('Everything you hid is shown again.')
await load()
} catch {
setError('Could not show those again.')
} finally {
setBusy(false)
}
}
if (!blocks) return null
return (
<div style={{ marginTop: 40, borderTop: '1px solid var(--line-soft)', paddingTop: 28 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.2rem', color: 'var(--head)' }}>
Blocked
</h2>
<p className="sans" style={{ color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
You don’t see anything a blocked member or player writes — forum posts and in-game chat — on this
site or in the app. They aren’t told. Staff can’t be blocked.
</p>
{blocks.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
{blocks.map((b) => (
<div key={b.id} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{b.name || 'Unknown player'}</div>
<div className="sans dim" style={{ fontSize: '0.76rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{blockSubtitle(b)}
</div>
{!b.applies && b.reason === 'staff' && (
<div className="sans" style={{ fontSize: '0.76rem', color: '#d9b884', marginTop: 2 }}>
Not applied: this account is now a staff member, and staff can’t be blocked.
</div>
)}
</div>
<button onClick={() => unblock(b)} disabled={busy} className="pill">
Unblock
</button>
</div>
))}
</div>
) : (
<p className="sans dim" style={{ fontSize: '0.86rem', margin: '14px 0' }}>You haven’t blocked anyone.</p>
)}
{hiddenCount > 0 && (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<span className="sans dim" style={{ fontSize: '0.86rem' }}>
You’ve hidden {hiddenCount} {hiddenCount === 1 ? 'item' : 'items'} for yourself.
</span>
<button onClick={showHidden} disabled={busy} className="pill">Show them again</button>
</div>
)}
{msg && <p className="sans" style={{ marginTop: 14, color: '#7fd0a4', fontSize: '0.86rem' }}>{msg}</p>}
{error && <p className="sans" style={{ marginTop: 14, color: '#d98b84', fontSize: '0.86rem' }}>{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,35 @@
// Report, block and hide — the parts of the screens that are rules rather than
// markup (docs/website/USER_SAFETY.md §1, §4.1). Plain JS so the client's
// node --test suite can hold them.
function fmtDate(s) {
if (!s) return ''
const d = new Date(s)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
/**
* The second line of a row on Account → Blocked: what was blocked, and when.
* An account block is the normal one (S12); a game block names the identity
* and says it is not linked to an account yet, since that is why it is a Steam
* id and not a username.
*/
export function blockSubtitle(block) {
const parts = []
if (block.kind === 'game' && block.identity) {
parts.push(`${block.identity.label} ${block.identity.id}, not linked to an account`)
} else {
parts.push('Account')
}
const when = fmtDate(block.createdAt)
if (when) parts.push(`blocked ${when}`)
return parts.join(' · ')
}
/**
* May the Reports queue offer "Hide for everyone" on this report? Not on an
* upload — it is shown inside a post, and hiding that post is the act — and not
* once this report has already hidden its item.
*/
export const canHideForEveryone = (report) =>
report.targetType !== 'team_forum_upload' && report.actionedWith !== 'hide'

View File

@@ -126,6 +126,10 @@ export default function TeamForumPanel({ externalId, moduleId }) {
imageMode={imageMode}
onBack={() => openThread(null)}
onChanged={() => loadThread(team.slug, thread.id)}
onGone={async () => {
await loadThreads(team.slug)
openThread(null)
}}
onModerate={async (action) => {
await api.teamForumModerate(team.slug, thread.id, { action })
await loadThreads(team.slug)
@@ -311,7 +315,7 @@ function GuestManager({ slug }) {
)
}
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) {
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onGone, onModerate }) {
// A clock that ticks, so an edit control whose deadline passed while the page
// sat open goes away instead of becoming a button that fails. It only ever
// REMOVES an offer — the server decides whether an edit happens, and re-derives
@@ -386,6 +390,15 @@ function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, o
targetId={thread.id}
label="Report this thread"
/>
{/* A thread this reader hides, or whose author they block, is gone
from their view, so the panel goes back to the list. */}
<SafetyControls
targetType="team_forum_thread"
targetId={thread.id}
author={thread.author}
canBlock={thread.canBlock}
onDone={onGone}
/>
{canModerate && (
<>
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
@@ -515,6 +528,15 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
label="Report"
/>
)}
{!post.mine && (
<SafetyControls
targetType="team_forum_post"
targetId={post.id}
author={post.author}
canBlock={post.canBlock}
onDone={onChanged}
/>
)}
{canModerate && (
<>
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
@@ -529,6 +551,76 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
)
}
/**
* "Hide for me" and "Block author" (USER_SAFETY.md §1).
*
* Both are the reader's own tools and both are saved on their account, so they
* hold on every device and in the app. Neither files a report or changes the
* item for anyone else, and the blocked person is not told (S9) — the
* confirmation says so, because a member deciding whether to block someone is
* usually deciding whether it will start something.
*
* `canBlock` is the server's answer (S8: never yourself, a deleted account or
* staff); this offers Block only when it is true. "Hide for me" works on any
* single item, a staff member's included.
*/
function SafetyControls({ targetType, targetId, author, canBlock, onDone }) {
const [confirming, setConfirming] = useState(false)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const run = async (call) => {
setBusy(true)
setError(null)
try {
await call({ targetType, targetId })
setConfirming(false)
await onDone()
} catch (err) {
setError(err.message || 'Could not do that')
} finally {
setBusy(false)
}
}
if (confirming) {
return (
<div
style={{
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
}}
>
<p className="sans" style={{ fontSize: '0.85rem', margin: 0 }}>
Block <strong>{author}</strong>? You won&rsquo;t see anything they write, here or in the app.
They aren&rsquo;t told. You can undo it from <em>Account → Blocked</em>.
</p>
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem', margin: 0 }}>{error}</p>}
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => run(api.blockAuthor)}>
Block
</button>
<button type="button" className="pill" onClick={() => setConfirming(false)}>Cancel</button>
</div>
</div>
)
}
return (
<>
<button type="button" className="pill" disabled={busy} onClick={() => run(api.hideItem)}>
Hide for me
</button>
{canBlock && (
<button type="button" className="pill" onClick={() => setConfirming(true)}>
Block author
</button>
)}
{error && <span className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.8rem' }}>{error}</span>}
</>
)
}
/**
* The report control — the first user-facing report flow this site has ever had.
*

View File

@@ -11,6 +11,12 @@
// 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.21.0 — report, block and hide reach a module's content (USER_SAFETY.md
// §4.1): `api.registerReportTargets`, `api.registerGameIdentities`,
// `ctx.safety`. Server-side: a module filters its own feeds per reader on the
// server (S11), and its client half calls core's ordinary `/player/reports` and
// `/auth/me/blocks` routes through the shared request primitive. This file bumps
// for the reason at the top.
// 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.
@@ -108,4 +114,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.20.0'
export const MODULE_API_VERSION = '1.21.0'

View File

@@ -6,6 +6,7 @@ import TrustedDevicesPanel from '../../../components/security/TrustedDevicesPane
import RecoveryCodesPanel from '../../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../../components/security/EmailAddressPanel.jsx'
import DeleteAccountPanel from '../../../components/security/DeleteAccountPanel.jsx'
import BlockedPanel from '../../../components/security/BlockedPanel.jsx'
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../../components/security/linkedIdentities.jsx'
import { api } from '../../../api/client.js'
@@ -317,6 +318,10 @@ export default function AccountAdmin() {
<LinkedAccounts />
{/* Blocks and hides are the member's own (USER_SAFETY.md §1); staff are
members too, and the list is the one the app shows. */}
<BlockedPanel />
{/* The same section the player portal shows: every member can delete
themself, staff included (ACCOUNT_DELETION.md §1). */}
{account && <DeleteAccountPanel account={account} />}

View File

@@ -5,6 +5,7 @@ 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'
import { canHideForEveryone } from '../../../lib/userSafety.js'
// The member-raised content-report queue (TEAMS.md §5.6).
//
@@ -21,10 +22,20 @@ import { markDeleted } from '../../../lib/deletedAccount.js'
// row here rather than as a new screen.
//
// **Handling a report is bookkeeping about the REPORT, not moderation of the
// content.** Acting on the content itself is the ordinary forum moderation
// control, or a site-wide sanction against the account. Keeping those separate is
// what stops "report" from becoming a way for any member to hide anything, so
// this screen deliberately offers no hide/delete button of its own.
// content.** Keeping those separate is what stops "report" from becoming a way
// for any member to hide anything.
//
// **The one exception is "Hide for everyone"** (USER_SAFETY.md §4.1, S3), and it
// is a separate button with its own words, not a status. It hides the item
// through the item's OWN moderation — the forum's hide, or a game module's for a
// chat line — so it is a staff moderation recorded where that content's
// moderation is recorded, and the report is actioned with that named. Undoing it
// happens where the item lives, where staff still see it marked (S10). Delete
// stays the forum's.
//
// Since 1.21.0 a report can be about a game module's item (a Rust chat line),
// and every new report carries a snapshot of what the item said, which is what
// this screen shows once the item itself is gone.
const STATUS_TABS = [
{ key: 'open_work', label: 'Open work', param: undefined },
@@ -71,12 +82,44 @@ const bytes = (n) => {
* was gone" is a fact worth seeing, and dropping it would hide the pattern of a
* member deleting their own content the moment it is reported.
*/
function SnapshotLine({ snapshot }) {
// An unlinked chat speaker is named by their in-game name and Steam id; the
// queue is staff-only, and the id is what a staffer acts on in game.
const who = markDeleted(snapshot.author, snapshot.authorDeleted) || 'unknown'
const idNote = snapshot.authorIdentity ? ` (${snapshot.authorIdentity.kind} ${snapshot.authorIdentity.id})` : ''
return (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>
{snapshot.label ? `${snapshot.label} · ` : ''}{who}{idNote}
{snapshot.where?.server ? ` on ${snapshot.where.server}` : ''}
{snapshot.at ? ` · ${dateTime(snapshot.at)}` : ''}
</span>
)
}
function TargetCell({ report }) {
const t = report.target
const snap = report.snapshot
if (!t) {
return (
<span style={{ color: 'var(--muted)' }}>
{report.targetType.replace('team_forum_', '')} #{report.targetId} — no longer exists
{/* What it said when it was reported, kept on the report (1.21.0). */}
{snap && (
<>
<span style={{ display: 'block', color: 'var(--text)' }}>{snap.text || <em>(no text)</em>}</span>
<SnapshotLine snapshot={snap} />
</>
)}
</span>
)
}
if (t.kind === 'module') {
return (
<span>
{t.text || <em className="dim">(no text)</em>}
{snap ? <SnapshotLine snapshot={snap} /> : (
<span className="dim" style={{ display: 'block', fontSize: '0.78rem' }}>{t.label}</span>
)}
</span>
)
}
@@ -134,10 +177,11 @@ export default function ContentReports() {
return (
<section>
<p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.85rem', maxWidth: 720 }}>
Reports raised by members about Team forum content. They come to site staff and are not visible
to a Team&rsquo;s own leaders — a leader moderates their own forum, so a report about a leader
has to reach someone above them. Handling a report records a decision about the report; hiding
or removing the content itself is done from the forum, or as a sanction against the account.
Reports raised by members about Team forum content and in-game chat. They come to site staff and
are not visible to a Team&rsquo;s own leaders — a leader moderates their own forum, so a report
about a leader has to reach someone above them. Handling a report records a decision about the
report. <em>Hide for everyone</em> also hides the item, for everyone but staff; removing it is
done from the forum, or as a sanction against the account.
{typeof data?.openCount === 'number' && ` ${data.openCount} open.`}
</p>
@@ -197,6 +241,9 @@ export default function ContentReports() {
<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.actionedWith === 'hide' && (
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>Hidden for everyone</span>
)}
{r.handledBy && (
<span className="dim" style={{ display: 'block', fontSize: '0.75rem' }}>
{markDeleted(r.handledBy, r.handledByDeleted)}
@@ -223,9 +270,9 @@ export default function ContentReports() {
<HandleModal
report={handling}
onCancel={() => setHandling(null)}
onDone={() => {
onDone={(text = 'Report updated.') => {
setHandling(null)
setNotice({ text: 'Report updated.', tone: 'ok' })
setNotice({ text, tone: 'ok' })
reload()
}}
onError={(message) => setNotice({ text: message, tone: 'error' })}
@@ -258,6 +305,17 @@ function HandleModal({ report, onCancel, onDone, onError }) {
}
}
const hideForEveryone = async () => {
setBusy(true)
try {
await api.admin.hideReportedItem(report.id, { note: note || undefined })
onDone('Hidden for everyone, and the report is actioned.')
} catch (err) {
onError(err.message || 'Could not hide that.')
setBusy(false)
}
}
return (
<Modal
title={`Report #${report.id}`}
@@ -265,6 +323,11 @@ function HandleModal({ report, onCancel, onDone, onError }) {
footer={(
<>
<button className="pill" onClick={onCancel}>Cancel</button>
{canHideForEveryone(report) && (
<button className="pill" onClick={hideForEveryone} disabled={busy} style={{ color: '#d98b84', borderColor: '#d98b84' }}>
Hide for everyone
</button>
)}
<button className="btn btn-primary btn-sq" onClick={submit} disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
@@ -273,8 +336,12 @@ function HandleModal({ report, onCancel, onDone, onError }) {
>
<div style={{ display: 'grid', gap: 12 }}>
<p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>
This records a decision about the report. It does not hide, delete or restore the content —
do that from the forum itself, or against the account.
<strong>Save</strong> records a decision about the report and changes nothing about the content.
{' '}
{canHideForEveryone(report)
? <><strong>Hide for everyone</strong> hides the item for everyone but staff, who still see it marked
where it is and can unhide it there, and actions this report.</>
: 'Deleting or restoring the content is done from the forum itself, or against the account.'}
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>

View File

@@ -6,6 +6,7 @@ import TrustedDevicesPanel from '../../components/security/TrustedDevicesPanel.j
import RecoveryCodesPanel from '../../components/security/RecoveryCodesPanel.jsx'
import EmailAddressPanel from '../../components/security/EmailAddressPanel.jsx'
import DeleteAccountPanel from '../../components/security/DeleteAccountPanel.jsx'
import BlockedPanel from '../../components/security/BlockedPanel.jsx'
import { IdentitySummary, linkBanner, linkableProviders, unlinkPrompt } from '../../components/security/linkedIdentities.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { api } from '../../api/client.js'
@@ -423,6 +424,7 @@ export default function PlayerAccount() {
)}
<LinkedAccounts />
<ActiveDevices />
<BlockedPanel />
<DeleteAccountPanel account={account} />
</>
)}

View File

@@ -247,6 +247,32 @@ test('the report queue defaults to the open work rather than to everything', asy
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
})
// USER_SAFETY.md §4.1: a report, a block and a hide name an ITEM, never a
// person, and the server reads who wrote it.
test('report, block and hide name an item, on the one route for every target', async () => {
willReply({ body: { ok: true } })
await api.reportItem({ targetType: 'rust.chat', targetId: 4410, reason: 'abuse' })
assert.equal(calls[0].url, '/api/v1/player/reports')
assert.deepEqual(JSON.parse(calls[0].opts.body), { targetType: 'rust.chat', targetId: 4410, reason: 'abuse' })
calls = []
willReply({ body: { block: {} } })
await api.blockAuthor({ targetType: 'team_forum_post', targetId: 80 })
assert.equal(calls[0].url, '/api/v1/auth/me/blocks')
assert.deepEqual(JSON.parse(calls[0].opts.body), { targetType: 'team_forum_post', targetId: 80 })
calls = []
willReply({ body: { ok: true } })
await api.unhideItem({ targetType: 'rust.chat', targetId: 4410 })
assert.equal(calls[0].url, '/api/v1/auth/me/hidden/rust.chat/4410')
assert.equal(calls[0].opts.method, 'DELETE')
calls = []
willReply({ body: {} })
await api.admin.hideReportedItem(41, { note: 'x' })
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports/41/hide')
})
test('a Team slug is URL-encoded on every forum path', async () => {
willReply({ body: { ok: true } })
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })

View File

@@ -0,0 +1,24 @@
// Account → Blocked and the Reports queue's "Hide for everyone"
// (client/src/lib/userSafety.js; docs/website/USER_SAFETY.md §1, §4.1).
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { blockSubtitle, canHideForEveryone } from '../src/lib/userSafety.js'
test('a game block says it is a Steam id with no account yet; an account block says Account', () => {
const game = blockSubtitle({
kind: 'game',
identity: { module: 'rust', kind: 'steam', label: 'Steam account', id: '76561198000000123' },
})
assert.equal(game, 'Steam account 76561198000000123, not linked to an account')
assert.equal(blockSubtitle({ kind: 'account', identity: null }), 'Account')
assert.match(blockSubtitle({ kind: 'account', createdAt: '2026-10-30T10:00:00Z' }), /^Account · blocked /)
})
test('hide for everyone is offered on a post, a thread and a chat line, never on an upload or twice', () => {
assert.equal(canHideForEveryone({ targetType: 'team_forum_post', actionedWith: null }), true)
assert.equal(canHideForEveryone({ targetType: 'team_forum_thread', actionedWith: null }), true)
assert.equal(canHideForEveryone({ targetType: 'rust.chat', actionedWith: null }), true)
assert.equal(canHideForEveryone({ targetType: 'team_forum_upload', actionedWith: null }), false)
assert.equal(canHideForEveryone({ targetType: 'rust.chat', actionedWith: 'hide' }), false)
})