1 Commits
edge ... v0.6.1

Author SHA1 Message Date
161457dbf3 Merge pull request 'release: seed the demo's NPC profiles, factions and chat styles (v0.6.1)' (#66) from edge into main
All checks were successful
Release / release (push) Successful in 35s
Reviewed-on: #66
2026-10-28 01:32:14 +00:00
31 changed files with 32 additions and 1326 deletions

View File

@@ -61,7 +61,6 @@
"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). **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.",
"$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).",
"repo": "https://gitea.whitlocktech.com/RunicGateway/website.git",
"ref": "f13b8fe1fec9dc330a985430b8bd4f67c9ec26c5",
"refName": "website main @ website#233 merge, MODULE_API 1.21.0"
"ref": "69998ec54854d46681def92985a2b299ee3582a6",
"refName": "website main @ website#224 merge, MODULE_API 1.19.0"
}

View File

@@ -350,21 +350,6 @@ 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
@@ -397,7 +382,6 @@ export default {
adminUserPermissions,
adminUserEconomics,
adminUserBackpacks,
safety,
coreEvents,
BASE,
}

View File

@@ -1,198 +0,0 @@
// ── 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,7 +13,6 @@
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'
@@ -33,7 +32,7 @@ const TONE = {
export default function Feed({ serverId, wipeId, filter, onFilter }) {
const kinds = kindsFor(filter)
const { data, error, loading, at, refresh } = usePolled(
const { data, error, loading, at } = 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
@@ -112,14 +111,10 @@ 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
@@ -142,17 +137,7 @@ 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>
)
})}

View File

@@ -1,56 +0,0 @@
// ── 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

@@ -1,45 +0,0 @@
// ── 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.21.0",
"coreApi": "^1.19.0",
"triggers": [
{
"id": "rust.base.destroyed",

View File

@@ -2,7 +2,7 @@
"id": "rust",
"name": "Rust",
"version": "0.1.0",
"coreApi": "^1.21.0",
"coreApi": "^1.19.0",
"server": "server/index.js",
"client": { "entry": "client/dist/entry.js" },
"schema": "server/db/schema.sql",
@@ -16,7 +16,6 @@
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"],
"demoLocks": [
{ "method": "PUT", "path": "/admin/rust/servers/:id" },
{ "method": "DELETE", "path": "/admin/rust/servers/:id" },
{ "method": "DELETE", "path": "/player/rust/links/:steamId" }
{ "method": "DELETE", "path": "/admin/rust/servers/:id" }
]
}

View File

@@ -251,16 +251,6 @@
"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,18 +175,6 @@ 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,18 +246,6 @@ 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

@@ -1,13 +1,9 @@
// ── The public demo's module-side locks (DEMO_MODE.md §3.4, §3.5) ──────────
//
// Core locks this module's two rig-link routes and the player's Steam unlink by
// `demoLocks` in module.json. What a route list cannot express lives here, behind
// `ctx.demoMode` (MODULE_API 1.18.0), and every handler that enforces one of these
// asks this file:
// Core locks this module's two link routes by `demoLocks` in module.json. What a
// route list cannot express lives here, behind `ctx.demoMode` (MODULE_API
// 1.18.0), and every handler that enforces one of these asks this file:
//
// the staff Steam unlink on Admin → Users (step-5 walk, W4): it serves under
// core's users mount, which a module's demoLocks may not name, so its handler
// refuses instead. The demo player's seeded link stays put either way;
// the console-command deny list (§3.4, DM11): the commands that would break
// the rig for every visitor until the next wipe. Asked at save, through
// core's `demoRefuses` hook (1.19.0, DM18), and again at run;

View File

@@ -168,23 +168,6 @@ 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
@@ -265,7 +248,6 @@ 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,14 +312,13 @@ 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, chat = {} }) {
async function recentEvents({ serverId, kinds, wipeId = null, limit = 50 }) {
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,
hidden_at AS hiddenAt
let sql = `SELECT id, server_id AS serverId, wipe_id AS wipeId, kind, t, steam_id AS steamId, raw
FROM ${EVENTS}
WHERE server_id = ? AND kind IN (${holes})`
@@ -328,53 +327,12 @@ async function recentEvents({ serverId, kinds, wipeId = null, limit = 50, chat =
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.
*
@@ -515,8 +473,6 @@ module.exports = {
replacePresence,
pruneEvents,
recentEvents,
chatLineById,
setChatHidden,
leaderboard,
listWipes,
presenceFor,

View File

@@ -59,27 +59,18 @@ 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, chat = {} }) {
async function recent({ serverId, admin = false, staff = false, presence = false, kind = null, wipeId = null, limit }) {
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)
@@ -179,9 +170,6 @@ 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,18 +166,6 @@ 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`).
@@ -229,6 +217,5 @@ module.exports = {
removeBySteamId,
statsForSteamId,
userIdsForSteamIds,
steamIdsForUsers,
linkedOnServer,
}

View File

@@ -19,7 +19,6 @@
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')
@@ -352,47 +351,6 @@ 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)
@@ -435,6 +393,5 @@ module.exports = {
unlinkFromGame,
checkSteamSignin,
linkFromSteamSignin,
unlinkForDeletion,
forAdmin,
}

View File

@@ -103,31 +103,17 @@ 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 { 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 }
}
}
if (!claimed || claimed.id == null) return nobody
/**
* 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)
const user = await core.users.getById(claimed.id)
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 : userId }
return { level, userId: user.id != null ? user.id : claimed.id }
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return nobody
@@ -168,22 +154,11 @@ async function presenceFor(serverId) {
*/
async function canSeePresence(req, serverId) {
try {
const [who, required] = await Promise.all([viewer(req), presenceFor(serverId)])
return { visible: meets(who.level, required), level: who.level, required, userId: who.userId }
const [level, required] = await Promise.all([viewerLevel(req), presenceFor(serverId)])
return { visible: meets(level, required), level, required }
} 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 }
}
}
/** `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 }
return { visible: false, level: 'public', required: DEFAULT_PRESENCE }
}
}
@@ -339,11 +314,9 @@ module.exports = {
normalise,
viewerLevel,
viewer,
viewerById,
fleetPresence,
presenceFor,
canSeePresence,
canSeePresenceAs,
describe,
update,
}

View File

@@ -13,7 +13,6 @@
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')
@@ -380,40 +379,7 @@ 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,39 +123,6 @@ 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

@@ -8,7 +8,6 @@
const crypto = require('node:crypto')
const core = require('../../core')
const demo = require('../../demo')
const backpacks = require('../../model/backpacks/backpacks.model')
const economics = require('../../model/economics/economics.model')
@@ -55,10 +54,6 @@ async function removeLink(req, res) {
const { steamId } = req.params
const userId = req.params.id
// On the demo the player's own unlink is locked by demoLocks; this route serves
// under core's users mount, which a module may not lock by route, so it asks here.
if (demo.isDemo()) return demo.refuse(res)
try {
const removed = await links.unlinkOwned(steamId, userId)

View File

@@ -15,7 +15,6 @@ 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')
@@ -91,30 +90,20 @@ 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: presence.visible ? await safety.annotate(rows, reader) : rows,
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,
}),
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. 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.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.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' } }

View File

@@ -1,193 +0,0 @@
// ── 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,12 +91,6 @@ 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(),
@@ -179,11 +173,6 @@ 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

@@ -447,44 +447,11 @@ test('the seed sets the faction table, saves each profile, and styles each group
// ── The manifest (§3.2) ────────────────────────────────────────────────────
test('module.json locks the two rig-link routes and the player Steam unlink, and needs a core that asks demoRefuses', () => {
test('module.json locks the two link routes and needs a core that asks demoRefuses', () => {
const manifest = require('../../module.json')
assert.deepEqual(manifest.demoLocks, [
{ method: 'PUT', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/admin/rust/servers/:id' },
{ method: 'DELETE', path: '/player/rust/links/:steamId' },
])
// 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) ────────────────────────────────────────────
test('staff cannot sever a Steam link on the demo, and the link is never touched', async () => {
for (const demoMode of [true, false]) {
withCore({ demo: demoMode })
const links = require('../model/links/links.model')
const asked = []
links.unlinkOwned = async (steamId, userId) => {
asked.push([steamId, userId])
return true
}
const res = fakeRes()
await require('../router/admin/usersRust.controller').removeLink(
{ params: { id: '3', steamId: '76561190000000001' }, user: { id: 1 } },
res,
)
if (demoMode) {
assert.equal(res.statusCode, 403)
assert.equal(res.body.code, 'DEMO_LOCKED')
assert.deepEqual(asked, [])
} else {
assert.equal(res.statusCode, 200)
assert.deepEqual(res.body, { unlinked: true })
assert.deepEqual(asked, [['76561190000000001', '3']])
}
}
assert.equal(manifest.coreApi, '^1.19.0')
})

View File

@@ -92,12 +92,6 @@ 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,42 +472,6 @@ 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) })

View File

@@ -1,349 +0,0 @@
// ── 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,61 +1,5 @@
{
"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": [
@@ -3259,7 +3203,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. 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`.",
"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.",
"parameters": [
{
"name": "id",