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

@@ -123,11 +123,19 @@ namespace Server.Custom.Bridge
if (m == null)
return;
BridgeLink.Emit(BridgeJson.Begin("mob.login")
// Carry the linked website id on the login anchor so the sidecar can attribute
// this session (and everything after it) to a site user without a lookup.
var webId = BridgeAccountLink.WebIdFor(m.Account as Account);
var sb = BridgeJson.Begin("mob.login")
.Mob("who", m)
.Str("map", m.Map == null ? null : m.Map.Name)
.Num("x", m.X).Num("y", m.Y).Num("z", m.Z)
.End());
.Num("x", m.X).Num("y", m.Y).Num("z", m.Z);
if (webId != null)
sb.Str("webId", webId);
BridgeLink.Emit(sb.End());
});
}