feat(safety): report, block and hide chat lines (MODULE_API 1.21.0)
Some checks failed
PR Checks / client-build (pull_request) Successful in 51s
PR Checks / frozen-manifest (pull_request) Successful in 53s
PR Checks / server-tests (pull_request) Failing after -17s

USER_SAFETY.md step 3, the module half of report, block and hide.

- server/safety.js registers the rust.chat report target (snapshot for one
  reader, null when they could not have seen the line; staff's hide and
  unhide) and the steam game identity (ownersOf from rust_account_links).
- The events feed filters chat per reader, in the query: lines from a
  blocked account's Steam ids, the reader's unlinked Steam-ID blocks and the
  lines they hid. Signed-in readers get canBlock on each chat line (false for
  their own and for staff-linked players). Lines staff hid for everyone are
  left out for everyone but staff, who get them with hidden: true.
- rust_events gains hidden_at and hidden_by (ALTER ... IF NOT EXISTS).
- POST /admin/rust/chat/:id/hide and /unhide (admin, moderator), logged.
- The feed's chat lines get a menu: Report, Hide for me, Block player,
  and for staff Hide for everyone / Show to everyone again.
- coreApi ^1.21.0; ci/core-ref.json pinned to the website#233 merge;
  routes.manifest.json and swagger-fragment.json 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 09:04:08 -05:00
parent 1fb685fa0f
commit dd9b8b2711
24 changed files with 1182 additions and 25 deletions

View File

@@ -91,6 +91,12 @@ function fakeCtx(overrides = {}) {
},
},
activity: { log: spy(Promise.resolve()) },
// Report, block and hide (1.21.0). A reader with no blocks and no hides, and
// nobody staff; a suite that needs either installs its own.
safety: {
forViewer: spy(Promise.resolve({ blockedUserIds: [], blockedIdentities: {}, hidden: {} })),
staffAmong: spy(Promise.resolve([])),
},
middleware: {
requireAuth: (req, res, next) => next(),
requireRole: () => (req, res, next) => next(),
@@ -175,6 +181,9 @@ function fakeApi() {
onDemoSeed(fn) { once('onDemoSeed'); record.hooks.onDemoSeed = fn },
// MODULE_API 1.20.0 (account deletion, ACCOUNT_DELETION.md §4.4).
onUserDeleting(fn) { once('onUserDeleting'); record.hooks.onUserDeleting = fn },
// MODULE_API 1.21.0 (report, block and hide, USER_SAFETY.md §4.2).
registerReportTargets(targets) { once('registerReportTargets'); record.reportTargets = targets },
registerGameIdentities(identities) { once('registerGameIdentities'); record.gameIdentities = identities },
}
api.record = record
return api

349
server/test/safety.test.js Normal file
View File

@@ -0,0 +1,349 @@
// ── Report, block and hide, for chat lines (USER_SAFETY.md §4.2) ───────────
//
// What this module promises core and the reader:
//
// • a chat line is answered only to a reader who could have seen it — a line
// on a server whose presence is hidden from them, or one staff hid when they
// are not staff, is the same `null` as a line that is gone;
// • a speaker is known by their linked account first, by Steam id only when
// there is none (S12);
// • the feed leaves out what this reader blocked or hid, IN the query, and
// marks each line `canBlock` for a signed-in reader (S11, S13).
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, spy } = require('./_fakes')
function withCore(overrides = {}) {
require('../core')._reset()
const ctx = fakeCtx(overrides)
require('../core').init(ctx)
return ctx
}
/** Swap named exports on a required file for the length of `fn`. */
async function stubbing(mod, stubs, fn) {
const saved = {}
for (const [name, value] of Object.entries(stubs)) {
saved[name] = mod[name]
mod[name] = value
}
try {
return await fn()
} finally {
Object.assign(mod, saved)
}
}
const LINE = {
id: 4410,
serverId: 'main',
wipeId: 'w1',
t: Date.UTC(2026, 9, 30, 10, 0, 0),
steamId: '76561198000000001',
raw: JSON.stringify({ name: 'Rusty', message: 'gg ez', channel: 'Global' }),
hiddenAt: null,
}
const SEEN = { visible: true, level: 'signed_in', required: 'signed_in', userId: 7 }
function deps() {
return {
eventsDb: require('../model/events/events.db'),
linksDb: require('../model/links/links.db'),
serversDb: require('../model/servers/servers.db'),
visibility: require('../model/visibility/visibility.model'),
safety: require('../safety'),
}
}
// ── registration ──────────────────────────────────────────────────────────
test('rust.chat and the steam identity are registered, in core’s shape', () => {
require('../core')._reset()
const { fakeApi } = require('./_fakes')
const api = fakeApi()
require('../index')(fakeCtx(), api)
const [target] = api.record.reportTargets
assert.strictEqual(api.record.reportTargets.length, 1)
assert.strictEqual(target.id, 'rust.chat')
// Core's rule: namespaced `<moduleId>.`, at most 32 characters, a label of 40.
assert.match(target.id, /^rust\.[a-z0-9._-]+$/)
assert.ok(target.id.length <= 32 && target.label.length <= 40)
for (const name of ['snapshot', 'hide', 'unhide']) assert.strictEqual(typeof target[name], 'function')
const [identity] = api.record.gameIdentities
assert.strictEqual(api.record.gameIdentities.length, 1)
assert.strictEqual(identity.kind, 'steam')
assert.strictEqual(typeof identity.ownersOf, 'function')
})
// ── snapshot ──────────────────────────────────────────────────────────────
test('a line is described for a reader who could see it, with the linked account', async () => {
withCore()
const { eventsDb, linksDb, serversDb, visibility, safety } = deps()
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
stubbing(visibility, { canSeePresenceAs: async () => SEEN }, () =>
stubbing(linksDb, { userIdsForSteamIds: async () => [{ steamId: LINE.steamId, userId: 12 }] }, () =>
stubbing(serversDb, { getServer: async () => ({ name: 'Main' }) }, async () => {
const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'player' } })
assert.deepStrictEqual(snap, {
author: { name: 'Rusty', userId: 12, identity: { kind: 'steam', id: LINE.steamId } },
text: 'gg ez',
at: '2026-10-30T10:00:00.000Z',
where: { server: 'main', serverName: 'Main', channel: 'Global', wipe: 'w1' },
})
}))))
})
test('an unlinked speaker has no account, only their Steam id', async () => {
withCore()
const { eventsDb, linksDb, serversDb, visibility, safety } = deps()
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
stubbing(visibility, { canSeePresenceAs: async () => SEEN }, () =>
stubbing(linksDb, { userIdsForSteamIds: async () => [] }, () =>
stubbing(serversDb, { getServer: async () => null }, async () => {
const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'player' } })
assert.strictEqual(snap.author.userId, null)
assert.deepStrictEqual(snap.author.identity, { kind: 'steam', id: LINE.steamId })
}))))
})
test('a line the reader could not have seen is the same null as a missing one', async () => {
withCore()
const { eventsDb, visibility, safety } = deps()
const viewer = { userId: 7, role: 'player' }
await stubbing(eventsDb, { chatLineById: async () => null }, async () => {
assert.strictEqual(await safety.snapshot({ targetId: 1, viewer }), null)
})
// Presence hidden from this reader on the line's server.
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
stubbing(visibility, { canSeePresenceAs: async () => ({ ...SEEN, visible: false }) }, async () => {
assert.strictEqual(await safety.snapshot({ targetId: 4410, viewer }), null)
}))
// Hidden by staff, and the reader is not staff (S10).
const hidden = { ...LINE, hiddenAt: new Date() }
await stubbing(eventsDb, { chatLineById: async () => hidden }, () =>
stubbing(visibility, { canSeePresenceAs: async () => SEEN }, async () => {
assert.strictEqual(await safety.snapshot({ targetId: 4410, viewer }), null)
}))
})
test('staff still get a line staff hid, so the report can be read', async () => {
withCore()
const { eventsDb, linksDb, serversDb, visibility, safety } = deps()
const hidden = { ...LINE, hiddenAt: new Date() }
await stubbing(eventsDb, { chatLineById: async () => hidden }, () =>
stubbing(visibility, { canSeePresenceAs: async () => ({ ...SEEN, level: 'staff' }) }, () =>
stubbing(linksDb, { userIdsForSteamIds: async () => [] }, () =>
stubbing(serversDb, { getServer: async () => null }, async () => {
const snap = await safety.snapshot({ targetId: 4410, viewer: { userId: 1, role: 'moderator' } })
assert.strictEqual(snap.text, 'gg ez')
}))))
})
test('the presence check is made for the account core names, not the session role', async () => {
withCore()
const { eventsDb, visibility, safety } = deps()
const asked = []
await stubbing(eventsDb, { chatLineById: async () => LINE }, () =>
stubbing(visibility, { canSeePresenceAs: async (userId, serverId) => { asked.push([userId, serverId]); return { ...SEEN, visible: false } } }, async () => {
await safety.snapshot({ targetId: 4410, viewer: { userId: 7, role: 'admin' } })
}))
assert.deepStrictEqual(asked, [[7, 'main']])
})
// ── hide for everyone ─────────────────────────────────────────────────────
test('hide sets the flag with who did it; a pruned line is left alone', async () => {
withCore()
const { eventsDb, safety } = deps()
const set = spy(Promise.resolve())
await stubbing(eventsDb, { chatLineById: async () => LINE, setChatHidden: set }, async () => {
await safety.hide({ targetId: 4410, actor: { id: 3, username: 'moderator', role: 'moderator' } })
await safety.unhide({ targetId: 4410, actor: { id: 3 } })
})
assert.deepStrictEqual(set.calls, [[4410, true, 3], [4410, false]])
const none = spy(Promise.resolve())
await stubbing(eventsDb, { chatLineById: async () => null, setChatHidden: none }, async () => {
await safety.hide({ targetId: 9, actor: { id: 3 } })
})
assert.deepStrictEqual(none.calls, [])
})
function fakeRes() {
const res = { statusCode: 200, body: null }
res.status = (code) => { res.statusCode = code; return res }
res.json = (body) => { res.body = body; return res }
res.end = () => res
return res
}
test('the line’s own menu hides and unhides through admin routes, logged', async () => {
const ctx = withCore()
const { eventsDb } = deps()
const admin = require('../router/admin/rust.controller')
const set = spy(Promise.resolve())
await stubbing(eventsDb, { chatLineById: async () => LINE, setChatHidden: set }, async () => {
const res = fakeRes()
await admin.hideChat({ params: { id: '4410' }, user: { id: 3 } }, res)
assert.strictEqual(res.statusCode, 204)
await admin.unhideChat({ params: { id: '4410' }, user: { id: 3 } }, fakeRes())
})
assert.deepStrictEqual(set.calls, [[4410, true, 3], [4410, false, 3]])
assert.deepStrictEqual(ctx.activity.log.calls.map(([e]) => e.action), ['rust.chat.hide', 'rust.chat.unhide'])
await stubbing(eventsDb, { chatLineById: async () => null }, async () => {
const res = fakeRes()
await admin.hideChat({ params: { id: '9' }, user: { id: 3 } }, res)
assert.strictEqual(res.statusCode, 404)
})
})
// ── the steam identity ────────────────────────────────────────────────────
test('ownersOf answers linked ids only, and asks nothing for an empty list', async () => {
withCore()
const { linksDb, safety } = deps()
const asked = spy(async () => [{ steamId: '1', userId: 5 }])
await stubbing(linksDb, { userIdsForSteamIds: asked }, async () => {
assert.deepStrictEqual(await safety.ownersOf(['1', '2', '1', null]), { 1: 5 })
assert.deepStrictEqual(await safety.ownersOf([]), {})
})
assert.deepStrictEqual(asked.calls, [[['1', '2']]])
})
// ── the feed ──────────────────────────────────────────────────────────────
test('a signed-out reader is filtered by nothing but staff’s hides', async () => {
const ctx = withCore()
const { safety } = deps()
assert.deepStrictEqual(await safety.feedFilter({ userId: null, staff: false }), { showHidden: false, steamIds: [], ids: [] })
assert.deepStrictEqual(ctx.safety.forViewer.calls, [])
})
test('a reader’s blocks become Steam ids: an account’s linked ids, and unlinked Steam blocks', async () => {
withCore({
safety: {
forViewer: async () => ({
blockedUserIds: [12],
blockedIdentities: { steam: ['900'] },
hidden: { 'rust.chat': [4410, 4411] },
}),
staffAmong: async () => [],
},
})
const { linksDb, safety } = deps()
const asked = spy(async () => ['100', '101'])
await stubbing(linksDb, { steamIdsForUsers: asked }, async () => {
assert.deepStrictEqual(await safety.feedFilter({ userId: 7, staff: true }), {
showHidden: true,
steamIds: ['100', '101', '900'],
ids: [4410, 4411],
})
})
assert.deepStrictEqual(asked.calls, [[[12]]])
})
test('a feed whose blocks cannot be read is shown unfiltered, not failed', async () => {
withCore({ safety: { forViewer: async () => { throw new Error('db down') }, staffAmong: async () => [] } })
const { safety } = deps()
assert.deepStrictEqual(await safety.feedFilter({ userId: 7, staff: false }), { showHidden: false, steamIds: [], ids: [] })
})
test('canBlock: not your own lines, not a staff-linked player, not a line with no Steam id', async () => {
withCore({ safety: { forViewer: async () => ({}), staffAmong: async (ids) => ids.filter((id) => id === 2) } })
const { linksDb, safety } = deps()
const rows = [
{ id: 1, kind: 'player.chat', steamId: 'mine' },
{ id: 2, kind: 'player.chat', steamId: 'staff' },
{ id: 3, kind: 'player.chat', steamId: 'linked' },
{ id: 4, kind: 'player.chat', steamId: 'unlinked' },
{ id: 5, kind: 'player.chat', steamId: null },
{ id: 6, kind: 'player.death', steamId: 'linked' },
]
await stubbing(linksDb, {
userIdsForSteamIds: async () => [
{ steamId: 'mine', userId: 7 },
{ steamId: 'staff', userId: 2 },
{ steamId: 'linked', userId: 12 },
],
}, async () => {
const out = await safety.annotate(rows, { userId: 7 })
assert.deepStrictEqual(out.map((r) => r.canBlock), [false, false, true, true, false, undefined])
// Signed out: untouched.
assert.strictEqual(await safety.annotate(rows, { userId: null }), rows)
})
})
test('when who is staff cannot be read, no line offers Block', async () => {
withCore({ safety: { forViewer: async () => ({}), staffAmong: async () => { throw new Error('db down') } } })
const { linksDb, safety } = deps()
await stubbing(linksDb, { userIdsForSteamIds: async () => [{ steamId: 'a', userId: 12 }] }, async () => {
const out = await safety.annotate([{ id: 1, kind: 'player.chat', steamId: 'a' }, { id: 2, kind: 'player.chat', steamId: 'b' }], { userId: 7 })
assert.deepStrictEqual(out.map((r) => r.canBlock), [false, false])
})
})
// ── the query ─────────────────────────────────────────────────────────────
test('the feed’s query leaves hidden, blocked and hidden-for-me chat out, and only chat', async () => {
const ctx = withCore()
const db = require('../model/events/events.db')
await db.recentEvents({ serverId: 'main', kinds: ['player.chat', 'player.death'], limit: 50 })
let [sql, params] = ctx.db.query.calls.at(-1)
assert.match(sql, /NOT \(kind = 'player\.chat' AND hidden_at IS NOT NULL\)/)
assert.deepStrictEqual(params, ['main', 'player.chat', 'player.death', 50])
await db.recentEvents({
serverId: 'main',
kinds: ['player.chat'],
limit: 50,
chat: { showHidden: true, steamIds: ['100', '900'], ids: [4410] },
});
[sql, params] = ctx.db.query.calls.at(-1)
assert.doesNotMatch(sql, /hidden_at IS NOT NULL/, 'staff get hidden lines')
assert.match(sql, /NOT \(kind = 'player\.chat' AND steam_id IN \(\?, \?\)\)/)
assert.match(sql, /NOT \(kind = 'player\.chat' AND id IN \(\?\)\)/)
assert.deepStrictEqual(params, ['main', 'player.chat', '100', '900', 4410, 50])
})
test('the model passes the reader’s chat filter through, narrow by default, and marks staff’s hidden lines', async () => {
withCore()
const db = require('../model/events/events.db')
const model = require('../model/events/events.model')
let asked = null
await stubbing(db, {
recentEvents: async (args) => {
asked = args
return [{ id: 1, kind: 'player.chat', t: 1, steamId: 'a', raw: '{}', hiddenAt: new Date() }]
},
}, async () => {
await model.recent({ serverId: 'main', presence: true })
assert.deepStrictEqual(asked.chat, { showHidden: false, steamIds: [], ids: [] })
const rows = await model.recent({ serverId: 'main', presence: true, chat: { showHidden: true, steamIds: ['x'], ids: [2] } })
assert.deepStrictEqual(asked.chat, { showHidden: true, steamIds: ['x'], ids: [2] })
assert.strictEqual(rows[0].hidden, true)
})
})