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
This commit is contained in:
527
server/configEdit.js
Normal file
527
server/configEdit.js
Normal file
@@ -0,0 +1,527 @@
|
||||
// ── 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,
|
||||
}
|
||||
Reference in New Issue
Block a user