feat(rust): mod configuration from the site, and an editor that will not rewrite a float
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

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:
2026-09-22 08:55:28 -05:00
parent b1abd87c3d
commit e54ae3afb9
21 changed files with 2862 additions and 21 deletions

View 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 }