docs(modules): phase 7b as built — configuration from the site, and the undo that makes it safe

PROTOCOL.md §11 specifies protocol 5: the walk rooted at the framework's own
config directory, the version a write must present back, the set-shaped write,
the watched reload and the automatic restore. PLAN.md §21 is the phase as built
— four org-lead decisions, the float trap and what avoiding it cost, and four
defects a browser found that 179 green tests did not.

PLAYER_WALK.md gains the configuration walk, because the acceptance line needs
the sidecar and the game on one host and the rig cannot reach one yet.
INTEGRATION.md gains the four things an operator should know before they edit a
plugin's settings from a web page.

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:39 -05:00
parent 132205f0f4
commit 82145d3b4a
4 changed files with 404 additions and 2 deletions

View File

@@ -985,7 +985,7 @@ Each phase ends with its findings written down, as every workstream here does.
| 5 | **Android leg A** (R10). ✅ **Done 2026-09-17 — as built and findings in §18.** The server list and one server with four tabs, gated on a NEW capability the module had to declare (D16 — its five named surfaces, and a client needs one that names the module); a poll that keeps its rows when it fails, which the app had no shape for (D17); the drawer badge as D15 translated (D19). Four decisions (§18.0) and **three defects an emulator walk found that 644 green tests did not** (§18.4). **Both halves of the criterion walked on one device against two cores** | Android-app + Module-Rust + docs | The app renders a Rust site it has never seen, and a UO site unchanged |
| 6 | **Identity** (R1), and the `admin.users.detail` slot (R13). ✅ **Built 2026-09-21 — as built and findings in §19.** Protocol 3: `/link` and `/unlink` in chat, codes in plugin memory, `link.confirm` as the first command the website originates; the site is the author of record and the game holds nothing. Seven org-lead decisions (§19.0), the slot declared in three registries (§19.2), a hole it found in this repo's own OpenAPI generator (§19.3) and **three defects a browser walk found that 122 green tests did not** (§19.4) — including every refusal sentence being invisible, because core's client reads `message` and this module had answered `error` since phase 1. **The site's half is walked; the code-from-the-game half is written down as a walk to run** (§19.6) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
| 7 | **Site-owned permissions** (R2). ✅ **Built 2026-09-21 — as built and findings in §20.** Protocol 4: one verb carrying the whole desired set, diffed by the plugin and drained in bounded steps; groups mirrored as groups; drift reported with adopt and revoke; the `PermissionExists` pre-check as the mechanism; the permission hooks as a live drift *signal* rather than the record. Seven org-lead decisions (§20.0), four defects a browser walk found and a fifth a test did (§20.5). **The site's half is walked end to end, including a restart that emptied the store; the acceptance line below is NOT met** — it needs a second, non-admin Steam account on the rig (§12.5, §20.7) | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one |
| 7b | **Mod configuration from the site** (R18). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
| 7b | **Mod configuration from the site** (R18). **Built 2026-09-22 — as built and findings in §21.** Protocol 5: a recursive walk rooted at the framework's own `ConfigDirectory`, a form generated from the values, a raw tier, an explicit reload target, and a write that backs the set up, reloads, watches `OnPluginLoaded` and **restores everything automatically** when it does not arrive. Four org-lead decisions (§21.0), a span-splicing editor so no untouched float is ever rewritten (§21.1), and **four defects a browser found that 179 green tests did not** (§21.5) — the worst being that every save of the bridge's own config was refused while the page said otherwise. **The site's half is walked end to end and the plugin compiles and loads on the live Oxide rig; the acceptance line below needs the sidecar and the game on one host** (§21.4) | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `<Mod>/x.json` is found and reloads the right plugin |
| 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app |
| 9 | **Teams from first-party clans** (R5). Membership event-driven, leadership read off `LocalClan` at snapshot; **`declareModuleSlot` × 3** for core's `team.notify` / `team.activity` / `team.forum` | Module-Rust + 2 | The clan page is ours, core's contributions land in places we named, and every slot empty still reads correctly |
| 10 | **Notifications and engagement** (R7). Streams, triggers with `ceiling` and `subjectKey`, audiences, engagement seeds, announce leg, post hook — **the catalogue is §10**, including the in-game-popup question | Module-Rust + docs | The offline raid alert reaches the player whose base it was, and nobody else |
@@ -3068,6 +3068,139 @@ identity), not a claim.
---
## 21. Phase 7b as built — configuration from the site, 2026-09-22
**The site's half is walked end to end against a real sidecar; the plugin's half is proven to
compile and load on a live Oxide rig, and the in-game leg is still blocked on the same firewall rule
phases 6 and 7 hit.** R18 is the second thing this module does *to* a game, and the first that
writes to the game host's filesystem.
Four repositories touched: [`Rust-Plugins#5`][rp], [`Rust-Link#5`][rl], [`Module-Rust#9`][mr] (all
into `edge`) and this document. Core needed no change again — the second Rust phase running.
### 21.0 The four decisions this phase needed
| | Decision |
|---|---|
| **D35** | **The website composes the file's bytes; the plugin writes them.** The generated form sends pointers and literals, a span-splicing editor on the Node side produces the new text, and `config.write` carries whole file text. The alternative — sending key/value edits for Newtonsoft to apply, where C# genuinely can tell `1` from `1.0` — was considered and rejected: it gives the plugin authority over content, puts the decision in a place with no test for it, and still needs the text path for the raw tier. |
| **D36** | **A number travels as the literal an admin typed**, all the way from the browser to disk, and never becomes a JavaScript number. `2.50` stays `2.50`. |
| **D37** | **The raw-JSON tier shows real values, including credentials.** An admin can already read the file over SSH, and a masked raw tier would need the server to splice the originals back into whatever was submitted. The generated form still masks a credential-shaped field behind a *Show* control, and **the audit trail never records the values either way** — a log is read by more people, for longer, and usually by somebody who was not there. |
| **D38** | **The bridge's own config is editable, with three keys locked.** `Host` and `Port` carry the link the edit is travelling over; `ServerId` keys every row this site holds. All three render read-only with the reason; `QueueCap` and anything added later stay editable. Hiding our config entirely was rejected — it reverses D3 — and so was warn-only, because a typo in `Port` orphans the server with no path back. |
### 21.1 The trap, and what it cost to avoid
**JavaScript cannot tell `1` from `1.0`.** `JSON.parse('{"Rate":1.0}')` is `1` and `JSON.stringify`
writes `1`, while both frameworks deserialize into typed C# classes — so a read-modify-write
silently rewrites every whole-numbered float as an integer *on fields nobody touched*, and Newtonsoft
may coerce that or may throw. A throw at load is a plugin that does not come back, and R6/R17 make
four of them required.
`server/configEdit.js` is the answer: a JSON reader that records the **source span** of every value,
and an `applyEdits` that splices literals into those spans from the end of the document backwards.
Nothing parses, mutates and re-serialises. Everything a save did not touch is byte-identical —
indentation, key order, and the `.0` on a float.
Its suite asserts the failure as well as the fix: the same document through `JSON.parse` /
`JSON.stringify` loses `1.0`, `2.50` and `1e3`, and the test says so in the same breath as proving
the splice keeps them. **A walk on the live rig wrote `2.50` into `ZoneManager.json` and left
`500.0` and a `null` exactly as they were.**
Two rules fall out and both are deliberate: a number's new value arrives as **text** an admin typed,
and the generated form is **type-preserving** — changing what a value *is* belongs in the form,
changing what *kind* of thing it is belongs in the raw tier.
### 21.2 The rollback is the feature, and the window is arithmetic
The plugin backs the set up, writes it, reloads the target through the framework, waits four seconds
for `OnPluginLoaded`, and — if it never arrives — **restores every file, reloads again, and reports
the failure with the tail of the newest log file.** Without that, R18 is a web form that takes a
required plugin off a production server one typo at a time.
Four seconds is not taste. The worst path is two windows — wait, give up, restore, wait again —
while the caller holds a socket, so it has to fit inside the sidecar's 10s `REPLY_TIMEOUT`. The
sidecar mirrors the number as `web::CONFIG_RELOAD_WINDOW` and **a test asserts the inequality**
rather than trusting the comment, in the shape phase 3 established for `budgetMs`.
Walked on the rig: a save whose reload never announced itself came back `200` with
`rolledBack: true`, the file on disk was the original, and the compiler line was on the screen.
**A rollback is a round trip that worked carrying bad news** — reporting it as a 5xx would throw
away the only diagnosis there is.
### 21.3 The bridge will not reload itself
Reloading this plugin would unload it, close the link the answer travels on, and leave a rollback
with nothing watching it: the one failure the whole mechanism exists to report would be the one it
could not. `reload-self` is refused at the plugin, the website leaves it out of the dropdown, and
our own settings apply on the next deliberate reload.
That produced the phase's best defect (§21.5, #1).
### 21.4 What the walk proved, and on what
Two rigs, because the site half and the plugin half fail in different places.
- **The site half** ran against the real sidecar and a stand-in plugin over a real directory of real
config files: the recursive walk (including a nested `Kits/kits.json`), a form save that reloaded,
a save that rolled back, a refusal, a write with no reload, a version conflict with the current
file handed back, and the locked keys. Five outcomes, all five in the audit trail.
- **The plugin half** ran on the **live Oxide rig**: 1,050 new lines of C# `compiled successfully in
0ms`, the plugin loaded, and `rg.config` answered
`protocol=5 framework=oxide root=/home/container/oxide/config`. That is `Interface.Oxide.
ConfigDirectory` resolving and the runtime framework detection agreeing, on a real server, without
a line of conditional compilation.
**The acceptance line is not met**: "an admin flips a ZoneManager setting from the website and it
takes effect" needs the plugin and the sidecar on one host, and the rig's plugin still cannot reach
a sidecar on the development machine — two `Block` rules for `rust-link-sidecar.exe` in Windows
Firewall, the same wall phases 6 and 7 stopped at. It is the same walk each time and it is written
down in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
### 21.5 Four defects a browser found that 179 green tests did not
1. **Every save of the bridge's own config was refused**, with the page saying the opposite. The
reload target was seeded from the file's guessed plugin — `RunicGateway` — which is deliberately
*not* offered in the dropdown, and **a `<select>` whose value matches no `<option>` displays the
first one**. So the screen read "nothing — just write the file" while the request carried
`reload: RunicGateway`, and the plugin refused it for a reason the page had just said did not
apply. D38's "editable except three keys" was worth nothing. A guess is now only taken when the
dropdown actually offers it.
2. **`btn ghost` is not a class this platform defines** — core's CSS has `.btn-ghost` — so every
"secondary" button in this module has rendered as a primary one **since phase 7**, and on this
page it meant the open file and the active tier were indistinguishable from the closed and
inactive ones. An unknown class fails silently: the button still renders, just as the wrong one.
Fixed here and on the three pages phase 6 and 7 shipped.
3. **The save's refusal rendered at the top of the page**, while the button that caused it is at the
bottom of a long form — a click that visibly did nothing. It is beside the button now.
4. **Core's module loader cannot see a symlinked module directory.** `loader.js` filters
`readdirSync(..., { withFileTypes: true })` on `isDirectory()`, and a Windows junction is a
symlink, so junctioning the working tree into a rig's `modules/` makes the module silently
invisible. Not a product defect — nothing ships that way — but it costs a confused restart, and
the rig recipe now copies rather than links.
### 21.6 Smaller things worth keeping
- **`config.list` hashes nothing.** A version comes from `config.read`, on the one file somebody
opened. Hashing 500 files would be up to 128 MB of reads in one frame, which is the unbounded
main-thread work §10.5 forbids.
- **The reload target is a field with a guess, never an inference.** A folder name is convention,
not contract: infer it silently and the wrong plugin is reloaded, `OnPluginLoaded` fires for *it*,
and the write reports success while the edited plugin never re-read anything.
- **A file past a limit is listed and marked, never hidden.** An operator who cannot find a file they
know exists goes looking for a bug in the bridge.
- **`rewritten` is normal**: both frameworks merge missing defaults on load and save the file back,
so the file after a good reload is regularly not the file that was sent. The report says so.
- **The version is an FNV-1a hash, not a digest.** Nothing here is a security claim — the website
never computes one, it only echoes back what it was given — and one fewer namespace has to be
available under two plugin compilers.
- **A path from a web form is a traversal surface**, and the guard lives on the host: canonicalise,
assert under the root, refuse absolute paths, drive letters, `..` and reparse points. The sidecar
forwards the path and judges nothing, because only the process holding the directory can decide;
the website checks the *shape* to save a round trip and never pretends that is the boundary.
- **Still no module-declared site permission** at MODULE_API 1.10.0, exactly as §20 predicted, so
`requireRole('admin')` is again the whole vocabulary. Two phases have now wanted the same member.
---
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
[rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins
[mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust