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

@@ -2,6 +2,18 @@ using System;
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>
/// 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();
}
/// <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>
@@ -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);
}
}
}