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

@@ -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()))
})