feat(safety): report, block and hide, MODULE_API 1.21.0 #233

Merged
whitlocktech merged 1 commits from feature/user-safety into main 2026-10-30 07:24:39 -05:00
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)
})

View File

@@ -1301,6 +1301,72 @@ CREATE TABLE IF NOT EXISTS content_reports (
INDEX idx_cr_team (team_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- What the reported item said when it was reported (USER_SAFETY.md §4.1). A chat
-- line is pruned with the rest of a module's event log, and a queue row must still
-- show what was said after that; a forum author editing a post after the report
-- must not change what staff read. NULL on reports filed before 1.21.0.
--
-- `actioned_with` names what the queue DID when a staffer actioned a report
-- through it ('hide' — hide for everyone). NULL for an "actioned" that only
-- records a decision, which is every report before 1.21.0.
ALTER TABLE content_reports ADD COLUMN IF NOT EXISTS snapshot JSON NULL;
ALTER TABLE content_reports ADD COLUMN IF NOT EXISTS actioned_with VARCHAR(16) NULL;
-- A member's blocks (USER_SAFETY.md §4.1, S2, S12, S14). Saved on the account,
-- so a block applies on the website and in the app, on every device.
--
-- Two kinds of row, never both at once:
-- • an ACCOUNT block names `blocked_user_id`. This is the normal block (S12):
-- a forum author, and a chat speaker whose game identity is linked to an
-- account, are blocked as the account.
-- • a GAME block names (`module_id`, `identity_kind`, `external_id`) — a
-- Steam id in a chat feed that no account has linked. When that identity is
-- later linked, the row is rewritten as an account block the next time it is
-- read (S14).
--
-- Staff cannot be blocked (S8). The refusal happens when the block is made; a
-- row whose target LATER became staff is kept and simply stops applying, so the
-- Blocked page can say why rather than the block vanishing.
--
-- Each kind has its own unique key, and MySQL's NULL-distinct rule keeps them
-- out of each other's way: an account row's game columns are NULL, and a game
-- row's `blocked_user_id` is.
--
-- `display_name` is a snapshot of what the member saw when they blocked: the
-- username, or the in-game name of an unlinked speaker.
CREATE TABLE IF NOT EXISTS user_blocks (
id INT AUTO_INCREMENT PRIMARY KEY,
blocker_user_id INT NOT NULL,
blocked_user_id INT NULL,
module_id VARCHAR(32) NULL,
identity_kind VARCHAR(32) NULL,
external_id VARCHAR(64) NULL,
display_name VARCHAR(64) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_ub_blocker FOREIGN KEY (blocker_user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_ub_blocked FOREIGN KEY (blocked_user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_ub_account (blocker_user_id, blocked_user_id),
UNIQUE KEY uq_ub_game (blocker_user_id, module_id, identity_kind, external_id),
INDEX idx_ub_external (module_id, identity_kind, external_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- A member's "Hide for me" (USER_SAFETY.md §1, S3): one item gone from their own
-- view on every device. No report is filed and nothing about the item changes.
--
-- `target_type` is a report target type (`team_forum_thread`, `team_forum_post`,
-- or a module's, e.g. `rust.chat`) and `target_id` its id. Nothing checks the
-- item exists: answering "no such item" would tell a member whether a thread id
-- exists in a forum they cannot see, and a row for an item that is not there
-- hides nothing.
CREATE TABLE IF NOT EXISTS user_hidden_items (
user_id INT NOT NULL,
target_type VARCHAR(32) NOT NULL,
target_id BIGINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, target_type, target_id),
CONSTRAINT fk_uhi_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The §2.9 approval queue. A MODERATOR performing one of the three actions that
-- publish untrusted game-sourced strings creates a pending row here; an ADMIN
-- performing one applies it immediately. Rows are kept after a decision — "a

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.20.0",
"moduleApiVersion": "1.21.0",
"triggers": [
{
"id": "event.phase.changed",

View File

@@ -818,6 +818,17 @@
"validate"
]
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/hide",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search",
@@ -1928,6 +1939,37 @@
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/blocks",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/auth/me/blocks",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/blocks/:id",
"handlers": 4,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/devices",
@@ -1959,6 +2001,46 @@
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/hidden",
"handlers": 2,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/hidden",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/auth/me/hidden",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/hidden/:targetType/:targetId",
"handlers": 5,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications",
@@ -2297,6 +2379,17 @@
"requireAuth"
]
},
{
"method": "POST",
"path": "/api/v1/player/reports",
"handlers": 7,
"gates": [
"noindex",
"requireAuth",
"middleware",
"validate"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams",

View File

@@ -357,6 +357,10 @@
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/handle"
},
{
"method": "POST",
"path": "/api/v1/admin/moderation/reports/:id/hide"
},
{
"method": "GET",
"path": "/api/v1/admin/moderation/search"
@@ -789,6 +793,18 @@
"method": "PATCH",
"path": "/api/v1/auth/me/account/username"
},
{
"method": "GET",
"path": "/api/v1/auth/me/blocks"
},
{
"method": "POST",
"path": "/api/v1/auth/me/blocks"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/blocks/:id"
},
{
"method": "GET",
"path": "/api/v1/auth/me/devices"
@@ -801,6 +817,22 @@
"method": "DELETE",
"path": "/api/v1/auth/me/devices/:id"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/hidden"
},
{
"method": "GET",
"path": "/api/v1/auth/me/hidden"
},
{
"method": "POST",
"path": "/api/v1/auth/me/hidden"
},
{
"method": "DELETE",
"path": "/api/v1/auth/me/hidden/:targetType/:targetId"
},
{
"method": "GET",
"path": "/api/v1/auth/me/notifications"
@@ -945,6 +977,10 @@
"method": "GET",
"path": "/api/v1/player/events/history"
},
{
"method": "POST",
"path": "/api/v1/player/reports"
},
{
"method": "GET",
"path": "/api/v1/player/teams"

View File

@@ -15,7 +15,7 @@ const { query } = require('../../utils/db')
const COLUMNS = `
id, target_type, target_id, team_id, reporter_user_id, reporter_username,
reason, detail, status, handled_by, handled_username, handled_note, handled_at,
created_at`
created_at, snapshot, actioned_with`
const OPEN_STATUSES = ['open', 'reviewing']
@@ -28,13 +28,16 @@ const OPEN_STATUSES = ['open', 'reviewing']
* comes back as a clean `null` so the caller can answer 409 without knowing what
* a MySQL error code looks like.
*/
async function insert({ targetType, targetId, teamId, reporterUserId, reporterUsername, reason, detail }) {
async function insert({ targetType, targetId, teamId, reporterUserId, reporterUsername, reason, detail, snapshot }) {
try {
const res = await query(
`INSERT INTO content_reports
(target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null],
(target_type, target_id, team_id, reporter_user_id, reporter_username, reason, detail, snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
targetType, targetId, teamId ?? null, reporterUserId, reporterUsername, reason, detail ?? null,
snapshot ? JSON.stringify(snapshot) : null,
],
)
return res.insertId
} catch (err) {
@@ -93,13 +96,19 @@ async function openCount() {
* `handled_*` is stamped for every status including `reviewing`, so "who has this"
* is answerable while it is in progress and not only after it is closed — that is
* what stops two staffers working the same report.
*
* `actionedWith` is written only when given (the queue's "Hide for everyone"):
* a later status change by hand records a new decision, and does not un-say
* that the item was hidden.
*/
async function handle(id, { status, handledBy, handledUsername, note }) {
async function handle(id, { status, handledBy, handledUsername, note, actionedWith }) {
const withAction = actionedWith !== undefined
const res = await query(
`UPDATE content_reports
SET status = ?, handled_by = ?, handled_username = ?, handled_note = ?, handled_at = NOW()
${withAction ? ', actioned_with = ?' : ''}
WHERE id = ?`,
[status, handledBy, handledUsername, note ?? null, id],
[status, handledBy, handledUsername, note ?? null, ...(withAction ? [actionedWith] : []), id],
)
return res.affectedRows > 0
}

View File

@@ -24,11 +24,24 @@
// participant in one Team could file reports carrying another Team's id, and the
// queue's per-Team filter would quietly be lying.
//
// **Since 1.21.0 (USER_SAFETY.md §4.1) a target can be a module's** — a Rust chat
// line is `rust.chat` — and every report stores a `snapshot` of what the item
// said when it was reported. Who may see the item is now asked of one resolver
// (`model/safety/targets.js`) for every type, forum or module, so reaching
// `file()` through `/player/reports` without a Team is as safe as reaching it
// through a Team's forum route.
const reportsDb = require('./contentReports.db')
const forumDb = require('../teams/teamForum.db')
const forum = require('../teams/teamForum.model')
const targets = require('../safety/targets')
const registries = require('../../modules/registries')
const { displayAuthor, hasActor } = require('../../utils/displayAuthor')
const TARGET_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload']
// Core's own types — what the forum's report route accepts. Module types are
// registered (registries.reportTarget) and checked by `targets.isReportable`.
const TARGET_TYPES = targets.FORUM_TYPES
const REASONS = ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other']
const STATUSES = ['open', 'reviewing', 'actioned', 'dismissed']
@@ -68,6 +81,23 @@ async function targetTeamId(targetType, targetId) {
return null
}
/**
* What `content_reports.snapshot` keeps: the item as the reporter saw it, with
* its author. The author's account id is kept beside the name so the queue can
* put the name through displayAuthor's rule like every other snapshot.
*/
function snapshotOf(item) {
return {
...item.snapshot,
module: item.module || null,
author: {
name: item.author.name,
userId: item.author.userId,
identity: item.author.identity,
},
}
}
/**
* File a report.
*
@@ -76,27 +106,30 @@ async function targetTeamId(targetType, targetId) {
* reports twice because nothing seemed to happen deserves to be told the first
* one is already in the queue.
*/
async function file({ team, actor, targetType, targetId, reason, detail }) {
if (!TARGET_TYPES.includes(targetType)) {
async function file({ team = null, actor, targetType, targetId, reason, detail }) {
if (!targets.isReportable(targetType)) {
return { ok: false, status: 400, error: 'Unknown report target' }
}
if (!REASONS.includes(reason)) {
return { ok: false, status: 400, error: 'Unknown report reason' }
}
const owner = await targetTeamId(targetType, targetId)
if (owner == null || owner !== team.id) {
return { ok: false, status: 404, error: 'Not found' }
}
// The reporter must be able to see what they report — the same resolver a
// block uses, so "not there" and "not yours to see" are one answer (404).
const item = await targets.resolve({ targetType, targetId, viewer: actor })
if (!item) return { ok: false, status: 404, error: 'Not found' }
// Through a Team's forum route, the target must be in THAT Team.
if (team && item.teamId !== team.id) return { ok: false, status: 404, error: 'Not found' }
const id = await reportsDb.insert({
targetType,
targetId,
teamId: team.id,
teamId: item.teamId,
reporterUserId: actor.id,
reporterUsername: actor.username,
reason,
detail,
snapshot: snapshotOf(item),
})
if (id == null) {
return { ok: false, status: 409, error: 'You have already reported this. Staff are looking at it.' }
@@ -207,9 +240,62 @@ function describeTarget(report, { threadMap, postMap, uploadMap }, viewer = null
deleted: u.deleted_at != null,
}
}
// A module's target (1.21.0). Core cannot read the module's rows, and does
// not need to: the snapshot taken when the report was filed is the target as
// the reporter saw it, and it outlives the module pruning its event log.
const target = registries.reportTarget(report.target_type)
const snap = parseSnapshot(report.snapshot)
if (target && snap) {
return {
kind: 'module',
module: target.owner,
label: target.label,
text: snap.text || null,
title: snap.title || null,
at: snap.at || null,
where: snap.where || {},
}
}
return null
}
function parseSnapshot(raw) {
if (!raw) return null
if (typeof raw === 'object') return raw
try {
return JSON.parse(raw)
} catch {
return null
}
}
/**
* The stored snapshot, for one viewer. The author's name goes through
* displayAuthor's rule like every other snapshot in the row; a game identity is
* staff's to see, and the queue is staff-only by its route.
*/
function publicSnapshot(raw, viewer) {
const snap = parseSnapshot(raw)
if (!snap) return null
const a = snap.author || {}
// An author with no account at all (an unlinked chat speaker) is not a deleted
// account: their name is the in-game one and shows as it is.
const author = a.userId == null && a.identity
? { name: a.name || null, deleted: false }
: displayAuthor({ userId: a.userId ?? null, username: a.name }, viewer)
return {
label: snap.label || null,
title: snap.title || null,
text: snap.text || null,
at: snap.at || null,
where: snap.where || {},
module: snap.module || null,
author: author.name,
authorDeleted: author.deleted,
authorIdentity: a.identity || null,
}
}
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
@@ -231,6 +317,9 @@ function publicReport(row, viewer = null) {
handledByDeleted: handler ? handler.deleted : false,
handledNote: row.handled_note,
handledAt: row.handled_at,
actionedWith: row.actioned_with || null,
// Reports filed before 1.21.0 have none.
snapshot: publicSnapshot(row.snapshot, viewer),
createdAt: row.created_at,
}
}
@@ -252,6 +341,84 @@ async function handle({ id, actor, status, note }) {
return { ok: true, report: publicReport(await reportsDb.byId(id), actor) }
}
/**
* "Hide for everyone" from the queue (USER_SAFETY.md §4.1, S3, S10). Staff-only
* by its route.
*
* The one queue action that DOES something to the content — which is why it is
* its own verb and not a status. It goes through the content's own hide, so
* nothing new can hide anything: a forum thread or post through the forum's
* moderation, recorded in its ledger as a staff action like a hide from the
* forum's own menu; a module's item through the module's `hide`. Then the
* report is actioned with `actioned_with = 'hide'`, so the queue says what was
* done and not only that something was decided.
*
* Undoing it is not here: the hidden item is still shown to staff, marked, where
* it lives (S10), and its own menu unhides it.
*
* Uploads cannot be hidden: an upload is shown inside a post, and hiding that
* post is the act.
*/
async function hideForEveryone({ id, actor, note }) {
const report = await reportsDb.byId(id)
if (!report) return { ok: false, status: 404, error: 'Report not found' }
const type = report.target_type
const targetId = Number(report.target_id)
if (type === 'team_forum_thread' || type === 'team_forum_post') {
const team = await forumTeamOf(type, targetId)
if (!team) return { ok: false, status: 404, error: 'That item no longer exists' }
const moderate = type === 'team_forum_thread' ? forum.moderateThread : forum.moderatePost
const result = await moderate({
team,
...(type === 'team_forum_thread' ? { threadId: targetId } : { postId: targetId }),
action: 'hide',
actor,
actorRole: 'staff',
reason: `Report #${id}`,
})
if (!result.ok) return result
} else {
const target = registries.reportTarget(type)
if (!target) {
return { ok: false, status: 400, error: type === 'team_forum_upload'
? 'An upload is hidden by hiding the post it is in'
: 'That kind of item cannot be hidden from here' }
}
try {
await targets.withTimeout(
() => target.hide({ targetId, actor: Object.freeze({ id: actor.id, username: actor.username, role: actor.role }) }),
targets.MODULE_TIMEOUT_MS,
)
} catch (err) {
return { ok: false, status: 503, error: 'The game module did not answer. Try again in a moment.' }
}
}
await reportsDb.handle(id, {
status: 'actioned',
handledBy: actor.id,
handledUsername: actor.username,
note,
actionedWith: 'hide',
})
return { ok: true, report: publicReport(await reportsDb.byId(id), actor), teamId: report.team_id }
}
/** The Team a forum thread or post is in, by id — for the queue, which has no slug. */
async function forumTeamOf(type, targetId) {
let threadId = targetId
if (type === 'team_forum_post') {
const post = await forumDb.postById(targetId)
if (!post) return null
threadId = post.thread_id
}
const thread = await forumDb.threadById(threadId)
if (!thread) return null
// The forum model wants the Team row only for its id; the ledger names it.
return { id: thread.team_id }
}
module.exports = {
TARGET_TYPES,
REASONS,
@@ -260,6 +427,7 @@ module.exports = {
file,
queue,
handle,
hideForEveryone,
openCount: reportsDb.openCount,
publicReport,
targetTeamId,

View File

@@ -0,0 +1,217 @@
// ── What a reported, hidden or blocked item IS, for one reader ─────────────
//
// USER_SAFETY.md §4.1. Every report, every "Block author" and the Reports
// queue's "Hide for everyone" starts by asking one question about an item: what
// is it, who wrote it, and may this reader see it at all? This file answers it
// once, for core's forum types and for every module's registered types.
//
// **A reader who cannot see the item gets `null`, the same answer as an item
// that is not there.** Telling them apart would let anyone ask whether thread
// 812 exists in a private Team's forum, or who said chat line 4410 on a server
// whose presence is hidden from them (§5.5.1 of TEAMS.md, and the reason the
// forum answers 404 rather than 403).
//
// Core's forum types are answered here directly; a module's are answered by its
// own `snapshot`, under a time limit, because the module owns the content.
const forumDb = require('../teams/teamForum.db')
const teamsDb = require('../teams/teams.db')
const access = require('../teams/teamAccess.model')
const forumSettings = require('../teams/teamForumSettings.model')
const registries = require('../../modules/registries')
const log = require('../../utils/logger')('safety')
// Report types core owns. Uploads can be reported (the forum's own route takes
// them) but not hidden or blocked from: an upload is shown inside a post, so
// hiding the post, or blocking its author, is what hides it.
const FORUM_TYPES = ['team_forum_thread', 'team_forum_post', 'team_forum_upload']
const FORUM_HIDEABLE = ['team_forum_thread', 'team_forum_post']
// The forum's own "staff": who may moderate there (teamForum.controller.js).
const FORUM_STAFF_ROLES = ['admin', 'moderator']
const MODULE_TIMEOUT_MS = 5_000
const TEXT_CHARS = 500
const excerpt = (html) => String(html || '')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, TEXT_CHARS)
const clip = (value, n) => (value == null ? null : String(value).slice(0, n))
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))
}
/** Is this a type anybody can report — core's or a started module's? */
function isReportable(type) {
return FORUM_TYPES.includes(type) || registries.reportTarget(type) != null
}
/** Is this a type a member can hide for themself, or block the author of? */
function isHideable(type) {
return FORUM_HIDEABLE.includes(type) || registries.reportTarget(type) != null
}
/**
* The forum that holds a thread, and whether this reader may see it and
* moderate in it. Null when the forum is off, the Team is gone or the reader
* has no access — the forum controller's `resolveForum`, for a target found by
* id rather than reached through a slug.
*/
async function forumFor(teamId, viewer) {
if (!(await forumSettings.forumsEnabled())) return null
const team = await teamsDb.findById(teamId)
if (!team) return null
const resolved = await access.forumAccess(team.id, viewer.id)
const staff = FORUM_STAFF_ROLES.includes(viewer.role)
if (!resolved.allowed && !staff) return null
return { team, canModerate: resolved.isLeader || staff }
}
/** A forum thread, post or upload, for one reader. */
async function resolveForum(type, id, viewer) {
if (type === 'team_forum_thread') {
const t = await forumDb.threadById(id)
if (!t) return null
const forum = await forumFor(t.team_id, viewer)
if (!forum) return null
if (t.status !== 'visible' && !forum.canModerate) return null
const first = await forumDb.firstPostOf(t.id)
return {
type,
targetId: id,
teamId: t.team_id,
team: forum.team,
author: { userId: t.created_by ?? null, name: t.created_username || null, identity: null },
snapshot: {
label: 'Forum thread',
title: clip(t.title, 200),
text: first ? excerpt(first.body_html) : null,
at: t.created_at,
where: { teamId: t.team_id, team: forum.team.name, threadId: t.id },
},
}
}
if (type === 'team_forum_post') {
const p = await forumDb.postById(id)
if (!p) return null
const t = await forumDb.threadById(p.thread_id)
if (!t) return null
const forum = await forumFor(t.team_id, viewer)
if (!forum) return null
if ((p.status !== 'visible' || t.status !== 'visible') && !forum.canModerate) return null
return {
type,
targetId: id,
teamId: t.team_id,
team: forum.team,
author: { userId: p.author_user_id ?? null, name: p.author_username || null, identity: null },
snapshot: {
label: 'Forum post',
title: clip(t.title, 200),
text: excerpt(p.body_html),
at: p.created_at,
where: { teamId: t.team_id, team: forum.team.name, threadId: t.id, postId: p.id },
},
}
}
if (type === 'team_forum_upload') {
const u = await forumDb.uploadById(id)
if (!u) return null
const forum = await forumFor(u.team_id, viewer)
if (!forum) return null
return {
type,
targetId: id,
teamId: u.team_id,
team: forum.team,
author: { userId: u.uploader_user_id ?? null, name: u.uploader_username || null, identity: null },
snapshot: {
label: 'Forum upload',
title: null,
text: clip(u.filename, 200),
at: u.created_at,
where: { teamId: u.team_id, team: forum.team.name, postId: u.post_id },
},
}
}
return null
}
/**
* A module's answer, checked. A module is trusted to describe its own content
* and nothing more: what reaches the queue is clipped to known fields, so a
* snapshot cannot carry markup or a megabyte of frame into `content_reports`.
*/
function shapeModuleSnapshot(target, raw) {
if (!raw || typeof raw !== 'object') return null
const a = raw.author && typeof raw.author === 'object' ? raw.author : {}
const ident = a.identity && typeof a.identity === 'object'
&& typeof a.identity.kind === 'string' && a.identity.id != null
? { kind: a.identity.kind, id: String(a.identity.id).slice(0, 64) }
: null
const userId = Number.isInteger(Number(a.userId)) && a.userId != null ? Number(a.userId) : null
const where = raw.where && typeof raw.where === 'object'
? Object.fromEntries(Object.entries(raw.where)
.filter(([, v]) => v == null || ['string', 'number', 'boolean'].includes(typeof v))
.slice(0, 8)
.map(([k, v]) => [String(k).slice(0, 32), typeof v === 'string' ? v.slice(0, 120) : v]))
: {}
return {
author: { userId, name: clip(a.name, 64), identity: ident },
snapshot: {
label: target.label,
title: clip(raw.title, 200),
text: clip(raw.text, TEXT_CHARS),
at: raw.at || null,
where,
},
}
}
/**
* The item, for this reader, or null.
*
* @param {{targetType: string, targetId: number, viewer: {id: number, role: string}}} q
* @returns {Promise<null | {type, targetId, teamId, team?, module?, author, snapshot}>}
*/
async function resolve({ targetType, targetId, viewer }) {
if (FORUM_TYPES.includes(targetType)) return resolveForum(targetType, targetId, viewer)
const target = registries.reportTarget(targetType)
if (!target) return null
let raw
try {
raw = await withTimeout(
() => target.snapshot({ targetId, viewer: Object.freeze({ userId: viewer.id, role: viewer.role }) }),
MODULE_TIMEOUT_MS,
)
} catch (err) {
log.error('report target snapshot failed', { module: target.owner, type: targetType, message: err.message })
const e = new Error('The game module did not answer')
e.status = 503
throw e
}
const shaped = shapeModuleSnapshot(target, raw)
if (!shaped) return null
return { type: targetType, targetId, teamId: null, module: target.owner, ...shaped }
}
module.exports = {
FORUM_TYPES,
FORUM_HIDEABLE,
FORUM_STAFF_ROLES,
MODULE_TIMEOUT_MS,
isReportable,
isHideable,
resolve,
withTimeout,
_shapeModuleSnapshot: shapeModuleSnapshot,
}

View File

@@ -0,0 +1,161 @@
// SQL for a member's blocks and hides (USER_SAFETY.md §4.1).
//
// `user_blocks` holds two kinds of row — an account block and a game block —
// and `user_hidden_items` one row per item a member hid for themself. Nothing
// here decides who may block whom; that is the model's job (S8, S12, S14).
const { query } = require('../../utils/db')
const BLOCK_COLUMNS = `
b.id, b.blocker_user_id, b.blocked_user_id, b.module_id, b.identity_kind,
b.external_id, b.display_name, b.created_at`
const isDuplicate = (err) => Boolean(err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062))
/**
* A member's blocks, newest first, each account block with the blocked
* account's CURRENT username and role beside the snapshot.
*
* The role is read here rather than stored, because S8 is about who the target
* is now: a block on a player who later became a moderator stops applying.
*/
async function blocksFor(blockerUserId) {
return query(
`SELECT ${BLOCK_COLUMNS}, u.username AS blocked_username, u.role AS blocked_role
FROM user_blocks b
LEFT JOIN users u ON u.id = b.blocked_user_id
WHERE b.blocker_user_id = ?
ORDER BY b.created_at DESC, b.id DESC`,
[blockerUserId],
)
}
/**
* Block an account. Answers the new row's id, or null when the member had
* already blocked them — caught by the unique key, so two taps cannot race into
* two rows.
*/
async function insertAccountBlock({ blockerUserId, blockedUserId, displayName }) {
try {
const res = await query(
`INSERT INTO user_blocks (blocker_user_id, blocked_user_id, display_name) VALUES (?, ?, ?)`,
[blockerUserId, blockedUserId, displayName ?? null],
)
return res.insertId
} catch (err) {
if (isDuplicate(err)) return null
throw err
}
}
/** Block an unlinked game identity. Null on a duplicate, as above. */
async function insertGameBlock({ blockerUserId, moduleId, kind, externalId, displayName }) {
try {
const res = await query(
`INSERT INTO user_blocks (blocker_user_id, module_id, identity_kind, external_id, display_name)
VALUES (?, ?, ?, ?, ?)`,
[blockerUserId, moduleId, kind, externalId, displayName ?? null],
)
return res.insertId
} catch (err) {
if (isDuplicate(err)) return null
throw err
}
}
/**
* Rewrite a game block as an account block (S14): its identity is linked now.
*
* When the member had ALSO blocked that account directly, the unique key refuses
* the rewrite, and the game row is then simply redundant — the account block
* already says everything it said — so it is deleted instead.
*/
async function convertToAccountBlock(id, blockedUserId) {
try {
await query(
`UPDATE user_blocks
SET blocked_user_id = ?, module_id = NULL, identity_kind = NULL, external_id = NULL
WHERE id = ?`,
[blockedUserId, id],
)
return 'converted'
} catch (err) {
if (!isDuplicate(err)) throw err
await query('DELETE FROM user_blocks WHERE id = ?', [id])
return 'merged'
}
}
/** Undo one of this member's blocks. Scoped to the blocker, so an id is not a capability. */
async function deleteBlock(blockerUserId, id) {
const res = await query('DELETE FROM user_blocks WHERE id = ? AND blocker_user_id = ?', [id, blockerUserId])
return res.affectedRows > 0
}
/** Which of these accounts are staff (admin, editor, moderator). */
async function staffAmong(userIds, staffRoles) {
if (!userIds.length) return []
const rows = await query(
`SELECT id FROM users
WHERE id IN (${userIds.map(() => '?').join(',')})
AND role IN (${staffRoles.map(() => '?').join(',')})`,
[...userIds, ...staffRoles],
)
return rows.map((r) => Number(r.id))
}
/** One account's id, username and role — the target of a block. */
async function userById(id) {
const rows = await query('SELECT id, username, role FROM users WHERE id = ? LIMIT 1', [id])
return rows[0] || null
}
// ── hide for me ────────────────────────────────────────────────────────────
/** Every item this member hid, newest first; optionally of the given types only. */
async function hiddenFor(userId, types = null) {
if (types && !types.length) return []
const where = types ? ` AND target_type IN (${types.map(() => '?').join(',')})` : ''
return query(
`SELECT target_type, target_id, created_at FROM user_hidden_items
WHERE user_id = ?${where}
ORDER BY created_at DESC`,
[userId, ...(types || [])],
)
}
/** Hide one item. Idempotent: hiding twice is still hidden. */
async function insertHidden(userId, targetType, targetId) {
await query(
`INSERT IGNORE INTO user_hidden_items (user_id, target_type, target_id) VALUES (?, ?, ?)`,
[userId, targetType, targetId],
)
}
async function deleteHidden(userId, targetType, targetId) {
const res = await query(
'DELETE FROM user_hidden_items WHERE user_id = ? AND target_type = ? AND target_id = ?',
[userId, targetType, targetId],
)
return res.affectedRows > 0
}
/** Show every hidden item again. Answers how many there were. */
async function clearHidden(userId) {
const res = await query('DELETE FROM user_hidden_items WHERE user_id = ?', [userId])
return res.affectedRows
}
module.exports = {
clearHidden,
blocksFor,
insertAccountBlock,
insertGameBlock,
convertToAccountBlock,
deleteBlock,
staffAmong,
userById,
hiddenFor,
insertHidden,
deleteHidden,
}

View File

@@ -0,0 +1,306 @@
// ── Block and "Hide for me" (USER_SAFETY.md §1, §4.1) ──────────────────────
//
// A block lives on the blocker's ACCOUNT (S2), so it applies on the website and
// in the app, on every device. The rules that shape this file, each decided by
// the org lead:
//
// S12 A block names the author's WEBSITE ACCOUNT whenever there is one. A
// forum author always has one; a chat speaker has one when their game
// identity is linked. Only an unlinked game identity — most people in a
// Rust chat feed — is blocked as itself, a Steam id.
// S14 When that identity is later linked, the game block becomes an account
// block, the next time the member's blocks are read. From then on it hides
// the account's forum posts too.
// S8 Staff (admin, editor, moderator) cannot be blocked. Blocking one is
// refused. A block whose target LATER became staff — promoted, or a game
// block whose identity linked to a staff account — is kept but stops
// applying, and the Blocked page says why.
// S9 A block does nothing to the blocked person. They are not told.
//
// "Hide for me" is one item gone from one member's view; it files no report and
// changes nothing about the item (§1).
//
// Filtering happens on the server for every reader-specific read: core's forum
// here, and a module's per-viewer feeds through `ctx.safety.forViewer` (S11).
const db = require('./userSafety.db')
const targets = require('./targets')
const registries = require('../../modules/registries')
const { STAFF_ROLES } = require('../../utils/displayAuthor')
const log = require('../../utils/logger')('safety')
const isStaffRole = (role) => STAFF_ROLES.includes(role)
const refuse = (status, code, error) => ({ ok: false, status, code, error })
/**
* This member's blocks, as they stand NOW.
*
* S14 runs here: every game block is checked against its module's links, and
* one whose identity is linked is rewritten as an account block before the list
* is answered. Reading is where it happens because a link is made in a module,
* which has no reason to tell core, and a block that waited for a notification
* would be a block that silently missed one.
*
* A module that does not answer leaves its game blocks as they are: they still
* apply to the Steam id, which is the block the member made.
*/
async function currentBlocks(userId) {
let rows = await db.blocksFor(userId)
const game = rows.filter((r) => r.blocked_user_id == null && r.module_id)
if (game.length) {
const groups = new Map()
for (const r of game) {
const key = `${r.module_id}:${r.identity_kind}`
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(r)
}
let changed = false
for (const group of groups.values()) {
const ident = registries.gameIdentity(group[0].module_id, group[0].identity_kind)
if (!ident) continue
let owners = {}
try {
owners = (await targets.withTimeout(
() => ident.ownersOf(group.map((r) => r.external_id)),
targets.MODULE_TIMEOUT_MS,
)) || {}
} catch (err) {
log.warn('game identity lookup failed; game blocks left as they are', {
module: ident.owner, kind: ident.kind, message: err.message,
})
continue
}
for (const r of group) {
const owner = Number(owners[r.external_id])
if (!Number.isInteger(owner) || owner <= 0) continue
// The member blocked a Steam id that turned out to be their own. A block
// on yourself hides nothing worth hiding, so it is simply removed.
if (owner === Number(userId)) await db.deleteBlock(userId, r.id)
else await db.convertToAccountBlock(r.id, owner)
changed = true
}
}
if (changed) rows = await db.blocksFor(userId)
}
return rows.map((r) => ({
...r,
applies: r.blocked_user_id == null ? true : !isStaffRole(r.blocked_role),
}))
}
/** One block as the member sees it on Account → Blocked. */
function publicBlock(row) {
const account = row.blocked_user_id != null
const ident = account ? null : registries.gameIdentity(row.module_id, row.identity_kind)
return {
id: row.id,
kind: account ? 'account' : 'game',
name: account ? (row.blocked_username || row.display_name) : row.display_name,
identity: account
? null
: {
module: row.module_id,
kind: row.identity_kind,
label: ident ? ident.label : row.identity_kind,
id: row.external_id,
},
applies: row.applies,
reason: row.applies ? null : 'staff',
createdAt: row.created_at,
}
}
async function listBlocks(actor) {
return (await currentBlocks(actor.id)).map(publicBlock)
}
/** Block an account, after S8's check. */
async function blockAccount(actor, userId) {
if (Number(userId) === Number(actor.id)) return refuse(400, 'self', 'You can’t block yourself.')
const target = await db.userById(userId)
if (!target) return refuse(404, 'not_found', 'Not found')
if (isStaffRole(target.role)) return refuse(403, 'staff', 'You can’t block a staff member.')
const id = await db.insertAccountBlock({
blockerUserId: actor.id,
blockedUserId: target.id,
displayName: target.username,
})
if (id == null) return refuse(409, 'already_blocked', `You have already blocked ${target.username}.`)
return { ok: true, block: { id, kind: 'account', name: target.username, identity: null, applies: true, reason: null } }
}
/**
* Block the author of one item (S12).
*
* The client names the item, never the person: the server reads who wrote it,
* through the same resolver a report uses, so a member can only block someone
* whose words they could see, and the block lands on the account when there is
* one.
*/
async function blockAuthorOf({ actor, targetType, targetId }) {
if (!targets.isHideable(targetType)) return refuse(400, 'unknown_target', 'Unknown target')
const item = await targets.resolve({ targetType, targetId, viewer: actor })
if (!item) return refuse(404, 'not_found', 'Not found')
const { author } = item
if (author.userId != null) return blockAccount(actor, author.userId)
// No account on the item. A forum item with no account is a deleted one, and
// there is nobody left to block.
if (!item.module || !author.identity) {
return refuse(400, 'no_author', 'That account no longer exists.')
}
// A game identity. S12: its account first, when it has one.
const ident = registries.gameIdentity(item.module, author.identity.kind)
if (!ident) return refuse(400, 'no_author', 'This player can’t be blocked.')
let owners
try {
owners = (await targets.withTimeout(() => ident.ownersOf([author.identity.id]), targets.MODULE_TIMEOUT_MS)) || {}
} catch (err) {
log.error('game identity lookup failed', { module: ident.owner, kind: ident.kind, message: err.message })
return refuse(503, 'module_unavailable', 'The game module did not answer. Try again in a moment.')
}
const owner = Number(owners[author.identity.id])
if (Number.isInteger(owner) && owner > 0) return blockAccount(actor, owner)
const id = await db.insertGameBlock({
blockerUserId: actor.id,
moduleId: item.module,
kind: author.identity.kind,
externalId: author.identity.id,
displayName: author.name,
})
if (id == null) return refuse(409, 'already_blocked', `You have already blocked ${author.name || 'this player'}.`)
return {
ok: true,
block: {
id,
kind: 'game',
name: author.name,
identity: { module: item.module, kind: ident.kind, label: ident.label, id: author.identity.id },
applies: true,
reason: null,
},
}
}
async function unblock(actor, id) {
return (await db.deleteBlock(actor.id, id)) ? { ok: true } : refuse(404, 'not_found', 'Not found')
}
// ── hide for me ────────────────────────────────────────────────────────────
/**
* Hide one item for this member. The type must be one a member can hide; the
* item is not looked up — see `user_hidden_items` in schema.sql for why a row for
* an id that does not exist is harmless and an existence check is not.
*/
async function hide(actor, targetType, targetId) {
if (!targets.isHideable(targetType)) return refuse(400, 'unknown_target', 'Unknown target')
await db.insertHidden(actor.id, targetType, targetId)
return { ok: true }
}
async function unhide(actor, targetType, targetId) {
if (!targets.isHideable(targetType)) return refuse(400, 'unknown_target', 'Unknown target')
await db.deleteHidden(actor.id, targetType, targetId)
return { ok: true }
}
/** Account → Blocked's "Show them all again". */
async function clearHidden(actor) {
return db.clearHidden(actor.id)
}
async function listHidden(actor) {
return (await db.hiddenFor(actor.id)).map((r) => ({
targetType: r.target_type,
targetId: Number(r.target_id),
createdAt: r.created_at,
}))
}
// ── what one reader's reads leave out ──────────────────────────────────────
const EMPTY_FILTER = Object.freeze({
blockedUserIds: new Set(),
hidden: Object.freeze({ team_forum_thread: new Set(), team_forum_post: new Set() }),
})
/**
* What core's forum leaves out for this reader: the accounts they block (that
* still apply) and the threads and posts they hid.
*/
async function forumFilter(userId) {
if (!userId) return EMPTY_FILTER
const [blocks, hidden] = await Promise.all([
currentBlocks(userId),
db.hiddenFor(userId, targets.FORUM_HIDEABLE),
])
const filter = {
blockedUserIds: new Set(blocks.filter((b) => b.applies && b.blocked_user_id != null).map((b) => Number(b.blocked_user_id))),
hidden: { team_forum_thread: new Set(), team_forum_post: new Set() },
}
for (const h of hidden) filter.hidden[h.target_type].add(Number(h.target_id))
return filter
}
/**
* `ctx.safety.forViewer` (API 1.21.0, S11): what one reader has blocked and
* hidden, for the calling module.
*
* `blockedUserIds` — accounts, every module's concern: a speaker linked to one
* is hidden whichever module's feed they speak in. `blockedIdentities` — this
* module's own unlinked game blocks, by kind. `hidden` — this reader's hides on
* this module's report targets, by type. Ids are answered as arrays of the type
* the module gave them (numbers for target ids, strings for identities).
*/
async function forModuleViewer(moduleId, userId) {
const empty = { blockedUserIds: [], blockedIdentities: {}, hidden: {} }
if (!userId) return empty
const types = registries.allReportTargets().filter((t) => t.owner === moduleId).map((t) => t.id)
const [blocks, hidden] = await Promise.all([currentBlocks(userId), db.hiddenFor(userId, types)])
const out = { blockedUserIds: [], blockedIdentities: {}, hidden: {} }
for (const b of blocks) {
if (!b.applies) continue
if (b.blocked_user_id != null) out.blockedUserIds.push(Number(b.blocked_user_id))
else if (b.module_id === moduleId) {
if (!out.blockedIdentities[b.identity_kind]) out.blockedIdentities[b.identity_kind] = []
out.blockedIdentities[b.identity_kind].push(String(b.external_id))
}
}
for (const t of types) out.hidden[t] = []
for (const h of hidden) out.hidden[h.target_type].push(Number(h.target_id))
return out
}
/** `ctx.safety.staffAmong` (S13): which of these accounts are staff. */
async function staffAmong(userIds) {
const ids = [...new Set((Array.isArray(userIds) ? userIds : [])
.map(Number)
.filter((n) => Number.isInteger(n) && n > 0))]
.slice(0, 500)
return db.staffAmong(ids, STAFF_ROLES)
}
module.exports = {
currentBlocks,
listBlocks,
blockAuthorOf,
blockAccount,
unblock,
hide,
unhide,
listHidden,
clearHidden,
forumFilter,
forModuleViewer,
staffAmong,
publicBlock,
}

View File

@@ -79,6 +79,15 @@ async function postById(id) {
return rows[0] || null
}
/** A thread's opening post, whatever its status — what a report on the thread quotes. */
async function firstPostOf(threadId) {
const rows = await query(
`SELECT ${POST_COLUMNS} FROM team_forum_posts WHERE thread_id = ? ORDER BY created_at, id LIMIT 1`,
[threadId],
)
return rows[0] || null
}
/**
* Append a post and move the thread's counters in the same breath.
*
@@ -275,6 +284,7 @@ module.exports = {
setThreadFlags,
postsByThread,
postById,
firstPostOf,
insertPost,
setPostStatus,
updatePostBody,

View File

@@ -21,7 +21,8 @@
const forumDb = require('./teamForum.db')
const forumSettings = require('./teamForumSettings.model')
const { cleanForumBody, renderForumBody } = require('../../utils/forumHtml')
const { displayAuthor } = require('../../utils/displayAuthor')
const { displayAuthor, STAFF_ROLES } = require('../../utils/displayAuthor')
const safetyDb = require('../safety/userSafety.db')
// 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
@@ -158,9 +159,42 @@ 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, viewer = null, limit = 50, offset = 0 } = {}) {
async function listThreads(teamId, { canModerate = false, viewer = null, filter = null, limit = 50, offset = 0 } = {}) {
const rows = await forumDb.threadsByTeam(teamId, { includeHidden: canModerate, limit, offset })
return rows.map((row) => publicThread(row, viewer))
return rows.filter((row) => !leftOutThread(row, filter)).map((row) => publicThread(row, viewer))
}
// ── one reader's blocks and hides (USER_SAFETY.md §4.1) ───────────────────
//
// `filter` is `userSafety.forumFilter(userId)`: the accounts this reader blocks
// and the threads and posts they hid. Applied on the server, to this reader's
// read only — nothing on disk changes, and nobody else's view does (S9).
/** Does this reader's filter leave this thread out? */
function leftOutThread(row, filter) {
if (!filter) return false
if (filter.hidden.team_forum_thread.has(Number(row.id))) return true
return row.created_by != null && filter.blockedUserIds.has(Number(row.created_by))
}
/**
* May this reader block these authors? Decided here and handed to the client,
* like `canEdit`: not yourself, not a deleted account, and not staff (S8) —
* the menu does not offer what the server would refuse.
*/
async function blockable(viewer, authorIds) {
const me = viewer && viewer.userId
if (!me) return () => false
const ids = [...new Set(authorIds.filter((id) => id != null && id !== me).map(Number))]
const staff = new Set(ids.length ? await safetyDb.staffAmong(ids, STAFF_ROLES) : [])
return (id) => id != null && Number(id) !== Number(me) && !staff.has(Number(id))
}
/** Does this reader's filter leave this post out? */
function leftOutPost(row, filter) {
if (!filter) return false
if (filter.hidden.team_forum_post.has(Number(row.id))) return true
return row.author_user_id != null && filter.blockedUserIds.has(Number(row.author_user_id))
}
/**
@@ -172,7 +206,7 @@ async function listThreads(teamId, { canModerate = false, viewer = null, limit =
* the window to the client and letting it compare timestamps — is the thing
* `editability` exists not to do.
*/
async function getThread(teamId, threadId, { canModerate = false, viewer } = {}) {
async function getThread(teamId, threadId, { canModerate = false, viewer, filter = null } = {}) {
const thread = await forumDb.threadById(threadId)
// The team check is here rather than in the SQL so a thread id from another
// Team reads as "not found" and not as "found, but not yours" — a forum is a
@@ -180,17 +214,23 @@ async function getThread(teamId, threadId, { canModerate = false, viewer } = {})
if (!thread || thread.team_id !== teamId) return null
if (thread.status === 'deleted' && !canModerate) return null
if (thread.status === 'hidden' && !canModerate) return null
// A thread this reader hid, or opened by someone they block, is gone from
// their view — a link to it included.
if (leftOutThread(thread, filter)) return null
const mode = await forumSettings.imageMode()
const posts = await forumDb.postsByThread(threadId, { includeHidden: canModerate })
const posts = (await forumDb.postsByThread(threadId, { includeHidden: canModerate }))
.filter((p) => !leftOutPost(p, filter))
const canBlock = await blockable(viewer, [thread.created_by, ...posts.map((p) => p.author_user_id)])
return {
...publicThread(thread, viewer),
canBlock: canBlock(thread.created_by),
// 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
// recomputed it would be a second place for the rule to live.
canReply: REPLYABLE_TYPES.includes(thread.type) && !thread.locked && thread.status === 'visible',
posts: posts.map((p) => renderPost(p, mode, viewer)),
posts: posts.map((p) => ({ ...renderPost(p, mode, viewer), canBlock: canBlock(p.author_user_id) })),
}
}
@@ -442,4 +482,6 @@ module.exports = {
publicThread,
renderPost,
editability,
leftOutThread,
leftOutPost,
}

View File

@@ -346,6 +346,25 @@ function buildCtx(id, moduleRoot) {
// configuration, so core answers it. A getter, not a captured string: the
// value is read per call, so it cannot go stale against the env.
site: { get baseUrl() { return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '') } },
// API 1.21.0 (USER_SAFETY.md §4.1, S11). READ only. What one reader has
// blocked and hidden, so a module filters its own per-reader feeds on the
// server — a chat feed is already read per viewer, so the website and the app
// need no list of their own.
//
// `forViewer` is bound to the CALLING module: it answers the reader's account
// blocks (with any game block whose identity is now linked already folded in,
// S14, and staff dropped, S8), this module's own unlinked game blocks, and
// the reader's hides on this module's report targets. Another module's game
// blocks and hides are not its business. A signed-out reader has none.
//
// `staffAmong(userIds)` answers which of those accounts are staff, for a
// module that marks lines `canBlock` (S13).
safety: {
// eslint-disable-next-line global-require
forViewer: (userId) => require('../model/safety/userSafety.model').forModuleViewer(id, userId),
// eslint-disable-next-line global-require
staffAmong: (userIds) => require('../model/safety/userSafety.model').staffAmong(userIds),
},
// API 1.18.0 (DEMO_MODE.md §3.4, §3.5). True on the public demo. For the
// locks a route list cannot express — a console command by its first word,
// a config file by which plugin owns it — which the module enforces in its
@@ -500,6 +519,18 @@ function buildApi(record) {
once('registerEventTemplates')
record.staged.registerEventTemplates(templates)
},
// API 1.21.0 (USER_SAFETY.md §4.1). What of the module's content a member
// can report, hide for themself and block the author of, and the game
// identity those authors are known by. Both STAGE, and `once` for the reason
// every batch above takes it.
registerReportTargets(targets) {
once('registerReportTargets')
record.staged.registerReportTargets(targets)
},
registerGameIdentities(identities) {
once('registerGameIdentities')
record.staged.registerGameIdentities(identities)
},
// The two lifecycle hooks (§2.5). Registered here, dispatched from
// lifecycle.js — this file runs with no database and the hooks run with one.
// Both are optional: a module with no warm-up and nothing to close simply

View File

@@ -97,6 +97,26 @@ const postHooks = new Map()
// second registration is a collision rather than an addition.
let teamProvider = null
// report target type → { owner, id, label, snapshot, hide, unhide } (API 1.21.0,
// USER_SAFETY.md §4.1). A module's content that a member can report, hide for
// themself, and block the author of — and that staff can hide for everyone. Core
// files the report and keeps the snapshot; the module owns the content and its
// hidden flag, so `hide`/`unhide` are the module's.
//
// Core's own forum types are NOT in this map: they predate it and their reads
// are core's, so `model/safety/targets.js` answers them directly. Registering
// them here would put core's forum behind a call core makes to itself.
const reportTargets = new Map()
// `${owner}:${kind}` → { owner, kind, label, ownersOf } (API 1.21.0, S12, S14).
// A game identity a module's report targets name as their author — for Rust, a
// Steam id. `ownersOf(externalIds)` answers which website account each is linked
// to, which is how a block on a chat speaker lands on their ACCOUNT when they
// have one, and how a block on an unlinked id becomes an account block once it
// is linked. Keyed by owner as well as kind: two modules may both speak of
// Steam ids, and each answers only for its own links.
const gameIdentities = new Map()
// command name → { owner, name, description, options, access, handler }. Slash
// commands a registrant has published for the chat platform (API 1.6.0,
// TEAMS.md §7.1).
@@ -361,6 +381,17 @@ const registeredTeamProvider = () => teamProvider
/** Is there a Team provider at all? Read by the reconciler and the read API. */
const hasTeamProvider = () => teamProvider !== null
// ── Report targets and game identities (USER_SAFETY.md §4.1) ──────────────
/** One module report target, or null. Core's forum types are never here. */
const reportTarget = (type) => reportTargets.get(type) || null
/** Every module report target, in registration order. */
const allReportTargets = () => [...reportTargets.values()]
/** One module's game identity of one kind, or null. */
const gameIdentity = (owner, kind) => gameIdentities.get(`${owner}:${kind}`) || null
// ── Slash commands (TEAMS.md §7.1) ─────────────────────────────────────────
/**
@@ -682,6 +713,55 @@ function checkTeamProviderShape(entry) {
return out
}
// A report target (API 1.21.0, USER_SAFETY.md §4.1). The copy is explicit, like
// the team provider's: what core calls is what is named here.
//
// snapshot({ targetId, viewer }) → { author, text, at, where } | null
// What the item is, for THIS viewer. `null` when it is gone or the viewer
// may not see it — core answers 404 either way, so a report, a block or a
// snapshot can never confirm an item the viewer could not have seen.
// `author` is { name, userId|null, identity: { kind, id }|null }.
// hide({ targetId, actor }) / unhide({ targetId, actor })
// Staff's hide for everyone (S3, S10), called from the Reports queue.
const REPORT_TARGET_ID = /^[a-z][a-z0-9-]*\.[a-z0-9][a-z0-9_.-]{0,22}$/
function checkReportTargetShape(entry) {
const t = entry || {}
if (typeof t.id !== 'string' || !REPORT_TARGET_ID.test(t.id) || t.id.length > 32) {
throw new Error(`registerReportTargets: bad id "${t.id}"`)
}
if (typeof t.label !== 'string' || !t.label.trim() || t.label.length > 40) {
throw new Error(`registerReportTargets: ${t.id} needs a label of at most 40 characters`)
}
for (const name of ['snapshot', 'hide', 'unhide']) {
if (typeof t[name] !== 'function') {
throw new Error(`registerReportTargets: ${t.id}.${name}() is missing or not a function`)
}
}
return { id: t.id, label: t.label.trim(), snapshot: t.snapshot, hide: t.hide, unhide: t.unhide }
}
// A game identity (API 1.21.0, S12, S14).
//
// ownersOf(externalIds) → { [externalId]: userId }
// The website account each id is linked to, through THIS module's links.
// An id with no account is left out.
const IDENTITY_KIND = /^[a-z][a-z0-9-]{1,31}$/
function checkGameIdentityShape(entry) {
const g = entry || {}
if (typeof g.kind !== 'string' || !IDENTITY_KIND.test(g.kind)) {
throw new Error(`registerGameIdentities: bad kind "${g.kind}"`)
}
if (typeof g.label !== 'string' || !g.label.trim() || g.label.length > 40) {
throw new Error(`registerGameIdentities: ${g.kind} needs a label of at most 40 characters`)
}
if (typeof g.ownersOf !== 'function') {
throw new Error(`registerGameIdentities: ${g.kind}.ownersOf() is missing or not a function`)
}
return { kind: g.kind, label: g.label.trim(), ownersOf: g.ownersOf }
}
// `pageUrlTemplate` is the fifth member and OPTIONAL (TEAMS.md §6.4, phase 6).
//
// **Why a module has to supply this at all.** Teams are a contract primitive with
@@ -1788,6 +1868,8 @@ function stage(owner) {
eventLeases: [],
eventOptionSources: [],
eventTemplates: [],
reportTargets: [],
gameIdentities: [],
}
return {
staged,
@@ -1855,6 +1937,16 @@ function stage(owner) {
registerEngagementSeeds(entry) {
staged.engagementSeeds.push(checkEngagementSeeds(owner, entry))
},
// MODULE_API 1.21.0 (USER_SAFETY.md §4.1). Core registers none: its forum
// types are answered by core directly (see `reportTargets` above).
registerReportTargets(entries) {
if (!Array.isArray(entries)) throw new Error('registerReportTargets: expected an array')
for (const e of entries) staged.reportTargets.push(checkReportTargetShape(e))
},
registerGameIdentities(entries) {
if (!Array.isArray(entries)) throw new Error('registerGameIdentities: expected an array')
for (const e of entries) staged.gameIdentities.push(checkGameIdentityShape(e))
},
}
}
@@ -1883,6 +1975,8 @@ function apply({
eventOptionSources: newEventOptionSources = [],
eventTemplates: newEventTemplates = [],
engagementSeeds: newSeeds = [],
reportTargets: newReportTargets = [],
gameIdentities: newGameIdentities = [],
}) {
// ── validate ──
const seenStreams = new Set()
@@ -2008,6 +2102,30 @@ function apply({
seenSources.add(s.id)
}
// Report targets: their own id space, namespaced, no legacy allowlist. Core's
// forum types are unprefixed and are not in this map, so a module can never
// claim one — `team_forum_post` fails the prefix rule before it gets here.
const seenReportTargets = new Set()
for (const t of newReportTargets) {
const held = reportTargets.get(t.id)
if (held) throw new Error(`report target "${t.id}" is already registered by "${held.owner}"`)
if (seenReportTargets.has(t.id)) throw new Error(`report target "${t.id}" registered twice`)
if (!namespaced(owner, t.id, {})) {
throw new Error(`report target "${t.id}" is not namespaced "${owner}."`)
}
seenReportTargets.add(t.id)
}
// Game identities are per owner, so the only collision is a module naming the
// same kind twice.
const seenKinds = new Set()
for (const g of newGameIdentities) {
if (seenKinds.has(g.kind) || gameIdentities.has(`${owner}:${g.kind}`)) {
throw new Error(`game identity "${g.kind}" registered twice by "${owner}"`)
}
seenKinds.add(g.kind)
}
const seenLegs = new Set()
for (const l of newLegs) {
const held = legs.get(l.leg)
@@ -2078,6 +2196,8 @@ function apply({
for (const s of newEventOptionSources) eventOptionSources.set(s.id, { owner, ...s })
for (const t of newEventTemplates) eventTemplates.set(t.id, { owner, ...t })
for (const seeds of newSeeds) engagementSeeds.set(owner, seeds)
for (const t of newReportTargets) reportTargets.set(t.id, { owner, ...t })
for (const g of newGameIdentities) gameIdentities.set(`${owner}:${g.kind}`, { owner, ...g })
}
// ── Core's own registrations ───────────────────────────────────────────────
@@ -2174,6 +2294,8 @@ function _reset() {
eventOptionSources.clear()
eventTemplates.clear()
engagementSeeds.clear()
reportTargets.clear()
gameIdentities.clear()
coreRegistered = false
}
@@ -2193,6 +2315,9 @@ module.exports = {
dispatchPostHook,
registeredTeamProvider,
hasTeamProvider,
reportTarget,
allReportTargets,
gameIdentity,
slashCommandDefinitions,
slashCommand,
allTriggers,

View File

@@ -9,6 +9,25 @@
// 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.21.0 — report, block and hide reach a module's content
// (docs/website/USER_SAFETY.md §4.1, S11-S14). Four additions, each optional:
// • `api.registerReportTargets([{ id, label, snapshot, hide, unhide }])` — a
// type of the module's content a member can report, hide for themself and
// block the author of, and that staff can hide for everyone from the
// Reports queue. `snapshot({ targetId, viewer })` answers what the item is
// FOR THAT VIEWER, or null when it is gone or they may not see it;
// • `api.registerGameIdentities([{ kind, label, ownersOf }])` — the game
// identity those authors are known by (Rust: a Steam id), and which website
// account each is linked to. A block lands on the account when there is one
// (S12), and a block on an unlinked id becomes an account block once it is
// linked (S14);
// • `ctx.safety.forViewer(userId)` — one reader's blocks and hides, so the
// module filters its own per-viewer feeds on the server (S11);
// • `ctx.safety.staffAmong(userIds)` — which accounts are staff, for marking
// the lines a reader cannot block (S8, S13).
// Additions only, so minor. Module-uo declares `^1.10.0` and registers none.
// Module-Rust's `rust.chat` is the first user.
//
// 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
@@ -267,6 +286,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.20.0'
const MODULE_API_VERSION = '1.21.0'
module.exports = { MODULE_API_VERSION }

View File

@@ -358,6 +358,34 @@ async function handleContentReport(req, res) {
}
}
/**
* Hide the reported item for everyone, and action the report (USER_SAFETY.md
* §4.1). The item goes through its own hide — the forum's moderation, or the
* module's — so this is a staff moderation of the content and is audited as one.
*/
async function hideReportedItem(req, res) {
try {
const result = await contentReports.hideForEveryone({
id: Number(req.params.id),
actor: req.user,
note: req.body.note,
})
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
const r = result.report
await activity.log({
req,
action: 'moderation.report.hide',
detail: `${req.user.username} (#${req.user.id}) hid ${r.targetType} #${r.targetId} for everyone `
+ `from report #${req.params.id}${req.body.note ? `: "${req.body.note}"` : ''}`,
})
return res.json(r)
} catch (err) {
log.error('hideReportedItem failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = {
getSummary,
getRecent,
@@ -376,4 +404,5 @@ module.exports = {
getUserAppeals,
getContentReports,
handleContentReport,
hideReportedItem,
}

View File

@@ -202,5 +202,22 @@ moderationRouter.post(
validate,
moderation.handleContentReport,
)
moderationRouter.post(
'/reports/:id/hide',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Hide the reported item for everyone, and action the report'
// #swagger.description = 'USER_SAFETY.md §4.1 (MODULE_API 1.21.0). The one queue action that changes the content. A forum thread or post is hidden through the forum’s own moderation (a staff row in its ledger); a game module’s item (e.g. a `rust.chat` line) through the module’s own hide. Staff still see the item where it lives, marked hidden by staff, and undo it there. The report becomes `actioned` with `actionedWith: "hide"`, and activity_log records it. An upload cannot be hidden from here: hide the post it is in.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Report id.' }
/* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: 'object', properties: { note: { type: 'string', maxLength: 500 } } } } } } */
/* #swagger.responses[200] = { description: 'The actioned report', content: { "application/json": { schema: { $ref: "#/components/schemas/ContentReport" } } } } */
/* #swagger.responses[400] = { description: 'The item cannot be hidden from here (an upload)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such report, or its item is gone', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'The game module that owns the item did not answer', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt({ min: 1 }),
body('note').optional({ values: 'falsy' }).isString().trim().isLength({ max: 500 }),
validate,
moderation.hideReportedItem,
)
module.exports = moderationRouter

View File

@@ -21,10 +21,11 @@ const express = require('express')
const { body, param, query } = require('express-validator')
const account = require('./account.controller')
const safetyCtrl = require('./safety.controller')
const { requireAuth } = require('../../../auth/session.middleware')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
const { accountChangeLimiter } = require('../../../middleware/rateLimit')
const { accountChangeLimiter, makeLimiter } = require('../../../middleware/rateLimit')
const { slowLogin, backoffGuard } = require('../../../middleware/loginProtection')
const meRouter = express.Router()
@@ -106,6 +107,124 @@ meRouter.delete(
account.deleteAccount,
)
// ── Block and "Hide for me" (USER_SAFETY.md §4.1) ─────────────────────────
//
// Saved on the account, so they apply on the website and in the app on every
// device (S2). A block names an ITEM, never a person: the server reads who wrote
// it, so a member can only block someone whose words they could see, and the
// block lands on the author's account whenever they have one (S12).
const safetyLimiter = makeLimiter({
windowMs: 10 * 60 * 1000,
max: 60,
label: 'user-safety',
message: 'Too many changes. Please slow down.',
})
meRouter.get(
'/blocks',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List the accounts and players this account has blocked'
// #swagger.description = 'Each block is an account block or, for a game identity no account has linked (a Steam id in a chat feed), a game block. A game block whose identity has since been linked is answered — and stored — as an account block (S14). A block on an account that is now staff does not apply, and says so (S8).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The blocks, newest first', content: { "application/json": { schema: { type: 'object', properties: { blocks: { type: 'array', items: { $ref: "#/components/schemas/UserBlock" } } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyCtrl.listBlocks,
)
meRouter.post(
'/blocks',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Block the author of an item'
// #swagger.description = 'Names the item (a forum thread or post, or a game module’s item such as a `rust.chat` line), never the person. The block lands on the author’s website account; a chat speaker with no linked account is blocked by their game identity. Everything the blocked author writes then disappears from this account’s view, on the website and in the app. The blocked person is not told (S9). Staff cannot be blocked (S8). An item this account cannot see answers 404.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyTarget" } } } } */
/* #swagger.responses[201] = { description: 'Blocked', content: { "application/json": { schema: { type: 'object', properties: { block: { $ref: "#/components/schemas/UserBlock" } } } } } } */
/* #swagger.responses[400] = { description: 'unknown_target, self or no_author', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'staff — a staff member cannot be blocked', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[404] = { description: 'No such item, or not one this account can see', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[409] = { description: 'already_blocked', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'module_unavailable — the game module did not answer', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
safetyLimiter,
body('targetType').isString().trim().isLength({ min: 1, max: 32 }),
body('targetId').isInt({ min: 1 }).toInt(),
validate,
safetyCtrl.createBlock,
)
meRouter.delete(
'/blocks/:id',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Undo a block'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'Unblocked', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not one of this account’s blocks', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
safetyLimiter,
param('id').isInt({ min: 1 }),
validate,
safetyCtrl.deleteBlock,
)
meRouter.get(
'/hidden',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'List the items this account hid for itself'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The hidden items, newest first', content: { "application/json": { schema: { type: 'object', properties: { hidden: { type: 'array', items: { type: 'object', properties: { targetType: { type: 'string' }, targetId: { type: 'integer' }, createdAt: { type: 'string', format: 'date-time' } } } } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyCtrl.listHidden,
)
meRouter.post(
'/hidden',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Hide one item for this account'
// #swagger.description = 'The item disappears from this account’s own view, on the website and in the app. No report is filed and nothing about the item changes. Hiding twice is still hidden.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyTarget" } } } } */
/* #swagger.responses[200] = { description: 'Hidden', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[400] = { description: 'unknown_target', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyLimiter,
body('targetType').isString().trim().isLength({ min: 1, max: 32 }),
body('targetId').isInt({ min: 1 }).toInt(),
validate,
safetyCtrl.createHidden,
)
meRouter.delete(
'/hidden',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Show every hidden item again'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Cleared', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, cleared: { type: 'integer' } } } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyLimiter,
safetyCtrl.clearHidden,
)
meRouter.delete(
'/hidden/:targetType/:targetId',
// #swagger.tags = ['Auth · Me']
// #swagger.summary = 'Show one hidden item again'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['targetType'] = { in: 'path', required: true, schema: { type: 'string' }, example: 'rust.chat' }
// #swagger.parameters['targetId'] = { in: 'path', required: true, schema: { type: 'integer' } }
/* #swagger.responses[200] = { description: 'Shown again', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
/* #swagger.responses[400] = { description: 'unknown_target', content: { "application/json": { schema: { $ref: "#/components/schemas/SafetyRefusal" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
safetyLimiter,
param('targetType').isString().isLength({ min: 1, max: 32 }),
param('targetId').isInt({ min: 1 }),
validate,
safetyCtrl.deleteHidden,
)
// 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

@@ -0,0 +1,85 @@
// /auth/me/blocks and /auth/me/hidden — a member's own blocks and hides
// (USER_SAFETY.md §4.1). Self-scoped on req.user.id; requireAuth is the group's.
const safety = require('../../../model/safety/userSafety.model')
const log = require('../../../utils/logger')('safety')
const fail = (res, err, what) => {
if (err && err.status === 503) {
return res.status(503).json({ code: 'module_unavailable', message: 'The game module did not answer. Try again in a moment.' })
}
log.error(`${what} failed`, { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
const refused = (res, result) => res.status(result.status || 400).json({ code: result.code, message: result.error })
async function listBlocks(req, res) {
try {
return res.json({ blocks: await safety.listBlocks(req.user) })
} catch (err) {
return fail(res, err, 'list blocks')
}
}
async function createBlock(req, res) {
try {
const result = await safety.blockAuthorOf({
actor: req.user,
targetType: req.body.targetType,
targetId: req.body.targetId,
})
if (!result.ok) return refused(res, result)
return res.status(201).json({ block: result.block })
} catch (err) {
return fail(res, err, 'create block')
}
}
async function deleteBlock(req, res) {
try {
const result = await safety.unblock(req.user, Number(req.params.id))
if (!result.ok) return refused(res, result)
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'delete block')
}
}
async function listHidden(req, res) {
try {
return res.json({ hidden: await safety.listHidden(req.user) })
} catch (err) {
return fail(res, err, 'list hidden')
}
}
async function createHidden(req, res) {
try {
const result = await safety.hide(req.user, req.body.targetType, req.body.targetId)
if (!result.ok) return refused(res, result)
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'hide item')
}
}
async function deleteHidden(req, res) {
try {
const result = await safety.unhide(req.user, req.params.targetType, Number(req.params.targetId))
if (!result.ok) return refused(res, result)
return res.json({ ok: true })
} catch (err) {
return fail(res, err, 'unhide item')
}
}
async function clearHidden(req, res) {
try {
return res.json({ ok: true, cleared: await safety.clearHidden(req.user) })
} catch (err) {
return fail(res, err, 'clear hidden')
}
}
module.exports = { listBlocks, createBlock, deleteBlock, listHidden, createHidden, deleteHidden, clearHidden }

View File

@@ -28,6 +28,7 @@ const appealsRouter = require('./appeals.router')
const teamsRouter = require('./teams.router')
const teamForumRouter = require('./teamForum.router')
const eventsRouter = require('./events.router')
const reportsRouter = require('./reports.router')
const playerRouter = express.Router()
@@ -43,6 +44,9 @@ playerRouter.use('/appeals', appealsRouter)
// This account's own event participation (Phase 14a). Self-scoped on
// req.user.id, like everything else in this group.
playerRouter.use('/events', eventsRouter)
// Report anything reportable (USER_SAFETY.md §4.1): forum items and a game
// module's items through one route.
playerRouter.use('/reports', reportsRouter)
playerRouter.use('/teams', teamsRouter)
// Same prefix, second router. The forum and the leader-exercised grant flow are a
// different capability from "the caller's own Teams", and splitting them keeps

View File

@@ -0,0 +1,71 @@
// /player/reports — file a report about any reportable item (USER_SAFETY.md §4.1).
//
// One route for every target: core's forum types and every module's registered
// types (`rust.chat`, …). The forum's own `/teams/:slug/forum/report` keeps
// working and goes through the same model.
//
// A signed-in account is required (S6), which the player group's gate gives:
// a queue anybody with `curl` could fill is a queue staff stop reading.
const express = require('express')
const { body } = require('express-validator')
const reports = require('../../../model/reports/contentReports.model')
const validate = require('../../../middleware/validate')
const { makeLimiter } = require('../../../middleware/rateLimit')
const log = require('../../../utils/logger')('reports')
const reportsRouter = express.Router()
// The forum report route's cap, for the forum report route's reason (TEAMS.md
// §5.6's third rule): a report costs the reporter nothing and costs a staffer
// attention.
const reportLimiter = makeLimiter({
windowMs: 60 * 60 * 1000,
max: 10,
label: 'content-report',
message: 'Too many reports. Please give staff a chance to look at the ones you have raised.',
})
async function createReport(req, res) {
try {
const result = await reports.file({
actor: req.user,
targetType: req.body.targetType,
targetId: req.body.targetId,
reason: req.body.reason,
detail: req.body.detail,
})
if (!result.ok) return res.status(result.status || 400).json({ message: result.error })
return res.json({ ok: true, reportId: result.reportId })
} catch (err) {
if (err.status === 503) return res.status(503).json({ message: 'The game module did not answer. Try again in a moment.' })
log.error('file report failed', { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
reportsRouter.post(
'/',
// #swagger.tags = ['Player · Reports']
// #swagger.summary = 'Report an item to site staff'
// #swagger.description = 'Files a report about a forum thread, post or upload, or a game module’s item (e.g. `rust.chat`, a Rust chat line; MODULE_API 1.21.0). The reporter must be able to see the item: one they cannot see answers 404, the same as one that does not exist. Filing changes nothing about the item; it opens a row in the staff Reports queue with a snapshot of what the item said, which outlives the item. Reports reach site staff and nobody else.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ReportRequest" } } } } */
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Raised', content: { "application/json": { schema: { type: 'object', properties: { ok: { type: 'boolean' }, reportId: { type: 'integer' } } } } } } */
/* #swagger.responses[400] = { description: 'Unknown target type or reason', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'No such item, or not one this account can see', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'You already have an open report on this', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[429] = { description: 'Too many reports (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[503] = { description: 'The game module that owns the item did not answer', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
reportLimiter,
body('targetType').isString().trim().isLength({ min: 1, max: 32 }),
body('targetId').isInt({ min: 1 }).toInt(),
body('reason').isIn(reports.REASONS),
body('detail').optional().isString().trim().isLength({ max: 500 }),
validate,
createReport,
)
module.exports = reportsRouter

View File

@@ -25,6 +25,7 @@ const forum = require('../../../model/teams/teamForum.model')
const forumSettings = require('../../../model/teams/teamForumSettings.model')
const uploads = require('../../../model/teams/teamForumUploads.model')
const reports = require('../../../model/reports/contentReports.model')
const safety = require('../../../model/safety/userSafety.model')
const activity = require('../../../model/activity/activity.model')
const teamNotify = require('../../../utils/teamNotify')
@@ -125,7 +126,11 @@ 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, viewer: { role: req.user.role } }),
threads: await forum.listThreads(ctx.team.id, {
canModerate: ctx.canModerate,
viewer: { role: req.user.role },
filter: await safety.forumFilter(req.user.id),
}),
// 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
@@ -150,6 +155,7 @@ async function getThread(req, res) {
const thread = await forum.getThread(ctx.team.id, Number(req.params.id), {
canModerate: ctx.canModerate,
viewer: await viewerFor(ctx, req.user),
filter: await safety.forumFilter(req.user.id),
})
if (!thread) return res.status(404).json({ message: 'Not found' })
return res.json({ ...thread, canModerate: ctx.canModerate })

View File

@@ -65,7 +65,7 @@ forumRouter.get(
'/:slug/forum/threads',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'List a Team forum’s threads'
// #swagger.description = 'Reachable by a member (path 1) OR a granted account (path 3) — a forum guest with no linked game identity reads exactly as a member does. Answers 404 while `teams_forums_enabled` is off, and 404 (never 403) to a caller with no access: in a private room, the contents and the existence are the same secret. Hidden threads are included for a leader or staff and for nobody else.'
// #swagger.description = 'Reachable by a member (path 1) OR a granted account (path 3) — a forum guest with no linked game identity reads exactly as a member does. Answers 404 while `teams_forums_enabled` is off, and 404 (never 403) to a caller with no access: in a private room, the contents and the existence are the same secret. Hidden threads are included for a leader or staff and for nobody else. Threads this caller hid for themself, or opened by an account they block, are left out (USER_SAFETY.md §4.1).'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'The thread list, with what this caller may do', content: { "application/json": { schema: { $ref: "#/components/schemas/TeamForumThreadList" } } } } */
@@ -96,7 +96,7 @@ forumRouter.get(
'/:slug/forum/threads/:id',
// #swagger.tags = ['Player · Teams']
// #swagger.summary = 'Read one thread and its posts'
// #swagger.description = 'Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated <img> beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration.'
// #swagger.description = 'Post bodies are rendered under the CURRENT image policy: `disabled` serves the stored HTML unchanged, `remote` and `uploads` add a core-generated <img> beneath each link that names an image. The stored HTML is identical in all three — flipping the policy back to disabled un-renders every image on every existing post with no data migration. Posts this caller hid, or written by an account they block, are left out, and a thread they hid or whose author they block answers 404 (USER_SAFETY.md §4.1). The thread and every post carry `canBlock`: false for the caller’s own, a deleted account’s and a staff member’s (S8).'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The Team slug.' }
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'The thread id.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]

File diff suppressed because it is too large Load Diff

View File

@@ -69,6 +69,7 @@ const doc = {
{ name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' },
{ name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' },
{ name: 'Player · Appeals', description: 'Player-submitted moderation appeals' },
{ name: 'Player · Reports', description: 'Reporting a forum or game item to site staff' },
{ name: 'Settings', description: 'Site-wide settings any authenticated account may read (nav overrides)' },
{ name: 'Admin · Dashboard', description: 'Dashboard summary and site mode' },
{ name: 'Admin · Posts', description: 'News / five-on-friday / newsletter / screenshots + uploads' },
@@ -514,6 +515,55 @@ const doc = {
message: { type: 'string', example: 'Your password is incorrect.' },
},
},
// Report, block and hide (USER_SAFETY.md §4.1, MODULE_API 1.21.0).
SafetyTarget: {
type: 'object',
required: ['targetType', 'targetId'],
description: 'One item: a forum thread or post, or a game module’s item. `team_forum_upload` can be reported but not hidden or blocked from — hiding the post it is in, or blocking its author, is what hides it.',
properties: {
targetType: { type: 'string', example: 'rust.chat', description: '`team_forum_thread`, `team_forum_post`, or a module’s registered type such as `rust.chat`.' },
targetId: { type: 'integer', example: 4410 },
},
},
ReportRequest: {
type: 'object',
required: ['targetType', 'targetId', 'reason'],
properties: {
targetType: { type: 'string', example: 'rust.chat', description: '`team_forum_thread`, `team_forum_post`, `team_forum_upload`, or a module’s registered type such as `rust.chat`.' },
targetId: { type: 'integer', example: 4410 },
reason: { type: 'string', enum: ['spam', 'abuse', 'sexual', 'illegal', 'impersonation', 'other'] },
detail: { type: 'string', maxLength: 500 },
},
},
UserBlock: {
type: 'object',
description: 'One of the signed-in member’s blocks. `account` blocks name a website account; `game` blocks name an unlinked game identity (a Steam id) and become account blocks once it is linked (S14). A block on a staff account does not apply (S8) and says so with `reason: "staff"`.',
properties: {
id: { type: 'integer' },
kind: { type: 'string', enum: ['account', 'game'] },
name: { type: 'string', example: 'rusty' },
identity: {
type: 'object',
nullable: true,
properties: {
module: { type: 'string', example: 'rust' },
kind: { type: 'string', example: 'steam' },
label: { type: 'string', example: 'Steam account' },
id: { type: 'string', example: '76561198000000123' },
},
},
applies: { type: 'boolean' },
reason: { type: 'string', nullable: true, enum: ['staff', null] },
createdAt: { type: 'string', format: 'date-time' },
},
},
SafetyRefusal: {
type: 'object',
properties: {
code: { type: 'string', enum: ['unknown_target', 'not_found', 'self', 'staff', 'no_author', 'already_blocked', 'module_unavailable'] },
message: { type: 'string', example: 'You can’t block a staff member.' },
},
},
ChangePasswordRequest: {
type: 'object',
required: ['newPassword'],
@@ -862,7 +912,7 @@ const doc = {
+ 'because a Team\'s leaders are exactly the people who will not report their own Team.',
properties: {
id: { type: 'integer', example: 41 },
targetType: { type: 'string', example: 'team_forum_post', description: 'team_forum_thread | team_forum_post | team_forum_upload' },
targetType: { type: 'string', example: 'team_forum_post', description: 'team_forum_thread | team_forum_post | team_forum_upload, or a game module\'s registered type such as `rust.chat` (MODULE_API 1.21.0).' },
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. Staff see it even after the account is deleted, with `reporterDeleted` true; anyone else would see "[deleted player]" (ACCOUNT_DELETION.md AD5, AD6).' },
@@ -874,6 +924,23 @@ const doc = {
handledByDeleted: { type: 'boolean', example: false },
handledNote: { type: 'string', nullable: true, example: 'Post hidden, author warned.' },
handledAt: { type: 'string', format: 'date-time', nullable: true },
actionedWith: { type: 'string', nullable: true, enum: ['hide', null], description: 'What the queue DID when it actioned the report: `hide` is "Hide for everyone" (USER_SAFETY.md §4.1). NULL when "actioned" only records a decision.' },
snapshot: {
type: 'object',
nullable: true,
description: 'What the item said when it was reported, kept on the report so it outlives the item and an author\'s later edit (USER_SAFETY.md §4.1). NULL on reports filed before MODULE_API 1.21.0. The author\'s name follows the same rule as `reporter`; `authorIdentity` is the game identity of an author with no account (an unlinked Rust chat speaker\'s Steam id).',
properties: {
label: { type: 'string', example: 'Chat line' },
title: { type: 'string', nullable: true },
text: { type: 'string', nullable: true, example: 'gg ez' },
at: { type: 'string', nullable: true },
where: { type: 'object', additionalProperties: true },
module: { type: 'string', nullable: true, example: 'rust' },
author: { type: 'string', nullable: true, example: 'Rusty' },
authorDeleted: { type: 'boolean' },
authorIdentity: { type: 'object', nullable: true, properties: { kind: { type: 'string', example: 'steam' }, id: { type: 'string', example: '76561198000000123' } } },
},
},
createdAt: { type: 'string', format: 'date-time' },
target: {
type: 'object',
@@ -883,7 +950,12 @@ const doc = {
+ '"somebody reported this and by the time we looked it was gone" is a fact a moderator needs. '
+ 'An upload target carries uploader, byte size and the SNIFFED mimetype (§5.6 rule 4).',
properties: {
kind: { type: 'string', enum: ['thread', 'post', 'upload'], example: 'post' },
kind: { type: 'string', enum: ['thread', 'post', 'upload', 'module'], example: 'post', description: '`module` is a game module\'s item, read from the stored snapshot: core cannot read the module\'s rows.' },
module: { type: 'string', nullable: true, example: 'rust' },
label: { type: 'string', nullable: true, example: 'Chat line' },
text: { type: 'string', nullable: true },
at: { type: 'string', nullable: true },
where: { type: 'object', nullable: true, additionalProperties: true },
threadId: { type: 'integer', nullable: true, example: 19 },
threadTitle: { type: 'string', nullable: true, example: 'Raid night' },
postId: { type: 'integer', nullable: true, example: 812 },

View File

@@ -15,6 +15,11 @@ const assert = require('node:assert/strict')
const reports = require('../src/model/reports/contentReports.model')
const reportsDb = require('../src/model/reports/contentReports.db')
const forumDb = require('../src/model/teams/teamForum.db')
const forum = require('../src/model/teams/teamForum.model')
const teamsDb = require('../src/model/teams/teams.db')
const access = require('../src/model/teams/teamAccess.model')
const forumSettings = require('../src/model/teams/teamForumSettings.model')
const registries = require('../src/modules/registries')
const saved = []
function patch(mod, name, fn) {
@@ -29,14 +34,24 @@ afterEach(() => {
})
const team = { id: 1, name: 'Ossuary' }
const reporter = { id: 11, username: 'wanderer' }
const reporter = { id: 11, username: 'wanderer', role: 'player' }
// The world a report is filed into: one thread, one post in it, one upload, all
// in team 1.
function stubTargets({ teamId = 1 } = {}) {
patch(forumDb, 'threadById', async (id) => (id === 5 ? { id: 5, team_id: teamId } : null))
patch(forumDb, 'postById', async (id) => (id === 80 ? { id: 80, thread_id: 5 } : null))
patch(forumDb, 'uploadById', async (id) => (id === 3 ? { id: 3, team_id: teamId } : null))
// in team 1, in a forum that is on and that the reporter can read.
function stubTargets({ teamId = 1, allowed = true } = {}) {
patch(forumDb, 'threadById', async (id) => (id === 5
? { id: 5, team_id: teamId, title: 'Raid night', status: 'visible', created_by: 12, created_username: 'someone', created_at: new Date() }
: null))
patch(forumDb, 'postById', async (id) => (id === 80
? { id: 80, thread_id: 5, status: 'visible', author_user_id: 12, author_username: 'someone', body_html: '<p>Rude words</p>', created_at: new Date() }
: null))
patch(forumDb, 'firstPostOf', async () => ({ id: 79, body_html: '<p>Opening words</p>' }))
patch(forumDb, 'uploadById', async (id) => (id === 3
? { id: 3, team_id: teamId, post_id: 80, uploader_user_id: 12, uploader_username: 'someone', filename: 'a1b2.png' }
: null))
patch(forumSettings, 'forumsEnabled', async () => true)
patch(teamsDb, 'findById', async (id) => ({ id, name: id === 1 ? 'Ossuary' : 'Elsewhere' }))
patch(access, 'forumAccess', async () => ({ allowed, viaMembership: allowed, viaGrant: false, isLeader: false }))
}
let written = []
@@ -119,6 +134,85 @@ test('an unknown target type or reason is refused before any lookup', async () =
})).status, 400)
})
// ── 1.21.0: one route for every target, and a snapshot (USER_SAFETY.md §4.1) ──
test('a report stores what the item said, so a later edit cannot change what staff read', async () => {
stubTargets()
stubInsert()
const result = await reports.file({ actor: reporter, targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
assert.equal(result.ok, true)
const [w] = written
assert.equal(w.teamId, 1, 'the Team comes from the item when no Team route was used')
assert.equal(w.snapshot.text, 'Rude words')
assert.equal(w.snapshot.label, 'Forum post')
assert.deepEqual(w.snapshot.author, { name: 'someone', userId: 12, identity: null })
})
test('a reporter who cannot see the item gets the same 404 as an item that is not there', async () => {
// Otherwise /player/reports would answer "does thread 5 exist" for a private
// Team's forum to anybody signed in.
stubTargets({ allowed: false })
stubInsert()
const result = await reports.file({ actor: reporter, targetType: 'team_forum_thread', targetId: 5, reason: 'abuse' })
assert.equal(result.status, 404)
assert.equal(written.length, 0)
})
test('a module target is reported through the module’s snapshot, for this viewer', async () => {
stubInsert()
const asked = []
patch(registries, 'reportTarget', (type) => (type === 'demo.chat'
? {
owner: 'demo',
id: 'demo.chat',
label: 'Chat line',
snapshot: async (q) => {
asked.push(q)
return q.targetId === 4410
? { author: { name: 'Rusty', userId: null, identity: { kind: 'steam', id: '765611980000001' } }, text: 'gg ez', at: '2026-10-30T10:00:00Z', where: { server: 'main' } }
: null
},
hide: async () => {},
unhide: async () => {},
}
: null))
const ok = await reports.file({ actor: reporter, targetType: 'demo.chat', targetId: 4410, reason: 'abuse' })
assert.equal(ok.ok, true)
assert.deepEqual(asked[0], { targetId: 4410, viewer: { userId: 11, role: 'player' } })
assert.equal(written[0].teamId, null)
assert.equal(written[0].snapshot.module, 'demo')
assert.equal(written[0].snapshot.text, 'gg ez')
assert.deepEqual(written[0].snapshot.author.identity, { kind: 'steam', id: '765611980000001' })
// The module said null: gone, or this viewer may not see it. One answer.
assert.equal((await reports.file({ actor: reporter, targetType: 'demo.chat', targetId: 1, reason: 'abuse' })).status, 404)
})
test('a module snapshot is clipped to known fields before it reaches the queue', async () => {
stubInsert()
patch(registries, 'reportTarget', () => ({
owner: 'demo',
id: 'demo.chat',
label: 'Chat line',
snapshot: async () => ({
author: { name: 'x'.repeat(200), userId: 'nope' },
text: 'y'.repeat(5000),
frame: { huge: 'z'.repeat(10000) },
where: { server: 'main', nested: { no: true } },
}),
hide: async () => {},
unhide: async () => {},
}))
await reports.file({ actor: reporter, targetType: 'demo.chat', targetId: 2, reason: 'spam' })
const snap = written[0].snapshot
assert.equal(snap.author.name.length, 64)
assert.equal(snap.author.userId, null)
assert.equal(snap.text.length, 500)
assert.equal(snap.frame, undefined)
assert.deepEqual(snap.where, { server: 'main' })
})
test('a second open report on the same target answers 409 rather than pretending', async () => {
stubTargets()
stubInsert({ duplicate: true })
@@ -308,6 +402,74 @@ test('handling records who decided, when, and why', async () => {
}]])
})
// ── Hide for everyone (USER_SAFETY.md §4.1) ────────────────────────────────
test('hide for everyone goes through the forum’s own moderation, as staff, and actions the report', async () => {
const moderated = []
const updates = []
patch(reportsDb, 'byId', async () => row({ target_type: 'team_forum_post', target_id: 80 }))
patch(reportsDb, 'handle', async (id, p) => { updates.push(p); return true })
patch(forumDb, 'postById', async () => ({ id: 80, thread_id: 5 }))
patch(forumDb, 'threadById', async () => ({ id: 5, team_id: 1 }))
patch(forum, 'moderatePost', async (args) => { moderated.push(args); return { ok: true } })
const staff = { id: 2, username: 'root', role: 'admin' }
const result = await reports.hideForEveryone({ id: 41, actor: staff, note: 'Slur.' })
assert.equal(result.ok, true)
assert.equal(moderated[0].action, 'hide')
assert.equal(moderated[0].actorRole, 'staff', 'a staff hide is a staff action in the forum’s ledger')
assert.equal(moderated[0].postId, 80)
assert.deepEqual(updates[0], { status: 'actioned', handledBy: 2, handledUsername: 'root', note: 'Slur.', actionedWith: 'hide' })
})
test('hide for everyone on a module item calls the module’s hide', async () => {
const hidden = []
patch(reportsDb, 'byId', async () => row({ target_type: 'demo.chat', target_id: 4410, team_id: null }))
patch(reportsDb, 'handle', async () => true)
patch(registries, 'reportTarget', () => ({
owner: 'demo', id: 'demo.chat', label: 'Chat line',
snapshot: async () => null,
hide: async (q) => { hidden.push(q) },
unhide: async () => {},
}))
const result = await reports.hideForEveryone({ id: 41, actor: { id: 2, username: 'root', role: 'admin' } })
assert.equal(result.ok, true)
assert.deepEqual(hidden, [{ targetId: 4410, actor: { id: 2, username: 'root', role: 'admin' } }])
})
test('an upload cannot be hidden from the queue, and nothing is recorded', async () => {
patch(reportsDb, 'byId', async () => row({ target_type: 'team_forum_upload', target_id: 3 }))
patch(reportsDb, 'handle', async () => { throw new Error('must not action') })
const result = await reports.hideForEveryone({ id: 41, actor: { id: 2, username: 'root' } })
assert.equal(result.status, 400)
assert.match(result.error, /post it is in/)
})
test('the queue shows a module target from its stored snapshot', async () => {
patch(registries, 'reportTarget', (type) => (type === 'demo.chat'
? { owner: 'demo', id: 'demo.chat', label: 'Chat line' }
: null))
patch(reportsDb, 'list', async () => [row({
target_type: 'demo.chat', target_id: 4410, team_id: null, actioned_with: 'hide',
snapshot: JSON.stringify({
label: 'Chat line', text: 'gg ez', at: 't', where: { server: 'main' }, module: 'demo',
author: { name: 'Rusty', userId: null, identity: { kind: 'steam', id: '765611980000001' } },
}),
})])
patch(reportsDb, 'threadsByIds', async () => [])
patch(reportsDb, 'postsByIds', async () => [])
patch(reportsDb, 'uploadsByIds', async () => [])
const [item] = await reports.queue({ viewer: { role: 'moderator' } })
assert.equal(item.target.kind, 'module')
assert.equal(item.target.text, 'gg ez')
assert.equal(item.actionedWith, 'hide')
// An unlinked chat speaker has no account, which is not a deleted account.
assert.equal(item.snapshot.author, 'Rusty')
assert.equal(item.snapshot.authorDeleted, false)
assert.deepEqual(item.snapshot.authorIdentity, { kind: 'steam', id: '765611980000001' })
})
test('an unknown status is refused, and an absent report is 404', async () => {
patch(reportsDb, 'byId', async () => null)
patch(reportsDb, 'handle', async () => { throw new Error('must not write') })
@@ -334,11 +496,14 @@ test('acceptance: nothing in the report model is reachable by a Team leader', ()
// If a leader-facing queue is ever wanted, it is a design decision for the org
// lead and not a refactor — which is what this test is here to make somebody
// notice.
// `hideForEveryone` (USER_SAFETY.md §4.1) is a staff verb like `handle`, and is
// mounted only under /admin/moderation beside it.
const surface = Object.keys(reports).filter((k) => typeof reports[k] === 'function')
assert.deepEqual(surface.sort(), ['file', 'handle', 'openCount', 'publicReport', 'queue', 'targetTeamId'])
assert.deepEqual(surface.sort(), ['file', 'handle', 'hideForEveryone', 'openCount', 'publicReport', 'queue', 'targetTeamId'])
// `handle` takes the actor and never a Team: there is no seat at this table for
// "the leader of the Team the report is about".
assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.handle.toString()))
assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.queue.toString()))
assert.ok(!/isLeader|leaderOf|forumAccess/.test(reports.hideForEveryone.toString()))
})

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.20.0')
assert.equal(MODULE_API_VERSION, '1.21.0')
})
test('a module registers actions, budgets, leases and option sources', () => {

View File

@@ -552,9 +552,11 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
// hooks on `api`, never by writing an identity itself.
// API 1.18.0 added `demoMode` (DEMO_MODE.md §3.4): a boolean, for the locks
// the module enforces in its own handlers.
// API 1.21.0 added `safety` (USER_SAFETY.md §4.1, S11): READ only, one
// reader's blocks and hides, so a module filters its own feeds on the server.
assert.deepEqual(probe.keys, [
'activity', 'auth', 'db', 'demoMode', 'events', 'express', 'identities', 'inbox', 'log', 'middleware', 'moduleId', 'paths',
'posts', 'push', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
'posts', 'push', 'safety', 'secretBox', 'settings', 'site', 'teams', 'uploads', 'users', 'validator',
])
// is core's limiter FACTORY, not a limiter: a module states its own
// window and cap and takes the plumbing, so there is one express-rate-limit in

View File

@@ -17,6 +17,7 @@ const grants = require('../src/model/teams/teamGrants.model')
const access = require('../src/model/teams/teamAccess.model')
const forum = require('../src/model/teams/teamForum.model')
const forumDb = require('../src/model/teams/teamForum.db')
const safetyDb = require('../src/model/safety/userSafety.db')
const uploads = require('../src/model/teams/teamForumUploads.model')
const { cleanForumBody, renderForumBody } = require('../src/utils/forumHtml')
@@ -599,6 +600,7 @@ test('every post comes back knowing whether THIS reader may edit it', async () =
post({ id: 80, author_user_id: 11, created_at: new Date() }),
post({ id: 81, author_user_id: 99, author_username: 'someone', created_at: new Date() }),
])
patch(safetyDb, 'staffAmong', async () => [])
const mine = await forum.getThread(1, 5, { viewer: { userId: 11, windowMinutes: 15 } })
assert.deepEqual(mine.posts.map((p) => p.canEdit), [true, false])
@@ -609,3 +611,40 @@ test('every post comes back knowing whether THIS reader may edit it', async () =
const anonymous = await forum.getThread(1, 5, {})
assert.deepEqual(anonymous.posts.map((p) => p.canEdit), [false, false])
})
// USER_SAFETY.md S8: the menu does not offer what the server would refuse.
test('every post says whether this reader may block its author: not yourself, not staff, not a deleted account', async () => {
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'postsByThread', async () => [
post({ id: 80, author_user_id: 11, created_at: new Date() }),
post({ id: 81, author_user_id: 99, created_at: new Date() }),
post({ id: 82, author_user_id: 2, created_at: new Date() }),
post({ id: 83, author_user_id: null, author_username: 'gone', created_at: new Date() }),
])
patch(safetyDb, 'staffAmong', async (ids) => ids.filter((id) => id === 2))
const thread = await forum.getThread(1, 5, { viewer: { userId: 11, windowMinutes: 15 } })
assert.deepEqual(thread.posts.map((p) => p.canBlock), [false, true, false, false])
patch(safetyDb, 'staffAmong', async () => { throw new Error('a signed-out read asks nothing') })
const anonymous = await forum.getThread(1, 5, {})
assert.deepEqual(anonymous.posts.map((p) => p.canBlock), [false, false, false, false])
})
test('a reader’s filter leaves out their hidden posts and their blocked authors’ posts', async () => {
patch(forumDb, 'threadById', async () => discussion())
patch(forumDb, 'postsByThread', async () => [
post({ id: 80, author_user_id: 3, created_at: new Date() }),
post({ id: 81, author_user_id: 99, created_at: new Date() }),
post({ id: 82, author_user_id: 4, created_at: new Date() }),
])
patch(safetyDb, 'staffAmong', async () => [])
const filter = { blockedUserIds: new Set([99]), hidden: { team_forum_thread: new Set(), team_forum_post: new Set([82]) } }
const thread = await forum.getThread(1, 5, { viewer: { userId: 11 }, filter })
assert.deepEqual(thread.posts.map((p) => p.id), [80])
// A thread its reader hid is gone from their view, a link to it included.
filter.hidden.team_forum_thread.add(5)
assert.equal(await forum.getThread(1, 5, { viewer: { userId: 11 }, filter }), null)
})

View File

@@ -29,6 +29,7 @@ const forum = require('../src/model/teams/teamForum.model')
const grants = require('../src/model/teams/teamGrants.model')
const access = require('../src/model/teams/teamAccess.model')
const reports = require('../src/model/reports/contentReports.model')
const safety = require('../src/model/safety/userSafety.model')
const db = require('../src/utils/db')
after(() => db.close())
@@ -394,6 +395,7 @@ test('with the forum ON, the same routes answer — the switch is the only diffe
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: false }))
patch(forum, 'listThreads', async () => [])
patch(safety, 'forumFilter', async () => null)
await withApp('/api/v1/player', playerRouter, async (app) => {
const res = await get(app, '/api/v1/player/teams/a/forum/threads')
@@ -415,6 +417,7 @@ test('a leader gets both composers; the announcement one is theirs alone', async
patch(teamsDbModule, 'findBySlug', async () => ({ id: 1, name: 'A' }))
patch(access, 'forumAccess', async () => ({ allowed: true, viaMembership: true, viaGrant: false, isLeader: true }))
patch(forum, 'listThreads', async () => [])
patch(safety, 'forumFilter', async () => null)
await withApp('/api/v1/player', playerRouter, async (app) => {
const body = await (await get(app, '/api/v1/player/teams/a/forum/threads')).json()

View File

@@ -0,0 +1,267 @@
// Block and "Hide for me" (docs/website/USER_SAFETY.md §1, §4.1).
//
// The rules under test are the org lead's, each named where it is checked:
// S8 (staff cannot be blocked), S12 (a block lands on the ACCOUNT whenever there
// is one), S14 (a game block becomes an account block once the identity is
// linked) and S11 (a module filters its own feeds with `ctx.safety.forViewer`).
const { test, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const safety = require('../src/model/safety/userSafety.model')
const safetyDb = require('../src/model/safety/userSafety.db')
const targets = require('../src/model/safety/targets')
const registries = require('../src/modules/registries')
const forum = require('../src/model/teams/teamForum.model')
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
afterEach(() => {
while (saved.length) {
const [mod, name, original] = saved.pop()
mod[name] = original
}
})
const alice = { id: 11, username: 'alice', role: 'player' }
// A pretend module: `demo.chat` lines, spoken by Steam ids, some linked.
function stubModule({ links = {}, ownersOf = null } = {}) {
const target = {
owner: 'demo', id: 'demo.chat', label: 'Chat line',
snapshot: async () => null, hide: async () => {}, unhide: async () => {},
}
const ident = {
owner: 'demo', kind: 'steam', label: 'Steam account',
ownersOf: ownersOf || (async (ids) => Object.fromEntries(ids.filter((id) => links[id]).map((id) => [id, links[id]]))),
}
patch(registries, 'reportTarget', (type) => (type === 'demo.chat' ? target : null))
patch(registries, 'allReportTargets', () => [target])
patch(registries, 'gameIdentity', (owner, kind) => (owner === 'demo' && kind === 'steam' ? ident : null))
}
// The item a block names, as the resolver would answer it.
const chatLine = (author) => ({ type: 'demo.chat', targetId: 4410, teamId: null, module: 'demo', author, snapshot: {} })
const forumPost = (author) => ({ type: 'team_forum_post', targetId: 80, teamId: 1, author, snapshot: {} })
function stubWrites() {
const writes = []
patch(safetyDb, 'insertAccountBlock', async (row) => { writes.push(['account', row]); return 7 })
patch(safetyDb, 'insertGameBlock', async (row) => { writes.push(['game', row]); return 8 })
return writes
}
// ── S12: the account first ─────────────────────────────────────────────────
test('blocking a forum author blocks their account', async () => {
patch(targets, 'resolve', async () => forumPost({ userId: 12, name: 'rusty', identity: null }))
patch(safetyDb, 'userById', async (id) => ({ id, username: 'rusty', role: 'player' }))
const writes = stubWrites()
const result = await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_post', targetId: 80 })
assert.equal(result.ok, true)
assert.equal(result.block.kind, 'account')
assert.deepEqual(writes, [['account', { blockerUserId: 11, blockedUserId: 12, displayName: 'rusty' }]])
})
test('blocking a chat speaker whose Steam id is linked blocks the ACCOUNT, not the Steam id (S12)', async () => {
stubModule({ links: { 7656: 12 } })
patch(targets, 'resolve', async () => chatLine({ userId: null, name: 'Rusty', identity: { kind: 'steam', id: '7656' } }))
patch(safetyDb, 'userById', async (id) => ({ id, username: 'rusty', role: 'player' }))
const writes = stubWrites()
const result = await safety.blockAuthorOf({ actor: alice, targetType: 'demo.chat', targetId: 4410 })
assert.equal(result.ok, true)
assert.equal(result.block.kind, 'account')
assert.equal(result.block.name, 'rusty', 'the website username, not the in-game name')
assert.equal(writes[0][0], 'account')
})
test('only an unlinked speaker is blocked by their game identity', async () => {
stubModule({ links: {} })
patch(targets, 'resolve', async () => chatLine({ userId: null, name: 'Rusty', identity: { kind: 'steam', id: '7656' } }))
const writes = stubWrites()
const result = await safety.blockAuthorOf({ actor: alice, targetType: 'demo.chat', targetId: 4410 })
assert.equal(result.ok, true)
assert.equal(result.block.kind, 'game')
assert.deepEqual(writes, [['game', { blockerUserId: 11, moduleId: 'demo', kind: 'steam', externalId: '7656', displayName: 'Rusty' }]])
})
// ── S8: never staff ────────────────────────────────────────────────────────
test('staff cannot be blocked, through a forum post or through a linked Steam id (S8)', async () => {
for (const role of ['admin', 'editor', 'moderator']) {
patch(targets, 'resolve', async () => forumPost({ userId: 2, name: 'mod', identity: null }))
patch(safetyDb, 'userById', async (id) => ({ id, username: 'mod', role }))
const writes = stubWrites()
// eslint-disable-next-line no-await-in-loop
const result = await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_post', targetId: 80 })
assert.equal(result.status, 403, role)
assert.equal(result.code, 'staff')
assert.equal(writes.length, 0)
}
stubModule({ links: { 7656: 2 } })
patch(targets, 'resolve', async () => chatLine({ userId: null, name: 'Mod', identity: { kind: 'steam', id: '7656' } }))
patch(safetyDb, 'userById', async (id) => ({ id, username: 'mod', role: 'moderator' }))
stubWrites()
assert.equal((await safety.blockAuthorOf({ actor: alice, targetType: 'demo.chat', targetId: 4410 })).code, 'staff')
})
test('a block on someone who later became staff is kept but stops applying, and says why (S8)', async () => {
patch(safetyDb, 'blocksFor', async () => [
{ id: 1, blocked_user_id: 12, blocked_username: 'rusty', blocked_role: 'moderator', created_at: new Date() },
{ id: 2, blocked_user_id: 13, blocked_username: 'grim', blocked_role: 'player', created_at: new Date() },
])
const blocks = await safety.listBlocks(alice)
assert.deepEqual(blocks.map((b) => [b.name, b.applies, b.reason]), [['rusty', false, 'staff'], ['grim', true, null]])
patch(safetyDb, 'hiddenFor', async () => [])
const filter = await safety.forumFilter(alice.id)
assert.deepEqual([...filter.blockedUserIds], [13])
})
test('you cannot block yourself, or a deleted account', async () => {
patch(targets, 'resolve', async () => forumPost({ userId: 11, name: 'alice', identity: null }))
assert.equal((await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_post', targetId: 80 })).code, 'self')
patch(targets, 'resolve', async () => forumPost({ userId: null, name: 'gone', identity: null }))
assert.equal((await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_post', targetId: 80 })).code, 'no_author')
})
test('an item the member cannot see is a 404, and an upload is not blockable from', async () => {
patch(targets, 'resolve', async () => null)
assert.equal((await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_post', targetId: 80 })).status, 404)
assert.equal((await safety.blockAuthorOf({ actor: alice, targetType: 'team_forum_upload', targetId: 3 })).code, 'unknown_target')
})
// ── S14: a game block becomes an account block ─────────────────────────────
test('a game block whose Steam id has since been linked is rewritten as an account block (S14)', async () => {
stubModule({ links: { 7656: 12 } })
let rows = [{ id: 5, blocked_user_id: null, module_id: 'demo', identity_kind: 'steam', external_id: '7656', display_name: 'Rusty', created_at: new Date() }]
patch(safetyDb, 'blocksFor', async () => rows)
const converted = []
patch(safetyDb, 'convertToAccountBlock', async (id, userId) => {
converted.push([id, userId])
rows = [{ id: 5, blocked_user_id: userId, blocked_username: 'rusty', blocked_role: 'player', created_at: new Date() }]
return 'converted'
})
const blocks = await safety.listBlocks(alice)
assert.deepEqual(converted, [[5, 12]])
assert.equal(blocks[0].kind, 'account')
assert.equal(blocks[0].name, 'rusty')
// From then on it hides the account's forum posts too.
patch(safetyDb, 'hiddenFor', async () => [])
assert.deepEqual([...(await safety.forumFilter(alice.id)).blockedUserIds], [12])
})
test('a module that does not answer leaves game blocks as they are, still applying', async () => {
stubModule({ ownersOf: async () => { throw new Error('down') } })
patch(safetyDb, 'blocksFor', async () => [
{ id: 5, blocked_user_id: null, module_id: 'demo', identity_kind: 'steam', external_id: '7656', display_name: 'Rusty', created_at: new Date() },
])
patch(safetyDb, 'convertToAccountBlock', async () => { throw new Error('must not convert') })
const [block] = await safety.listBlocks(alice)
assert.equal(block.kind, 'game')
assert.equal(block.applies, true)
})
// ── S11: what a module's feed leaves out ───────────────────────────────────
test('forViewer gives a module the accounts, its own game blocks and its own hides', async () => {
stubModule({ links: {} })
patch(safetyDb, 'blocksFor', async () => [
{ id: 1, blocked_user_id: 12, blocked_role: 'player' },
{ id: 2, blocked_user_id: 2, blocked_role: 'admin' },
{ id: 3, blocked_user_id: null, module_id: 'demo', identity_kind: 'steam', external_id: '7656' },
{ id: 4, blocked_user_id: null, module_id: 'other', identity_kind: 'steam', external_id: '9999' },
])
patch(safetyDb, 'hiddenFor', async (userId, types) => {
assert.deepEqual(types, ['demo.chat'], 'only this module’s target types are read')
return [{ target_type: 'demo.chat', target_id: 4410 }]
})
const view = await safety.forModuleViewer('demo', alice.id)
assert.deepEqual(view, {
blockedUserIds: [12],
blockedIdentities: { steam: ['7656'] },
hidden: { 'demo.chat': [4410] },
})
})
test('a signed-out reader has no blocks and no hides', async () => {
patch(safetyDb, 'blocksFor', async () => { throw new Error('must not read') })
assert.deepEqual(await safety.forModuleViewer('demo', null), { blockedUserIds: [], blockedIdentities: {}, hidden: {} })
assert.equal((await safety.forumFilter(null)).blockedUserIds.size, 0)
})
test('staffAmong takes only positive integer ids', async () => {
let asked
patch(safetyDb, 'staffAmong', async (ids) => { asked = ids; return [] })
await safety.staffAmong([3, '4', 3, -1, 'x', null, 2.5])
assert.deepEqual(asked, [3, 4])
})
// ── hide for me, and the forum's read ──────────────────────────────────────
test('hide for me names a type a member can hide, and never looks the item up', async () => {
const inserted = []
patch(safetyDb, 'insertHidden', async (...args) => { inserted.push(args) })
patch(targets, 'resolve', async () => { throw new Error('must not look up') })
assert.equal((await safety.hide(alice, 'team_forum_post', 80)).ok, true)
assert.equal((await safety.hide(alice, 'wiki_page', 1)).code, 'unknown_target')
assert.deepEqual(inserted, [[11, 'team_forum_post', 80]])
})
test('the forum leaves out a reader’s hidden items and blocked authors, and nothing else', () => {
const filter = {
blockedUserIds: new Set([12]),
hidden: { team_forum_thread: new Set([5]), team_forum_post: new Set([81]) },
}
assert.equal(forum.leftOutThread({ id: 5, created_by: 3 }, filter), true, 'hidden')
assert.equal(forum.leftOutThread({ id: 6, created_by: 12 }, filter), true, 'blocked author')
assert.equal(forum.leftOutThread({ id: 7, created_by: 3 }, filter), false)
assert.equal(forum.leftOutThread({ id: 8, created_by: null }, filter), false, 'a deleted author is not a blocked one')
assert.equal(forum.leftOutPost({ id: 81, author_user_id: 3 }, filter), true)
assert.equal(forum.leftOutPost({ id: 82, author_user_id: 12 }, filter), true)
assert.equal(forum.leftOutPost({ id: 83, author_user_id: 3 }, filter), false)
assert.equal(forum.leftOutPost({ id: 81, author_user_id: 3 }, null), false, 'no filter, nothing left out')
})
// ── the registry ───────────────────────────────────────────────────────────
test('a module registers report targets and game identities, namespaced and shape-checked', () => {
registries._reset()
try {
const api = registries.stage('demo')
api.registerReportTargets([{ id: 'demo.chat', label: 'Chat line', snapshot() {}, hide() {}, unhide() {} }])
api.registerGameIdentities([{ kind: 'steam', label: 'Steam account', ownersOf() {} }])
registries.apply(api.staged)
assert.equal(registries.reportTarget('demo.chat').owner, 'demo')
assert.equal(registries.gameIdentity('demo', 'steam').label, 'Steam account')
assert.equal(registries.gameIdentity('other', 'steam'), null, 'each module answers only for its own links')
const other = registries.stage('other')
other.registerReportTargets([{ id: 'demo.post', label: 'Post', snapshot() {}, hide() {}, unhide() {} }])
assert.throws(() => registries.apply(other.staged), /not namespaced "other\."/)
const core = registries.stage('sneaky')
assert.throws(
() => core.registerReportTargets([{ id: 'team_forum_post', label: 'x', snapshot() {}, hide() {}, unhide() {} }]),
/bad id/,
'a module can never claim one of core’s forum types',
)
assert.throws(
() => registries.stage('demo2').registerReportTargets([{ id: 'demo2.chat', label: 'x', snapshot() {} }]),
/hide\(\) is missing/,
)
} finally {
registries._reset()
}
})