diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 71084f0..0e8a259 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -983,7 +983,7 @@ Each phase ends with its findings written down, as every workstream here does. | 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 | | 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 | | 7b | **Mod configuration from the site** (R18). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `/x.json` is found and reloads the right plugin | | 8 | **Android leg B** (R10). Identity and permission surfaces | Android-app | A player links from the app | @@ -2731,6 +2731,158 @@ an `install -r` over an earlier install keeps the stored base URL. 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. + --- [rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link diff --git a/rust-link/INTEGRATION.md b/rust-link/INTEGRATION.md index 1389c52..f2aa057 100644 --- a/rust-link/INTEGRATION.md +++ b/rust-link/INTEGRATION.md @@ -220,6 +220,21 @@ 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 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. + **Every row carries its wipe.** The plugin derives a `wipeId` from the save's creation time and stamps it on every frame, so a wipe splits the history rather than ending it. That is also why **the sidecar's database must never be in a wipe script's delete list** — see the Pterodactyl egg's diff --git a/rust-link/PLAYER_WALK.md b/rust-link/PLAYER_WALK.md index a438574..5d08631 100644 --- a/rust-link/PLAYER_WALK.md +++ b/rust-link/PLAYER_WALK.md @@ -98,3 +98,32 @@ 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 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 . 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. diff --git a/rust-link/PROTOCOL.md b/rust-link/PROTOCOL.md index aa724b8..04a6fe5 100644 --- a/rust-link/PROTOCOL.md +++ b/rust-link/PROTOCOL.md @@ -50,8 +50,8 @@ it is listening without one. ## 2. Versioning -The wire version is a single integer — **2** as of the read path (§8) — declared in **four** places -that must agree: +The wire version is a single integer — **3** as of identity (§9) — declared in **four** places that +must agree: | Where | Repo | |---|---| @@ -333,7 +333,7 @@ writing the file and generating the token if they are missing — and prints it Protocol 2 is the transport plus the read path. Every one of these 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) - clans, for core's Team provider (phase 9) - leases, budgets and the event actions (phases 12-13) @@ -458,6 +458,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.initialized` | `OnServerInitialized` | 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 and every community site shows it; a **structure's** grid is where somebody lives, which is why @@ -585,3 +587,118 @@ 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. + +--- +