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:
64
tools/scaffolding/BridgeLinkProbe.cs
Normal file
64
tools/scaffolding/BridgeLinkProbe.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Custom.Bridge;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
66
tools/stub_sidecar_link.ps1
Normal file
66
tools/stub_sidecar_link.ps1
Normal 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()
|
||||
Reference in New Issue
Block a user