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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user