// ── 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, 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, }