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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user