7 Commits
v0.6.2 ... main

Author SHA1 Message Date
70bafdff63 Merge pull request 'release: account deletion's unlink and report/block/hide on chat lines (v0.7.0)' (#73) from edge into main
All checks were successful
Release / release (push) Successful in 52s
Reviewed-on: #73
2026-10-30 21:45:57 +00:00
0c29dbf6eb Merge pull request 'feat(safety): report, block and hide chat lines (MODULE_API 1.21.0)' (#72) from feature/user-safety into edge
Some checks failed
PR Checks / client-build (pull_request) Successful in 49s
PR Checks / frozen-manifest (pull_request) Successful in 1m17s
PR Checks / server-tests (pull_request) Failing after 58s
Reviewed-on: #72
2026-10-30 14:20:39 +00:00
dd9b8b2711 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
2026-10-30 09:04:08 -05:00
1fb685fa0f Merge pull request 'chore: remove a stray empty file committed by #70' (#71) from fix/stray-file into edge
Reviewed-on: #71
2026-10-30 09:30:40 +00:00
bd13f197c5 chore: remove a stray empty file committed by #70
All checks were successful
PR Checks / client-build (pull_request) Successful in 51s
PR Checks / frozen-manifest (pull_request) Successful in 1m19s
PR Checks / server-tests (pull_request) Successful in 35s
`Promise.reject(dup)` is a zero-byte file a tool hook wrote into the working
tree; #70 committed it by accident. Nothing reads it. Found in the account
deletion walk (docs/website/ACCOUNT_DELETION.md step 6, W1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-30 03:56:42 -05:00
e30c7d591d Merge pull request 'feat(links): unlink every Steam id before an account is deleted (MODULE_API 1.20.0)' (#70) from feature/account-deletion-hook into edge
Reviewed-on: #70
2026-10-30 04:48:54 +00:00
b5573d9ae3 feat(links): unlink every Steam id before an account is deleted (MODULE_API 1.20.0)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 1m1s
PR Checks / frozen-manifest (pull_request) Successful in 1m28s
PR Checks / client-build (pull_request) Successful in 43s
ACCOUNT_DELETION.md §4.4 (AD10). api.onUserDeleting calls links.unlinkForDeletion,
which runs the player's own unlinkOwned per link, logs rust.account.unlink with
reason 'account deleted', and marks every server's permission sync dirty so the
withdrawn grants reach the game on the next tick. coreApi ^1.20.0; core-ref pinned
to website#232's merge (7bd93b8); engagement manifest regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-29 20:38:30 -05:00
29 changed files with 1282 additions and 27 deletions

View File

@@ -61,6 +61,7 @@
"permSync.js",
"raidableBases.js",
"router",
"safety.js",
"sidecarClient.js",
"titleSync.js",
"worldEvents.js"

View File

@@ -1,6 +1,6 @@
{
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change. **2026-10-26, the public demo (DEMO_MODE.md §3.4, §3.5, DM18):** this module declares demoLocks, reads ctx.demoMode, registers onDemoSeed (MODULE_API 1.18.0) and gives its two console-command actions demoRefuses (1.19.0), so coreApi is ^1.19.0. It was pinned to the head of website feature/demo-step-refusal (website#224) while that PR was open — a 1.18.0 core never asks demoRefuses — and is back on `main` at the sha #224 merged as (the same tree, so routes.manifest.json does not change).",
"$comment": "The core this module is proved against. MODULE_API.md §5.3: the frozen-manifest job clones RunicGateway/website at this exact ref, drops this module in as modules/rust and runs CORE's own routeManifest.js — nothing else can answer whether the URLs the module claims are the URLs it actually serves, because a mount prefix is a string in server/index.js and a documented path is a string in a JSON file, and whether those name the same URL is a fact about a running core. It also answers the blind spot phase 1 had to check by hand: core mounts several routes at the TIER ROOT (/status, /version), which the loader's collision probe cannot see, so /rust being free is asserted here by a core rather than by a reading. Pinned rather than tracking a branch on purpose: core moves for reasons that have nothing to do with this module, and a bump is then a deliberate commit saying which core the module was last proved against, instead of an unexplained red X on someone else's PR. Bump it, regenerate routes.manifest.json, and commit both together. This module needs MODULE_API 1.10.0 (module.json's coreApi is ^1.10.0), which the Event System cutover put on `main` — so unlike Module-uo, which spent the Event System window pinned to `edge`, this repo starts pinned to `main` and should stay there unless it comes to depend on a contract member that has not shipped yet. **2026-09-27, protocol 13 step 2:** this module calls ctx.events.expired (MODULE_API 1.11.0, PLAN_FIXES D183), so coreApi is ^1.11.0. It was pinned to website#209's branch head while that PR was open — a 1.10.0 core refuses to load it — and is back on `main` at the sha #209 merged as. **2026-10-12, protocol 14 (PLAN_REDESIGNS §2):** this module registers an event template and declares param durations, a lease value example and option notes (MODULE_API 1.13.0), so coreApi is ^1.13.0. It was pinned to website#212's branch head while that PR was open, and is back on `main` at the sha #212 merged as (the same tree). **2026-10-12, PLAN_REDESIGNS §12 (permission rewards):** this module declares a param with multiple: true (MODULE_API 1.14.0), so coreApi is ^1.14.0. It was pinned to website#213's branch head while that PR was open, and is back on `main` at the sha #213 merged as (the same tree). **2026-10-15, PLAN_REDESIGNS §14.3.4 (Dangerous Treasures):** the Start a Dangerous Treasures event step answers DT's warm-up with retryAfterMs (MODULE_API 1.15.0, D371), so coreApi is ^1.15.0, pinned to `main` at the sha website#215 merged as. **2026-10-19, Steam sign-in (PLAN_REDESIGNS §15, D392/D396):** this module registers onIdentityLinking and onIdentityLinked (MODULE_API 1.17.0), so coreApi is ^1.17.0. Pinned to the head of website feat/steam-signin while that PR is open — a 1.16.0 core refuses to load it — and is now on `edge` at the sha website#218 merged as. That is `edge`, not `main`, because #218 went into `edge` for the in-progress cutover. **2026-10-22, the cutover (PLAN_LAST_FIXES stage 6):** website#220 put `edge` on `main`, so this is back on `main` at the sha #220 merged as. That tree adds the last fixes' core half (website#219, no MODULE_API bump), whose only route changes are core's own event routes, so routes.manifest.json does not change. **2026-10-26, the public demo (DEMO_MODE.md §3.4, §3.5, DM18):** this module declares demoLocks, reads ctx.demoMode, registers onDemoSeed (MODULE_API 1.18.0) and gives its two console-command actions demoRefuses (1.19.0), so coreApi is ^1.19.0. It was pinned to the head of website feature/demo-step-refusal (website#224) while that PR was open — a 1.18.0 core never asks demoRefuses — and is back on `main` at the sha #224 merged as (the same tree, so routes.manifest.json does not change). **2026-10-30, account deletion (ACCOUNT_DELETION.md §4.4):** this module registers onUserDeleting (MODULE_API 1.20.0), so coreApi is ^1.20.0, pinned to `main` at the sha website#232 merged as. **2026-10-30, report, block and hide (USER_SAFETY.md §4.2):** this module registers a rust.chat report target and the steam game identity, and reads ctx.safety (MODULE_API 1.21.0), so coreApi is ^1.21.0, pinned to `main` at the sha website#233 merged as.",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "69998ec54854d46681def92985a2b299ee3582a6",
"refName": "website main @ website#224 merge, MODULE_API 1.19.0"
"ref": "f13b8fe1fec9dc330a985430b8bd4f67c9ec26c5",
"refName": "website main @ website#233 merge, MODULE_API 1.21.0"
}

View File

@@ -350,6 +350,21 @@ export const adminUserBackpacks = {
req(`/admin/users/${encodeURIComponent(userId)}/rust/backpacks${query({ serverId, steamId })}`),
}
// ── report, block and hide (USER_SAFETY.md §4.2) ───────────────────────────
// Core's routes, not this module's: the Reports queue, a member's blocks and
// their "Hide for me" list are core's and hold for every module and the forum.
// Each names an ITEM — `{ targetType: 'rust.chat', targetId }` — never a person;
// core asks `server/safety.js` who said it. The last two are this module's own:
// staff's hide for everyone, set and cleared where the line lives.
export const safety = {
report: ({ targetType, targetId, reason, detail }) =>
req('/player/reports', { method: 'POST', body: { targetType, targetId, reason, detail } }),
block: ({ targetType, targetId }) => req('/auth/me/blocks', { method: 'POST', body: { targetType, targetId } }),
hide: ({ targetType, targetId }) => req('/auth/me/hidden', { method: 'POST', body: { targetType, targetId } }),
hideForEveryone: (lineId) => req(`/admin/rust/chat/${encodeURIComponent(lineId)}/hide`, { method: 'POST' }),
unhideForEveryone: (lineId) => req(`/admin/rust/chat/${encodeURIComponent(lineId)}/unhide`, { method: 'POST' }),
}
// ── core's public event calendar ───────────────────────────────────────────
// The one CORE route this module reads, and core published `runId` on it for
// exactly this: a map marker holds a run id and nothing else, and the calendar is
@@ -382,6 +397,7 @@ export default {
adminUserPermissions,
adminUserEconomics,
adminUserBackpacks,
safety,
coreEvents,
BASE,
}

View File

@@ -0,0 +1,198 @@
// ── A chat line's menu: report, hide, block (USER_SAFETY.md §4.2) ──────────
//
// One small button at the end of each chat line in the feed, opening a row of
// actions under it. What it offers is `lib/safety.js`'s `chatActions`; what it
// does is core's (report, block, Hide for me) or this module's admin route
// (staff's hide for everyone). After anything that changes what the reader
// sees, the feed is asked again: the server is what filters it (S11), so the
// honest way to show a block is to re-read the feed, never to drop the line here.
//
// The wording follows core's forum menu, so a member reads one set of sentences
// wherever they report or block from.
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../core.js'
import api from '../api.js'
import { CHAT_TARGET, REPORT_REASONS, chatActions } from '../lib/safety.js'
export default function ChatMenu({ line, onChanged }) {
const { user } = useAuth()
const actions = chatActions(line, user)
const [mode, setMode] = useState('closed') // closed | open | report | block | done
const [reason, setReason] = useState('abuse')
const [detail, setDetail] = useState('')
const [message, setMessage] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
if (!actions) return null
// Every move between the menu's states drops the last error: a refusal from
// one action ("you have already reported this") is not about the next.
const go = (next) => { setError(null); setMode(next) }
const target = { targetType: CHAT_TARGET, targetId: line.id }
const name = (line.frame && line.frame.name) || 'this player'
const run = async (call, { refetch = true, done = null } = {}) => {
setBusy(true)
setError(null)
try {
await call()
if (done) {
setMessage(done)
setMode('done')
} else {
setMode('closed')
}
if (refetch) await onChanged()
} catch (err) {
setError(err.message || 'Could not do that')
} finally {
setBusy(false)
}
}
const toggle = (
<button
type="button"
onClick={() => { setError(null); setMode(mode === 'closed' ? 'open' : 'closed') }}
aria-label={`Actions for ${name}’s line`}
aria-expanded={mode !== 'closed'}
title="Report, hide or block"
style={toggleStyle}
>
⋯
</button>
)
if (mode === 'closed') return toggle
return (
<>
{toggle}
<div className="sans" style={panelStyle}>
{mode === 'done' && <p style={noteStyle}>{message}</p>}
{mode === 'open' && actions.signIn && (
<p style={noteStyle}>
<Link to="/account/login" state={{ from: { pathname: window.location.pathname } }}>Sign in</Link>
{' '}to report or block.
</p>
)}
{mode === 'open' && !actions.signIn && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{actions.report && (
<button type="button" className="pill" onClick={() => go('report')}>Report</button>
)}
{actions.hide && (
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.hide(target))}>
Hide for me
</button>
)}
{actions.block && (
<button type="button" className="pill" onClick={() => go('block')}>Block player</button>
)}
{actions.staff === 'hide' && (
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.hideForEveryone(line.id))}>
Hide for everyone
</button>
)}
{actions.staff === 'unhide' && (
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.unhideForEveryone(line.id))}>
Show to everyone again
</button>
)}
</div>
)}
{mode === 'report' && (
<form
onSubmit={(e) => {
e.preventDefault()
run(() => api.safety.report({ ...target, reason, detail: detail || undefined }), {
refetch: false,
done: 'Reported to site staff.',
})
}}
style={{ display: 'grid', gap: 8 }}
>
{/* A report changes nothing about the line, and says so: a member who
expects it to vanish and watches it stay will report it again. */}
<p style={noteStyle}>
This goes to <strong>site staff</strong>. Reporting does not hide or change anything — it asks a
staffer to look.
</p>
<label style={{ fontSize: '0.85rem' }}>
Reason
{' '}
<select className="input" value={reason} onChange={(e) => setReason(e.target.value)}>
{REPORT_REASONS.map(([value, text]) => (
<option key={value} value={value}>{text}</option>
))}
</select>
</label>
<textarea
className="textarea"
value={detail}
onChange={(e) => setDetail(e.target.value)}
placeholder="Anything a staffer should know (optional)"
maxLength={500}
rows={3}
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Send report</button>
<button type="button" className="pill" onClick={() => go('open')}>Cancel</button>
</div>
</form>
)}
{mode === 'block' && (
<div style={{ display: 'grid', gap: 8 }}>
{/* S9: the blocked player is not told, and a member deciding whether
to block is usually deciding whether it will start something. */}
<p style={noteStyle}>
Block <strong>{name}</strong>? You won&rsquo;t see anything they write, here or in the app. They
aren&rsquo;t told. You can undo it from <em>Account → Blocked</em>.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => run(() => api.safety.block(target))}>
Block
</button>
<button type="button" className="pill" onClick={() => go('open')}>Cancel</button>
</div>
</div>
)}
{error && <p style={{ ...noteStyle, color: 'var(--danger, crimson)' }}>{error}</p>}
</div>
</>
)
}
const toggleStyle = {
flex: 'none',
marginLeft: 'auto',
background: 'transparent',
border: 0,
color: 'var(--dim)',
cursor: 'pointer',
fontSize: '1rem',
lineHeight: 1,
padding: '0 4px',
}
const panelStyle = {
flexBasis: '100%',
display: 'grid',
gap: 8,
marginTop: 6,
padding: 10,
border: '1px solid var(--line)',
borderRadius: 6,
fontSize: '0.85rem',
}
const noteStyle = { margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }

View File

@@ -13,6 +13,7 @@
import { ErrorState, Loading } from '../core.js'
import Empty from './Empty.jsx'
import ChatMenu from './ChatMenu.jsx'
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
import { ago, clock } from '../lib/format.js'
import usePolled from '../hooks/usePolled.js'
@@ -32,7 +33,7 @@ const TONE = {
export default function Feed({ serverId, wipeId, filter, onFilter }) {
const kinds = kindsFor(filter)
const { data, error, loading, at } = usePolled(
const { data, error, loading, at, refresh } = usePolled(
() => api.servers.events(serverId, { kinds, wipe: wipeId, limit: 100 }),
// The key is the QUESTION. Changing server, wipe or filter blanks the rows,
// because what is on screen is an answer to a different one; a poll tick
@@ -111,10 +112,14 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
key={event.id}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 12,
alignItems: 'baseline',
padding: '7px 0',
borderBottom: '1px solid var(--line-soft, var(--line))',
// Staff's hidden line (S10): only staff ever get one, dimmed
// and marked so the hide can be checked and undone here.
opacity: event.hidden ? 0.6 : 1,
}}
>
<time
@@ -137,7 +142,17 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
{line.detail}
</span>
)}
{event.hidden && (
<span className="sans" style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
{' · '}
Hidden by staff
</span>
)}
</span>
{/* Report, Hide for me, Block player, and staff's Hide for
everyone (USER_SAFETY.md §4.2). The server filtered the feed
for this reader, so after any of them the feed is re-read. */}
<ChatMenu line={event} onChanged={refresh} />
</li>
)
})}

56
client/src/lib/safety.js Normal file
View File

@@ -0,0 +1,56 @@
// ── Report, block and hide on a chat line: what the menu offers ────────────
//
// USER_SAFETY.md §4.2. Nothing here decides what a reader may SEE — the server
// already left out what they blocked or hid, and staff's hidden lines for
// everybody else (S11). This file only turns what the server said about a line
// into which buttons its menu shows, and it is pure so a DOM-less runner can ask
// all of it.
/** The report target a chat line is filed as. Registered by `server/safety.js`. */
export const CHAT_TARGET = 'rust.chat'
/**
* The reasons a report can give. Core's `content_reports.reason` enum, in the
* order and words core's own forum report uses, so a member reads one list
* wherever they report from.
*/
export const REPORT_REASONS = Object.freeze([
['abuse', 'Abusive or harassing'],
['spam', 'Spam'],
['sexual', 'Sexual content'],
['illegal', 'Illegal content'],
['impersonation', 'Impersonation'],
['other', 'Something else'],
])
/**
* The roles that hide a line for everyone: the two core's Reports queue is open
* to, and the two `POST /admin/rust/chat/:id/hide` admits. An editor writes
* content and moderates none.
*/
export const HIDE_ROLES = Object.freeze(['admin', 'moderator'])
/**
* What a chat line's menu offers this reader.
*
* signIn a signed-out reader gets one line telling them to sign in (S6)
* report any signed-in reader, on any line
* hide "Hide for me", likewise — a staff member's line included (S8)
* block only where the server said `canBlock` (S8, S13)
* staff 'hide' or 'unhide' for a reader who can hide for everyone (S10)
*
* @param {{ kind: string, canBlock?: boolean, hidden?: boolean }} line
* @param {{ role?: string } | null} user
*/
export function chatActions(line, user) {
if (!line || line.kind !== 'player.chat') return null
if (!user) return { signIn: true, report: false, hide: false, block: false, staff: null }
const canHideForAll = HIDE_ROLES.includes(user.role)
return {
signIn: false,
report: true,
hide: true,
block: line.canBlock === true,
staff: canHideForAll ? (line.hidden ? 'unhide' : 'hide') : null,
}
}

View File

@@ -0,0 +1,45 @@
// ── A chat line's menu (USER_SAFETY.md §4.2) ───────────────────────────────
//
// The server decides what a reader sees and whether a line's speaker can be
// blocked; this asks that the menu shows exactly what it said, and nothing a
// signed-out reader could use.
import test from 'node:test'
import assert from 'node:assert/strict'
import { CHAT_TARGET, HIDE_ROLES, REPORT_REASONS, chatActions } from '../src/lib/safety.js'
const chat = (extra = {}) => ({ id: 1, kind: 'player.chat', steamId: '7656', frame: { name: 'Rusty', message: 'gg' }, ...extra })
test('the target is the one the server registers', () => {
assert.equal(CHAT_TARGET, 'rust.chat')
})
test('the six reasons are core’s report enum', () => {
assert.deepEqual(REPORT_REASONS.map(([value]) => value).sort(), ['abuse', 'illegal', 'impersonation', 'other', 'sexual', 'spam'])
assert.ok(REPORT_REASONS.every(([, label]) => typeof label === 'string' && label.length > 0))
})
test('a signed-out reader is told to sign in, and offered nothing else (S6)', () => {
assert.deepEqual(chatActions(chat({ canBlock: true }), null), { signIn: true, report: false, hide: false, block: false, staff: null })
})
test('a member can report and hide any line, and block only where the server said so', () => {
const member = { role: 'player' }
assert.deepEqual(chatActions(chat({ canBlock: true }), member), { signIn: false, report: true, hide: true, block: true, staff: null })
assert.equal(chatActions(chat({ canBlock: false }), member).block, false)
// A line with no word from the server is not blockable.
assert.equal(chatActions(chat(), member).block, false)
})
test('admins and moderators can hide for everyone, and undo it; editors cannot', () => {
assert.deepEqual([...HIDE_ROLES].sort(), ['admin', 'moderator'])
assert.equal(chatActions(chat(), { role: 'moderator' }).staff, 'hide')
assert.equal(chatActions(chat({ hidden: true }), { role: 'admin' }).staff, 'unhide')
assert.equal(chatActions(chat(), { role: 'editor' }).staff, null)
})
test('only chat lines have a menu', () => {
assert.equal(chatActions({ id: 2, kind: 'player.death' }, { role: 'player' }), null)
assert.equal(chatActions(null, { role: 'player' }), null)
})

View File

@@ -1,6 +1,6 @@
{
"_comment": "Generated freeze of module-rust's engagement contract (docs/modules/rust/PLAN.md §25). Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.",
"coreApi": "^1.19.0",
"coreApi": "^1.20.0",
"triggers": [
{
"id": "rust.base.destroyed",

View File

@@ -2,7 +2,7 @@
"id": "rust",
"name": "Rust",
"version": "0.1.0",
"coreApi": "^1.19.0",
"coreApi": "^1.21.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",

View File

@@ -251,6 +251,16 @@
"path": "/api/v1/admin/rust/permissions/groups/:id",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/chat/:id/hide",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/chat/:id/unhide",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/config/:serverId/file",

View File

@@ -175,6 +175,18 @@ module.exports = {
pushActivity: (items) => need().teams.activity.push(items),
},
// Report, block and hide (MODULE_API.md §2.3, 1.21.0; USER_SAFETY.md S11, S13).
// Read only, both of them. `forViewer(userId)` is what one reader has blocked
// and hidden that touches this module — the accounts they block (already
// checked against staff, S8, and with linked Steam blocks already turned into
// account blocks, S14), this module's own unlinked Steam blocks, and their hides
// on `rust.chat`. `staffAmong(userIds)` is which accounts are staff, for a chat
// line's `canBlock`. Wrapped as calls, like `emit`. See `safety.js`.
safety: {
forViewer: (userId) => need().safety.forViewer(userId),
staffAmong: (userIds) => need().safety.staffAmong(userIds),
},
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
// only correct way to find a file you shipped, because the working directory is
// core's and the module's location is the loader's business.

View File

@@ -246,6 +246,18 @@ CREATE TABLE IF NOT EXISTS rust_events (
KEY idx_rust_events_created (created_at)
);
-- Staff's "Hide for everyone" on a chat line (USER_SAFETY.md §4.2, S10). Set from
-- core's Reports queue or from the line's own menu, and cleared from that menu.
-- A hidden line is left out of the feed for everybody but staff, who still get it,
-- marked, so a hide can be checked and undone where it lives. The row is kept, and
-- the retention sweep prunes it like any other.
--
-- `hidden_by` is the staff account's id, with no foreign key: the hide stays a
-- hide when the account that made it is deleted, and nothing reads it back as a
-- person. ALTER rather than columns above, so an existing install gets them too.
ALTER TABLE rust_events ADD COLUMN IF NOT EXISTS hidden_at DATETIME NULL;
ALTER TABLE rust_events ADD COLUMN IF NOT EXISTS hidden_by INT NULL;
-- ── Who is on right now ───────────────────────────────────────────────────
--

View File

@@ -168,6 +168,23 @@ module.exports = function register(ctx, api) {
api.onIdentityLinking(links.checkSteamSignin)
api.onIdentityLinked(links.linkFromSteamSignin)
// Account deletion (MODULE_API 1.20.0; ACCOUNT_DELETION.md §4.4, AD10). Core
// asks before the users row goes: every Steam id the account holds is unlinked
// through the player's own unlink, and the permission sync is marked dirty so
// the withdrawn grants reach the game on the next tick. It cannot stop the
// deletion, and never waits on a game.
api.onUserDeleting(links.unlinkForDeletion)
// Report, block and hide (MODULE_API 1.21.0; USER_SAFETY.md §4.2). A chat line
// is a `rust.chat` report target: core files the report and keeps the queue,
// and asks this module what a line is, for one reader, and to hide it for
// everyone. A speaker is known by their Steam id, and `steam` says which
// website account one is linked to, so a block lands on the account first
// (S12). The feed's own filtering and `canBlock` are in the events route.
const safety = require('./safety') // eslint-disable-line global-require
api.registerReportTargets(safety.REPORT_TARGETS)
api.registerGameIdentities(safety.GAME_IDENTITIES)
// The public demo's seed (MODULE_API 1.18.0, DM15): the rig's server row and
// demo-player's Steam link, written by this module from its part of the
// fixture. Called only by `npm run seed:demo` on a DEMO_MODE deployment. The
@@ -248,6 +265,7 @@ module.exports = function register(ctx, api) {
budgets: eventWorld.BUDGETS.length + eventRewards.BUDGETS.length + eventPermRewards.BUDGETS.length + eventWorldCall.BUDGETS.length + eventDangerousTreasures.BUDGETS.length + eventGuardedCrate.BUDGETS.length + eventCommands.BUDGETS.length + eventRaidableBases.BUDGETS.length,
announceLeg: eventRewards.LEG.leg,
commands: commands.map((cmd) => cmd.name).join(' '),
reportTargets: safety.REPORT_TARGETS.map((t) => t.id).join(' '),
optionSources: eventLeases.OPTION_SOURCES.length + eventWorld.OPTION_SOURCES.length + eventRewards.OPTION_SOURCES.length + eventPermRewards.OPTION_SOURCES.length + eventWorldCall.OPTION_SOURCES.length + eventDangerousTreasures.OPTION_SOURCES.length + eventGuardedCrate.OPTION_SOURCES.length + eventRaidableBases.OPTION_SOURCES.length,
})
}

View File

@@ -312,13 +312,14 @@ async function pruneEvents(days) {
* every time; `events.model.js` builds the list from the catalogue's allowlist
* and an empty list answers with no rows rather than with everything.
*/
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50, chat = {} }) {
if (!Array.isArray(kinds) || kinds.length === 0) return []
const holes = kinds.map(() => '?').join(', ')
const params = [serverId, ...kinds]
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw,
hidden_at AS hiddenAt
FROM ${EVENTS}
WHERE server_id = ? AND kind IN (${holes})`
@@ -327,12 +328,53 @@ async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
params.push(wipeId)
}
// What this reader does not get of the chat (USER_SAFETY.md §4.2), left out in
// the query rather than after it, so a page of 100 is still 100 lines long.
// Only `player.chat` rows: a block hides what someone SAYS, not that they died.
if (!chat.showHidden) sql += ` AND NOT (kind = 'player.chat' AND hidden_at IS NOT NULL)`
if (chat.steamIds && chat.steamIds.length) {
sql += ` AND NOT (kind = 'player.chat' AND steam_id IN (${chat.steamIds.map(() => '?').join(', ')}))`
params.push(...chat.steamIds)
}
if (chat.ids && chat.ids.length) {
sql += ` AND NOT (kind = 'player.chat' AND id IN (${chat.ids.map(() => '?').join(', ')}))`
params.push(...chat.ids)
}
sql += ' ORDER BY id DESC LIMIT ?'
params.push(limit)
return core.query(sql, params)
}
/** One chat line by id, or null: the report target `rust.chat` (USER_SAFETY.md §4.2). */
async function chatLineById(id) {
const rows = await core.query(
`SELECT id, server_id AS serverId, wipe_id AS wipeId, t, steam_id AS steamId, raw,
hidden_at AS hiddenAt
FROM ${EVENTS}
WHERE id = ? AND kind = 'player.chat'`,
[id],
)
return rows[0] || null
}
/**
* Staff's hide for everyone, or its undo. A line already hidden keeps its first
* hide's time and author: the second click of two changes nothing. Whether the
* line exists is the caller's to ask first — `affectedRows` on an UPDATE that
* changes nothing depends on a driver flag.
*/
async function setChatHidden(id, hidden, by = null) {
await core.query(
hidden
? `UPDATE ${EVENTS} SET hidden_at = COALESCE(hidden_at, CURRENT_TIMESTAMP), hidden_by = COALESCE(hidden_by, ?)
WHERE id = ? AND kind = 'player.chat'`
: `UPDATE ${EVENTS} SET hidden_at = NULL, hidden_by = NULL WHERE id = ? AND kind = 'player.chat'`,
hidden ? [by, id] : [id],
)
}
/**
* The leaderboard for one wipe, or across every wipe when `wipeId` is null.
*
@@ -473,6 +515,8 @@ module.exports = {
replacePresence,
pruneEvents,
recentEvents,
chatLineById,
setChatHidden,
leaderboard,
listWipes,
presenceFor,

View File

@@ -59,18 +59,27 @@ function parseKinds(raw) {
* refused: naming it in an error would confirm the kind exists, which is a small
* thing to leak and a free one to avoid.
*/
async function recent({ serverId, admin = false, staff = false, presence = false, kind = null, wipeId = null, limit }) {
async function recent({ serverId, admin = false, staff = false, presence = false, kind = null, wipeId = null, limit, chat = {} }) {
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
// Every requested kind was refused. Answering with an empty list is right —
// the events they asked for are, as far as they are concerned, not there.
if (kinds.length === 0) return []
// `chat` is this reader's part of the chat (`safety.js`, USER_SAFETY.md §4.2):
// the Steam ids whose lines they do not get, the lines they hid, and whether a
// line staff hid for everyone is still theirs to see. Its default is the
// narrow one — a hidden line is left out unless a caller says otherwise.
const rows = await db.recentEvents({
serverId,
kinds,
wipeId,
limit: boundedLimit(limit),
chat: {
showHidden: Boolean(chat.showHidden),
steamIds: Array.isArray(chat.steamIds) ? chat.steamIds : [],
ids: Array.isArray(chat.ids) ? chat.ids : [],
},
})
const shaped = rows.map(shape)
@@ -170,6 +179,9 @@ function shape(row) {
wipeId: row.wipeId || null,
steamId: row.steamId || null,
frame,
// Staff hid this line for everyone (S10). Only staff's reads ever hold one,
// and they get it marked so the hide can be checked and undone.
...(row.hiddenAt ? { hidden: true } : {}),
}
}

View File

@@ -166,6 +166,18 @@ async function userIdsForSteamIds(steamIds) {
)
}
/**
* The other direction: every Steam id these website accounts hold. A chat line
* is filed under a Steam id, so a block on an account (USER_SAFETY.md S12) hides
* the lines of every Steam id that account links.
*/
async function steamIdsForUsers(userIds) {
if (!userIds.length) return []
const marks = userIds.map(() => '?').join(', ')
const rows = await core.query(`SELECT steam_id AS steamId FROM ${LINKS} WHERE user_id IN (${marks})`, userIds)
return rows.map((r) => String(r.steamId))
}
/**
* The linked players of one server: every linked Steam account that has played
* there, in any wipe (PLAN_REDESIGNS.md §2.5, the kit weekend's `linked`).
@@ -217,5 +229,6 @@ module.exports = {
removeBySteamId,
statsForSteamId,
userIdsForSteamIds,
steamIdsForUsers,
linkedOnServer,
}

View File

@@ -19,6 +19,7 @@
const core = require('../../core')
const db = require('./links.db')
const engagement = require('../../engagement/emit')
const permissionsDb = require('../permissions/permissions.db')
const servers = require('../servers/servers.model')
const serversDb = require('../servers/servers.db')
const sidecar = require('../../sidecarClient')
@@ -351,6 +352,47 @@ async function linkFromSteamSignin({ userId, provider, subject, displayName }) {
engagement.linked({ userId, steamId, name: displayName || null })
}
// ── The website account is being deleted (MODULE_API 1.20.0) ──────────────
//
// ACCOUNT_DELETION.md §4.4. Core asks before the users row goes, while the
// person's links can still be read (AD10). The cascade on `rust_account_links`
// would remove the rows anyway; calling the real unlink keeps what a cascade
// skips — the `rust.account.unlink` entry staff read and the prompt Teams
// re-sync — so the game sees exactly what a player's own unlink produces.
/**
* Core's `api.onUserDeleting`: unlink every Steam id this account holds.
*
* The same `unlinkOwned` the player's Rust page calls, one link at a time, each
* logged as `rust.account.unlink` with `reason: 'account deleted'`. Then every
* server's permission sync is marked dirty: the desired set reaches a grant's
* Steam id through the link, so with the links gone the grants have already
* left it, and the next tick pushes the withdrawal instead of waiting for the
* digest check.
*
* Nothing here waits on a game. The link is the site's own record and the plugin
* keeps none (the header above), so there is never pending work: what this does
* not finish, the cascade does.
*/
async function unlinkForDeletion({ userId, deletedBy }) {
const held = await db.listForUser(userId)
let removed = 0
for (const row of held) {
// eslint-disable-next-line no-await-in-loop
if (await unlinkOwned(row.steamId, userId)) {
removed += 1
// eslint-disable-next-line no-await-in-loop
await core.activity.log({
userId,
action: 'rust.account.unlink',
detail: { steamId: row.steamId, reason: 'account deleted', deletedBy },
})
}
}
if (removed > 0) await permissionsDb.markDirty(null)
log.info('rust links removed before an account was deleted', { userId, deletedBy, removed })
}
/** The admin panel's read: every link this user holds, with per-server totals. */
async function forAdmin(userId) {
const links = await db.listForUserWithPlayer(userId)
@@ -393,5 +435,6 @@ module.exports = {
unlinkFromGame,
checkSteamSignin,
linkFromSteamSignin,
unlinkForDeletion,
forAdmin,
}

View File

@@ -103,17 +103,31 @@ async function viewerLevel(req) {
* decided the rung: a banned account is nobody, whatever its token names.
*/
async function viewer(req) {
const nobody = { level: 'public', userId: null }
try {
const claimed = req.user || core.auth.getUserFromRequest(req)
if (!claimed || claimed.id == null) return nobody
if (!claimed || claimed.id == null) return { level: 'public', userId: null }
return await viewerById(claimed.id)
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return { level: 'public', userId: null }
}
}
const user = await core.users.getById(claimed.id)
/**
* The same re-read, for a caller that has an account id and no request: core's
* report-target `snapshot`, which is handed `viewer.userId` (USER_SAFETY.md
* §4.2). The role core passes beside it is the session's; this reads the row.
*/
async function viewerById(userId) {
const nobody = { level: 'public', userId: null }
if (userId == null) return nobody
try {
const user = await core.users.getById(userId)
if (!user) return nobody
if (user.status && user.status !== 'active') return nobody
const level = user.role === 'admin' || user.role === 'moderator' ? 'staff' : 'signed_in'
return { level, userId: user.id != null ? user.id : claimed.id }
return { level, userId: user.id != null ? user.id : userId }
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return nobody
@@ -154,11 +168,22 @@ async function presenceFor(serverId) {
*/
async function canSeePresence(req, serverId) {
try {
const [level, required] = await Promise.all([viewerLevel(req), presenceFor(serverId)])
return { visible: meets(level, required), level, required }
const [who, required] = await Promise.all([viewer(req), presenceFor(serverId)])
return { visible: meets(who.level, required), level: who.level, required, userId: who.userId }
} catch (err) {
log.warn('could not resolve presence visibility; withholding it', { server: serverId, error: err.message })
return { visible: false, level: 'public', required: DEFAULT_PRESENCE }
return { visible: false, level: 'public', required: DEFAULT_PRESENCE, userId: null }
}
}
/** `canSeePresence` for an account id rather than a request (a report's snapshot). */
async function canSeePresenceAs(userId, serverId) {
try {
const [who, required] = await Promise.all([viewerById(userId), presenceFor(serverId)])
return { visible: meets(who.level, required), level: who.level, required, userId: who.userId }
} catch (err) {
log.warn('could not resolve presence visibility; withholding it', { server: serverId, error: err.message })
return { visible: false, level: 'public', required: DEFAULT_PRESENCE, userId: null }
}
}
@@ -314,9 +339,11 @@ module.exports = {
normalise,
viewerLevel,
viewer,
viewerById,
fleetPresence,
presenceFor,
canSeePresence,
canSeePresenceAs,
describe,
update,
}

View File

@@ -13,6 +13,7 @@
const core = require('../../core')
const db = require('../../model/servers/servers.db')
const eventsDb = require('../../model/events/events.db')
const mapImages = require('../../mapImages')
const nextWipe = require('../../model/servers/nextWipe')
const servers = require('../../model/servers/servers.model')
@@ -379,7 +380,40 @@ async function putVoice(req, res) {
}
}
/**
* Staff's hide for everyone on one chat line, and its undo, from the line's own
* menu in the feed (USER_SAFETY.md §1, S10). Core's Reports queue hides through
* `safety.js`'s registered `hide`; this is the same flag set where the line
* lives, and the only place it is cleared, since staff still see a hidden line
* there, marked.
*/
function chatHidden(hidden) {
return async function setChatHidden(req, res) {
const id = Number(req.params.id)
try {
const line = await eventsDb.chatLineById(id)
if (!line) return res.status(404).json({ message: 'No such chat line' })
await eventsDb.setChatHidden(id, hidden, req.user ? req.user.id : null)
await core.activity.log({
req,
action: hidden ? 'rust.chat.hide' : 'rust.chat.unhide',
detail: { line: id, server: line.serverId, steamId: line.steamId || null },
})
return res.status(204).end()
} catch (err) {
log.error('failed to change a chat line', { line: id, hidden, error: err.message })
return res.status(500).json({ message: hidden ? 'Failed to hide the line' : 'Failed to show the line again' })
}
}
}
const hideChat = chatHidden(true)
const unhideChat = chatHidden(false)
module.exports = {
hideChat,
unhideChat,
scheduleFrom,
listServers,
putServer,

View File

@@ -123,6 +123,39 @@ adminRustRouter.post(
admin.testServer,
)
// ── Hide a chat line for everyone (USER_SAFETY.md §1, S10) ────────────────
//
// From the line's own menu in the feed. `admin` and `moderator`, the two roles
// core's Reports queue is open to — an editor writes content and moderates none.
adminRustRouter.post(
'/chat/:id/hide',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Hide a chat line for everyone'
// #swagger.description = 'Staff’s hide for everyone (USER_SAFETY.md S10): the line leaves the public feed for everybody but staff, who still get it with `hidden: true`. The same flag core’s Reports queue sets through the `rust.chat` report target. Hiding a hidden line changes nothing. Recorded in the activity log. Admins and moderators.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The chat line’s event id', schema: { type: 'integer' } }
/* #swagger.responses[204] = { description: 'Hidden' } */
/* #swagger.responses[404] = { description: 'No such chat line' } */
requireRole('admin', 'moderator'),
param('id').isInt({ min: 1 }),
validate,
admin.hideChat,
)
adminRustRouter.post(
'/chat/:id/unhide',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Show a hidden chat line again'
// #swagger.description = 'Undoes a hide for everyone, whether it was made here or from the Reports queue. Recorded in the activity log. Admins and moderators.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The chat line’s event id', schema: { type: 'integer' } }
/* #swagger.responses[204] = { description: 'Shown again' } */
/* #swagger.responses[404] = { description: 'No such chat line' } */
requireRole('admin', 'moderator'),
param('id').isInt({ min: 1 }),
validate,
admin.unhideChat,
)
// ── The map's picture (phase 14) ──────────────────────────────────────────
adminRustRouter.post(

View File

@@ -15,6 +15,7 @@ const clans = require('../../model/clans/clans.model')
const dangerousTreasures = require('../../dangerousTreasures')
const guardedCrate = require('../../guardedCrate')
const raidableBases = require('../../raidableBases')
const safety = require('../../safety')
const economics = require('../../model/economics/economics.model')
const events = require('../../model/events/events.model')
const map = require('../../model/map/map.model')
@@ -90,20 +91,30 @@ async function getServer(req, res) {
*
* `staff` is the same re-read rung, and reaches only what a world event carries
* (protocol 14, D353): a Steam id and a position. It never widens the kinds.
*
* The chat is read for THIS reader too (USER_SAFETY.md S11): lines whose speaker
* they block or that they hid are left out, a line staff hid for everyone is
* left out unless they are staff, and a signed-in reader gets `canBlock` on each
* line (S13). That is why this route was already `perViewer` and must stay so.
*/
async function listEvents(req, res) {
try {
const presence = await visibility.canSeePresence(req, req.params.id)
const reader = { userId: presence.userId, staff: presence.level === 'staff' }
// Below the presence audience there is no chat to filter or mark.
const chat = presence.visible ? await safety.feedFilter(reader) : {}
const rows = await events.recent({
serverId: req.params.id,
presence: presence.visible,
staff: reader.staff,
kind: req.query.kind,
wipeId: req.query.wipe || null,
limit: req.query.limit,
chat,
})
perViewer(res)
res.json({
events: await events.recent({
serverId: req.params.id,
presence: presence.visible,
staff: presence.level === 'staff',
kind: req.query.kind,
wipeId: req.query.wipe || null,
limit: req.query.limit,
}),
events: presence.visible ? await safety.annotate(rows, reader) : rows,
presenceHidden: !presence.visible,
presenceAudience: presence.required,
})

View File

@@ -68,7 +68,7 @@ rustRouter.get(
'/servers/:id/events',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Recent events on one Rust server'
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped.'
// #swagger.description = 'The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped. Chat is read for each reader (USER_SAFETY.md §4.2): a signed-in reader does not get the lines of a player they blocked (by the player’s linked account, or by Steam id when there is none) or lines they hid for themselves, and each `player.chat` row carries `canBlock` — false for their own lines and for a player linked to a staff account. A line staff hid for everyone is left out for everybody but staff, who get it with `hidden: true`.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }

193
server/safety.js Normal file
View File

@@ -0,0 +1,193 @@
// ── Report, block and hide, for chat lines ─────────────────────────────────
//
// USER_SAFETY.md §4.2 (MODULE_API 1.21.0). Core owns the Reports queue, a
// member's blocks and their "Hide for me" list; this module owns the chat lines
// those name, and is the only one that can say what a line is, who said it, and
// whether a reader may see it. Four things live here:
//
// REPORT_TARGETS `rust.chat`: a chat line, as core's resolver asks for it —
// `snapshot` for one reader, and staff's hide for everyone
// GAME_IDENTITIES `steam`: which website account a Steam id is linked to,
// so a block on a linked speaker lands on their account (S12)
// and a Steam-id block becomes one once the id links (S14)
// feedFilter what one reader's events feed leaves out of the chat (S11)
// annotate `canBlock` on each chat line, for a signed-in reader (S13)
//
// ── The rules, each decided by the org lead ───────────────────────────────
//
// S6 Reporting, hiding and blocking all need an account. A signed-out reader
// gets the feed as it is, with no `canBlock`.
// S8 Staff cannot be blocked. Core refuses the block and drops a block whose
// target later became staff; here a line whose speaker is linked to staff
// says `canBlock: false`.
// S10 A line staff hid for everyone is still staff's to see, marked.
// S12 A speaker is checked by their linked ACCOUNT first, and by Steam id only
// when they have none.
//
// A block hides what someone SAYS: chat lines. Their deaths, connects and kills
// stay in the feed, because the feed is what happened on the server and a block
// is not a way to rewrite that.
const core = require('./core')
const eventsDb = require('./model/events/events.db')
const linksDb = require('./model/links/links.db')
const serversDb = require('./model/servers/servers.db')
const visibility = require('./model/visibility/visibility.model')
const log = core.logger('safety')
const CHAT = 'rust.chat'
const STEAM = 'steam'
function parseFrame(raw) {
try {
return typeof raw === 'string' ? JSON.parse(raw) : raw || {}
} catch {
return {}
}
}
const ids = (list) => [...new Set((list || []).filter((v) => v != null).map(String))]
/** Steam id → website account id, for the ids that are linked. */
async function ownersOf(steamIds) {
const wanted = ids(steamIds)
if (!wanted.length) return {}
const rows = await linksDb.userIdsForSteamIds(wanted)
return Object.fromEntries(rows.map((r) => [String(r.steamId), Number(r.userId)]))
}
/**
* One chat line, for one reader, or null.
*
* Null is the answer for a line that is gone AND for one this reader could not
* have seen — on a server whose presence is hidden from them, or hidden by staff
* when they are not staff — because core answers 404 to both, and a report or a
* block must never confirm who said line 4410 to somebody the feed never showed
* it to (MODULE_API.md §2.4).
*/
async function snapshot({ targetId, viewer }) {
const row = await eventsDb.chatLineById(targetId)
if (!row) return null
const presence = await visibility.canSeePresenceAs(viewer && viewer.userId, row.serverId)
if (!presence.visible) return null
if (row.hiddenAt && presence.level !== 'staff') return null
const frame = parseFrame(row.raw)
const steamId = row.steamId ? String(row.steamId) : null
const [owners, server] = await Promise.all([
steamId ? ownersOf([steamId]) : {},
serversDb.getServer(row.serverId).catch(() => null),
])
return {
author: {
name: frame.name || null,
userId: steamId && owners[steamId] ? owners[steamId] : null,
identity: steamId ? { kind: STEAM, id: steamId } : null,
},
text: typeof frame.message === 'string' ? frame.message : '',
at: Number.isFinite(Number(row.t)) ? new Date(Number(row.t)).toISOString() : null,
where: {
server: row.serverId,
serverName: server ? server.name : null,
channel: frame.channel || 'Global',
wipe: row.wipeId || null,
},
}
}
/**
* Staff's hide for everyone, from core's Reports queue (S3, S10). A line the
* sweep has already pruned has nothing left to hide, and that is not an error:
* the report is still actioned.
*/
async function hide({ targetId, actor }) {
if (!(await eventsDb.chatLineById(targetId))) return
await eventsDb.setChatHidden(targetId, true, actor && actor.id != null ? actor.id : null)
}
async function unhide({ targetId }) {
await eventsDb.setChatHidden(targetId, false)
}
const REPORT_TARGETS = Object.freeze([
Object.freeze({ id: CHAT, label: 'Chat line', snapshot, hide, unhide }),
])
const GAME_IDENTITIES = Object.freeze([
Object.freeze({ kind: STEAM, label: 'Steam account', ownersOf }),
])
/**
* What one reader's feed leaves out of the chat, as `events.recent` takes it:
* the Steam ids whose lines they do not get, the lines they hid, and whether
* staff's hidden lines are theirs to see.
*
* The Steam ids are S12 in one set: every id linked to an account they block,
* plus their Steam-id blocks — which core answers only for ids still unlinked,
* having already turned a linked one into an account block (S14) or dropped it
* when that account is staff (S8). So a line is left out by its speaker's
* account when there is one, and by its Steam id only when there is not.
*
* **A failure here shows the feed unfiltered rather than failing it.** The feed
* is the page the site promises to render while everything behind it is down;
* a member who sees a blocked line once, with a warning in the log, is the
* smaller harm than a blank killfeed for everyone signed in.
*/
async function feedFilter({ userId, staff }) {
const base = { showHidden: Boolean(staff), steamIds: [], ids: [] }
if (userId == null) return base
try {
const mine = await core.safety.forViewer(userId)
const blockedUsers = (mine.blockedUserIds || []).map(Number).filter((n) => Number.isInteger(n) && n > 0)
const linked = blockedUsers.length ? await linksDb.steamIdsForUsers(blockedUsers) : []
const unlinked = (mine.blockedIdentities && mine.blockedIdentities[STEAM]) || []
const hidden = (mine.hidden && mine.hidden[CHAT]) || []
return {
...base,
steamIds: ids([...linked, ...unlinked]),
ids: [...new Set(hidden.map(Number).filter((n) => Number.isInteger(n) && n > 0))],
}
} catch (err) {
log.warn('could not read the reader’s blocks; the feed is unfiltered for them', { error: err.message })
return base
}
}
/**
* `canBlock` on each chat line, for a signed-in reader (S13). False when the
* line has no Steam id, when the speaker's linked account is the reader's own,
* or when it is staff (S8). Signed-out readers get the rows untouched.
*
* A failure answers `canBlock: false` on every line: the menu then offers no
* Block, and core would refuse a block on staff anyway.
*/
async function annotate(events, { userId }) {
if (userId == null) return events
const chat = events.filter((e) => e.kind === 'player.chat')
if (!chat.length) return events
let owners = {}
let staff = new Set()
let ok = true
try {
owners = await ownersOf(chat.map((e) => e.steamId))
const linked = [...new Set(Object.values(owners))]
staff = new Set((linked.length ? await core.safety.staffAmong(linked) : []).map(Number))
} catch (err) {
log.warn('could not work out who can be blocked; offering no block', { error: err.message })
ok = false
}
return events.map((e) => {
if (e.kind !== 'player.chat') return e
const owner = e.steamId ? owners[String(e.steamId)] : undefined
const canBlock = ok && Boolean(e.steamId) && owner !== Number(userId) && !(owner != null && staff.has(owner))
return { ...e, canBlock }
})
}
module.exports = { CHAT, STEAM, REPORT_TARGETS, GAME_IDENTITIES, snapshot, hide, unhide, ownersOf, feedFilter, annotate }

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(),
@@ -173,6 +179,11 @@ function fakeApi() {
onIdentityLinked(fn) { once('onIdentityLinked'); record.hooks.onIdentityLinked = fn },
// MODULE_API 1.18.0 (the public demo's seed, DM15).
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

View File

@@ -454,7 +454,11 @@ test('module.json locks the two rig-link routes and the player Steam unlink, and
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/player/rust/links/:steamId' },
])
assert.equal(manifest.coreApi, '^1.19.0')
// A floor, not an exact value: later contract members raise it (1.20.0 for
// account deletion), and what this test holds is that it never drops below the
// core that asks demoRefuses.
const [major, minor] = manifest.coreApi.replace(/^\^/, '').split('.').map(Number)
assert.ok(major === 1 && minor >= 19, `coreApi ${manifest.coreApi} is at least ^1.19.0`)
})
// ── The staff Steam unlink (W4) ────────────────────────────────────────────

View File

@@ -92,6 +92,12 @@ test('registers both identity hooks, so a Steam sign-in link is a Rust link (D39
assert.strictEqual(api.record.hooks.onIdentityLinked, links.linkFromSteamSignin)
})
test('registers the account-deletion hook, so a deleted account is unlinked first (AD10)', () => {
const { api } = register()
const links = require('../model/links/links.model')
assert.strictEqual(api.record.hooks.onUserDeleting, links.unlinkForDeletion)
})
test('the manifest declares what the loader requires', () => {
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
assert.match(manifest.version, /^\d+\.\d+\.\d+/)

View File

@@ -472,6 +472,42 @@ test('the linked hook writes nothing for a link that is already there, or for an
assert.equal(ctx.teams.reconcile.calls.length, 0)
})
// ── The website account is being deleted (MODULE_API 1.20.0, AD10) ─────────
test('a deleted account loses every Steam link through the player’s own unlink, each logged', async () => {
const { ctx, queries } = withCore({ select: [[{ steamId: '7656', userId: 4 }, { steamId: STEAM, userId: 4 }]] })
const links = require('../model/links/links.model')
await links.unlinkForDeletion({ userId: 4, deletedBy: 'self' })
// The same statement `unlinkOwned` uses, scoped by user, once per link.
const dels = queries.filter((q) => q.sql.trim().toUpperCase().startsWith('DELETE'))
assert.deepEqual(dels.map((q) => q.params), [['7656', 4], [STEAM, 4]])
// What a cascade would skip: one entry per link, and the Teams re-sync.
assert.deepEqual(ctx.activity.log.calls.map((c) => c[0]), [
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: '7656', reason: 'account deleted', deletedBy: 'self' } },
{ userId: 4, action: 'rust.account.unlink', detail: { steamId: STEAM, reason: 'account deleted', deletedBy: 'self' } },
])
assert.equal(ctx.teams.reconcile.calls.length, 2)
// Every server is marked dirty, so the withdrawn grants go on the next tick.
const dirty = queries.filter((q) => /UPDATE rust_perm_sync SET dirty = 1/.test(q.sql))
assert.equal(dirty.length, 1)
assert.ok(!/WHERE/.test(dirty[0].sql), 'the whole fleet, not one server')
})
test('an account with no Steam link deletes without touching the game or the sync', async () => {
const { ctx, queries } = withCore({ select: [[]] })
const links = require('../model/links/links.model')
await links.unlinkForDeletion({ userId: 4, deletedBy: 'staff' })
assert.equal(queries.filter((q) => !q.sql.trim().toUpperCase().startsWith('SELECT')).length, 0)
assert.equal(ctx.activity.log.calls.length, 0)
assert.equal(ctx.teams.reconcile.calls.length, 0)
})
test('a /link that lands between core’s check and the hook keeps its link; the hook does not throw', async () => {
const dup = Object.assign(new Error('dup'), { code: 'ER_DUP_ENTRY' })
const { ctx } = withCore({ select: [[]], onInsert: () => Promise.reject(dup) })

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

View File

@@ -1,5 +1,61 @@
{
"paths": {
"/api/v1/admin/rust/chat/{id}/hide": {
"post": {
"tags": [
"Admin · Rust"
],
"summary": "Hide a chat line for everyone",
"description": "Staff’s hide for everyone (USER_SAFETY.md S10): the line leaves the public feed for everybody but staff, who still get it with `hidden: true`. The same flag core’s Reports queue sets through the `rust.chat` report target. Hiding a hidden line changes nothing. Recorded in the activity log. Admins and moderators.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "The chat line’s event id"
}
],
"responses": {
"204": {
"description": "Hidden"
},
"404": {
"description": "No such chat line"
}
}
}
},
"/api/v1/admin/rust/chat/{id}/unhide": {
"post": {
"tags": [
"Admin · Rust"
],
"summary": "Show a hidden chat line again",
"description": "Undoes a hide for everyone, whether it was made here or from the Reports queue. Recorded in the activity log. Admins and moderators.",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
},
"description": "The chat line’s event id"
}
],
"responses": {
"204": {
"description": "Shown again"
},
"404": {
"description": "No such chat line"
}
}
}
},
"/api/v1/admin/rust/config/{serverId}/file": {
"get": {
"tags": [
@@ -3203,7 +3259,7 @@
"Public · Rust"
],
"summary": "Recent events on one Rust server",
"description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped.",
"description": "The killfeed and everything else public that happened on a server, newest first. Narrow with `kind` (comma-separated) and `wipe`. Only publicly classified kinds are ever returned — moderation events, login attempts and anything carrying an IP address are stored but never served here. Kinds that name a player who was on the server (connects, respawns, deaths, chat, tallies) are served only to viewers inside the operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld. Rust’s own world events (protocol 14, PLAN_REDESIGNS §14.3.1) arrive as `world.event` with `event` naming which of 24: they are public, with a `grid` worked out from the map, but a player’s `name` (and the names in `credited`) only for the presence audience, a Steam id and a position only for staff, and a type this build does not know only for staff. A downed patrol helicopter, Chinook or Bradley carries `credited` and `creditedCount` by the server’s `creditRule` as it was when the event arrived, so a line keeps naming whom the Rotorbane and Tankbreaker counts credited after the rule changes. A frame with a `weapon` (the held entity’s short prefab name, which the title lists count by) also carries `weaponName`, what a page should print: the game’s own name for a ranged weapon, or the prefab name as words with its `.entity` ending dropped. Chat is read for each reader (USER_SAFETY.md §4.2): a signed-in reader does not get the lines of a player they blocked (by the player’s linked account, or by Steam id when there is none) or lines they hid for themselves, and each `player.chat` row carries `canBlock` — false for their own lines and for a player linked to a staff account. A line staff hid for everyone is left out for everybody but staff, who get it with `hidden: true`.",
"parameters": [
{
"name": "id",