Compare commits
13 Commits
2f24236993
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a4c4476c3e | |||
| 3b7b9cca4f | |||
| eb4a8bcafc | |||
| 11b8965aa7 | |||
| 15d64b28fe | |||
| 7f008fd1f3 | |||
| 82145d3b4a | |||
| 132205f0f4 | |||
| bba2ab04e0 | |||
| 4fe8864939 | |||
| e35880e713 | |||
| 54b4059091 | |||
| dfdb0a3f63 |
221
android/PLAN.md
221
android/PLAN.md
@@ -1307,6 +1307,227 @@ push, and Play (M6–M8) follow the designed app.
|
|||||||
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
|
an inbox event link opening the app natively while a forum link still opened a Custom Tab; and
|
||||||
participation history self-scoped, proved by two accounts rather than asserted.
|
participation history self-scoped, proved by two accounts rather than asserted.
|
||||||
|
|
||||||
|
15. **M14 — the Rust module in the app** (post-v1; built 2026-09-17). The platform's **second game
|
||||||
|
module** reached its first public pages in `module-rust` phase 4
|
||||||
|
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §17), and this is phase 5 — the app's leg.
|
||||||
|
R10 has each Android leg trail the website surface it consumes by exactly one phase, so every
|
||||||
|
route here existed and answered before a line of Kotlin was written.
|
||||||
|
|
||||||
|
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §17 for the surface
|
||||||
|
this mirrors and §18 for this phase as built. The contract is normative there; this entry records
|
||||||
|
what the app does about it.
|
||||||
|
|
||||||
|
**No backend work beyond one word.** The five public routes were live. The one change is
|
||||||
|
Module-Rust#5, which adds `rust` to the module's `capabilities` — see the gate below.
|
||||||
|
|
||||||
|
#### Why this is not the shard screens with a different name
|
||||||
|
|
||||||
|
The two games have genuinely different shapes, and collapsing them would have cost the app the
|
||||||
|
thing that makes each legible. **UO is one shard: a place**, five drawer rows, a live SSE stream.
|
||||||
|
**Rust is a fleet**: a list, and one page beneath it with four tabs. The app grows a second route
|
||||||
|
tree rather than a second meaning for `shard/`, and both can be installed on one backend — in
|
||||||
|
which case both trees exist at once and neither row appears on a site without its module.
|
||||||
|
|
||||||
|
| Screen | Route | Reads |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Rust servers** (the list) | `rust` | `GET /public/rust/servers` |
|
||||||
|
| **One server** (four tabs) | `rust/servers/{serverId}` | `…/:id`, `…/:id/events`, `…/leaderboard`, `…/online`, `…/wipes` |
|
||||||
|
|
||||||
|
#### Four decisions, taken by the org lead on 2026-09-16
|
||||||
|
|
||||||
|
- **D16 — the gate is a new capability, `rust`.** `module-uo`'s five shard rows all hang on one
|
||||||
|
string, `shard`, because that is the only question a capability can answer: *is the module
|
||||||
|
there*. `module-rust` declared five and every one named a **surface** — `servers`, `killfeed`,
|
||||||
|
`leaderboard`, `presence`, `wipes`. Core flattens every started module's capabilities into a
|
||||||
|
single list, so gating on `servers` would let another module declaring that word silently reveal
|
||||||
|
these screens on a site that does not run Rust. Gating on the module **id** was considered and
|
||||||
|
rejected: `id` is a mount prefix (§2.1 requires it to equal the directory core loads from), and
|
||||||
|
`MODULE_API.md` §2.9 forbids a client inferring a route from a capability — making the two the
|
||||||
|
same thing would quietly end that separation. So the module declares its own name as a sixth
|
||||||
|
string, asserted in its suite against `module.json`'s own `id` so the two cannot drift.
|
||||||
|
- **D17 — poll every 20s while the screen is RESUMED**, the phone's version of D14's Page
|
||||||
|
Visibility gate. Immediate refresh on return to the foreground; nothing at all while away.
|
||||||
|
- **D18 — the Rust repositories move to `edge`** for the rest of the workstream, with releases at
|
||||||
|
the cutover rather than per phase. `pr-checks.yml` in all four repositories already triggers on
|
||||||
|
`[main, edge]`, so this costs no CI — the trap that made all nine M12 phase PRs land unchecked
|
||||||
|
was closed in engagement Phase 8.
|
||||||
|
- **D19 — the drawer row carries a live player count**, and NavPaths learns `/rust`.
|
||||||
|
|
||||||
|
#### A refresh is not a load, and the app had only ever done loads
|
||||||
|
|
||||||
|
The app has had exactly one shape for a read since M1: set `Loading`, ask, replace. That is right
|
||||||
|
for opening a screen and wrong for a poll — a twenty-second refresh built on it clears the
|
||||||
|
killfeed, renders a spinner in its place and re-fills it, three times a minute, for ever. **The
|
||||||
|
website hit the same wall one tier along**, which is why `module-rust` bundles its own `usePolled`
|
||||||
|
instead of using core's `useAsync` (§17.3). `ui/Polling.kt` is that hook's other half:
|
||||||
|
|
||||||
|
- `refreshInto` — **a refresh is invisible when it succeeds and keeps the rows when it fails.** A
|
||||||
|
failure with rows on screen keeps them and reports the failure beside them; a failure with
|
||||||
|
nothing on screen is an ordinary error with a retry, because there is nothing to protect.
|
||||||
|
- `PollWhileResumed` — `repeatOnLifecycle(RESUMED)`, which buys three behaviours from one line: no
|
||||||
|
requests at all while backgrounded, an immediate refresh on return, and a pause behind a dialog
|
||||||
|
or the recents switcher. `STARTED` would keep polling for a reader who is not reading.
|
||||||
|
|
||||||
|
Only the **visible** live panel is polled. The leaderboard and the wipe list never are: a
|
||||||
|
leaderboard that re-sorted itself under a finger every twenty seconds would be worse than a stale
|
||||||
|
one. Changing the filter, the sort or the wipe **is** a different question, so that panel blanks
|
||||||
|
and loads — leaving the old rows up would show last wipe's killfeed under this wipe's heading.
|
||||||
|
|
||||||
|
#### The drawer badge is D15 translated, not D15 copied
|
||||||
|
|
||||||
|
D15 put a live count in core's `site.footer.status` slot, which works because every page of the
|
||||||
|
website renders the same footer. The app has no footer and no slot. What it has is a drawer row
|
||||||
|
per surface and, since engagement Phase 8, a precedent for a number beside one — the inbox's
|
||||||
|
unread badge, in the `NavigationDrawerItem` badge slot, with a `contentDescription` so a screen
|
||||||
|
reader says "42 players online" rather than "42". The count rides there, and keeps the website
|
||||||
|
version's three rules: **zero renders nothing** (an empty fleet is not a notification), a failed
|
||||||
|
read keeps the last number, and it never polls. It is asked for only where the module is
|
||||||
|
installed, so a UO site makes no request at all.
|
||||||
|
|
||||||
|
#### Verified
|
||||||
|
|
||||||
|
The app suite (**644 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||||
|
against the phase-4 rig — a core with the module installed, one live server and one seeded fixture
|
||||||
|
that has never reported.
|
||||||
|
|
||||||
|
**Both halves of the phase criterion, directly.** With its server unreachable and reading
|
||||||
|
*Offline*, the page still rendered its map, size, seed, wipe date, killfeed, per-wipe and all-time
|
||||||
|
leaderboards, its last known presence board and its wipe history. The same app pointed at the UO
|
||||||
|
core showed Shard / Rules / Atlas / Leaderboards / Market and **no Rust row**.
|
||||||
|
|
||||||
|
Also proven rather than asserted: `refreshInto` against a genuinely dead backend (the core was
|
||||||
|
stopped with the list on screen; a poll tick later the rows were unchanged under one quiet line);
|
||||||
|
R12's arithmetic on a phone (all-time 59 = 41 + 18, and a player who appears only in the older
|
||||||
|
wipe **drops out** of it rather than reading zero); every `describe` branch from real rows,
|
||||||
|
including the fall that must not read as a kill by nobody; the calendar-day rule, filtering to the
|
||||||
|
August wipe and getting three rows six weeks old, each unmistakably dated; and the badge.
|
||||||
|
|
||||||
|
#### The walk found three defects, and 644 green tests found none of them
|
||||||
|
|
||||||
|
- **The drawer's live count resolved once per process.** It was keyed on the capability answer
|
||||||
|
alone, so it was read at connect and never again — which is not what *live* means on a row
|
||||||
|
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge.
|
||||||
|
- **Every card's text sat flush against its edge.** `ShardCard` is the themed `Card` and carries
|
||||||
|
no padding of its own; each caller pads its own content, and these four did not. On a phone the
|
||||||
|
first glyph of each line read as clipped.
|
||||||
|
- **A name touched its own kill count.** Five numeric columns beside an equal-weight name column
|
||||||
|
left *Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes,
|
||||||
|
and the **active sort is marked on the header** rather than by tinting a column of numbers — the
|
||||||
|
header is the control, and tinting the values says *these are special* instead of *this is what
|
||||||
|
the table is ordered by*.
|
||||||
|
|
||||||
|
#### The rig note worth keeping
|
||||||
|
|
||||||
|
The debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost` only**
|
||||||
|
— not `10.0.2.2`. An emulator walk against a local core therefore needs
|
||||||
|
`adb reverse tcp:<port> tcp:<port>` and the loopback address; typed as `10.0.2.2` every request
|
||||||
|
fails with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, which the
|
||||||
|
connect screen reports — correctly, and indistinguishably from a core that is not running.
|
||||||
|
|
||||||
|
- **Excluded**, in the same class as every earlier milestone's exclusions: the Rust **admin**
|
||||||
|
surface. Server configuration, the sidecar token and the connection test are admin
|
||||||
|
*configuration*, which the app consumes and does not edit. Identity and permissions are legs B
|
||||||
|
and C (phases 8 and 11), and the map is leg D.
|
||||||
|
|
||||||
|
16. **M15 — the player's own Rust account** (post-v1; built 2026-09-22). `module-rust` phase 8, and
|
||||||
|
R10's leg B: identity (phase 6) and the half of site-owned permissions (phase 7) a player is
|
||||||
|
allowed to see. Every route existed and answered before a line of Kotlin was written except one,
|
||||||
|
`GET /player/rust/permissions`, which this phase added on the website for exactly this screen.
|
||||||
|
|
||||||
|
**Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §22 for the phase as
|
||||||
|
built and its three decisions. The contract is normative there; this entry records what the app
|
||||||
|
does about it.
|
||||||
|
|
||||||
|
#### It is `CharactersScreen` for a different game, deliberately
|
||||||
|
|
||||||
|
The org lead's instruction was to mirror `module-uo`, and the mirror is exact: **one drawer row
|
||||||
|
under the player group**, with the code card at the top of the screen it opens — the same shape
|
||||||
|
UO has had since M4, where the `[link` card sits above the character rosters. Two alternatives
|
||||||
|
were rejected for reasons the mirror makes obvious: a tab under one Rust server (a link is
|
||||||
|
**fleet-wide** — one Steam account is one person on every server, while stats are per server and
|
||||||
|
per wipe), and a section inside core's own Account screen (the app has no slot mechanism, so the
|
||||||
|
module's data would be hard-wired into a core screen).
|
||||||
|
|
||||||
|
| Screen | Route | Reads |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **My Rust account** | `player/rust` | `GET /player/rust/links`, `POST /player/rust/link`, `DELETE …/links/{steamId}`, `GET /player/rust/permissions` |
|
||||||
|
|
||||||
|
#### The gate is `rust`, and `PLAYER` means staff too
|
||||||
|
|
||||||
|
`module-uo`'s five shard rows all hang on `shard`, and this hangs on `rust` for the same reason
|
||||||
|
D16 gave: a capability answers *is the module there*, and core flattens every started module's
|
||||||
|
capabilities into one list, so a surface word like `identity` is not something a row may hang
|
||||||
|
on. `MenuAccess.PLAYER` is `isPlayer || isStaff`, which is right here — `/player/rust/*` is
|
||||||
|
`requireAuth` with no role above it, and staff play the game as well.
|
||||||
|
|
||||||
|
#### Two reads, and neither blocks the other
|
||||||
|
|
||||||
|
The accounts and the entitlements load separately and fail separately. That is not tidiness: an
|
||||||
|
entitlement is authored against the **website** account, so it exists before a Steam id does, and
|
||||||
|
the person who has just been given something and has not linked yet is exactly the one who needs
|
||||||
|
to see both halves at once. The screen says so in as many words when nothing is linked.
|
||||||
|
|
||||||
|
**The app does no scope arithmetic.** `*` never reaches a screen: each entry arrives carrying the
|
||||||
|
servers its scope reaches, each already marked *has it* or *waiting*, because a second
|
||||||
|
implementation of that rule is a second thing to keep true.
|
||||||
|
|
||||||
|
#### The refusals stay four pieces of advice
|
||||||
|
|
||||||
|
A refusal is chosen by **status** and rendered from a string resource, the convention every
|
||||||
|
earlier milestone follows (the app is localized; the website's sentence is not). 400 is a spent
|
||||||
|
code, 409 is a Steam account another website account holds — released with `/unlink` in game,
|
||||||
|
never moved silently — 429 is the server's limiter, and 503 is a server that could not be
|
||||||
|
reached. The last of those may **not** say "get a new code": the code is still good, and a player
|
||||||
|
told otherwise goes back to the same unreachable server for another one.
|
||||||
|
|
||||||
|
The known cost, written down rather than discovered later: the website distinguishes three 503s
|
||||||
|
by sentence (one server unreachable, the whole fleet down, no servers configured at all) and the
|
||||||
|
app has one string for the status, written to be true of all three.
|
||||||
|
|
||||||
|
#### Verified
|
||||||
|
|
||||||
|
The app suite (**657 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk
|
||||||
|
against a core with the module installed and a **live Oxide rig** behind it — the first Rust leg
|
||||||
|
where the backend was talking to a real game server rather than a stand-in.
|
||||||
|
|
||||||
|
Walked directly: the row absent signed-out and absent on a UO site, present for a signed-in
|
||||||
|
player; both reads; a code the live plugin genuinely refused, with its advice rendering **beside
|
||||||
|
the button** rather than at the top of a long form; a rank marked *has it* and a grant marked
|
||||||
|
*waiting* on the same screen, which is what the pushed ledger actually said; and the release.
|
||||||
|
|
||||||
|
#### The walk found one thing the suite did not
|
||||||
|
|
||||||
|
**The row said who, and not when or where.** The website's row has always read "linked just now
|
||||||
|
on rust-oxide"; the app's carried the name and the Steam id and stopped. Neither fact is part of
|
||||||
|
the identity — a link is fleet-wide — but which server minted the code is where a support
|
||||||
|
conversation starts, and the module's own schema says so in a comment.
|
||||||
|
|
||||||
|
- **Excluded**, in the same class as M14's exclusions: the Rust **admin** permission surface.
|
||||||
|
Authoring grants, groups and drift is `requireRole('admin')` on the website, the app has no
|
||||||
|
admin user-detail screen to put it in, and it writes into a running game — the phone is where
|
||||||
|
you read what you hold, not where you decide what somebody else holds.
|
||||||
|
- **No deep link yet.** `/player/rust` is not in the app's web-path table, deliberately:
|
||||||
|
`module-uo`'s player screens are not either, and that table is built from the *public* nav.
|
||||||
|
Phase 10 is when it will matter, because a notification about an entitlement will want
|
||||||
|
somewhere to land.
|
||||||
|
|
||||||
|
#### Amended 2026-09-22 — M14's Online tab and feed stop naming players by default
|
||||||
|
|
||||||
|
Not a milestone of its own: a correction to what M14 shipped, made on the website first
|
||||||
|
([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §23). **Nothing names who is online by
|
||||||
|
default** — the module now withholds the Online list, and every feed item that says a named
|
||||||
|
player was on, from anyone below an operator-chosen audience (staff unless widened), while the
|
||||||
|
player **count** stays public.
|
||||||
|
|
||||||
|
The app's part is to never read that as *nobody is on*. `RustOnlineDto` carries `hidden`,
|
||||||
|
`count` and `audience`, and `RustEventListDto` carries `presenceHidden` and `presenceAudience`;
|
||||||
|
the repository and view model keep the whole answer rather than its rows. The Online tab says
|
||||||
|
"2 players online" and who can see the names; the feed says once, above the rows, that joins,
|
||||||
|
deaths and chat are not shown. An older module without the flags decodes as visible, as before.
|
||||||
|
Walked on an emulator: the withheld panel at the default, and the names arriving on the next poll
|
||||||
|
once the fleet was widened to signed-in — the app's bearer session reaching the module's viewer
|
||||||
|
check. Branch `fix/rust-presence-visibility`.
|
||||||
|
|
||||||
### Deferred (not a milestone)
|
### Deferred (not a milestone)
|
||||||
|
|
||||||
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
- **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is
|
||||||
|
|||||||
@@ -982,12 +982,12 @@ Each phase ends with its findings written down, as every workstream here does.
|
|||||||
| 2 | **Packaging and release.** ✅ **Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
|
| 2 | **Packaging and release.** ✅ **Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` |
|
||||||
| 3 | **The read path, on both frameworks.** ✅ **Built and largely proven 2026-09-16 — as built and findings in §16.** Protocol 2: fifteen hooks, an envelope every frame carries, boards, a cursor feed and bounded history; four org-lead decisions (§16.0), two defects only a booted server could find (§16.2), and CI for the two bridge repositories that had none. **The player half of the catalogue is written down as a walk to run rather than measured** — see §16.7. First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
|
| 3 | **The read path, on both frameworks.** ✅ **Built and largely proven 2026-09-16 — as built and findings in §16.** Protocol 2: fifteen hooks, an envelope every frame carries, boards, a cursor feed and bounded history; four org-lead decisions (§16.0), two defects only a booted server could find (§16.2), and CI for the two bridge repositories that had none. **The player half of the catalogue is written down as a walk to run rather than measured** — see §16.7. First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** |
|
||||||
| 4 | **The first pages.** ✅ **Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off |
|
| 4 | **The first pages.** ✅ **Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off |
|
||||||
| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged |
|
| 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) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page |
|
| 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). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported. The `PermissionExists` pre-check stays the mechanism on **both** frameworks (R19); Carbon's 14 permission hooks are tested here as a possible live drift signal, and suppressed against our own pushes if they fire | 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 |
|
| 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 |
|
| 8 | **Android leg B** (R10), and the half of R2 a player may see. ✅ **Built 2026-09-22 — as built and findings in §22.** One drawer row under the player group shaped like `module-uo`'s own (the code card first, then what the code got them), gated on `rust` and `PLAYER`; the four refusals kept four pieces of advice; and **one new website route**, `GET /player/rust/permissions`, because phase 7 gave an operator every view of in-game privilege and a player none. Three org-lead decisions (§22.0), the scope arithmetic answered on the server so `inScope` exists once (§22.1), and **the rig moved the sidecar inside the game container** ([`INSTALL_RIG.md`](../../rust-link/INSTALL_RIG.md)), which retired the firewall wall phases 6, 7 and 7b each stopped at | Android-app + Module-Rust + docs | 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 |
|
| 9 | **Teams from first-party clans** (R5). *Preceded by the presence fix (§23), which this phase's roster question produced.* 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 |
|
| 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 |
|
||||||
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
|
| 11 | **Android leg C** (R10). Inbox and notification preferences for Rust triggers | Android-app | A Rust notification arrives on a phone and can be switched off there |
|
||||||
| 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking |
|
| 12 | **Events: budgets, option sources and the leases** (§9). [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised | Module-Rust + 2 | A leased value is observed changing in the running game and restored, per key; `rust.group.membership` expires without core asking |
|
||||||
@@ -2583,6 +2583,832 @@ file and nothing else on the site changes.
|
|||||||
What is **not** proven here and is deliberately left: the pages have not been read on a phone-width
|
What is **not** proven here and is deliberately left: the pages have not been read on a phone-width
|
||||||
viewport, and the Android leg (phase 5) is where the same surface gets a second client anyway.
|
viewport, and the Android leg (phase 5) is where the same surface gets a second client anyway.
|
||||||
|
|
||||||
|
## 18. Phase 5 as built — Android leg A, 2026-09-17
|
||||||
|
|
||||||
|
The first leg of R10, and the first time this module's surface has had a second client. The app's
|
||||||
|
own record of it is [`../../android/PLAN.md`](../../android/PLAN.md) **M14**; what follows is what
|
||||||
|
the phase decided and what the walk found.
|
||||||
|
|
||||||
|
**Criterion met, and walked in an emulator rather than asserted:** *the app renders a Rust site it
|
||||||
|
has never seen, and a UO site unchanged.* Both halves were shown on one device against two running
|
||||||
|
cores.
|
||||||
|
|
||||||
|
### 18.0 Four org-lead decisions
|
||||||
|
|
||||||
|
- **D16 — the app gates on a new capability, `rust`.** This module declared five strings and every
|
||||||
|
one names a **surface**: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`. Core flattens
|
||||||
|
every started module's capabilities into one list, so a client gating a whole navigation group on
|
||||||
|
`servers` would have those screens revealed by any future module that declared the same generic
|
||||||
|
word. `module-uo` has exactly one string for this job — `shard` — and this module had none.
|
||||||
|
|
||||||
|
**Gating on the module `id` was considered and rejected.** It is the strongest fact available and
|
||||||
|
it is already on the wire, but `id` is a **mount prefix** (§2.1 requires it to equal the directory
|
||||||
|
core loads the module from) and `MODULE_API.md` §2.9 forbids a client inferring a route from a
|
||||||
|
capability. Letting a client gate on `id` makes the two the same value in practice, and the day one
|
||||||
|
builds `/<id>/servers` from it the separation that lets this module move its own pages is gone.
|
||||||
|
|
||||||
|
So `module.json` declares its own name as a sixth capability, and `server/test/entry.test.js`
|
||||||
|
asserts it **against `manifest.id`** rather than against the literal `"rust"` — the day the id
|
||||||
|
changes, the string a client gates on has to change with it. Module-Rust#5.
|
||||||
|
|
||||||
|
- **D17 — the app polls every 20 seconds while its screen is RESUMED.** D14's Page Visibility gate,
|
||||||
|
translated. `repeatOnLifecycle(RESUMED)` gives the same three behaviours from one line: nothing at
|
||||||
|
all while the app is away, an immediate refresh on return, and a pause behind a dialog. `STARTED`
|
||||||
|
was rejected — it keeps polling behind a partially obscured screen, which is precisely the reader
|
||||||
|
who is not reading.
|
||||||
|
|
||||||
|
- **D18 — the three Rust repositories move to `edge`** for the rest of the workstream, with releases
|
||||||
|
cut at the cutover rather than per phase. `edge` branches were created from `main` in Module-Rust,
|
||||||
|
Rust-Link and Rust-Plugins; `pr-checks.yml` in all three (and in Android-app) already triggers on
|
||||||
|
`[main, edge]`, so this costs no CI. `release.yml` still fires only on a push to `main`, which is
|
||||||
|
what makes the cutover the release.
|
||||||
|
|
||||||
|
- **D19 — the drawer row carries a live player count, and `NavPaths` learns `/rust`.** The second is
|
||||||
|
small and load-bearing: without it an admin's nav override on the module's own `Servers` row, or an
|
||||||
|
added link to `/rust`, hands off to a browser rather than opening the native screen.
|
||||||
|
|
||||||
|
### 18.1 D15 has no analogue on a phone, so it was translated
|
||||||
|
|
||||||
|
The footer slot works on the web because every public page renders the same footer (§17.4). The app
|
||||||
|
has no footer and no slot. What it has is one drawer row per surface and, since engagement Phase 8, a
|
||||||
|
precedent for a number beside one — the inbox's unread badge, in `NavigationDrawerItem`'s badge slot,
|
||||||
|
with a `contentDescription` so a screen reader says *"42 players online"* and not *"42"*.
|
||||||
|
|
||||||
|
The count rides there and keeps all three of the website version's rules: **zero renders nothing** (an
|
||||||
|
empty fleet is not a notification, and a badge reading `0` on a quiet evening is worse than none), a
|
||||||
|
failed read keeps the last number rather than dropping to zero, and it **never polls**. It is asked
|
||||||
|
for only where the module is installed, so a UO site makes no request at all.
|
||||||
|
|
||||||
|
**The number is players, not servers.** A badge is one integer, and of D15's two halves the live one
|
||||||
|
is how many people are on — a server count changes when an operator edits configuration, which is not
|
||||||
|
news, and is on the page the row opens anyway.
|
||||||
|
|
||||||
|
### 18.2 What the app had to grow: a refresh that is not a load
|
||||||
|
|
||||||
|
The app has had exactly one shape for a read since its first milestone — set `Loading`, ask, replace.
|
||||||
|
That is right for opening a screen and wrong for a poll, and it is the *same* wall this module hit one
|
||||||
|
tier along with core's `useAsync` (§17.3). A twenty-second refresh built on it would clear the
|
||||||
|
killfeed, render a spinner in its place and re-fill it, three times a minute, for ever.
|
||||||
|
|
||||||
|
`ui/Polling.kt` is `usePolled`'s other half, and it keeps the same rule: **a refresh is invisible when
|
||||||
|
it succeeds and keeps the rows when it fails.** Three cases, and the middle one is the whole point:
|
||||||
|
|
||||||
|
| Result | What the reader sees |
|
||||||
|
| --- | --- |
|
||||||
|
| It answered | New rows. Nothing else. |
|
||||||
|
| It failed, and there are rows | The same rows, and one quiet line saying the refresh failed. |
|
||||||
|
| It failed, and there is nothing yet | An ordinary error with a retry — a first load that failed. |
|
||||||
|
|
||||||
|
Only the **visible** live panel is polled. The website can afford to mount the one tab it is
|
||||||
|
rendering; the app's four tabs are one screen, so the refresh asks what the reader is actually looking
|
||||||
|
at. The leaderboard and the wipe list are never polled at all.
|
||||||
|
|
||||||
|
**Changing the question is not a poll.** Filter, sort and wipe blank their panel and load, because
|
||||||
|
what is on screen is an answer to something the reader has stopped asking — leaving it up would show
|
||||||
|
last wipe's killfeed under this wipe's heading.
|
||||||
|
|
||||||
|
### 18.3 What the walk proved, and how
|
||||||
|
|
||||||
|
The rig is phase 4's, unchanged: a core on `:3200` with this module installed, one live server
|
||||||
|
(`main`, a real sidecar and a real game host) and the seeded `demo` fixture that has never reported.
|
||||||
|
A second core on `:3100` serves `module-uo` and no Rust.
|
||||||
|
|
||||||
|
- **The criterion, first half.** With `demo` unreachable and reading *Offline*, the phone rendered its
|
||||||
|
map, size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, its last known presence
|
||||||
|
board and its wipe history. Nothing on the screen is a live call to a game host.
|
||||||
|
- **The criterion, second half.** The same app, switched to the UO core, showed Shard / Rules / Atlas
|
||||||
|
/ Leaderboards / Market and **no Rust row**.
|
||||||
|
- **`refreshInto` against a genuinely dead backend.** The core was stopped with the list on screen. A
|
||||||
|
poll tick later the rows were unchanged, under one line reading *"Could not refresh just now. This
|
||||||
|
is the last thing the site heard."* — no spinner, no error page, nothing blanked.
|
||||||
|
- **R12's arithmetic, on a phone.** All-time 59 = 41 + 18 across two wipes, and Drift — who appears
|
||||||
|
only in the current wipe — **drops out** of the August board rather than reading zero.
|
||||||
|
- **Every `describe` branch, from real rows**: a player kill with weapon, distance and grid; an NPC
|
||||||
|
kill with the prefab read as words; a suicide; an environment death (the fall that must not read as
|
||||||
|
a kill by nobody); chat with its colon in the join and a non-Global channel beside it; a disconnect
|
||||||
|
with reason and session length; and *while sleeping*.
|
||||||
|
- **The calendar-day rule.** Filtering to the August wipe produced three rows six weeks old, each
|
||||||
|
carrying its date — the §17.2 defect, not re-introduced in Kotlin.
|
||||||
|
- **The presence panel saying which it is.** The offline server's board rendered under *"The last
|
||||||
|
board this server sent. It is offline, so this is who was on then — not who is on now."*
|
||||||
|
- **The badge**, showing a live count on the drawer row.
|
||||||
|
|
||||||
|
### 18.4 The walk found three defects, and a green suite found none of them
|
||||||
|
|
||||||
|
1. **The drawer's live count resolved once per process.** It was keyed on the capability answer alone,
|
||||||
|
so it was read when the app connected and never again — which is not what *live* means on a row
|
||||||
|
somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge and for
|
||||||
|
the same reason: coming back to the app is exactly when a stale number would be noticed. *Visible
|
||||||
|
only by backgrounding the app and returning to it.*
|
||||||
|
2. **Every card's text sat flush against its edge.** The app's themed `ShardCard` is a `Card` and
|
||||||
|
nothing more — it carries no padding, and each caller pads its own content. Four new call sites did
|
||||||
|
not, and on a phone the first glyph of each line read as clipped.
|
||||||
|
3. **A name touched its own kill count.** Five numeric columns beside an equal-weight name column left
|
||||||
|
*Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes — and the
|
||||||
|
**active sort moved to the header**, because the header is the control: tinting a column of numbers
|
||||||
|
says *these are special* where tinting the header says *this is what the table is ordered by*.
|
||||||
|
|
||||||
|
### 18.5 The rig note worth keeping
|
||||||
|
|
||||||
|
The app's debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost`
|
||||||
|
only** — not `10.0.2.2`. An emulator walk against a local core therefore needs
|
||||||
|
`adb reverse tcp:<port> tcp:<port>` and the loopback address. Typed as `10.0.2.2`, every request fails
|
||||||
|
with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, and the connect
|
||||||
|
screen reports *"Couldn't reach that site"* — correct, and indistinguishable from a core that is not
|
||||||
|
running.
|
||||||
|
|
||||||
|
Two smaller things: the first AVD tried had 95% of `/data` used and refused a 44 MB install with
|
||||||
|
*"Requested internal only, but not enough space"* — `pm trim-caches` freed nothing, and the second AVD
|
||||||
|
was the answer. And the app's own `pm clear` is the way to reach the first-run connect screen, because
|
||||||
|
an `install -r` over an earlier install keeps the stored base URL.
|
||||||
|
|
||||||
|
### 18.6 What is not proven here
|
||||||
|
|
||||||
|
- **A phone-width read of the website's own pages**, which §17.6 deliberately left open. This phase
|
||||||
|
gave the surface a second client rather than re-reading the first, and the pages have still not been
|
||||||
|
looked at in a narrow browser.
|
||||||
|
- **The badge's non-zero case on real traffic.** Nobody was playing on the rig, so the count was shown
|
||||||
|
by seeding a player count on the unreachable server and reading the badge before the 30-second
|
||||||
|
refresh zeroed it. The arithmetic and the rendering are proven; a fleet with people on it is not.
|
||||||
|
|
||||||
|
|
||||||
|
## 19. Phase 6 as built — identity, 2026-09-21
|
||||||
|
|
||||||
|
R1, and R13's first slot. A player proves a Steam account is theirs by typing `/link` in game and
|
||||||
|
entering the code on the website; an operator sees the result inside core's own user page.
|
||||||
|
|
||||||
|
**Criterion met for everything the site owns, and walked in a browser** — the Steam id, the link
|
||||||
|
date and per-server all-time totals render under core's security panel on `/admin/users/:id`, with a
|
||||||
|
staff unlink that writes an audit row. **The in-game half is written down as a walk to run** rather
|
||||||
|
than claimed: a code reaches a player and nobody else, so no console can read one. It is
|
||||||
|
[`../../rust-link/PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md)'s new *identity walk*, beside the
|
||||||
|
read-path walk phase 3 left there for the same reason.
|
||||||
|
|
||||||
|
### 19.0 The org-lead decisions this phase needed
|
||||||
|
|
||||||
|
Four were taken while it was being built (D20-D23) and three during the walk (D24-D26):
|
||||||
|
|
||||||
|
- **D20 — `/link` is a CHAT command, not a console one.** The Rust idiom, and what players expect
|
||||||
|
from every Discord-linking server they have used. Both frameworks consume a `/` command rather
|
||||||
|
than broadcasting it and `SendReply` addresses one player, so neither the request nor the code
|
||||||
|
reaches public chat.
|
||||||
|
- **D21 — pending codes live in plugin memory, matching `module-uo`.** Asked as "where does an
|
||||||
|
unconfirmed code live"; answered *"match how it works for the uo module"*, and the UO bridge's
|
||||||
|
shape was then read out of `BridgeAccountLink.cs` rather than guessed: six characters, five-minute
|
||||||
|
TTL, thirty-second cooldown, one outstanding code per player. A plugin reload drops them, which
|
||||||
|
matters because phase 7b's config editor will reload plugins routinely — and the cost is a player
|
||||||
|
typing `/link` again, which is cheaper than an unconfirmed credential in a second process.
|
||||||
|
- **D22 — the code alphabet has no O, 0, I or 1.** A player reads it off their screen and types it
|
||||||
|
into a browser, often on a phone, sometimes reading it to somebody else.
|
||||||
|
- **D23 — a Steam id another website account holds is REFUSED, never moved**, and the refusal names
|
||||||
|
the holder. Phase 7 grants permissions against a link and phase 13 hangs entitlements off it, so a
|
||||||
|
silent move is an account takeover performed by typing six characters. The way out is `/unlink` in
|
||||||
|
game, which the player can reach from the machine they are sitting at.
|
||||||
|
- **D24 — the website asks EVERY configured server, first `link.ok` wins.** A code is minted by one
|
||||||
|
server and nothing in it says which. Asking the player to pick was rejected: a wrong pick comes
|
||||||
|
back indistinguishable from a wrong code, and that is the one refusal which must not be ambiguous.
|
||||||
|
- **D25 — staff can sever a link from the `admin.users.detail` panel**, with an activity row. It is
|
||||||
|
the counterweight to D23: a player who has lost access to that Steam account in game has no other
|
||||||
|
route back.
|
||||||
|
- **D26 — the activity-row overflow (§19.4, defect 3) is CORE's to fix, in its own PR.** A module may
|
||||||
|
legitimately name an action; shortening this module's names only moves the ceiling to the next one.
|
||||||
|
|
||||||
|
### 19.1 What the three repos gained
|
||||||
|
|
||||||
|
| Repo | What |
|
||||||
|
|---|---|
|
||||||
|
| [Rust-Plugins][rp] | `/link` and `/unlink` chat commands, the in-memory code table with its purge timer, and `link.confirm` — the first command the **website** originates. `checkPlugin.js` grew a check for chat commands, which bind by reflection with the same silent-failure mode as hooks |
|
||||||
|
| [Rust-Link][rl] | `POST /link/confirm` — the first route on this sidecar that is not a GET. It forwards and nothing else: it does not mint codes, does not store them, and cannot tell a good one from a bad one |
|
||||||
|
| [Module-Rust][mr] | `rust_account_links`, the fleet loop, the player page at `/player/rust`, and both halves of `admin.users.detail` |
|
||||||
|
|
||||||
|
**A refused code is a `200` all the way up.** `link.ok` and `link.error` are both answers; the
|
||||||
|
sidecar keeps its own status codes for the transport (503 game down, 504 game silent), because the
|
||||||
|
website has to tell *"that code is wrong"* from *"the game never replied"*.
|
||||||
|
|
||||||
|
### 19.2 `admin.users.detail` is declared in three places, and they are three different registries
|
||||||
|
|
||||||
|
The slot cost more wiring than its size suggests, and each of the three is held by a different thing:
|
||||||
|
|
||||||
|
1. **`module.json`'s `extensions` array** — validated by the loader against the **server** registry.
|
||||||
|
Naming a client slot there fails the load outright, which phase 1 found the hard way with
|
||||||
|
`site.footer.status`.
|
||||||
|
2. **`api.registerExtension('admin.users.detail', router)`** in `server/index.js` — the routes,
|
||||||
|
mounted inside core's `/api/v1/admin/users/:id` with `mergeParams`. Without that flag
|
||||||
|
`req.params.id` is `undefined` and every statement in the panel silently scopes to nobody.
|
||||||
|
3. **`registry.registerExtension(ID, 'admin.users.detail', Component)`** in the chunk — the panel.
|
||||||
|
Core passes it `userId` and nothing else, so it builds its own client for the routes the server
|
||||||
|
half registered.
|
||||||
|
|
||||||
|
**The gate is core's and it is stricter than the admin tier's.** Core's users router is
|
||||||
|
`requireRole('admin')` and the slot is mounted inside it, so editors and moderators never reach these
|
||||||
|
routes — which is right for a surface that can sever what phases 7 and 13 grant against.
|
||||||
|
|
||||||
|
### 19.3 The hole the slot found in this repo's own OpenAPI generator
|
||||||
|
|
||||||
|
`swaggerFragment.js` ran `register()` against a recording api and walked `record.routes` — the three
|
||||||
|
tiers. A slot router is not registered under a tier, so **the two routes under `/admin/users/:id`
|
||||||
|
were generated by nothing**: a fragment that was internally consistent, passed every check in the
|
||||||
|
repo, and described two routes fewer than the module serves.
|
||||||
|
|
||||||
|
A slot's mount is **core's**, so it cannot be derived from anything here — it is a fourth constant
|
||||||
|
beside `TIER_BASE`, and like `TIER_BASE` it is held to account by a real core in the frozen-manifest
|
||||||
|
job. That check was verified to catch exactly this, by deleting the two paths from the fragment and
|
||||||
|
watching it fail.
|
||||||
|
|
||||||
|
`test/frozenManifest.test.js` grew the other half. Its *mounts agree* case was written in phase 1
|
||||||
|
with this phase named in a comment — *"when the slot arrives this test must grow the exception
|
||||||
|
deliberately, rather than a route outside every declared mount arriving unnoticed"* — and it failed
|
||||||
|
on the first run after the slot was filled. It now also fails when a **declared slot contributes no
|
||||||
|
route**, because core never checks that a declared slot was filled.
|
||||||
|
|
||||||
|
### 19.4 Three defects the browser walk found, and 122 green tests did not
|
||||||
|
|
||||||
|
1. **Every refusal sentence was invisible.** Core's request primitive is the only thing that reads a
|
||||||
|
module's failures, and it reads one field:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const message = (data && data.message) || res.statusText || 'Request failed'
|
||||||
|
```
|
||||||
|
|
||||||
|
This module has answered `{ error: … }` since phase 1 and got away with it, because until now
|
||||||
|
every failure landed in `ErrorState` on a page whose whole content was missing — where a generic
|
||||||
|
sentence is honest. **A form is different: the sentence IS the outcome.** The link page showed
|
||||||
|
*Service Unavailable* for all four of the refusals this phase exists to write. All 23 error bodies
|
||||||
|
in the module now answer in `message`, and `test/errorShape.test.js` drives each outcome rather
|
||||||
|
than grepping for the field.
|
||||||
|
|
||||||
|
It is also a correction to phases 1-4, which shipped the wrong shape while referencing core's
|
||||||
|
`Error` schema — `{ message }` — in their own `#swagger.responses` annotations.
|
||||||
|
|
||||||
|
2. **The player saw a stale name.** `/player/rust` showed `Wanderer-old` — the name recorded at link
|
||||||
|
time — while the admin panel showed `Wanderer`, the name the game last saw. Only the admin read
|
||||||
|
joined `rust_players`. The same person, labelled two ways on one site, because a Rust name changes
|
||||||
|
on a whim.
|
||||||
|
|
||||||
|
3. **Core's activity row collides with a long action name.** `Dashboard.jsx` renders the action in a
|
||||||
|
`width: 110`, `flex: 'none'` span with no overflow handling, so `rust.account.unlink.staff`
|
||||||
|
overlaps the detail text beside it. D26 sends it to core as its own change.
|
||||||
|
|
||||||
|
### 19.5 What the walk proved, and how
|
||||||
|
|
||||||
|
Against a core at the pinned ref with the module installed, two configured servers (one sidecar up
|
||||||
|
with no game behind it, one address with nothing listening) and three logins:
|
||||||
|
|
||||||
|
- **The criterion**: `/admin/users/2` rendered the Steam id, *linked last month on rust-oxide*, *last
|
||||||
|
played 12 hours ago*, and per-server all-time totals — 59 kills across two wipes on one server, 3
|
||||||
|
on another — under core's own security panel.
|
||||||
|
- **D25**: Unlink removed the row, the panel then rendered *nothing at all* (most users have no Rust
|
||||||
|
account, and a "no linked accounts" notice on every user page is noise), and
|
||||||
|
`rust.account.unlink.staff` landed in the activity log naming the operator.
|
||||||
|
- **The player page**: the link row with its own Unlink, the empty state, and the three-step
|
||||||
|
instruction that is the only place on the site a player learns the code comes from the game.
|
||||||
|
- **A refusal that is a sentence**: with both sidecars unreachable, *"The game servers are unreachable
|
||||||
|
right now — try again in a minute."* — which is what defect 1 above was hiding.
|
||||||
|
- **Ownership**: a second player deleting the first player's link gets the same `404` as one that does
|
||||||
|
not exist, so a signed-in stranger cannot discover linked Steam ids by deleting them one at a time.
|
||||||
|
A player reaching the admin slot route gets `403` from core's own gate.
|
||||||
|
- **R1's rate limit, live**: ten attempts pass, the eleventh answers `429` with *"Too many link
|
||||||
|
attempts."* The limiter is per-IP, like core's own login limiter — which means two players behind
|
||||||
|
one address share the allowance, and that is core's policy rather than a choice made here.
|
||||||
|
|
||||||
|
### 19.6 What is not proven here
|
||||||
|
|
||||||
|
- **The code from the game.** The rig booted with the phase-6 plugin loaded and announcing protocol 3,
|
||||||
|
and could not reach the sidecar on the development machine: no inbound firewall rule for TCP 7800
|
||||||
|
on this Windows host, which is not a change to make from a session. The plugin half's own checks
|
||||||
|
are green and its shape is the UO bridge's, proven; what is untested is the whole path with a
|
||||||
|
person in it. **D27 (org lead): it goes in the manual walk document**, as its own *identity walk*
|
||||||
|
beside phase 3's player walk.
|
||||||
|
- **`unsure` against a real refusal.** Proving it needs one server that genuinely refuses a code —
|
||||||
|
which needs a plugin connected — alongside one that is down. The branch is unit-tested and its
|
||||||
|
sentence was read in a browser; the live combination is step 6 of the identity walk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Phase 7 as built — site-owned permissions, 2026-09-21
|
||||||
|
|
||||||
|
**The site's half is built and walked; the in-game half is written down as a walk to run.** R2 is
|
||||||
|
the direction the Integration Kit has no chapter for (§2, R2), and building it once is what phase 19
|
||||||
|
was told to wait for. It is also the first phase where this module writes to a game.
|
||||||
|
|
||||||
|
Five repositories touched: `Rust-Plugins#4`, `Rust-Link#4`, `Module-Rust#8` (all into `edge`), this
|
||||||
|
document, and nothing in `website` — core needed no change, which is itself worth recording after
|
||||||
|
phases 5 and 6 both found something missing in the contract.
|
||||||
|
|
||||||
|
### 20.0 The seven decisions this phase needed
|
||||||
|
|
||||||
|
| | Decision |
|
||||||
|
|---|---|
|
||||||
|
| **D28** | **A grant is keyed to the website USER**, resolved to every Steam id they have linked at push time. Not to a Steam id: the site authors privilege for a *person*, phase 13's earned entitlements follow whoever earned them, and unlinking an account takes its privileges with it. The cost, which is real and appears again in §20.5, is that a person with two linked accounts holds it on both |
|
||||||
|
| **D29** | **Every authored row carries a scope** — one server id, or `*` for the fleet. A modded server and a vanilla one will not want one set on both, and a single-server community never sees the choice |
|
||||||
|
| **D30** | **Groups are mirrored as real groups**, not flattened into per-player grants. Third-party plugins read group membership, R15's BetterChat group API (phase 17) has something to hang on, and an operator reading `oxide.show groups` sees what the website shows. The price is §12.2 rule 4: a player the store has never seen cannot be put in a group, while a direct grant to the same account works immediately |
|
||||||
|
| **D31** | **A holder the site did not author is reported, never undone**, and an operator is offered two answers: adopt it (the site maintains it from then on) or revoke it (removed on the next sync). A console grant during an incident must survive the next reconcile |
|
||||||
|
| **D32** | **One verb, and the plugin does the diffing.** The site sends the whole desired set; the plugin compares it against the live store and writes only the difference. Shipping the store to the website was rejected: it is the bigger of the two sets, and a copy of it is a second source of truth that is stale the moment it lands |
|
||||||
|
| **D33** | **Never self-register a permission.** The form offers what the servers report; a name that stops resolving is reported unresolved and the grant is kept. §12.2 rule 3 says `RegisterPermission` would make it stick with a console warning — doing so fabricates a permission the operator never installed, inert until some plugin happens to check it |
|
||||||
|
| **D34** | **People and groups, by hand.** Rules that keep themselves true — "everyone in the Donators team is in `vip`" — are genuinely wanted and are a second authority over the same rows, with its own reconcile. Deferred |
|
||||||
|
|
||||||
|
### 20.1 Three sets, and every interesting question is a difference between two
|
||||||
|
|
||||||
|
```
|
||||||
|
desired − pushed apply
|
||||||
|
pushed − desired RETIRE, because the site put it there and has since withdrawn it
|
||||||
|
present − desired drift
|
||||||
|
```
|
||||||
|
|
||||||
|
The middle row is the whole reason `rust_perm_pushed` exists, and it is the part that cannot be
|
||||||
|
inferred from anything the game knows. **A name in the store that is not in the desired set is
|
||||||
|
either something the site retired or something a human granted**, and those two have opposite
|
||||||
|
correct answers. The store records who granted a permission nowhere, so the site's own memory of
|
||||||
|
what it pushed is the only thing that tells them apart.
|
||||||
|
|
||||||
|
That table is keyed by **Steam id**, not by user, because it records what is in the GAME and the
|
||||||
|
game has never heard of a website account. Unlinking therefore leaves its rows until the next sync
|
||||||
|
retires them, which is correct and would be inexpressible keyed the other way.
|
||||||
|
|
||||||
|
**Revoking drift needed its own table**, and the reason is a good one: a foreign grant often names a
|
||||||
|
Steam id no website account holds, so there is no user to author it against and nothing in
|
||||||
|
`rust_perm_pushed` to remove. `rust_perm_revocations` is an instruction with its own lifetime —
|
||||||
|
queued by a person, carried in the next sync's `retire` list, deleted when a report comes back. A
|
||||||
|
server that is offline keeps the instruction until it returns, which is what an operator expects
|
||||||
|
from a site that claims to be the author of record.
|
||||||
|
|
||||||
|
### 20.2 What lands is not what was sent
|
||||||
|
|
||||||
|
Two outcomes look exactly like success from the website and are not, and both are reported by the
|
||||||
|
plugin rather than assumed:
|
||||||
|
|
||||||
|
- **`unresolved`** — no loaded plugin on that server registered the name, and `GrantUserPermission`
|
||||||
|
no-ops silently for one (§12.2 rule 1). This is the finding with teeth that phase 0 produced, and
|
||||||
|
phase 7 is where it is actually paid for.
|
||||||
|
- **`pending`** — the store has never seen that player, so a group membership cannot be placed
|
||||||
|
(§12.2 rule 4). The plugin reads the membership back after adding it, because a void return and no
|
||||||
|
change is the only signal available.
|
||||||
|
|
||||||
|
**Neither is recorded as pushed.** A site that recorded them would believe it had given a privilege
|
||||||
|
it had not — and would later "retire" it from a server that never had it, a no-op that reads as a
|
||||||
|
success in every log.
|
||||||
|
|
||||||
|
### 20.3 The loop, and what provokes it
|
||||||
|
|
||||||
|
Every 30 seconds it compares a digest of the desired set against what each server last confirmed,
|
||||||
|
and does nothing when they match. A sync happens when the set changed, when the game restarted or
|
||||||
|
wiped (the boot-id and wipe-id watch), when a `perm.drift` hook arrived, when the last attempt
|
||||||
|
failed and its backoff has elapsed, or every 15 minutes regardless — the audit is what finds drift
|
||||||
|
on a server nobody has touched.
|
||||||
|
|
||||||
|
**The digest is sorted before hashing.** The rows come out of five queries in an order nothing
|
||||||
|
guarantees, and an unsorted digest differs between two reads of an unchanged set — which would push
|
||||||
|
to every game server on every tick, for ever.
|
||||||
|
|
||||||
|
`dirty` is an optimisation rather than the truth, and it is written down as such: the loop's real
|
||||||
|
condition is `desired_hash != synced_hash`, recomputed from the tables every tick, so a flag cleared
|
||||||
|
while a sync was in flight costs nothing.
|
||||||
|
|
||||||
|
### 20.4 Nothing the far side sends may cost the main thread unbounded work
|
||||||
|
|
||||||
|
`perm.sync` is the first command whose work is not bounded by its own shape. A community with two
|
||||||
|
thousand linked players sends thousands of store operations in one frame; applying them in the tick
|
||||||
|
they arrive is a freeze an operator will blame on the game.
|
||||||
|
|
||||||
|
The plugin compiles a sync into single-store operations and drains 200 at a time on a timer,
|
||||||
|
reporting when the last one lands. Compiling touches nothing, so an oversized or malformed sync is
|
||||||
|
refused before there is any state to unwind. **This is §5's no-stall rule pointed at the inbound
|
||||||
|
half**, and it is new: every earlier command was a request to repeat something the game already knew.
|
||||||
|
|
||||||
|
The three bounds are each on the side that can say something useful when one is hit — ~15,000 rows
|
||||||
|
at the website (which can name the server to an operator), 1 MiB at the sidecar (the game link's own
|
||||||
|
line cap; forwarded, the line is discarded silently and presents as a `504`), 20,000 operations at
|
||||||
|
the plugin.
|
||||||
|
|
||||||
|
### 20.5 Four defects a browser walk found that 133 green tests did not
|
||||||
|
|
||||||
|
1. **A person with permissions and no linked Steam account had the whole Rust section hidden** on
|
||||||
|
core's user page. The section was gated on `links.length`, phase 6's rule, and phase 7 gave it a
|
||||||
|
second reason to exist — *for exactly the person whose grants reach nobody*, which is the state an
|
||||||
|
operator most needs to see. It is the same class of defect as phase 6's invisible refusal: a
|
||||||
|
correct decision that a later phase quietly invalidated.
|
||||||
|
2. **A member waiting on a first connection looked like an ordinary member.** The count was in the
|
||||||
|
server strip; nothing was beside the person. Fixed by matching each member's accounts against the
|
||||||
|
`pending` entries in every server's report — which also required the overview to carry *all* of a
|
||||||
|
member's Steam accounts rather than whichever one the join returned first.
|
||||||
|
3. **A grant naming an unregistered permission carried no warning**, though a group's permission
|
||||||
|
list had carried one from the start. Same fact, two places, one of them missed.
|
||||||
|
4. The page drew its own `<h1>` under core's chrome title — the same words twice.
|
||||||
|
|
||||||
|
**A fifth was found by a test, and it was the better catch.** `buildDesired` resolved a grant's
|
||||||
|
Steam accounts from the **join** in `listGrants` rather than from the link map, which gives the
|
||||||
|
right answer by accident: the join repeats a grant per linked account. It would have kept giving the
|
||||||
|
right answer until somebody changed that query, at which point one of a person's two accounts
|
||||||
|
quietly stops being granted. The test that caught it was written against a fixture with one row per
|
||||||
|
grant — the fixture was wrong about the query and right about the model.
|
||||||
|
|
||||||
|
### 20.6 What was proven, and how
|
||||||
|
|
||||||
|
A real core at the pinned ref with the module installed, the real `rust-link` sidecar, and a
|
||||||
|
**stand-in plugin** speaking protocol 4 on the loopback — a Node script implementing the store
|
||||||
|
semantics that matter (unregistered names no-op; an unseen player can hold a grant but not a
|
||||||
|
membership; `GetPermissionUsers` answers direct holders only).
|
||||||
|
|
||||||
|
- a group created, its permission added and two memberships applied on the first sync — two, because
|
||||||
|
one member holds two linked Steam accounts (D28 on the wire);
|
||||||
|
- `unresolved: ["kits.gold"]` and `pending: ["7656003:vip"]` reported, and **absent from
|
||||||
|
`rust_perm_pushed`**;
|
||||||
|
- a hand edit found as drift, adopted, and the refusal for one whose Steam id belongs to nobody:
|
||||||
|
*"That Steam account is not linked to any account on this site, so there is nobody to author this
|
||||||
|
against."*;
|
||||||
|
- a drift row revoked — queued, carried on the next sync (`revokes: 1`), the queue row cleared;
|
||||||
|
- a withdrawn grant retired (`pushed − desired`), and its pushed row removed;
|
||||||
|
- **a restart that emptied the store**: `reason: "restart"`, the whole set re-pushed, group,
|
||||||
|
permission, memberships and grant all back. That is R2's central promise, walked;
|
||||||
|
- a `perm.drift` frame marking the server dirty through the ingest, and the next sync reporting the
|
||||||
|
hand edit authoritatively — the live signal and the authoritative answer, in the division §10.4
|
||||||
|
describes;
|
||||||
|
- an unreachable second server reporting `transport-error` and retrying on its backoff while the
|
||||||
|
first stayed in sync;
|
||||||
|
- the module's chunk alone in the browser console: no second React, no bare import.
|
||||||
|
|
||||||
|
### 20.7 What is NOT proven, and who it is waiting on
|
||||||
|
|
||||||
|
**The acceptance line — "a grant made on the website gates a third-party plugin in-game" — is not
|
||||||
|
met**, and the reason was known before the phase started (§12.5): no console session can observe a
|
||||||
|
permission gate, and an admin account bypasses PopupNotifications and ZoneManager unconditionally.
|
||||||
|
It needs a **second, non-admin Steam account** on the rig, which is the org lead's to arrange. The
|
||||||
|
phase was built on the understanding that the in-game leg becomes a walk to run (as D27 did for
|
||||||
|
identity), not a claim.
|
||||||
|
|
||||||
|
**Nothing in the plugin has been compiled.** Two specifics for whoever runs that walk:
|
||||||
|
|
||||||
|
- `GetPermissionUsers` and `GetUsersInGroup` answer with `id(name)` and the spacing differs between
|
||||||
|
the two calls and between the frameworks. The plugin takes everything before the first bracket;
|
||||||
|
confirm it on both rigs, because a parse that is wrong here reports every holder as foreign.
|
||||||
|
- `GetGroupPermissions(name, false)` is called with both arguments. If Carbon's signature has no
|
||||||
|
second parameter this does not compile there — the one place in this change where R19's
|
||||||
|
"byte-identical plugin" claim is at risk.
|
||||||
|
|
||||||
|
### 20.8 Smaller things worth keeping
|
||||||
|
|
||||||
|
- **A mount prefix is ONE path segment.** Core's `PREFIX` is `/^\/[a-z0-9][a-z0-9-]*$/`, so
|
||||||
|
`/rust/permissions` cannot be declared in `module.json` and has to be a nested `use()` under
|
||||||
|
`/rust`. The useful half: **swagger-autogen follows the `require` and generates it with the right
|
||||||
|
prefix anyway** — the exact opposite of phase 6's hole, where the registration walk could not see
|
||||||
|
a slot router. The generator sees a nested router the walk cannot.
|
||||||
|
- **This module's first admin page**, and the first thing in it that had to be *authored* rather
|
||||||
|
than configured. The server rows are still configured through the API and have no screen —
|
||||||
|
a gap this phase deliberately did not widen into.
|
||||||
|
- **There is no module-declared site permission at MODULE_API 1.10.0**, so `requireRole('admin')` on
|
||||||
|
every route is the whole of the available vocabulary. R18's "its own site permission" for the
|
||||||
|
config editor needs a contract member that does not exist; phase 7b will meet the same wall.
|
||||||
|
- **Adopting a hand grant widens it.** It becomes a grant against the *person*, so it reaches every
|
||||||
|
Steam account they hold — one consequence of D28 that only shows up when adopting, and the next
|
||||||
|
sync applies it to their other accounts. Correct, and worth saying out loud on the screen one day.
|
||||||
|
- The rig had a leftover core **and** a leftover sidecar from phase 6 still running, and the sidecar
|
||||||
|
held the release binary open so `cargo build` failed with `Access is denied (os error 5)` and
|
||||||
|
**exit code 0**. Two sessions' rigs on one machine is now a standing hazard; check ports 3200,
|
||||||
|
8090 and 7799 before starting anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 22. Phase 8 as built — the player's own half, on two screens, 2026-09-22
|
||||||
|
|
||||||
|
**Walked end to end against a live rig, and the rig is why this phase reads differently from the
|
||||||
|
three before it.** The sidecar now runs inside the game container, so for the first time since phase
|
||||||
|
5 the website talked to a real plugin on a real Oxide server rather than to a stand-in — including
|
||||||
|
the permission push, which landed a group membership in Oxide's own store and reported
|
||||||
|
`unresolved` for a name nothing had registered.
|
||||||
|
|
||||||
|
Two repositories and this document: [`Module-Rust#10`][mr] (the website half, into `edge`) and
|
||||||
|
[`Android-app#48`][aa] (M15, into `edge`). Core needed no change — the third Rust phase running.
|
||||||
|
|
||||||
|
### 22.0 The three decisions this phase needed
|
||||||
|
|
||||||
|
| | Decision |
|
||||||
|
|---|---|
|
||||||
|
| **D39** | **The app's permission half is player-facing and read-only, and the website grows the route for it.** Phase 7's whole surface is `requireRole('admin')`, and the app has no admin user-detail screen to port it into; porting the authoring page to a phone was considered and rejected (it writes into a running game from a device that is easy to mis-tap, and M14 already excluded the Rust admin surface as configuration the app consumes rather than edits). So the phase adds one self-scoped read instead — and renders it on the **website** as well, so the app never has a surface the website lacks. |
|
||||||
|
| **D40** | **It mirrors `module-uo`'s player surface, not a shape of its own.** One drawer row under the player group, with the code card at the top of the screen it leads to — exactly `CharactersScreen`. A tab under one server was rejected because a link is fleet-wide, and a section inside core's own Account screen was rejected because the app has no slot mechanism and the module's data would be hard-wired into a core screen. |
|
||||||
|
| **D41** | **The row hangs on `rust`, matching `module-uo`'s single `shard`.** The module declares a surface word per feature and `identity` is one of them, but D16's rule stands: a capability answers *is the module there*, and core flattens every module's capabilities into one list. Requiring `rust` **and** `identity` was considered — it would refuse the screen on a build predating phase 6 — and rejected as a second meaning for a word that has one. |
|
||||||
|
|
||||||
|
### 22.1 What the player is told, and what they are not
|
||||||
|
|
||||||
|
`GET /player/rust/permissions` is a different shape from the admin read rather than a filtered one,
|
||||||
|
and the three differences are the phase:
|
||||||
|
|
||||||
|
1. **The scope is resolved on the server.** A client handed `scope: "*"` would have to know what the
|
||||||
|
fleet is to say anything, and then `inScope` exists twice. Each entry arrives carrying the
|
||||||
|
servers it reaches, each already marked.
|
||||||
|
2. **`live` is the pushed ledger, never the authored row.** Phase 7 is careful never to record a
|
||||||
|
push that silently did nothing, so *waiting* here means waiting — and the alternative is the site
|
||||||
|
claiming to have given something it has not.
|
||||||
|
3. **Nothing says why it is waiting.** An offline server, a permission no loaded plugin registered
|
||||||
|
and a store that has never seen the account are one state on this screen. Telling them apart is
|
||||||
|
an operator's diagnosis and an inventory of what is installed on a host.
|
||||||
|
|
||||||
|
**An entitlement that reaches nobody still lists**, and both surfaces say so: authored against the
|
||||||
|
website account, it exists before a Steam id does. Hiding it until one turns up is the defect the
|
||||||
|
admin user page shipped in phase 7 (§20.5) — the same mistake, one tier along, caught before it
|
||||||
|
shipped this time because the rule was already written down.
|
||||||
|
|
||||||
|
**The honest limit, named rather than designed around:** a *rank* can be live while every permission
|
||||||
|
it carries resolves nowhere. The rig proved it — `vip` was created in Oxide's store and the
|
||||||
|
membership landed, while `kits.vip` came back `unresolved` because Kits is not installed there. The
|
||||||
|
player is told the rank reached the game, which is true and is what the site gave them; whether a
|
||||||
|
server's plugins understand the names inside it is on the admin screen, where the warning already
|
||||||
|
is.
|
||||||
|
|
||||||
|
### 22.2 The refusals, on a phone
|
||||||
|
|
||||||
|
The app's convention since M1 is that a refusal is chosen by **status** and rendered from a string
|
||||||
|
resource — the website's sentence is never displayed, because the app is localized and the website
|
||||||
|
is not. That convention holds here and it costs something worth writing down: the module
|
||||||
|
distinguishes *three* 503s (a server it could not reach, a fleet that is all down, a site with no
|
||||||
|
servers configured at all) by sentence, and the app has one string for the status. It is written to
|
||||||
|
be true of all three, and it does **not** say "get a new code" — a player told that would go back to
|
||||||
|
the same unreachable server for another one.
|
||||||
|
|
||||||
|
The four that do differ by status keep four different pieces of advice: 400 a spent code, 409 a
|
||||||
|
Steam account another website account holds, 429 the limiter, 503 a server that could not be
|
||||||
|
reached.
|
||||||
|
|
||||||
|
### 22.3 What the walk proved, and on what
|
||||||
|
|
||||||
|
- **The website half** in a browser, against the live rig: the section rendering with zero linked
|
||||||
|
accounts, a rank marked *has it* and a grant marked *waiting* on the same screen, the note that
|
||||||
|
explains why, and the refusal path.
|
||||||
|
- **The app half** on an emulator against the same core: the row absent when signed out and absent
|
||||||
|
on a UO site, present for a signed-in player; both reads; a refused code rendering **beside the
|
||||||
|
button**; the marks; and the release.
|
||||||
|
- **The push itself against a real Oxide store**, which is new. `perm.sync` created the group,
|
||||||
|
applied the membership, and reported `unresolved: ["kits.vip", "zonemanager.admin"]` — the second
|
||||||
|
of those a genuine finding about the rig rather than the code: ZoneManager registers
|
||||||
|
`zonemanager.ignoreflag.*` and friends, and **not** `zonemanager.admin`. A permission name that
|
||||||
|
looks obvious is still a name some plugin has to have registered.
|
||||||
|
|
||||||
|
**The acceptance line needs a person in game.** Everything above used a link row written directly
|
||||||
|
into the rig's database, because a `/link` code reaches a player and nobody else. The three minutes
|
||||||
|
that close it are in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
|
||||||
|
|
||||||
|
### 22.4 Smaller things worth keeping
|
||||||
|
|
||||||
|
- **`MenuAccess.PLAYER` is `isPlayer || isStaff`**, and that is right here: `/player/rust/*` is
|
||||||
|
`requireAuth` with no role above it, and staff play the game too. The app's own gating test now
|
||||||
|
asks *which* module a row on a module path declares, not merely whether it declares one — a
|
||||||
|
second game under `player/` is a new way for that test to have been passing for the wrong reason.
|
||||||
|
- **The website's pill carries the word, not only the dot.** A filled circle beside a hollow one is
|
||||||
|
the whole difference between "you have this in game" and "you do not yet". Found by looking at it.
|
||||||
|
- **The app's row now says when and where an account was linked**, which the website's always did.
|
||||||
|
Which server minted the code is not part of the identity — a link is fleet-wide — but it is where
|
||||||
|
a support conversation starts.
|
||||||
|
- **`/player/rust` has no app deep link**, deliberately: `module-uo`'s player screens have none
|
||||||
|
either, and the app's web-path table is the *public* nav's. Phase 10 is where it will matter, when
|
||||||
|
a notification about an entitlement wants somewhere to land.
|
||||||
|
- **A rig timestamp written by a different connection is five hours out.** The `DATETIME` columns
|
||||||
|
here are naive, and they round-trip correctly only through the connection that wrote them —
|
||||||
|
inserting a stand-in row with `docker exec mariadb` made the site read it as the future. Not a
|
||||||
|
product defect; a rig recipe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 23. The presence fix — nothing names who is online by default, 2026-09-22
|
||||||
|
|
||||||
|
Not a numbered phase: a correction to what phases 4 and 5 shipped, landed **before** phase 9 on the
|
||||||
|
org lead's instruction. It came out of phase 9's roster question. Asked who may see a clan's roster,
|
||||||
|
the org lead answered with a rule wider than the phase:
|
||||||
|
|
||||||
|
> *Nothing should tell who is online by default — that should always default to the lowest blast
|
||||||
|
> area, like members and admin. We can have a default online count but not naming players.*
|
||||||
|
|
||||||
|
Measured against that rule, the public site had been breaking it since phase 4, in three places: the
|
||||||
|
server page's **Online** tab named every connected player, the public **feed** carried
|
||||||
|
`player.connected`/`player.disconnected`, and the Android app (phase 5) rendered both.
|
||||||
|
|
||||||
|
As built: [Module-Rust][mr] `fix/presence-visibility` and [Android-app][aa]
|
||||||
|
`fix/rust-presence-visibility` (both into `edge`). Core needed no change.
|
||||||
|
|
||||||
|
### 23.0 The decisions this needed
|
||||||
|
|
||||||
|
| # | Decision |
|
||||||
|
|---|---|
|
||||||
|
| **D42** | **Nothing names who is online by default.** Staff (admin or moderator) unless an operator widens it. A *count* is public at every setting — it is already on the server list and in the footer, and it names nobody. |
|
||||||
|
| **D43** | **Every kind that says a named player was on is gated**, not only connects: `player.connected`, `player.disconnected`, `player.respawned`, **`player.death`**, **`player.chat`** and `player.tally`. The org lead chose "gate all of them" over keeping the killfeed and chat public as content, so a default install's public feed carries only the server's own story (a wipe, a start, a shutdown). |
|
||||||
|
| **D44** | **Three rungs — `staff` · `signed_in` · `public`** — named to line up with phase 14's map-layer switches (public / players / admin) so that one layer can adopt them rather than sit beside them. A "linked players" rung was offered and not taken. |
|
||||||
|
| **D45** | **A fleet-wide default with an optional per-server override.** A PvE or creative server may reasonably publish a roll call a PvP server must not. A server with no override follows the fleet, so narrowing the fleet narrows every server that never chose otherwise. |
|
||||||
|
| **D46** | **The fix is its own PR, before phase 9**, not a commit inside it and not deferred to phase 14. |
|
||||||
|
|
||||||
|
### 23.1 "Who is online" was wider than the tab of that name
|
||||||
|
|
||||||
|
D43 is the org lead's; the fourth surface was found by reading the leaderboard. A leaderboard row
|
||||||
|
carries `lastSeen`, and a gather tally is flushed every minute a player is on — so a `lastSeen` of
|
||||||
|
forty seconds ago is the Online tab by another name. It is withheld below the audience too, and the
|
||||||
|
website drops the column rather than render a row of dashes that read as "never". The rows' **order**
|
||||||
|
still breaks ties on it, which says who was on more recently and never whether anybody is on now.
|
||||||
|
|
||||||
|
### 23.2 The viewer is re-read, not decoded
|
||||||
|
|
||||||
|
`ctx.auth.getUserFromRequest` decodes a token and nothing more: the role in it is the role the
|
||||||
|
account had when it signed in. For a gate on who may see who is online that is not good enough, so
|
||||||
|
the module re-reads the `users` row through `ctx.users.getById` (MODULE_API 1.1.0, previously unused
|
||||||
|
here) and takes role and status from it. **Walked:** a moderator demoted in the database mid-session
|
||||||
|
lost the roll call on the same cookie, on the next request. A banned account reads as anonymous.
|
||||||
|
|
||||||
|
Any failure narrows. An unknown stored audience reads as `staff`; a viewer who cannot be resolved
|
||||||
|
reads as anonymous; a setting that cannot be read withholds rather than answering 500 or publishing.
|
||||||
|
The asymmetric pair of fallbacks is `module-uo`'s shard-visibility rule, copied rather than
|
||||||
|
rediscovered.
|
||||||
|
|
||||||
|
### 23.3 The wire shape, and why it did not change
|
||||||
|
|
||||||
|
`GET /public/rust/servers/:id/online` keeps its shape and adds three fields: below the audience it
|
||||||
|
answers `{ players: [], hidden: true, count, audience }`. A client that predates the flag renders an
|
||||||
|
empty list rather than breaking — wrong, but not a crash — and a current one says "2 players online"
|
||||||
|
and who can see the names. The feed adds `presenceHidden` and `presenceAudience`. All three routes
|
||||||
|
answer `Cache-Control: private, no-store` with `Vary: Cookie, Authorization`, because a shared cache
|
||||||
|
that stored a moderator's answer would hand the roll call to the next anonymous visitor.
|
||||||
|
|
||||||
|
Settings live in a new `rust_settings` key/value table (phase 9's roster audience is the next key)
|
||||||
|
and an optional `presence_audience` column on `rust_servers`. `GET/PUT /admin/rust/visibility` is
|
||||||
|
`requireRole('admin')` over the tier gate, validated whole before anything is written, and every save
|
||||||
|
is one activity-log row. The admin page is **Rust visibility**.
|
||||||
|
|
||||||
|
### 23.4 A defect four phases old: every empty state was a blank box
|
||||||
|
|
||||||
|
Core's `EmptyState` renders its **children** and nothing else. This module had passed it `title` and
|
||||||
|
`message` since phase 4 — the shape the Integration Kit's template teaches (`Clans.jsx`) — and React
|
||||||
|
drops an unknown prop without a word, so **every empty panel in the module rendered as an empty
|
||||||
|
box**: "Nobody is on", "No scores yet", "No servers yet", all six of them. It surfaced only because
|
||||||
|
the presence fix's own message ("2 players online") depended on one; 169 server tests and 39 client
|
||||||
|
tests could not see it.
|
||||||
|
|
||||||
|
Fixed **module-side** on the org lead's steer ("we can't change too much, we do not want to break
|
||||||
|
other integrations like the UO module"): a small `Empty` wrapper renders a heading and a sentence as
|
||||||
|
children of core's component, so nothing core or `module-uo` renders changes. A client test now
|
||||||
|
refuses a titled `EmptyState` — and a `PageHeader subtitle`, the same class of bug the kit shipped in
|
||||||
|
the Teams work — and was proven by planting an offence and watching it fail.
|
||||||
|
|
||||||
|
**Owed to the kit (phase 19):** the template's `Clans.jsx` teaches the wrong prop. Corrected in its
|
||||||
|
own Integration-kit PR alongside this one.
|
||||||
|
|
||||||
|
### 23.5 What was proven, and how
|
||||||
|
|
||||||
|
- **API, four viewers × three routes**, against a core at the pinned sha with the module installed
|
||||||
|
and a stand-in sidecar serving a board with two players and a feed with a join, a death, a tally
|
||||||
|
and a chat line: anonymous and a signed-in player got `count: 2` and no names, a feed of wipes only
|
||||||
|
and no `lastSeen`; a moderator and an admin got all of it.
|
||||||
|
- **The writes:** a moderator's `PUT` 403s; an unknown audience 400s; a request naming one unknown
|
||||||
|
server 404s and writes nothing; fleet → `signed_in` opened the names to a player and not to
|
||||||
|
anonymous; a per-server `public` override opened them to anonymous on that server alone; `null`
|
||||||
|
put it back on the fleet default.
|
||||||
|
- **The browser**, signed out: the Online tab reads *2 players online · Only this site's staff can
|
||||||
|
see who they are*; the feed says joins, deaths and chat are not shown; the leaderboard has no
|
||||||
|
"Last seen" column. The admin page was proven by API only — the walk does not type a password into
|
||||||
|
a browser — and is left for the org lead's review.
|
||||||
|
- **The emulator**, signed in as a player: the same two messages at the staff default, and the names
|
||||||
|
arriving on the next poll after the fleet was widened to `signed_in` — which also proves the app's
|
||||||
|
bearer session reaches the module's viewer check.
|
||||||
|
|
||||||
|
180 server tests, 40 client tests, 652 app tests, `check:imports`, `check:bundle`,
|
||||||
|
`check:externals`, the regenerated OpenAPI fragment and the frozen route manifest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
|
||||||
|
|||||||
135
rust-link/INSTALL_RIG.md
Normal file
135
rust-link/INSTALL_RIG.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# The sidecar inside the game container — the Pterodactyl rig recipe
|
||||||
|
|
||||||
|
**Added 2026-09-22, during phase 8.** It is written here rather than in a phase section because it
|
||||||
|
is not a phase: it is how the rigs are wired from now on, and it is the shape
|
||||||
|
[`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) R20 says the **egg** ships in phase 18.
|
||||||
|
|
||||||
|
Until now the rigs ran the sidecar on a development machine and the game on the Pterodactyl node,
|
||||||
|
which meant the plugin had to dial *out across a LAN* to reach it. That contradicts D2 — the game
|
||||||
|
link is loopback and carries no token, precisely because it is not supposed to leave the host — and
|
||||||
|
it is why the acceptance line of phases 6, 7 and 7b each ended at a firewall rule.
|
||||||
|
|
||||||
|
**The fix is not a firewall rule. It is putting the sidecar where the design always said it lives.**
|
||||||
|
A container's `127.0.0.1` is genuinely private, so a stock plugin config and a stock sidecar find
|
||||||
|
each other with nothing configured at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What goes on the volume
|
||||||
|
|
||||||
|
Two files under `/home/container/rust-link/`, plus whatever the sidecar writes beside them:
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `rust-link-sidecar` | A **statically linked** Linux binary (`x86_64-unknown-linux-musl`), `chmod 755`. Static because the game image is not ours and its glibc is not a contract |
|
||||||
|
| `with-sidecar.sh` | The launcher below, `chmod 755` |
|
||||||
|
| `sidecar.toml` | Written by the sidecar itself on first run, with a generated token. Env overrides it |
|
||||||
|
| `rust-link.db` | The store. **It must never appear in the egg's `REMOVE_FILES`** — R12 keeps all-time rollups across wipes, and a swept store is the failure that looks like success |
|
||||||
|
|
||||||
|
Building the binary needs no Rust toolchain on the host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "$PWD/rust-link/sidecar:/src" -v "$PWD/out:/out" rust:1-slim-bookworm bash -c '
|
||||||
|
apt-get update -qq && apt-get install -y -qq musl-tools >/dev/null
|
||||||
|
rustup target add x86_64-unknown-linux-musl
|
||||||
|
cd /src && CARGO_TARGET_DIR=/build cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
cp /build/x86_64-unknown-linux-musl/release/rust-link-sidecar /out/'
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload both files with the panel's **client** API (`POST /api/client/servers/{id}/files/write`,
|
||||||
|
raw body — it creates missing parent directories), then `files/chmod` them. **Chmod one file per
|
||||||
|
call:** a two-entry `files` array applied only the first, silently, on this panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The launcher
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
RL=/home/container/rust-link
|
||||||
|
mkdir -p "$RL"
|
||||||
|
|
||||||
|
export RUSTLINK_CONFIG="$RL/sidecar.toml"
|
||||||
|
: "${RUSTLINK_DB_PATH:=$RL/rust-link.db}"
|
||||||
|
export RUSTLINK_DB_PATH
|
||||||
|
|
||||||
|
for v in RUSTLINK_GAME_BIND RUSTLINK_WEB_BIND RUSTLINK_SERVER_ID RUSTLINK_WEB_TOKEN RUSTLINK_RETAIN_DAYS; do
|
||||||
|
eval "val=\${$v-}"
|
||||||
|
if [ -n "$val" ]; then export "$v"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
env -u LD_PRELOAD "$RL/rust-link-sidecar" >> "$RL/sidecar.log" 2>&1 &
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
Three things in it are load-bearing, and each is a thing that went wrong first:
|
||||||
|
|
||||||
|
- **`env -u LD_PRELOAD` for the sidecar.** Carbon's entrypoint prepends
|
||||||
|
`LD_PRELOAD=$(pwd)/libdoorstop.so` to the **whole** startup string, so without this the Mono
|
||||||
|
preloader is injected into a static Rust binary that has never heard of it. RustDedicated still
|
||||||
|
inherits it from this script's environment and still boots modded — which is the composition R20
|
||||||
|
left unsettled, and this is the answer.
|
||||||
|
- **`exec "$@"` for the game.** The game *becomes* this process, so the panel console keeps its
|
||||||
|
stdin and stdout and **stop still stops the server** — which then takes the sidecar down with the
|
||||||
|
container. A `wait` here instead would leave the panel talking to a shell.
|
||||||
|
- **An unset variable is never exported.** The sidecar's precedence is env > file > default, and
|
||||||
|
exporting `RUSTLINK_WEB_TOKEN=""` would override a perfectly good token in `sidecar.toml` with
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
## The startup command
|
||||||
|
|
||||||
|
The launcher is a **prefix** on the egg's own startup, with the sidecar's settings in front of it
|
||||||
|
the way egg variables will supply them in phase 18 (R22):
|
||||||
|
|
||||||
|
```
|
||||||
|
RUSTLINK_WEB_BIND=0.0.0.0:<sidecar allocation> RUSTLINK_SERVER_ID=<server id> \
|
||||||
|
RUSTLINK_WEB_TOKEN=<token> ./rust-link/with-sidecar.sh <the egg's unchanged startup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Set it with the **application** API (`PATCH /api/application/servers/{id}/startup`, sending the
|
||||||
|
server's existing `environment`, `egg` and `image` back unchanged with `skip_scripts: true`).
|
||||||
|
|
||||||
|
**Shell operators cannot be used here.** The image's entrypoint runs the startup string through
|
||||||
|
`eval echo` before handing it to `node /wrapper.js`, so an `&` in it would background the *eval*
|
||||||
|
and a quoted sub-shell would lose its quotes. A wrapper program that `exec`s the rest is the shape
|
||||||
|
that survives that, which is why the launcher takes the game command as arguments rather than
|
||||||
|
containing it.
|
||||||
|
|
||||||
|
## Wiring the website to it
|
||||||
|
|
||||||
|
The sidecar's web API is on the **second allocation**, so from the site it is
|
||||||
|
`http://<node ip>:<sidecar allocation>` with the token above — the ordinary Admin → Rust server row,
|
||||||
|
no tunnel and no rule. `POST /admin/rust/servers/{id}/test` should answer with
|
||||||
|
`plugin_connected: true` and the protocol version.
|
||||||
|
|
||||||
|
The plugin needs **no configuration**: `oxide/config/RunicGateway.json`'s defaults
|
||||||
|
(`127.0.0.1:7799`) are already right, which is the clearest statement of why the sidecar belongs in
|
||||||
|
the container.
|
||||||
|
|
||||||
|
## What it proved, first time
|
||||||
|
|
||||||
|
On `rust-oxide` (egg 18, `ghcr.io/pterodactyl/games:rust`), from a cold start:
|
||||||
|
|
||||||
|
```
|
||||||
|
web server listening addr=0.0.0.0:21004
|
||||||
|
plugin connected peer=127.0.0.1:51148
|
||||||
|
{"kind":"server.hello","protocol":5,"serverId":"rust-oxide",...}
|
||||||
|
```
|
||||||
|
|
||||||
|
— the sidecar bound its allocation **29 seconds** before the world had finished generating, and the
|
||||||
|
plugin found it on loopback as soon as Oxide loaded. `/health` from another machine on the LAN
|
||||||
|
answered `plugin_connected: true`.
|
||||||
|
|
||||||
|
## Still open for phase 18
|
||||||
|
|
||||||
|
- **The egg's own variables.** `RUSTLINK_*` are not egg variables yet, so they live in the startup
|
||||||
|
string on the rigs. Pterodactyl rejects environment keys an egg does not declare, which is exactly
|
||||||
|
what R22's variable block is for.
|
||||||
|
- **Carbon.** The launcher is written for it and the reasoning above is specific about why, but at
|
||||||
|
the time of writing it has run on the Oxide rig only. The two rigs cannot be up at once on this
|
||||||
|
node, so this is a walk to run, not a claim to repeat.
|
||||||
|
- **The framework is reinstalled on every boot** (Carbon from `production_build`, Oxide from
|
||||||
|
`releases/latest`), so a restart is a framework upgrade and neither is pinnable. Unchanged by any
|
||||||
|
of this, and still the reason a rig can differ from itself between two runs.
|
||||||
@@ -220,6 +220,69 @@ how long the sidecar keeps raw events. The permanent record — per-wipe totals
|
|||||||
lives in the website's own tables, so shortening this loses recent detail and never loses a player's
|
lives in the website's own tables, so shortening this loses recent detail and never loses a player's
|
||||||
history. Set it to `0` to keep everything, if the host's disk is yours to spend.
|
history. Set it to `0` to keep everything, if the host's disk is yours to spend.
|
||||||
|
|
||||||
|
**From protocol 3 your players can link their Steam account.** In game they type `/link` and the
|
||||||
|
server answers them privately with a six-character code; on the website they type that code in
|
||||||
|
within five minutes and the two are joined. Nothing about the link is stored on the game host — the
|
||||||
|
website owns the record, and `/unlink` in game asks it to let go.
|
||||||
|
|
||||||
|
Two things an operator should know about it:
|
||||||
|
|
||||||
|
- **The code is never in a frame.** It reaches the player and nobody else, which is what makes typing
|
||||||
|
it into a signed-in browser proof that they are the one who asked. What crosses the bridge is
|
||||||
|
`account.link.requested`, a staff-visible note that somebody asked.
|
||||||
|
- **A Steam account can belong to one website account at a time, across your whole fleet.** A code
|
||||||
|
from any of your servers links for all of them. If somebody links the wrong account the site
|
||||||
|
refuses to move it — the player runs `/unlink` in game, or staff release it from the user's page in
|
||||||
|
the admin panel.
|
||||||
|
|
||||||
|
**From protocol 4 the website owns your permissions.** Groups and grants are written in
|
||||||
|
Admin → Rust permissions and pushed into this server's own Oxide/Carbon permission store, so every
|
||||||
|
plugin you already run honours them — Kits, ZoneManager, anything that calls `UserHasPermission`.
|
||||||
|
Nothing is required of those plugins and nothing is configured twice.
|
||||||
|
|
||||||
|
Four things an operator should know about it, because each looks like something else from the game
|
||||||
|
side:
|
||||||
|
|
||||||
|
- **A wipe does not lose them.** The site re-pushes the whole set when the server comes back. If your
|
||||||
|
wipe script clears `oxide/data/`, the permissions the site authored are back within a minute of the
|
||||||
|
server being up; ones granted at the console are not, because nothing remembers those.
|
||||||
|
- **Granting at the console still works, and the website notices.** A hand edit is reported as
|
||||||
|
drift on that screen and is **never** undone on its own — an operator is offered two answers to
|
||||||
|
it: adopt it, so the site maintains it from then on, or revoke it. That is deliberate: a console
|
||||||
|
grant during an incident must survive the next sync.
|
||||||
|
- **A permission no loaded plugin has registered cannot be granted.** Oxide's own API silently does
|
||||||
|
nothing for an unknown name, so the site checks first and reports the name as unresolved instead
|
||||||
|
of claiming a privilege nobody has. Load the plugin and the grant lands by itself.
|
||||||
|
- **A player who has never connected to that server can hold a grant but cannot be in a group.**
|
||||||
|
The store has no record of them to put in a group yet; the site says which memberships are waiting
|
||||||
|
and they land on that player's first connection.
|
||||||
|
|
||||||
|
`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
|
**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
|
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
|
**the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's
|
||||||
|
|||||||
@@ -98,3 +98,145 @@ Not "frames arrived". Three things, and the third is the one worth slowing down
|
|||||||
|
|
||||||
Anything that disagrees with the table is a finding about the game or the framework rather than a
|
Anything that disagrees with the table is a finding about the game or the framework rather than a
|
||||||
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.
|
mistake in the table — record it, the same way phases 0, 1 and 2 recorded theirs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The identity walk (protocol 3, phase 6)
|
||||||
|
|
||||||
|
Added 2026-09-21, and here for the same reason as everything above: **a link code reaches a player
|
||||||
|
and nobody else**, so no console can read one. The site's own half was walked in a browser — the
|
||||||
|
refusals, the admin panel, staff unlink, the rate limit — and what needs a person in game is the
|
||||||
|
three steps below.
|
||||||
|
|
||||||
|
It takes two minutes, and it wants **two website accounts** — one you will link, one you will try to
|
||||||
|
link the same Steam account to.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **In game, type `/link`** | A private reply with a six-character code and a five-minute deadline. Check it is private: a second player on the server must not see it. The code has **no O, 0, I or 1** in it — those glyphs are not in the alphabet, so one in your code is a finding |
|
||||||
|
| 2 | **Type `/link` again straight away** | *"Please wait a moment…"* — the thirty-second cooldown. The first code is now dead either way: a new request drops the old one, so only the newest ever works |
|
||||||
|
| 3 | **On the website, sign in and open `/player/rust`. Type the code** | The account appears, named as the game knows you, with the server it came from. Try the same code again: *"That code is unknown or has expired"* — it works once |
|
||||||
|
| 4 | **Sign in as the SECOND account and type a fresh code for the same Steam account** | Refused, naming the account that holds it: *"That Steam account is already linked to <name>. Run /unlink in game to release it."* The link must **not** move — it is what phase 7 grants permissions against |
|
||||||
|
| 5 | **In game, type `/unlink`** | The site's row disappears within one ingest tick (five seconds by default). Reload `/player/rust` to confirm — this is the frame arriving over the feed, not the page asking |
|
||||||
|
| 6 | **Type a code from a server whose sidecar you have just stopped** | *"One of the servers could not be reached… your code is still good — try again in a minute."* Distinct from step 3's refusal, and the distinction is the point: the code is fine and fetching another one would not help |
|
||||||
|
|
||||||
|
Step 6 needs a fleet of two, one of them down; on a single-server rig it reads *"The game servers are
|
||||||
|
unreachable right now"* instead, which is the same rule with nothing left to be unsure about.
|
||||||
|
|
||||||
|
**What counts as a pass here:** the code never appears anywhere but in front of the player who asked
|
||||||
|
for it (check the chat log and the sidecar's `/events?kind=account.link.requested` — the frame
|
||||||
|
carries the steam id, the name and a TTL, and **no code**), a Steam account belongs to one website
|
||||||
|
account at a time, and every refusal is a sentence that tells the player what to do next.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The permission walk (protocol 4, phase 7)
|
||||||
|
|
||||||
|
Added 2026-09-21. The website half was walked end to end against a stand-in plugin — the authoring
|
||||||
|
screen, the report, drift and its two answers, and a restart that emptied the store and was fully
|
||||||
|
re-pushed. **What is left is the sentence the phase exists for: a grant made on the website gates a
|
||||||
|
third-party plugin in the game.**
|
||||||
|
|
||||||
|
It cannot be walked from a console, and it cannot be walked on the owner's account:
|
||||||
|
|
||||||
|
- **A console session bypasses every gate.** The standard idiom is
|
||||||
|
`return !player || permission.UserHasPermission(...)`, and an RCON command has no `BasePlayer` —
|
||||||
|
so the console is unconditionally allowed ([PLAN.md §12.5](../modules/rust/PLAN.md)).
|
||||||
|
- **An admin account bypasses most plugins' gates too**, and not uniformly: Popup Notifications
|
||||||
|
(`player.IsAdmin ||`) and Zone Manager (`authLevel > 0 ||`) are hard bypasses. Kits is the
|
||||||
|
exception — its `IsAdmin` is the `kits.admin` **permission** and `AdminIgnoreRestrictions`
|
||||||
|
defaults to `false` — so a kit's `RequiredPermission` does apply to a server owner.
|
||||||
|
|
||||||
|
So this walk wants a **second, non-admin Steam account** connected to the rig. Kits alone can be
|
||||||
|
walked on the owner's account; steps 4 and 5 cannot.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Link the second account** (the identity walk above), then on the website open Admin → Rust permissions and grant it a kit's `RequiredPermission` — pick the kit from `GetKitNames`, or read one out of `oxide/config/Kits.json` | The grant appears with the account beside it. Within a minute the server row reads **in sync** — or press *Sync now* and watch it happen |
|
||||||
|
| 2 | **In game on that account, open the kit menu** | The kit is no longer locked. Before the grant it shows as locked; that difference is the whole phase |
|
||||||
|
| 3 | **At the server console, `oxide.show user <steamid>`** | The permission is there, granted by this plugin rather than by hand |
|
||||||
|
| 4 | **At the console, `oxide.grant user <steamid> zonemanager.admin`** (a permission the site manages but did not grant) | Within seconds the website's screen shows it under *Changed in game*. **Revoke** it there, and it is gone from `oxide.show user` on the next sync. **Adopt** a different one instead and it stays, now listed as the site's own |
|
||||||
|
| 5 | **Put the second account in a group on the website, then wipe or restart the server** (a wipe script that clears `oxide/data/` is the interesting case) | After the server is back: the group exists again, the membership is back, and the grant is back — without anybody touching the website. This is R2's central promise and the one thing a stand-in cannot prove |
|
||||||
|
| 6 | **Grant a permission whose plugin you have just unloaded** | The site reports it **unresolved** against that server and keeps the grant. Load the plugin again: it lands on the next sync, with nothing typed |
|
||||||
|
| 7 | **Add a website account that has never connected to this server to a group** | The site reports the membership as *waiting on their first connection*. Have them connect: it lands. A **direct grant** to the same account, by contrast, is in `oxide.show user` immediately |
|
||||||
|
|
||||||
|
**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 there rather than assume:
|
||||||
|
|
||||||
|
- **`GetPermissionUsers` / `GetUsersInGroup` entry format.** Both answer `id(name)`, and the spacing
|
||||||
|
differs between the calls and between the frameworks. The plugin takes everything before the first
|
||||||
|
bracket. If that parse is wrong, **every holder is reported as foreign** — which is visible
|
||||||
|
immediately: the drift list fills with grants the site itself made.
|
||||||
|
- **`GetGroupPermissions(name, false)`** is called with both arguments. If Carbon's signature has no
|
||||||
|
second parameter, the plugin does not compile there at all — the one place in protocol 4 where
|
||||||
|
R19's byte-identical-plugin claim is at risk.
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
**Since 2026-09-22 the rigs have that**, and it is no longer a firewall rule on anybody's
|
||||||
|
development machine: the sidecar runs **inside the game container** on the Pterodactyl rigs, which
|
||||||
|
is the shape phase 18's egg ships. The recipe is in [`INSTALL_RIG.md`](INSTALL_RIG.md); a stock
|
||||||
|
plugin config (`127.0.0.1:7799`) and a stock sidecar need no configuration at all to find each
|
||||||
|
other, which is the whole point of putting them in one container.
|
||||||
|
|
||||||
|
| # | 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.
|
||||||
|
|
||||||
|
## The account walk on a phone (phase 8, Android leg B)
|
||||||
|
|
||||||
|
Added 2026-09-22. The app's half was walked on an emulator against a core with the module installed
|
||||||
|
and a **live** rig behind it — the drawer row appearing only for a signed-in player on a site that
|
||||||
|
runs the module, both reads, a refused code rendering beside the button, the entitlement list with
|
||||||
|
its per-server marks, and a release. What no emulator can produce is the code itself, so this is the
|
||||||
|
same three minutes as the identity walk above, done on the phone instead of in a browser.
|
||||||
|
|
||||||
|
| # | Do this | You should see |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **In game, type `/link`.** On the phone, open the drawer → *My Rust account*, type the code and press *Link account* | The account appears with the name the game knows you by, when it was linked and which server minted the code |
|
||||||
|
| 2 | **Press it again with the same code** | *"That code is unknown or has expired."* Beside the button, not at the top of the screen |
|
||||||
|
| 3 | **Turn the site off and try a fresh code** | *"A server could not be reached… your code is still good — try again in a minute."* It must NOT tell you to get a new code: you would get it from the same unreachable server |
|
||||||
|
| 4 | **Have an operator grant you something on the website, then pull down / reopen the screen** | It appears under *What you can do in game*, marked **waiting** until a sync lands it and **has it** afterwards. The two states are a word as well as a colour |
|
||||||
|
| 5 | **Press *Unlink*** | The row goes, and every entitlement returns to *waiting* on the next read — the site still holds them, and they now reach nobody |
|
||||||
|
| 6 | **Sign out** | The row is gone from the drawer. On a site with no Rust module it is never there at all, whoever is signed in |
|
||||||
|
|
||||||
|
**What counts as a pass:** a player links an account from the phone without touching a browser, and
|
||||||
|
the screen never claims an entitlement is in the game when the site has not confirmed it there.
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ it is listening without one.
|
|||||||
|
|
||||||
## 2. Versioning
|
## 2. Versioning
|
||||||
|
|
||||||
The wire version is a single integer — **2** as of the read path (§8) — declared in **four** places
|
The wire version is a single integer — **5** as of configuration from the site (§11) — declared in
|
||||||
that must agree:
|
**four** places that must agree:
|
||||||
|
|
||||||
| Where | Repo |
|
| Where | Repo |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -330,11 +330,12 @@ writing the file and generating the token if they are missing — and prints it
|
|||||||
|
|
||||||
## 7. What is deliberately not here yet
|
## 7. What is deliberately not here yet
|
||||||
|
|
||||||
Protocol 2 is the transport plus the read path. Every one of these arrives with the phase that needs
|
Protocol 4 is the transport, the read path, identity and the permission mirror. Every one of these
|
||||||
it, and each is a version bump:
|
arrives with the phase that needs it, and each is a version bump:
|
||||||
|
|
||||||
- identity and the in-game link code (phase 6)
|
- ~~identity and the in-game link code (phase 6)~~ — **protocol 3, §9**
|
||||||
- the permission mirror (phase 7), and plugin configuration edited from the site (phase 7b)
|
- ~~the permission mirror (phase 7)~~ — **protocol 4, §10**
|
||||||
|
- plugin configuration edited from the site (phase 7b)
|
||||||
- clans, for core's Team provider (phase 9)
|
- clans, for core's Team provider (phase 9)
|
||||||
- leases, budgets and the event actions (phases 12-13)
|
- leases, budgets and the event actions (phases 12-13)
|
||||||
- the map image over the asset-bridge shape (phase 14)
|
- the map image over the asset-bridge shape (phase 14)
|
||||||
@@ -444,12 +445,12 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
|
|||||||
|
|
||||||
| `kind` | Hook | `class` | Carries |
|
| `kind` | Hook | `class` | Carries |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `player.connected` | `OnPlayerConnected` | public | steamId, name |
|
| `player.connected` | `OnPlayerConnected` | **presence** | steamId, name |
|
||||||
| `player.disconnected` | `OnPlayerDisconnected` | public | steamId, name, reason, sessionSec |
|
| `player.disconnected` | `OnPlayerDisconnected` | **presence** | steamId, name, reason, sessionSec |
|
||||||
| `player.respawned` | `OnPlayerRespawned` | public | steamId |
|
| `player.respawned` | `OnPlayerRespawned` | **presence** | steamId |
|
||||||
| `player.death` | `OnPlayerDeath` | public | victim, attacker, attackerType, weapon, distance, grid |
|
| `player.death` | `OnPlayerDeath` | **presence** | victim, attacker, attackerType, weapon, distance, grid |
|
||||||
| `player.chat` | `OnPlayerChat` | public | steamId, name, channel, message |
|
| `player.chat` | `OnPlayerChat` | **presence** | steamId, name, channel, message |
|
||||||
| `player.tally` | *aggregate* — see §8.6 | public | steamId, gathered{}, npcKills, structures |
|
| `player.tally` | *aggregate* — see §8.6 | **presence** | steamId, gathered{}, npcKills, structures |
|
||||||
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
|
| `entity.destroyed` | `OnEntityDeath` on owned building blocks | **staff** | ownerId, prefab, grid, attacker |
|
||||||
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
|
| `player.reported` | `OnPlayerReported` | **staff** | reporter, target, subject, message, type |
|
||||||
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
|
| `player.banned` / `player.unbanned` | `OnUserBanned` / `OnUserUnbanned` | **staff** | id, name, **ip**, reason |
|
||||||
@@ -458,6 +459,8 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
|
|||||||
| `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced |
|
| `server.wipe` | `OnNewSave` | public | the new `wipeId`, the one it replaced |
|
||||||
| `server.initialized` | `OnServerInitialized` | public | — |
|
| `server.initialized` | `OnServerInitialized` | public | — |
|
||||||
| `server.shutdown` | `OnServerShutdown` | public | — |
|
| `server.shutdown` | `OnServerShutdown` | public | — |
|
||||||
|
| `account.link.requested` | `/link` chat command *(protocol 3)* | **staff** | steamId, name, ttlSec — **never the code** |
|
||||||
|
| `account.unlinked` | `/unlink` chat command *(protocol 3)* | **staff** | steamId, name, origin |
|
||||||
|
|
||||||
`grid` is the Rust map reference (`H7`), not a coordinate. A death's grid is where a fight happened
|
`grid` is the Rust map reference (`H7`), not a coordinate. A death's grid is where a fight happened
|
||||||
and every community site shows it; a **structure's** grid is where somebody lives, which is why
|
and every community site shows it; a **structure's** grid is where somebody lives, which is why
|
||||||
@@ -483,6 +486,14 @@ So: the table in §8.4 is the specification, `module-rust` holds the allowlist,
|
|||||||
its allowlist against this document, so adding a kind here without classifying it there fails a
|
its allowlist against this document, so adding a kind here without classifying it there fails a
|
||||||
build rather than shipping an IP address to a public page.
|
build rather than shipping an IP address to a public page.
|
||||||
|
|
||||||
|
**`presence` is `public` with an audience an operator chooses** (added 2026-09-22, [`PLAN.md`](../modules/rust/PLAN.md) §23).
|
||||||
|
The six kinds marked so each say that a *named* player was on the server at a given moment, and the
|
||||||
|
org lead's rule is that nothing names who is online by default: `module-rust` serves them only to
|
||||||
|
viewers inside an operator-chosen audience — staff unless widened, fleet-wide with a per-server
|
||||||
|
override. Below it the public feed carries only what names nobody (a wipe, a start, a shutdown).
|
||||||
|
Nothing on the wire changed: the class is still the module's to enforce, which is why the rule could
|
||||||
|
be added without a protocol bump.
|
||||||
|
|
||||||
`player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and
|
`player.login.attempt`, `player.approved` and `player.banned` carry **IP addresses**, and
|
||||||
`player.reported` carries the text of one player's complaint about another. They are stored because
|
`player.reported` carries the text of one player's complaint about another. They are stored because
|
||||||
an operator chasing ban evasion needs them and because the sidecar persists what it is told; they
|
an operator chasing ban evasion needs them and because the sidecar persists what it is told; they
|
||||||
@@ -585,3 +596,502 @@ a game host is a wipe-day outage waiting for a busy month.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 9. Protocol 3 — identity
|
||||||
|
|
||||||
|
R1's identity link, and the first message in this bridge that the **website** originates. Everything
|
||||||
|
in protocol 2 was the game talking, or the sidecar asking the game to repeat something it already
|
||||||
|
knew.
|
||||||
|
|
||||||
|
The shape is the one the UO bridge proved: the player asks in game, the plugin mints a one-time code
|
||||||
|
and hands it to them privately, and the website redeems it through the sidecar.
|
||||||
|
|
||||||
|
```
|
||||||
|
player plugin sidecar website
|
||||||
|
│ /link │ │ │
|
||||||
|
├────────────────────►│ mint code, hold it │ │
|
||||||
|
│◄────── code ────────┤ in memory, 5 min │ │
|
||||||
|
│ ├─ account.link.requested ►│ ───── feed ───────►│
|
||||||
|
│ │
|
||||||
|
│ ………… the player types the code into the website ……………………………►│
|
||||||
|
│ │ │◄ POST /link/confirm ┤
|
||||||
|
│ │◄──── link.confirm ───────┤ │
|
||||||
|
│ ├───── link.ok ───────────►│ ── steamId, name ──►│
|
||||||
|
│ │ (code spent) │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nothing about the link is stored in the game.** The site is the author of record, which is not a
|
||||||
|
preference: there is no per-account store in Rust that survives a wipe, and phase 7 makes the site
|
||||||
|
authoritative anyway — it pushes permissions *into* the game keyed by Steam id. A copy on the game
|
||||||
|
host would be a second thing to reconcile every wipe, answering no question better.
|
||||||
|
|
||||||
|
### 9.1 `/link` and `/unlink` are CHAT commands, and the reply is private
|
||||||
|
|
||||||
|
`[ChatCommand("link")]`. Both frameworks consume a `/` command rather than broadcasting it, and
|
||||||
|
`SendReply` addresses one player — so neither the request nor the code reaches anybody else's chat.
|
||||||
|
That is load-bearing rather than polish: **a code read off a stream is a code somebody else can
|
||||||
|
spend.**
|
||||||
|
|
||||||
|
`/unlink` emits rather than deletes, because the plugin holds no link to delete. It exists because
|
||||||
|
the website **refuses** to move a Steam id another account already holds (D23): without a way out, a
|
||||||
|
player who linked the wrong account while signed in as it would need staff. The authority on that
|
||||||
|
path is the Steam account itself — whoever is connected to the game as it is who it is.
|
||||||
|
|
||||||
|
### 9.2 The code is **not** on the wire
|
||||||
|
|
||||||
|
`account.link.requested` carries the Steam id, the name and the TTL, and **never the code**. The
|
||||||
|
event exists so an operator can see linking being used and so the site can see a player fishing; it
|
||||||
|
is not how the code travels. The code travels **through the player**, which is what makes typing it
|
||||||
|
into a signed-in browser proof that they are the one who asked.
|
||||||
|
|
||||||
|
Both account frames are **staff** class (§8.5). Neither carries a secret, but both name a Steam id
|
||||||
|
beside a website account's activity, and that join — *this player is that person* — is a fact about
|
||||||
|
somebody's identity rather than about what happened on the server.
|
||||||
|
|
||||||
|
### 9.3 `link.confirm` — website → plugin
|
||||||
|
|
||||||
|
The first inbound command that is not a request to repeat something.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "cmd": "link.confirm", "reqId": "r-42", "code": "K7M2PQ" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Answered with `link.ok` carrying `steamId` and `name`, or `link.error` carrying a `reason` of
|
||||||
|
`unknown`, `expired` or `malformed`. Both are replies, correlated by `reqId` like `server.status`.
|
||||||
|
|
||||||
|
**A code is consumed on the FIRST lookup, whether or not it turns out to be expired.** The removal
|
||||||
|
happens before the expiry check rather than after it, so a code cannot be probed twice.
|
||||||
|
|
||||||
|
**`unknown` and `expired` are separate here and identical to the player.** An operator reading a log
|
||||||
|
wants to know whether codes are being guessed or merely going stale; a stranger typing codes must not
|
||||||
|
learn which of the two they hit, because that is the difference between "keep guessing" and "guess
|
||||||
|
faster".
|
||||||
|
|
||||||
|
### 9.4 The code itself
|
||||||
|
|
||||||
|
Six characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` — **no O, 0, I or 1**, because a player reads
|
||||||
|
this off their screen and types it into a browser, often on a phone. A five-minute TTL, a
|
||||||
|
thirty-second cooldown per player, **one outstanding code each** (a new `/link` drops the old one),
|
||||||
|
and a purge timer, because an unconfirmed code is never looked up and nothing else would ever remove
|
||||||
|
it.
|
||||||
|
|
||||||
|
They live in plugin memory and nowhere else. A plugin reload drops every pending code — and phase
|
||||||
|
7b's config editor will reload plugins routinely — but the cost of that is a player typing `/link`
|
||||||
|
again, which is cheaper than an unconfirmed credential living in a second process.
|
||||||
|
|
||||||
|
### 9.5 `POST /link/confirm` — the first route on this sidecar that is not a GET
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /link/confirm { "code": "K7M2PQ" } → 200 { "kind": "link.ok", "steamId": "765…" }
|
||||||
|
→ 200 { "kind": "link.error", "reason": "unknown" }
|
||||||
|
→ 503 the game is not connected
|
||||||
|
→ 504 the game is up and did not answer
|
||||||
|
```
|
||||||
|
|
||||||
|
**A refused code is a `200`.** `link.ok` and `link.error` are both answers; the sidecar reserves its
|
||||||
|
own status codes for the transport, because the website has to tell *"that code is wrong"* from
|
||||||
|
*"the game never replied"* to say the right thing to a player (§4.3).
|
||||||
|
|
||||||
|
The sidecar validates nothing but the shape — it trims the code, bounds its length, and forwards it.
|
||||||
|
Only the game holds the pending codes, and putting the table here instead would give the sidecar a
|
||||||
|
credential and an opinion, which D2 and the bridge principles say it has neither of.
|
||||||
|
|
||||||
|
### 9.6 The website asks EVERY server (D24)
|
||||||
|
|
||||||
|
A code is minted by one server, and the player types six characters into a browser. Nothing in the
|
||||||
|
code says which server it came from, so the module asks each configured server in turn and the first
|
||||||
|
`link.ok` wins; the others answer `unknown` and nothing happens there, because a code is only spent
|
||||||
|
at the server that holds it.
|
||||||
|
|
||||||
|
Asking the player to pick was rejected: a wrong pick comes back indistinguishable from a wrong code.
|
||||||
|
|
||||||
|
The consequence for this protocol is worth stating, because it is the shape of every later
|
||||||
|
fleet-wide command: **"every reachable server refused" is not the same answer as "a server could not
|
||||||
|
be reached"**, and a module that collapses them tells the player whose server is down that their code
|
||||||
|
is wrong — so they fetch another code from the same server and hear it again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 10. Protocol 4 — the permission mirror
|
||||||
|
|
||||||
|
R2, and the first command on this bridge that **changes the game**. Protocol 3's
|
||||||
|
`link.confirm` was the website originating a message, but it spent a code the game
|
||||||
|
itself had minted; this writes to a store the game enforces.
|
||||||
|
|
||||||
|
```
|
||||||
|
website sidecar plugin
|
||||||
|
│ │ │
|
||||||
|
├── POST /permissions/sync ►│ ──── perm.sync ─────────►│ diff against the
|
||||||
|
│ the whole desired set │ (the same object) │ live store, apply
|
||||||
|
│ │ │ the difference in
|
||||||
|
│◄──── the report ──────────│◄──── perm.report ────────┤ bounded steps
|
||||||
|
│ │
|
||||||
|
│◄──── perm.drift (event) ──────────────────────────────┤ somebody else wrote
|
||||||
|
```
|
||||||
|
|
||||||
|
**The website is the author of record and the framework's store is an enforcement
|
||||||
|
cache.** Every third-party plugin honours a site grant with no adapter, because
|
||||||
|
they all already call `permission.UserHasPermission` — reaching them is the point,
|
||||||
|
and it is why the site does not keep a private table of its own.
|
||||||
|
|
||||||
|
### 10.1 One verb, and the PLUGIN does the diffing
|
||||||
|
|
||||||
|
`perm.sync` carries the whole set the site authors **for that server**. The plugin
|
||||||
|
compares it against the live store and writes only what differs.
|
||||||
|
|
||||||
|
The alternative — the plugin reporting its store and the website computing the
|
||||||
|
difference — was rejected for two reasons. The store is the bigger of the two sets
|
||||||
|
and would cross the wire constantly, and a website holding a copy of it has a
|
||||||
|
second source of truth that is stale the moment it lands.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cmd": "perm.sync",
|
||||||
|
"reqId": "r-42",
|
||||||
|
"setId": "69dfc769…",
|
||||||
|
"groups": [
|
||||||
|
{ "name": "vip", "title": "VIP", "rank": 10,
|
||||||
|
"permissions": ["kits.vip"],
|
||||||
|
"members": ["76561198000000001", "76561198000000002"] }
|
||||||
|
],
|
||||||
|
"grants": [
|
||||||
|
{ "steamId": "76561198000000001", "permissions": ["kits.gold"] }
|
||||||
|
],
|
||||||
|
"managed": ["kits.vip", "kits.gold"],
|
||||||
|
"retire": [
|
||||||
|
{ "kind": "grant", "subject": "76561198000000003", "object": "kits.silver" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Means |
|
||||||
|
|---|---|
|
||||||
|
| `setId` | the site's digest of the set, echoed in the report. It is how the site knows a report describes the set it sent rather than an earlier one |
|
||||||
|
| `groups` | group definitions, what each carries, and who is in it. **Three separate facts**, because the game can fail at each independently |
|
||||||
|
| `grants` | permissions held by one account without a group |
|
||||||
|
| `managed` | the permission namespace the site claims. Foreign holders are only looked for within it — which also bounds the scan by the site's own set rather than by the size of the store |
|
||||||
|
| `retire` | what the site put there and has since withdrawn (§10.3) |
|
||||||
|
|
||||||
|
### 10.2 `perm.report` — what actually happened
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "perm.report", "type": "reply", "reqId": "r-42", "setId": "69dfc769…",
|
||||||
|
"applied": { "grants": 1, "revokes": 0, "groupsCreated": 1, "groupPermissions": 1,
|
||||||
|
"members": 2, "membersRemoved": 0, "groupsRemoved": 0,
|
||||||
|
"groupPermissionsRemoved": 0 },
|
||||||
|
"alreadyCorrect": 14,
|
||||||
|
"absent": 0,
|
||||||
|
"unresolved": ["kits.gold"],
|
||||||
|
"pending": ["76561198000000003:vip"],
|
||||||
|
"foreign": [{ "kind": "grant", "subject": "76561198000000009", "object": "kits.admin" }],
|
||||||
|
"operations": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`unresolved` and `pending` are the two ways a push looks like it worked and did
|
||||||
|
not**, and both are load-bearing:
|
||||||
|
|
||||||
|
- **`unresolved`** — no loaded plugin on that server has registered the name.
|
||||||
|
`permission.GrantUserPermission` returns void, throws nothing and logs nothing
|
||||||
|
for an unregistered name ([PLAN.md §12.2](../modules/rust/PLAN.md) rule 1), so
|
||||||
|
without the `PermissionExists` pre-check the grant vanishes without a trace. The
|
||||||
|
plugin does **not** register the name itself: that fabricates a permission the
|
||||||
|
operator never installed.
|
||||||
|
- **`pending`** — the store has never seen that player, so there is no user record
|
||||||
|
to put in a group (§12.2 rule 4). A **direct grant** to the same account works
|
||||||
|
immediately, and the asymmetry is exactly why groups are not the only shape the
|
||||||
|
site can express. The membership lands on their first connection.
|
||||||
|
|
||||||
|
Neither is recorded by the website as pushed. A site that recorded them would
|
||||||
|
believe it had given a privilege it had not — and would later "retire" it from a
|
||||||
|
server that never had it, which is a no-op that reads as a success in every log.
|
||||||
|
|
||||||
|
**A refusal of the whole sync is `perm.error`**, with a reason of `busy` (an
|
||||||
|
earlier sync is still draining) or `too-large`. Like `link.error` it is a `200`
|
||||||
|
from the sidecar: the transport worked and the game answered.
|
||||||
|
|
||||||
|
### 10.3 Retirement is the one thing the game cannot work out
|
||||||
|
|
||||||
|
A name in the store that is not in the desired set is **either** something the site
|
||||||
|
authored and has since withdrawn **or** something a human granted at a console —
|
||||||
|
and those two have opposite correct answers. The store records who granted a
|
||||||
|
permission nowhere, so only the website can tell them apart, from its own memory of
|
||||||
|
what it pushed.
|
||||||
|
|
||||||
|
So the site sends `retire` explicitly, and everything else it did not ask for comes
|
||||||
|
back as `foreign`. **Nothing in `foreign` is ever removed by a sync** (D31): a
|
||||||
|
console `oxide.grant` during an incident is drift, not an error, and an operator is
|
||||||
|
offered two answers to it on the website — adopt it, or revoke it.
|
||||||
|
|
||||||
|
### 10.4 `perm.drift` — a reason to reconcile, not the reconciliation
|
||||||
|
|
||||||
|
Both frameworks raise a hook for every permission write. The plugin subscribes to
|
||||||
|
six of them and emits `perm.drift` for writes **it did not make itself**, staff
|
||||||
|
class (§8.5): it names a Steam id beside a privilege, which is a fact about a
|
||||||
|
person's standing rather than about what happened on the server.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "kind": "perm.drift", "type": "event", "action": "granted",
|
||||||
|
"steamId": "76561198000000009", "permission": "kits.admin" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`action` is one of `granted`, `revoked`, `group-added`, `group-removed`,
|
||||||
|
`group-permission-granted`, `group-permission-revoked`.
|
||||||
|
|
||||||
|
**It cannot say whether the change is foreign** — only the desired set can, and
|
||||||
|
that comparison happens in a sync. So the website treats the frame as a reason to
|
||||||
|
reconcile *soon*: a hand edit shows up in seconds instead of at the next audit, and
|
||||||
|
the authoritative answer still arrives as a report. That division is what makes the
|
||||||
|
hooks safe to trust at this weight: one that stops firing on a framework upgrade
|
||||||
|
costs latency, not correctness.
|
||||||
|
|
||||||
|
The plugin suppresses them while it is applying a sync, because they fire for its
|
||||||
|
own writes too — and the site cannot tell its own grant from a human's by looking
|
||||||
|
at one.
|
||||||
|
|
||||||
|
### 10.5 Nothing the far side sends may cost the main thread unbounded work
|
||||||
|
|
||||||
|
This is the first command whose work is **not** bounded by its own shape. A
|
||||||
|
community with two thousand linked players sends thousands of store operations in
|
||||||
|
one frame, and applying them in the tick the frame arrives is a freeze an operator
|
||||||
|
will blame on the game.
|
||||||
|
|
||||||
|
So a sync is compiled into a list of single-store operations and drained a few
|
||||||
|
hundred at a time on a timer; the report goes back when the last one lands.
|
||||||
|
Compiling touches nothing, so an oversized or malformed sync is refused before any
|
||||||
|
state exists to unwind. That is §5's rule — the one that keeps a wedged sidecar
|
||||||
|
from stalling the game — pointed at the inbound half.
|
||||||
|
|
||||||
|
Three bounds, each on the side that can say something useful when it is hit:
|
||||||
|
|
||||||
|
| Bound | Where | Why there |
|
||||||
|
|---|---|---|
|
||||||
|
| ~15,000 rows | the website | it can name the server and reach an operator |
|
||||||
|
| 1 MiB | the sidecar | it is the game link's own line cap (§3.1); forwarded, the line is discarded silently and presents as a `504` |
|
||||||
|
| 20,000 operations | the plugin | past it, a half-applied permission set is the state nobody can reason about |
|
||||||
|
|
||||||
|
### 10.6 `GET /permissions/catalogue`
|
||||||
|
|
||||||
|
A live round trip to the plugin: every permission the loaded plugins have
|
||||||
|
registered, and the groups the store holds. It is the option source behind the
|
||||||
|
website's authoring form — a grant can only be written against a name that will
|
||||||
|
actually resolve — and, like `/status`, it fails when the game is down, because
|
||||||
|
"what exists right now" has no stale answer worth giving.
|
||||||
|
|
||||||
|
### 10.7 What the sidecar does NOT do
|
||||||
|
|
||||||
|
It defines no schema for either body. Protocol 4 adds the largest command on this
|
||||||
|
bridge and touches neither the store nor the feed, which is §8.1's dumb-forwarder
|
||||||
|
property paying for itself a second time.
|
||||||
|
|
||||||
|
What it does own is the envelope: `cmd` and `reqId` are written over whatever the
|
||||||
|
caller sent, so no request can arrive claiming to be a different command or aimed
|
||||||
|
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