feat(bridge): protocol 3 — /link, /unlink, and the first command the site sends #3

Merged
whitlocktech merged 1 commits from feat/phase-6-identity into edge 2026-09-21 17:25:39 -05:00
Member

R1's identity flow, in the shape the UO bridge already proved: the player asks in game, the plugin mints a one-time code and hands it to them, and the website confirms it back through the sidecar. The site is the author of record; this plugin holds no link at all.

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

Pending codes live in memory and nowhere else (D21, D22)

Matching module-uo, whose shape was read out of BridgeAccountLink.cs rather than guessed: six characters from an alphabet with no O, 0, I or 1, a five-minute TTL, a thirty-second cooldown per player, one outstanding code each, and a purge timer — because an unconfirmed code is never looked up, so nothing else would ever remove one.

A plugin reload drops them. Phase 7b's config editor will do that routinely, and the cost is a player typing /link again, which is cheaper than an unconfirmed credential living in a second process.

The code is deliberately NOT in account.link.requested

That event exists so an operator can see linking being used. 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.

There is nothing here to delete. It is the escape hatch that makes the site's refusal to move an already-linked Steam id safe (D23): a player who linked the wrong account walks to a computer they are already sitting at. The authority on that path is the Steam account itself.

link.confirm

A code is consumed on the first lookup whether or not it turns out to be expired, so a code cannot be probed twice — which is why the removal happens before the expiry check rather than after it. unknown and expired stay separate in the reply (an operator wants to know whether codes are being guessed or going stale) and the website says the same sentence for both.

The checker grew a surface

checkPlugin.js now checks chat commands, because they bind by reflection exactly like a hook and fail the same silent way. It asserts the signature, the void return and the absence of duplicates, and — like the hook parser before it — a test reads the real plugin so a regex that matches nothing cannot pass. Verified by breaking it five ways.

Where this has been

Uploaded to the rust-oxide rig and booted: [Runic Gateway] protocol 3, serverId 'rust-oxide', sidecar 192.168.0.101:7800, compiled in 0ms, plugin loaded. It could not reach the sidecar on the development machine — no inbound firewall rule for TCP 7800 on that host — so the code-from-the-game path is not walked here. It is written down instead, as the new identity walk in docs/rust-link/PLAYER_WALK.md, beside phase 3's player walk and for the same reason: a code reaches a player and nobody else, so no console can read one.

Protocol bumped in both declaration sites this repo owns (ProtocolVersion and overlay.toml).

AI disclosure

  • This contribution was AI-assisted (Claude Code).

🤖 Generated with Claude Code

https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM

R1's identity flow, in the shape the UO bridge already proved: the player asks in game, the plugin mints a one-time code and hands it to them, and the website confirms it back through the sidecar. **The site is the author of record; this plugin holds no link at all.** ## `/link` is chat, not console, and the reply is private (D20) `[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.** ## Pending codes live in memory and nowhere else (D21, D22) Matching `module-uo`, whose shape was read out of `BridgeAccountLink.cs` rather than guessed: six characters from an alphabet with **no O, 0, I or 1**, a five-minute TTL, a thirty-second cooldown per player, one outstanding code each, and a purge timer — because an unconfirmed code is never looked up, so nothing else would ever remove one. A plugin reload drops them. Phase 7b's config editor will do that routinely, and the cost is a player typing `/link` again, which is cheaper than an unconfirmed credential living in a second process. ## The code is deliberately NOT in `account.link.requested` That event exists so an operator can see linking being used. 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. ## `/unlink` emits rather than deletes There is nothing here to delete. It is the escape hatch that makes the site's refusal to move an already-linked Steam id safe (D23): a player who linked the wrong account walks to a computer they are already sitting at. The authority on that path is the Steam account itself. ## `link.confirm` A code is consumed on the **first** lookup whether or not it turns out to be expired, so a code cannot be probed twice — which is why the removal happens before the expiry check rather than after it. `unknown` and `expired` stay separate in the reply (an operator wants to know whether codes are being guessed or going stale) and the website says the same sentence for both. ## The checker grew a surface `checkPlugin.js` now checks chat commands, because they bind by reflection exactly like a hook and fail the same silent way. It asserts the signature, the void return and the absence of duplicates, and — like the hook parser before it — a test reads the real plugin so a regex that matches nothing cannot pass. **Verified by breaking it five ways.** ## Where this has been Uploaded to the `rust-oxide` rig and booted: `[Runic Gateway] protocol 3, serverId 'rust-oxide', sidecar 192.168.0.101:7800`, compiled in 0ms, plugin loaded. It could not reach the sidecar on the development machine — no inbound firewall rule for TCP 7800 on that host — so **the code-from-the-game path is not walked here**. It is written down instead, as the new *identity walk* in `docs/rust-link/PLAYER_WALK.md`, beside phase 3's player walk and for the same reason: a code reaches a player and nobody else, so no console can read one. Protocol bumped in both declaration sites this repo owns (`ProtocolVersion` and `overlay.toml`). ## AI disclosure - [x] This contribution was AI-assisted (Claude Code). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PMH6bw1jXMgbyF3ZWGEzSM
wtclaude added 1 commit 2026-09-21 09:11:47 -05:00
feat(bridge): protocol 3 — /link, /unlink, and the first command the site sends
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
47692e8832
R1's identity flow, in the shape the UO bridge already proved: the player asks
in game, the plugin mints a one-time code and hands it to them, and the website
confirms it back through the sidecar. The site is the author of record; this
plugin holds no link at all.

**`/link` is chat, not console, and the reply is private** (D21). Both frameworks
consume a `/` command rather than broadcasting it and `SendReply` addresses one
player, so neither the request nor the code reaches anybody else — a code read
off a stream is a code somebody else can spend.

**Pending codes live in memory and nowhere else** (D22, matching module-uo): six
characters from an alphabet with no O/0/I/1, five-minute TTL, a thirty-second
cooldown per player, one outstanding code each, and a purge timer because an
unconfirmed code is never looked up. A plugin reload drops them — phase 7b's
config editor will do that routinely — and the cost is typing `/link` again,
which is cheaper than a credential living in a second process.

**The code is deliberately NOT in `account.link.requested`.** That event exists so
an operator can see linking being used; the code travels through the player,
which is what makes typing it proof they are the one who asked.

`/unlink` emits rather than deletes, because there is nothing here to delete. It
is the escape hatch that makes the site's refusal to move an already-linked Steam
id safe: a player who linked the wrong account walks to a computer they are
already sitting at.

`link.confirm` consumes a code on the FIRST lookup whether or not it turns out to
be expired, so a code cannot be probed twice. `unknown` and `expired` stay
separate in the reply — an operator wants to know whether codes are being guessed
or going stale — and the website says the same sentence for both.

`checkPlugin.js` gains a check for the new surface, because a chat command binds
by reflection exactly like a hook and fails the same silent way. It asserts the
signature, the void return and the absence of duplicates, and — like the hook
parser before it — a test reads the real plugin so a regex that matches nothing
cannot pass. Verified by breaking it five ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
whitlocktech merged commit 0a1f7725f1 into edge 2026-09-21 17:25:39 -05:00
whitlocktech deleted branch feat/phase-6-identity 2026-09-21 17:25:40 -05:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/Rust-Plugins#3
No description provided.