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

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