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

@@ -260,6 +260,29 @@ side:
`rg.perms` at the server console prints what the last sync did, which is the fastest way to tell
"that permission does not exist here" from "that player has never been seen here".
**From protocol 5 you can edit your plugins' settings from the website**, in Admin → Rust mod
config. It reads the configuration directory your framework actually uses — `oxide/config` or
`carbon/configs`, or wherever you moved it — and generates a form from the values it finds, so it
works for whatever you have installed. Four things worth knowing before you use it:
- **A save reloads the plugin and watches the reload.** If the plugin does not come back within four
seconds, the old file is **restored automatically** and the site shows you the log line that says
why. A typo costs you a few seconds, not a plugin.
- **Your data directory is not listed, deliberately.** `oxide/data` (or `carbon/data`) holds live
state — kit cooldowns, zone definitions, the permission store itself — not settings. Editing it
from a web form edits your players' cooldowns, and a running plugin overwrites the change on its
next save anyway.
- **Which plugin gets reloaded is your choice, with a guess filled in.** A folder name is
convention, not contract, so the site suggests one and lets you change it. The suggestion is right
nearly always and wrong silently when it is wrong, which is why it is a field rather than an
assumption.
- **This bridge's own `Host`, `Port` and `ServerId` are read-only there.** Changing them from the
website would cut the link carrying the change, or strand every row the site holds for this
server. Edit them on the host; everything else in that file is editable from the site.
`rg.config` at the server console prints which directory the site is reading and what the last write
from it did.
**Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and
stamps it on every frame, so a wipe splits the history rather than ending it. That is also why
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's

View File

@@ -174,3 +174,44 @@ this phase has two specific things to confirm there rather than assume:
**What counts as a pass:** a non-admin player's access in game changes because of something typed on
the website and nothing else; a hand edit is reported rather than undone; and a wipe costs the
operator nothing.
## The configuration walk (protocol 5, phase 7b)
Added 2026-09-22. The website half was walked end to end against a real sidecar and a stand-in
plugin over a real directory of real config files — the recursive walk, a form save, a rollback, a
refusal, a version conflict and the locked keys — and the plugin half **compiles and loads on the
live Oxide rig**, where `rg.config` answers
`protocol=5 framework=oxide root=/home/container/oxide/config`.
**What is left is the sentence the phase exists for: a setting changed on the website takes effect
in the running game.** It needs the sidecar and the game server on **one host**, because the game
link is loopback by design (D2) — on the Pterodactyl rigs that is phase 18's egg, and on a
development machine it is a firewall rule for the port the plugin dials.
| # | Do this | You should see |
|---|---|---|
| 1 | **Open Admin → Rust mod config** and pick the server | The tree the framework actually uses — `oxide/config` on Oxide, `carbon/configs` on Carbon — grouped by plugin, with every loaded plugin's version beside it |
| 2 | **Open `ZoneManager.json`, change a setting, leave the reload target on its guess, and save** | "Saved, and the plugin reloaded." At the console, `oxide.show`/`c.show` is irrelevant — the proof is the plugin behaving differently, so pick a setting you can see: `Auto Show Zones`, or an entry message |
| 3 | **Check a float nobody touched**, e.g. a rate ending `.0`, in the file on the host | It is still `1.0`, not `1`. This is the trap the whole editor exists for, and a server whose configs are full of whole-numbered floats is where it bites |
| 4 | **Break a config on purpose** — in Raw JSON, give a numeric field a string, or anything the plugin's own class cannot deserialize — and save with that plugin as the reload target | Within about four seconds: *"The plugin did not come back, so the old file was put back automatically"*, the compiler's own line underneath it, and the file on the host back as it was. `oxide.plugins` shows the plugin **loaded** — because the restore was reloaded too |
| 5 | **Save a nested file** (`Kits/kits.json`, or any `config/<Mod>/x.json`) **and confirm the reload target** | The right plugin reloads. Reloading the wrong one is the failure this field exists to prevent, and it reports success — so check `oxide.plugins`' timestamps, not the website's word |
| 6 | **Open the bridge's own config** | `Host`, `Port` and `ServerId` are read-only with the reason; `QueueCap` saves; the reload dropdown does not offer this plugin. The save says it was written and **not** reloaded, which is the honest answer — our settings apply on the next deliberate reload |
| 7 | **Edit a file on the host over SSH while the website has it open, then save from the website** | A conflict, with the current file offered — never an overwrite |
| 8 | **Ask for a file outside the tree** (`../data/oxide.users.data`, an absolute path) with `curl` against the sidecar, with a valid token | Refused by the **plugin**, with a reason. The sidecar forwards paths and judges none of them; the guard is where the directory is |
**Run it on both frameworks.** From phase 3, done means done on Oxide and on Carbon (R19/R21), and
this phase has two specific things to confirm rather than assume:
- **The reload path.** The plugin asks `Interface.Oxide` for `ReloadPlugin` by reflection and falls
back to a console command — `c.reload` on Carbon, `oxide.reload` on Oxide, chosen by looking for a
Carbon assembly at runtime. A wrong prefix on Carbon prints **nothing at all**, which looks
exactly like a command that worked (`CARBON.md` §5), so the proof is `OnPluginLoaded` arriving,
not the command being accepted.
- **`OnPluginLoaded` / `OnPluginUnloaded` firing at all.** They are the rollback's only evidence. If
either does not fire on a framework, every save there rolls itself back four seconds later and
reports a plugin that is in fact perfectly fine. `rg.hooks` at the console is the standing answer:
both names are in `ExpectedHooks`, so a framework that never raises one shows a zero.
**What counts as a pass:** a setting typed on the website changes what the running game does; a
deliberately broken config leaves the plugin loaded and the operator holding the reason; and no file
the save did not touch differs by a single byte.

View File

@@ -50,7 +50,7 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer — **4** as of the permission mirror (§10) — declared in
The wire version is a single integer — **5** as of configuration from the site (§11) — declared in
**four** places that must agree:
| Where | Repo |
@@ -882,3 +882,208 @@ caller sent, so no request can arrive claiming to be a different command or aime
at a correlation id somebody else is waiting on.
---
## 11. Protocol 5 — configuration from the site
R18, and the first command on this bridge that writes to the game host's
**filesystem**. Protocol 4 wrote to a store the game owns through an API the game
owns; this replaces bytes in a file and then asks the framework to read them.
```
website sidecar plugin
│ │ │
├── GET /config/files ─────►│ ──── config.list ───────►│ walk ConfigDirectory
│◄──── the tree ────────────│◄──── config.catalogue ───┤ (never DataDirectory)
│ │ │
├── GET /config/file ──────►│ ──── config.read ───────►│ one file + a version
│ │ │
├── POST /config/write ────►│ ──── config.write ──────►│ back up, write,
│ whole file TEXT │ │ reload, WATCH
│◄──── the report ──────────│◄──── config.report ──────┤ …or restore it all
```
**The website composes the bytes and the plugin writes them.** That split is the
one design decision everything else here follows from, and §11.5 is why.
### 11.1 The roots come from the framework, and one of them is forbidden
The walk is rooted at `Interface.Oxide.ConfigDirectory``oxide/config` on
Oxide, `carbon/configs` on Carbon, and neither on a server whose operator moved
it with `-carbon.configdir` ([`CARBON.md`](../modules/rust/CARBON.md) §3). It is
never composed from a literal, and that amendment was proven the best way it
could have been: this bridge's own config landed in **both** places, written by
the same source file.
`DataDirectory` is **never walked**. It holds live state — kit cooldowns, zone
definitions — and both frameworks' own permission stores (`oxide.users.data`,
`oxide.groups.data`), which is protocol 4's mirror one directory over. A
settings editor that strayed there would be editing §10 underneath itself.
### 11.2 `config.list` — a description of the tree, never its contents
```json
{
"kind": "config.catalogue", "type": "reply", "reqId": "r-7",
"root": "/home/container/oxide/config",
"self": "RunicGateway",
"files": [
{ "path": "ZoneManager.json", "bytes": 4210, "modified": 1758500000000,
"plugin": "ZoneManager", "editable": true },
{ "path": "Kits/kits.json", "bytes": 980, "modified": 1758400000000,
"plugin": "Kits", "editable": true },
{ "path": "Huge.json", "bytes": 9400000, "editable": false,
"reason": "larger than this bridge will carry" }
],
"plugins": [ { "name": "ZoneManager", "title": "Zone Manager", "version": "3.1.14" } ],
"truncated": false,
"limits": { "depth": 6, "files": 500, "fileBytes": 262144, "writeFiles": 10 }
}
```
Four things about that shape are load-bearing.
**No file is hashed here.** A version is produced by `config.read`, on the one
file somebody actually opened. Hashing 500 files would be up to 128 MB of reads
in a single frame, which is the unbounded main-thread work §10.5 forbids — so
this walk reads directory entries and nothing else.
**`plugin` is a GUESS and is labelled one all the way to the form.** It is the
folder for a nested file and the filename otherwise, and a folder name is
convention rather than contract. Infer it silently and the failure is the
nastiest available here: the wrong plugin is reloaded, `OnPluginLoaded` fires for
*it*, and the write is reported as a success while the plugin that was actually
edited 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; one who
can see why it was refused does not.
**`self` is the plugin naming itself**, so the website can lock the three keys in
*our* config that would cut this link (§11.6) without matching on a filename
somebody may rename.
### 11.3 `config.read` — one file, and the version a write must present back
```json
{ "kind": "config.file", "type": "reply", "reqId": "r-8",
"path": "ZoneManager.json", "text": "{\n \"Auto Show\": true\n}",
"version": "1a4-3f2c8a91b0de4471", "bytes": 420, "modified": 1758500000000 }
```
`version` is the file's length and an FNV-1a hash of its text. It is deliberately
**not** a cryptographic digest: nothing here is a security claim — the website
never computes one, it only echoes back the one it was given — and
`System.Security.Cryptography` is one more thing that would have to be available
under two plugin compilers.
### 11.4 `config.write` — the set, the reload, and the undo
```json
{ "cmd": "config.write", "reqId": "r-9",
"files": [ { "path": "ZoneManager.json", "version": "1a4-3f2c…", "text": "{…}" } ],
"reload": "ZoneManager" }
```
The plugin, in order:
1. resolves and guards every path (§11.6), checks every version, and checks that
every document parses — **before the first byte is written**. Same posture as
`perm.sync`: a refusal that has touched nothing has nothing to unwind;
2. backs each file up under `DataDirectory/RunicGateway/config-backups/`, keeping
the last ten per file, and holds the original in memory for the rollback;
3. writes the set;
4. reloads the named plugin **through the framework**, not by composing a console
string — Carbon's commands are `c.`-prefixed, an alias for the Oxide names is
opt-in, and a wrong prefix on Carbon prints *nothing*, so it looks exactly
like a command that worked;
5. waits up to **four seconds** for `OnPluginLoaded` naming that plugin;
6. if it arrives, re-reads each file and reports the new versions. If it does
not, **restores every file, reloads again, and reports the failure with the
tail of the server's newest log file.**
```json
{ "kind": "config.report", "type": "reply", "reqId": "r-9",
"ok": false, "reloaded": false, "rolledBack": true,
"reason": "'ZoneManager' did not reload within 4s",
"log": "…Error while compiling ZoneManager…",
"files": [ { "path": "ZoneManager.json", "version": "1a4-…", "rewritten": false } ] }
```
**That rollback is the feature.** Without it this is a web form that takes a
required plugin off a production server one typo at a time — and four plugins are
required (R6/R17), so a broken `ZoneManager` config is also event participation
gone.
Three consequences worth naming:
- **The window is arithmetic, not taste.** The worst path is two windows — wait,
give up, restore, wait again — and the caller holds a socket throughout. It
must fit inside the sidecar's `REPLY_TIMEOUT` (§4.4, 10s), or the rollback
report arrives after the only thing waiting for it has gone. The sidecar
mirrors the number as `web::CONFIG_RELOAD_WINDOW` and a test asserts the
inequality rather than trusting it.
- **`rewritten` is normal.** Both frameworks merge missing defaults into a config
on load and save it back, so the file after a successful reload is regularly
not the file that was sent. The report says so; a website that assumed
otherwise would conflict with itself on the next save.
- **The bridge will not reload itself.** The reload would unload this plugin and
close the link carrying the answer, leaving a rollback with nothing watching
it — the one failure the mechanism exists to report would be the one it could
not. `reload-self` is refused, and our own settings apply on the next
deliberate reload instead.
### 11.5 JavaScript cannot tell `1` from `1.0`, so it never writes the number
`JSON.parse('{"Rate":1.0}')` yields `1` and `JSON.stringify` writes `1`. Both
frameworks deserialize a config into typed C# classes, so a naive
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.
So the website never parses, mutates and re-serialises. Its editor records the
**source span** of every value and splices new literals into them, which is why
`config.write` carries whole file text: the bytes on the wire are the bytes that
will be on disk, and the fields nobody edited are byte-identical. A number's new
value travels as the literal an admin typed, and never becomes a JavaScript
number anywhere in the path.
The plugin's contribution to that is deliberately nothing beyond checking that
the document parses. Giving this end an opinion about content would put the
decision in two places, and only one of them can be tested against a real
Newtonsoft.
### 11.6 Addressing by path is a new bug class, and it is guarded here
Protocol 4 addressed things by name. This addresses them by path, which is
exactly the change that introduces traversal — so the plugin refuses a path that
is absolute, carries a drive letter, contains `..`, does not end in `.json`, or
does not resolve **under the canonicalised config root**. Links are not followed:
any file or directory carrying a reparse point is skipped by the walk and refused
by the resolver, because resolving one is how a tree that looks bounded turns out
not to be.
The sidecar forwards the path verbatim and judges nothing, as it forwards a link
code and a permission set. That is not laziness: only the process holding the
directory can decide whether a path resolves inside it, and a guard in the middle
would be a weaker second opinion in a place with no way to check it.
The website checks the *shape* before spending a round trip, and the bridge's own
three keys — `Host`, `Port`, `ServerId` — are refused there rather than here,
because "which file is ours" is a question about the website's configuration, not
about the game's.
### 11.7 What the sidecar does NOT do
It stores nothing. Nothing from protocol 5 reaches the store or the feed: a
config this sidecar cached would be an edit an operator made over SSH that the
website then silently overwrote. All three routes fail when the game is down,
like `/status`, because "what is on that host's disk" has no stale answer worth
giving.
The one thing it adds is a better `504`. A timeout on `/config/write` is the only
timeout on this bridge with a knowable answer, because the plugin writes a whole
set or restores a whole set and never half of either — so the body says to
re-read rather than to guess, and names the reload window that is probably still
running.
---