Files
docs/link/ADMIN_CONTROLS.md
Claude 5880bc9f1e docs: fix cross-refs and scrub machine paths
- Repoint doc-to-doc references from the old docs/ prefix to the co-located
  sibling filenames (docs live under link/ here now).
- Replace the personal ServUO checkout path (C:\Users\...\servuo) with a
  <servuo> placeholder throughout.
2026-07-18 02:32:49 -05:00

28 KiB

Administrative Controls — Research & Integration Plan

Status: Research + design. No code written yet. Date: 2026-07-12 Codebase: ServUO 57.4, <servuo>, net48 / x64, Expansion EJ. Companion to PLAN.md (the read/event plane) and INTEGRATION.md (the website API). This document covers the write plane: staff actions the website should be able to take against the live shard.


1. The question

The bridge today is almost entirely outbound. It streams events and answers read queries. Its entire inbound (website → shard) surface is three verbs:

Verb File What it does
ping BridgeBoot.cs:139 Liveness echo.
link.confirm BridgeAccountLink.cs Ties a game account to a website user.
towncrier.add / towncrier.remove BridgeTownCrier.cs Publishes news to the in-game criers.

None of these are moderation. A staff member who wants to kick a cheater, ban an account, answer a help page, or teleport a stuck player still has to be logged into the game client. This document surveys what in-game administrative controls exist, decides which are worth exposing over the bridge, and specifies the protocol and safety model for doing it.

The thesis up front: a small, well-guarded set of account/session-moderation verbs plus the help-page queue covers the overwhelming majority of "why do I have to log in to the game for this" moments. World-building and object manipulation ([add, [set, [dupe, decorate, spawners) should stay in the game client — they are target-driven, high-blast-radius, and gain nothing from a web form.


2. How ServUO admin controls actually work

Four mechanisms, all of which the bridge must respect or reuse.

2.1 The AccessLevel ladder

Server/Mobile.cs:431:

Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer, Administrator, Developer, CoOwner, Owner

Every command is gated on a minimum level (CommandSystem.Register(name, level, handler)). This ladder is the shard's whole authorization model. The bridge has no Mobile and therefore no natural place on this ladder — see §5, the attribution problem.

2.2 The command system

Two registration styles:

  • Simple commandsCommandSystem.Register("Save", AccessLevel.Administrator, handler). The bridge already uses this for [bridge (BridgeBoot.cs:44, Administrator-gated).
  • Generic/target commandsBaseCommand subclasses in Commands/Generic/Commands/Commands.cs, registered as objects (KillCommand, KickCommand, FirewallCommand, …). These are built to be targeted in-game (click a mobile). Their logic is reusable from the bridge; their targeting/gump plumbing is not.

2.3 Command logging (the existing audit trail)

Staff actions call CommandLogging.WriteLine(from, ...), which writes Logs/Commands/*.log and is the source of the bridge's own audit.command / audit.set events (INTEGRATION.md §4). Any web-initiated action must feed this same trail, or the in-game audit log develops blind spots exactly where remote power is exercised.

2.4 Account model (the moderation state)

Scripts/Accounting/Account.cs. The durable, offline-capable levers live here:

Lever API Notes
Ban (indefinite) acct.Banned = true; acct.SetUnspecifiedBan(from) Account.cs:440, :1098
Ban (timed) acct.SetBanTags(from, DateTime.UtcNow, TimeSpan) then acct.Banned = true :1103; Banned getter auto-clears when the window lapses (:454)
Unban acct.Banned = false; acct.SetUnspecifiedBan(null) clears the tags
Read ban acct.GetBanTags(out when, out dur) :1133
Staff level acct.AccessLevel = … :557 — promotes/demotes a whole account
Young status acct.Young :471

Account-level state persists and applies whether or not the player is online. Per-mobile state (below) generally requires the target resident.


3. Candidate controls

Grouped by subsystem. Tier: A = wire in first, B = second wave, N = never expose remotely. H = excluded. The former "hold" items (firewall, kill/res, jail, item/gold grants, set-access-level) were reviewed and cut from the roadmap entirely per the 2026-07-12 decision — their rows are kept below for the record but will not be built. The write plane is deliberately account/session moderation + support, nothing that manipulates the world or the object graph.

3.1 Session control (target online)

Control In-game Bridge API Tier Notes
Kick [KickKickCommand, Commands.cs:1170 targ.NetState?.Dispose() A Pure disconnect. Reversible (they reconnect). Lowest blast radius of any real moderation action.
Firewall (IP block) [Firewall, Commands.cs:1125 Firewall.Add(state.Address) H Blocks an IP, not an account. Collateral damage on shared IPs/CGNAT; hard to reverse from the same UI. Powerful but sharp.
Locate / who [Where, [Client already have char.vitals/mob.login Effectively already covered by the event plane.

3.2 Account moderation (works offline)

Control In-game Bridge API Tier Notes
Ban (indefinite) [BanKickCommand(ban:true), Commands.cs:1225 Banned=true; SetUnspecifiedBan + kick live sessions A The headline verb. Note the in-game path also opens BanDurationGump — we replace that with an explicit duration in the request.
Ban (timed) (gump) SetBanTags(actor, now, dur); Banned=true A Duration in the request body; auto-expires.
Unban property edit Banned=false; SetUnspecifiedBan(null) A
Mute / squelch property Squelched mob.Squelched = true (Mobile.cs:5807) B Per-character, not per-account. Persists across relog + restart (serialized, Mobile.cs:6489/:6013); works on offline chars too. Mute an account = squelch each resident character (§7.1).
Page-mute PagingSquelched set on PlayerMobile B Stops help-page spam without a full mute.
Set access level property AccessLevel acct.AccessLevel = … H Promoting staff from a web UI is a serious privilege path. Gate hard, or omit.
Comments / notes account comments acct.Comments B A staff notes field — pairs naturally with a web moderation panel.

3.3 Player actions (target online)

Control In-game Bridge API Tier Notes
Kill / Resurrect [Kill / [Res, Commands.cs:966 mob.Kill() / mob.Resurrect() H Legitimate for stuck/exploit cleanup; also the most "griefable" verb if the web authz ever leaks.
Teleport / Bring [Go, [Move, [Tele mob.MoveToWorld(p, map) B "Bring to me" has no meaning without a staff mobile; "send to coordinates / named location" does.
Jail region only — Regions/Jail.cs, no stock command custom: move to jail point (+ flag) H Needs us to build the action (pick a jail location, decide on release). Region exists; the verb does not.
Hide / Unhide [Hide, Commands.cs:1066 mob.Hidden = bool N No remote use case.
Set/Get property [Set / [Get / [Props reflection N Arbitrary property writes = arbitrary power. Keep in-client.
Give item / gold [Add, Bank construct + place H Compensation flows are real but this is a duplication/economy risk; if wanted, expose specific curated grants, never [add by type.

3.4 Support: the help-page queue ★

Scripts/Services/Help/PageQueue.cs. When a player uses the in-game Help button they create a PageEntry (Bug, Stuck, Account, Question, Suggestion, Harassment, …) carrying sender, message, type, location/map, timestamp, and assigned handler. PageQueue.List is the live queue; PageQueue.Enqueue/Remove mutate it; a staff reply reaches the player via ResponseEntryMessageSentGump.

This is the single best tie-in and deserves its own slice of work:

  • Stream new pages as a page.new event and removals as page.closed.
  • Snapshot the open queue over REST (GET /pages).
  • Respond from the website (POST /pages/{id}/respond) → delivers a message to the player in-game, exactly like a staff member typing a response.
  • Close / assign a page.

It turns "a staff member must be logged into the game to see the queue" into "the queue is a page on the site." Tier A, but scoped as its own phase (§6, Phase 2) because it is read+write+stream, not a single verb.

3.5 Broadcast & messaging

Control In-game Bridge API Tier Notes
Server broadcast [BCast, Handlers.cs World.Broadcast(hue, ascii, text) A Overlaps town-crier but different UX (instant system message vs. crier loop). Cheap, high-value.
Staff message (SMsg) [SMsg, Handlers.cs send to online staff B "Post to staff channel" from the site.
Tell / private msg [Tell mob.SendMessage B Message one player from the web (e.g. auto-reply to a page).

3.6 World / server operations

Control In-game Bridge API Tier Notes
Save [Save, Handlers.cs (Administrator) AutoSave.Save() B Trigger a world save from a deploy/admin panel. Emits world.save.* we already stream.
Background save [BGSave B Non-blocking variant.
Shutdown / restart console process-level N Do this at the process/host layer, not through a game plugin.
Freeze / Wipe / DecorateDelete / TelGen various N Destructive world-building. In-client only.

4. Roadmap (decided)

Build status (2026-07-13): Phase 1 is built and live-verified end-to-end, branch feature/admin-controls.

  • Plugin (BridgeAdmin.cs + config): all four verbs, web:<actor> attribution, the audit stream, and the Owner-protection floor (an admin.ban on the Owner was refused) confirmed against a booted ServUO.
  • Sidecar (sidecar/src/web.rs): POST /admin/{kick,ban,unban,broadcast} routes with the status mapping in §6. Verified with the real sidecar + shard: 200 on success, 403 on the Owner floor, 404 unknown target, 400 missing actor, 401 no token.
  • Docs: INTEGRATION.md §6 documents the endpoints and the admin.audit event.
  • Bidirectional audit (§5.5): built and live-verified. patches/commandlogging-event.patch (adds CommandLogging.OnWrite) + patches/BridgeModerationAudit.cs (the subscriber) forward in-game bans/kicks/broadcasts to the site as admin.audit (origin:"in-game"). A boot-time probe confirmed a genuine [bcast and resolved ban/kick lines produce the right frames with the target parsed, non-moderation lines ignored.

Phase 2 — help-page queue: built and live-verified. BridgePages.cs polls the queue (PageSweepSeconds, default 5s) → page.new/page.updated/page.closed; inbound pages.snapshot/page.respond/page.close; sidecar GET /pages + POST /pages/{id}/respond|close; INTEGRATION.md §4/§6 documented. A live run (probe-seeded tickets) confirmed snapshot, both page.new emits, respond, close (→ page removed), page.closed emit, and 404 on an unknown page.

Phase 1 + bidirectional audit + Phase 2 are complete — this is the shipped scope. Phase 3 (below) is not planned (owner decision, 2026-07-13). Remaining work is downstream and website-side only: the admin/mod UI (moderation log + support-queue view).

Wire in, in order:

  1. Phase 1 — Account & session moderation (Tier A). admin.kick, admin.ban (timed + indefinite), admin.unban, plus admin.broadcast. These are the actions a staff member most often wishes they could do from a phone. Ban/unban work offline and are the highest-value; kick and broadcast are trivial and safe.
  2. Phase 2 — Help-page queue (Tier A, own phase). Stream + snapshot + respond/close. The biggest single quality-of-life win, but it is a read/write/stream subsystem, not one verb.
  3. Phase 3 — Second wave (Tier B). Mute/page-mute, account comments, teleport-to-location, staff message, manual save. Not planned (owner decision, 2026-07-13). The Tier-B candidates catalogued in §3 stay documented for the record, but the shipped scope is Phase 1 + Phase 2.

Cross-cutting, lands alongside Phase 1: bidirectional audit — in-game use of any of these moderation verbs is forwarded to the website in the same shape as web-initiated ones, so the site has a complete moderation picture (§5.5).

Excluded — will not be built: firewall, set-access-level, kill/res, jail, item/gold grants (the former Tier H), and the Tier-N set — arbitrary [set/[get, [add, hide, freeze, wipe, decorate, shutdown. Sharp, privilege-escalating, or catastrophic; all stay in the game client.


5. Authorization & attribution (decided)

Every in-game moderation command carries a Mobile from — the staff member — used for two things the bridge has no natural source for:

  1. AuditCommandLogging.WriteLine(from, …) and the SetBanTags(from, …) "BanDealer" tag record who did it.
  2. Authorization — e.g. KickCommand refuses unless from.AccessLevel > targ.AccessLevel (Commands.cs:1200), so a GM can't ban an Admin.

The resolved model:

Authorization lives on the website. The website gates these commands behind its own admin-only roles (and moderator ability levels). The shard does not — cannot — re-derive per-user permission; it trusts the loopback socket + auth token exactly as it already trusts town-crier. The sidecar is the trust boundary.

Sidecar commands carry CoOwner-level authority on the shard. Because the website has already authenticated and authorized the staff user, an inbound admin.* is applied as if issued by a synthetic CoOwner — the second-highest rung (Server/Mobile.cs:431: only Owner is above it). This cleanly satisfies the from.AccessLevel > targ.AccessLevel guard for every ordinary target.

The one shard-side floor: never touch the Owner. Even at CoOwner authority, an admin.* command refuses any target account whose AccessLevel >= CoOwner. That is the whole defense-in-depth on the plugin side: a compromised or buggy sidecar can moderate players and staff below CoOwner, but can never ban, kick, or demote the Owner (or another CoOwner). Note the consequence, plainly: this is a permissive posture — it deliberately lets the web plane act on Administrator/Seer/GM-level accounts, on the assumption that reaching the web admin panel already means near-total trust. If that assumption ever weakens, raise the floor in Bridge.cfg (AdminAccessFloor).

Attribution is an explicit web:<actor> string. Every admin.* request carries a required actor field — the website username/id of the staff member. The shard:

  • logs it to the server console as [Bridge][admin] web:<actor> <action> …. (Note, corrected during implementation: CommandLogging.WriteLine cannot be reused for web actions — it dereferences from.NetState/from.Account/from.AccessLevel (Scripts/Commands/Logging.cs:93-103) and there is no staff Mobile. So web actions do not land in Logs/Commands/; the console line plus the admin.audit stream plus the website's own log are their durable record. Logs/Commands/ remains the record for in-game staff actions, which §5.5 forwards to the site — so the complete picture lives on the website, by design.)
  • stores web:<actor> in the ban "BanDealer" tag (SetBanTags wants a Mobile from; we pass null for the Mobile and set the tag ourselves — no core edit),
  • echoes it back in an admin.audit event (§5.5) so the website's own moderation record and the game's audit agree.

The website keeps its own durable record. Independently of the shard, the website persists every moderation action to its own log (who/what/when/why), mirroring the existing admin-activity-log pattern. The shard's CommandLogging + admin.audit are the game-side truth; the website log is the site-side truth; §5.5 keeps them in sync in both directions.

5.5 Bidirectional audit — one moderation picture, both origins

The website must see moderation actions whether they originate on the site or in the game client, in one consistent schema. Two directions:

  • Web → game (already in the request path). Each applied admin.* emits an unsolicited admin.audit broadcast frame to every connected dashboard, tagged "origin":"web", "actor":"web:<user>".
  • Game → web (the "full picture" requirement). When a staff member runs one of these same verbs in the game client[ban, [kick, [bcast, a page-queue response, a mute — the plugin forwards it to the website as the same admin.audit shape, tagged "origin":"in-game", "actor":"<staff account/name>".

The raw hook already exists: BridgeEvents.OnStaffCommand subscribes to EventSink.Command and emits audit.command for every staff command (BridgeEvents.cs:404), and OnStaffPropertySet emits audit.set. Those stay as the low-level firehose. On top of them we add a normalizer that emits a structured admin.audit for the specific moderation verbs, so the website's moderation log has one shape to store, not a freeform command string to parse.

{ "kind": "admin.audit", "origin": "in-game", "action": "ban",
  "actor": "GreyBeard", "target": "griefer42", "reason": null,
  "durationSec": 604800, "t": 1783720195626 }

The dispatch path — traced and settled (no longer an open question). [ban and [kick are registered directly in the command table: SingleCommandImplementor.Register calls CommandSystem.Register(name, level, Redirect) for each command name (SingleCommandImplementor.cs:22), so they sit in m_Entries and EventSink.InvokeCommand(e) fires for them (Server/Commands.cs:259). So the existing audit.command hook already sees them — the earlier worry that generic commands bypass EventSink.Command is wrong.

The genuine subtlety is when it fires and with what:

Verb shape Example What EventSink.Command carries Complete?
Arg-bearing, no target [bcast Server down in 5 verb + full args fully captured
Target-cursor [ban → click victim verb only, empty args ⚠️ verb but not the victim

For target-cursor verbs, Handle runs entry.Handler(e) (→ RedirectProcessfrom.BeginTarget(...), which arms the cursor and returns) and then InvokeCommand(e) (Commands.cs:255-259). The event therefore fires the moment [ban is typed, before the staff clicks anyone. The resolved action — the actual target and Account.Banned = true — happens later inside KickCommand.Execute, which calls CommandLogging.WriteLine(from, "… banning {target}") with the victim (Commands.cs:1211).

Conclusion: the reliable choke point for a resolved in-game moderation action (verb and victim) is CommandLogging.WriteLine (Scripts/Commands/Logging.cs:86), which is where every command already records its outcome — but it has no event to subscribe to today. So the "full picture" needs one small hook:

  • Add a WriteLine event to Scripts/Commands/Logging.cs (a 1-line Action<Mobile,string> raised in WriteLine). This is a stock file, so it ships as a patches/ diff — the same mechanism Phase 7's PlayerVendorSale already established, and arguably the correct universal tap for a staff-action feed regardless of this feature. The normalizer subscribes, matches the moderation lines, and emits admin.audit.
  • Broadcasts and other arg-bearing simple commands need no patch — the existing EventSink.Command hook already carries their full payload; the normalizer just reshapes them.

6. Protocol design

Reuse the existing inbound machinery verbatim — BridgeBoot.RegisterHandler(kind, handler), Core-thread dispatch via Timer.DelayCall, reqId echo, and *.ok / *.error replies — exactly as BridgeRequests and BridgeTownCrier already do. A new BridgeAdmin.cs registers the admin.* handlers.

Request shape (website → sidecar → shard)

{ "kind": "admin.ban", "reqId": "a1b2", "actor": "whitlocktech",
  "account": "griefer42", "durationSec": 604800, "reason": "harassment" }
  • reqId — correlation id, echoed on the reply (as in BridgeRequests).
  • actorrequired. The website staff user. Rejected if absent.
  • Target — account (offline-capable verbs) or serial (online mobiles), resolved with the same ResolveSerial / Accounts.GetAccount helpers BridgeRequests uses.
  • reason — recorded in the audit trail.

Reply shape (shard → sidecar → website)

{ "kind": "admin.ok", "reqId": "a1b2", "action": "ban", "target": "griefer42" }
{ "kind": "admin.error", "reqId": "a1b2", "reason": "target is staff; refused" }

Map to REST like the rest of INTEGRATION.md: admin.ok → 200, unknown target → 404, floor-violation/actor missing → 403, malformed → 400.

Audit event (shard → website, unsolicited)

Every applied admin.* also emits a broadcast audit frame so all connected dashboards see it, not just the caller — parallel to the existing audit.command, and (per §5.5) emitted for in-game uses of the same verbs too:

{ "kind": "admin.audit", "origin": "web", "action": "ban", "actor": "web:whitlocktech",
  "target": "griefer42", "reason": "harassment", "durationSec": 604800, "t": 1783720195626 }

origin is "web" for sidecar-initiated actions or "in-game" for actions a staff member took in the game client.

Verbs for Phase 1

kind target required fields shard action
admin.kick serial or account actor dispose live NetState(s)
admin.ban account actor (+ durationSec optional) set ban tags/flag, then kick live sessions
admin.unban account actor clear ban
admin.broadcast actor, text (+ hue) World.Broadcast

Every one: enforce the Owner floor on the target (refuse AccessLevel >= CoOwner), apply on the Core thread as a synthetic CoOwner, CommandLogging.WriteLine("web:<actor> …"), emit admin.audit (origin:"web"), reply admin.ok/admin.error.

Caps / defense-in-depth (mirroring town-crier)

  • actor required and non-empty.
  • Target floor: refuse any target with AccessLevel >= CoOwner (AdminAccessFloor in Bridge.cfg, default CoOwner → only the Owner/CoOwners are shielded).
  • reason length cap; durationSec clamp (min/max); broadcast text length cap.
  • Master switch AdminWriteEnabled in Bridge.cfg (default off) so the whole write plane is opt-in per shard.

7. Verification log (all resolved)

All resolved by source inspection (ServUO checkout at <servuo>). No live-shard run was needed — every path below is unambiguous in the code, and a mute smoke-test would in any case require a real UO client to log in and speak.

  1. Mobile.Squelched persists — confirmed durable. Serialized unconditionally (Server/Mobile.cs:6489 write) and read back in the version ladder at case 9 (:6013), so it survives relog and a full server restart; no need to persist it ourselves. It gates OnSaid (:7591"You can not say anything, you have been muted."). Two consequences for the plan: (a) it is per-Mobile (per-character), not per-account — "mute the account" means squelch each resident character; (b) it works on offline characters too, since logged-off mobiles stay resident in World. Phase 3 mute is therefore durable and offline-capable out of the box.
  2. Kicking all sessions — settled. Enumerate NetState.Instances (Server/Network/NetState.cs:583, a ReadOnlyCollection<NetState>), filter on ns.Account == acct (:574), and Dispose() each. This is strictly better than walking the account's characters' NetState: a client sitting at character-select has a NetState with an Account but no mobile, and only the Instances sweep catches it. admin.kick and the live-session cleanup in admin.ban both use this.
  3. In-game capture of resolved bans/kicks (was the ★ risk). Traced through the dispatch path — settled in §5.5. [ban/[kick do raise EventSink.Command, but at type-time without the target. The complete capture point is a 1-line event added to Scripts/Commands/Logging.cs:86, shipped as a patches/ diff. Broadcasts need no patch.
  4. Ban attribution — pass null for the Mobile from and set web:<actor> as the BanDealer tag ourselves. No core edit.
  5. Broadcast + town-crier — keep both; they differ (instant system line vs. looping crier) and both are cheap.
  6. Access floorCoOwner (Owner-only shield). See §5.

Nothing in §7 remains open — the plan is implementation-ready.


8. Decisions — locked 2026-07-12

  • Scope: Phase 1 (kick / ban / unban / broadcast) + Phase 2 (help-page queue) + Phase 3 second-wave. The former Tier-H verbs (firewall, kill/res, jail, item/gold grants, set-access-level) are cut entirely — not now, not later.
  • Authorization: enforced on the website (admin-only + moderator roles). Inbound sidecar commands are applied on the shard as CoOwner-level authority, with a hard floor that refuses any target at AccessLevel >= CoOwner (Owner-only shield). Write plane defaults off in Bridge.cfg.
  • Attribution: web:<actor> in CommandLogging and the BanDealer tag; no core edits.
  • Logging: the website keeps its own durable moderation record; the plugin forwards in-game uses of these same verbs to the site as admin.audit (origin:"in-game") so the picture is complete from both sides (§5.5).
  • Help-page queue: confirmed, lands as Phase 2.

9. Where the code goes

File Responsibility
overlay/Scripts/Custom/Bridge/BridgeAdmin.cs New. Registers admin.* handlers; the CoOwner-authority application + Owner floor; web admin.audit emission. Mirrors BridgeTownCrier.cs structure.
overlay/Scripts/Custom/Bridge/BridgeEvents.cs Extend: normalize in-game moderation verbs into admin.audit (origin:"in-game"). Broadcasts reshape from the existing EventSink.Command hook; ban/kick subscribe to the new CommandLogging event (§5.5).
patches/commandlogging-event.patch New. Adds a 1-line Action<Mobile,string> event to Scripts/Commands/Logging.cs:86 so resolved staff actions (verb + target) are observable. Stock file → ships as a patch, per the Phase-7 precedent.
overlay/Scripts/Custom/Bridge/BridgePages.cs New (Phase 2). Streams/snapshots/answers the PageQueue.
overlay/Config/Bridge.cfg Add AdminWriteEnabled (default off), AdminAccessFloor (default CoOwner), and the caps.
sidecar/src/web.rs New REST routes (POST /admin/*, /pages/*) → inbound lines; map replies to status codes.
INTEGRATION.md Document the new endpoints + the admin.audit / page.* events.
(website, separate repo) Admin/moderator-gated UI + a durable moderation log that records both its own actions and inbound admin.audit frames.

The Phase-1 verbs need no core or stock edit — every web-initiated action is an existing script-layer API called from the new BridgeAdmin.cs overlay. The only non-overlay change is the one-line CommandLogging event (patches/commandlogging-event.patch), needed solely so in-game bans/kicks forward their resolved target to the website (§5.5); it reuses the Phase-7 patches/ mechanism and touches nothing else.