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