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

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

View File

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

View File

@@ -50,8 +50,8 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer — **2** as of the read path8) — declared in **four** places
that must agree:
The wire version is a single integer — **3** as of identity9) — 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.
---