R2, and the first phase where this module WRITES to a game. Groups and grants are authored on the website and pushed into each server's own permission store, so every plugin that already calls `UserHasPermission` honours them with no adapter, and a wipe stops being a data-loss event. **Seven org-lead decisions (D28-D34).** A grant is keyed to the website USER and resolved to every Steam id they have linked at push time (D28); every authored row carries a scope — a server or `*` (D29); groups are mirrored as real groups rather than flattened (D30); a holder the site did not author is REPORTED, never undone, with adopt and revoke offered (D31); one verb, with the plugin diffing locally (D32); a permission no server has registered is reported unresolved and never self-registered (D33); authoring is people and groups by hand, with rules deferred (D34). **Three sets, and every interesting question is a difference between two.** `desired − pushed` is what to apply; `pushed − desired` is what to RETIRE, because the site put it there and has since withdrawn it; `present − desired` is drift. The middle one is why `rust_perm_pushed` exists: a name in the store that is not in the desired set is either something the site retired or something a human granted, and those two have opposite correct answers. **What lands is not what was sent.** A grant naming a permission the server has not registered did not land — `GrantUserPermission` no-ops silently — and a member the store has never seen could not be placed. Neither is recorded as pushed, so the site never believes it gave a privilege it did not. The loop asks a cheap question every thirty seconds — does the digest of the desired set still equal what this server last confirmed — and syncs on a change, a restart, a wipe, a drift hook, a failed attempt past its backoff, or the fifteen-minute audit that finds drift on a server nobody has touched. **This module's first admin page**, because a permission model is the first thing here that has to be composed rather than configured. What is on it is decided by what an operator can get wrong: four states are invisible from the game and from a list of grants, and each is a sentence rather than a number. Walked end to end against a real core at the pinned ref, the real sidecar, and a stand-in speaking protocol 4 — including a restart that emptied the store and was fully re-pushed. Four defects the browser found that 133 green tests did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
95 lines
5.0 KiB
JavaScript
95 lines
5.0 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'))
|
||
|
||
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
|