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