R18's two tiers: a form generated from a config file's own values, and raw JSON for what a form cannot express. Admin → Rust mod config, one live round trip per action, nothing cached between a browser and a game host's disk. `configEdit.js` is the part that could not be done naively. JavaScript cannot tell `1` from `1.0`, and both mod frameworks deserialize a config into typed C# classes — so a read-modify-write silently rewrites every whole-numbered float as an integer on fields nobody touched, and a plugin that then throws at load does not come back. It never parses, mutates and re-serialises: it records the SOURCE SPAN of every value and splices literals into them, so an untouched `1.0` is still `1.0` and a number an admin types travels as text the whole way (D35/D36). The bridge's own config is editable with `Host`, `Port` and `ServerId` locked, in the form and in the raw tier, because either would cut the link carrying the edit or strand every row this site holds (D38). Credentials render masked with a reveal; the raw tier shows them (D37) and the audit trail never does. `rust_config_writes` records every save including the refused and the rolled back — an operator asking why a setting is not what they set needs to see that somebody tried. Three defects a browser walk found that 179 green tests did not: * every save of the bridge's own config was refused while the page said the opposite — a `<select>` whose value matches no `<option>` shows the first one, so the reload guess `RunicGateway` was on the wire and "nothing" was on the screen; * `btn ghost` is not a class this platform defines (`.btn-ghost` is), so every secondary button in this module has rendered as a primary one since phase 7 — here it made the open file and the active tier indistinguishable; * a save's refusal rendered at the top of a long form, far from the button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
109 lines
6.0 KiB
JavaScript
109 lines
6.0 KiB
JavaScript
// ── 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 does not come back — which is answered 200 with `report.rolledBack`, because a rollback is a round trip that worked and an edit that did not.'
|
||
/* #swagger.responses[200] = { description: 'What happened: applied, or rolled back with the reason' } */
|
||
/* #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,
|
||
)
|
||
|
||
module.exports = configRouter
|