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

Merged
whitlocktech merged 2 commits from docs/rust-phase-7b into main 2026-09-22 15:01:28 +00:00
4 changed files with 411 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,146 @@ 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 **both live rigs**, from one byte-identical file. On Oxide 2.0.4143,
1,050 new lines of C# `compiled successfully in 0ms` and the plugin loaded; on Carbon 2.0.259.0 it
loaded in 3,267 ms. `rg.config` answers
`protocol=5 framework=oxide root=/home/container/oxide/config` on the first and
**`protocol=5 framework=carbon root=/home/container/carbon/configs`** on the second.
That is two things at once and the second is the more valuable. `Interface.Oxide.ConfigDirectory`
resolves to a *different directory* on each framework, exactly as R18's amendment predicted — a
literal `oxide/config/` in this editor would have missed every config on half of all installs — and
the runtime framework detection, which the `c.reload` fallback depends on, agrees with the host it
is running on. Neither needed a line of conditional compilation, so R19's claim survives protocol
5.
**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

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.
---