fix(rust): nothing names who is online by default
All checks were successful
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / server-tests (pull_request) Successful in 8m6s

The org lead's rule, settled 2026-09-22: who is online is always the
narrowest audience - staff - unless an operator deliberately widens it,
and a count is fine where a list of names is not.

The public site broke that in three places since phase 4. The Online
tab named every player, the feed carried joins, respawns, deaths, chat
and tallies, and the leaderboard's lastSeen - refreshed every minute by
a gather tally - said who was on as plainly as either. All three now
sit behind one setting:

* PRESENCE_KINDS, a subset of the public allowlist, gated per request.
  Below the audience the feed keeps the server's own story (wipe, start,
  shutdown) and says presenceHidden rather than looking quiet.
* the Online route answers { players: [], hidden, count, audience } -
  same shape, so an older client renders empty rather than breaking.
* rungs staff / signed_in / public, fleet-wide default in a new
  rust_settings table with an optional per-server override on
  rust_servers; an unknown stored word narrows to staff.
* the viewer's standing is RE-READ from the users row (ctx.users.getById),
  not taken from the token, so a demotion or a ban applies on the next
  request. Walked: a moderator demoted mid-session lost the roll call on
  the same cookie.
* per-viewer answers are Cache-Control: private, no-store.
* GET/PUT /admin/rust/visibility (requireRole admin) and an admin page,
  Rust visibility; every save is one activity-log row.

The browser walk also found every empty state in this module rendering
as a blank box. Core's EmptyState renders children only; this module
passed title/message (the shape the Integration Kit template teaches)
and React dropped both without a word. Fixed module-side with a small
Empty wrapper - nothing core or module-uo renders changes - and a client
test that refuses a titled EmptyState or a PageHeader subtitle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-23 00:30:08 -05:00
parent 480a99f661
commit be44839896
31 changed files with 1739 additions and 33 deletions

View File

@@ -154,6 +154,17 @@ export const adminPermissions = {
req('/admin/rust/permissions/sync', { method: 'POST', body: serverId ? { serverId } : {} }),
}
// ── admin · visibility ────────────────────────────────────────────────────
//
// Who may see who is online. The org lead's rule is that nothing names who is
// online by default; this is where an operator deliberately widens it. A save
// answers the whole new state, so the screen re-renders from the server's word
// rather than from what it sent.
export const adminVisibility = {
read: () => req('/admin/rust/visibility'),
save: (body) => req('/admin/rust/visibility', { method: 'PUT', body }),
}
// ── admin · mod configuration (R18) ───────────────────────────────────────
//
// Every call here is a LIVE round trip to a game host, which makes this the only
@@ -226,6 +237,7 @@ export default {
admin,
adminPermissions,
adminConfig,
adminVisibility,
adminUserLinks,
adminUserPermissions,
BASE,

View File

@@ -0,0 +1,26 @@
// ── An empty state with a heading and a sentence ──────────────────────────
//
// Core's `EmptyState` renders its CHILDREN and nothing else. This module passed
// it `title` and `message` from phase 4 onwards — the shape the Integration Kit's
// template teaches — and React drops an unknown prop without a word, so every
// empty panel in the module rendered as a blank box: "Nobody is on", "No scores
// yet", "No servers yet", all of them. Found by the presence fix's browser walk,
// when the "12 players online" it depended on came out as nothing.
//
// Fixed here rather than in core: core's component is shared by every module,
// and a module-side wrapper changes nothing anybody else renders. The client
// suite (`test/uiKitProps.test.js`) refuses a titled EmptyState so the mistake
// cannot come back.
import { EmptyState } from '../core.js'
export default function Empty({ title, message }) {
return (
<EmptyState>
{title && (
<strong style={{ display: 'block', color: 'var(--head)', marginBottom: message ? 6 : 0 }}>{title}</strong>
)}
{message && <span>{message}</span>}
</EmptyState>
)
}

View File

@@ -11,11 +11,13 @@
// see the comment at the top of that file for why core's `useAsync` cannot do
// this job.
import { EmptyState, ErrorState, Loading } from '../core.js'
import { ErrorState, Loading } from '../core.js'
import Empty from './Empty.jsx'
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
import { ago, clock } from '../lib/format.js'
import usePolled from '../hooks/usePolled.js'
import api from '../api.js'
import { hiddenMessage } from './Online.jsx'
const TONE = {
kill: 'var(--accent-bright)',
@@ -79,10 +81,24 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
off" must not blank itself the first time a request does. */}
{error && !data && <ErrorState error={error} />}
{/* Below the operator's presence audience the server withholds every item
that names a player who was on — the killfeed, chat, joins — and keeps
only the server's own story. Said once, above the rows, so a thin feed
reads as withheld rather than as a quiet server. */}
{data && data.presenceHidden && (
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
Joins, deaths and chat are not shown. {hiddenMessage(data.presenceAudience, 'what players did')}
</p>
)}
{data && events.length === 0 && (
<EmptyState
<Empty
title="Nothing here yet"
message="Nothing this server has reported matches. A server that has just been added has no history until it says something."
message={
data.presenceHidden
? 'Nothing this server has reported about itself matches.'
: 'Nothing this server has reported matches. A server that has just been added has no history until it says something.'
}
/>
)}

View File

@@ -10,7 +10,8 @@
// than one that is four minutes old, and the page has a `Refresh` on the tab
// strip for anybody who disagrees.
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import { ago, count, duration, shortId } from '../lib/format.js'
import api from '../api.js'
@@ -32,13 +33,15 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
)
const rows = data ? data.leaderboard : []
// Present on every row or on none — the server decides per request.
const showLastSeen = rows.some((row) => 'lastSeen' in row)
if (loading) return <Loading />
if (error) return <ErrorState error={error} />
if (rows.length === 0) {
return (
<EmptyState
<Empty
title="No scores yet"
message={
wipeId
@@ -80,7 +83,13 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
)}
</th>
))}
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
{/* The server withholds `lastSeen` below the operator's presence
audience — a gather tally refreshes it every minute somebody plays,
so it would name who is online. The column goes with it rather
than rendering a row of dashes that look like "never". */}
{showLastSeen && (
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
)}
</tr>
</thead>
<tbody>
@@ -98,7 +107,9 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
{column.value(row)}
</td>
))}
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
{showLastSeen && (
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
)}
</tr>
))}
</tbody>

View File

@@ -9,7 +9,8 @@
// It polls with the feed, because "who is on" is the one thing on this page that
// is a live question.
import { EmptyState, ErrorState, Loading } from '../core.js'
import { ErrorState, Loading } from '../core.js'
import Empty from './Empty.jsx'
import { duration, shortId } from '../lib/format.js'
import usePolled from '../hooks/usePolled.js'
import api from '../api.js'
@@ -25,9 +26,23 @@ export default function Online({ serverId, online }) {
if (loading) return <Loading />
if (error && !data) return <ErrorState error={error} />
// Nothing names who is online by default (the org lead's rule). Below the
// operator's audience the server answers a count and no names, and the page
// says so — an empty list here would read as "nobody is on", which is a
// different claim and a false one.
if (data && data.hidden) {
const count = Number(data.count) || 0
return (
<Empty
title={online ? `${count.toLocaleString()} ${count === 1 ? 'player' : 'players'} online` : 'The server is offline'}
message={hiddenMessage(data.audience)}
/>
)
}
if (players.length === 0) {
return (
<EmptyState
<Empty
title={online ? 'Nobody is on' : 'The server is offline'}
message={
online
@@ -92,3 +107,16 @@ function sessionSoFar(connectedAt) {
if (Number.isNaN(since)) return ''
return duration((Date.now() - since) / 1000)
}
/**
* Why something was withheld, in words a visitor can act on.
*
* `what` completes the sentence — "who they are", "what players did". The
* audience is the operator's (`staff` unless widened), and only `signed_in` is
* something a visitor can do anything about.
*/
export function hiddenMessage(audience, what = 'who they are') {
if (audience === 'signed_in') return `Sign in to see ${what}.`
if (audience === 'public') return `This site is not showing ${what} right now.`
return `Only this site’s staff can see ${what}.`
}

View File

@@ -11,7 +11,8 @@
// id the events and the leaderboard filter by. There is no second derivation
// anywhere that could disagree.
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
import { ago, day } from '../lib/format.js'
import api from '../api.js'
@@ -24,7 +25,7 @@ export default function Wipes({ serverId, currentWipeId, selected, onSelect }) {
if (wipes.length === 0) {
return (
<EmptyState
<Empty
title="No wipes recorded"
message="A wipe appears here once this server has reported something during it."
/>

View File

@@ -23,9 +23,10 @@ import ServerDetail from './routes/public/ServerDetail.jsx'
import Account from './routes/player/Account.jsx'
import Permissions from './routes/admin/Permissions.jsx'
import ModConfig from './routes/admin/ModConfig.jsx'
import Visibility from './routes/admin/Visibility.jsx'
import UserRustSections from './routes/admin/UserRustSections.jsx'
import FooterStatus from './components/FooterStatus.jsx'
import { IconKey, IconLink, IconSliders } from './icons.jsx'
import { IconEye, IconKey, IconLink, IconSliders } from './icons.jsx'
// The module id, exactly as `module.json` spells it. Core keys the registry by it
// and prefixes every route path with it.
@@ -94,6 +95,10 @@ registry.registerRoutes(ID, {
// `/admin/rust/config` and core's admin gate applies to it exactly as it
// does to the page above.
{ path: 'config', element: <ModConfig /> },
// Who may see who is online — a third neighbour. The org lead's rule is that
// nothing names who is online by default; this is where an operator widens
// it on purpose, fleet-wide or per server.
{ path: 'visibility', element: <Visibility /> },
],
})
@@ -142,6 +147,7 @@ registry.registerNav(ID, {
items: [
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
{ label: 'Rust visibility', to: '/admin/rust/visibility', icon: IconEye },
],
})

View File

@@ -83,4 +83,16 @@ export const IconSliders = () => (
</Icon>
)
export default { IconLink, IconKey, IconSliders }
/**
* An eye — the admin sidebar's row for who may see who is online.
*
* The page decides what the public can SEE, so the glyph is the act of seeing.
*/
export const IconEye = () => (
<Icon>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
<circle cx="12" cy="12" r="3" />
</Icon>
)
export default { IconLink, IconKey, IconSliders, IconEye }

View File

@@ -0,0 +1,196 @@
// ── Admin · Rust · Visibility ─────────────────────────────────────────────
//
// Who may see who is online. The org lead's rule (2026-09-22): nothing names who
// is online by default — the narrowest audience, staff, unless an operator
// deliberately widens it here. A count of players is public at every setting.
//
// One fleet default and an optional override per server, because a creative or
// PvE server may reasonably publish a roll call a PvP server must not — and a
// server that has not chosen follows the fleet, so narrowing the fleet narrows
// every server that never said otherwise.
//
// The page says what "who is online" covers, because it is wider than the tab
// of the same name: the killfeed, chat and joins in the feed, and the
// leaderboard's "last seen" all name a player who was on at a given moment.
import { useCallback, useEffect, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
const INHERIT = ''
const LABEL = {
staff: 'Staff only',
signed_in: 'Signed-in members',
public: 'Everyone',
}
const DESCRIBE = {
staff: 'Admins and moderators. The default.',
signed_in: 'Anybody with an account on this site.',
public: 'Anybody at all, signed in or not.',
}
function Card({ title, subtitle, children }) {
return (
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
{title}
</h2>
{subtitle && (
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
{subtitle}
</span>
)}
</header>
{children}
</section>
)
}
function AudienceSelect({ value, onChange, audiences, inherit = null, label }) {
return (
<select value={value} onChange={(e) => onChange(e.target.value)} style={selectStyle} aria-label={label}>
{inherit && <option value={INHERIT}>{inherit}</option>}
{audiences.map((a) => (
<option key={a} value={a}>{LABEL[a] || a}</option>
))}
</select>
)
}
export default function Visibility() {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.adminVisibility.read(), [reloads])
const [fleet, setFleet] = useState('staff')
const [servers, setServers] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
// The form starts from what the server said and is reset from it after every
// save — the answer to a PUT is the new state, so what is on screen is always
// the site's word rather than what this page sent.
const load = useCallback((state) => {
setFleet(state.presence.fleet)
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
}, [])
useEffect(() => {
if (data) load(data)
}, [data, load])
if (loadError) return <ErrorState error={loadError} />
if (!data) return <Loading />
const audiences = data.audiences
const rows = data.presence.servers
const dirtyFleet = fleet !== data.presence.fleet
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
const dirty = dirtyFleet || dirtyServers.length > 0
const effective = (id) => servers[id] || fleet
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
const save = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
setSaved(false)
try {
const body = {}
if (dirtyFleet) body.fleet = fleet
if (dirtyServers.length) {
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
}
load(await api.adminVisibility.save(body))
setSaved(true)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={save} style={{ maxWidth: 900 }}>
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
Nothing on this site names who is online unless you choose to show it. That covers more than
the Online tab: the joins, deaths and chat in each server’s feed, and the leaderboard’s “last
seen”, all say that a named player was on at a given moment. How many players are online is
always shown.
</p>
<Card title="Who is online" subtitle="the default for every server">
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.86rem' }}>
<AudienceSelect value={fleet} onChange={setFleet} audiences={audiences} label="Fleet default" />
<span className="dim" style={{ fontSize: '0.78rem' }}>{DESCRIBE[fleet]}</span>
</div>
</Card>
<Card title="Per server" subtitle="an override, or the default above">
{rows.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
)}
{rows.map((s) => (
<div
key={s.id}
className="sans"
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 0',
borderTop: '1px solid var(--line-soft)',
fontSize: '0.86rem',
}}
>
<span style={{ minWidth: 180, color: 'var(--head)' }}>
{s.name}
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
</span>
<AudienceSelect
value={servers[s.id] ?? INHERIT}
onChange={(v) => setServers((prev) => ({ ...prev, [s.id]: v }))}
audiences={audiences}
inherit={`Default (${LABEL[fleet] || fleet})`}
label={`Who is online on ${s.name}`}
/>
<span className="dim" style={{ fontSize: '0.78rem' }}>
{servers[s.id] ? 'its own setting' : 'follows the default'}
</span>
</div>
))}
</Card>
{widened && (
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.8rem' }}>
Wider than staff: on a PvP server, knowing who is on tells a raiding party whose base is
undefended.
</p>
)}
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button type="submit" className="btn" disabled={busy || !dirty}>
{busy ? 'Saving…' : 'Save'}
</button>
{saved && !dirty && <span className="dim" style={{ fontSize: '0.8rem' }}>Saved.</span>}
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
</div>
</form>
)
}
const selectStyle = {
background: 'var(--panel-flat, transparent)',
color: 'var(--text)',
border: '1px solid var(--line)',
borderRadius: 'var(--radius-input, 6px)',
padding: '4px 8px',
fontSize: '0.84rem',
}

View File

@@ -22,7 +22,8 @@
// is down. The site's availability does not depend on the game's.
import { Link } from 'react-router-dom'
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import Empty from '../../components/Empty.jsx'
import { ago, count, day } from '../../lib/format.js'
import api from '../../api.js'
@@ -61,7 +62,7 @@ export default function Servers() {
empty game — it is an install that is not finished. Saying so beats a
blank page that looks like a failure. */}
{data && servers.length === 0 && (
<EmptyState
<Empty
title="No servers yet"
message="An administrator adds a Rust server, and its sidecar, from the admin panel."
/>

View File

@@ -0,0 +1,49 @@
// ── The UI kit's props, as core actually reads them ───────────────────────
//
// React drops an unknown prop without a word, so a UI-kit component called with
// the wrong one renders — just not what was written. Two of these have shipped
// from this org already: `PageHeader subtitle` (Teams phase 11, the kit's
// template) and `EmptyState title/message` (this module, phases 4 to 8 — every
// empty panel was a blank box until the presence fix's browser walk).
//
// A DOM-less runner cannot see a blank box, so this reads the source instead:
// it names the props core's components do NOT take and fails on any use of them.
// It is a claim about core that must be re-read when core's kit changes —
// written down rather than imported, because no core is in this process.
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'src')
/** Every .jsx/.js under src/. */
function sources(dir = SRC) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) return sources(full)
return /\.(jsx?|mjs)$/.test(entry.name) ? [full] : []
})
}
// Core's `components/PageState.jsx` and `PageHeader.jsx`, read 2026-09-23 at the
// pinned core (ci/core-ref.json).
const REFUSED = {
// `EmptyState({ children })` — children only.
EmptyState: /<EmptyState\b[^>]*\b(title|message|description|text)\s*=/,
// `PageHeader({ eyebrow, title, lead, center })` — there is no `subtitle`.
PageHeader: /<PageHeader\b[^>]*\bsubtitle\s*=/,
}
test('no UI-kit component is handed a prop core does not read', () => {
const offences = []
for (const file of sources()) {
const text = fs.readFileSync(file, 'utf8')
for (const [component, pattern] of Object.entries(REFUSED)) {
if (pattern.test(text)) offences.push(`${path.relative(SRC, file)}: ${component}`)
}
}
assert.deepEqual(offences, [], 'use components/Empty.jsx for a titled empty state')
})