Files
Module-Rust/server/model/config/config.model.js
wtclaude e54ae3afb9
All checks were successful
PR Checks / server-tests (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / client-build (pull_request) Successful in 7m56s
feat(rust): mod configuration from the site, and an editor that will not rewrite a float
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
2026-09-22 08:55:28 -05:00

230 lines
8.5 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),
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),
})),
}
}
module.exports = {
LOCKED_KEYS,
LOCKED_REASON,
PATH_SHAPE,
isPlausiblePath,
shapeCatalogue,
shapeFile,
isBridgeConfig,
lockedKeysFor,
lockedChanges,
summariseReport,
}