diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index c1beefe..7cff2d6 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -57,6 +57,31 @@ AdminReasonMaxLength=400
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
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
# (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
index d9a34e2..5e0b1ef 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs
@@ -52,6 +52,7 @@ namespace Server.Custom.Bridge
return;
CommandSystem.Register("link", AccessLevel.Player, OnLinkCommand);
+ CommandSystem.Register("unlink", AccessLevel.Player, OnUnlinkCommand);
BridgeBoot.RegisterHandler("link.confirm", OnLinkConfirm);
// 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);
}
+ // ---- [unlink ----
+
+ [Usage("unlink")]
+ [Description("Unlinks this game account from your website account.")]
+ private static void OnUnlinkCommand(CommandEventArgs e)
+ {
+ Unlink(e.Mobile);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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 ----
private static void OnLinkConfirm(Dictionary o)
diff --git a/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs b/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
new file mode 100644
index 0000000..e076eab
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
@@ -0,0 +1,281 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+
+using Server.Accounting;
+using Server.Misc;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// 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.
+ ///
+ 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 ----
+
+ ///
+ /// 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.
+ ///
+ private static void OnCreate(Dictionary 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 ----
+
+ ///
+ /// 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.
+ ///
+ private static void OnUnlink(Dictionary 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());
+ }
+
+ ///
+ /// Opens an account.audit frame (origin=web) broadcast to every dashboard, parallel to
+ /// admin.audit. Never carries the password.
+ ///
+ 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);
+ }
+
+ /// Mirrors the username safety rules in AccountHandler.CreateAccount.
+ 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;
+ }
+
+ /// Mirrors the password safety rules in AccountHandler.CreateAccount.
+ 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);
+ }
+ }
+}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
index 1171713..b82d893 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
@@ -283,9 +283,10 @@ namespace Server.Custom.Bridge
///
/// 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.
///
- private static Account ResolveTargetAccount(Dictionary o)
+ public static Account ResolveTargetAccount(Dictionary o)
{
var serialStr = BridgeJson.GetString(o, "serial");
if (serialStr != null)
@@ -301,9 +302,10 @@ namespace Server.Custom.Bridge
///
/// 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
- /// 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.
///
- private static bool Protected(Account acct)
+ public static bool Protected(Account acct)
{
var lvl = acct.AccessLevel;
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index 627e5e4..5ab17f2 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -2,6 +2,18 @@ using System;
namespace Server.Custom.Bridge
{
+ ///
+ /// 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).
+ ///
+ 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
+ }
+
///
/// Tunables from Config/Bridge.cfg. Key scope is the filename, so `Port=7788` there
/// reads as "Bridge.Port" here.
@@ -33,6 +45,13 @@ namespace Server.Custom.Bridge
public static int AdminReasonMaxLength { 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 void Configure()
@@ -73,8 +92,64 @@ namespace Server.Custom.Bridge
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
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)
QueueCap = 16;
+
+ WarnOnSignupMismatch();
+ }
+
+ ///
+ /// 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.
+ ///
+ 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.");
+ }
+
+ ///
+ /// Parses a SignupMode name, case-insensitively, falling back to
+ /// on anything unrecognized so a typo can never open provisioning wider than intended.
+ ///
+ 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;
}
///
@@ -95,9 +170,9 @@ namespace Server.Custom.Bridge
public static string Describe()
{
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,
- ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor);
+ ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
}
}
}