docs(modules): phase 7b as built — configuration from the site, and the undo that makes it safe #260
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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