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:
@@ -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:**
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user