feat(admin): sidecar REST routes for the write plane

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
This commit is contained in:
2026-07-13 02:00:17 -05:00
parent 213f3fa2ac
commit a0dbb80e1e
3 changed files with 155 additions and 1 deletions

View File

@@ -134,7 +134,12 @@ It turns "a staff member must be logged into the game to see the queue" into "th
## 4. Roadmap (decided) ## 4. Roadmap (decided)
> **Build status (2026-07-13):** Phase 1 **plugin side is built and live-verified** on the seeded shard — `BridgeAdmin.cs` + config, branch `feature/admin-controls`. All four verbs, the `web:<actor>` attribution, the audit stream, and the **Owner-protection floor** (an `admin.ban` on the Owner account was refused) confirmed end-to-end against a booted ServUO, no exceptions. **Remaining for Phase 1 to be usable from the site:** sidecar REST routes (`sidecar/src/web.rs`) and the `INTEGRATION.md` docs. The bidirectional-audit slice (§5.5, incl. the `CommandLogging` patch) is not yet started. > **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.
>
> **Remaining:** the bidirectional-audit slice (§5.5, `BridgeEvents` normalizer + the one-line `CommandLogging` patch) — not yet started.
**Wire in, in order:** **Wire in, in order:**

View File

@@ -178,6 +178,7 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s
| `cheat.fastwalk` | `who`, `ip` | The shard's own speed-hack detector fired. | | `cheat.fastwalk` | `who`, `ip` | The shard's own speed-hack detector fired. |
| `audit.set` | `staff`, `prop`, `target`, `targetSerial`, `old`, `new` | A staff member used `[set` to change a property. `staff` may be null. | | `audit.set` | `staff`, `prop`, `target`, `targetSerial`, `old`, `new` | A staff member used `[set` to change a property. `staff` may be null. |
| `audit.command` | `staff`, `command`, `args` | A staff command was invoked. | | `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
| kind | fields | notes | | kind | fields | notes |
@@ -301,6 +302,55 @@ DELETE /towncrier/{id}
Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`. Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`.
### Staff moderation — the write plane
Account and session moderation against the live shard. **These are privileged.** The sidecar does
not model per-user roles — **your site must authenticate the staff user and check their permission
before calling.** The shard trusts the loopback socket and applies each command with CoOwner-level
authority, with one hard floor it enforces itself: any target at or above CoOwner (e.g. the Owner
account) is refused (**403**). The whole plane is **opt-in on the shard** (`AdminWriteEnabled` in
`Bridge.cfg`); when it's off, every call returns **403** `"admin write plane disabled"`.
Every request requires an **`actor`** — the website username/id of the staff member taking the
action. It is recorded in the shard console log, the ban's `BanDealer` tag, and the `admin.audit`
event, so actions are always attributable. A missing `actor` is **400**.
```
POST /admin/kick { "actor":"jane", "account":"griefer42" } # or "serial":"0x2E0"
POST /admin/ban { "actor":"jane", "account":"griefer42", "durationSec":604800, "reason":"harassment" }
POST /admin/unban { "actor":"jane", "account":"griefer42" }
POST /admin/broadcast { "actor":"jane", "text":"Server restart in 5 minutes", "hue":53 }
```
- **kick** — disconnects every live session of the target account (including one parked at
character-select). Target by `account` or `serial`. Reply carries `sessions` (how many were cut).
- **ban** — bans the account (works offline) and disconnects any live sessions. `durationSec > 0`
is a timed ban that auto-expires; `0`/absent is indefinite. Clamped to the shard's
`AdminBanMaxDurationSec`.
- **unban** — clears the ban.
- **broadcast** — a system message to everyone online. `hue` optional (default `53`, staff green).
Length-capped by the shard.
Success → **200** with an `admin.ok`:
```json
{ "kind":"admin.ok", "reqId":"r-2", "action":"ban", "target":"griefer42", "durationSec":604800, "sessions":1 }
```
Failure → an `admin.error` with a mapped status:
| Status | When |
|--------|------|
| 400 | missing `actor`, malformed body, or bad parameter |
| 401 | missing/invalid auth token |
| 403 | target is protected (at/above the floor), or the write plane is disabled on the shard |
| 404 | unknown or accountless target |
| 503 / 504 | shard not connected / didn't reply in time |
Each applied action also emits an unsolicited **`admin.audit`** frame on the WebSocket (§4) with
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
by staff in the game client surfaces the same way with `origin:"in-game"`.
### History (from the sidecar's database) ### History (from the sidecar's database)
``` ```

View File

@@ -57,6 +57,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
.route("/link/:account", get(link_lookup)) .route("/link/:account", get(link_lookup))
.route("/towncrier", post(towncrier_add)) .route("/towncrier", post(towncrier_add))
.route("/towncrier/:id", axum::routing::delete(towncrier_remove)) .route("/towncrier/:id", axum::routing::delete(towncrier_remove))
// Staff write plane (correlated by reqId). The shard enforces the real authorization;
// the website must gate these behind admin/moderator roles before calling.
.route("/admin/kick", post(admin_kick))
.route("/admin/ban", post(admin_ban))
.route("/admin/unban", post(admin_unban))
.route("/admin/broadcast", post(admin_broadcast))
// History, read from SQLite rather than the shard. // History, read from SQLite rather than the shard.
.route("/history", get(history)) .route("/history", get(history))
.route("/economy", get(economy)) .route("/economy", get(economy))
@@ -231,6 +237,99 @@ fn respond(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
} }
} }
/// Like `respond`, but for the admin write plane, where a rejection is not a not-found. Maps an
/// `admin.error` reply to a status by its reason: an unknown target is a 404, a floor/authorization
/// refusal (protected target, or the write plane being disabled) is a 403, anything else a 400.
fn respond_admin(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 == "admin.error" {
let reason = value
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("request rejected");
let code = if reason.contains("unknown") {
StatusCode::NOT_FOUND
} else if reason.contains("protected")
|| reason.contains("refused")
|| reason.contains("disabled")
{
StatusCode::FORBIDDEN
} 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"})),
),
}
}
// ---- admin write-plane handlers ----
/// Forwards a staff moderation command to the shard, correlated on a fresh reqId. Injects `kind`
/// and `reqId`, requiring the caller-supplied `actor` up front (the shard enforces it too). The
/// body's remaining fields (account/serial/durationSec/reason/text/hue) pass straight through.
async fn admin_call(st: &AppState, kind: &str, body: Value) -> (StatusCode, Json<Value>) {
let mut obj = match body {
Value::Object(m) => m,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "body must be a JSON object"})),
)
}
};
let actor_ok = obj
.get("actor")
.and_then(|a| a.as_str())
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
if !actor_ok {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "actor is required"})),
);
}
let req_id = st.rpc.next_req_id();
obj.insert("kind".to_string(), json!(kind));
obj.insert("reqId".to_string(), json!(req_id));
respond_admin(st.rpc.call(&st.shard, Value::Object(obj), &req_id).await)
}
/// Body: {"actor":"...","account":"..."|"serial":"0x.."}. Disconnects the target's live sessions.
async fn admin_kick(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
admin_call(&st, "admin.kick", body).await
}
/// Body: {"actor":"...","account":"...","durationSec":<opt>,"reason":<opt>}. 0/absent = indefinite.
async fn admin_ban(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
admin_call(&st, "admin.ban", body).await
}
/// Body: {"actor":"...","account":"..."}.
async fn admin_unban(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
admin_call(&st, "admin.unban", body).await
}
/// Body: {"actor":"...","text":"...","hue":<opt>}. Announces a system message to everyone online.
async fn admin_broadcast(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
admin_call(&st, "admin.broadcast", body).await
}
// ---- query handlers ---- // ---- query handlers ----
async fn char_by_slot( async fn char_by_slot(