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

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

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

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

View File

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