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:
@@ -19,6 +19,9 @@ StatSweepSeconds=30
|
|||||||
DecaySweepSeconds=60
|
DecaySweepSeconds=60
|
||||||
EconomySweepSeconds=300
|
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
|
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
||||||
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
||||||
# Config.Get returns the default of false when a key is missing, so a deployed
|
# Config.Get returns the default of false when a key is missing, so a deployed
|
||||||
|
|||||||
250
overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs
Normal file
250
overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Commands;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string, Pending> _codes =
|
||||||
|
new Dictionary<string, Pending>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// account -> last [link time, to rate-limit code spam.
|
||||||
|
private static readonly Dictionary<string, DateTime> _lastRequest =
|
||||||
|
new Dictionary<string, DateTime>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the linked website id for an account, or null. Used to enrich events.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string, object> 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<string>();
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ namespace Server.Custom.Bridge
|
|||||||
public static int DecaySweepSeconds { get; private set; }
|
public static int DecaySweepSeconds { get; private set; }
|
||||||
public static int EconomySweepSeconds { 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 bool Enabled { get; private set; }
|
||||||
|
|
||||||
public static void Configure()
|
public static void Configure()
|
||||||
@@ -38,6 +40,8 @@ namespace Server.Custom.Bridge
|
|||||||
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
||||||
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
||||||
|
|
||||||
|
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
||||||
|
|
||||||
if (QueueCap < 16)
|
if (QueueCap < 16)
|
||||||
QueueCap = 16;
|
QueueCap = 16;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,11 +123,19 @@ namespace Server.Custom.Bridge
|
|||||||
if (m == null)
|
if (m == null)
|
||||||
return;
|
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)
|
.Mob("who", m)
|
||||||
.Str("map", m.Map == null ? null : m.Map.Name)
|
.Str("map", m.Map == null ? null : m.Map.Name)
|
||||||
.Num("x", m.X).Num("y", m.Y).Num("z", m.Z)
|
.Num("x", m.X).Num("y", m.Y).Num("z", m.Z);
|
||||||
.End());
|
|
||||||
|
if (webId != null)
|
||||||
|
sb.Str("webId", webId);
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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. |
|
| `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`. |
|
| `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. |
|
| `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
|
## 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