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
350 lines
15 KiB
JavaScript
350 lines
15 KiB
JavaScript
// ── 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)
|
||
})
|
||
})
|