Files
Module-Rust/server/model/config/config.model.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

280 lines
10 KiB
JavaScript

// ── The logic half of configuration-from-the-site ─────────────────────────
//
// Everything here is about the difference between what a game host reports and
// what an admin should be shown. Three jobs:
//
// 1. **Group a flat file list by plugin**, because one plugin can own several
// files and a form that lists 40 paths is not a settings screen.
// 2. **Say which file is ours, and which keys inside it are locked** (D38). The
// plugin names itself in the catalogue rather than us matching a filename,
// so renaming the file cannot quietly unlock the three keys that would cut
// the link or split a server's history.
// 3. **Decide nothing about paths.** The only process that can say whether a
// path resolves inside a configuration directory is the one holding the
// directory. This file checks SHAPE, so an obviously malformed request is
// refused before it costs a round trip — never as a substitute for the real
// check on the host.
const configEdit = require('../../configEdit')
/**
* Keys in the bridge plugin's own config that the website may not change (D38).
*
* `Host` and `Port` are the link this edit is travelling over, and `ServerId` is
* how every row this module has ever stored is keyed — changing it does not
* rename a server, it strands its history and starts a new one under a name
* nobody chose deliberately. All three are editable on the host, by a person
* who is standing on it.
*/
const LOCKED_KEYS = ['Host', 'Port', 'ServerId']
/** What a locked field says for itself, on the screen and in a refusal. */
const LOCKED_REASON = {
host: 'the website reaches this server through this address',
port: 'the website reaches this server through this port',
serverid: 'every row this site holds for this server is keyed to this id',
}
/**
* A path shaped like something the host could plausibly have listed.
*
* Deliberately narrow and deliberately **not** the security boundary: no `..`,
* nothing absolute, no drive letter, forward slashes, and it ends in `.json`.
*/
const PATH_SHAPE = /^(?!.*\.\.)(?!\/)[A-Za-z0-9 _.\-()[\]]+(?:\/[A-Za-z0-9 _.\-()[\]]+)*\.json$/
function isPlausiblePath(path) {
return typeof path === 'string' && path.length > 0 && path.length <= 255 && PATH_SHAPE.test(path)
}
/**
* Shapes the plugin's catalogue into the screen's shape: plugins, each with its
* files, each file saying whether it can be edited and why not.
*
* A file whose guessed plugin is not loaded is kept and **marked**, not dropped.
* An operator whose config for an unloaded plugin vanished from the page would
* conclude the bridge cannot see it, which is a different and much more alarming
* problem than the true one.
*/
function shapeCatalogue(catalogue) {
if (!catalogue || typeof catalogue !== 'object') return null
const loaded = Array.isArray(catalogue.plugins) ? catalogue.plugins : []
const byName = new Map(loaded.map((p) => [String(p.name).toLowerCase(), p]))
const self = catalogue.self ? String(catalogue.self) : null
const groups = new Map()
for (const file of Array.isArray(catalogue.files) ? catalogue.files : []) {
const plugin = String(file.plugin || 'unknown')
const key = plugin.toLowerCase()
if (!groups.has(key)) {
const match = byName.get(key)
groups.set(key, {
plugin,
loaded: Boolean(match),
title: match ? match.title : null,
version: match ? match.version : null,
// The bridge plugin cannot reload itself — the reload would close the
// link carrying the answer — so the screen says so up front rather than
// offering a button that always refuses.
isBridge: self != null && plugin.toLowerCase() === self.toLowerCase(),
files: [],
})
}
groups.get(key).files.push({
path: String(file.path),
bytes: Number(file.bytes) || 0,
modified: file.modified ? Number(file.modified) : null,
editable: file.editable !== false,
...(file.reason ? { reason: String(file.reason) } : {}),
})
}
return {
root: catalogue.root ? String(catalogue.root) : null,
self,
truncated: Boolean(catalogue.truncated),
limits: catalogue.limits || null,
plugins: [...groups.values()].sort((a, b) => a.plugin.localeCompare(b.plugin)),
loaded: loaded
.map((p) => ({ name: String(p.name), title: p.title || null, version: p.version || null }))
.sort((a, b) => a.name.localeCompare(b.name)),
}
}
/** Whether this file is the bridge's own config, by the name the plugin gave. */
function isBridgeConfig(path, self) {
if (!self) return false
const plugin = String(path).includes('/') ? String(path).split('/')[0] : String(path).replace(/\.json$/i, '')
return plugin.toLowerCase() === String(self).toLowerCase()
}
/** The locked keys for a file: three of them in our own config, none anywhere else. */
function lockedKeysFor(path, self) {
return isBridgeConfig(path, self) ? LOCKED_KEYS : []
}
/**
* Turns one file the host sent into what the form renders.
*
* The text is passed through untouched. What is added is the READING of it: the
* field list, which fields are locked, and which hold something a browser should
* mask by default.
*/
function shapeFile(file, { self = null, maxDepth = 6 } = {}) {
if (!file || typeof file.text !== 'string') return null
const locked = lockedKeysFor(file.path, self)
let fields = null
let parseError = null
try {
fields = configEdit.describe(configEdit.scan(file.text), { maxDepth, locked })
} catch (err) {
// A config already broken on disk still opens — in the raw tier, which is
// the only thing that can fix it. A page that refused to show a broken file
// would send somebody to SSH for the one job this feature exists to do.
parseError = err.message
}
return {
path: String(file.path),
plugin: file.plugin ? String(file.plugin) : null,
version: String(file.version),
bytes: Number(file.bytes) || 0,
modified: file.modified ? Number(file.modified) : null,
text: file.text,
fields,
parseError,
locked: locked.map((key) => ({ key, reason: LOCKED_REASON[key.toLowerCase()] || null })),
isBridge: isBridgeConfig(file.path, self),
}
}
/**
* Which locked keys differ between two versions of a document.
*
* The form refuses a locked field by pointer, but the **raw tier submits a whole
* document**, and a document can change `Port` without anything resembling an
* edit to a field. So the raw tier is checked the only way it can be: by
* comparing the literals before and after.
*
* A file that will not parse is not a way around this — an unparseable document
* is refused before it gets here.
*/
function lockedChanges(before, after, locked) {
if (!locked || locked.length === 0) return []
let a
let b
try {
a = configEdit.scan(before)
b = configEdit.scan(after)
} catch {
// Nothing can be compared, so nothing is cleared. The caller refuses.
return locked.slice()
}
const literal = (root, key) => {
if (root.type !== 'object') return null
const node = root.children.find((c) => String(c.key).toLowerCase() === key.toLowerCase())
return node ? JSON.stringify(node.value) + ':' + (node.raw || '') : null
}
return locked.filter((key) => literal(a, key) !== literal(b, key))
}
/** Every change a report says landed, as one line per file. */
function summariseReport(report) {
if (!report || typeof report !== 'object') return null
const files = Array.isArray(report.files) ? report.files : []
return {
ok: report.ok !== false,
reloaded: Boolean(report.reloaded),
rolledBack: Boolean(report.rolledBack),
// Protocol 13: the files are written and the reload has not finished. The
// outcome follows as a `config.outcome` frame naming `writeId`.
pending: Boolean(report.pending),
writeId: report.writeId ? String(report.writeId) : null,
ceilingMs: Number(report.ceilingMs) > 0 ? Number(report.ceilingMs) : null,
restored: typeof report.restored === 'boolean' ? report.restored : null,
reason: report.reason ? String(report.reason) : null,
// The plugin only reads this on the failure path, and it is the difference
// between "your change was undone" and "your change was undone BECAUSE line
// 14 is not valid for that field".
log: report.log ? String(report.log).slice(-4000) : null,
files: files.map((f) => ({
path: String(f.path),
version: f.version ? String(f.version) : null,
bytes: f.bytes != null ? Number(f.bytes) : null,
// Both frameworks merge missing defaults on load and save the file back,
// so the file after a successful reload is regularly not the file we
// wrote. Saying so keeps an operator from reading it as our bug.
rewritten: Boolean(f.rewritten),
})),
}
}
/**
* How long a pending write may go unanswered before it is lost, in seconds.
*
* The plugin waits at most one ceiling for the edit's reload and one more for a
* restore's; ingest reads the feed every few seconds on top. Past both, with a
* margin, nothing is coming — the plugin was reloaded or the link dropped — and
* the page says so instead of spinning. A plugin that sent no ceiling gets the
* one protocol 13 shipped with.
*/
const SETTLE_SLACK_SECONDS = 20
const DEFAULT_CEILING_MS = 30 * 1000
function settleSeconds(ceilingMs) {
const ceiling = Number(ceilingMs) > 0 ? Number(ceilingMs) : DEFAULT_CEILING_MS
return Math.ceil((2 * ceiling) / 1000) + SETTLE_SLACK_SECONDS
}
/**
* A `config.outcome` frame as the row it settles, or null when it names no write.
*
* `applied` covers every case where the edit is on disk — reloaded, or standing
* because the framework never reloaded it (the reason says which). A rollback is
* `rolled-back` whether or not the plugin came back on the old file; `restored`
* says which, and it is the difference between "try again" and "go and look".
*/
function outcomeOf(frame) {
if (!frame || typeof frame !== 'object' || !frame.writeId) return null
const report = summariseReport(frame)
const first = report.files[0]
return {
writeId: String(frame.writeId),
outcome: report.rolledBack ? 'rolled-back' : 'applied',
reloaded: report.reloaded,
restored: report.rolledBack ? report.restored : null,
versionAfter: first ? first.version : null,
detail: report.reason,
log: report.log,
}
}
module.exports = {
LOCKED_KEYS,
LOCKED_REASON,
PATH_SHAPE,
isPlausiblePath,
shapeCatalogue,
shapeFile,
isBridgeConfig,
lockedKeysFor,
lockedChanges,
summariseReport,
outcomeOf,
settleSeconds,
}