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

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