docs(modules): phase 6 as built — identity, and the sentence a player could not see

Protocol 3 in PROTOCOL.md §9, the identity walk in PLAYER_WALK.md, what an
operator needs in INTEGRATION.md, and PLAN.md §19.

Seven org-lead decisions (§19.0): /link is chat and its reply is private, codes
live in plugin memory as the UO bridge does, an alphabet with no O/0/I/1, a
Steam id another account holds is refused rather than moved, the website asks
EVERY server because a code does not say which one minted it, staff can sever a
link, and the activity-row overflow belongs to core.

§19.3 is the finding worth reading: a slot router is not registered under a tier,
so this repo own OpenAPI generator described two routes fewer than the module
serves — internally consistent, and wrong. The frozen-manifest job catches it,
which was verified by deleting the two paths and watching it fail.

§19.4 is what a browser found and 122 green tests did not. Core request
primitive reads data.message; this module has answered { error } since phase 1,
so every refusal this phase exists to write rendered as Service Unavailable.

The code-from-the-game half is written down rather than claimed: a code reaches
a player and nobody else, so no console can read one (D27).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
This commit is contained in:
2026-09-21 09:06:51 -05:00
parent 54b4059091
commit e35880e713
4 changed files with 317 additions and 4 deletions

View File

@@ -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 `<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 |
@@ -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