From a0dbb80e1eba1945d7adf4cfad3bc883abbba0ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 02:00:17 -0500 Subject: [PATCH] feat(admin): sidecar REST routes for the write plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- docs/ADMIN_CONTROLS.md | 7 ++- docs/INTEGRATION.md | 50 +++++++++++++++++++++ sidecar/src/web.rs | 99 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 1 deletion(-) diff --git a/docs/ADMIN_CONTROLS.md b/docs/ADMIN_CONTROLS.md index db39185..cd177f8 100644 --- a/docs/ADMIN_CONTROLS.md +++ b/docs/ADMIN_CONTROLS.md @@ -134,7 +134,12 @@ It turns "a staff member must be logged into the game to see the queue" into "th ## 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:` 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:` 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:** diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index b281755..17d46db 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -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. | | `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. | +| `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:"`) 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 | 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`. +### 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) ``` diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 261c350..435f8d8 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -57,6 +57,12 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/link/:account", get(link_lookup)) .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; + // 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. .route("/history", get(history)) .route("/economy", get(economy)) @@ -231,6 +237,99 @@ fn respond(result: Result) -> (StatusCode, Json) { } } +/// 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) -> (StatusCode, Json) { + 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) { + 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, Json(body): Json) -> impl IntoResponse { + admin_call(&st, "admin.kick", body).await +} + +/// Body: {"actor":"...","account":"...","durationSec":,"reason":}. 0/absent = indefinite. +async fn admin_ban(State(st): State, Json(body): Json) -> impl IntoResponse { + admin_call(&st, "admin.ban", body).await +} + +/// Body: {"actor":"...","account":"..."}. +async fn admin_unban(State(st): State, Json(body): Json) -> impl IntoResponse { + admin_call(&st, "admin.unban", body).await +} + +/// Body: {"actor":"...","text":"...","hue":}. Announces a system message to everyone online. +async fn admin_broadcast(State(st): State, Json(body): Json) -> impl IntoResponse { + admin_call(&st, "admin.broadcast", body).await +} + // ---- query handlers ---- async fn char_by_slot(