Phase 5: [link account linking

BridgeAccountLink ties a game account to a website account. [link mints a
one-time, 5-minute code from an unambiguous alphabet (no O/0/I/1), holds it in a
Core-thread dict keyed to the account, and emits link.request. The website
relays the code back through the sidecar as link.confirm; the shard validates,
writes the WebsiteUserId account tag, and replies link.ok. A bad or expired code
gets link.error.

The tag persists to accounts.xml in ServUO's standard <tags> format, read by
LoadTags at boot, so a link survives restarts with no new persistence layer.
mob.login now carries webId when the account is linked, so the sidecar can
attribute a session to a site user without a lookup.

Safeguards: one-time codes; only the newest code per account is valid; per-account
30s rate limit against code spam; a 1-minute purge bounds the code table; the
websiteUserId is trusted only because the socket is loopback-only. The tag reaches
memory on confirm but disk only on the next save — a hard crash between loses it,
and the player just re-runs [link.

Verified end to end with a smart stub that reads the emitted code and confirms
it: link.request -> link.confirm -> link.ok, a bad code -> link.error, and the
tag observed in accounts.xml after a save. Evidence in docs/PLAN.md §15.

The [link command body is exposed as RequestLink(Mobile) so it can be driven in
tests without a client. Adds tools/stub_sidecar_link.ps1 and
tools/scaffolding/BridgeLinkProbe.cs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 11:47:17 -05:00
parent 7e48d60a8b
commit feeb904bfe
7 changed files with 399 additions and 3 deletions

View File

@@ -0,0 +1,66 @@
param(
[int] $Port = 7788,
[string] $Log = "$PSScriptRoot\sc_link.log"
)
function Say($msg) {
for ($i = 0; $i -lt 5; $i++) {
try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
catch { Start-Sleep -Milliseconds 100 }
}
}
"" | Out-File -FilePath $Log -Encoding utf8
Say "[link-sc] starting on 127.0.0.1:$Port"
$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
$bound = $false
for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
try { $listener.Start(); $bound = $true } catch { Start-Sleep -Seconds 1 }
}
if (-not $bound) { Say "[link-sc] could not bind"; exit 1 }
Say "[link-sc] listening"
$client = $listener.AcceptTcpClient()
Say "[link-sc] === shard connected ==="
$stream = $client.GetStream()
$reader = New-Object System.IO.StreamReader($stream)
$writer = New-Object System.IO.StreamWriter($stream)
$writer.AutoFlush = $true
$deadline = (Get-Date).AddSeconds(20)
$confirmed = $false
while ((Get-Date) -lt $deadline) {
if ($stream.DataAvailable) {
$line = $reader.ReadLine()
if ($null -eq $line) { break }
Say "[link-sc] <- $line"
# When the shard emits a link.request, extract the code and confirm it.
if (-not $confirmed -and $line -match '"kind":"link\.request"') {
if ($line -match '"code":"([^"]+)"') {
$code = $Matches[1]
$confirm = '{"kind":"link.confirm","code":"' + $code + '","websiteUserId":"web-9931"}'
$writer.WriteLine($confirm)
Say "[link-sc] -> $confirm"
$confirmed = $true
}
}
} else {
Start-Sleep -Milliseconds 100
}
}
# Second exchange: send a bad confirm to prove the error path.
$writer.WriteLine('{"kind":"link.confirm","code":"BADCOD","websiteUserId":"web-0000"}')
Say '[link-sc] -> {"kind":"link.confirm","code":"BADCOD","websiteUserId":"web-0000"}'
$t = (Get-Date).AddSeconds(3)
while ((Get-Date) -lt $t) {
if ($stream.DataAvailable) { $l = $reader.ReadLine(); if ($l) { Say "[link-sc] <- $l" } }
else { Start-Sleep -Milliseconds 100 }
}
Say "[link-sc] done"
$client.Close(); $listener.Stop()