From be05ea912ce7e1b1ac7ca4c1e3b1a2f20cd96f33 Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 11:47:17 -0500 Subject: [PATCH] Phase 5: [link account linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- README.md | 3 +- docs/PLAN.md | 31 +++ overlay/Config/Bridge.cfg | 3 + .../Custom/Bridge/BridgeAccountLink.cs | 250 ++++++++++++++++++ overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 4 + overlay/Scripts/Custom/Bridge/BridgeEvents.cs | 14 +- tools/scaffolding/BridgeLinkProbe.cs | 64 +++++ tools/scaffolding/README.md | 1 + tools/stub_sidecar_link.ps1 | 66 +++++ 9 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs create mode 100644 tools/scaffolding/BridgeLinkProbe.cs create mode 100644 tools/stub_sidecar_link.ps1 diff --git a/README.md b/README.md index 92886e7..67d4b4e 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree | 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** | | 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** | | 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** | -| 5 — `[link` account linking | not started | +| 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in `docs/PLAN.md` §15** | | 6 — town-crier inbound | not started | | 7 — `PlayerVendorSale` core event | not started | | 8 — cheat signals | not started | @@ -69,6 +69,7 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S | `BridgeSweeps.cs` | Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers. | | `BridgeProfile.cs` | Read-model builders (Phase 4): full character profile, account roster. Core-thread reads. | | `BridgeRequests.cs` | Inbound request handlers (Phase 4): `char.request`, `account.roster`, `vendor.snapshot`, with `bridge.error` replies. | +| `BridgeAccountLink.cs` | `[link` account linking (Phase 5): one-time code, `link.confirm`, `WebsiteUserId` account tag. | `Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on. diff --git a/docs/PLAN.md b/docs/PLAN.md index 5c2dc2d..e1c6c65 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -307,6 +307,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val 2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12. 3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13. 4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests. +5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15. 5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed. 6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries. 7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed. @@ -348,6 +349,36 @@ Two defects were found this way and fixed: --- +## 15. Phase 5 acceptance + +`BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`. + +Verified end to end with a smart stub (`tools/scaffolding/BridgeLinkProbe.cs` + a sidecar that reads the code and confirms it): + +``` +<- link.request code=77M9TK account=seed_001 char=Seed001A ttlSec=300 +-> link.confirm code=77M9TK websiteUserId=web-9931 +<- link.ok code=77M9TK account=seed_001 websiteUserId=web-9931 +-> link.confirm code=BADCOD ... +<- link.error code=BADCOD reason="unknown or expired code" +``` + +**The tag persists.** After a `World.Save()`, `accounts.xml` contained: + +```xml + + web-9931 + +``` + +This is ServUO's standard account-tag format, read by `LoadTags` at boot, so the link survives restarts with no new persistence layer — as the plan promised. + +Safeguards in place: codes are one-time and short-TTL; only the newest code per account is valid (a new `[link` drops prior codes); `[link` is rate-limited per account (30 s) against code spam; a 1-minute purge timer bounds the code table; and the `websiteUserId` is trusted only because the socket is loopback-only. `mob.login` now carries `webId` when the account is linked, so the sidecar can attribute the session without a lookup. + +Note: the tag is written to memory on `link.confirm` but only reaches disk on the next world save (AutoSave, clean shutdown, or an explicit save). A hard crash between the two loses it — acceptable, since the player simply re-runs `[link`. + +--- + ## 14. Phase 4 acceptance `BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence. diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg index f1ddd8f..5ceccce 100644 --- a/overlay/Config/Bridge.cfg +++ b/overlay/Config/Bridge.cfg @@ -19,6 +19,9 @@ StatSweepSeconds=30 DecaySweepSeconds=60 EconomySweepSeconds=300 +# Shown to a player when they run [link. The website page where they enter the code. +LinkUrl=https://yoursite/link + # The test scaffolding in tools/scaffolding/ reads its own flags from this file # (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose: # Config.Get returns the default of false when a key is missing, so a deployed diff --git a/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs new file mode 100644 index 0000000..d9a34e2 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; + +using Server.Accounting; +using Server.Commands; + +namespace Server.Custom.Bridge +{ + /// + /// Ties a game account to a website account. + /// + /// Flow: + /// 1. In game, the player runs [link. The shard mints a short, one-time, expiring code, + /// holds it in memory keyed to their account, and emits link.request to the sidecar. + /// 2. The player enters that code on the website. The website tells the sidecar, which + /// sends link.confirm inbound. + /// 3. The shard validates the code, writes Account tag "WebsiteUserId", drops the code, + /// and replies link.ok. The tag persists to accounts.xml across restarts. + /// + /// The code table and the account write both live on the Core thread. The websiteUserId in + /// link.confirm is trusted only because the socket is loopback-only (docs/PLAN.md §2); if the + /// sidecar ever moves off-host, gate it behind a shared secret. + /// + public static class BridgeAccountLink + { + private const string Tag = "WebsiteUserId"; + + // Unambiguous alphabet: no O/0, I/1, so a player reading a code aloud can't get it wrong. + private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private const int CodeLength = 6; + + private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5); + private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30); + + private sealed class Pending + { + public string Account; + public DateTime Expires; + } + + // code -> pending link. Core-thread only. + private static readonly Dictionary _codes = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + // account -> last [link time, to rate-limit code spam. + private static readonly Dictionary _lastRequest = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand); + BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm); + + // Purge expired codes so an unconfirmed spam of [link cannot grow the table forever. + Timer.DelayCall(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0), PurgeExpired); + } + + /// Reads the linked website id for an account, or null. Used to enrich events. + public static string WebIdFor(Account acct) + { + if (acct == null) + return null; + + return acct.GetTag(Tag); + } + + // ---- [link ---- + + [Usage("link")] + [Description("Links this game account to your website account via a one-time code.")] + private static void OnLinkCommand(CommandEventArgs e) + { + RequestLink(e.Mobile); + } + + /// + /// Mints a one-time code for the mobile's account and emits link.request. This is the + /// body of the [link command, exposed so it can be driven in tests without a client. + /// + public static void RequestLink(Mobile m) + { + if (m == null) + return; + + var acct = m.Account as Account; + + if (acct == null) + { + m.SendMessage("Bridge: no account on this character."); + return; + } + + var existing = acct.GetTag(Tag); + if (existing != null) + { + m.SendMessage("Your account is already linked to website user {0}.", existing); + return; + } + + DateTime last; + if (_lastRequest.TryGetValue(acct.Username, out last) && DateTime.UtcNow - last < RequestCooldown) + { + m.SendMessage("Please wait a moment before requesting another link code."); + return; + } + + // One outstanding code per account: drop any prior code so only the newest works. + DropCodesFor(acct.Username); + + var code = MintCode(); + _codes[code] = new Pending { Account = acct.Username, Expires = DateTime.UtcNow + CodeTtl }; + _lastRequest[acct.Username] = DateTime.UtcNow; + + BridgeLink.Emit(BridgeJson.Begin("link.request") + .Str("code", code) + .Str("account", acct.Username) + .Str("char", m.Name) + .Num("ttlSec", (long)CodeTtl.TotalSeconds) + .End()); + + var url = BridgeConfig.LinkUrl; + m.SendMessage(0x35, "Link code: {0}", code); + m.SendMessage("Enter it at {0} within {1} minutes to link your account.", + url, (int)CodeTtl.TotalMinutes); + } + + // ---- inbound link.confirm ---- + + private static void OnLinkConfirm(Dictionary o) + { + var code = BridgeJson.GetString(o, "code"); + var webId = BridgeJson.GetString(o, "websiteUserId"); + + if (code == null || webId == null) + { + Reply("link.error", null, null, "malformed link.confirm"); + return; + } + + Pending pending; + if (!_codes.TryGetValue(code, out pending)) + { + Reply("link.error", code, null, "unknown or expired code"); + return; + } + + _codes.Remove(code); + + if (DateTime.UtcNow > pending.Expires) + { + Reply("link.error", code, pending.Account, "code expired"); + return; + } + + var acct = Accounting.Accounts.GetAccount(pending.Account) as Account; + if (acct == null) + { + Reply("link.error", code, pending.Account, "account no longer exists"); + return; + } + + // Persisted to accounts.xml on the next world save. + acct.SetTag(Tag, webId); + DropCodesFor(pending.Account); + + Reply("link.ok", code, pending.Account, null, webId); + + NotifyOnline(acct, webId); + } + + // ---- helpers ---- + + private static void Reply(string kind, string code, string account, string reason, string webId = null) + { + var sb = BridgeJson.Begin(kind); + if (code != null) sb.Str("code", code); + if (account != null) sb.Str("account", account); + if (webId != null) sb.Str("websiteUserId", webId); + if (reason != null) sb.Str("reason", reason); + BridgeLink.Emit(sb.End()); + } + + private static void NotifyOnline(Account acct, string webId) + { + for (int i = 0; i < acct.Length; i++) + { + var m = acct[i]; + if (m != null && m.NetState != null) + m.SendMessage(0x40, "Your account is now linked to website user {0}.", webId); + } + } + + private static string MintCode() + { + // Avoid a collision with an outstanding code, though at 32^6 it is astronomically rare. + for (int attempt = 0; attempt < 8; attempt++) + { + var chars = new char[CodeLength]; + for (int i = 0; i < CodeLength; i++) + chars[i] = Alphabet[Utility.Random(Alphabet.Length)]; + + var code = new string(chars); + if (!_codes.ContainsKey(code)) + return code; + } + + // Fall back to a guaranteed-unique code. + return "L" + DateTime.UtcNow.Ticks.ToString("X").Substring(0, CodeLength - 1); + } + + private static void DropCodesFor(string account) + { + var doomed = new List(); + + foreach (var kv in _codes) + { + if (String.Equals(kv.Value.Account, account, StringComparison.OrdinalIgnoreCase)) + doomed.Add(kv.Key); + } + + foreach (var c in doomed) + _codes.Remove(c); + } + + private static void PurgeExpired() + { + try + { + var now = DateTime.UtcNow; + var doomed = new List(); + + foreach (var kv in _codes) + { + if (now > kv.Value.Expires) + doomed.Add(kv.Key); + } + + foreach (var c in doomed) + _codes.Remove(c); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] link purge threw: {0}", ex.Message); + } + } + } +} diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs index c5e0207..a369599 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs @@ -18,6 +18,8 @@ namespace Server.Custom.Bridge public static int DecaySweepSeconds { get; private set; } public static int EconomySweepSeconds { get; private set; } + public static string LinkUrl { get; private set; } + public static bool Enabled { get; private set; } public static void Configure() @@ -38,6 +40,8 @@ namespace Server.Custom.Bridge DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60); EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300); + LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link"); + if (QueueCap < 16) QueueCap = 16; } diff --git a/overlay/Scripts/Custom/Bridge/BridgeEvents.cs b/overlay/Scripts/Custom/Bridge/BridgeEvents.cs index 9c373ce..a05fbea 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeEvents.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeEvents.cs @@ -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()); }); } diff --git a/tools/scaffolding/BridgeLinkProbe.cs b/tools/scaffolding/BridgeLinkProbe.cs new file mode 100644 index 0000000..7004b80 --- /dev/null +++ b/tools/scaffolding/BridgeLinkProbe.cs @@ -0,0 +1,64 @@ +using System; + +using Server.Accounting; +using Server.Custom.Bridge; +using Server.Mobiles; + +namespace Server.Custom +{ + /// + /// Triggers the [link flow for a seeded character without a game client, so the account + /// linking round-trip can be tested end to end: RequestLink emits link.request, the test + /// sidecar reads the code and sends link.confirm, and the account gets tagged. + /// + /// Test scaffolding. Never deployed. Writes an account tag (persisted on save). + /// + public static class BridgeLinkProbe + { + public static void Initialize() + { + if (Config.Get("Bridge.LinkProbeOnStart", false)) + EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(3.0), Run); + } + + private static void Run() + { + try + { + // Use a seed account not already linked. seed_001 slot 0. + var acct = Accounting.Accounts.GetAccount("seed_001") as Account; + var pm = acct == null ? null : acct[0] as PlayerMobile; + + if (pm == null) + { + Console.WriteLine("[LinkProbe] seed_001 slot 0 not found; seed the world first"); + return; + } + + var existing = acct.GetTag("WebsiteUserId"); + if (existing != null) + Console.WriteLine("[LinkProbe] seed_001 already linked to {0}; re-running anyway", existing); + + Console.WriteLine("[LinkProbe] requesting link for seed_001 / {0}", pm.Name); + BridgeAccountLink.RequestLink(pm); + Console.WriteLine("[LinkProbe] link.request emitted; watch for the code -> link.confirm -> link.ok"); + + // Give the confirm time to land and set the tag, then save so it reaches + // accounts.xml. This proves persistence across a restart. + Timer.DelayCall(TimeSpan.FromSeconds(5.0), () => + { + var tag = acct.GetTag("WebsiteUserId"); + Console.WriteLine("[LinkProbe] after confirm, seed_001 WebsiteUserId tag = {0}", + tag ?? "(null)"); + Console.WriteLine("[LinkProbe] saving world to persist the tag..."); + World.Save(); + Console.WriteLine("[LinkProbe] saved"); + }); + } + catch (Exception ex) + { + Console.WriteLine("[LinkProbe] FAILED: " + ex); + } + } + } +} diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md index 14a83e8..3c33838 100644 --- a/tools/scaffolding/README.md +++ b/tools/scaffolding/README.md @@ -10,6 +10,7 @@ These two scripts produced the measured budget in `docs/PLAN.md` §1. They are k | `BridgeProbe.cs` | `Scripts/Custom/BridgeProbe.cs` | Times every read the plugin performs, on the Core thread. Read-only. | | `BridgeEventProbe.cs` | `Scripts/Custom/BridgeEventProbe.cs` | Fires gold/fame/karma/save events through their real code paths so the emit path can be verified without a game client. **Mutates the world and saves.** Flag: `EventProbeOnStart`. | | `BridgeSweepProbe.cs` | `Scripts/Custom/BridgeSweepProbe.cs` | Bumps one seeded house's decay stage after baseline so the decay sweep's transition detection can be observed without waiting a real IDOC stage. Flag: `SweepProbeOnStart`. Pair with short `*SweepSeconds` overrides. | +| `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. | ## Deploy overwrites Bridge.cfg diff --git a/tools/stub_sidecar_link.ps1 b/tools/stub_sidecar_link.ps1 new file mode 100644 index 0000000..123eab1 --- /dev/null +++ b/tools/stub_sidecar_link.ps1 @@ -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()