diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index a9b0115..be90ad9 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -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 `/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 `/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 `