The org lead's rule, settled 2026-09-22: who is online is always the
narrowest audience - staff - unless an operator deliberately widens it,
and a count is fine where a list of names is not.
The public site broke that in three places since phase 4. The Online
tab named every player, the feed carried joins, respawns, deaths, chat
and tallies, and the leaderboard's lastSeen - refreshed every minute by
a gather tally - said who was on as plainly as either. All three now
sit behind one setting:
* PRESENCE_KINDS, a subset of the public allowlist, gated per request.
Below the audience the feed keeps the server's own story (wipe, start,
shutdown) and says presenceHidden rather than looking quiet.
* the Online route answers { players: [], hidden, count, audience } -
same shape, so an older client renders empty rather than breaking.
* rungs staff / signed_in / public, fleet-wide default in a new
rust_settings table with an optional per-server override on
rust_servers; an unknown stored word narrows to staff.
* the viewer's standing is RE-READ from the users row (ctx.users.getById),
not taken from the token, so a demotion or a ban applies on the next
request. Walked: a moderator demoted mid-session lost the roll call on
the same cookie.
* per-viewer answers are Cache-Control: private, no-store.
* GET/PUT /admin/rust/visibility (requireRole admin) and an admin page,
Rust visibility; every save is one activity-log row.
The browser walk also found every empty state in this module rendering
as a blank box. Core's EmptyState renders children only; this module
passed title/message (the shape the Integration Kit template teaches)
and React dropped both without a word. Fixed module-side with a small
Empty wrapper - nothing core or module-uo renders changes - and a client
test that refuses a titled EmptyState or a PageHeader subtitle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
105 lines
5.6 KiB
JavaScript
105 lines
5.6 KiB
JavaScript
// ── Admin · Rust ──────────────────────────────────────────────────────────
|
||
//
|
||
// Mounted at `/api/v1/admin/rust`. The tier's gate is already applied: `admin`
|
||
// sits behind `noindex, isLoggedIn, requireRole('admin','editor','moderator')`.
|
||
//
|
||
// **That gate is broader than these routes should be.** Editing a server row
|
||
// means editing the credential that reaches a game host, which is an
|
||
// administrator's job and not a moderator's — so the routes that write add
|
||
// `requireRole('admin')` on top of the tier. A module adds per-route gates over
|
||
// the tier gate and never re-implements it; this is what adding one looks like.
|
||
//
|
||
// ── The token is write-only ───────────────────────────────────────────────
|
||
//
|
||
// `sidecarToken` is accepted and never returned. The list route reports
|
||
// `hasToken` instead, because a blank field otherwise means both "unset" and
|
||
// "set, and not being shown to you". An empty string on a save leaves the stored
|
||
// value alone — an operator renaming a server must not have to re-paste a
|
||
// credential, and a form that posts its own blank field would otherwise erase one
|
||
// on every unrelated edit.
|
||
|
||
const core = require('../../core')
|
||
|
||
const express = core.express
|
||
const admin = require('./rust.controller')
|
||
const { requireRole, validate } = core.middleware
|
||
const { body, param } = core.validator
|
||
|
||
const adminRustRouter = express.Router()
|
||
|
||
// R2's authoring surface, under `/rust/permissions`. Its own file because it is
|
||
// its own subject — this router configures the bridge, that one decides who may
|
||
// do what inside the game the bridge reaches.
|
||
adminRustRouter.use('/permissions', require('./permissions.router'))
|
||
|
||
// R18's editor, under `/rust/config`. A third subject again: this router
|
||
// configures the BRIDGE, `permissions` decides who may do what inside the game,
|
||
// and this one edits the game host's own plugin settings.
|
||
adminRustRouter.use('/config', require('./config.router'))
|
||
|
||
// Who may see who is online, under `/rust/visibility`. The org lead's rule is
|
||
// that nothing names who is online by default; this is where an operator
|
||
// deliberately widens it, fleet-wide or for one server.
|
||
adminRustRouter.use('/visibility', require('./visibility.router'))
|
||
|
||
adminRustRouter.get(
|
||
'/servers',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Every configured Rust server'
|
||
// #swagger.description = 'The operator’s server rows with their sidecar URLs, whether a token is stored, and whether each sidecar was reachable on the last poll. The token itself is never returned.'
|
||
/* #swagger.responses[200] = { description: 'The configured servers', content: { "application/json": { schema: { $ref: "#/components/schemas/RustAdminServerList" } } } } */
|
||
admin.listServers,
|
||
)
|
||
|
||
adminRustRouter.put(
|
||
'/servers/:id',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Create or update a Rust server'
|
||
// #swagger.description = 'Writes one server row. `sidecarToken` is write-only — send it to set or rotate the credential, and omit it or send an empty string to leave the stored one untouched. The id is the slug every URL under the module carries.'
|
||
/* #swagger.responses[204] = { description: 'Saved' } */
|
||
/* #swagger.responses[400] = { description: 'Invalid body' } */
|
||
requireRole('admin'),
|
||
param('id')
|
||
.matches(/^[a-z0-9][a-z0-9-]{0,63}$/)
|
||
.withMessage('id must be lowercase letters, digits and hyphens'),
|
||
body('name').isString().trim().isLength({ min: 1, max: 120 }),
|
||
// A base URL is validated for SHAPE and not for reachability: an operator
|
||
// configures a sidecar before installing it about half the time, and refusing
|
||
// the row because nothing answers yet would make the obvious order of
|
||
// operations impossible.
|
||
body('sidecarBaseUrl').isURL({ require_tld: false, protocols: ['http', 'https'] }),
|
||
body('sidecarToken').optional({ values: 'falsy' }).isString().isLength({ max: 512 }),
|
||
body('protocol').optional().isInt({ min: 1, max: 1000 }).toInt(),
|
||
body('enabled').optional().isBoolean().toBoolean(),
|
||
body('sortOrder').optional().isInt({ min: -1000, max: 1000 }).toInt(),
|
||
validate,
|
||
admin.putServer,
|
||
)
|
||
|
||
adminRustRouter.delete(
|
||
'/servers/:id',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Remove a Rust server'
|
||
// #swagger.description = 'Deletes the server row and the observed state that hangs off it. It does not touch the sidecar or the game host — those are removed with the installer.'
|
||
/* #swagger.responses[204] = { description: 'Deleted' } */
|
||
requireRole('admin'),
|
||
param('id').isString().isLength({ min: 1, max: 64 }),
|
||
validate,
|
||
admin.deleteServer,
|
||
)
|
||
|
||
adminRustRouter.post(
|
||
'/servers/:id/test',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Probe a server’s sidecar'
|
||
// #swagger.description = 'Calls the sidecar’s health endpoint with the stored credential and reports what came back — whether it answered, whether the bridge plugin is connected to it, and which protocol version it speaks. This is the one route that tells a wrong URL from a wrong token from a mismatched version.'
|
||
/* #swagger.responses[200] = { description: 'What the sidecar said', content: { "application/json": { schema: { $ref: "#/components/schemas/RustSidecarProbe" } } } } */
|
||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||
requireRole('admin'),
|
||
param('id').isString().isLength({ min: 1, max: 64 }),
|
||
validate,
|
||
admin.testServer,
|
||
)
|
||
|
||
module.exports = adminRustRouter
|