use std::sync::Arc;
use axum::Extension;
use axum::Router;
use axum::extract::Request;
use axum::http::{Method, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use serde::Serialize;
use sqlx::{Pool, Sqlite};
use crate::xrpc_gateway::auth::XrpcAuthService;
use crate::xrpc_gateway::config::XrpcGatewayConfig;
use crate::xrpc_gateway::handlers::{XrpcGatewayState, emit_event, query_events, query_statuses};
use crate::xrpc_gateway::middleware::{
xrpc_auth_middleware, xrpc_membership_middleware, xrpc_replay_middleware,
};
use crate::xrpc_gateway::replay::XrpcReplayCache;
pub fn build_router(
config: XrpcGatewayConfig,
auth_service: Arc<XrpcAuthService>,
pool: Pool<Sqlite>,
replay_cache: Arc<XrpcReplayCache>,
handler_state: XrpcGatewayState,
) -> Router {
let _ = config; routes()
.layer(Extension(handler_state))
.layer(axum::middleware::from_fn_with_state(
replay_cache,
xrpc_replay_middleware,
))
.layer(axum::middleware::from_fn_with_state(
pool,
xrpc_membership_middleware,
))
.layer(axum::middleware::from_fn_with_state(
auth_service,
xrpc_auth_middleware,
))
}
fn routes() -> Router {
Router::new()
.route(
"/xrpc/tools.ozone.moderation.emitEvent",
post(handle_emit_event).fallback(handle_method_not_allowed),
)
.route(
"/xrpc/tools.ozone.moderation.queryStatuses",
get(handle_query_statuses).fallback(handle_method_not_allowed),
)
.route(
"/xrpc/tools.ozone.moderation.queryEvents",
get(handle_query_events).fallback(handle_method_not_allowed),
)
.fallback(handle_unknown_nsid)
}
async fn handle_emit_event(
state: Extension<XrpcGatewayState>,
claims: Extension<crate::xrpc_gateway::XrpcAuthClaims>,
body: axum::body::Bytes,
) -> Response {
emit_event::handler(state, claims, body).await
}
async fn handle_query_statuses(
state: Extension<XrpcGatewayState>,
claims: Extension<crate::xrpc_gateway::XrpcAuthClaims>,
params: axum::extract::Query<query_statuses::QueryStatusesParams>,
) -> Response {
query_statuses::handler(state, claims, params).await
}
async fn handle_query_events(
state: Extension<XrpcGatewayState>,
claims: Extension<crate::xrpc_gateway::XrpcAuthClaims>,
params: axum::extract::Query<query_events::QueryEventsParams>,
) -> Response {
query_events::handler(state, claims, params).await
}
async fn handle_unknown_nsid(req: Request) -> Response {
let path = req.uri().path();
let nsid = path.strip_prefix("/xrpc/").unwrap_or("<unknown>");
if nsid.is_empty() || nsid == "<unknown>" {
tracing::warn!(
path = %path,
"xrpc_gateway: unknown NSID — no /xrpc/ prefix on request"
);
return method_not_implemented_response("<unknown>");
}
tracing::warn!(
nsid = nsid,
"xrpc_gateway: unknown NSID — not on v1.7 allowlist (§A7)"
);
method_not_implemented_response(nsid)
}
async fn handle_method_not_allowed(req: Request) -> Response {
let method = req.method().clone();
let path = req.uri().path();
let nsid = path.strip_prefix("/xrpc/").unwrap_or("<unknown>");
tracing::warn!(
nsid,
method = %method,
"xrpc_gateway: HTTP method not allowed for this NSID"
);
method_not_allowed_response(nsid, &method)
}
pub(crate) fn method_not_implemented_response(nsid: &str) -> Response {
let body = XrpcErrorEnvelope {
error: "MethodNotImplemented",
message: format!("Method {nsid} is not implemented"),
};
(StatusCode::NOT_IMPLEMENTED, axum::Json(body)).into_response()
}
fn method_not_allowed_response(nsid: &str, method: &Method) -> Response {
let body = XrpcErrorEnvelope {
error: "MethodNotAllowed",
message: format!("HTTP {method} not allowed for NSID {nsid}"),
};
(StatusCode::METHOD_NOT_ALLOWED, axum::Json(body)).into_response()
}
#[derive(Serialize)]
struct XrpcErrorEnvelope {
error: &'static str,
message: String,
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
async fn spawn_for_test(router: Router) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, router.into_make_service()).await.ok();
});
format!("http://{addr}")
}
fn client() -> reqwest::Client {
reqwest::Client::new()
}
#[tokio::test]
async fn wrong_method_on_known_nsid_returns_405_envelope() {
let url = spawn_authed().await;
let cases: &[(reqwest::Method, &str)] = &[
(reqwest::Method::GET, "tools.ozone.moderation.emitEvent"),
(
reqwest::Method::POST,
"tools.ozone.moderation.queryStatuses",
),
(reqwest::Method::POST, "tools.ozone.moderation.queryEvents"),
];
for (i, (method, nsid)) in cases.iter().enumerate() {
let mut claims = fx::valid_claims(nsid);
claims["jti"] = serde_json::json!(format!("jti-wrong-method-{i}"));
let jwt = fx::build_jwt(&claims, "ES256K");
let res = client()
.request(method.clone(), format!("{url}/xrpc/{nsid}"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(
res.status().as_u16(),
405,
"{nsid} via {method} (wrong method)"
);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("MethodNotAllowed"),
"{nsid}"
);
let msg = body.get("message").and_then(|v| v.as_str()).unwrap();
assert!(msg.contains(nsid), "405 envelope names the NSID: {msg}");
}
}
#[tokio::test]
async fn unknown_nsid_returns_501_via_fallback() {
let url = spawn_authed().await;
for nsid in [
"tools.ozone.moderation.somethingElse",
"tools.ozone.moderation.emitEventV2",
"com.atproto.moderation.deleteReport",
"com.atproto.server.createSession",
"com.example.foo",
] {
let res = client()
.post(format!("{url}/xrpc/{nsid}"))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 501, "{nsid}");
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("MethodNotImplemented"),
"{nsid}"
);
let msg = body.get("message").and_then(|v| v.as_str()).unwrap();
assert!(
msg.contains(nsid),
"fallback envelope names the NSID: {msg}"
);
}
}
#[tokio::test]
async fn case_mismatch_falls_through_to_unknown_nsid() {
let url = spawn_authed().await;
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.EmitEvent"))
.send()
.await
.unwrap();
assert_eq!(
res.status().as_u16(),
501,
"capitalized NSID should NOT match named route"
);
let body: serde_json::Value = res.json().await.unwrap();
let msg = body.get("message").and_then(|v| v.as_str()).unwrap();
assert!(msg.contains("EmitEvent"), "{msg}");
}
#[tokio::test]
async fn non_xrpc_path_falls_through_to_404() {
let url = spawn_authed().await;
let res = client().get(format!("{url}/health")).send().await.unwrap();
assert_eq!(res.status().as_u16(), 501);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("MethodNotImplemented")
);
}
#[tokio::test]
async fn envelope_shape_is_two_field_xrpc_error_object() {
let url = spawn_authed().await;
let res = client()
.post(format!("{url}/xrpc/com.example.unknown"))
.send()
.await
.unwrap();
let body: serde_json::Value = res.json().await.unwrap();
let obj = body.as_object().expect("response body is a JSON object");
let keys: std::collections::BTreeSet<&str> = obj.keys().map(String::as_str).collect();
assert_eq!(
keys,
std::collections::BTreeSet::from(["error", "message"]),
"envelope must contain exactly `error` and `message` fields"
);
}
#[test]
fn method_not_implemented_envelope_status_is_501() {
let response = method_not_implemented_response("foo.bar.baz");
assert_eq!(response.status().as_u16(), 501);
}
#[test]
fn method_not_allowed_envelope_status_is_405() {
let response = method_not_allowed_response("foo.bar.baz", &Method::GET);
assert_eq!(response.status().as_u16(), 405);
}
use crate::xrpc_gateway::test_fixtures as fx;
async fn spawn_authed() -> String {
let auth = fx::build_service();
let pool = fx::build_test_pool().await;
let cache = fx::build_replay_cache();
let state = fx::build_handler_state(pool.clone()).await;
let router = build_router(fx::fixture_config(), auth, pool, cache, state);
spawn_for_test(router).await
}
fn auth_header(jwt: &str) -> String {
format!("Bearer {jwt}")
}
#[tokio::test]
async fn missing_authorization_header_returns_401_authrequired() {
let url = spawn_authed().await;
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 401);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("AuthRequired")
);
}
#[tokio::test]
async fn malformed_authorization_header_returns_401() {
let url = spawn_authed().await;
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", "Basic dXNlcjpwYXNz")
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 401);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("AuthRequired")
);
}
#[tokio::test]
async fn unknown_nsid_path_passes_through_auth_to_router_fallback() {
let url = spawn_authed().await;
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"ES256K",
);
let res = client()
.post(format!("{url}/xrpc/com.example.unknown"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 501);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("MethodNotImplemented")
);
}
#[tokio::test]
async fn valid_jwt_for_known_nsid_reaches_handler_with_empty_body_returns_400() {
let url = spawn_authed().await;
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"ES256K",
);
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 400);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("InvalidRequest")
);
}
#[tokio::test]
async fn valid_jwt_for_known_nsid_with_wrong_method_returns_405_after_auth() {
let url = spawn_authed().await;
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"ES256K",
);
let res = client()
.get(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 405);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("MethodNotAllowed")
);
}
#[tokio::test]
async fn jwt_with_audience_mismatch_returns_403_invalidtoken() {
let url = spawn_authed().await;
let mut claims = fx::valid_claims("tools.ozone.moderation.emitEvent");
claims["aud"] = serde_json::json!("did:web:other.example.com");
let jwt = fx::build_jwt(&claims, "ES256K");
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 403);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("InvalidToken")
);
}
#[tokio::test]
async fn jwt_with_lxm_mismatching_url_returns_403() {
let url = spawn_authed().await;
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"ES256K",
);
let res = client()
.get(format!("{url}/xrpc/tools.ozone.moderation.queryStatuses"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 403);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("InvalidToken")
);
}
#[tokio::test]
async fn expired_jwt_returns_403_expiredtoken() {
let url = spawn_authed().await;
let mut claims = fx::valid_claims("tools.ozone.moderation.emitEvent");
claims["exp"] = serde_json::json!(fx::FIXED_NOW - 100);
let jwt = fx::build_jwt(&claims, "ES256K");
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 403);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("ExpiredToken")
);
}
use crate::xrpc_gateway::{
XrpcReplayCache, add_known_caller, add_trusted_pds, revoke_known_caller,
};
use std::time::Duration as StdDuration;
async fn spawn_with_pool(pool: sqlx::Pool<sqlx::Sqlite>) -> String {
let auth = fx::build_service();
let cache = fx::build_replay_cache();
let state = fx::build_handler_state(pool.clone()).await;
let router = build_router(fx::fixture_config(), auth, pool, cache, state);
spawn_for_test(router).await
}
async fn empty_pool() -> sqlx::Pool<sqlx::Sqlite> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("xrpc-test-empty.db");
let pool = crate::storage::open(&path).await.unwrap();
Box::leak(Box::new(dir));
pool
}
#[tokio::test]
async fn known_caller_in_table_can_call_emit_event() {
let pool = empty_pool().await;
add_known_caller(&pool, fx::ISSUER_DID, Some("test"), "did:plc:m")
.await
.unwrap();
let url = spawn_with_pool(pool).await;
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"ES256K",
);
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 400);
}
#[tokio::test]
async fn unknown_caller_emit_event_returns_403() {
let pool = empty_pool().await;
let url = spawn_with_pool(pool).await;
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"ES256K",
);
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 403);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("AccountTakedown")
);
}
#[tokio::test]
async fn create_report_falls_through_to_unknown_nsid_on_gateway_router() {
let url = spawn_authed().await;
let jwt = fx::build_jwt(
&fx::valid_claims("com.atproto.moderation.createReport"),
"ES256K",
);
let res = client()
.post(format!("{url}/xrpc/com.atproto.moderation.createReport"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 501);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("MethodNotImplemented")
);
}
#[tokio::test]
async fn cross_table_isolation_trusted_pds_cannot_call_emit_event() {
let pool = empty_pool().await;
add_trusted_pds(&pool, fx::ISSUER_DID, None, "did:plc:m")
.await
.unwrap();
let url = spawn_with_pool(pool).await;
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"ES256K",
);
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 403);
}
#[tokio::test]
async fn revoked_known_caller_returns_403() {
let pool = empty_pool().await;
add_known_caller(&pool, fx::ISSUER_DID, None, "did:plc:m")
.await
.unwrap();
revoke_known_caller(&pool, fx::ISSUER_DID, "did:plc:m")
.await
.unwrap();
let url = spawn_with_pool(pool).await;
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"ES256K",
);
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 403);
}
#[tokio::test]
async fn replay_second_request_with_same_jti_returns_400_expiredtoken() {
let pool = empty_pool().await;
add_known_caller(&pool, fx::ISSUER_DID, None, "did:plc:m")
.await
.unwrap();
let url = spawn_with_pool(pool).await;
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"ES256K",
);
let res1 = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res1.status().as_u16(), 400, "first call reaches handler");
let body1: serde_json::Value = res1.json().await.unwrap();
assert_eq!(
body1.get("error").and_then(|v| v.as_str()),
Some("InvalidRequest")
);
let res2 = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res2.status().as_u16(), 400, "replay must short-circuit");
let body: serde_json::Value = res2.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("ExpiredToken")
);
}
#[tokio::test]
async fn layer_order_unauthorized_request_does_not_pollute_replay_cache() {
let pool = empty_pool().await;
add_known_caller(&pool, fx::ISSUER_DID, None, "did:plc:m")
.await
.unwrap();
let auth = fx::build_service();
let cache = Arc::new(XrpcReplayCache::new(StdDuration::from_secs(90)));
let state = fx::build_handler_state(pool.clone()).await;
let router = build_router(fx::fixture_config(), auth, pool, cache.clone(), state);
let url = spawn_for_test(router).await;
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 401);
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"ES256K",
);
let res2 = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res2.status().as_u16(), 400);
let body: serde_json::Value = res2.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("InvalidRequest")
);
}
#[tokio::test]
async fn layer_order_unauthorized_membership_does_not_pollute_replay_cache() {
let pool = empty_pool().await; let url = spawn_with_pool(pool.clone()).await;
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"ES256K",
);
let res1 = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res1.status().as_u16(), 403);
add_known_caller(&pool, fx::ISSUER_DID, None, "did:plc:m")
.await
.unwrap();
let res2 = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(
res2.status().as_u16(),
400,
"after grant, same jti should pass through (cache wasn't poisoned)"
);
let body: serde_json::Value = res2.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("InvalidRequest")
);
}
#[tokio::test]
async fn jwt_with_alg_none_returns_401_invalidtoken() {
let url = spawn_authed().await;
let jwt = fx::build_jwt(
&fx::valid_claims("tools.ozone.moderation.emitEvent"),
"none",
);
let res = client()
.post(format!("{url}/xrpc/tools.ozone.moderation.emitEvent"))
.header("authorization", auth_header(&jwt))
.send()
.await
.unwrap();
assert_eq!(res.status().as_u16(), 401);
let body: serde_json::Value = res.json().await.unwrap();
assert_eq!(
body.get("error").and_then(|v| v.as_str()),
Some("InvalidToken")
);
}
}