feat(protocol2): website account provisioning & unlinking (Part A)

Adds the account-provisioning plane from docs/PROTOCOL_2.md Part A: the
website can create game accounts and unlink them, gated by a shard-wide
signup mode. The existing [link flow is unchanged.

Overlay:
- BridgeConfig: SignupMode (website|game|hybrid, default hybrid; unrecognized
  falls back to game), AccountCreateEnabled (mode-following default),
  RequireIpForCreate, name/password caps, and a boot warning when the core
  Accounts.AutoCreateAccounts setting contradicts the mode.
- BridgeAccounts (new): account.create (mode gate, actor required, char-safety
  mirrored from AccountHandler, collision check, per-IP cap via CanCreate/
  LogAccess with fail-closed missing/loopback IP, create + WebsiteUserId link,
  account.audit; password never logged or echoed) and account.unlink (Owner
  floor via BridgeAdmin.Protected, clears the tag).
- BridgeAccountLink: in-game [unlink command, emits account.unlinked.
- BridgeAdmin: Protected / ResolveTargetAccount promoted to public for reuse.

Sidecar:
- POST /accounts/create, DELETE /link/:account, respond_account status mapping
  (409 collision / 429 ip cap / 403 disabled|protected / 404 not-linked / 400).
- store.record_unlink drops the mirrored link row.
- PROTOCOL_VERSION -> 2 (outbound events additive; new endpoints need v2).

Docs: INTEGRATION.md protocol bump, account.* events, endpoints, 409/429;
PROTOCOL_2.md Part A marked built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 07:42:06 -05:00
parent 048ee000f5
commit 5816c29c67
5 changed files with 437 additions and 6 deletions

View File

@@ -57,6 +57,31 @@ AdminReasonMaxLength=400
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite. # Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
AdminBanMaxDurationSec=31536000 AdminBanMaxDurationSec=31536000
# Account provisioning (docs/PROTOCOL_2.md Part A). Which side may mint game accounts:
# website — the website is the authority; pair with Accounts.AutoCreateAccounts=false
# (else an in-game login of any new name still mints an account).
# game — the game server is the authority; website account.create is refused.
# hybrid — either side may create (the default).
# The bridge governs only the account.create verb; the in-game first-login auto-create is
# the core Accounts.AutoCreateAccounts setting, which you pair with the mode above. On boot
# the bridge warns if the two contradict. An unrecognized value here falls back to 'game'
# (the safest — no website creation).
SignupMode=hybrid
# Master switch for the account.create verb. Absent, it follows the mode (on unless
# SignupMode=game). Set explicitly to force it on or off regardless of mode.
AccountCreateEnabled=true
# Fail closed if account.create omits a usable browser IP. The per-IP cap
# (Accounts.AccountsPerIp) only means something if a missing/loopback IP is refused rather
# than waved through. Turn off only for a deployment that deliberately does not cap website
# signups by IP (MaxAccountsPerIP still applies in-game either way).
RequireIpForCreate=true
# Length caps on a website-supplied username / password, checked before the account is made.
AccountNameMaxLength=16
AccountPasswordMaxLength=30
# 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

View File

@@ -52,6 +52,7 @@ namespace Server.Custom.Bridge
return; return;
CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand); CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand);
BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm); BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
// Purge expired codes so an unconfirmed spam of [link cannot grow the table forever. // Purge expired codes so an unconfirmed spam of [link cannot grow the table forever.
@@ -127,6 +128,53 @@ namespace Server.Custom.Bridge
url, (int)CodeTtl.TotalMinutes); url, (int)CodeTtl.TotalMinutes);
} }
// ---- [unlink ----
[Usage("unlink")]
[Description("Unlinks this game account from your website account.")]
private static void OnUnlinkCommand(CommandEventArgs e)
{
Unlink(e.Mobile);
}
/// <summary>
/// Clears the WebsiteUserId tie from the caller's own account and tells the sidecar, so
/// the website can reconcile a player-initiated unlink. Player-scoped (own account only),
/// so it needs no access floor. After unlinking, [link works again.
/// </summary>
public static void Unlink(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 not linked to a website account.");
return;
}
acct.RemoveTag(Tag);
DropCodesFor(acct.Username); // drop any pending codes so nothing dangles
BridgeLink.Emit(BridgeJson.Begin("account.unlinked")
.Str("origin", "in-game")
.Str("account", acct.Username)
.Str("websiteUserId", existing)
.Str("char", m.Name)
.End());
m.SendMessage(0x40, "Your account is no longer linked to website user {0}.", existing);
}
// ---- inbound link.confirm ---- // ---- inbound link.confirm ----
private static void OnLinkConfirm(Dictionary<string, object> o) private static void OnLinkConfirm(Dictionary<string, object> o)

View File

@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.Net;
using Server.Accounting;
using Server.Misc;
namespace Server.Custom.Bridge
{
/// <summary>
/// The account provisioning plane (docs/PROTOCOL_2.md Part A): website-driven account
/// creation and unlinking. Companion to BridgeAccountLink (the in-game [link flow), which
/// is unchanged.
///
/// account.create — mint a game account and link it to a website user in one step.
/// account.unlink — sever the WebsiteUserId tie from the website side.
///
/// Both handlers run on the Core thread (BridgeBoot marshals inbound lines through
/// Timer.DelayCall first), so they touch accounts freely.
///
/// Trust model matches the admin plane (docs/ADMIN_CONTROLS.md §5): authorization lives on
/// the website; the shard trusts the loopback + token socket and a required "actor" field.
/// The one shard-side floor on unlink is BridgeAdmin.Protected — a protected staff account is
/// never unlinkable from the web. The whole create plane is opt-in via SignupMode /
/// AccountCreateEnabled.
/// </summary>
public static class BridgeAccounts
{
private const string Tag = "WebsiteUserId";
// Mirrors AccountHandler.m_ForbiddenChars so a website-created name behaves exactly like an
// in-game one (AccountHandler.cs). Kept local because that array is private.
private static readonly char[] ForbiddenChars =
{
'<', '>', ':', '"', '/', '\\', '|', '?', '*', ' '
};
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("account.create", OnCreate);
BridgeBoot.RegisterHandler("account.unlink", OnUnlink);
}
// ---- account.create ----
/// <summary>
/// Creates a game account and links it to the given website user. Refused unless the
/// signup mode allows website creation. Enforces the same username/password character
/// safety and per-IP cap as ServUO's in-game create path; the password never leaves the
/// process in any reply, audit, or log.
/// </summary>
private static void OnCreate(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "create";
if (!BridgeConfig.AccountCreateEnabled || BridgeConfig.Signup == SignupMode.Game)
{
Err(reqId, action, "signups disabled for this mode");
return;
}
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
{
Err(reqId, action, "missing actor");
return;
}
var account = BridgeJson.GetString(o, "account");
var password = BridgeJson.GetString(o, "password");
var webId = BridgeJson.GetString(o, "websiteUserId");
var ipStr = BridgeJson.GetString(o, "ip");
if (String.IsNullOrEmpty(account))
{
Err(reqId, action, "missing account");
return;
}
if (String.IsNullOrEmpty(password))
{
Err(reqId, action, "missing password");
return;
}
if (String.IsNullOrEmpty(webId))
{
Err(reqId, action, "missing websiteUserId");
return;
}
if (account.Length > BridgeConfig.AccountNameMaxLength ||
password.Length > BridgeConfig.AccountPasswordMaxLength)
{
Err(reqId, action, "username or password too long");
return;
}
if (!IsSafeUsername(account) || !IsSafePassword(password))
{
Err(reqId, action, "invalid username/password");
return;
}
// Collision: the only correct resolution of a website/in-game race for a name.
if (Accounts.GetAccount(account) != null)
{
Err(reqId, action, "account already exists");
return;
}
// Per-IP cap. Fail closed on a missing/loopback IP when RequireIpForCreate — loopback is
// exempt in IPLimiter, so accepting it would silently bypass the cap.
IPAddress ip;
bool haveIp = TryParseIp(ipStr, out ip);
if (BridgeConfig.RequireIpForCreate && (!haveIp || IPAddress.IsLoopback(ip)))
{
Err(reqId, action, "client ip required");
return;
}
if (haveIp && !AccountHandler.CanCreate(ip))
{
Err(reqId, action, "ip account limit reached");
return;
}
// Create + link. new Account self-registers (Accounts.Add) and hashes the password per
// the shard's ProtectPasswords; LogAccess records the IP and bumps IPTable exactly as an
// in-game first-login does; the tag persists on the next world save.
var acct = new Account(account, password);
if (haveIp)
acct.LogAccess(ip);
acct.SetTag(Tag, webId);
Console.WriteLine("[Bridge][account] web:{0} create {1} websiteUserId={2} ip={3}",
actor, account, webId, haveIp ? ip.ToString() : "-");
BridgeLink.Emit(AuditBegin(action, actor, account)
.Str("websiteUserId", webId)
.End());
var sb = BridgeJson.Begin("account.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action).Str("account", account).Str("websiteUserId", webId);
BridgeLink.Emit(sb.End());
}
// ---- account.unlink ----
/// <summary>
/// Removes the WebsiteUserId tie from an account. Symmetric with the in-game [unlink; the
/// Owner floor keeps a protected staff account unreachable from the web.
/// </summary>
private static void OnUnlink(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "unlink";
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
{
Err(reqId, action, "missing actor");
return;
}
var acct = BridgeAdmin.ResolveTargetAccount(o);
if (acct == null)
{
Err(reqId, action, "unknown or accountless target");
return;
}
if (BridgeAdmin.Protected(acct))
{
Err(reqId, action, "target is protected staff; refused");
return;
}
var existing = acct.GetTag(Tag);
if (existing == null)
{
Err(reqId, action, "not linked");
return;
}
acct.RemoveTag(Tag);
Console.WriteLine("[Bridge][account] web:{0} unlink {1} (was websiteUserId={2})",
actor, acct.Username, existing);
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
.Str("websiteUserId", existing)
.End());
var sb = BridgeJson.Begin("account.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action).Str("account", acct.Username);
BridgeLink.Emit(sb.End());
}
// ---- helpers ----
private static void Err(string reqId, string action, string reason)
{
var sb = BridgeJson.Begin("account.error");
if (reqId != null) sb.Str("reqId", reqId);
if (action != null) sb.Str("action", action);
sb.Str("reason", reason);
BridgeLink.Emit(sb.End());
}
/// <summary>
/// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to
/// admin.audit. Never carries the password.
/// </summary>
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
{
return BridgeJson.Begin("account.audit")
.Str("origin", "web")
.Str("action", action)
.Str("actor", "web:" + actor)
.Str("target", target);
}
/// <summary>Mirrors the username safety rules in AccountHandler.CreateAccount.</summary>
private static bool IsSafeUsername(string un)
{
if (un.StartsWith(" ") || un.EndsWith(" ") || un.EndsWith("."))
return false;
for (int i = 0; i < un.Length; i++)
{
char c = un[i];
if (c < 0x20 || c >= 0x7F || IsForbidden(c))
return false;
}
return true;
}
/// <summary>Mirrors the password safety rules in AccountHandler.CreateAccount.</summary>
private static bool IsSafePassword(string pw)
{
for (int i = 0; i < pw.Length; i++)
{
char c = pw[i];
if (c < 0x20 || c >= 0x7F)
return false;
}
return true;
}
private static bool IsForbidden(char c)
{
for (int i = 0; i < ForbiddenChars.Length; i++)
if (c == ForbiddenChars[i])
return true;
return false;
}
private static bool TryParseIp(string s, out IPAddress ip)
{
ip = null;
if (String.IsNullOrEmpty(s))
return false;
return IPAddress.TryParse(s.Trim(), out ip);
}
}
}

View File

@@ -283,9 +283,10 @@ namespace Server.Custom.Bridge
/// <summary> /// <summary>
/// Resolves the command's target account, by "serial" (a player mobile's account) or by /// Resolves the command's target account, by "serial" (a player mobile's account) or by
/// "account" (username). Returns null if neither resolves to a real account. /// "account" (username). Returns null if neither resolves to a real account. Public so the
/// account plane (unlink) resolves targets the same way the moderation plane does.
/// </summary> /// </summary>
private static Account ResolveTargetAccount(Dictionary<string, object> o) public static Account ResolveTargetAccount(Dictionary<string, object> o)
{ {
var serialStr = BridgeJson.GetString(o, "serial"); var serialStr = BridgeJson.GetString(o, "serial");
if (serialStr != null) if (serialStr != null)
@@ -301,9 +302,10 @@ namespace Server.Custom.Bridge
/// <summary> /// <summary>
/// The one shard-side safety floor. Protects any account whose effective access level — /// The one shard-side safety floor. Protects any account whose effective access level —
/// the account's own or the highest of its characters' — is at or above the configured /// the account's own or the highest of its characters' — is at or above the configured
/// floor. Even under CoOwner authority the Owner is never reachable from the web. /// floor. Even under CoOwner authority the Owner is never reachable from the web. Public
/// so the account plane (unlink) enforces the identical floor.
/// </summary> /// </summary>
private static bool Protected(Account acct) public static bool Protected(Account acct)
{ {
var lvl = acct.AccessLevel; var lvl = acct.AccessLevel;

View File

@@ -2,6 +2,18 @@ using System;
namespace Server.Custom.Bridge namespace Server.Custom.Bridge
{ {
/// <summary>
/// Which side may mint game accounts. Governs the bridge's inbound account.create verb;
/// the in-game first-login auto-create is a separate core setting (Accounts.AutoCreateAccounts)
/// the operator pairs with this (docs/PROTOCOL_2.md §2).
/// </summary>
public enum SignupMode
{
Website, // website is the account authority; in-game auto-create should be off
Game, // game server is the authority; account.create is refused
Hybrid // either side may create
}
/// <summary> /// <summary>
/// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there /// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
/// reads as "Bridge.Port" here. /// reads as "Bridge.Port" here.
@@ -33,6 +45,13 @@ namespace Server.Custom.Bridge
public static int AdminReasonMaxLength { get; private set; } public static int AdminReasonMaxLength { get; private set; }
public static int AdminBanMaxDurationSec { get; private set; } public static int AdminBanMaxDurationSec { get; private set; }
// ---- account provisioning (docs/PROTOCOL_2.md Part A) ----
public static SignupMode Signup { get; private set; }
public static bool AccountCreateEnabled { get; private set; }
public static bool RequireIpForCreate { get; private set; }
public static int AccountNameMaxLength { get; private set; }
public static int AccountPasswordMaxLength { 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()
@@ -73,8 +92,64 @@ namespace Server.Custom.Bridge
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400); AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000); AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
// unrecognized* value falls back to Game (the safest — no website creation), so a
// typo can never accidentally open provisioning.
Signup = ParseSignupMode(Config.Get("Bridge.SignupMode", "hybrid"), SignupMode.Game);
// Default follows the mode: creation is on unless the shard is game-authority.
AccountCreateEnabled = Config.Get("Bridge.AccountCreateEnabled", Signup != SignupMode.Game);
RequireIpForCreate = Config.Get("Bridge.RequireIpForCreate", true);
AccountNameMaxLength = Config.Get("Bridge.AccountNameMaxLength", 16);
AccountPasswordMaxLength = Config.Get("Bridge.AccountPasswordMaxLength", 30);
if (AccountNameMaxLength < 1)
AccountNameMaxLength = 1;
if (AccountPasswordMaxLength < 1)
AccountPasswordMaxLength = 1;
if (QueueCap < 16) if (QueueCap < 16)
QueueCap = 16; QueueCap = 16;
WarnOnSignupMismatch();
}
/// <summary>
/// The bridge governs only the account.create verb; ServUO's in-game first-login
/// auto-create is the core Accounts.AutoCreateAccounts setting. A shard whose two halves
/// disagree is quietly broken (website-only that still auto-creates in game, or a mode
/// that expects in-game creation with it switched off), so surface the contradiction
/// loudly rather than silently doing the permissive thing.
/// </summary>
private static void WarnOnSignupMismatch()
{
var autoCreate = Config.Get("Accounts.AutoCreateAccounts", true);
if (Signup == SignupMode.Website && autoCreate)
Console.WriteLine(
"[Bridge] WARNING: SignupMode=website but Accounts.AutoCreateAccounts=true; "
+ "an in-game login of any new name still mints an account. Set it false for website-only.");
else if (Signup == SignupMode.Game && !autoCreate)
Console.WriteLine(
"[Bridge] WARNING: SignupMode=game but Accounts.AutoCreateAccounts=false; "
+ "in-game creation is off and account.create is refused, so no account can be created.");
else if (Signup == SignupMode.Hybrid && !autoCreate)
Console.WriteLine(
"[Bridge] WARNING: SignupMode=hybrid but Accounts.AutoCreateAccounts=false; "
+ "in-game first-login creation is off. Only website account.create will work.");
}
/// <summary>
/// Parses a SignupMode name, case-insensitively, falling back to <paramref name="fallback"/>
/// on anything unrecognized so a typo can never open provisioning wider than intended.
/// </summary>
private static SignupMode ParseSignupMode(string value, SignupMode fallback)
{
SignupMode parsed;
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
Enum.IsDefined(typeof(SignupMode), parsed))
return parsed;
Console.WriteLine("[Bridge] unrecognized SignupMode '{0}', using {1}", value, fallback);
return fallback;
} }
/// <summary> /// <summary>
@@ -95,9 +170,9 @@ namespace Server.Custom.Bridge
public static string Describe() public static string Describe()
{ {
return String.Format( return String.Format(
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9})", "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11})",
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds, Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor); ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
} }
} }
} }