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

528 lines
17 KiB
JavaScript

// ── Editing a plugin's config without rewriting the numbers ───────────────
//
// R18's base tier generates a form from a config file's VALUES — a boolean
// becomes a toggle, a number a field, a string a text box — so it works for
// whatever plugins an operator happens to have installed, including ones added
// after we shipped. This file is the half of that which cannot be done naively.
//
// ── The trap ──────────────────────────────────────────────────────────────
//
// **JavaScript cannot tell `1` from `1.0`.** `JSON.parse('{"Rate":1.0}')` yields
// the number `1`, and `JSON.stringify` writes it back as `1`. Both frameworks
// deserialize a config into typed C# classes, so a naive read-modify-write
// silently rewrites every whole-numbered float as an integer — **on fields
// nobody touched** — and Newtonsoft may coerce it or may throw. A throw at load
// means the plugin does not come back, and R6/R17 make four of them required.
//
// The fields at risk are exactly the ones a Rust server tunes: gather rates,
// multipliers, scales.
//
// ── So nothing here ever parses, mutates and re-serialises ────────────────
//
// `scan` is a JSON reader that records, for every value, the **span of source
// text** it came from. `applyEdits` splices new literals into those spans and
// leaves every other byte of the document exactly as it was — including the
// author's indentation, key order, and the `.0` on a float nobody edited.
//
// Two rules fall out of that and both are deliberate:
//
// 1. **A number's new value arrives as the literal text an admin typed**, never
// as a JavaScript number. `2.50` stays `2.50`; `1.0` stays `1.0`. The value
// never becomes a `Number` anywhere in this module, which is the only way to
// be sure it cannot be re-serialised into something else.
// 2. **The generated form is type-preserving.** An edit may change what a value
// IS, never what KIND of thing it is; changing a number into a string, or
// adding a key, is a structural change and belongs in the raw-JSON tier,
// where the admin is editing the document itself.
//
// Nothing in this file touches the network, a database, or core.
/** Value kinds this module names, in the language the form speaks. */
const KINDS = ['object', 'array', 'string', 'number', 'boolean', 'null']
/**
* A JSON number, by the grammar rather than by `Number()`.
*
* Used to judge a literal an admin typed. `Number('0x10')`, `Number('')` and
* `Number(' 1 ')` are all happily finite and none of the three is JSON, so the
* check has to be the grammar — which is also what keeps `1.0` and `1e3`
* acceptable, since preserving those is the entire point.
*/
const JSON_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/
/**
* Words that make a value a secret.
*
* Matched against the key split into WORDS, not as a substring: `Monkey` and
* `Keybind` contain "key" and neither is a credential, and a config editor that
* masked every third field would teach an operator to ignore the mask.
*/
const SECRET_WORDS = new Set([
'key',
'keys',
'apikey',
'token',
'tokens',
'secret',
'secrets',
'password',
'passwd',
'pass',
'webhook',
'webhooks',
'credential',
'credentials',
'auth',
])
class JsonScanError extends Error {}
/**
* Reads `text` into a tree of nodes that remember where they came from.
*
* Every node carries `start` and `end`, the half-open span of the value in the
* source. A caller that only wants the data can read `value`; a caller that
* wants to CHANGE the data uses the span, because the span is the only thing
* that survives a round trip unchanged.
*
* @param {string} text
* @returns {object} the root node
* @throws {JsonScanError} with a position, on anything that is not JSON
*/
function scan(text) {
const src = String(text)
let at = 0
function fail(message) {
throw new JsonScanError(`${message} at offset ${at}`)
}
function ws() {
while (at < src.length && (src[at] === ' ' || src[at] === '\t' || src[at] === '\n' || src[at] === '\r')) at += 1
}
function literal(word, value) {
if (src.startsWith(word, at)) {
const start = at
at += word.length
return { type: word === 'null' ? 'null' : 'boolean', value, start, end: at }
}
return null
}
function string() {
const start = at
at += 1 // the opening quote
let out = ''
while (at < src.length) {
const ch = src[at]
if (ch === '"') {
at += 1
return { type: 'string', value: out, start, end: at }
}
if (ch === '\\') {
const esc = src[at + 1]
at += 2
if (esc === 'u') {
const hex = src.slice(at, at + 4)
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('bad unicode escape')
out += String.fromCharCode(parseInt(hex, 16))
at += 4
} else if (esc === 'n') out += '\n'
else if (esc === 't') out += '\t'
else if (esc === 'r') out += '\r'
else if (esc === 'b') out += '\b'
else if (esc === 'f') out += '\f'
else if (esc === '"' || esc === '\\' || esc === '/') out += esc
else fail('bad escape')
continue
}
out += ch
at += 1
}
return fail('unterminated string')
}
function number() {
const start = at
if (src[at] === '-') at += 1
while (at < src.length && /[0-9]/.test(src[at])) at += 1
if (src[at] === '.') {
at += 1
while (at < src.length && /[0-9]/.test(src[at])) at += 1
}
if (src[at] === 'e' || src[at] === 'E') {
at += 1
if (src[at] === '+' || src[at] === '-') at += 1
while (at < src.length && /[0-9]/.test(src[at])) at += 1
}
const raw = src.slice(start, at)
if (!JSON_NUMBER.test(raw)) fail(`'${raw}' is not a number`)
// `raw` is the fact; `value` is a convenience for rendering and comparison,
// and is never written back to the document.
return { type: 'number', value: Number(raw), raw, start, end: at }
}
function value() {
ws()
const ch = src[at]
if (ch === '{') return object()
if (ch === '[') return array()
if (ch === '"') return string()
if (ch === '-' || (ch >= '0' && ch <= '9')) return number()
const lit = literal('true', true) || literal('false', false) || literal('null', null)
if (lit) return lit
return fail('unexpected character')
}
function object() {
const start = at
at += 1 // {
const children = []
ws()
if (src[at] === '}') {
at += 1
return { type: 'object', children, start, end: at }
}
for (;;) {
ws()
if (src[at] !== '"') fail('expected a key')
const key = string()
ws()
if (src[at] !== ':') fail('expected a colon')
at += 1
const child = value()
child.key = key.value
child.keyStart = key.start
child.keyEnd = key.end
children.push(child)
ws()
if (src[at] === ',') {
at += 1
continue
}
if (src[at] === '}') {
at += 1
return { type: 'object', children, start, end: at }
}
return fail('expected a comma or a closing brace')
}
}
function array() {
const start = at
at += 1 // [
const children = []
ws()
if (src[at] === ']') {
at += 1
return { type: 'array', children, start, end: at }
}
for (;;) {
const child = value()
child.index = children.length
children.push(child)
ws()
if (src[at] === ',') {
at += 1
continue
}
if (src[at] === ']') {
at += 1
return { type: 'array', children, start, end: at }
}
return fail('expected a comma or a closing bracket')
}
}
const root = value()
ws()
if (at !== src.length) fail('trailing content')
return root
}
/** Splits a config key into words, across camelCase, snake_case, spaces and dots. */
function words(key) {
return String(key)
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[^A-Za-z0-9]+/)
.filter(Boolean)
.map((w) => w.toLowerCase())
}
/**
* Whether a key names a credential. See `SECRET_WORDS`.
*
* **A field flagged here is not emptied.** D37 decided the raw tier shows real
* values — an admin can already read the file over SSH — so the API answers with
* the document as it is, the form renders a flagged field masked with a reveal
* control, and the flag's load-bearing use is the audit trail, where the values
* genuinely never appear.
*/
function isSecretKey(key) {
return words(key).some((w) => SECRET_WORDS.has(w))
}
/** A pointer as a person reads it: `Settings.Rates[0].Wood`. */
function pointerPath(pointer) {
return pointer
.map((step) => (typeof step === 'number' ? `[${step}]` : step))
.join('.')
.replace(/\.\[/g, '[')
}
/**
* Walks a scanned tree into the flat description the form is built from.
*
* **What is NOT here is as deliberate as what is.** There are no descriptions,
* no minimums, no maximums and no allowed-value sets, because a config file
* carries none: the key name is the entire label. An empty array and a `null`
* carry no type at all, so nothing can be inferred for them and they are marked
* `advanced` — the raw tier is where a value with no shape gets edited.
*
* @param {object} root from `scan`
* @param {object} [options]
* @param {number} [options.maxDepth] past this, a subtree is advanced-only
* @param {string[]} [options.locked] top-level keys that may not be edited (D38)
*/
function describe(root, { maxDepth = 6, locked = [] } = {}) {
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
const fields = []
function visit(node, pointer, depth, inheritedSecret, inheritedLock) {
const key = pointer.length ? pointer[pointer.length - 1] : ''
const secret = inheritedSecret || (typeof key === 'string' && isSecretKey(key))
const isLocked =
inheritedLock || (pointer.length === 1 && typeof key === 'string' && lockedSet.has(key.toLowerCase()))
if (node.type === 'object' || node.type === 'array') {
const tooDeep = depth >= maxDepth
fields.push({
pointer: [...pointer],
path: pointerPath(pointer),
key: typeof key === 'number' ? `[${key}]` : key,
type: node.type,
depth,
count: node.children.length,
secret,
locked: isLocked,
// An empty container has nothing to infer a member's shape from, and a
// container past the depth limit has more shape than a form should try
// to draw. Both are honest reasons to send somebody to the raw tier.
advanced: tooDeep || node.children.length === 0,
...(tooDeep ? { reason: 'deeper than the form will draw' } : {}),
...(node.children.length === 0 ? { reason: 'empty, so there is no shape to read' } : {}),
})
if (tooDeep) return
node.children.forEach((child, index) => {
visit(child, [...pointer, node.type === 'array' ? index : child.key], depth + 1, secret, isLocked)
})
return
}
fields.push({
pointer: [...pointer],
path: pointerPath(pointer),
key: typeof key === 'number' ? `[${key}]` : key,
type: node.type,
depth,
// A number is reported as its LITERAL as well as its value. The literal is
// what the form must round-trip; the value is for display and sorting.
...(node.type === 'number' ? { raw: node.raw } : {}),
value: node.value,
secret,
locked: isLocked,
// `null` has no type, so there is nothing to render but a raw editor.
advanced: node.type === 'null',
...(node.type === 'null' ? { reason: 'null carries no type to read' } : {}),
})
}
visit(root, [], 0, false, false)
return fields
}
/** Finds the node a pointer names, or null. */
function resolve(root, pointer) {
let node = root
for (const step of pointer) {
if (!node || (node.type !== 'object' && node.type !== 'array')) return null
if (node.type === 'array') {
if (typeof step !== 'number') return null
node = node.children[step]
} else {
node = node.children.find((child) => child.key === step)
}
if (!node) return null
}
return node
}
/** The exact source text a node was read from. */
function literalOf(text, node) {
return String(text).slice(node.start, node.end)
}
/**
* Turns one edit into the literal that will be spliced in, or explains why not.
*
* `raw` is used verbatim for a number — that is the whole mechanism — and is
* validated against the JSON grammar first, because verbatim and unvalidated
* would be a way to write anything at all into somebody's config file.
*/
function literalFor(node, edit) {
if (node.type === 'number') {
const raw = String(edit.raw !== undefined && edit.raw !== null ? edit.raw : edit.value).trim()
if (!JSON_NUMBER.test(raw)) return { error: `'${raw}' is not a number` }
return { literal: raw }
}
if (node.type === 'string') {
if (typeof edit.value !== 'string') return { error: 'expected text' }
return { literal: JSON.stringify(edit.value) }
}
if (node.type === 'boolean') {
if (typeof edit.value !== 'boolean') return { error: 'expected true or false' }
return { literal: edit.value ? 'true' : 'false' }
}
return { error: `a ${node.type} is edited in the raw tier` }
}
/**
* Applies a set of edits to a document and returns the new text.
*
* Spans are spliced from the **end of the document backwards**, so that an
* earlier edit never moves a later edit's offsets. Every edit is resolved and
* checked before any splice happens: a refusal leaves the caller with the
* original text rather than a partly-edited one.
*
* @param {string} text
* @param {Array<{pointer: Array<string|number>, value?: any, raw?: string}>} edits
* @returns {{ text?: string, changes?: object[], error?: string }}
*/
function applyEdits(text, edits, { locked = [] } = {}) {
let root
try {
root = scan(text)
} catch (err) {
return { error: `the file on the server is not valid JSON: ${err.message}` }
}
const lockedSet = new Set(locked.map((k) => String(k).toLowerCase()))
const staged = []
const seen = new Set()
for (const edit of edits || []) {
const pointer = Array.isArray(edit.pointer) ? edit.pointer : null
if (!pointer || pointer.length === 0) return { error: 'an edit must name a field' }
const path = pointerPath(pointer)
if (seen.has(path)) return { error: `'${path}' is edited twice in one save` }
seen.add(path)
if (typeof pointer[0] === 'string' && lockedSet.has(pointer[0].toLowerCase())) {
return { error: `'${path}' cannot be edited from the website` }
}
const node = resolve(root, pointer)
if (!node) return { error: `'${path}' is not in this file` }
const { literal, error } = literalFor(node, edit)
if (error) return { error: `'${path}': ${error}` }
staged.push({
path,
pointer,
start: node.start,
end: node.end,
from: literalOf(text, node),
to: literal,
secret: pointer.some((step) => typeof step === 'string' && isSecretKey(step)),
})
}
// Nothing to do is not an error, but it must not produce a write either: a
// save with no changes would spend a reload — and a reload is the one part of
// this feature that can take a plugin down.
const changed = staged.filter((s) => s.from !== s.to)
if (changed.length === 0) return { text: String(text), changes: [] }
let out = String(text)
for (const edit of [...changed].sort((a, b) => b.start - a.start)) {
out = out.slice(0, edit.start) + edit.to + out.slice(edit.end)
}
// The result must still be JSON. It always is when the pieces are — this is a
// guard against a bug in this file, not against the caller.
try {
scan(out)
} catch (err) {
return { error: `the edit produced something that is not JSON: ${err.message}` }
}
return { text: out, changes: changed.map(redactChange) }
}
/**
* What the audit trail records for one changed field.
*
* **A secret's values are never written down.** The raw tier shows real values
* to an admin who asks for them, which is a deliberate decision (D37) about a
* page somebody has to open — but an activity log is read by more people, for
* longer, and usually by somebody who was not there. Those are different
* exposures and they get different answers.
*/
function redactChange(change) {
return {
path: change.path,
from: change.secret ? '***' : change.from,
to: change.secret ? '***' : change.to,
...(change.secret ? { secret: true } : {}),
}
}
module.exports = {
KINDS,
JSON_NUMBER,
JsonScanError,
scan,
describe,
resolve,
applyEdits,
isSecretKey,
pointerPath,
words,
}