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

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
This commit is contained in:
2026-09-17 07:33:45 -05:00
parent 47bd166d91
commit 47692e8832
4 changed files with 502 additions and 6 deletions

View File

@@ -1,4 +1,4 @@
// A check is worth what it catches, so this breaks it seven ways.
// A check is worth what it catches, so this breaks it thirteen ways.
//
// The case that matters most is the last one: `checkPlugin.js` finds hooks with a
// deliberately narrow regex, and a regex that silently matches NOTHING passes
@@ -16,7 +16,7 @@ const assert = require('node:assert')
const fs = require('node:fs')
const path = require('node:path')
const { check, readExpectedHooks, readMethods, HOOK_NAME } = require('./checkPlugin')
const { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } = require('./checkPlugin')
/** A minimal plugin that passes, as the baseline every case below deviates from. */
function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) {
@@ -128,3 +128,85 @@ test('the parser actually reads the real plugin, rather than quietly matching no
const expected = readExpectedHooks(real)
assert.ok(expected.length >= 15, `only found ${expected.length} entries in ExpectedHooks`)
})
// ── Chat commands (protocol 3) ─────────────────────────────────────────────
//
// A chat command binds by reflection on an exact signature, and a wrong one is
// never invoked and never logged: the player types `/link` and nothing happens.
// That is the same failure mode the hook checks exist for, one surface along.
/** A plugin whose only body is one chat command. */
function withChat(attribute, signature, returns = 'void') {
return source({
expected: [],
methods: ` [ChatCommand("${attribute}")]
private ${returns} CmdThing(${signature})
{
}`,
})
}
const GOOD_SIGNATURE = 'BasePlayer player, string command, string[] args'
test('a correctly shaped chat command passes', () => {
assert.deepEqual(check(withChat('link', GOOD_SIGNATURE), toml()), [])
})
test('a chat command taking the wrong player type is caught', () => {
// The plausible mistake: Covalence's `IPlayer` is what a Universal plugin
// takes, and this plugin is not one. It compiles and binds nothing.
const problems = check(withChat('link', 'IPlayer player, string command, string[] args'), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /not \(BasePlayer, string, string\[\]\)/)
})
test('a chat command missing the trailing args is caught', () => {
const problems = check(withChat('link', 'BasePlayer player, string command'), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /nothing happens at all/)
})
test('a chat command that returns something is caught', () => {
const problems = check(withChat('link', GOOD_SIGNATURE, 'object'), toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /returns object, not void/)
})
test('two chat commands answering to one name is caught', () => {
const both = source({
expected: [],
methods: ` [ChatCommand("link")]
private void CmdOne(${GOOD_SIGNATURE})
{
}
[ChatCommand("link")]
private void CmdTwo(${GOOD_SIGNATURE})
{
}`,
})
const problems = check(both, toml())
assert.equal(problems.length, 1)
assert.match(problems[0], /declared twice/)
})
test('the chat-command parser reads the real plugin, rather than quietly matching nothing', () => {
// The same trap the method parser has, and the reason this file's last test
// exists: a regex that matches NOTHING passes every case above.
const real = fs.readFileSync(
path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'),
'utf8'
)
const commands = readChatCommands(real)
const names = new Set(commands.map((c) => c.command))
assert.ok(commands.length >= 2, `only found ${commands.length} chat commands in the real plugin`)
assert.ok(names.has('link'), '/link was not found by the chat-command parser')
assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser')
})