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
80 lines
2.6 KiB
JavaScript
80 lines
2.6 KiB
JavaScript
// ── The SQL half of the configuration audit ───────────────────────────────
|
|
//
|
|
// One table, two questions: record what a save did, and show an operator what
|
|
// has been done to a server lately.
|
|
//
|
|
// Nothing here talks to a game. The game half is `sidecarClient`, and the two
|
|
// are deliberately not mixed: this file is what remains true after the plugin
|
|
// has been reloaded, rolled back, or lost.
|
|
|
|
const core = require('../../core')
|
|
|
|
/**
|
|
* Records one save attempt — including the ones that never reached a file.
|
|
*
|
|
* A refusal is written for the same reason a success is: an operator asking why
|
|
* a setting is not what they set has to be able to see that somebody tried and
|
|
* was told no, and a table that only holds successes answers that question with
|
|
* silence.
|
|
*/
|
|
async function recordWrite(row) {
|
|
await core.query(
|
|
`INSERT INTO rust_config_writes
|
|
(server_id, path, plugin, reload_target, tier, user_id, outcome, reloaded,
|
|
changes, version_before, version_after, detail)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
row.serverId,
|
|
row.path,
|
|
row.plugin || null,
|
|
row.reloadTarget || null,
|
|
row.tier || 'form',
|
|
row.userId || null,
|
|
row.outcome,
|
|
row.reloaded ? 1 : 0,
|
|
row.changes ? JSON.stringify(row.changes) : null,
|
|
row.versionBefore || null,
|
|
row.versionAfter || null,
|
|
row.detail ? String(row.detail).slice(0, 500) : null,
|
|
],
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The recent history for one server, newest first.
|
|
*
|
|
* `changes` comes back parsed, and a row whose JSON will not parse comes back
|
|
* with `null` rather than throwing — a corrupt audit row must not be able to
|
|
* break the page that displays the rest of them.
|
|
*/
|
|
async function recentWrites(serverId, limit = 50) {
|
|
const rows = await core.query(
|
|
`SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier,
|
|
user_id AS userId, outcome, reloaded, changes,
|
|
version_before AS versionBefore, version_after AS versionAfter, detail, created_at AS createdAt
|
|
FROM rust_config_writes
|
|
WHERE server_id = ?
|
|
ORDER BY id DESC
|
|
LIMIT ?`,
|
|
[serverId, Math.max(1, Math.min(Number(limit) || 50, 200))],
|
|
)
|
|
|
|
return rows.map((row) => ({
|
|
...row,
|
|
reloaded: Boolean(row.reloaded),
|
|
changes: parseChanges(row.changes),
|
|
}))
|
|
}
|
|
|
|
function parseChanges(raw) {
|
|
if (!raw) return null
|
|
|
|
try {
|
|
return JSON.parse(raw)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
module.exports = { recordWrite, recentWrites, parseChanges }
|