Files
Module-Rust/server/router/admin/config.router.js
wtclaude 654a24585d
Some checks failed
PR Checks / server-tests (pull_request) Failing after 16s
PR Checks / client-build (pull_request) Successful in 17s
PR Checks / frozen-manifest (pull_request) Failing after 37s
fix(rust): protocol 13 — a configuration save that settles after its reload (F9, D179)
The plugin now answers a save once the files are written, with pending and
a writeId, and reports the reload later as a config.outcome event. The save
is recorded as reloading with a settle_by of two plugin ceilings plus slack
on the database's clock; ingest settles the row by (server, writeId), only
while it is still reloading, so a replay moves nothing and a late outcome
still lands. A row past settle_by reads as lost.

GET /admin/rust/config/:serverId/writes/:writeId serves the poll; the page
polls it every two seconds, holds the Save button while it waits, and says
whether a rolled-back plugin came back on its old file. config.outcome is
a staff kind: it carries the server's log tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 17:16:45 -05:00

123 lines
7.0 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 · Mod configuration ──────────────────────────────────────
//
// Mounted under the admin tier's `/rust` prefix, so every path here is
// `/api/v1/admin/rust/config…`. A **nested** `use()` rather than a second mount,
// because a mount prefix is one path segment — core's own check is
// `/^\/[a-z0-9][a-z0-9-]*$/`, so `/rust/config` could never be declared in
// `module.json`. (The OpenAPI generator follows the require and prefixes these
// correctly regardless, which phase 7 established the hard way.)
//
// **Every route is `requireRole('admin')`**, on top of the tier's own gate. This
// is a website form writing files onto a game host and reloading its plugins,
// which is the most powerful thing this module can do to somebody's server.
// There is still no module-declared site permission at MODULE_API 1.10.0 — R18
// asked for one and hits the same wall phase 7 did — so role is the whole of the
// available vocabulary, and `admin` is the honest choice within it.
const core = require('../../core')
const express = core.express
const config = require('./config.controller')
const { requireRole, validate } = core.middleware
const { body, param, query } = core.validator
const configRouter = express.Router()
/** A server id, as every other route in this module spells it. */
const SERVER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
/**
* A plugin name to reload.
*
* Shape only. Whether the name is loaded — and whether it is the bridge itself,
* which cannot reload itself without closing the link carrying the answer — is
* the plugin's decision, because it is the only process that knows.
*/
const PLUGIN_NAME = /^[A-Za-z0-9_.-]{1,128}$/
configRouter.get(
'/:serverId/files',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Every plugin configuration file on one server'
// #swagger.description = 'A live recursive walk of the game host’s configuration directory, grouped by the plugin each file probably belongs to, plus every plugin currently loaded. The root comes from the mod framework, so it is `oxide/config` on Oxide and `carbon/configs` on Carbon. Files past the size limit are listed and marked un-editable rather than hidden. The game’s data directory is never walked.'
/* #swagger.responses[200] = { description: 'The configuration tree and the loaded plugins' } */
/* #swagger.responses[503] = { description: 'The sidecar or the game is unreachable' } */
requireRole('admin'),
param('serverId').matches(SERVER_ID),
validate,
config.listFiles,
)
configRouter.get(
'/:serverId/file',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'One configuration file'
// #swagger.description = 'The file’s text, the version a save must present back, and the field list the generated form is drawn from — types, keys, and which values are credentials. A file that is already broken on disk still opens, with the parse error, because the raw tier is the only thing that can fix it.'
/* #swagger.responses[200] = { description: 'The file and the reading of it' } */
/* #swagger.responses[400] = { description: 'Not a configuration path' } */
/* #swagger.responses[503] = { description: 'The sidecar or the game is unreachable' } */
requireRole('admin'),
param('serverId').matches(SERVER_ID),
query('path').isString().isLength({ min: 1, max: 255 }),
validate,
config.readFile,
)
configRouter.post(
'/:serverId/file',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Save a configuration file and reload its plugin'
// #swagger.description = 'Send `edits` (the generated form: pointers and literals, type-preserving) or `text` (the raw tier: the whole document). `version` must match what the host holds or the save is refused 409 with the current file. The game backs the file up, writes it, reloads the named plugin, and **restores the old file automatically** if the plugin fails to load. With a plugin to reload, the answer comes as soon as the file is written — `pending: true` and `write.id` — and the outcome is read from `GET …/writes/{writeId}` once the reload settles. Without one, the answer is final.'
/* #swagger.responses[200] = { description: 'Written and reloading (`pending`, with the write’s id), or the final outcome when nothing was reloaded' } */
/* #swagger.responses[400] = { description: 'Invalid body, an edit the form may not make, or a locked key' } */
/* #swagger.responses[409] = { description: 'The file changed on the host since it was read' } */
/* #swagger.responses[503] = { description: 'The sidecar or the game is unreachable' } */
requireRole('admin'),
param('serverId').matches(SERVER_ID),
body('path').isString().isLength({ min: 1, max: 255 }),
body('version').isString().isLength({ min: 1, max: 64 }),
body('reload').optional({ values: 'falsy' }).matches(PLUGIN_NAME),
// One tier or the other, never both and never neither. `edits` carries the
// form's pointers and literals; `text` is the whole document.
body('edits').optional().isArray({ max: 500 }),
body('text').optional().isString().isLength({ max: 262144 }),
body().custom((value) => {
const hasEdits = Array.isArray(value.edits)
const hasText = typeof value.text === 'string'
if (hasEdits === hasText) throw new Error('send either edits or text')
return true
}),
validate,
config.writeFile,
)
configRouter.get(
'/:serverId/writes',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Recent configuration writes'
// #swagger.description = 'The audit trail for one server: who changed which field, from what to what, whether the plugin reloaded, and whether the change was rolled back. Refused and unreachable attempts are recorded too — an operator asking why a setting is not what they set needs to see that somebody tried. Values of credential-shaped fields are never stored.'
/* #swagger.responses[200] = { description: 'The recent writes, newest first' } */
requireRole('admin'),
param('serverId').matches(SERVER_ID),
query('limit').optional().isInt({ min: 1, max: 200 }),
validate,
config.history,
)
configRouter.get(
'/:serverId/writes/:writeId',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'One configuration write, while its reload settles'
// #swagger.description = 'A save that reloads a plugin answers as soon as the file is written, with `pending: true` and this write’s id; the reload can take longer than a request should, behind a cold compile. Poll this until `outcome` leaves `reloading`: `applied`, or `rolled-back` with the reason, the server’s log, and `restored` (whether the plugin came back on its old file). `lost` means the plugin never reported — it was reloaded, or the link dropped — so re-read the file.'
/* #swagger.responses[200] = { description: 'The write and how far it has got' } */
/* #swagger.responses[404] = { description: 'No such write on that server' } */
requireRole('admin'),
param('serverId').matches(SERVER_ID),
param('writeId').isInt({ min: 1 }),
validate,
config.writeStatus,
)
module.exports = configRouter