| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
use crate::protocol::{self, Headers, Reply}; |
| 7 |
use crate::tools::Ctx; |
| 8 |
use axum::body::Bytes; |
| 9 |
use axum::extract::connect_info::{ConnectInfo, Connected}; |
| 10 |
use axum::extract::State; |
| 11 |
use axum::http::{HeaderMap, StatusCode}; |
| 12 |
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; |
| 13 |
use axum::response::{IntoResponse, Response}; |
| 14 |
use axum::routing::post; |
| 15 |
use axum::Router; |
| 16 |
use futures::stream::{self, StreamExt}; |
| 17 |
use serde_json::{json, Value as Json}; |
| 18 |
use spectra_core::authn::Caller; |
| 19 |
use spectra_core::daemon::{Daemon, Peer, WireEvent}; |
| 20 |
use spectra_core::principals::Principal; |
| 21 |
use spectra_core::proof::Proof; |
| 22 |
use std::convert::Infallible; |
| 23 |
use std::sync::Arc; |
| 24 |
use std::time::Duration; |
| 25 |
|
| 26 |
|
| 27 |
#[derive(Clone, Copy, Debug)] |
| 28 |
pub struct PeerInfo(pub Option<Peer>); |
| 29 |
|
| 30 |
impl Connected<axum::serve::IncomingStream<'_, tokio::net::UnixListener>> for PeerInfo { |
| 31 |
fn connect_info(stream: axum::serve::IncomingStream<'_, tokio::net::UnixListener>) -> Self { |
| 32 |
let cred = stream.io().peer_cred().ok(); |
| 33 |
PeerInfo(cred.map(|c| Peer { uid: c.uid(), pid: c.pid() })) |
| 34 |
} |
| 35 |
} |
| 36 |
|
| 37 |
impl Connected<axum::serve::IncomingStream<'_, tokio::net::TcpListener>> for PeerInfo { |
| 38 |
fn connect_info(_: axum::serve::IncomingStream<'_, tokio::net::TcpListener>) -> Self { |
| 39 |
PeerInfo(None) |
| 40 |
} |
| 41 |
} |
| 42 |
|
| 43 |
impl Connected<axum::serve::IncomingStream<'_, crate::tls::TlsListener>> for PeerInfo { |
| 44 |
fn connect_info(_: axum::serve::IncomingStream<'_, crate::tls::TlsListener>) -> Self { |
| 45 |
PeerInfo(None) |
| 46 |
} |
| 47 |
} |
| 48 |
|
| 49 |
|
| 50 |
#[derive(Clone)] |
| 51 |
pub struct AppState { |
| 52 |
pub daemon: Arc<Daemon>, |
| 53 |
pub origins: Arc<Vec<String>>, |
| 54 |
|
| 55 |
pub sessions: Arc<crate::session::Sessions>, |
| 56 |
} |
| 57 |
|
| 58 |
|
| 59 |
pub fn router(state: AppState) -> Router { |
| 60 |
Router::new() |
| 61 |
.route("/mcp", post(mcp).get(not_allowed).delete(not_allowed)) |
| 62 |
.route("/api/v1/ledger/events", post(owner_events)) |
| 63 |
.route("/api/v1/instance", axum::routing::get(instance)) |
| 64 |
.route("/api/v1/claim", post(claim)) |
| 65 |
.with_state(state) |
| 66 |
} |
| 67 |
|
| 68 |
|
| 69 |
|
| 70 |
|
| 71 |
async fn instance(State(state): State<AppState>) -> Response { |
| 72 |
let (instance, owner) = { |
| 73 |
let p = state.daemon.principals.read().await; |
| 74 |
(p.instance().cloned(), p.owner().map(|o| o.hash().to_hex())) |
| 75 |
}; |
| 76 |
let owner_missing = owner.is_none(); |
| 77 |
let Some(instance) = instance else { |
| 78 |
return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(json!({ "title": "this Spectra has no instance key" }))).into_response(); |
| 79 |
}; |
| 80 |
let tls = match crate::instance::latest_tls(&state.daemon.ledger).await { |
| 81 |
Ok(Some(e)) => json!({ "payload": e.json(), "proof": e.auth.proof.as_ref().map(hex::encode) }), |
| 82 |
_ => Json::Null, |
| 83 |
}; |
| 84 |
axum::Json(json!({ |
| 85 |
"name": instance.name, |
| 86 |
"instance": spectra_core::principals::identity_payload(&instance), |
| 87 |
"owner": owner, |
| 88 |
|
| 89 |
"named_owner": if owner_missing { state.daemon.named_owner() } else { None }, |
| 90 |
"tls": tls, |
| 91 |
})) |
| 92 |
.into_response() |
| 93 |
} |
| 94 |
|
| 95 |
|
| 96 |
|
| 97 |
|
| 98 |
async fn claim(State(state): State<AppState>, body: Bytes) -> Response { |
| 99 |
let events: Vec<WireEvent> = match serde_json::from_slice(&body) { |
| 100 |
Ok(v) => v, |
| 101 |
Err(e) => return (StatusCode::BAD_REQUEST, axum::Json(json!({ "title": format!("not a list of events: {e}") }))).into_response(), |
| 102 |
}; |
| 103 |
match state.daemon.claim(events).await { |
| 104 |
Ok(seqs) => axum::Json(json!({ "seqs": seqs })).into_response(), |
| 105 |
Err(e) => (StatusCode::FORBIDDEN, axum::Json(json!({ "title": format!("{e:#}") }))).into_response(), |
| 106 |
} |
| 107 |
} |
| 108 |
|
| 109 |
async fn not_allowed() -> StatusCode { |
| 110 |
StatusCode::METHOD_NOT_ALLOWED |
| 111 |
} |
| 112 |
|
| 113 |
|
| 114 |
pub fn proof_from(headers: &HeaderMap, method_path: &str, body: &[u8]) -> Option<Proof> { |
| 115 |
let get = |k: &str| headers.get(k).and_then(|v| v.to_str().ok()); |
| 116 |
let key = get("spectra-key")?; |
| 117 |
let time_ms = get("spectra-time")?.parse().ok()?; |
| 118 |
let nonce: [u8; 16] = hex::decode(get("spectra-nonce")?).ok()?.try_into().ok()?; |
| 119 |
let sig_bytes = hex::decode(get("spectra-signature")?).ok()?; |
| 120 |
let sig = oxy_core::cbor::decode(&sig_bytes).ok().and_then(|v| oxy_core::identity::Signature::from_value(&v).ok())?; |
| 121 |
if sig.key.to_hex() != key { |
| 122 |
return None; |
| 123 |
} |
| 124 |
Some(Proof { sig, method: method_path.into(), body_hash: *spectra_core::event::hash_body_plain(body), time_ms, nonce }) |
| 125 |
} |
| 126 |
|
| 127 |
|
| 128 |
pub fn origin_ok(headers: &HeaderMap, allowed: &[String]) -> bool { |
| 129 |
match headers.get("origin").and_then(|v| v.to_str().ok()) { |
| 130 |
None => true, |
| 131 |
Some(o) => allowed.iter().any(|a| a == o), |
| 132 |
} |
| 133 |
} |
| 134 |
|
| 135 |
|
| 136 |
pub async fn authenticate_why( |
| 137 |
state: &AppState, |
| 138 |
peer: PeerInfo, |
| 139 |
headers: &HeaderMap, |
| 140 |
method_path: &str, |
| 141 |
body: &[u8], |
| 142 |
) -> Result<Caller, (StatusCode, String)> { |
| 143 |
if !origin_ok(headers, &state.origins) { |
| 144 |
return Err((StatusCode::FORBIDDEN, "this origin may not call Spectra".into())); |
| 145 |
} |
| 146 |
let proof = proof_from(headers, method_path, body); |
| 147 |
state.daemon.authenticate(proof.as_ref(), method_path, body, peer.0).await.map_err(|why| { |
| 148 |
tracing::info!(%why, "refused a request"); |
| 149 |
|
| 150 |
|
| 151 |
(StatusCode::UNAUTHORIZED, why.to_string()) |
| 152 |
}) |
| 153 |
} |
| 154 |
|
| 155 |
|
| 156 |
pub async fn authenticate( |
| 157 |
state: &AppState, |
| 158 |
peer: PeerInfo, |
| 159 |
headers: &HeaderMap, |
| 160 |
method_path: &str, |
| 161 |
body: &[u8], |
| 162 |
) -> Result<Caller, Response> { |
| 163 |
authenticate_why(state, peer, headers, method_path, body).await.map_err(|(status, why)| { |
| 164 |
let (code, msg) = if status == StatusCode::FORBIDDEN { |
| 165 |
(protocol::INVALID_REQUEST, why) |
| 166 |
} else { |
| 167 |
(protocol::UNAUTHENTICATED, format!("not authenticated: {why}")) |
| 168 |
}; |
| 169 |
(status, axum::Json(protocol::error(&Json::Null, code, &msg))).into_response() |
| 170 |
}) |
| 171 |
} |
| 172 |
|
| 173 |
async fn mcp(State(state): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, body: Bytes) -> Response { |
| 174 |
let caller = match authenticate(&state, peer, &headers, "POST /mcp", &body).await { |
| 175 |
Ok(c) => c, |
| 176 |
Err(r) => return r, |
| 177 |
}; |
| 178 |
let req: Json = match serde_json::from_slice(&body) { |
| 179 |
Ok(v) => v, |
| 180 |
Err(e) => { |
| 181 |
return (StatusCode::BAD_REQUEST, axum::Json(protocol::error(&Json::Null, protocol::PARSE_ERROR, &format!("not JSON: {e}")))) |
| 182 |
.into_response() |
| 183 |
} |
| 184 |
}; |
| 185 |
let new_session = req.pointer("/params/arguments/new_session").and_then(Json::as_bool).unwrap_or(false) |
| 186 |
&& req.pointer("/params/name").and_then(Json::as_str) == Some("brief"); |
| 187 |
let session = state.daemon.touch(&caller.principal, new_session).await; |
| 188 |
let h = Headers { |
| 189 |
version: headers.get("mcp-protocol-version").and_then(|v| v.to_str().ok()), |
| 190 |
method: headers.get("mcp-method").and_then(|v| v.to_str().ok()), |
| 191 |
name: headers.get("mcp-name").and_then(|v| v.to_str().ok()), |
| 192 |
}; |
| 193 |
let version = h.version.unwrap_or("2025-03-26").to_string(); |
| 194 |
let ctx = Ctx { daemon: state.daemon.clone(), caller, session }; |
| 195 |
let meta = meta(&ctx).await; |
| 196 |
match protocol::handle(&ctx, &h, req, meta.clone()).await { |
| 197 |
Reply::Json(status, v) => (StatusCode::from_u16(status).unwrap_or(StatusCode::OK), axum::Json(v)).into_response(), |
| 198 |
Reply::Accepted => StatusCode::ACCEPTED.into_response(), |
| 199 |
Reply::Stream(id, tool, args) => { |
| 200 |
let fut = async move { |
| 201 |
let out = crate::tools::invoke(&ctx, tool, args).await; |
| 202 |
let meta = crate::http::meta(&ctx).await; |
| 203 |
let msg = protocol::stream_result(&id, &version, &out, meta); |
| 204 |
Ok::<_, Infallible>(SseEvent::default().event("message").data(msg.to_string())) |
| 205 |
}; |
| 206 |
sse(stream::once(fut).boxed()) |
| 207 |
} |
| 208 |
Reply::Listen(id, sub) => { |
| 209 |
let ack = json!({ "jsonrpc": "2.0", "method": "notifications/subscriptions/acknowledged", "params": { "_meta": { "io.modelcontextprotocol/subscriptionId": sub } } }); |
| 210 |
let _ = id; |
| 211 |
let first = stream::once(async move { Ok::<_, Infallible>(SseEvent::default().event("message").data(ack.to_string())) }); |
| 212 |
|
| 213 |
|
| 214 |
sse(first.chain(stream::pending()).boxed()) |
| 215 |
} |
| 216 |
} |
| 217 |
} |
| 218 |
|
| 219 |
fn sse(s: futures::stream::BoxStream<'static, Result<SseEvent, Infallible>>) -> Response { |
| 220 |
let mut r = Sse::new(s).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)).text("")).into_response(); |
| 221 |
r.headers_mut().insert("x-accel-buffering", "no".parse().unwrap()); |
| 222 |
r |
| 223 |
} |
| 224 |
|
| 225 |
|
| 226 |
|
| 227 |
pub async fn meta(ctx: &Ctx) -> Json { |
| 228 |
let mut m = serde_json::Map::new(); |
| 229 |
if let Ok(r) = spectra_core::rules::compile(&ctx.daemon.data) { |
| 230 |
m.insert("spectra/rules_version".into(), json!(r.version)); |
| 231 |
} |
| 232 |
if let Some(s) = &ctx.session { |
| 233 |
m.insert("spectra/session".into(), json!(s)); |
| 234 |
} |
| 235 |
if let Ok(n) = spectra_core::conversation::unread(&ctx.daemon.ledger, &ctx.caller.principal.actor()).await { |
| 236 |
m.insert("spectra/inbox_unread".into(), json!(n)); |
| 237 |
} |
| 238 |
Json::Object(m) |
| 239 |
} |
| 240 |
|
| 241 |
|
| 242 |
|
| 243 |
|
| 244 |
async fn owner_events( |
| 245 |
State(state): State<AppState>, |
| 246 |
ConnectInfo(peer): ConnectInfo<PeerInfo>, |
| 247 |
headers: HeaderMap, |
| 248 |
body: Bytes, |
| 249 |
) -> Response { |
| 250 |
let caller = match authenticate(&state, peer, &headers, "POST /api/v1/ledger/events", &body).await { |
| 251 |
Ok(c) => c, |
| 252 |
Err(r) => return r, |
| 253 |
}; |
| 254 |
if !matches!(caller.principal, Principal::Owner { .. }) { |
| 255 |
return (StatusCode::FORBIDDEN, axum::Json(json!({ "title": "only the owner hands events to the daemon" }))).into_response(); |
| 256 |
} |
| 257 |
let events: Vec<WireEvent> = match serde_json::from_slice(&body) { |
| 258 |
Ok(v) => v, |
| 259 |
Err(e) => return (StatusCode::BAD_REQUEST, axum::Json(json!({ "title": format!("not a list of events: {e}") }))).into_response(), |
| 260 |
}; |
| 261 |
match state.daemon.accept_owner_events(events).await { |
| 262 |
Ok(seqs) => axum::Json(json!({ "appended": seqs })).into_response(), |
| 263 |
Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, axum::Json(json!({ "title": e.to_string() }))).into_response(), |
| 264 |
} |
| 265 |
} |