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:
79
server/model/config/config.db.js
Normal file
79
server/model/config/config.db.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// ── 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 }
|
||||
Reference in New Issue
Block a user