fix(rust): nothing names who is online by default #11
@@ -42,10 +42,18 @@ rows here; the website core never learns there is more than one.
|
|||||||
| Public | `GET …/servers/:id/wipes` and `…/online` |
|
| Public | `GET …/servers/:id/wipes` and `…/online` |
|
||||||
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
|
| 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/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` — the server list, and the module's landing page |
|
||||||
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes |
|
| 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 |
|
| 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
|
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
|
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.
|
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 } : {} }),
|
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) ───────────────────────────────────────
|
// ── admin · mod configuration (R18) ───────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Every call here is a LIVE round trip to a game host, which makes this the only
|
// Every call here is a LIVE round trip to a game host, which makes this the only
|
||||||
@@ -226,6 +237,7 @@ export default {
|
|||||||
admin,
|
admin,
|
||||||
adminPermissions,
|
adminPermissions,
|
||||||
adminConfig,
|
adminConfig,
|
||||||
|
adminVisibility,
|
||||||
adminUserLinks,
|
adminUserLinks,
|
||||||
adminUserPermissions,
|
adminUserPermissions,
|
||||||
BASE,
|
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
|
// see the comment at the top of that file for why core's `useAsync` cannot do
|
||||||
// this job.
|
// 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 { describe, FILTERS, kindsFor } from '../lib/feed.js'
|
||||||
import { ago, clock } from '../lib/format.js'
|
import { ago, clock } from '../lib/format.js'
|
||||||
import usePolled from '../hooks/usePolled.js'
|
import usePolled from '../hooks/usePolled.js'
|
||||||
import api from '../api.js'
|
import api from '../api.js'
|
||||||
|
import { hiddenMessage } from './Online.jsx'
|
||||||
|
|
||||||
const TONE = {
|
const TONE = {
|
||||||
kill: 'var(--accent-bright)',
|
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. */}
|
off" must not blank itself the first time a request does. */}
|
||||||
{error && !data && <ErrorState error={error} />}
|
{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 && (
|
{data && events.length === 0 && (
|
||||||
<EmptyState
|
<Empty
|
||||||
title="Nothing here yet"
|
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
|
// than one that is four minutes old, and the page has a `Refresh` on the tab
|
||||||
// strip for anybody who disagrees.
|
// 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 { ago, count, duration, shortId } from '../lib/format.js'
|
||||||
import api from '../api.js'
|
import api from '../api.js'
|
||||||
|
|
||||||
@@ -32,13 +33,15 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const rows = data ? data.leaderboard : []
|
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 (loading) return <Loading />
|
||||||
if (error) return <ErrorState error={error} />
|
if (error) return <ErrorState error={error} />
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<Empty
|
||||||
title="No scores yet"
|
title="No scores yet"
|
||||||
message={
|
message={
|
||||||
wipeId
|
wipeId
|
||||||
@@ -80,7 +83,13 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
|||||||
)}
|
)}
|
||||||
</th>
|
</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>
|
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -98,7 +107,9 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
|||||||
{column.value(row)}
|
{column.value(row)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
{showLastSeen && (
|
||||||
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
// It polls with the feed, because "who is on" is the one thing on this page that
|
// It polls with the feed, because "who is on" is the one thing on this page that
|
||||||
// is a live question.
|
// 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 { duration, shortId } from '../lib/format.js'
|
||||||
import usePolled from '../hooks/usePolled.js'
|
import usePolled from '../hooks/usePolled.js'
|
||||||
import api from '../api.js'
|
import api from '../api.js'
|
||||||
@@ -25,9 +26,23 @@ export default function Online({ serverId, online }) {
|
|||||||
if (loading) return <Loading />
|
if (loading) return <Loading />
|
||||||
if (error && !data) return <ErrorState error={error} />
|
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) {
|
if (players.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<Empty
|
||||||
title={online ? 'Nobody is on' : 'The server is offline'}
|
title={online ? 'Nobody is on' : 'The server is offline'}
|
||||||
message={
|
message={
|
||||||
online
|
online
|
||||||
@@ -92,3 +107,16 @@ function sessionSoFar(connectedAt) {
|
|||||||
if (Number.isNaN(since)) return ''
|
if (Number.isNaN(since)) return ''
|
||||||
return duration((Date.now() - since) / 1000)
|
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
|
// id the events and the leaderboard filter by. There is no second derivation
|
||||||
// anywhere that could disagree.
|
// 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 { ago, day } from '../lib/format.js'
|
||||||
import api from '../api.js'
|
import api from '../api.js'
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export default function Wipes({ serverId, currentWipeId, selected, onSelect }) {
|
|||||||
|
|
||||||
if (wipes.length === 0) {
|
if (wipes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<Empty
|
||||||
title="No wipes recorded"
|
title="No wipes recorded"
|
||||||
message="A wipe appears here once this server has reported something during it."
|
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 Account from './routes/player/Account.jsx'
|
||||||
import Permissions from './routes/admin/Permissions.jsx'
|
import Permissions from './routes/admin/Permissions.jsx'
|
||||||
import ModConfig from './routes/admin/ModConfig.jsx'
|
import ModConfig from './routes/admin/ModConfig.jsx'
|
||||||
|
import Visibility from './routes/admin/Visibility.jsx'
|
||||||
import UserRustSections from './routes/admin/UserRustSections.jsx'
|
import UserRustSections from './routes/admin/UserRustSections.jsx'
|
||||||
import FooterStatus from './components/FooterStatus.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
|
// The module id, exactly as `module.json` spells it. Core keys the registry by it
|
||||||
// and prefixes every route path with 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
|
// `/admin/rust/config` and core's admin gate applies to it exactly as it
|
||||||
// does to the page above.
|
// does to the page above.
|
||||||
{ path: 'config', element: <ModConfig /> },
|
{ 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: [
|
items: [
|
||||||
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
|
{ label: 'Rust permissions', to: '/admin/rust', icon: IconKey },
|
||||||
{ label: 'Rust mod config', to: '/admin/rust/config', icon: IconSliders },
|
{ 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>
|
</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.
|
// is down. The site's availability does not depend on the game's.
|
||||||
|
|
||||||
import { Link } from 'react-router-dom'
|
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 { ago, count, day } from '../../lib/format.js'
|
||||||
import api from '../../api.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
|
empty game — it is an install that is not finished. Saying so beats a
|
||||||
blank page that looks like a failure. */}
|
blank page that looks like a failure. */}
|
||||||
{data && servers.length === 0 && (
|
{data && servers.length === 0 && (
|
||||||
<EmptyState
|
<Empty
|
||||||
title="No servers yet"
|
title="No servers yet"
|
||||||
message="An administrator adds a Rust server, and its sidecar, from the admin panel."
|
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",
|
"path": "/api/v1/admin/rust/servers",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/admin/rust/visibility",
|
||||||
|
"tier": "public"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/v1/admin/users/:id/rust/links",
|
"path": "/api/v1/admin/users/:id/rust/links",
|
||||||
@@ -175,6 +180,11 @@
|
|||||||
"method": "PUT",
|
"method": "PUT",
|
||||||
"path": "/api/v1/admin/rust/servers/:id",
|
"path": "/api/v1/admin/rust/servers/:id",
|
||||||
"tier": "public"
|
"tier": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/v1/admin/rust/visibility",
|
||||||
|
"tier": "public"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,11 +82,39 @@ const STAFF_KINDS = Object.freeze([
|
|||||||
'perm.drift',
|
'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. */
|
/** Every kind protocol 3 defines. */
|
||||||
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
const ALL_KINDS = Object.freeze([...PUBLIC_KINDS, ...STAFF_KINDS])
|
||||||
|
|
||||||
const PUBLIC = new Set(PUBLIC_KINDS)
|
const PUBLIC = new Set(PUBLIC_KINDS)
|
||||||
const STAFF = new Set(STAFF_KINDS)
|
const STAFF = new Set(STAFF_KINDS)
|
||||||
|
const PRESENCE = new Set(PRESENCE_KINDS)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* May a signed-out visitor see this kind?
|
* May a signed-out visitor see this kind?
|
||||||
@@ -103,15 +131,27 @@ function isKnown(kind) {
|
|||||||
return PUBLIC.has(kind) || STAFF.has(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.
|
* 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
|
* Returning the allowlist itself when nothing was requested is what makes the
|
||||||
* public route safe by construction rather than by remembering to filter: there
|
* public route safe by construction rather than by remembering to filter: there
|
||||||
* is no code path where "no filter" means "everything".
|
* 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 } = {}) {
|
function kindsFor({ admin = false, presence = false, requested = null } = {}) {
|
||||||
const permitted = admin ? ALL_KINDS : PUBLIC_KINDS
|
const permitted = admin
|
||||||
|
? ALL_KINDS
|
||||||
|
: PUBLIC_KINDS.filter((k) => presence || !PRESENCE.has(k))
|
||||||
|
|
||||||
if (!requested || requested.length === 0) return [...permitted]
|
if (!requested || requested.length === 0) return [...permitted]
|
||||||
|
|
||||||
@@ -122,8 +162,10 @@ function kindsFor({ admin = false, requested = null } = {}) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
PUBLIC_KINDS,
|
PUBLIC_KINDS,
|
||||||
STAFF_KINDS,
|
STAFF_KINDS,
|
||||||
|
PRESENCE_KINDS,
|
||||||
ALL_KINDS,
|
ALL_KINDS,
|
||||||
isPublic,
|
isPublic,
|
||||||
isKnown,
|
isKnown,
|
||||||
|
isPresence,
|
||||||
kindsFor,
|
kindsFor,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ module.exports = {
|
|||||||
// that needs an identity needs to *read* one.
|
// that needs an identity needs to *read* one.
|
||||||
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
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
|
// 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
|
// 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.
|
// stack. Routers are built inside `register()`, so `ctx` is set by then.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
-- registrant owned what.
|
-- registrant owned what.
|
||||||
|
|
||||||
-- Phase 7b.
|
-- Phase 7b.
|
||||||
|
DROP TABLE IF EXISTS rust_settings;
|
||||||
DROP TABLE IF EXISTS rust_config_writes;
|
DROP TABLE IF EXISTS rust_config_writes;
|
||||||
|
|
||||||
-- Phase 7. Children before parents: every one of these carries a foreign key
|
-- 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,
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
|
||||||
KEY idx_rust_config_writes_server (server_id, created_at)
|
KEY idx_rust_config_writes_server (server_id, created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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
|
* refused: naming it in an error would confirm the kind exists, which is a small
|
||||||
* thing to leak and a free one to avoid.
|
* thing to leak and a free one to avoid.
|
||||||
*/
|
*/
|
||||||
async function recent({ serverId, admin = false, kind = null, wipeId = null, limit }) {
|
async function recent({ serverId, admin = false, presence = false, kind = null, wipeId = null, limit }) {
|
||||||
const kinds = catalogue.kindsFor({ admin, requested: parseKinds(kind) })
|
const kinds = catalogue.kindsFor({ admin, presence, requested: parseKinds(kind) })
|
||||||
|
|
||||||
// Every requested kind was refused. Answering with an empty list is right —
|
// 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.
|
// 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
|
* 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.
|
* "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({
|
const rows = await db.leaderboard({
|
||||||
serverId,
|
serverId,
|
||||||
wipeId,
|
wipeId,
|
||||||
@@ -118,7 +118,11 @@ async function leaderboard({ serverId, wipeId = null, sort = 'kills', limit }) {
|
|||||||
npcKills: Number(r.npcKills) || 0,
|
npcKills: Number(r.npcKills) || 0,
|
||||||
structures: Number(r.structures) || 0,
|
structures: Number(r.structures) || 0,
|
||||||
playtimeSec: Number(r.playtimeSec) || 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.
|
// and this one edits the game host's own plugin settings.
|
||||||
adminRustRouter.use('/config', require('./config.router'))
|
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(
|
adminRustRouter.get(
|
||||||
'/servers',
|
'/servers',
|
||||||
// #swagger.tags = ['Admin · Rust']
|
// #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 events = require('../../model/events/events.model')
|
||||||
const servers = require('../../model/servers/servers.model')
|
const servers = require('../../model/servers/servers.model')
|
||||||
|
const visibility = require('../../model/visibility/visibility.model')
|
||||||
|
|
||||||
const log = core.logger('public')
|
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) {
|
async function listServers(req, res) {
|
||||||
try {
|
try {
|
||||||
res.json({ servers: await servers.listPublic() })
|
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
|
* 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
|
* public allowlist, so the way to leak an IP address from here is to add an
|
||||||
* argument rather than to forget one.
|
* 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) {
|
async function listEvents(req, res) {
|
||||||
try {
|
try {
|
||||||
|
const presence = await visibility.canSeePresence(req, req.params.id)
|
||||||
|
perViewer(res)
|
||||||
res.json({
|
res.json({
|
||||||
events: await events.recent({
|
events: await events.recent({
|
||||||
serverId: req.params.id,
|
serverId: req.params.id,
|
||||||
|
presence: presence.visible,
|
||||||
kind: req.query.kind,
|
kind: req.query.kind,
|
||||||
wipeId: req.query.wipe || null,
|
wipeId: req.query.wipe || null,
|
||||||
limit: req.query.limit,
|
limit: req.query.limit,
|
||||||
}),
|
}),
|
||||||
|
presenceHidden: !presence.visible,
|
||||||
|
presenceAudience: presence.required,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('failed to read events', { server: req.params.id, error: err.message })
|
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) {
|
async function listLeaderboard(req, res) {
|
||||||
try {
|
try {
|
||||||
|
const presence = await visibility.canSeePresence(req, req.params.id)
|
||||||
|
perViewer(res)
|
||||||
res.json({
|
res.json({
|
||||||
leaderboard: await events.leaderboard({
|
leaderboard: await events.leaderboard({
|
||||||
serverId: req.params.id,
|
serverId: req.params.id,
|
||||||
wipeId: req.query.wipe || null,
|
wipeId: req.query.wipe || null,
|
||||||
sort: req.query.sort,
|
sort: req.query.sort,
|
||||||
limit: req.query.limit,
|
limit: req.query.limit,
|
||||||
|
presence: presence.visible,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} 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) {
|
async function listOnline(req, res) {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
log.error('failed to read presence', { server: req.params.id, error: err.message })
|
log.error('failed to read presence', { server: req.params.id, error: err.message })
|
||||||
res.status(500).json({ message: 'Failed to read who is online' })
|
res.status(500).json({ message: 'Failed to read who is online' })
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ rustRouter.get(
|
|||||||
'/servers/:id/events',
|
'/servers/:id/events',
|
||||||
// #swagger.tags = ['Public · Rust']
|
// #swagger.tags = ['Public · Rust']
|
||||||
// #swagger.summary = 'Recent events on one Rust server'
|
// #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['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['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' } }
|
// #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',
|
'/servers/:id/leaderboard',
|
||||||
// #swagger.tags = ['Public · Rust']
|
// #swagger.tags = ['Public · Rust']
|
||||||
// #swagger.summary = 'The leaderboard for one Rust server'
|
// #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['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['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' } }
|
// #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',
|
'/servers/:id/online',
|
||||||
// #swagger.tags = ['Public · Rust']
|
// #swagger.tags = ['Public · Rust']
|
||||||
// #swagger.summary = 'Who is on one Rust server right now'
|
// #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.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,
|
siteMode,
|
||||||
servers.listOnline,
|
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: {
|
RustSidecarProbe: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',
|
description: 'What a sidecar said when probed (POST /admin/rust/servers/{id}/test).',
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ function fakeCtx(overrides = {}) {
|
|||||||
return log
|
return log
|
||||||
},
|
},
|
||||||
auth: { getUserFromRequest: spy(null) },
|
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
|
// 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.
|
// 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 —
|
// 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 asPublic = catalogue.kindsFor({})
|
||||||
const asAdmin = catalogue.kindsFor({ admin: true })
|
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)
|
assert.equal(asAdmin.length, catalogue.ALL_KINDS.length)
|
||||||
|
|
||||||
// The property that makes the route safe by construction: there is no argument
|
// 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', () => {
|
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'])
|
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
|
// 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
|
// model turns into an empty list — the events are, as far as this viewer is
|
||||||
// concerned, not there.
|
// 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())
|
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' })
|
await model.recent({ serverId: 'main' })
|
||||||
|
|
||||||
assert.ok(!asked.kinds.includes('player.banned'), 'no IP-carrying kind by default')
|
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 })
|
await model.recent({ serverId: 'main', admin: true })
|
||||||
assert.ok(asked.kinds.includes('player.banned'), 'an admin who says so gets them')
|
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
|
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": {
|
"/api/v1/admin/users/{id}/rust/links": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1245,7 +1307,7 @@
|
|||||||
"Public · Rust"
|
"Public · Rust"
|
||||||
],
|
],
|
||||||
"summary": "Recent events on one Rust server",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -1300,7 +1362,7 @@
|
|||||||
"Public · Rust"
|
"Public · Rust"
|
||||||
],
|
],
|
||||||
"summary": "The leaderboard for one Rust server",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -1355,7 +1417,7 @@
|
|||||||
"Public · Rust"
|
"Public · Rust"
|
||||||
],
|
],
|
||||||
"summary": "Who is on one Rust server right now",
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -1369,7 +1431,14 @@
|
|||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"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": {
|
"500": {
|
||||||
"description": "Internal Server Error"
|
"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": {
|
"RustSidecarProbe": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
Reference in New Issue
Block a user