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

@@ -42,10 +42,18 @@ rows here; the website core never learns there is more than one.
| Public | `GET …/servers/:id/wipes` and `…/online` |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` |
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
| Pages | `/rust` — the server list, and the module's landing page |
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes |
| Slot | `site.footer.status` — a live server/player count in core's footer |
**Nothing names who is online by default.** The Online list, every feed item that says a named
player was on the server (connects, respawns, deaths, chat, gather tallies) and the leaderboard's
"last seen" reach **staff** unless an operator widens them in Admin → Rust visibility — fleet-wide,
with an optional override per server. How many players are online is public at every setting. The
viewer's standing is re-read from the database on each request, so a demotion or a ban applies at
once rather than when a token expires.
Every page reads this module's own tables and never calls a game server, which is what lets the
whole surface render while every server in the fleet is off. Tab, feed filter, wipe and leaderboard
sort all live in the URL, so any view of it is a link.

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 sites 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 servers feed, and the leaderboards 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')
})

View File

@@ -66,6 +66,11 @@
"path": "/api/v1/admin/rust/servers",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/rust/visibility",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/admin/users/:id/rust/links",
@@ -175,6 +180,11 @@
"method": "PUT",
"path": "/api/v1/admin/rust/servers/:id",
"tier": "public"
},
{
"method": "PUT",
"path": "/api/v1/admin/rust/visibility",
"tier": "public"
}
]
}

View File

@@ -82,11 +82,39 @@ const STAFF_KINDS = Object.freeze([
'perm.drift',
])
/**
* The public kinds that say a NAMED player was on the server at a given moment.
*
* A subset of `PUBLIC_KINDS`, not a third list: these are public-page material
* whose audience an operator chooses (`model/visibility`), where the rest of
* `PUBLIC_KINDS` is public by construction. The org lead's rule, settled
* 2026-09-22: **nothing tells who is online by default** — the narrowest
* audience (staff) unless an operator widens it, and a count is never a name.
*
* `player.death` and `player.chat` are here, and that was decided rather than
* overlooked. They are the killfeed and the chat — the content a feed exists
* for — and each one says "this person was on at 12:03" as plainly as a connect
* frame does. `player.tally` is a per-minute flush that is only ever sent for a
* player who is playing, which makes it a roll call with extra steps.
*
* What is left in the public set once these are removed is the server's own
* story — a wipe, a start, a shutdown — which names nobody.
*/
const PRESENCE_KINDS = Object.freeze([
'player.connected',
'player.disconnected',
'player.respawned',
'player.death',
'player.chat',
'player.tally',
])
/** Every kind protocol 3 defines. */
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
const PUBLIC = new Set(PUBLIC_KINDS)
const STAFF = new Set(STAFF_KINDS)
const PRESENCE = new Set(PRESENCE_KINDS)
/**
* May a signed-out visitor see this kind?
@@ -103,15 +131,27 @@ function isKnown(kind) {
return PUBLIC.has(kind) || STAFF.has(kind)
}
/** Does this kind name a player who was on the server at the time? */
function isPresence(kind) {
return PRESENCE.has(kind)
}
/**
* Narrows a list of requested kinds to the ones a viewer may have.
*
* Returning the allowlist itself when nothing was requested is what makes the
* public route safe by construction rather than by remembering to filter: there
* is no code path where "no filter" means "everything".
*
* `presence` defaults to `false` for the same reason `admin` does: a caller that
* forgets to say what the viewer may see gets the narrowest answer. The route
* resolves it from the operator's setting (`model/visibility`); nothing else
* should be passing `true`.
*/
function kindsFor({ admin = false, requested = null } = {}) {
const permitted = admin ? ALL_KINDS : PUBLIC_KINDS
function kindsFor({ admin = false, presence = false, requested = null } = {}) {
const permitted = admin
? ALL_KINDS
: PUBLIC_KINDS.filter((k) => presence || !PRESENCE.has(k))
if (!requested || requested.length === 0) return [...permitted]
@@ -122,8 +162,10 @@ function kindsFor({ admin = false, requested = null } = {}) {
module.exports = {
PUBLIC_KINDS,
STAFF_KINDS,
PRESENCE_KINDS,
ALL_KINDS,
isPublic,
isKnown,
isPresence,
kindsFor,
}

View File

@@ -86,6 +86,14 @@ module.exports = {
// that needs an identity needs to *read* one.
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
// One user by id (MODULE_API.md §2.3, 1.1.0). Here for the presence gate
// (`model/visibility`): `getUserFromRequest` decodes a token and nothing more,
// so the role in it is the role the account had when the token was minted. A
// moderator demoted this morning would keep reading who is online until their
// token expired. Re-reading the row is what makes a demotion — or a ban — take
// effect on the next request, the same promise core's admin tier makes.
users: { getById: (...args) => need().users.getById(...args) },
// Core's middleware, taken as values rather than wrapped: express stores the
// function reference at mount time, so a wrapper is what would end up in the
// stack. Routers are built inside `register()`, so `ctx` is set by then.

View File

@@ -20,6 +20,7 @@
-- registrant owned what.
-- Phase 7b.
DROP TABLE IF EXISTS rust_settings;
DROP TABLE IF EXISTS rust_config_writes;
-- Phase 7. Children before parents: every one of these carries a foreign key

View File

@@ -668,3 +668,36 @@ CREATE TABLE IF NOT EXISTS rust_config_writes (
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
KEY idx_rust_config_writes_server (server_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Who may see who is online (the presence fix, 2026-09-22) ──────────────
--
-- The org lead's rule: **nothing tells who is online by default.** The Online
-- list, the killfeed, chat and every other frame that says a named player was on
-- the server reach STAFF unless an operator deliberately widens them. A count is
-- not a name and stays public.
--
-- Two places, because the decision has two shapes:
--
-- • `rust_settings` holds the FLEET default — one row per key. A key/value
-- table rather than a column per setting, because phase 9's clan-roster
-- audience is the next key and a table that grows a column per setting grows
-- an ALTER per setting.
-- • `rust_servers.presence_audience` is an optional PER-SERVER override. NULL
-- means "inherit the fleet default", which is not the same as any audience —
-- an operator who later narrows the fleet must narrow every server that never
-- chose otherwise.
--
-- The stored value is a word (`staff` · `signed_in` · `public`) and an unknown
-- word reads as `staff` (`model/visibility`): a typo in a row must narrow, never
-- widen.
CREATE TABLE IF NOT EXISTS rust_settings (
setting_key VARCHAR(64) NOT NULL PRIMARY KEY,
value VARCHAR(255) NOT NULL,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_settings_user
FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS presence_audience VARCHAR(16) NULL;

View File

@@ -51,8 +51,8 @@ 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, kind = null, wipeId = null, limit }) {
const kinds = catalogue.kindsFor({ admin, requested: parseKinds(kind) })
async function recent({ serverId, admin = 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.
@@ -102,7 +102,7 @@ function shape(row) {
* counters, so the two can never disagree — which is the whole reason R12's
* "per-wipe detail plus all-time rollups" is one table and not two.
*/
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit }) {
async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit, presence = false }) {
const rows = await db.leaderboard({
serverId,
wipeId,
@@ -118,7 +118,11 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit }) {
npcKills: Number(r.npcKills) || 0,
structures: Number(r.structures) || 0,
playtimeSec: Number(r.playtimeSec) || 0,
lastSeen: r.lastSeen || null,
// Withheld below the presence audience. A tally refreshes it every minute a
// player is on, so a `lastSeen` of forty seconds ago is the Online tab by
// another name. The ORDER still uses it as a tie-break — that says who was
// on more recently, never whether anybody is on now.
...(presence ? { lastSeen: r.lastSeen || null } : {}),
}))
}

View File

@@ -0,0 +1,55 @@
// ── SQL for the visibility settings ───────────────────────────────────────
//
// Two stores for one decision: the fleet default in `rust_settings`, and an
// optional per-server override on `rust_servers`. See `schema.sql` for why each
// lives where it does.
const core = require('../../core')
const SETTINGS = 'rust_settings'
const SERVERS = 'rust_servers'
/** One setting's stored value, or `null` when nobody has ever set it. */
async function getSetting(key) {
const rows = await core.query(`SELECT value FROM ${SETTINGS} WHERE setting_key = ?`, [key])
return rows[0] ? rows[0].value : null
}
async function setSetting(key, value, userId = null) {
await core.query(
`INSERT INTO ${SETTINGS} (setting_key, value, updated_by, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by),
updated_at = CURRENT_TIMESTAMP`,
[key, value, userId],
)
}
/** One server's override, `null` for "inherit", or `undefined` when there is no such server. */
async function getServerPresence(serverId) {
const rows = await core.query(`SELECT presence_audience AS presence FROM ${SERVERS} WHERE id = ?`, [serverId])
return rows[0] ? rows[0].presence : undefined
}
/** Every configured server with its override, in the operator's own order. */
async function listServerPresence() {
return core.query(
`SELECT id, name, enabled, presence_audience AS presence
FROM ${SERVERS}
ORDER BY sort_order ASC, id ASC`,
)
}
/**
* Sets or clears (`null`) one server's override.
*
* Returns nothing, deliberately. `affectedRows` would look like a way to tell
* "no such server" from success, and it is not one: without `foundRows` an
* UPDATE writing the value already there reports 0, and whether core's pool sets
* that flag is core's business. The model checks existence with a read first.
*/
async function setServerPresence(serverId, value) {
await core.query(`UPDATE ${SERVERS} SET presence_audience = ? WHERE id = ?`, [value, serverId])
}
module.exports = { getSetting, setSetting, getServerPresence, listServerPresence, setServerPresence }

View File

@@ -0,0 +1,207 @@
// ── Who may see who is online ─────────────────────────────────────────────
//
// The org lead's rule, settled 2026-09-22: **nothing tells who is online by
// default.** It is always the lowest blast radius — staff — unless an operator
// deliberately widens it, and a COUNT of players is fine where a list of names
// is not.
//
// "Who is online" is wider than the Online tab. Every frame that says a named
// player was on the server at a given moment says it: a connect, a respawn, a
// death, a chat line, a gather tally (`catalogue.PRESENCE_KINDS`), and a
// leaderboard row's `lastSeen`, which a tally refreshes every minute while
// somebody plays. All of them sit behind this one setting.
//
// ── The audiences ─────────────────────────────────────────────────────────
//
// staff an admin or a moderator — the two roles every Team surface in
// core also means by "staff"
// signed_in any active website account
// public anybody, signed in or not
//
// Ordered, each rung implying the ones below it. The names line up with phase
// 14's map-layer switches (public / players / admin) so that one layer can take
// this over rather than sit beside it.
//
// ── Two fallbacks, deliberately asymmetric ────────────────────────────────
//
// An unrecognised VIEWER reads as the bottom rung and an unrecognised
// REQUIREMENT reads as the top one, so a value nobody expected always loses.
// One shared fallback cannot do that: whichever way it points, it fails open on
// one side. module-uo's shard visibility learned this the hard way; the rule is
// copied here rather than rediscovered.
const core = require('../../core')
const db = require('./visibility.db')
const log = core.logger('visibility')
const AUDIENCES = Object.freeze(['public', 'signed_in', 'staff'])
const RANK = new Map(AUDIENCES.map((a, i) => [a, i]))
/** The narrowest rung, and the default wherever nothing has been chosen. */
const DEFAULT_PRESENCE = 'staff'
/** The `rust_settings` key the fleet default lives under. */
const PRESENCE_KEY = 'presence.audience'
const isAudience = (value) => RANK.has(value)
const viewerRank = (level) => RANK.get(level) ?? 0
const requiredRank = (level) => RANK.get(level) ?? RANK.get('staff')
/** Does a viewer at `viewer` satisfy a requirement of `required`? */
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
/**
* The viewer's rung, re-read from the database.
*
* `getUserFromRequest` decodes a token and nothing more — the role in it is the
* role the account had when it signed in. For a gate on who may see who is
* online, that is not good enough: a moderator demoted this morning would keep
* the roll call until their token expired, and a banned account would keep
* reading it too. So the token only says WHO; the row says what they are now.
*
* Any failure resolves to `public` — the bottom rung — because an unanswerable
* question about somebody's standing must grant nothing.
*/
async function viewerLevel(req) {
try {
const claimed = req.user || core.auth.getUserFromRequest(req)
if (!claimed || claimed.id == null) return 'public'
const user = await core.users.getById(claimed.id)
if (!user) return 'public'
if (user.status && user.status !== 'active') return 'public'
if (user.role === 'admin' || user.role === 'moderator') return 'staff'
return 'signed_in'
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return 'public'
}
}
/** A stored value as an audience, narrowing anything this build does not recognise. */
function normalise(value) {
return isAudience(value) ? value : DEFAULT_PRESENCE
}
/** The fleet default. */
async function fleetPresence() {
const stored = await db.getSetting(PRESENCE_KEY)
return stored == null ? DEFAULT_PRESENCE : normalise(stored)
}
/**
* The audience that applies to one server: its override if it has one, the
* fleet default otherwise.
*
* A server that does not exist gets the fleet default, which is the right answer
* for the routes that call this: they answer an empty list for an unknown id,
* and an empty list is empty at every rung.
*/
async function presenceFor(serverId) {
const override = await db.getServerPresence(serverId)
if (override != null) return normalise(override)
return fleetPresence()
}
/**
* Everything a public route needs in one call: may this viewer see who is on
* this server?
*
* Throws nothing. A setting that cannot be read resolves to "no" — the routes
* that ask would otherwise have to choose between a 500 and publishing names.
*/
async function canSeePresence(req, serverId) {
try {
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 }
}
}
/** The admin screen's read: the fleet default and every server beside it. */
async function describe() {
const [fleet, servers] = await Promise.all([fleetPresence(), db.listServerPresence()])
return {
audiences: [...AUDIENCES],
presence: {
fleet,
servers: servers.map((s) => {
const override = s.presence == null ? null : normalise(s.presence)
return {
id: s.id,
name: s.name,
enabled: Boolean(s.enabled),
override,
effective: override || fleet,
}
}),
},
}
}
/**
* The admin screen's write.
*
* `fleet` is optional; `servers` maps an id to an audience, or to `null` to
* clear its override. Validated whole before anything is written, so a request
* naming one unknown server changes nothing rather than half of what it asked.
*
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
* sentence the page can show.
*/
async function update({ fleet, servers } = {}, actor = null) {
if (fleet !== undefined && !isAudience(fleet)) {
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
}
const changes = Object.entries(servers || {})
for (const [id, value] of changes) {
if (value !== null && !isAudience(value)) {
return { ok: false, status: 400, message: `"${value}" is not an audience for server ${id}.` }
}
// eslint-disable-next-line no-await-in-loop
if ((await db.getServerPresence(id)) === undefined) {
return { ok: false, status: 404, message: `There is no server called ${id}.` }
}
}
const userId = actor && actor.id != null ? actor.id : null
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
for (const [id, value] of changes) {
// eslint-disable-next-line no-await-in-loop
await db.setServerPresence(id, value)
}
// What was written, for the controller's audit row. Recorded there rather than
// here because the activity log takes the REQUEST (who, from where), and a
// model that took a request would be a model that could only be called by one.
return {
ok: true,
changed: {
...(fleet !== undefined ? { fleet } : {}),
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
},
}
}
module.exports = {
AUDIENCES,
DEFAULT_PRESENCE,
PRESENCE_KEY,
isAudience,
meets,
normalise,
viewerLevel,
fleetPresence,
presenceFor,
canSeePresence,
describe,
update,
}

View File

@@ -37,6 +37,11 @@ adminRustRouter.use('/permissions', require('./permissions.router'))
// and this one edits the game host's own plugin settings.
adminRustRouter.use('/config', require('./config.router'))
// Who may see who is online, under `/rust/visibility`. The org lead's rule is
// that nothing names who is online by default; this is where an operator
// deliberately widens it, fleet-wide or for one server.
adminRustRouter.use('/visibility', require('./visibility.router'))
adminRustRouter.get(
'/servers',
// #swagger.tags = ['Admin · Rust']

View File

@@ -0,0 +1,39 @@
// ── Admin · Rust · Visibility — the handlers ──────────────────────────────
const core = require('../../core')
const visibility = require('../../model/visibility/visibility.model')
const log = core.logger('visibility')
async function read(req, res) {
try {
res.json(await visibility.describe())
} catch (err) {
log.error('failed to read visibility settings', { error: err.message })
res.status(500).json({ message: 'Failed to read the visibility settings' })
}
}
async function update(req, res) {
try {
const { fleet, servers } = req.body || {}
const result = await visibility.update({ fleet, servers }, req.user)
if (!result.ok) {
res.status(result.status || 400).json({ message: result.message })
return
}
// One row per save, naming everything it changed. Widening who may see the
// roll call is exactly the kind of change somebody later needs to trace to a
// person and a time.
await core.activity.log({ req, action: 'rust.visibility.save', detail: result.changed })
res.json(await visibility.describe())
} catch (err) {
log.error('failed to save visibility settings', { error: err.message })
res.status(500).json({ message: 'Failed to save the visibility settings' })
}
}
module.exports = { read, update }

View File

@@ -0,0 +1,51 @@
// ── Admin · Rust · Visibility ─────────────────────────────────────────────
//
// Mounted under the admin tier's `/rust` prefix, so every path here is
// `/api/v1/admin/rust/visibility`. Who may see what the servers say about the
// people on them — a fourth subject beside the bridge, the permissions and the
// mod configuration.
//
// **Every route is `requireRole('admin')`.** The tier's own gate admits editors
// and moderators, and a moderator widening the roll call to the public is the
// decision the org lead settled should be deliberate. Reading is gated the same
// as writing: the screen is one form, and a view of the settings without the
// power to change them is not something anybody has asked for.
const core = require('../../core')
const express = core.express
const visibility = require('./visibility.controller')
const { requireRole, validate } = core.middleware
const { body } = core.validator
const visibilityRouter = express.Router()
const AUDIENCES = ['staff', 'signed_in', 'public']
visibilityRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Who may see who is online'
// #swagger.description = 'The fleet default and every servers optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboards `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting.'
/* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
requireRole('admin'),
visibility.read,
)
visibilityRouter.put(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change who may see who is online'
// #swagger.description = 'Sets the fleet default, one or more server overrides, or both. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
/* #swagger.responses[404] = { description: 'A server that does not exist' } */
requireRole('admin'),
body('fleet').optional().isIn(AUDIENCES).withMessage(`fleet must be one of ${AUDIENCES.join(', ')}`),
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
validate,
visibility.update,
)
module.exports = visibilityRouter

View File

@@ -13,9 +13,24 @@ const core = require('../../core')
const events = require('../../model/events/events.model')
const servers = require('../../model/servers/servers.model')
const visibility = require('../../model/visibility/visibility.model')
const log = core.logger('public')
/**
* Marks a response as depending on who asked.
*
* Three routes below answer differently for a moderator and for a stranger, and
* a shared cache in front of the site that stored the moderator's answer would
* hand the roll call to the next anonymous visitor. `private` keeps it out of
* every cache but the viewer's own; `Vary` says why, for any cache that reads it.
*/
function perViewer(res) {
res.set('Cache-Control', 'private, no-store')
res.vary('Cookie')
res.vary('Authorization')
}
async function listServers(req, res) {
try {
res.json({ servers: await servers.listPublic() })
@@ -56,16 +71,26 @@ async function getServer(req, res) {
* handler.** `events.recent` takes the viewer explicitly and defaults to the
* public allowlist, so the way to leak an IP address from here is to add an
* argument rather than to forget one.
*
* `presence` is resolved per request from the operator's setting. Below it, the
* feed carries only what names nobody — a wipe, a start, a shutdown — and says
* so with `presenceHidden`, so a page can explain a quiet feed instead of
* implying a quiet server.
*/
async function listEvents(req, res) {
try {
const presence = await visibility.canSeePresence(req, req.params.id)
perViewer(res)
res.json({
events: await events.recent({
serverId: req.params.id,
presence: presence.visible,
kind: req.query.kind,
wipeId: req.query.wipe || null,
limit: req.query.limit,
}),
presenceHidden: !presence.visible,
presenceAudience: presence.required,
})
} catch (err) {
log.error('failed to read events', { server: req.params.id, error: err.message })
@@ -75,12 +100,15 @@ async function listEvents(req, res) {
async function listLeaderboard(req, res) {
try {
const presence = await visibility.canSeePresence(req, req.params.id)
perViewer(res)
res.json({
leaderboard: await events.leaderboard({
serverId: req.params.id,
wipeId: req.query.wipe || null,
sort: req.query.sort,
limit: req.query.limit,
presence: presence.visible,
}),
})
} catch (err) {
@@ -98,9 +126,34 @@ async function listWipes(req, res) {
}
}
/**
* Who is on the server right now — or, below the operator's audience, how many.
*
* The count stays public: it is already on the server list and in the footer,
* and a number names nobody. The names do not, by default (the org lead's rule,
* `model/visibility`). A hidden answer is still a 200 with the same shape — an
* empty `players` array — plus `hidden` and `count`, so a client that predates
* the flag renders an empty list rather than breaking, and a current one can say
* "12 online" instead of "nobody".
*/
async function listOnline(req, res) {
try {
res.json({ players: await events.online(req.params.id) })
const presence = await visibility.canSeePresence(req, req.params.id)
perViewer(res)
if (!presence.visible) {
const server = await servers.getPublic(req.params.id)
res.json({
players: [],
hidden: true,
count: server ? server.players : 0,
audience: presence.required,
})
return
}
const players = await events.online(req.params.id)
res.json({ players, hidden: false, count: players.length, audience: presence.required })
} catch (err) {
log.error('failed to read presence', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read who is online' })

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.'
// #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 operators presence audience, which defaults to staff; `presenceHidden` says when they were withheld.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The servers 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' } }
@@ -82,7 +82,7 @@ rustRouter.get(
'/servers/:id/leaderboard',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The leaderboard for one Rust server'
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a players history without ending it.'
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a players history without ending it. `lastSeen` is withheld below the operators presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The servers slug', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills or playtime', schema: { type: 'string' } }
@@ -107,9 +107,9 @@ rustRouter.get(
'/servers/:id/online',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'Who is on one Rust server right now'
// #swagger.description = 'Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one.'
// #swagger.description = 'Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one. **Nothing names who is online by default**: below the operators presence audience (staff unless widened) the names are withheld and only `count` is answered.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The servers slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'Who is online' } */
/* #swagger.responses[200] = { description: 'Who is online — or, below the operators presence audience, only how many', content: { "application/json": { schema: { $ref: "#/components/schemas/RustOnline" } } } } */
siteMode,
servers.listOnline,
)

View File

@@ -478,6 +478,77 @@ module.exports = {
},
},
},
RustOnline: {
type: 'object',
description: 'Who is on one server (GET /public/rust/servers/{id}/online). Below the operators presence audience the names are withheld and only the count is answered — nothing names who is online by default.',
properties: {
players: {
type: 'array',
description: 'Empty whenever `hidden` is true.',
items: {
type: 'object',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: { type: 'string', nullable: true, example: 'Wanderer' },
sleeping: { type: 'boolean', example: false },
connectedAt: { type: 'string', nullable: true },
},
},
},
hidden: { type: 'boolean', description: 'Were the names withheld from this viewer?', example: true },
count: { type: 'integer', description: 'How many are online. Public at every audience.', example: 12 },
audience: { $ref: '#/components/schemas/RustAudience' },
},
},
RustAudience: {
type: 'string',
enum: ['staff', 'signed_in', 'public'],
description: 'Who may see something: admins and moderators, any signed-in account, or anybody. Ordered — each includes the ones before it.',
example: 'staff',
},
RustVisibility: {
type: 'object',
description: 'Who may see who is online: the fleet default and each servers optional override (GET /admin/rust/visibility).',
properties: {
audiences: { type: 'array', items: { $ref: '#/components/schemas/RustAudience' } },
presence: {
type: 'object',
properties: {
fleet: { $ref: '#/components/schemas/RustAudience' },
servers: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
enabled: { type: 'boolean', example: true },
override: {
type: 'string',
nullable: true,
enum: ['staff', 'signed_in', 'public', null],
description: 'This servers own choice, or null to follow the fleet default.',
},
effective: { $ref: '#/components/schemas/RustAudience' },
},
},
},
},
},
},
},
RustVisibilityUpdate: {
type: 'object',
description: 'A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again.',
properties: {
fleet: { $ref: '#/components/schemas/RustAudience' },
servers: {
type: 'object',
additionalProperties: { type: 'string', nullable: true, enum: ['staff', 'signed_in', 'public', null] },
example: { main: 'public', pvp: null },
},
},
},
RustSidecarProbe: {
type: 'object',
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',

View File

@@ -53,6 +53,9 @@ function fakeCtx(overrides = {}) {
return log
},
auth: { getUserFromRequest: spy(null) },
// One user by id. Null by default — an anonymous suite resolves nobody —
// and a test that needs a viewer installs its own.
users: { getById: spy(Promise.resolve(null)) },
// The engagement seam (§2.3). One method, recording, because that is the
// whole of what a module may do with it: fire a declared event and stop.
// Core's own emit is fire-and-forget and returns nothing, so this does too —

View File

@@ -51,7 +51,12 @@ test('a viewer with no kinds asked for gets the allowlist, never everything', ()
const asPublic = catalogue.kindsFor({})
const asAdmin = catalogue.kindsFor({ admin: true })
assert.deepEqual(asPublic, [...catalogue.PUBLIC_KINDS])
// The public view with nothing said about presence is the kinds that name
// nobody — a wipe, a start, a shutdown.
assert.deepEqual(
asPublic,
catalogue.PUBLIC_KINDS.filter((k) => !catalogue.PRESENCE_KINDS.includes(k)),
)
assert.equal(asAdmin.length, catalogue.ALL_KINDS.length)
// The property that makes the route safe by construction: there is no argument
@@ -61,10 +66,13 @@ test('a viewer with no kinds asked for gets the allowlist, never everything', ()
})
test('a kind a viewer may not see is dropped, not refused', () => {
const asked = catalogue.kindsFor({ requested: ['player.death', 'player.banned'] })
const asked = catalogue.kindsFor({ presence: true, requested: ['player.death', 'player.banned'] })
assert.deepEqual(asked, ['player.death'])
// Without the presence audience a death is dropped too.
assert.deepEqual(catalogue.kindsFor({ requested: ['player.death', 'server.wipe'] }), ['server.wipe'])
// Asking for only forbidden kinds answers with nothing to select, which the
// model turns into an empty list — the events are, as far as this viewer is
// concerned, not there.
@@ -116,3 +124,26 @@ test('the classification covers exactly the kinds protocol 4 defines', () => {
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
})
test('every kind that names a player who was on is behind the presence setting', () => {
// The org lead's rule (2026-09-22): nothing tells who is online by default.
// Each of these says a named player was on the server at a given moment.
for (const kind of [
'player.connected',
'player.disconnected',
'player.respawned',
'player.death',
'player.chat',
'player.tally',
]) {
assert.ok(catalogue.isPresence(kind), `${kind} must be gated as presence`)
assert.ok(!catalogue.kindsFor({}).includes(kind), `${kind} must not reach a default public view`)
assert.ok(catalogue.kindsFor({ presence: true }).includes(kind))
}
// A presence kind is a subset of the public ones, never a staff kind widened.
for (const kind of catalogue.PRESENCE_KINDS) assert.ok(catalogue.PUBLIC_KINDS.includes(kind))
// And what is left names nobody.
assert.deepEqual(catalogue.kindsFor({}).sort(), ['server.initialized', 'server.shutdown', 'server.wipe'])
})

View File

@@ -60,7 +60,14 @@ test('a reader who does not say who they are gets the public view', async () =>
await model.recent({ serverId: 'main' })
assert.ok(!asked.kinds.includes('player.banned'), 'no IP-carrying kind by default')
assert.ok(asked.kinds.includes('player.death'))
// Nor anything naming a player who was on — the org lead's rule, and a caller
// that forgets to say what the viewer may see gets the narrowest answer.
assert.ok(!asked.kinds.includes('player.death'), 'no presence kind by default')
assert.ok(asked.kinds.includes('server.wipe'))
await model.recent({ serverId: 'main', presence: true })
assert.ok(asked.kinds.includes('player.death'), 'a viewer inside the presence audience gets the killfeed')
assert.ok(!asked.kinds.includes('player.banned'), 'presence never widens to staff kinds')
await model.recent({ serverId: 'main', admin: true })
assert.ok(asked.kinds.includes('player.banned'), 'an admin who says so gets them')
@@ -142,3 +149,24 @@ test('the leaderboard answers numbers, never nulls', async () => {
db.leaderboard = original
}
})
test('the leaderboard withholds lastSeen unless the viewer may see who is online', async () => {
withCore()
const db = require('../model/events/events.db')
const model = require('../model/events/events.model')
const original = db.leaderboard
db.leaderboard = async () => [{ steamId: '7656', name: 'A', kills: 3, lastSeen: '2026-09-22T10:00:00Z' }]
try {
const hidden = await model.leaderboard({ serverId: 'main' })
assert.equal('lastSeen' in hidden[0], false, 'absent, not null — null would read as "never seen"')
assert.equal(hidden[0].kills, 3)
const shown = await model.leaderboard({ serverId: 'main', presence: true })
assert.equal(shown[0].lastSeen, '2026-09-22T10:00:00Z')
} finally {
db.leaderboard = original
}
})

View File

@@ -0,0 +1,262 @@
// ── Who may see who is online ─────────────────────────────────────────────
//
// The org lead's rule (2026-09-22): nothing tells who is online by default. The
// suite holds the four properties that make that rule true rather than merely
// intended:
//
// • an install nobody has configured answers STAFF;
// • the viewer's standing comes from the ROW, not the token — a demotion or a
// ban takes effect on the next request;
// • anything unrecognised or unanswerable narrows, never widens;
// • the public routes answer the count and withhold the names.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, spy } = require('./_fakes')
/**
* The model with a stubbed db and a chosen viewer.
*
* `claimed` is what the token says; `row` is what the users table says now.
*/
function setup({ fleet = null, overrides = {}, claimed = null, row = null, usersThrow = false } = {}) {
require('../core')._reset()
require('../core').init(
fakeCtx({
auth: { getUserFromRequest: () => claimed },
users: {
getById: async () => {
if (usersThrow) throw new Error('pool exhausted')
return row
},
},
}),
)
const db = require('../model/visibility/visibility.db')
const model = require('../model/visibility/visibility.model')
const written = { settings: [], servers: [] }
const originals = { ...db }
db.getSetting = async () => fleet
db.setSetting = async (key, value, userId) => written.settings.push({ key, value, userId })
db.getServerPresence = async (id) => (id in overrides ? overrides[id] : undefined)
db.listServerPresence = async () =>
Object.entries(overrides).map(([id, presence]) => ({ id, name: id.toUpperCase(), enabled: 1, presence }))
db.setServerPresence = async (id, value) => written.servers.push({ id, value })
return { model, written, restore: () => Object.assign(db, originals) }
}
test('an install nobody has configured shows the roll call to staff and nobody else', async () => {
const { model, restore } = setup({ overrides: { main: null } })
try {
assert.equal(await model.fleetPresence(), 'staff')
assert.equal(await model.presenceFor('main'), 'staff')
assert.equal((await model.canSeePresence({}, 'main')).visible, false, 'anonymous')
} finally {
restore()
}
})
test('the standing comes from the row, not the token', async () => {
// The token says moderator; the row says they were demoted this morning.
const demoted = setup({ claimed: { id: 4, role: 'moderator' }, row: { id: 4, role: 'player', status: 'active' } })
try {
assert.equal(await demoted.model.viewerLevel({}), 'signed_in')
} finally {
demoted.restore()
}
// The token says admin; the account has been banned since.
const banned = setup({ claimed: { id: 4, role: 'admin' }, row: { id: 4, role: 'admin', status: 'banned' } })
try {
assert.equal(await banned.model.viewerLevel({}), 'public')
} finally {
banned.restore()
}
const moderator = setup({ claimed: { id: 5 }, row: { id: 5, role: 'moderator', status: 'active' } })
try {
assert.equal(await moderator.model.viewerLevel({}), 'staff')
} finally {
moderator.restore()
}
})
test('a viewer who cannot be resolved is anonymous', async () => {
const gone = setup({ claimed: { id: 9 }, row: null })
try {
assert.equal(await gone.model.viewerLevel({}), 'public')
} finally {
gone.restore()
}
const failing = setup({ claimed: { id: 9 }, usersThrow: true })
try {
assert.equal(await failing.model.viewerLevel({}), 'public')
} finally {
failing.restore()
}
})
test('a stored value this build does not recognise narrows to staff', async () => {
const { model, restore } = setup({ fleet: 'everyone', overrides: { main: 'PUBLIC', pvp: null } })
try {
assert.equal(await model.fleetPresence(), 'staff')
assert.equal(await model.presenceFor('main'), 'staff', 'a mis-cased word is not "public"')
assert.equal(await model.presenceFor('pvp'), 'staff', 'inherits the (narrowed) fleet default')
} finally {
restore()
}
})
test('a server override wins over the fleet, and null inherits it', async () => {
const { model, restore } = setup({
fleet: 'signed_in',
overrides: { main: 'public', pvp: 'staff', creative: null },
claimed: { id: 4 },
row: { id: 4, role: 'player', status: 'active' },
})
try {
assert.equal(await model.presenceFor('main'), 'public')
assert.equal(await model.presenceFor('pvp'), 'staff')
assert.equal(await model.presenceFor('creative'), 'signed_in')
// A signed-in player sees main and creative, not pvp.
assert.equal((await model.canSeePresence({}, 'main')).visible, true)
assert.equal((await model.canSeePresence({}, 'creative')).visible, true)
assert.equal((await model.canSeePresence({}, 'pvp')).visible, false)
const described = await model.describe()
const byId = Object.fromEntries(described.presence.servers.map((s) => [s.id, s]))
assert.equal(byId.creative.override, null)
assert.equal(byId.creative.effective, 'signed_in')
assert.equal(byId.pvp.effective, 'staff')
} finally {
restore()
}
})
test('an update naming an unknown audience or server writes nothing at all', async () => {
const { model, written, restore } = setup({ overrides: { main: null } })
try {
const badAudience = await model.update({ fleet: 'public', servers: { main: 'everyone' } })
assert.equal(badAudience.ok, false)
assert.equal(badAudience.status, 400)
const badServer = await model.update({ fleet: 'public', servers: { main: 'public', nope: 'public' } })
assert.equal(badServer.ok, false)
assert.equal(badServer.status, 404)
assert.match(badServer.message, /nope/)
assert.deepEqual(written, { settings: [], servers: [] }, 'validated whole before anything was written')
const ok = await model.update({ fleet: 'signed_in', servers: { main: null } }, { id: 1 })
assert.equal(ok.ok, true)
assert.deepEqual(written.settings, [{ key: 'presence.audience', value: 'signed_in', userId: 1 }])
assert.deepEqual(written.servers, [{ id: 'main', value: null }])
assert.deepEqual(ok.changed, { fleet: 'signed_in', servers: { main: 'inherit' } })
} finally {
restore()
}
})
// ── The public routes ─────────────────────────────────────────────────────
/** A response double recording what a handler answered. */
function fakeRes() {
const res = {
statusCode: 200,
headers: {},
varied: [],
body: undefined,
status(code) { this.statusCode = code; return this },
json(body) { this.body = body; return this },
set(name, value) { this.headers[name.toLowerCase()] = value; return this },
vary(name) { this.varied.push(name); return this },
}
return res
}
function withPresence(visible) {
const visibility = require('../model/visibility/visibility.model')
const events = require('../model/events/events.model')
const servers = require('../model/servers/servers.model')
const originals = {
canSeePresence: visibility.canSeePresence,
online: events.online,
getPublic: servers.getPublic,
}
visibility.canSeePresence = async () => ({ visible, level: visible ? 'staff' : 'public', required: 'staff' })
events.online = spy(Promise.resolve([{ steamId: '7656', name: 'Wanderer', sleeping: false, connectedAt: null }]))
servers.getPublic = async () => ({ id: 'main', players: 12 })
return {
events,
restore: () => {
visibility.canSeePresence = originals.canSeePresence
events.online = originals.online
servers.getPublic = originals.getPublic
},
}
}
test('below the audience, the Online list answers the count and never reads the names', async () => {
require('../core')._reset()
require('../core').init(fakeCtx())
const { events, restore } = withPresence(false)
try {
const controller = require('../router/public/rust.controller')
const res = fakeRes()
await controller.listOnline({ params: { id: 'main' } }, res)
assert.deepEqual(res.body, { players: [], hidden: true, count: 12, audience: 'staff' })
assert.equal(events.online.calls.length, 0, 'the names are not even read')
assert.equal(res.headers['cache-control'], 'private, no-store', 'a per-viewer answer must not be shared by a cache')
} finally {
restore()
}
})
test('inside the audience, the Online list names the players', async () => {
require('../core')._reset()
require('../core').init(fakeCtx())
const { restore } = withPresence(true)
try {
const controller = require('../router/public/rust.controller')
const res = fakeRes()
await controller.listOnline({ params: { id: 'main' } }, res)
assert.equal(res.body.hidden, false)
assert.equal(res.body.players[0].name, 'Wanderer')
} finally {
restore()
}
})
test('below the audience, the feed says it withheld the players rather than implying a quiet server', async () => {
require('../core')._reset()
require('../core').init(fakeCtx())
const { restore } = withPresence(false)
const events = require('../model/events/events.model')
const original = events.recent
let asked = null
events.recent = async (args) => {
asked = args
return []
}
try {
const controller = require('../router/public/rust.controller')
const res = fakeRes()
await controller.listEvents({ params: { id: 'main' }, query: {} }, res)
assert.equal(asked.presence, false)
assert.equal(asked.admin, undefined, 'the public route never passes admin')
assert.equal(res.body.presenceHidden, true)
assert.equal(res.body.presenceAudience, 'staff')
} finally {
events.recent = original
restore()
}
})

View File

@@ -683,6 +683,68 @@
}
}
},
"/api/v1/admin/rust/visibility": {
"get": {
"tags": [
"Admin · Rust"
],
"summary": "Who may see who is online",
"description": "The fleet default and every servers optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboards `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting.",
"responses": {
"200": {
"description": "The fleet default and each server",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustVisibility"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
},
"put": {
"tags": [
"Admin · Rust"
],
"summary": "Change who may see who is online",
"description": "Sets the fleet default, one or more server overrides, or both. A server set to `null` follows the fleet default again. Validated whole before anything is written: a request naming a server that does not exist changes nothing.",
"responses": {
"200": {
"description": "Saved; answers the new state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustVisibility"
}
}
}
},
"400": {
"description": "An audience that does not exist"
},
"404": {
"description": "A server that does not exist"
},
"500": {
"description": "Internal Server Error"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustVisibilityUpdate"
}
}
}
}
}
},
"/api/v1/admin/users/{id}/rust/links": {
"get": {
"tags": [
@@ -1245,7 +1307,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.",
"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 operators presence audience, which defaults to staff; `presenceHidden` says when they were withheld.",
"parameters": [
{
"name": "id",
@@ -1300,7 +1362,7 @@
"Public · Rust"
],
"summary": "The leaderboard for one Rust server",
"description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a players history without ending it.",
"description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a players history without ending it. `lastSeen` is withheld below the operators presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online.",
"parameters": [
{
"name": "id",
@@ -1355,7 +1417,7 @@
"Public · Rust"
],
"summary": "Who is on one Rust server right now",
"description": "Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one.",
"description": "Read from the presence board the bridge re-sends on every connect and every minute, rather than counted from connect and disconnect events — so it is correct even after the website has missed one. **Nothing names who is online by default**: below the operators presence audience (staff unless widened) the names are withheld and only `count` is answered.",
"parameters": [
{
"name": "id",
@@ -1369,7 +1431,14 @@
],
"responses": {
"200": {
"description": "Who is online"
"description": "Who is online — or, below the operators presence audience, only how many",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RustOnline"
}
}
}
},
"500": {
"description": "Internal Server Error"
@@ -3894,6 +3963,374 @@
}
}
},
"RustOnline": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Who is on one server (GET /public/rust/servers/{id}/online). Below the operators presence audience the names are withheld and only the count is answered — nothing names who is online by default."
},
"properties": {
"type": "object",
"properties": {
"players": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"description": {
"type": "string",
"example": "Empty whenever `hidden` is true."
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"steamId": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "76561198000000000"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"example": {
"type": "string",
"example": "Wanderer"
}
}
},
"sleeping": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": false
}
}
},
"connectedAt": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
}
}
}
}
}
}
}
}
},
"hidden": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Were the names withheld from this viewer?"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"count": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "How many are online. Public at every audience."
},
"example": {
"type": "number",
"example": 12
}
}
},
"audience": {
"$ref": "#/components/schemas/RustAudience"
}
}
}
}
},
"RustAudience": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"staff",
"signed_in",
"public"
],
"items": {
"type": "string"
}
},
"description": {
"type": "string",
"example": "Who may see something: admins and moderators, any signed-in account, or anybody. Ordered — each includes the ones before it."
},
"example": {
"type": "string",
"example": "staff"
}
}
},
"RustVisibility": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "Who may see who is online: the fleet default and each servers optional override (GET /admin/rust/visibility)."
},
"properties": {
"type": "object",
"properties": {
"audiences": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"$ref": "#/components/schemas/RustAudience"
}
}
},
"presence": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"fleet": {
"$ref": "#/components/schemas/RustAudience"
},
"servers": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "array"
},
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "main"
}
}
},
"name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "Main · Vanilla"
}
}
},
"enabled": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"example": {
"type": "boolean",
"example": true
}
}
},
"override": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"enum": {
"type": "array",
"example": [
"staff",
"signed_in",
"public",
null
],
"items": {}
},
"description": {
"type": "string",
"example": "This servers own choice, or null to follow the fleet default."
}
}
},
"effective": {
"$ref": "#/components/schemas/RustAudience"
}
}
}
}
}
}
}
}
}
}
}
}
}
}
},
"RustVisibilityUpdate": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "A change to who may see who is online. Either part may be omitted; a server set to null follows the fleet default again."
},
"properties": {
"type": "object",
"properties": {
"fleet": {
"$ref": "#/components/schemas/RustAudience"
},
"servers": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"additionalProperties": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"enum": {
"type": "array",
"example": [
"staff",
"signed_in",
"public",
null
],
"items": {}
}
}
},
"example": {
"type": "object",
"properties": {
"main": {
"type": "string",
"example": "public"
},
"pvp": {}
}
}
}
}
}
}
}
},
"RustSidecarProbe": {
"type": "object",
"properties": {