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

@@ -52,11 +52,11 @@ const TIMEOUT_MS = 12000
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
* plugin, and `protocol` in its `overlay.toml`.
*
* **4 — the permission mirror.** Protocol 2 was the read path, 3 the first
* message the WEBSITE originates (`link.confirm`); 4 is the first that WRITES
* to the game — the whole permission set the site authors for one server, and
* the report the plugin sends back. The bump lands here in the same change as
* the emitters,
* **5 — configuration from the site.** Protocol 2 was the read path, 3 the
* first message the WEBSITE originates (`link.confirm`), 4 the first that
* writes to the game's permission store; 5 is the first that writes to the game
* HOST'S FILESYSTEM — a plugin's settings, and a reload watched closely enough
* to be undone. The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -66,7 +66,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 4
const PROTOCOL_VERSION = 5
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -239,6 +239,41 @@ const permCatalogue = (server) => request(server, '/permissions/catalogue')
*/
const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set })
/**
* Every settings file on one game host, and every plugin loaded to reload one
* (protocol 5, R18).
*
* A description of the tree, never its contents: paths, sizes, which files are
* too large to edit, and the plugin each one probably belongs to. **Probably**
* is the operative word and it survives all the way to the form — a folder name
* is convention, not contract, and reloading the wrong plugin would report
* success while the edited one never re-read anything.
*
* Live, like `/status`: what is on a host's disk has no stale answer worth
* giving, and a cached one would be an edit an operator made over SSH that the
* website then overwrote.
*/
const configFiles = (server) => request(server, '/config/files')
/** One settings file as text, with the version a write has to present back. */
const configFile = (server, path) =>
request(server, `/config/file?path=${encodeURIComponent(path)}`)
/**
* Replace a set of settings files and reload what owns them (protocol 5).
*
* **The only call in this module that writes to a filesystem**, and the only one
* whose reply routinely takes seconds: the plugin holds it open across the
* reload it is watching, and across the rollback if that reload never arrives.
*
* Like every other write on this bridge, a refusal comes back `{ ok: true }`
* with the answer in `data.kind` — `config.report` or `config.error`. The
* transport keeps its own codes, and a `504` here is the one case worth reading
* carefully: the plugin writes a whole set or restores a whole set, never half
* of either, so the state is knowable by re-reading rather than by guessing.
*/
const configWrite = (server, body) => request(server, '/config/write', { method: 'POST', body })
module.exports = {
TIMEOUT_MS,
PROTOCOL_VERSION,
@@ -252,5 +287,8 @@ module.exports = {
confirmLink,
permCatalogue,
permSync,
configFiles,
configFile,
configWrite,
joinUrl,
}