Merge pull request 'fix(rust): nothing names who is online by default' (#11) from fix/presence-visibility into edge
Reviewed-on: #11
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
26
client/src/components/Empty.jsx
Normal file
26
client/src/components/Empty.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}.`
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
196
client/src/routes/admin/Visibility.jsx
Normal file
196
client/src/routes/admin/Visibility.jsx
Normal 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',
|
||||
}
|
||||
@@ -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."
|
||||
/>
|
||||
|
||||
49
client/test/uiKitProps.test.js
Normal file
49
client/test/uiKitProps.test.js
Normal 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')
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
55
server/model/visibility/visibility.db.js
Normal file
55
server/model/visibility/visibility.db.js
Normal 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 }
|
||||
207
server/model/visibility/visibility.model.js
Normal file
207
server/model/visibility/visibility.model.js
Normal 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,
|
||||
}
|
||||
@@ -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']
|
||||
|
||||
39
server/router/admin/visibility.controller.js
Normal file
39
server/router/admin/visibility.controller.js
Normal 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 }
|
||||
51
server/router/admin/visibility.router.js
Normal file
51
server/router/admin/visibility.router.js
Normal 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 server’s 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 leaderboard’s `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
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 operator’s presence audience, which defaults to staff; `presenceHidden` says when they were withheld.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['kind'] = { in: 'query', required: false, description: 'One kind, or several comma-separated', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
@@ -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 player’s 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 player’s history without ending it. `lastSeen` is withheld below the operator’s 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 server’s 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 operator’s presence audience (staff unless widened) the names are withheld and only `count` is answered.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'Who is online' } */
|
||||
/* #swagger.responses[200] = { description: 'Who is online — or, below the operator’s presence audience, only how many', content: { "application/json": { schema: { $ref: "#/components/schemas/RustOnline" } } } } */
|
||||
siteMode,
|
||||
servers.listOnline,
|
||||
)
|
||||
|
||||
@@ -478,6 +478,77 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
RustOnline: {
|
||||
type: 'object',
|
||||
description: 'Who is on one server (GET /public/rust/servers/{id}/online). Below the operator’s 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 server’s 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 server’s 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).',
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
262
server/test/visibility.test.js
Normal file
262
server/test/visibility.test.js
Normal 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()
|
||||
}
|
||||
})
|
||||
@@ -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 server’s 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 leaderboard’s `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 operator’s 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 player’s 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 player’s history without ending it. `lastSeen` is withheld below the operator’s 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 operator’s 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 operator’s 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 operator’s 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 server’s 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 server’s 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": {
|
||||
|
||||
Reference in New Issue
Block a user