Files
Module-Rust/server/router/admin/rust.router.js
wtclaude 0cb9bdd1f0
All checks were successful
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -1m9s
feat(rust): the live map (phase 14, protocol 11)
PLAN.md §30 as approved, plus D119/D120 from the build.

Server:
- rust_map_images (one row per server: picture as MEDIUMBLOB, geometry,
  monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair.
- mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and
  asks map.info; a new key or hash from the free Rust+ cache (or a render
  kept on disk) is fetched in slices, checked against its SHA-256 and stored
  in one statement. One fetch per server, a backoff on failure, `stale`
  abandons a fetch that straddles a map change. Render now (D109) is
  admin-only and watched to completion.
- mapLive.js: D111. One map.live per server per 5 s whoever asks; positions
  are held in memory only.
- model/map: four layers (world, events public; players, bases staff), a
  fleet default plus per-server override (D114), the players layer capped by
  presence (D113), own dot and online first-party clan mates for a linked
  viewer (D115, D117, D118). A layer the viewer may not see is absent from
  the answer, never sent and hidden.
- Routes: public /servers/:id/map, /map/image (immutable under its hash),
  /map/live; admin /servers/:id/map/fetch and /render; the Map card on the
  visibility PUT. Swagger fragment and frozen manifest regenerated.

Client:
- A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid
  (labels only when a cell is wide enough to hold one), a legend that lists
  hidden layers with who can see them, polled every 10 s while visible.
- D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml
  copies every dist/*.js; checkExternals and build.test.js hold both ends.
- The Map card on Admin -> Rust visibility, with Fetch again and Render now.

Capability `map` declared for the Android app (phase 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 01:06:10 -05:00

138 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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,
)
// ── 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