OXYGENHUB

Coverage / crates/spectra-mcp/src/api.rs 91.7%

364 of 397 lines run, 63 of 140 functions · run #19 on main · run missed

1
//! The JSON API (spec §18): what spectra-vscode, scripts and SpectraHub
2
//! read and write. Every request is a signed request (spec §8.5); a read
3
//! may instead carry the owner's Radar session. Errors are RFC 7807
4
//! problems; lists page with `?after=`; reads carry an ETag.
5
6
use crate::http::{AppState, PeerInfo};
7
use axum::body::Bytes;
8
use axum::extract::connect_info::ConnectInfo;
9
use axum::extract::{Path, Query, State};
10
use axum::http::{header, HeaderMap, StatusCode};
11
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
12
use axum::response::{IntoResponse, Response};
13
use axum::routing::{get, post};
14
use axum::Router;
15
use futures::StreamExt;
16
use serde::Deserialize;
17
use spectra_api as api;
18
use spectra_core::authn::Caller;
19
use spectra_core::conversation::{self as conv, Author, Post, Posted, Signer};
20
use spectra_core::event::{Auth, Method};
21
use spectra_core::ids::now_ms;
22
use spectra_core::principals::Principal;
23
use std::time::Duration;
24
2514×
pub fn router(state: AppState) -> Router {
2614×
    Router::new()
2714×
        .route("/api/v1/health", get(health))
2814×
        .route("/api/v1/version", get(version))
2914×
        .route("/api/v1/brief", get(brief))
3014×
        .route("/api/v1/agents", get(agents))
3114×
        .route("/api/v1/projects", get(projects))
3214×
        .route("/api/v1/channels", get(channels))
3314×
        .route("/api/v1/channels/{name}/messages", get(messages).post(post_message))
3414×
        .route("/api/v1/threads/{id}", get(thread))
3514×
        .route("/api/v1/inbox", get(inbox))
3614×
        .route("/api/v1/inbox/ack", post(ack))
3714×
        .route("/api/v1/search", get(search))
3814×
        .route("/api/v1/events", get(events))
3914×
        .with_state(state)
4014×
}
41
42
/// An RFC 7807 problem.
434×
pub fn problem(status: StatusCode, title: &str, detail: Option<String>) -> Response {
444×
    let p = api::Problem { kind: "about:blank".into(), title: title.into(), status: status.as_u16(), detail };
454×
    let mut r = (status, axum::Json(p)).into_response();
464×
    r.headers_mut().insert(header::CONTENT_TYPE, "application/problem+json".parse().unwrap());
474×
    r
484×
}
49
501×
fn bad(e: anyhow::Error) -> Response {
511×
    problem(StatusCode::BAD_REQUEST, "the request cannot be done", Some(format!("{e:#}")))
521×
}
53
54
/// JSON with an ETag; 304 when the client has it already.
5515×
fn json_etag<T: serde::Serialize>(headers: &HeaderMap, v: &T) -> Response {
5615×
    let body = serde_json::to_vec(v).unwrap_or_default();
5715×
    let tag = format!("\"{}\"", &blake3_hex(&body)[..32]);
5815×
    if headers.get(header::IF_NONE_MATCH).and_then(|h| h.to_str().ok()) == Some(tag.as_str()) {
591×
        return StatusCode::NOT_MODIFIED.into_response();
6014×
    }
6114×
    ([(header::CONTENT_TYPE, "application/json".to_string()), (header::ETAG, tag)], body).into_response()
6215×
}
63
6415×
fn blake3_hex(b: &[u8]) -> String {
6515×
    hex::encode(*spectra_core::event::hash_body_plain(b))
6615×
}
67
68
/// Who is asking: a signed request, or for a read the owner's session.
6930×
async fn caller(
7030×
    st: &AppState,
7130×
    peer: PeerInfo,
7230×
    headers: &HeaderMap,
7330×
    method_path: &str,
7430×
    body: &[u8],
7530×
    read: bool,
7630×
) -> Result<Caller, Response> {
7730×
    if read && headers.get("spectra-key").is_none() {
781×
        let cookie = headers.get(header::COOKIE).and_then(|c| c.to_str().ok()).unwrap_or("");
791×
        let id = cookie.split(';').map(str::trim).find_map(|kv| kv.strip_prefix("spectra_session=")).unwrap_or("");
801×
        if let Some(s) = st.sessions.get(id, now_ms()) {
810×
            if !crate::http::origin_ok(headers, &st.origins) {
820×
                return Err(problem(StatusCode::FORBIDDEN, "this origin may not read Spectra", None));
830×
            }
840×
            let key = oxy_core::hash::ObjectRef(s.key);
850×
            let principal = st
860×
                .daemon
870×
                .principals
880×
                .read()
890×
                .await
900×
                .principal(&key, now_ms())
910×
                .map_err(|why| problem(StatusCode::UNAUTHORIZED, "not authenticated", Some(why)))?;
920×
            return Ok(Caller {
930×
                principal,
940×
                auth: Auth { key: Some(s.key), method: Method::Session, assurance: 2, proof: Some(s.proof) },
950×
                learn_uid: None,
960×
            });
971×
        }
9829×
    }
9930×
    let signed = headers.get("spectra-key").is_some();
10030×
    crate::http::authenticate_why(st, peer, headers, method_path, body).await.map_err(|(status, why)| {
1012×
        if status == StatusCode::FORBIDDEN {
1020×
            problem(status, "this origin may not call Spectra", None)
1032×
        } else if signed {
104
            // A signed request is told why its signature was not enough.
1051×
            problem(StatusCode::UNAUTHORIZED, "not authenticated", Some(why))
106
        } else {
1071×
            problem(
108
                StatusCode::UNAUTHORIZED,
1091×
                "not authenticated",
1101×
                Some("sign the request with your key (spectra's client does), or open Radar with `spectra open`".into()),
111
            )
112
        }
1132×
    })
11430×
}
115
11630×
fn path_of(uri: &axum::http::Uri) -> String {
11730×
    uri.path_and_query().map(|p| p.as_str().to_string()).unwrap_or_else(|| uri.path().to_string())
11830×
}
119
120
macro_rules! who {
121
    ($st:expr, $peer:expr, $headers:expr, $uri:expr, $method:literal, $body:expr, $read:expr) => {
122
        match caller(&$st, $peer, &$headers, &format!("{} {}", $method, path_of(&$uri)), $body, $read).await {
123
            Ok(c) => c,
124
            Err(r) => return r,
125
        }
126
    };
127
}
128
1291×
async fn health() -> Response {
1301×
    axum::Json(serde_json::json!({ "ok": true })).into_response()
1311×
}
132
1331×
async fn version() -> Response {
134
    axum::Json(api::Version {
1351×
        version: env!("CARGO_PKG_VERSION").into(),
136
        ledger_format: spectra_core::LEDGER_FORMAT,
1374×
        mcp_protocol_versions: crate::PROTOCOL_VERSIONS.iter().map(|v| v.to_string()).collect(),
138
    })
1391×
    .into_response()
1401×
}
141
1424×
async fn agent_list(st: &AppState) -> Vec<api::Agent> {
143
    #[allow(clippy::type_complexity)]
1444×
    let rows: Vec<(String, String, String, String, String, Option<String>, Option<i64>)> = sqlx::query_as(
1454×
        "SELECT a.name, a.owner_name, a.key, a.kind, a.project, p.status_text, a.disabled_ms FROM agents a LEFT JOIN presence p ON p.agent = a.name ORDER BY a.name",
1464×
    )
1474×
    .fetch_all(st.daemon.ledger.pool())
1484×
    .await
1494×
    .unwrap_or_default();
1504×
    let presence = st.daemon.presence.lock().await;
1514×
    let now = now_ms();
1524×
    rows.into_iter()
1534×
        .map(|(name, owner, key, kind, project, status, disabled)| api::Agent {
1543×
            display: format!("{name} · {owner}'s agent"),
1553×
            presence: if disabled.is_some() { "offline".into() } else { presence.state(&name, now).into() },
1563×
            disabled: disabled.is_some(),
1573×
            name,
1583×
            owner,
1593×
            key,
1603×
            kind,
1613×
            project,
1623×
            status,
1633×
        })
1644×
        .collect()
1654×
}
166
1674×
async fn agents(
1684×
    State(st): State<AppState>,
1694×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
1704×
    headers: HeaderMap,
1714×
    uri: axum::http::Uri,
1724×
) -> Response {
1734×
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
1742×
    json_etag(&headers, &agent_list(&st).await)
1754×
}
176
1773×
async fn projects(
1783×
    State(st): State<AppState>,
1793×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
1803×
    headers: HeaderMap,
1813×
    uri: axum::http::Uri,
1823×
) -> Response {
1833×
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
1843×
    let pool = st.daemon.ledger.pool();
1853×
    let rows: Vec<(String, String, Option<String>)> =
1863×
        sqlx::query_as("SELECT name, path, remote FROM projects ORDER BY name").fetch_all(pool).await.unwrap_or_default();
1873×
    let mut out = Vec::new();
1883×
    for (name, path, remote) in rows {
1893×
        let depends_on = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ? ORDER BY 1")
1903×
            .bind(&name)
1913×
            .fetch_all(pool)
1923×
            .await
1933×
            .unwrap_or_default();
1943×
        let depended_on_by = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ? ORDER BY 1")
1953×
            .bind(&name)
1963×
            .fetch_all(pool)
1973×
            .await
1983×
            .unwrap_or_default();
1993×
        out.push(api::Project { name, path, remote, depends_on, depended_on_by });
200
    }
2013×
    json_etag(&headers, &out)
2023×
}
203
2041×
async fn channels(
2051×
    State(st): State<AppState>,
2061×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
2071×
    headers: HeaderMap,
2081×
    uri: axum::http::Uri,
2091×
) -> Response {
2101×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
2111×
    let all = match conv::channels(&st.daemon.ledger).await {
2121×
        Ok(a) => a,
2130×
        Err(e) => return bad(e),
214
    };
2151×
    let mut out = Vec::new();
2164×
    for ch in all.into_iter().filter(|ch| conv::can_read(&c.principal, ch)) {
2174×
        let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE channel = ?")
2184×
            .bind(&ch.id)
2194×
            .fetch_one(st.daemon.ledger.pool())
2204×
            .await
2214×
            .unwrap_or(0);
2224×
        out.push(api::Channel { display: ch.display(), id: ch.id, name: ch.name, kind: ch.kind, purpose: ch.purpose, messages: n });
223
    }
2241×
    json_etag(&headers, &out)
2251×
}
226
2274×
async fn to_api(st: &AppState, m: conv::Message, ch: &str) -> api::Message {
228
    api::Message {
2294×
        author_display: conv::display_name(&st.daemon.ledger, &m.author).await,
2304×
        mentions: serde_json::from_str(&m.mentions).unwrap_or_default(),
2314×
        refs: serde_json::from_str(&m.refs).unwrap_or_default(),
2324×
        redacted: m.redacted.is_some(),
2334×
        id: m.id,
2344×
        channel: ch.to_string(),
2354×
        reply_to: m.reply_to,
2364×
        author: m.author,
2374×
        kind: m.kind,
2384×
        body: m.body,
2394×
        at_ms: m.at_ms,
2404×
        replies: m.replies,
241
    }
2424×
}
243
244
#[derive(Deserialize)]
245
struct PageQuery {
246
    after: Option<String>,
247
    limit: Option<u32>,
248
}
249
2501×
async fn messages(
2511×
    State(st): State<AppState>,
2521×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
2531×
    headers: HeaderMap,
2541×
    uri: axum::http::Uri,
2551×
    Path(name): Path<String>,
2561×
    Query(q): Query<PageQuery>,
2571×
) -> Response {
2581×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
2591×
    let limit = q.limit.unwrap_or(50).clamp(1, 500);
2601×
    match conv::read(&st.daemon.ledger, &c.principal, Some(&name), None, q.after.as_deref(), limit).await {
2611×
        Ok((ch, msgs)) => {
2621×
            let next = (msgs.len() as u32 == limit).then(|| msgs.last().map(|m| m.id.clone())).flatten();
2631×
            let mut items = Vec::new();
2642×
            for m in msgs {
2652×
                items.push(to_api(&st, m, &ch.display()).await);
266
            }
2671×
            json_etag(&headers, &api::Messages { channel: ch.display(), items, next })
268
        }
2690×
        Err(e) => problem(StatusCode::NOT_FOUND, "no such channel, or not yours to read", Some(format!("{e:#}"))),
270
    }
2711×
}
272
2731×
async fn thread(
2741×
    State(st): State<AppState>,
2751×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
2761×
    headers: HeaderMap,
2771×
    uri: axum::http::Uri,
2781×
    Path(id): Path<String>,
2791×
    Query(q): Query<PageQuery>,
2801×
) -> Response {
2811×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
2821×
    let limit = q.limit.unwrap_or(200).clamp(1, 500);
2831×
    match conv::read(&st.daemon.ledger, &c.principal, None, Some(&id), q.after.as_deref(), limit).await {
2841×
        Ok((ch, msgs)) => {
2851×
            let next = (msgs.len() as u32 == limit).then(|| msgs.last().map(|m| m.id.clone())).flatten();
2861×
            let mut items = Vec::new();
2872×
            for m in msgs {
2882×
                items.push(to_api(&st, m, &ch.display()).await);
289
            }
2901×
            json_etag(&headers, &api::Messages { channel: ch.display(), items, next })
291
        }
2920×
        Err(e) => problem(StatusCode::NOT_FOUND, "no such thread, or not yours to read", Some(format!("{e:#}"))),
293
    }
2941×
}
295
2969×
async fn post_message(
2979×
    State(st): State<AppState>,
2989×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
2999×
    headers: HeaderMap,
3009×
    uri: axum::http::Uri,
3019×
    Path(name): Path<String>,
3029×
    body: Bytes,
3039×
) -> Response {
3049×
    let c = who!(st, peer, headers, uri, "POST", &body, false);
3059×
    let n: api::NewMessage = match serde_json::from_slice(&body) {
3069×
        Ok(n) => n,
3070×
        Err(e) => return problem(StatusCode::BAD_REQUEST, "not a message", Some(e.to_string())),
308
    };
3099×
    let author = Author { principal: c.principal.clone(), signer: Signer::Request(c.auth.clone()) };
3109×
    let post = Post {
3119×
        channel: if n.reply_to.is_some() { None } else { Some(name) },
3129×
        body: n.body,
3139×
        reply_to: n.reply_to,
3149×
        kind: n.kind,
3159×
        ..Default::default()
316
    };
3179×
    match conv::post(&st.daemon.ledger, &author, post, now_ms()).await {
3188×
        Ok(Posted::Message { id, channel }) => {
3198×
            (StatusCode::CREATED, axum::Json(api::Posted { id, channel, duplicate: false })).into_response()
320
        }
3210×
        Ok(Posted::Duplicate { id }) => axum::Json(api::Posted { id, channel: String::new(), duplicate: true }).into_response(),
3221×
        Err(e) => bad(e),
323
    }
3249×
}
325
3264×
async fn inbox_items(st: &AppState, actor: &str) -> Vec<api::InboxItem> {
3274×
    conv::inbox(&st.daemon.ledger, actor, None)
3284×
        .await
3294×
        .unwrap_or_default()
3304×
        .into_iter()
3315×
        .map(|i| api::InboxItem { id: i.id, kind: i.kind, at_ms: i.at_ms, from: i.from, channel: i.channel, summary: i.summary })
3324×
        .collect()
3334×
}
334
3352×
async fn inbox(State(st): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, uri: axum::http::Uri) -> Response {
3362×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
3372×
    json_etag(&headers, &inbox_items(&st, &c.principal.actor()).await)
3382×
}
339
3401×
async fn ack(
3411×
    State(st): State<AppState>,
3421×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
3431×
    headers: HeaderMap,
3441×
    uri: axum::http::Uri,
3451×
    body: Bytes,
3461×
) -> Response {
3471×
    let c = who!(st, peer, headers, uri, "POST", &body, false);
3481×
    let a: api::Ack = match serde_json::from_slice(&body) {
3491×
        Ok(a) => a,
3500×
        Err(e) => return problem(StatusCode::BAD_REQUEST, "not an ack", Some(e.to_string())),
351
    };
3521×
    let author = Author { principal: c.principal, signer: Signer::Request(c.auth) };
3531×
    match conv::ack(&st.daemon.ledger, &author, &a.ids, a.reaction.as_deref()).await {
3541×
        Ok(n) => axum::Json(serde_json::json!({ "read": n })).into_response(),
3550×
        Err(e) => bad(e),
356
    }
3571×
}
358
3592×
async fn brief(State(st): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, uri: axum::http::Uri) -> Response {
3602×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
3612×
    let project = match &c.principal {
3621×
        Principal::Agent { project, .. } => Some(project.clone()),
3631×
        _ => None,
364
    };
3652×
    let rules = spectra_core::rules::compile(&st.daemon.data).map(|r| r.version).unwrap_or_default();
3662×
    let inbox = inbox_items(&st, &c.principal.actor()).await;
3672×
    let b = api::Brief {
3682×
        you: c.principal.display(),
3692×
        actor: c.principal.actor(),
3702×
        project,
3712×
        rules_version: rules,
3722×
        unread: inbox.len() as i64,
3732×
        inbox,
3742×
        agents: agent_list(&st).await,
3752×
        head: st.daemon.ledger.head().await.0,
376
    };
3772×
    json_etag(&headers, &b)
3782×
}
379
380
#[derive(Deserialize)]
381
struct SearchQuery {
382
    q: String,
383
    channel: Option<String>,
384
    author: Option<String>,
385
    since: Option<String>,
386
    limit: Option<u32>,
387
}
388
3891×
async fn search(
3901×
    State(st): State<AppState>,
3911×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
3921×
    headers: HeaderMap,
3931×
    uri: axum::http::Uri,
3941×
    Query(q): Query<SearchQuery>,
3951×
) -> Response {
3961×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
3971×
    let since = q.since.as_deref().and_then(|s| crate::tools::parse_since(s, now_ms()));
3981×
    match conv::search(&st.daemon.ledger, &c.principal, &q.q, q.channel.as_deref(), q.author.as_deref(), since, q.limit.unwrap_or(20)).await
399
    {
4001×
        Ok(hits) => {
4011×
            let out: Vec<api::Hit> = hits
4021×
                .into_iter()
4033×
                .map(|h| api::Hit { id: h.id, channel: h.channel, author: h.author, at_ms: h.at_ms, snippet: h.snippet, thread: h.thread })
4041×
                .collect();
4051×
            json_etag(&headers, &out)
406
        }
4070×
        Err(e) => bad(e),
408
    }
4091×
}
410
411
#[derive(Deserialize)]
412
struct EventsQuery {
413
    after: Option<u64>,
414
    kind: Option<String>,
415
    limit: Option<u32>,
416
    follow: Option<u8>,
417
    /// With `follow`, end the stream after this many events.
418
    max: Option<usize>,
419
}
420
42113×
fn summary(e: &spectra_core::event::Event) -> api::EventSummary {
42213×
    api::EventSummary {
42313×
        seq: e.seq,
42413×
        id: e.id.clone(),
42513×
        at_ms: e.at_ms,
42613×
        kind: e.kind.clone(),
42713×
        actor: e.actor.clone(),
42813×
        subject: e.subject.clone(),
42913×
        method: e.auth.method.name().into(),
43013×
        assurance: e.auth.assurance,
43113×
        hash: hex::encode(e.hash),
43213×
    }
43313×
}
434
435
/// The log as summaries; `follow=1` streams new ones as SSE instead.
4365×
async fn events(
4375×
    State(st): State<AppState>,
4385×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
4395×
    headers: HeaderMap,
4405×
    uri: axum::http::Uri,
4415×
    Query(q): Query<EventsQuery>,
4425×
) -> Response {
4435×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
444
    // The log is the owner's to read; an agent reads what concerns it
445
    // through the conversation and its inbox.
4465×
    if !matches!(c.principal, Principal::Owner { .. }) {
4471×
        return problem(StatusCode::FORBIDDEN, "the event log is the owner's", None);
4484×
    }
4494×
    if q.follow == Some(1) {
4502×
        let kind = q.kind.clone();
4512×
        let rx = st.daemon.ledger.subscribe();
4522×
        let stream = tokio_stream::wrappers::BroadcastStream::new(rx)
4534×
            .filter_map(move |e| {
4544×
                let kind = kind.clone();
4554×
                async move {
4564×
                    let e = e.ok()?;
4574×
                    if kind.as_ref().is_some_and(|k| *k != e.kind) {
4582×
                        return None;
4592×
                    }
4602×
                    Some(Ok::<_, std::convert::Infallible>(
4612×
                        SseEvent::default().data(serde_json::to_string(&summary(&e)).unwrap_or_default()),
4622×
                    ))
4634×
                }
4644×
            })
4652×
            .take(q.max.unwrap_or(usize::MAX));
4662×
        return Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response();
4672×
    }
4682×
    let limit = q.limit.unwrap_or(100).clamp(1, 1000);
4692×
    let from = q.after.map(|a| a + 1).unwrap_or(1);
4702×
    let evs = match &q.kind {
4711×
        Some(k) => st.daemon.ledger.events_of_kinds(from, &[k.as_str()], limit).await,
4721×
        None => st.daemon.ledger.events(from, limit).await,
473
    };
4742×
    match evs {
4752×
        Ok(evs) => {
4762×
            let next = (evs.len() as u32 == limit).then(|| evs.last().map(|e| e.seq)).flatten();
4772×
            json_etag(&headers, &api::Events { items: evs.iter().map(summary).collect(), next })
478
        }
4790×
        Err(e) => bad(e),
480
    }
4815×
}