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:
@@ -31,16 +31,18 @@ Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`.
|
||||
|
||||
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
|
||||
|
||||
- Every response carries an **`X-UOLink-Version: 1`** header.
|
||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 1`.
|
||||
- **Optionally**, send `X-UOLink-Version: 1` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||
- Every response carries an **`X-UOLink-Version: 2`** header.
|
||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`.
|
||||
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||
|
||||
```json
|
||||
{ "error": "protocol version mismatch", "sidecar_protocol": 1, "client_protocol": "2" }
|
||||
{ "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" }
|
||||
```
|
||||
|
||||
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
|
||||
|
||||
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
|
||||
|
||||
---
|
||||
|
||||
## 3. Health
|
||||
@@ -180,10 +182,12 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
|
||||
| `audit.command` | `staff`, `command`, `args` | A staff command was invoked. |
|
||||
| `admin.audit` | `origin`, `action`, `actor`, `target`, `reason`, plus action-specific (`durationSec`, `sessions`, `hue`, `text`) | A moderation action was applied. `origin` is `"web"` (from the site, `actor:"web:<user>"`) or `"in-game"` (a staff member in the game client). Broadcast to every dashboard so your moderation log stays complete regardless of who acted. Emitted alongside the `admin.ok` reply for web actions; see §6. |
|
||||
|
||||
#### Account linking
|
||||
#### Account linking & provisioning
|
||||
| kind | fields | notes |
|
||||
|------|--------|-------|
|
||||
| `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. |
|
||||
| `account.audit` | `origin`, `action`, `actor`, `target`, `websiteUserId` | A provisioning action was applied from the site (`origin:"web"`, `actor:"web:<user>"`). `action` is `create` or `unlink`; `target` is the account. Broadcast to every dashboard. **Never carries the password.** Emitted alongside the `account.ok` reply; see §6. |
|
||||
| `account.unlinked` | `origin`, `account`, `websiteUserId`, `char` | A player ran `[unlink` **in game** (`origin:"in-game"`), severing the tie themselves. Drop the link from any roster you cache and reconcile your own record. |
|
||||
|
||||
#### Help-page (support) queue
|
||||
| kind | fields | notes |
|
||||
@@ -335,6 +339,48 @@ GET /link/{account}
|
||||
|
||||
(This reads the sidecar's mirror of confirmed links — no shard round-trip.)
|
||||
|
||||
### Create a game account (Protocol 2.0)
|
||||
|
||||
Provision a game account from your signup form and link it to the website user in one step. Requires a **v2** sidecar. Whether this is honored depends on the shard's signup mode (`website`/`hybrid` accept it; `game` refuses).
|
||||
|
||||
```
|
||||
POST /accounts/create
|
||||
{ "actor": "whitlocktech", "account": "bob", "password": "hunter2",
|
||||
"websiteUserId": "9931", "ip": "203.0.113.7" }
|
||||
```
|
||||
|
||||
- `actor` — the website user/staff id, recorded in the audit. Required.
|
||||
- `account`, `password` — the game-client credentials the player chose. The password is hashed on the shard and **never** appears in any reply, event, or log.
|
||||
- `websiteUserId` — the site user to auto-link.
|
||||
- `ip` — **the end user's browser IP**, which you read from your own request context (remote-addr, or a trusted `X-Forwarded-For`). The shard enforces its per-IP account cap with this, exactly as it does for in-game signups. The sidecar cannot see the browser's IP (it only sees your server), so you must send it.
|
||||
|
||||
Responses:
|
||||
|
||||
- Success → **200** `{"kind":"account.ok","action":"create","account":"bob","websiteUserId":"9931"}`. The account exists and is linked; subsequent `mob.login` events carry `webId`.
|
||||
- Name already taken → **409** `{"kind":"account.error","reason":"account already exists"}`.
|
||||
- Per-IP cap hit → **429** `{"kind":"account.error","reason":"ip account limit reached"}`.
|
||||
- Signups disabled for this mode → **403** `{"kind":"account.error","reason":"signups disabled for this mode"}`.
|
||||
- Missing browser IP (when the shard requires it) → **400** `{"kind":"account.error","reason":"client ip required"}`.
|
||||
- Bad username/password, or a missing field → **400**.
|
||||
|
||||
Abuse control beyond the per-IP cap (captcha, email verification, signup rate) is your site's responsibility.
|
||||
|
||||
### Unlink an account (Protocol 2.0)
|
||||
|
||||
Sever a game account's tie to its website user, from the site side. Requires a **v2** sidecar.
|
||||
|
||||
```
|
||||
DELETE /link/{account}
|
||||
{ "actor": "whitlocktech" }
|
||||
```
|
||||
|
||||
- Success → **200** `{"kind":"account.ok","action":"unlink","account":"bob"}`. The `WebsiteUserId` tag is cleared on the shard and the sidecar's link mirror is dropped, so attribution stops immediately.
|
||||
- Not linked → **404** `{"kind":"account.error","reason":"not linked"}`.
|
||||
- Protected staff account → **403** `{"kind":"account.error","reason":"target is protected staff; refused"}`.
|
||||
- Missing `actor` → **400**.
|
||||
|
||||
A player can also unlink themselves in game with `[unlink`; that emits an `account.unlinked` event (see §4) so you can reconcile your record.
|
||||
|
||||
### Publish / remove town-crier news
|
||||
|
||||
Push a message that every in-game town crier announces until it expires.
|
||||
@@ -475,8 +521,9 @@ A row survives a sidecar restart (it's in SQLite), so the board reflects the las
|
||||
| 200 | OK |
|
||||
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
|
||||
| 401 | Missing or invalid auth token |
|
||||
| 404 | Not found (unknown account / character / id) |
|
||||
| 409 | Protocol version mismatch (you sent `X-UOLink-Version` and it disagreed) |
|
||||
| 404 | Not found (unknown account / character / id, or a not-linked account) |
|
||||
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` |
|
||||
| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |
|
||||
| 500 | Internal error (e.g. database) |
|
||||
| 503 | Shard not connected — the query needs the live game and it's down |
|
||||
| 504 | Shard connected but didn't reply within 10s |
|
||||
@@ -490,7 +537,7 @@ A row survives a sidecar restart (it's in SQLite), so the board reflects the las
|
||||
A typical character page:
|
||||
|
||||
```js
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "1" };
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };
|
||||
|
||||
// 1. render the roster
|
||||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
||||
|
||||
448
docs/PROTOCOL_2.md
Normal file
448
docs/PROTOCOL_2.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# Protocol 2.0 — Provisioning & World-State Streams
|
||||
|
||||
**Status:** Part A **built** on branch `feat/protocol2-account-provisioning` (2026-07-17), compiles clean both sides. Part B is design.
|
||||
**Date:** 2026-07-17
|
||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
||||
**Companion to** [`PLAN.md`](PLAN.md) (read/event plane), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), and [`INTEGRATION.md`](INTEGRATION.md) (website API).
|
||||
|
||||
Protocol 1.0 shipped the read/event plane, the request/reply plane, `[link` account linking, town-crier, the player-vendor-sale core edit, the admin write plane, and the help-page queue.
|
||||
|
||||
2.0 has **two scope areas**:
|
||||
|
||||
- **A — Account provisioning & unlinking (§1–§9).** The website can **create** accounts, **unlink** them, and the shard runs in one of three **signup modes** that decide which side may mint accounts. (1.0 could only *link* an account that already existed, and a link could never be undone.)
|
||||
- **B — Social & political world-state streams (§10–§11).** Guilds, town governors ("mayors"), factions/VvV, and player titles — the standings a website community page wants. §10 specs the requested streams; §11 is a menu of further integration points to pick from.
|
||||
|
||||
---
|
||||
|
||||
# Part A — Account provisioning & unlinking
|
||||
|
||||
---
|
||||
|
||||
## 1. What exists today, and the gap
|
||||
|
||||
| Capability | 1.0 | 2.0 |
|
||||
|------------|-----|-----|
|
||||
| Create account in-game (first-login auto-create) | ✔ `AccountHandler.cs:281` | unchanged |
|
||||
| Link an **existing** game account to a website user | ✔ `[link` → `link.confirm` | unchanged |
|
||||
| **Create** a game account from the website | ✗ | **new** `account.create` |
|
||||
| **Unlink** a game account from its website user | ✗ | **new** `account.unlink` + `[unlink` |
|
||||
| Choose which side may create accounts | ✗ (always in-game) | **new** signup mode |
|
||||
|
||||
**The linking flow is not changing.** `[link`, the one-time code, `link.confirm`, and the `WebsiteUserId` tag all stay exactly as they are (`BridgeAccountLink.cs`). 2.0 only *adds* verbs alongside them.
|
||||
|
||||
### The account-creation facts that shape this
|
||||
|
||||
- `new Account(username, password)` self-registers — its constructor calls `Accounts.Add(this)` (`Account.cs:186`) and `SetPassword` hashes per the shard's `AccountHandler.ProtectPasswords` (`Account.cs:174`). So creating an account from the bridge is `new Account(un, pw)` plus the link tag — no extra persistence layer, same as the `[link` tag reaching disk on the next world save.
|
||||
- ServUO's in-game auto-create is gated on the **core** config `Accounts.AutoCreateAccounts` (default `true`, read once in `AccountHandler`'s static init, `AccountHandler.cs:29`). The bridge cannot intercept that path without a core edit, so the signup mode governs the **bridge's** `account.create` verb; the in-game side is controlled by pairing it with the matching core config (see §3).
|
||||
- The core `CreateAccount` path (`AccountHandler.cs:494`) validates the username/password character set (printable ASCII `0x20–0x7F`, no forbidden chars) and enforces `MaxAccountsPerIP` (`Accounts.AccountsPerIp`, default **1**). The website path has **no `NetState`**, so the browser IP must be **passed through explicitly** to enforce that same cap (§3.1); and it must reuse the **character-safety** validation before `new Account`, or it can mint an account no client can log into (or that corrupts serialization).
|
||||
- There is **no `EventSink.AccountCreated`**. The create path is silent. This is why in-game→website creation sync is an open item, not committed scope (§7).
|
||||
|
||||
---
|
||||
|
||||
## 2. Signup modes — the model
|
||||
|
||||
A single shard-wide setting, `Bridge.SignupMode`, with three values. **Default `hybrid`.**
|
||||
|
||||
| Mode | `account.create` from website | In-game first-login auto-create | Who is the account authority |
|
||||
|------|:-----------------------------:|:-------------------------------:|------------------------------|
|
||||
| `website` | **accepted** | should be **off** | the website |
|
||||
| `game` | **rejected** (`account.error`) | **on** | the game server |
|
||||
| `hybrid` *(default)* | **accepted** | **on** | either side |
|
||||
|
||||
The bridge enforces exactly one half of this: whether it **honors `account.create`**. The other half — in-game auto-create — is the core `Accounts.AutoCreateAccounts` config, which the operator sets to match:
|
||||
|
||||
| `Bridge.SignupMode` | pair with `Accounts.AutoCreateAccounts` |
|
||||
|---------------------|-----------------------------------------|
|
||||
| `website` | `false` — otherwise any client that types a new name still mints an account, defeating website-only |
|
||||
| `game` | `true` |
|
||||
| `hybrid` | `true` |
|
||||
|
||||
On boot the bridge **reads `Accounts.AutoCreateAccounts` and warns** if it contradicts the selected mode (e.g. `SignupMode=website` while auto-create is still on), so a half-configured shard is loud, not silently permissive. The bridge does not try to flip the core setting — it only detects and reports the mismatch, the same defensive posture `BridgeConfig.ParseAccessLevel` already takes.
|
||||
|
||||
**Reconciliation in `hybrid`.** Both paths can race for the same username. `account.create` resolves it the only correct way: `Accounts.GetAccount(un) != null` → refuse with `account.error "account already exists"`. First writer wins; the loser gets a clean error, never a duplicate.
|
||||
|
||||
---
|
||||
|
||||
## 3. `account.create` — website-driven provisioning
|
||||
|
||||
The website has already authenticated and authorized the user (its own signup form). It hands the shard a username, the password the player chose, and the website user id, and asks for an account that is created **and linked in one step** — no code exchange, because the website *is* the authority here (unlike `[link`, where the game side proves ownership with a code).
|
||||
|
||||
### Request (website → sidecar → shard)
|
||||
|
||||
```json
|
||||
{ "kind": "account.create", "reqId": "c1", "actor": "whitlocktech",
|
||||
"account": "bob", "password": "hunter2", "websiteUserId": "9931",
|
||||
"ip": "203.0.113.7" }
|
||||
```
|
||||
|
||||
- `reqId` — correlation id, echoed on the reply (as everywhere else).
|
||||
- `actor` — the website user/staff id, for the audit line. Required, non-empty (mirrors the admin plane).
|
||||
- `account` — desired username.
|
||||
- `password` — the game-client password the player chose on the site. Plaintext over the **loopback + token** socket, the same trust boundary every inbound verb already relies on; the shard hashes it via `SetPassword` immediately.
|
||||
- `websiteUserId` — the site user to auto-link.
|
||||
- `ip` — the **end user's browser IP**, so the shard can enforce `MaxAccountsPerIP` on website signups exactly as it does on in-game first-login. The website reads this from its own request context (remote-addr, or a trusted `X-Forwarded-For`); the **sidecar cannot derive it** — the sidecar only sees the website's connection IP, not the browser's, so this must be an explicit field. See §3.1.
|
||||
|
||||
### Shard behavior (Core thread, in `BridgeAccounts.cs`)
|
||||
|
||||
1. **Gate.** `SignupMode == game` → `account.error "signups disabled for this mode"`. Master switch `Bridge.AccountCreateEnabled` (default follows mode) must be on.
|
||||
2. **Validate `actor`** present (as admin plane does).
|
||||
3. **Validate username/password** with the same character-safety rules as `AccountHandler.CreateAccount` (printable ASCII, no leading/trailing space, no trailing dot, no forbidden chars). Enforce length caps from config.
|
||||
4. **Collision check.** `Accounts.GetAccount(account) != null` → `account.error "account already exists"`.
|
||||
5. **IP cap.** Parse `ip` → `IPAddress`. If `RequireIpForCreate` and it is missing/unparseable/loopback → `account.error "client ip required"` (**fail closed** — a missing IP must never silently bypass the cap; loopback is exempt in `IPLimiter`, so accepting it *is* a bypass). Then `AccountHandler.CanCreate(ip) == false` → `account.error "ip account limit reached"`. This is the same read-side check the in-game path runs at `AccountHandler.cs:510`.
|
||||
6. **Create + link atomically.** `var a = new Account(account, password); a.LogAccess(ip); a.SetTag("WebsiteUserId", websiteUserId);` — `LogAccess` bumps `AccountHandler.IPTable[ip]` and records the IP into `LoginIPs` (`Account.cs:1251`), which is exactly what an in-game first-login does, so the per-IP count is both live-accurate and durable (it rebuilds from `LoginIPs[0]` on reboot). The `WebsiteUserId` tag persists to `accounts.xml` on the next world save, identical to the `[link` path.
|
||||
7. **Reply** `account.ok` and **emit** an unsolicited `account.audit` (`origin:"web"`, `action:"create"`) to every dashboard, parallel to `admin.audit`.
|
||||
|
||||
### Reply
|
||||
|
||||
```json
|
||||
{ "kind": "account.ok", "reqId": "c1", "action": "create",
|
||||
"account": "bob", "websiteUserId": "9931" }
|
||||
{ "kind": "account.error", "reqId": "c1", "reason": "account already exists" }
|
||||
```
|
||||
|
||||
### 3.1 The IP flow — who sees what
|
||||
|
||||
```
|
||||
browser ──HTTP signup──► website ──POST /accounts/create──► sidecar ──account.create──► shard
|
||||
(real IP) (sees browser IP) (sees WEBSITE's IP, not browser's) (enforces cap)
|
||||
```
|
||||
|
||||
The chain hops hosts, so the only party that sees the **end user's** IP is the website, at the edge. By the time the request reaches the sidecar, the socket's peer address is the *website*, not the player — which is why `ip` is a body field, not something the sidecar reads off the connection. The website populates it from its request context (remote-addr, or `X-Forwarded-For` from a proxy it trusts).
|
||||
|
||||
Two consequences to state plainly:
|
||||
|
||||
- **The IP is only as trustworthy as the website's proxy handling.** A compromised or misconfigured website could send a spoofed or wrong IP. That is already inside the 2.0 trust boundary (the website is trusted via loopback + token), but it means the per-IP cap is an *honesty* control against ordinary multi-account signups, not a hard security boundary against a hostile website.
|
||||
- **IPv4/IPv6 skew.** A browser may present IPv6 while the UO client connects over IPv4; the two are different `IPAddress` keys, so a website account and a later in-game account from the "same" person may not share an `IPTable` bucket. Inherent to keying on raw IP — noted, not solved.
|
||||
|
||||
The sidecar itself does **not** validate or transform `ip`; it forwards the field and lets the shard (which owns `IPTable`) decide. If a shard wants the sidecar to reject obviously-bad input early, that is a later refinement, not required for correctness — the shard fails closed regardless.
|
||||
|
||||
### Sidecar route
|
||||
|
||||
`POST /accounts/create`, body `{actor, account, password, websiteUserId, ip}` → the inbound line, correlated on a fresh `reqId`. Status mapping (new `respond_account`, modeled on `respond_admin`):
|
||||
|
||||
| Reply / reason | HTTP |
|
||||
|----------------|------|
|
||||
| `account.ok` | 200 |
|
||||
| `"account already exists"` | 409 Conflict |
|
||||
| `"ip account limit reached"` | 429 Too Many Requests |
|
||||
| `"signups disabled…"` | 403 |
|
||||
| `"client ip required"`, `"invalid username/password"`, missing field | 400 |
|
||||
| shard down / timeout | 503 / 504 |
|
||||
|
||||
> ⚠️ The password is a secret in a request body and a shard reply. Keep it off the WebSocket broadcast entirely: `account.audit`/`account.ok` **never carry the password**, and the console/audit log records only `account` + `actor`. This is the same discipline `BridgeEvents` already applies to the plaintext `AccountLoginEventArgs.Password` it deliberately never forwards (`PLAN.md` §12).
|
||||
|
||||
---
|
||||
|
||||
## 4. Unlinking
|
||||
|
||||
Symmetric with `[link`: either side can sever the tie. Both paths do the same one thing — remove the `WebsiteUserId` account tag (`acct.RemoveTag("WebsiteUserId")`) — and both persist on the next world save.
|
||||
|
||||
### 4.1 Website → `account.unlink`
|
||||
|
||||
```json
|
||||
{ "kind": "account.unlink", "reqId": "u1", "actor": "whitlocktech", "account": "bob" }
|
||||
```
|
||||
|
||||
- Resolve by `account` (username) or `serial` (a player mobile's account), reusing `BridgeAdmin.ResolveTargetAccount`.
|
||||
- Not linked → `account.error "not linked"` (a no-op is reported honestly, not faked as success).
|
||||
- Apply the **Owner floor** (`BridgeAdmin.Protected`): refuse to unlink an account at/above `AdminAccessFloor`, same defense-in-depth as the admin verbs.
|
||||
- Reply `account.ok action:"unlink"`; emit `account.audit action:"unlink"`.
|
||||
|
||||
Sidecar: `DELETE /link/{account}` (the existing `/link/:account` GET already looks a link up; this adds the delete verb next to it) → also clears the sidecar's mirrored link row (`store.record_unlink`), so event attribution stops immediately without waiting on the shard.
|
||||
|
||||
### 4.2 In-game → `[unlink`
|
||||
|
||||
`CommandSystem.Register("unlink", AccessLevel.Player, …)` in `BridgeAccountLink.cs`, next to `[link`:
|
||||
|
||||
- Reads the caller's own account, clears the tag, emits `account.unlinked` (so the site learns of a player-initiated unlink and can reconcile its own record).
|
||||
- Player-scoped: a player can only unlink **their own** account (no target argument), so it needs no floor.
|
||||
- Symmetric UX with `[link`: `"Your account is no longer linked."`
|
||||
|
||||
> **Note — `[link` behavior is unchanged.** `[link` still refuses when a tag already exists (`BridgeAccountLink.cs:96`). `[unlink` is what clears it; after unlinking, `[link` works again. That is the whole interaction, and it needs no change to the existing link code — only the new command beside it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Trust & attribution
|
||||
|
||||
Identical model to the admin write plane (`ADMIN_CONTROLS.md` §5), because these are the same shape of action (website-authorized, applied on the loopback socket):
|
||||
|
||||
- **Authorization lives on the website.** `account.create`/`unlink` are gated behind the site's own roles (self-service signup for create; admin/self for unlink). The shard trusts the loopback + token socket and the required `actor` field.
|
||||
- **Owner floor** applies to `account.unlink` (never unlink a protected staff account from the web).
|
||||
- **Attribution** is the `web:<actor>` string in the console line and the `account.audit` frame; the website keeps its own durable record, as it already does for `admin.audit`.
|
||||
- **`account.create` now enforces `MaxAccountsPerIP`** using the browser IP the website forwards (§3.1), via the same `CanCreate` / `LogAccess` path as in-game first-login. But the cap is only as honest as the website's IP reporting, and it fails **closed** on a missing/loopback IP when `RequireIpForCreate` is on. Higher-order abuse control (captcha, email verification, per-account-per-day) remains the website's job — the shard cap is a floor, not the whole defense.
|
||||
|
||||
---
|
||||
|
||||
## 6. Config keys (`Config/Bridge.cfg`)
|
||||
|
||||
```ini
|
||||
SignupMode=hybrid # website | game | hybrid (default hybrid)
|
||||
AccountCreateEnabled=true # master switch for account.create; auto-off when SignupMode=game
|
||||
RequireIpForCreate=true # fail closed if account.create omits a usable browser IP
|
||||
AccountNameMaxLength=16
|
||||
AccountPasswordMaxLength=30
|
||||
```
|
||||
|
||||
Read in `BridgeConfig.Load()`, re-readable via `[bridge reload`. `SignupMode` parses like `AdminAccessFloor` — unrecognized value falls back to the safest option (`game`, i.e. no website creation) with a console warning, so a typo can never accidentally open provisioning. `RequireIpForCreate` defaults **on**: the per-IP cap only means something if a missing IP is refused rather than waved through. Turn it off only for a deployment that deliberately does not cap website signups by IP (and then `MaxAccountsPerIP` still applies in-game as before).
|
||||
|
||||
---
|
||||
|
||||
## 7. Open item (not committed) — in-game → website creation sync
|
||||
|
||||
Per the 2026-07-17 decision, **this is not in 2.0's committed scope.** When an account is created *in-game* (first-login auto-create, or staff `[AddAccount`), the website is **not** notified today, and 2.0 does not change that. Recorded here so the tradeoff is explicit, not forgotten:
|
||||
|
||||
- **Why it's hard cleanly:** there is no `EventSink.AccountCreated`. The only faithful tap is a core edit — an `Action<Account>` raised in the `Account(string, string)` ctor (safe: the load path is a *separate* ctor, `Account.cs:189`, so it won't fire during world load), shipped as a `patches/` diff exactly like `PlayerVendorSale` and the `CommandLogging` event.
|
||||
- **Why it may not be needed:** in `website`-mode the website already knows every account (it created them). Sync only matters for `hybrid`/`game` modes where the website wants a roster of game-born accounts — and even then the sidecar can approximate "new account" from the `mob.login` `acct` field it already receives (first-seen = new), lossy but zero core edits.
|
||||
- **If we do it later:** it becomes an `account.created` event stream (`origin:"in-game"`), the natural mirror of the `account.audit` (`origin:"web"`) that `account.create` emits — the same bidirectional-audit shape §5.5 of `ADMIN_CONTROLS.md` established. Revisit if a shard chooses `hybrid`/`game` and wants a complete website roster.
|
||||
|
||||
---
|
||||
|
||||
## 8. Where the code goes
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeAccounts.cs` | **New.** Registers `account.create` and `account.unlink`; the create+link, char-safety validation, collision check, Owner floor on unlink, `account.audit` emission. Mirrors `BridgeAdmin.cs` structure. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs` | **Extend.** Add the `[unlink` player command beside `[link`. No change to existing link behavior. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeConfig.cs` | **Extend.** `SignupMode` (parsed, safe fallback), `AccountCreateEnabled`, `RequireIpForCreate`, name/password length caps; read `Accounts.AutoCreateAccounts` and warn on mode mismatch. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeBoot.cs` | **Extend.** Wire `BridgeAccounts.Initialize()` into `Initialize()` (one line, beside the other subsystems). |
|
||||
| `overlay/Config/Bridge.cfg` + `.example` | **Extend.** The §6 keys, defaults documented. |
|
||||
| `sidecar/src/web.rs` | **Extend.** `POST /accounts/create` (forwards `ip` from the body untouched), `DELETE /link/:account`; `respond_account` status mapping (409 on collision, 429 on IP cap, 400 on missing IP); scrub password from any logged/broadcast value. |
|
||||
| `sidecar/src/store.rs` | **Extend.** `record_unlink` (clear the mirrored link row) beside the existing `record_link`. |
|
||||
| `docs/INTEGRATION.md` | **Extend.** Document `POST /accounts/create`, `DELETE /link/{account}`, and the `account.audit` event. |
|
||||
| *(website, separate repo)* | Signup form → `POST /accounts/create`; unlink control → `DELETE /link/{account}`; consume `account.audit`. |
|
||||
|
||||
No new core/stock edits in committed scope — `account.create`/`unlink` are all script-layer (`new Account`, `SetTag`/`RemoveTag`) called from the new overlay. The only core edit contemplated (the `AccountCreated` event, §7) is explicitly deferred.
|
||||
|
||||
---
|
||||
|
||||
## 9. Phasing
|
||||
|
||||
1. ~~**Config + modes.**~~ **Done.** `BridgeConfig` gains `SignupMode` (parsed, unrecognized → `game`), `AccountCreateEnabled` (mode-following default), `RequireIpForCreate`, name/password caps, and the boot-time `Accounts.AutoCreateAccounts` mismatch warning. `[bridge status` shows `signup=…(create=…)`.
|
||||
2. ~~**`account.create`.**~~ **Done.** `BridgeAccounts.cs` + `POST /accounts/create` + `respond_account`. Gate on mode, `actor` required, char-safety mirrored from `AccountHandler`, collision → 409, IP cap via `CanCreate`/`LogAccess` (fail-closed on missing/loopback IP when `RequireIpForCreate`), create + link, `account.audit`, password never logged/echoed. *Acceptance below is written for a live run — not yet exercised end-to-end.*
|
||||
3. ~~**Unlink — both surfaces.**~~ **Done.** `account.unlink` + `DELETE /link/:account` + `store.record_unlink`, and the in-game `[unlink`. Owner floor reuses `BridgeAdmin.Protected`; `[unlink` emits `account.unlinked`.
|
||||
4. ~~**Docs.**~~ **Done.** `INTEGRATION.md` §2 (protocol bumped to **2**), §4 (`account.audit`/`account.unlinked`), §6 (`POST /accounts/create`, `DELETE /link/{account}`), §7 (409/429).
|
||||
|
||||
**Build verification (2026-07-17):** sidecar `cargo check` clean; overlay compiled in the full ServUO Scripts tree — **0 errors, 0 warnings**. **Live end-to-end run still pending** (needs a booted shard + sidecar): create+link in website/hybrid, game-mode refusal, duplicate 409, the per-IP cap holding (second create same `ip` → 429, different IP succeeds, omitted IP → 400 while `RequireIpForCreate`), `LoginIPs[0]`/`IPTable` incremented, and unlink clearing tag+mirror with the Owner floor refusing a protected target.
|
||||
|
||||
Deferred (revisit only if a shard needs it): the §7 in-game→website `account.created` sync; the §12.3 `account.setpassword`/`account.exists` siblings; §12.5 credential-verb rate limiting.
|
||||
|
||||
---
|
||||
|
||||
# Part B — Social & political world-state streams
|
||||
|
||||
These are **outbound** streams (shard → website), the natural extension of `PLAN.md`'s event/sweep plane. None needs a write plane; all reuse the transport, the bounded queue, and the sweep/emit-on-change discipline `BridgeSweeps` already established. Each entry below states its **grounded hook situation** so nothing rides an event that doesn't fire.
|
||||
|
||||
## 10. The requested streams
|
||||
|
||||
### 10.1 Guilds
|
||||
|
||||
**Hook reality (verified):**
|
||||
|
||||
- `EventSink.JoinGuild` is real — raised at `Scripts/Misc/Guild.cs:1597` when a mobile joins a guild. Usable as a live `guild.join`.
|
||||
- `EventSink.CreateGuild` is **not** a creation notification. It is the load-time deserialization factory: raised only from `Server/World.cs:517` while reading the guild index at boot, where the handler's job is to *construct* the guild instance (`Guild.cs:775` → `new Guild(args.Id)`). Player guild creation (`new Guild(pm, name, abbrev)` at `Create Guild Gump.cs:83`, `GuildDeed.cs:127`) raises **no event**. **Do not use `CreateGuild` for "a guild was created"** — it would fire once per guild at every boot and never on an actual new guild.
|
||||
- Leave, disband, leader change, alliance change, rename: **no events.**
|
||||
|
||||
**Delivery — a guild sweep + diff, exactly like house decay (`PLAN.md` §5.4).** `BaseGuild.List` is a `Dictionary<int, BaseGuild>` (`Server/Guild.cs:54`) — the whole registry, enumerable on the Core thread. Hold a `Dictionary<int, GuildSnapshot>` (name, abbreviation, leader serial, member count, alliance name, member-serial set hash). On each sweep, diff:
|
||||
|
||||
- id present now, absent before → `guild.created`
|
||||
- id absent now, present before → `guild.disbanded`
|
||||
- leader / alliance / name / abbreviation changed → `guild.updated`
|
||||
- member set grew/shrank → `guild.join` / `guild.leave` (the sweep is the reliable source for leaves; `EventSink.JoinGuild` can *also* emit an immediate `guild.join` for joins, with the sweep as the backstop)
|
||||
|
||||
Take a **silent baseline** on `ServerStarted` (populate without emitting), same as decay, or every guild re-announces on every boot. Cost is trivial — a shard has tens to low-hundreds of guilds, and reading `Members.Count` + `Leader` is a handful of field reads each.
|
||||
|
||||
```jsonc
|
||||
{"kind":"guild.created","id":1234,"name":"The Silver Hand","abbr":"TSH",
|
||||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"},"members":14,"alliance":null}
|
||||
{"kind":"guild.leave","id":1234,"who":{"serial":"0x77","name":"Bran"},"members":13}
|
||||
{"kind":"guild.disbanded","id":1234,"name":"The Silver Hand"}
|
||||
```
|
||||
|
||||
> If real-time (not next-sweep) leave/disband ever matters, the clean tap is a one-line `patches/` hook in `Scripts/Misc/Guild.cs` `RemoveMember`/`OnDelete` — the Phase-7 `patches/` precedent. Start with the sweep; add the patch only if latency is a real complaint. Guild membership does not move fast enough to justify it up front.
|
||||
|
||||
### 10.2 Town governors ("mayors")
|
||||
|
||||
In modern ServUO the "mayor of a town" is the **Governor** in the City Loyalty System (King Blackthorn's governance). Each `City` (enum, `CityLoyaltySystem.cs:15`) has a `CityLoyaltySystem` instance carrying `Governor` (Mobile), `GovernorElect`, an `Election`, a `Citizens` count, and a herald. The `Governor` setter already broadcasts a herald message on change (`CityLoyaltySystem.cs:193`), confirming a governor transition is a first-class in-game event — there just isn't an `EventSink` for it.
|
||||
|
||||
**Delivery — a city sweep, emit-on-change.** `CityLoyaltySystem.Cities` (static `List<CityLoyaltySystem>`, `CityLoyaltySystem.cs:680`) is the full set, one per city. Sweep, hold `Dictionary<City, governorSerial>`, emit on transition. Governors change on the order of weeks — a slow sweep (e.g. 5 min, or fold into the economy sweep cadence) is ample. Also emit election open/close and, optionally, the standing.
|
||||
|
||||
```jsonc
|
||||
{"kind":"city.governor","city":"Britain","from":{"serial":"0x55","name":"Old Mayor"},
|
||||
"to":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"}}
|
||||
{"kind":"city.election","city":"Moonglow","phase":"nominate","candidates":3,"endsAt":"2026-07-24T…"}
|
||||
```
|
||||
|
||||
> **Gate on `CityLoyaltySystem.Enabled`** (`CityLoyalty.Enabled`, default true). If a shard runs its own custom town-ownership system instead, this sweep should no-op — detect and log, don't assume.
|
||||
|
||||
### 10.3 Player titles
|
||||
|
||||
There is **no title-change event.** Titles are read-model state, best delivered two ways, not as a stream:
|
||||
|
||||
- **Enrich `char.profile`** (`BridgeProfile`) with a `titles` block. Sources on a `PlayerMobile`: the reward-title list `m_RewardTitles` (`List<object>`) + the selected index `m_SelectedTitle` (`PlayerMobile.cs:4194,4595`), the champion title `m_CurrentChampTitle`, plus the computed titles from `Titles.ComputeTitle` / `ComputeFameTitle` / `GetSkillTitle` / veteran titles (`Scripts/Misc/Titles.cs`). `char.profile` already carries all-skills, so titles slot in beside it at near-zero extra cost, and it is a *read* — no hook needed.
|
||||
- **Optional `title.change`** only if the community page wants a live "so-and-so is now *Grandmaster Blacksmith*" feed — and then it comes from a **profile-diff in the sidecar**, not a shard event (the shard has nothing to subscribe to). Recommend starting with profile enrichment; add the diff feed only if there is demand.
|
||||
|
||||
City titles and faction/VvV merchant titles (`CityLoyaltySystem.ApplyCityTitle`, `MerchantTitles.cs`) fold into the same `titles` block.
|
||||
|
||||
### 10.4 Factions / Vice vs Virtue
|
||||
|
||||
**Which system is live is a shard decision — verify before building.** Two exist:
|
||||
|
||||
- **Old Factions** (`Scripts/Services/Factions`): `Faction.Commander` (leader, `Faction.cs:160`), `Faction.Election`, `Faction.Members` (`List<PlayerState>`), and faction-controlled **Towns** (`Town.cs` — each town has an owning faction, a sheriff, and finance). Config-gated and, on most modern shards, **off**.
|
||||
- **Vice vs Virtue** (`Scripts/Services/ViceVsVirtue`): the modern replacement. `ViceVsVirtueSystem.Enabled` (`VvV.Enabled`, default **true**), a singleton `Instance`, an active `Battle`, and per-player `VvVPlayerEntry` (score, kills, assists). City control in VvV rides the same city-loyalty/governor rails as §10.2.
|
||||
|
||||
**Delivery — a sweep, gated on whichever is enabled.** Neither system raises membership/leadership `EventSink`s, so it is the same sweep+diff pattern:
|
||||
|
||||
- VvV (recommended default): standings per side, active-battle status (`Battle.OnGoing`, current city), and the top `VvVPlayerEntry` scores → a `vvv.standings` snapshot on change + a `vvv.battle` open/close event.
|
||||
- Old Factions (only if a shard runs it): `faction.control` (town → owning faction on change), `faction.commander` (leader change from the `Election`).
|
||||
|
||||
```jsonc
|
||||
{"kind":"vvv.battle","phase":"start","city":"Britain","map":"Felucca","endsAt":"2026-07-17T…"}
|
||||
{"kind":"vvv.standings","order":142000,"chaos":138500,"leaderSide":"Order"}
|
||||
```
|
||||
|
||||
> Start by detecting which system is enabled at boot and streaming only that one; emit a one-time `world.systems` frame (what's on: cityLoyalty, vvv, factions) so the website renders the right panels instead of guessing.
|
||||
|
||||
## 11. Further integration points — a menu to pick from
|
||||
|
||||
Everything below is grounded in a hook or a cheap sweep in *this* server. Ranked roughly by value-to-effort. **Pick the ones you want and I'll fold them into the phasing.** (✔ = a real `EventSink` exists; ⟳ = sweep/diff; ⚑ = needs a small `patches/` core tap.)
|
||||
|
||||
| # | Stream | Source | Effort | Why it's worth it |
|
||||
|---|--------|--------|:------:|-------------------|
|
||||
| 1 | **Who's-online / population** | ⟳ online sweep over `NetState.Instances` | low | A live "N players online", per-facet population, and a history series. The single most-asked-for website widget. |
|
||||
| 2 | **Region presence** | ✔ `EventSink.OnEnterRegion` (`Region.cs:1160`, player-filtered) | low | Cheap location stream → town population heatmap, "who's in Despise" — `PLAN.md` §5.6 already flags it as the right answer over `Movement`. |
|
||||
| 3 | **Crafting feed** | ✔ `EventSink.CraftSuccess` | low | Who crafted what, exceptional/runic — a crafting economy + "notable crafts" feed. |
|
||||
| 4 | **Taming feed** | ✔ `EventSink.TameCreature` | low | New tames, esp. rares/greaters — high community interest. |
|
||||
| 5 | **Resource harvesting** | ✔ `EventSink.ResourceHarvestSuccess` | low-med | Mining/lumber/fishing volume → the raw-material side of the economy (pairs with the vendor/gold streams already shipped). |
|
||||
| 6 | **Virtue progression** | ✔ `EventSink.VirtueLevelChange` | low | Knight/Seeker/etc. virtue ranks — a progression badge system. |
|
||||
| 7 | **Bulk Order Deeds** | ✔ `EventSink.BODOffered` / `BODUsed` | low | BOD turn-ins and rewards — a crafting-endgame feed and reward-title source. |
|
||||
| 8 | **Guild wars** | ⟳ from the §10.1 guild sweep (war state on `Guild`) | low | Declared/active/ended wars between guilds — a PvP politics board, nearly free once guilds sweep. |
|
||||
| 9 | **Player housing registry** | ⟳ extend the existing decay sweep to a full house list | med | Owner → houses map, "houses for sale" (via vendor data already streamed), a housing map. Reuses `PLAN.md` §5.4 machinery. |
|
||||
| 10 | **Peerless / boss / rare drops** | ⚑ virtual-override or drop-system tap (no `EventSink`) | med | An "epic loot" feed. Honest cost: no clean event (same gap as per-hit damage, `PLAN.md` §5.9) — needs a targeted `patches/` hook, so it is a deliberate pick, not a freebie. |
|
||||
| 11 | **Secure player trades** | ⚑ `SecureTrade` completion has no `EventSink` | med | Player-to-player item/gold transfers → economy + fraud signal, complements the vendor-sale core edit. Needs a core tap. |
|
||||
| 12 | **Champion spawn *board*** | already shipped (`BridgeChamps`) — extend, don't rebuild | — | Champs are done in 1.0. Listed so it is not re-proposed; any gap is an extension of the existing sweep. |
|
||||
|
||||
**Selected for Part B (owner pick, 2026-07-17):** guilds (§10.1) + governors (§10.2) + who's-online (#1) + region presence (#2) + **housing registry (#9)** + titles (§10.3, free as profile enrichment). All reuse the sweep pattern and need no core edit; together they give a website its "living world" page — population, guild politics, town leadership, and a housing map. Factions/VvV (§10.4) is deferred until you confirm which system your shard runs. The phasing is §13.
|
||||
|
||||
### Where the Part B code goes
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeSocial.cs` | **New.** The guild sweep+diff and the `EventSink.JoinGuild` subscription → `guild.*`. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeGovernance.cs` | **New.** The city sweep → `city.governor`/`city.election`; the VvV/faction standings sweep (gated on enabled) → `vvv.*` / `faction.*`; the one-time `world.systems` frame. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeProfile.cs` | **Extend.** Add the `titles` block to `char.profile`. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeSweeps.cs` | **Extend / mirror.** New sweep timers (guild, city, presence), re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`, counters in `[bridge status` — same shape as the existing sweeps. |
|
||||
| `overlay/Config/Bridge.cfg` | **Extend.** `GuildSweepSeconds`, `CitySweepSeconds`, `PresenceSweepSeconds` (+ enable flags). |
|
||||
| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the snapshots that back boards (guild roster, governors, population history); `GET /guilds`, `/governors`, `/online` served from the store so they survive a shard outage, exactly like `/champs` and `/economy` do today. |
|
||||
| `docs/INTEGRATION.md` | **Extend.** New event catalog entries + the read endpoints. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Cross-cutting additions (recommended)
|
||||
|
||||
Five things that are not new *streams* but make 2.0 correct and complete. The first two I consider **essential**; the rest are high-value companions to what's already specced.
|
||||
|
||||
### 12.1 Bump the protocol version to 2 — **essential**
|
||||
|
||||
The sidecar is `PROTOCOL_VERSION = 1` (`sidecar/src/main.rs:23`), and every response carries `X-UOLink-Version`; the gate 409s a client that declares a different one (`web.rs:146`). 2.0 adds inbound verbs (`account.create`, `account.unlink`, …) and event kinds, so it must bump to `2`.
|
||||
|
||||
The compatibility rule to write down: **new outbound event kinds are additive** — a 1.x website ignores unknown kinds and keeps working, so the live feed stays backward-compatible. What is *not* compatible is a client that calls a **new inbound verb** against an old sidecar, or a new sidecar that a strict old client rejects on the version header. So: bump to `2`, keep the feed additive, and document that the new *verbs/endpoints* require a v2 sidecar while the *event feed* degrades gracefully.
|
||||
|
||||
### 12.2 Every diff stream needs a REST snapshot companion — **essential**
|
||||
|
||||
The Part B streams are **diff-based**: `guild.created`/`disbanded`, `city.governor`, housing changes emit only on transition (like house decay). That means a website that connects fresh — or a **sidecar that restarts** — has seen *no* deltas yet and therefore has **no current state**. The live feed alone can never answer "what are the guilds *right now*."
|
||||
|
||||
So every board-backed stream ships with a REST snapshot served from the sidecar's store, exactly as `/champs` and `/economy` already are (`web.rs`): `GET /guilds`, `/governors`, `/online`, `/houses`. The shard emits deltas; the sidecar persists the latest snapshot; the website hydrates from REST on load and then live-updates from the feed. This is the single most important robustness rule for Part B — without it, a sidecar restart silently blanks the community page until the next guild happens to change.
|
||||
|
||||
> Concretely: the sidecar keeps a `guilds` / `governors` / `population` table updated from the stream (upsert on each delta, plus a periodic full snapshot the shard can push), and the REST route reads that table. The shard should also support an on-demand full re-emit (a `snapshot.request` inbound, or just re-run the sweep with baseline suppression off) so a sidecar that lost its store can rebuild.
|
||||
|
||||
### 12.3 Round out the provisioning surface — password reset, existence check
|
||||
|
||||
`account.create` sets the account's **initial** password (§3) — that part is done. What it does not cover is the rest of the credential lifecycle. Two small siblings close it, both trivially grounded:
|
||||
|
||||
- **`account.setpassword`** — the **later** password *change/reset* for an account that already exists (a player who forgot theirs), distinct from the initial password `account.create` sets. `acct.SetPassword(newpw)` (`Account.cs:676`) is public; the verb takes `{actor, account, password}`, applies the Owner floor, emits `account.audit action:"setpassword"`, and — like create — **never echoes the password**. `POST /accounts/{account}/password`. Only worth building if the site will offer a "forgot password" flow.
|
||||
- **`account.exists`** — the signup form wants to say "that name is taken" before submit. A read: `Accounts.GetAccount(un) != null`. `GET /accounts/{account}` → `{exists: true|false, linked: bool}`. Cheap, and it prevents the worse UX of finding out via a 409 on submit.
|
||||
|
||||
Both reuse the `account.*` machinery from Part A verbatim. `account.setpassword` is the higher-value of the two.
|
||||
|
||||
### 12.4 Mirror hygiene — deletion & link teardown
|
||||
|
||||
If the website mirrors rosters/links (it does — `store.record_link`), it must learn when the game side removes things, or the mirror rots:
|
||||
|
||||
- **Character deletion.** `EventSink.DeleteRequest` (`EventSink.cs:1754`) fires when a player deletes a character at the select screen. Emit `char.deleted` so the website drops it from any roster it caches. (`PLAN.md` §5.1 already lists this hook as a roster-honesty signal — 2.0 is where it earns its place, now that the website keeps rosters.)
|
||||
- **Account deletion.** `Account.Delete()` exists (`Account.cs:642`); an optional `account.delete` verb (Owner-floor-guarded, `origin:"web"` audit) closes the lifecycle. Lower priority — most shards ban rather than delete — but list it so the option is on record.
|
||||
- On any unlink **or** account delete, the sidecar clears its link mirror (the `record_unlink` already specced in §4.1), so event attribution stops immediately.
|
||||
|
||||
### 12.5 Rate-limit the credential verbs
|
||||
|
||||
`account.create` and `account.setpassword` mint/change persistent credentials. The per-IP cap (§3.1) blocks multi-accounting from one IP, but a compromised or buggy website could still hammer distinct IPs. Add a **sidecar-side rate limit** on the credential verbs — a global create-per-minute ceiling and a per-`actor` cooldown — mirroring the caps philosophy town-crier and the admin plane already follow (`BridgeConfig.TownCrier*`, `Admin*`). Cheap insurance; the shard stays the last line of defense (collision + IP cap), the sidecar is the first.
|
||||
|
||||
---
|
||||
|
||||
## 13. Part B phasing
|
||||
|
||||
1. **Guilds + governors.** `BridgeSocial.cs` (guild sweep + `JoinGuild`) and `BridgeGovernance.cs` (city sweep), their `GuildSweepSeconds`/`CitySweepSeconds` config, and the `world.systems` frame. Ship with their REST snapshots (`GET /guilds`, `/governors`, §12.2) from day one — a diff stream without its snapshot is half-built.
|
||||
2. **Presence.** Who's-online/population sweep + region presence (`OnEnterRegion`) → `GET /online`, population history in the store.
|
||||
3. **Housing registry.** Extend the decay sweep to a full owner→houses list + houses-for-sale → `GET /houses`. (Selected from the §11 menu.)
|
||||
4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`.
|
||||
5. **Factions/VvV** — only after confirming which system the shard runs; stream just the enabled one.
|
||||
|
||||
Cross-cutting, lands with Phase 1: the **protocol bump to 2** (§12.1) and the **snapshot-companion rule** (§12.2). The provisioning siblings (§12.3) and mirror-hygiene (§12.4) attach to Part A's phasing since they extend the `account.*` surface.
|
||||
|
||||
---
|
||||
|
||||
## 14. Built-in reports — replace the FTP/HTML path with JSON over the sidecar
|
||||
|
||||
ServUO ships a **Reports engine** (`Server.Engines.Reports`, `Scripts/Services/Reports/`) that already compiles exactly the dashboard data a website wants — it just delivers it the way RunUO did in 2004: render static HTML and **FTP it to your website**. The bridge can tap the *compiled data* directly and ship JSON, retiring the file/FTP path entirely. **No core edit** — the compile methods are `public static`.
|
||||
|
||||
### 14.1 What the engine produces (verified)
|
||||
|
||||
`Reports.Generate()` runs hourly on the Core thread and builds a `Snapshot` from public static compile methods (`Reports.cs`):
|
||||
|
||||
| Method | Returns | Content |
|
||||
|--------|---------|---------|
|
||||
| `CompileGeneralStats()` | `Report` | NPCs, Players, Clients, Accounts, Items |
|
||||
| `CompileStatChart()` | `Chart` | population over time |
|
||||
| `CompileSkillReports()` | `PersistableObject[]` | **skill distribution — GM count per skill** |
|
||||
| `CompileFactionReports()` | `PersistableObject[]` | faction membership / stats |
|
||||
| `Reports.StaffHistory` | `StaffHistory` | staff activity per account (`StaffInfo`/`UserInfo` hashtables), **help-page-queue length over time** (`QueueStats`), page history |
|
||||
|
||||
Each `Report` is structured (`Columns` + `Items`), so it serializes to JSON cleanly with the hand-rolled `BridgeJson` writers — no reflection serializer. The engine also persists an hourly **`SnapshotHistory`** series to disk, so a backfill of historical points is available if wanted.
|
||||
|
||||
### 14.2 How it's delivered today (the file path you flagged)
|
||||
|
||||
- **HTML + FTP.** `UpdateOutput` (`Reports.cs:406`, on a ThreadPool thread) runs `HtmlRenderer` into `<BaseDir>/reports/stats/` and `reports/staff/` (`Reports.Path`, default `reports`), then `Upload()` writes an `upload.ftp` job to FTP the HTML to a website. Gated on `Reports.AutoGenerate` (**default off**).
|
||||
- **WebStatus.** A *separate* mechanism (`Scripts/Misc/WebStatus.cs`): an in-process `HttpListener` on `:80/status/` serving a live status HTML page. Default `Enabled = false`.
|
||||
|
||||
Both are the "report goes to a file / gets pushed out-of-band" pattern. The sidecar already replaces the second one (`/health` + the live feed cover what `WebStatus` served); §14 replaces the first.
|
||||
|
||||
### 14.3 The tap — a report sweep, JSON out
|
||||
|
||||
`BridgeReports.cs` runs a **Core-thread timer** (`ReportSweepSeconds`, e.g. hourly to match stock, or faster) that calls the same public compile methods, serializes the `Report`/`Chart` objects to JSON, emits `report.*`, and hands the sidecar a snapshot to persist and serve over REST:
|
||||
|
||||
```jsonc
|
||||
{"kind":"report.skills","t":1752…,"skills":[
|
||||
{"skill":"Swordsmanship","gms":42},{"skill":"Magery","gms":88}, …]}
|
||||
{"kind":"report.general","players":142,"npcs":42826,"clients":150,"accounts":51,"items":206467}
|
||||
{"kind":"report.staff","window":"7d","staff":[
|
||||
{"account":"GreyBeard","actions":318}],"pageQueue":[{"t":…,"open":4}, …]}
|
||||
```
|
||||
|
||||
Served for hydration (the §12.2 snapshot rule): `GET /reports/skills`, `/reports/general`, `/reports/staff`.
|
||||
|
||||
Key points, all grounded:
|
||||
|
||||
- **No core edit, no HTML, no FTP.** Calling `Compile*` directly skips `HtmlRenderer`/`Upload` entirely. Leave `Reports.AutoGenerate` **off** (no HTML files written) and run the bridge tap instead. The FTP `upload.ftp` path and `Reports.Path` become dead weight for a bridge-connected shard.
|
||||
- **Threading.** `Compile*` read `World.Mobiles`/`Skills`, so they must run on the Core thread — which the bridge's sweep timers already are (`PLAN.md` non-negotiables). Stock only offloaded the *HTML rendering* (slow string work) to a ThreadPool; the bridge skips that step, so there's nothing to offload. Skill distribution walks all mobiles once — treat it like the profile-bulk warning in `PLAN.md` §1: run it on a slow cadence (hourly is plenty), never in a fast sweep.
|
||||
- **Dedupe against Part B.** `report.general` and the population chart overlap with who's-online (§11 #1); faction reports overlap with §10.4. The **unique** wins here are **skill distribution** (a GM-per-skill leaderboard available nowhere else in the bridge) and the **staff-activity + page-queue-length history** (aggregates that complement the per-action `admin.audit` we already stream). Prioritize those two; treat the rest as "already covered, don't double-emit."
|
||||
- **Optional backfill.** On first connect the sidecar could ingest the engine's persisted `SnapshotHistory` (`Reports.StaffHistory`/stats history) to seed the historical series instead of starting empty. Nice-to-have, not required.
|
||||
|
||||
### 14.4 Where the code goes
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeReports.cs` | **New.** Core-thread report sweep calling `Reports.Compile*` + `Reports.StaffHistory`; serialize to `report.*`; re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`. |
|
||||
| `overlay/Config/Bridge.cfg` | **Extend.** `ReportSweepSeconds` (+ enable flag). |
|
||||
| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the report snapshots; `GET /reports/{skills,general,staff}` served from the store (survives shard outage, like `/champs`). |
|
||||
| `docs/INTEGRATION.md` | **Extend.** `report.*` events + endpoints; note they supersede the stock FTP/HTML reports and `WebStatus`. |
|
||||
|
||||
> **Recommendation:** fold this in as **Part B, Phase 6** (after the world-state streams), scoped to skill distribution + staff/page-queue history first. It is low-effort (public methods, existing sweep pattern) and directly answers "get the admin reports onto the site instead of a file" — by tapping the data the engine already computes and never letting it become a file at all.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <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 ----
|
||||
|
||||
private static void OnLinkConfirm(Dictionary<string, object> o)
|
||||
|
||||
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal file
281
overlay/Scripts/Custom/Bridge/BridgeAccounts.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,9 +283,10 @@ namespace Server.Custom.Bridge
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
private static Account ResolveTargetAccount(Dictionary<string, object> o)
|
||||
public static Account ResolveTargetAccount(Dictionary<string, object> o)
|
||||
{
|
||||
var serialStr = BridgeJson.GetString(o, "serial");
|
||||
if (serialStr != null)
|
||||
@@ -301,9 +302,10 @@ namespace Server.Custom.Bridge
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static bool Protected(Account acct)
|
||||
public static bool Protected(Account acct)
|
||||
{
|
||||
var lvl = acct.AccessLevel;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,11 @@ use tracing_subscriber::EnvFilter;
|
||||
/// Wire-protocol version between the website and the sidecar. Bump this whenever an event or
|
||||
/// endpoint's shape changes so a mismatched client is detected immediately (409 / health) instead
|
||||
/// of failing in confusing ways.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
///
|
||||
/// v2 (Protocol 2.0): adds the account-provisioning verbs/endpoints (`POST /accounts/create`,
|
||||
/// `DELETE /link/:account`) and their events. Outbound event kinds are additive, so a v1 website
|
||||
/// keeps working against the live feed; the new *endpoints* require a v2 sidecar.
|
||||
pub const PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
|
||||
@@ -104,6 +104,16 @@ impl Store {
|
||||
Ok(row.map(|r| r.get::<String, _>("website_user_id")))
|
||||
}
|
||||
|
||||
/// Drops the mirrored link row so event attribution stops immediately, without waiting on the
|
||||
/// shard. Returns the number of rows removed (0 if the account was not linked here).
|
||||
pub async fn record_unlink(&self, account: &str) -> anyhow::Result<u64> {
|
||||
let res = sqlx::query("DELETE FROM links WHERE account = ?")
|
||||
.bind(account)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn cache_profile(
|
||||
&self,
|
||||
serial: &str,
|
||||
|
||||
@@ -54,7 +54,9 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
|
||||
.route("/vendors/:account", get(vendors))
|
||||
// Inbound commands (correlated by code / id).
|
||||
.route("/link/confirm", post(link_confirm))
|
||||
.route("/link/:account", get(link_lookup))
|
||||
// Account provisioning (Protocol 2.0). Create is correlated by reqId; the DELETE unlinks.
|
||||
.route("/accounts/create", post(account_create))
|
||||
.route("/link/:account", get(link_lookup).delete(link_delete))
|
||||
.route("/towncrier", post(towncrier_add))
|
||||
.route("/towncrier/:id", axum::routing::delete(towncrier_remove))
|
||||
// Staff write plane (correlated by reqId). The shard enforces the real authorization;
|
||||
@@ -288,6 +290,138 @@ fn respond_admin(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `respond`, but for the account-provisioning plane. Maps an `account.error` reply to a
|
||||
/// status by its reason: a name clash is a 409, the per-IP cap is a 429, a disabled/protected/
|
||||
/// refused action is a 403, an unknown target or "not linked" is a 404, anything else a 400.
|
||||
fn respond_account(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||
match result {
|
||||
Ok(value) => {
|
||||
let kind = value.get("kind").and_then(|k| k.as_str()).unwrap_or("");
|
||||
if kind == "account.error" {
|
||||
let reason = value
|
||||
.get("reason")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("request rejected");
|
||||
let code = if reason.contains("already exists") {
|
||||
StatusCode::CONFLICT
|
||||
} else if reason.contains("ip account limit") {
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
} else if reason.contains("disabled")
|
||||
|| reason.contains("protected")
|
||||
|| reason.contains("refused")
|
||||
{
|
||||
StatusCode::FORBIDDEN
|
||||
} else if reason.contains("unknown") || reason.contains("not linked") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
(code, Json(value))
|
||||
} else {
|
||||
(StatusCode::OK, Json(value))
|
||||
}
|
||||
}
|
||||
Err(RpcError::NoShard) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({"error": "shard not connected"})),
|
||||
),
|
||||
Err(RpcError::Timeout) => (
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(json!({"error": "shard did not reply in time"})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- account-provisioning handlers ----
|
||||
|
||||
/// Body: {"actor","account","password","websiteUserId","ip"}. Creates and links a game account.
|
||||
/// Correlated on a fresh reqId. The password is forwarded to the shard (loopback) but never logged
|
||||
/// here and never appears in the reply; a successful create mirrors the link into the store.
|
||||
async fn account_create(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||
let mut obj = match body {
|
||||
Value::Object(m) => m,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "body must be a JSON object"})),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Required, non-empty. `ip` is validated on the shard (which owns the cap), not here.
|
||||
for field in ["actor", "account", "password", "websiteUserId"] {
|
||||
let present = obj
|
||||
.get(field)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
if !present {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": format!("{field} is required") })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let req_id = st.rpc.next_req_id();
|
||||
obj.insert("kind".to_string(), json!("account.create"));
|
||||
obj.insert("reqId".to_string(), json!(req_id));
|
||||
|
||||
let result = st.rpc.call(&st.shard, Value::Object(obj), &req_id).await;
|
||||
|
||||
// Mirror a successful create's link into the store, so events are attributable without the
|
||||
// shard (same as link.confirm does).
|
||||
if let Ok(value) = &result {
|
||||
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
|
||||
if let (Some(account), Some(web_id)) = (
|
||||
value.get("account").and_then(|a| a.as_str()),
|
||||
value.get("websiteUserId").and_then(|w| w.as_str()),
|
||||
) {
|
||||
let t = value.get("t").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let _ = st.store.record_link(account, web_id, t).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
respond_account(result)
|
||||
}
|
||||
|
||||
/// Unlinks a game account from its website user. Body: {"actor"}. Correlated on reqId; a success
|
||||
/// also clears the sidecar's mirrored link row so attribution stops immediately.
|
||||
async fn link_delete(
|
||||
State(st): State<AppState>,
|
||||
Path(account): Path<String>,
|
||||
body: Option<Json<Value>>,
|
||||
) -> impl IntoResponse {
|
||||
let actor = body
|
||||
.as_ref()
|
||||
.and_then(|Json(b)| b.get("actor").and_then(|a| a.as_str()))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if actor.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "actor is required"})),
|
||||
);
|
||||
}
|
||||
|
||||
let req_id = st.rpc.next_req_id();
|
||||
let cmd = json!({
|
||||
"kind": "account.unlink", "reqId": req_id, "actor": actor, "account": account
|
||||
});
|
||||
let result = st.rpc.call(&st.shard, cmd, &req_id).await;
|
||||
|
||||
if let Ok(value) = &result {
|
||||
if value.get("kind").and_then(|k| k.as_str()) == Some("account.ok") {
|
||||
let _ = st.store.record_unlink(&account).await;
|
||||
}
|
||||
}
|
||||
|
||||
respond_account(result)
|
||||
}
|
||||
|
||||
// ---- admin write-plane handlers ----
|
||||
|
||||
/// Forwards a staff moderation command to the shard, correlated on a fresh reqId. Injects `kind`
|
||||
|
||||
Reference in New Issue
Block a user