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

@@ -114,7 +114,7 @@ function ServerState({ row, onSync, busy }) {
{row.lastOkAt ? `last pushed ${ago(row.lastOkAt)}` : 'never pushed'}
</span>
<span style={{ flex: 1 }} />
<button type="button" className="btn ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
<button type="button" className="btn btn-ghost" onClick={() => onSync(row.serverId)} disabled={busy}>
{busy ? 'Syncing…' : 'Sync now'}
</button>
</div>
@@ -158,10 +158,10 @@ function DriftRow({ row, onAdopt, onRevoke, busy }) {
{row.serverId} · seen {ago(row.firstSeen)}
</span>
</span>
<button type="button" className="btn ghost" onClick={() => onAdopt(row)} disabled={busy}>
<button type="button" className="btn btn-ghost" onClick={() => onAdopt(row)} disabled={busy}>
Adopt
</button>
<button type="button" className="btn ghost" onClick={() => onRevoke(row)} disabled={busy}>
<button type="button" className="btn btn-ghost" onClick={() => onRevoke(row)} disabled={busy}>
Revoke
</button>
</Row>
@@ -224,7 +224,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
actions={
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.deleteGroup(group.name))}
>
@@ -248,7 +248,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
)}
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => save(group.permissions.filter((p) => p !== perm))}
>
@@ -317,7 +317,7 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
</span>
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.removeMember(group.name, m.userId))}
>
@@ -415,7 +415,7 @@ export default function Permissions() {
title="Servers"
subtitle={`${servers.length} configured`}
actions={
<button type="button" className="btn ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => act(() => api.adminPermissions.sync())}>
Sync all
</button>
}
@@ -490,7 +490,7 @@ export default function Permissions() {
</span>
<button
type="button"
className="btn ghost"
className="btn btn-ghost"
disabled={busy}
onClick={() => act(() => api.adminPermissions.revoke(row.id))}
>