Five read-only commands registered with api.registerSlashCommands: /status, /wipe, /top, /online and /clan (D126). Every refusal is private, and any answer narrower than public (online names, a clan roster) goes to the caller alone (D127). No command asks a sidecar. The next wipe (D128, D130): six nullable columns on rust_servers, a pure nextWipe(row, now) with the zone arithmetic through Intl, computed on every read. The public server shape gains nextWipe; the admin shape gains the stored schedule; PUT /admin/rust/servers/:id takes the six fields and writes them only when wipeRule is present. server/commands joins ci/bundle.json, which checkBundle caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
149 lines
9.5 KiB
JavaScript
149 lines
9.5 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. The wipe schedule (`wipeRule`, `wipeDay`, `wipeTime`, `wipeTz`, `wipeAnchor`, `wipeOnceAt`) is written only when `wipeRule` is present, so a body without it leaves the stored schedule alone. `wipeRule` is `none`, `forced`, `weekly` or `biweekly`; a weekly or biweekly rule needs `wipeDay` (0 = Sunday), `wipeTime` (`HH:MM`) and an IANA `wipeTz`, and a biweekly rule a `wipeAnchor` date on that day. `wipeOnceAt` is a one-off wipe in the future. A 400 carries one sentence per problem in `errors`.'
|
||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustServerSave" } } } } */
|
||
/* #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(),
|
||
// The wipe schedule (phase 16, D130). Shape only here; the rules that span
|
||
// fields — a day for a weekly rule, an anchor on that day, a known zone, a
|
||
// one-off date in the future — are `nextWipe.validateSchedule`'s, in the
|
||
// controller, so the form gets one sentence per problem.
|
||
body('wipeRule').optional().isIn(['none', 'forced', 'weekly', 'biweekly']),
|
||
body('wipeDay').optional({ values: 'null' }).isInt({ min: 0, max: 6 }),
|
||
body('wipeTime').optional({ values: 'null' }).isString().isLength({ max: 5 }),
|
||
body('wipeTz').optional({ values: 'null' }).isString().isLength({ max: 64 }),
|
||
body('wipeAnchor').optional({ values: 'falsy' }).isISO8601({ strict: true }).isLength({ max: 10 }),
|
||
body('wipeOnceAt').optional({ values: 'falsy' }).isISO8601({ strict: true }),
|
||
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,
|
||
)
|
||
|
||
// ── The map's picture (phase 14) ──────────────────────────────────────────
|
||
|
||
adminRustRouter.post(
|
||
'/servers/:id/map/fetch',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Fetch a server’s map picture again'
|
||
// #swagger.description = 'Asks the game what its map is and fetches the picture again, whatever this site already holds. The site does this by itself the first time it sees a new map when the game has a picture to give; this is the button for when that did not happen. `outcome` is `fetched`, `current` (the stored picture is this map’s), `none` (the game has no picture, and Render now is the way to get one) or `failed`, with a sentence in `message`. One fetch per server at a time: a second answers 409.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||
/* #swagger.responses[200] = { description: 'What the fetch did' } */
|
||
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
|
||
/* #swagger.responses[409] = { description: 'A fetch is already running for this server' } */
|
||
/* #swagger.responses[502] = { description: 'The game or its sidecar did not give a picture; the stored one is kept' } */
|
||
requireRole('admin'),
|
||
param('id').isString().isLength({ min: 1, max: 64 }),
|
||
validate,
|
||
admin.fetchMap,
|
||
)
|
||
|
||
adminRustRouter.post(
|
||
'/servers/:id/map/render',
|
||
// #swagger.tags = ['Admin · Rust']
|
||
// #swagger.summary = 'Ask a server to draw its own map'
|
||
// #swagger.description = '**This stalls the game server** while it draws — about 8.5 seconds on a 3000 map, longer on a larger one — and nothing on it moves for that time. It exists for a server without Rust+ (`app.port`), whose game keeps no picture of its map, and is refused when a picture already exists. Answers 202 as soon as the game accepts; the picture is fetched when the render finishes, and kept by the game so a restart on the same map does not need another.'
|
||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||
/* #swagger.responses[202] = { description: 'The game accepted and will draw on its next frame' } */
|
||
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
|
||
/* #swagger.responses[409] = { description: 'A picture already exists, or a render is already running' } */
|
||
requireRole('admin'),
|
||
param('id').isString().isLength({ min: 1, max: 64 }),
|
||
validate,
|
||
admin.renderMap,
|
||
)
|
||
|
||
module.exports = adminRustRouter
|