Phase 1 (sidecar side): POST /admin/{kick,ban,unban,broadcast} forward to the
shard, correlated on a fresh reqId, with an admin-specific status mapping —
unknown target -> 404, protected target / plane-disabled -> 403, missing actor
/ bad body -> 400. actor is required and checked up front. Documents the
endpoints and the admin.audit event in INTEGRATION.md.
Verified end-to-end (real sidecar + booted shard): 200 on success, 403 on the
Owner floor, 404 unknown target, 400 missing actor, 401 no token.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
27 KiB
Administrative Controls — Research & Integration Plan
Status: Research + design. No code written yet.
Date: 2026-07-12
Codebase: ServUO 57.4, C:\Users\colby\Desktop\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 commands —
CommandSystem.Register("Save", AccessLevel.Administrator, handler). The bridge already uses this for[bridge(BridgeBoot.cs:44, Administrator-gated). - Generic/target commands —
BaseCommandsubclasses inCommands/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 | [Kick → KickCommand, 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) | [Ban → KickCommand(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 ResponseEntry → MessageSentGump.
This is the single best tie-in and deserves its own slice of work:
- Stream new pages as a
page.newevent and removals aspage.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 (anadmin.banon 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 theadmin.auditevent.Remaining: the bidirectional-audit slice (§5.5,
BridgeEventsnormalizer + the one-lineCommandLoggingpatch) — not yet started.
Wire in, in order:
- Phase 1 — Account & session moderation (Tier A).
admin.kick,admin.ban(timed + indefinite),admin.unban, plusadmin.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. - 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.
- Phase 3 — Second wave (Tier B). Mute/page-mute, account comments, teleport-to-location, staff message, manual save. Add as the web moderation panel matures.
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:
- Audit —
CommandLogging.WriteLine(from, …)and theSetBanTags(from, …)"BanDealer" tag record who did it. - Authorization — e.g.
KickCommandrefuses unlessfrom.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.WriteLinecannot be reused for web actions — it dereferencesfrom.NetState/from.Account/from.AccessLevel(Scripts/Commands/Logging.cs:93-103) and there is no staffMobile. So web actions do not land inLogs/Commands/; the console line plus theadmin.auditstream 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 (SetBanTagswants aMobile from; we passnullfor the Mobile and set the tag ourselves — no core edit), - echoes it back in an
admin.auditevent (§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 unsolicitedadmin.auditbroadcast 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 sameadmin.auditshape, 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) (→ Redirect → Process → from.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
WriteLineevent toScripts/Commands/Logging.cs(a 1-lineAction<Mobile,string>raised inWriteLine). This is a stock file, so it ships as apatches/diff — the same mechanism Phase 7'sPlayerVendorSalealready established, and arguably the correct universal tap for a staff-action feed regardless of this feature. The normalizer subscribes, matches the moderation lines, and emitsadmin.audit. - Broadcasts and other arg-bearing simple commands need no patch — the existing
EventSink.Commandhook 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 inBridgeRequests).actor— required. The website staff user. Rejected if absent.- Target —
account(offline-capable verbs) orserial(online mobiles), resolved with the sameResolveSerial/Accounts.GetAccounthelpersBridgeRequestsuses. 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)
actorrequired and non-empty.- Target floor: refuse any target with
AccessLevel >= CoOwner(AdminAccessFloorinBridge.cfg, defaultCoOwner→ only the Owner/CoOwners are shielded). reasonlength cap;durationSecclamp (min/max);broadcasttext length cap.- Master switch
AdminWriteEnabledinBridge.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 C:\Users\colby\Desktop\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.
Mobile.Squelchedpersists — confirmed durable. Serialized unconditionally (Server/Mobile.cs:6489write) 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 gatesOnSaid(: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 inWorld. Phase 3 mute is therefore durable and offline-capable out of the box.- Kicking all sessions — settled. Enumerate
NetState.Instances(Server/Network/NetState.cs:583, aReadOnlyCollection<NetState>), filter onns.Account == acct(:574), andDispose()each. This is strictly better than walking the account's characters'NetState: a client sitting at character-select has aNetStatewith anAccountbut no mobile, and only theInstancessweep catches it.admin.kickand the live-session cleanup inadmin.banboth use this. - In-game capture of resolved bans/kicks (was the ★ risk). Traced through the dispatch path — settled in §5.5.
[ban/[kickdo raiseEventSink.Command, but at type-time without the target. The complete capture point is a 1-line event added toScripts/Commands/Logging.cs:86, shipped as apatches/diff. Broadcasts need no patch. - Ban attribution — pass
nullfor theMobile fromand setweb:<actor>as theBanDealertag ourselves. No core edit. - Broadcast + town-crier — keep both; they differ (instant system line vs. looping crier) and both are cheap.
- Access floor —
CoOwner(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 inBridge.cfg. - Attribution:
web:<actor>inCommandLoggingand theBanDealertag; 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. |
docs/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.