feat(safety): report, block and hide, MODULE_API 1.21.0
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:
306
server/src/model/safety/userSafety.model.js
Normal file
306
server/src/model/safety/userSafety.model.js
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user