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
230 lines
8.5 KiB
JavaScript
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,
|
|
}
|