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

@@ -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<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 ----
async fn char_by_slot(