OXYGENHUB

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

460 of 482 lines run, 73 of 156 functions · run #34 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::ids::now_ms;
21
use spectra_core::principals::Principal;
22
use std::time::Duration;
23
2422×
pub fn router(state: AppState) -> Router {
2522×
    Router::new()
2622×
        .route("/api/v1/health", get(health))
2722×
        .route("/api/v1/version", get(version))
2822×
        .route("/api/v1/brief", get(brief))
2922×
        .route("/api/v1/agents", get(agents))
3022×
        .route("/api/v1/projects", get(projects))
3122×
        .route("/api/v1/channels", get(channels))
3222×
        .route("/api/v1/channels/{name}/messages", get(messages).post(post_message))
3322×
        .route("/api/v1/threads/{id}", get(thread))
3422×
        .route("/api/v1/inbox", get(inbox))
3522×
        .route("/api/v1/inbox/ack", post(ack))
3622×
        .route("/api/v1/search", get(search))
3722×
        .route("/api/v1/events", get(events))
3822×
        .route("/api/v1/admin/verify", get(admin_verify))
3922×
        .route("/api/v1/briefs", get(briefs).put(set_brief))
4022×
        .with_state(state)
4122×
}
42
43
/// An RFC 7807 problem.
446×
pub fn problem(status: StatusCode, title: &str, detail: Option<String>) -> Response {
456×
    let p = api::Problem { kind: "about:blank".into(), title: title.into(), status: status.as_u16(), detail };
466×
    let mut r = (status, axum::Json(p)).into_response();
476×
    r.headers_mut().insert(header::CONTENT_TYPE, "application/problem+json".parse().unwrap());
486×
    r
496×
}
50
511×
fn bad(e: anyhow::Error) -> Response {
521×
    problem(StatusCode::BAD_REQUEST, "the request cannot be done", Some(format!("{e:#}")))
531×
}
54
55
/// JSON with an ETag; 304 when the client has it already.
5647×
fn json_etag<T: serde::Serialize>(headers: &HeaderMap, v: &T) -> Response {
5747×
    let body = serde_json::to_vec(v).unwrap_or_default();
5847×
    let tag = format!("\"{}\"", &blake3_hex(&body)[..32]);
5947×
    if headers.get(header::IF_NONE_MATCH).and_then(|h| h.to_str().ok()) == Some(tag.as_str()) {
601×
        return StatusCode::NOT_MODIFIED.into_response();
6146×
    }
6246×
    ([(header::CONTENT_TYPE, "application/json".to_string()), (header::ETAG, tag)], body).into_response()
6347×
}
64
6547×
fn blake3_hex(b: &[u8]) -> String {
6647×
    hex::encode(*spectra_core::event::hash_body_plain(b))
6747×
}
68
69
/// Who is asking: every request is signed (spec §8.5).
7068×
async fn caller(
7168×
    st: &AppState,
7268×
    peer: PeerInfo,
7368×
    headers: &HeaderMap,
7468×
    method_path: &str,
7568×
    body: &[u8],
7668×
    read: bool,
7768×
) -> Result<Caller, Response> {
7868×
    let _ = read;
7968×
    let signed = headers.get("spectra-key").is_some();
8068×
    crate::http::authenticate_why(st, peer, headers, method_path, body).await.map_err(|(status, why)| {
812×
        if status == StatusCode::FORBIDDEN {
820×
            problem(status, "this origin may not call Spectra", None)
832×
        } else if signed {
84
            // A signed request is told why its signature was not enough.
851×
            problem(StatusCode::UNAUTHORIZED, "not authenticated", Some(why))
86
        } else {
871×
            problem(
88
                StatusCode::UNAUTHORIZED,
891×
                "not authenticated",
901×
                Some("sign the request with your key: spectra's own client, its shims and `spectra bridge` do".into()),
91
            )
92
        }
932×
    })
9468×
}
95
9668×
fn path_of(uri: &axum::http::Uri) -> String {
9768×
    uri.path_and_query().map(|p| p.as_str().to_string()).unwrap_or_else(|| uri.path().to_string())
9868×
}
99
100
macro_rules! who {
101
    ($st:expr, $peer:expr, $headers:expr, $uri:expr, $method:literal, $body:expr, $read:expr) => {
102
        match caller(&$st, $peer, &$headers, &format!("{} {}", $method, path_of(&$uri)), $body, $read).await {
103
            Ok(c) => c,
104
            Err(r) => return r,
105
        }
106
    };
107
}
108
109
/// `GET /api/v1/admin/verify`: the owner asks the daemon to verify its
110
/// ledger (spec §8.6): what `spectra admin verify` prints, from afar.
1111×
async fn admin_verify(
1121×
    State(st): State<AppState>,
1131×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
1141×
    headers: HeaderMap,
1151×
    uri: axum::http::Uri,
1161×
) -> Response {
1171×
    let c = who!(st, peer, headers, uri, "GET", b"", false);
1181×
    if !matches!(c.principal, Principal::Owner { .. }) {
1190×
        return problem(StatusCode::FORBIDDEN, "only the owner verifies the ledger", None);
1201×
    }
1211×
    let ledger = match spectra_core::ledger::Ledger::open_read_only(&st.daemon.data.join("spectra.db")).await {
1221×
        Ok(l) => l,
1230×
        Err(e) => return bad(e),
124
    };
1251×
    match ledger.verify(&mut spectra_core::principals::Principals::default()).await {
1261×
        Ok(v) => axum::Json(v).into_response(),
1270×
        Err(e) => bad(e),
128
    }
1291×
}
130
131
/// `GET /api/v1/briefs`: the overall brief and every project's (spec §12.1).
1323×
async fn briefs(
1333×
    State(st): State<AppState>,
1343×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
1353×
    headers: HeaderMap,
1363×
    uri: axum::http::Uri,
1373×
) -> Response {
1383×
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
1393×
    let pool = st.daemon.ledger.pool();
1403×
    let overall = spectra_core::rules::layer(pool, None).await.ok().flatten();
1413×
    let rows: Vec<(String, String)> = sqlx::query_as("SELECT project, text FROM briefs WHERE layer = 'project' ORDER BY project")
1423×
        .fetch_all(pool)
1433×
        .await
1443×
        .unwrap_or_default();
1453×
    json_etag(
1463×
        &headers,
1474×
        &api::Briefs { overall, projects: rows.into_iter().map(|(project, text)| api::ProjectBrief { project, text }).collect() },
148
    )
1493×
}
150
151
/// `PUT /api/v1/briefs`: the owner sets a brief; the signed request is the
152
/// event's proof.
1534×
async fn set_brief(
1544×
    State(st): State<AppState>,
1554×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
1564×
    headers: HeaderMap,
1574×
    uri: axum::http::Uri,
1584×
    body: Bytes,
1594×
) -> Response {
1604×
    let c = who!(st, peer, headers, uri, "PUT", &body, false);
1614×
    if !matches!(c.principal, Principal::Owner { .. }) {
1621×
        return problem(StatusCode::FORBIDDEN, "only the owner sets a brief", None);
1633×
    }
1643×
    let b: api::SetBrief = match serde_json::from_slice(&body) {
1653×
        Ok(b) => b,
1660×
        Err(e) => return problem(StatusCode::BAD_REQUEST, "not a brief", Some(e.to_string())),
167
    };
1683×
    if let Err(e) = spectra_core::rules::check(&b.text) {
1690×
        return problem(StatusCode::BAD_REQUEST, "too long", Some(e.to_string()));
1703×
    }
1713×
    let project = b.project.filter(|p| !p.is_empty());
1723×
    if let Some(p) = &project {
1732×
        let known: Option<String> = sqlx::query_scalar("SELECT name FROM projects WHERE name = ?")
1742×
            .bind(p)
1752×
            .fetch_optional(st.daemon.ledger.pool())
1762×
            .await
1772×
            .unwrap_or(None);
1782×
        if known.is_none() {
1791×
            return problem(StatusCode::NOT_FOUND, "no such project", Some(p.clone()));
1801×
        }
1811×
    }
1822×
    let mut e = spectra_core::event::NewEvent::system("brief.set", serde_json::json!({ "project": project, "text": b.text }))
1832×
        .by(c.principal.actor(), c.auth.clone());
1842×
    e.project = project.clone();
1852×
    match st.daemon.ledger.append_one(e).await {
1862×
        Ok(ev) => axum::Json(serde_json::json!({ "seq": ev.seq })).into_response(),
1870×
        Err(e) => bad(e),
188
    }
1894×
}
190
1911×
async fn health() -> Response {
1921×
    axum::Json(serde_json::json!({ "ok": true })).into_response()
1931×
}
194
1951×
async fn version() -> Response {
196
    axum::Json(api::Version {
1971×
        version: env!("CARGO_PKG_VERSION").into(),
198
        ledger_format: spectra_core::LEDGER_FORMAT,
1994×
        mcp_protocol_versions: crate::PROTOCOL_VERSIONS.iter().map(|v| v.to_string()).collect(),
200
    })
2011×
    .into_response()
2021×
}
203
20416×
async fn agent_list(st: &AppState) -> Vec<api::Agent> {
205
    #[allow(clippy::type_complexity)]
20616×
    let rows: Vec<(String, String, String, String, String, Option<String>, Option<i64>)> = sqlx::query_as(
20716×
        "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",
20816×
    )
20916×
    .fetch_all(st.daemon.ledger.pool())
21016×
    .await
21116×
    .unwrap_or_default();
21216×
    let presence = st.daemon.presence.lock().await;
21316×
    let principals = st.daemon.principals.read().await;
21416×
    let now = now_ms();
21516×
    rows.into_iter()
21618×
        .map(|(name, owner, key, kind, project, status, disabled)| {
21718×
            let k = spectra_core::identity::KeyId::from_hex(&key);
21818×
            let state = match k.map(|k| principals.principal(&k, now)) {
21917×
                Some(Ok(_)) => "ok".to_string(),
2201×
                Some(Err(why)) => why,
2210×
                None => "a malformed key".into(),
222
            };
22318×
            let delegation_expires = k.and_then(|k| principals.owner().and_then(|o| o.delegate(&k)).and_then(|d| d.expires));
224
            api::Agent {
22518×
                display: format!("{name} · {owner}'s agent"),
22618×
                presence: if disabled.is_some() { "offline".into() } else { presence.state(&name, now).into() },
22718×
                disabled: disabled.is_some(),
22818×
                name,
22918×
                owner,
23018×
                key,
23118×
                kind,
23218×
                project,
23318×
                status,
23418×
                state,
23518×
                delegation_expires,
236
            }
23718×
        })
23816×
        .collect()
23916×
}
240
24114×
async fn agents(
24214×
    State(st): State<AppState>,
24314×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
24414×
    headers: HeaderMap,
24514×
    uri: axum::http::Uri,
24614×
) -> Response {
24714×
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
24812×
    json_etag(&headers, &agent_list(&st).await)
24914×
}
250
25116×
async fn projects(
25216×
    State(st): State<AppState>,
25316×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
25416×
    headers: HeaderMap,
25516×
    uri: axum::http::Uri,
25616×
) -> Response {
25716×
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
25816×
    let pool = st.daemon.ledger.pool();
25916×
    let rows: Vec<(String, Option<String>, String, Option<String>)> =
26016×
        sqlx::query_as("SELECT name, path, description, url FROM projects ORDER BY name").fetch_all(pool).await.unwrap_or_default();
26116×
    let mut out = Vec::new();
26219×
    for (name, path, description, url) in rows {
26319×
        let depends_on = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ? ORDER BY 1")
26419×
            .bind(&name)
26519×
            .fetch_all(pool)
26619×
            .await
26719×
            .unwrap_or_default();
26819×
        let depended_on_by = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ? ORDER BY 1")
26919×
            .bind(&name)
27019×
            .fetch_all(pool)
27119×
            .await
27219×
            .unwrap_or_default();
27319×
        let agents = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name")
27419×
            .bind(&name)
27519×
            .fetch_all(pool)
27619×
            .await
27719×
            .unwrap_or_default();
27819×
        out.push(api::Project { name, path, description, url, depends_on, depended_on_by, agents });
279
    }
28016×
    json_etag(&headers, &out)
28116×
}
282
2831×
async fn channels(
2841×
    State(st): State<AppState>,
2851×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
2861×
    headers: HeaderMap,
2871×
    uri: axum::http::Uri,
2881×
) -> Response {
2891×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
2901×
    let all = match conv::channels(&st.daemon.ledger).await {
2911×
        Ok(a) => a,
2920×
        Err(e) => return bad(e),
293
    };
2941×
    let mut out = Vec::new();
2954×
    for ch in all.into_iter().filter(|ch| conv::can_read(&c.principal, ch)) {
2964×
        let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE channel = ?")
2974×
            .bind(&ch.id)
2984×
            .fetch_one(st.daemon.ledger.pool())
2994×
            .await
3004×
            .unwrap_or(0);
3014×
        out.push(api::Channel { display: ch.display(), id: ch.id, name: ch.name, kind: ch.kind, purpose: ch.purpose, messages: n });
302
    }
3031×
    json_etag(&headers, &out)
3041×
}
305
3064×
async fn to_api(st: &AppState, m: conv::Message, ch: &str) -> api::Message {
307
    api::Message {
3084×
        author_display: conv::display_name(&st.daemon.ledger, &m.author).await,
3094×
        mentions: serde_json::from_str(&m.mentions).unwrap_or_default(),
3104×
        refs: serde_json::from_str(&m.refs).unwrap_or_default(),
3114×
        redacted: m.redacted.is_some(),
3124×
        id: m.id,
3134×
        channel: ch.to_string(),
3144×
        reply_to: m.reply_to,
3154×
        author: m.author,
3164×
        kind: m.kind,
3174×
        body: m.body,
3184×
        at_ms: m.at_ms,
3194×
        replies: m.replies,
320
    }
3214×
}
322
323
#[derive(Deserialize)]
324
struct PageQuery {
325
    after: Option<String>,
326
    limit: Option<u32>,
327
}
328
3291×
async fn messages(
3301×
    State(st): State<AppState>,
3311×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
3321×
    headers: HeaderMap,
3331×
    uri: axum::http::Uri,
3341×
    Path(name): Path<String>,
3351×
    Query(q): Query<PageQuery>,
3361×
) -> Response {
3371×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
3381×
    let limit = q.limit.unwrap_or(50).clamp(1, 500);
3391×
    match conv::read(&st.daemon.ledger, &c.principal, Some(&name), None, q.after.as_deref(), limit).await {
3401×
        Ok((ch, msgs)) => {
3411×
            let next = (msgs.len() as u32 == limit).then(|| msgs.last().map(|m| m.id.clone())).flatten();
3421×
            let mut items = Vec::new();
3432×
            for m in msgs {
3442×
                items.push(to_api(&st, m, &ch.display()).await);
345
            }
3461×
            json_etag(&headers, &api::Messages { channel: ch.display(), items, next })
347
        }
3480×
        Err(e) => problem(StatusCode::NOT_FOUND, "no such channel, or not yours to read", Some(format!("{e:#}"))),
349
    }
3501×
}
351
3521×
async fn thread(
3531×
    State(st): State<AppState>,
3541×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
3551×
    headers: HeaderMap,
3561×
    uri: axum::http::Uri,
3571×
    Path(id): Path<String>,
3581×
    Query(q): Query<PageQuery>,
3591×
) -> Response {
3601×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
3611×
    let limit = q.limit.unwrap_or(200).clamp(1, 500);
3621×
    match conv::read(&st.daemon.ledger, &c.principal, None, Some(&id), q.after.as_deref(), limit).await {
3631×
        Ok((ch, msgs)) => {
3641×
            let next = (msgs.len() as u32 == limit).then(|| msgs.last().map(|m| m.id.clone())).flatten();
3651×
            let mut items = Vec::new();
3662×
            for m in msgs {
3672×
                items.push(to_api(&st, m, &ch.display()).await);
368
            }
3691×
            json_etag(&headers, &api::Messages { channel: ch.display(), items, next })
370
        }
3710×
        Err(e) => problem(StatusCode::NOT_FOUND, "no such thread, or not yours to read", Some(format!("{e:#}"))),
372
    }
3731×
}
374
37510×
async fn post_message(
37610×
    State(st): State<AppState>,
37710×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
37810×
    headers: HeaderMap,
37910×
    uri: axum::http::Uri,
38010×
    Path(name): Path<String>,
38110×
    body: Bytes,
38210×
) -> Response {
38310×
    let c = who!(st, peer, headers, uri, "POST", &body, false);
38410×
    let n: api::NewMessage = match serde_json::from_slice(&body) {
38510×
        Ok(n) => n,
3860×
        Err(e) => return problem(StatusCode::BAD_REQUEST, "not a message", Some(e.to_string())),
387
    };
38810×
    let author = Author { principal: c.principal.clone(), signer: Signer::Request(c.auth.clone()) };
38910×
    let post = Post {
39010×
        channel: if n.reply_to.is_some() { None } else { Some(name) },
39110×
        body: n.body,
39210×
        reply_to: n.reply_to,
39310×
        kind: n.kind,
39410×
        ..Default::default()
395
    };
39610×
    match conv::post(&st.daemon.ledger, &author, post, now_ms()).await {
3979×
        Ok(Posted::Message { id, channel }) => {
3989×
            (StatusCode::CREATED, axum::Json(api::Posted { id, channel, duplicate: false })).into_response()
399
        }
4000×
        Ok(Posted::Duplicate { id }) => axum::Json(api::Posted { id, channel: String::new(), duplicate: true }).into_response(),
4011×
        Err(e) => bad(e),
402
    }
40310×
}
404
4059×
async fn inbox_items(st: &AppState, actor: &str) -> Vec<api::InboxItem> {
4069×
    let mut out = Vec::new();
4079×
    for i in conv::inbox(&st.daemon.ledger, actor, None).await.unwrap_or_default() {
4086×
        let from_display = conv::display_name(&st.daemon.ledger, &i.from).await;
4096×
        out.push(api::InboxItem {
4106×
            id: i.id,
4116×
            kind: i.kind,
4126×
            at_ms: i.at_ms,
4136×
            from: i.from,
4146×
            from_display,
4156×
            channel: i.channel,
4166×
            summary: i.summary,
4176×
        });
418
    }
4199×
    out
4209×
}
421
4225×
async fn inbox(State(st): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, uri: axum::http::Uri) -> Response {
4235×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
4245×
    json_etag(&headers, &inbox_items(&st, &c.principal.actor()).await)
4255×
}
426
4271×
async fn ack(
4281×
    State(st): State<AppState>,
4291×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
4301×
    headers: HeaderMap,
4311×
    uri: axum::http::Uri,
4321×
    body: Bytes,
4331×
) -> Response {
4341×
    let c = who!(st, peer, headers, uri, "POST", &body, false);
4351×
    let a: api::Ack = match serde_json::from_slice(&body) {
4361×
        Ok(a) => a,
4370×
        Err(e) => return problem(StatusCode::BAD_REQUEST, "not an ack", Some(e.to_string())),
438
    };
4391×
    let author = Author { principal: c.principal, signer: Signer::Request(c.auth) };
4401×
    match conv::ack(&st.daemon.ledger, &author, &a.ids, a.reaction.as_deref()).await {
4411×
        Ok(n) => axum::Json(serde_json::json!({ "read": n })).into_response(),
4420×
        Err(e) => bad(e),
443
    }
4441×
}
445
4464×
async fn brief(State(st): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, uri: axum::http::Uri) -> Response {
4474×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
4484×
    let project = match &c.principal {
4491×
        Principal::Agent { project, .. } => Some(project.clone()),
4503×
        _ => None,
451
    };
4524×
    let rules = spectra_core::rules::compile(st.daemon.ledger.pool(), project.as_deref()).await.map(|r| r.version).unwrap_or_default();
4534×
    let inbox = inbox_items(&st, &c.principal.actor()).await;
4544×
    let b = api::Brief {
4554×
        you: c.principal.display(),
4564×
        actor: c.principal.actor(),
4574×
        project,
4584×
        rules_version: rules,
4594×
        unread: inbox.len() as i64,
4604×
        inbox,
4614×
        agents: agent_list(&st).await,
4624×
        head: st.daemon.ledger.head().await.0,
463
    };
4644×
    json_etag(&headers, &b)
4654×
}
466
467
#[derive(Deserialize)]
468
struct SearchQuery {
469
    q: String,
470
    channel: Option<String>,
471
    author: Option<String>,
472
    since: Option<String>,
473
    limit: Option<u32>,
474
}
475
4762×
async fn search(
4772×
    State(st): State<AppState>,
4782×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
4792×
    headers: HeaderMap,
4802×
    uri: axum::http::Uri,
4812×
    Query(q): Query<SearchQuery>,
4822×
) -> Response {
4832×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
4842×
    let since = q.since.as_deref().and_then(|s| crate::tools::parse_since(s, now_ms()));
4852×
    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
486
    {
4872×
        Ok(hits) => {
4882×
            let mut out: Vec<api::Hit> = Vec::new();
4894×
            for h in hits {
4904×
                let author_display = conv::display_name(&st.daemon.ledger, &h.author).await;
4914×
                out.push(api::Hit {
4924×
                    id: h.id,
4934×
                    channel: h.channel,
4944×
                    author: h.author,
4954×
                    at_ms: h.at_ms,
4964×
                    snippet: h.snippet,
4974×
                    thread: h.thread,
4984×
                    author_display,
4994×
                });
500
            }
5012×
            json_etag(&headers, &out)
502
        }
5030×
        Err(e) => bad(e),
504
    }
5052×
}
506
507
#[derive(Deserialize)]
508
struct EventsQuery {
509
    after: Option<u64>,
510
    kind: Option<String>,
511
    limit: Option<u32>,
512
    follow: Option<u8>,
513
    /// With `follow`, end the stream after this many events.
514
    max: Option<usize>,
515
}
516
51713×
fn summary(e: &spectra_core::event::Event) -> api::EventSummary {
51813×
    api::EventSummary {
51913×
        seq: e.seq,
52013×
        id: e.id.clone(),
52113×
        at_ms: e.at_ms,
52213×
        kind: e.kind.clone(),
52313×
        actor: e.actor.clone(),
52413×
        subject: e.subject.clone(),
52513×
        method: e.auth.method.name().into(),
52613×
        assurance: e.auth.assurance,
52713×
        hash: hex::encode(e.hash),
52813×
    }
52913×
}
530
531
/// The log as summaries; `follow=1` streams new ones as SSE instead.
5325×
async fn events(
5335×
    State(st): State<AppState>,
5345×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
5355×
    headers: HeaderMap,
5365×
    uri: axum::http::Uri,
5375×
    Query(q): Query<EventsQuery>,
5385×
) -> Response {
5395×
    let c = who!(st, peer, headers, uri, "GET", b"", true);
540
    // The log is the owner's to read; an agent reads what concerns it
541
    // through the conversation and its inbox.
5425×
    if !matches!(c.principal, Principal::Owner { .. }) {
5431×
        return problem(StatusCode::FORBIDDEN, "the event log is the owner's", None);
5444×
    }
5454×
    if q.follow == Some(1) {
5462×
        let kind = q.kind.clone();
5472×
        let rx = st.daemon.ledger.subscribe();
5482×
        let stream = tokio_stream::wrappers::BroadcastStream::new(rx)
5494×
            .filter_map(move |e| {
5504×
                let kind = kind.clone();
5514×
                async move {
5524×
                    let e = e.ok()?;
5534×
                    if kind.as_ref().is_some_and(|k| *k != e.kind) {
5542×
                        return None;
5552×
                    }
5562×
                    Some(Ok::<_, std::convert::Infallible>(
5572×
                        SseEvent::default().data(serde_json::to_string(&summary(&e)).unwrap_or_default()),
5582×
                    ))
5594×
                }
5604×
            })
5612×
            .take(q.max.unwrap_or(usize::MAX));
562
        // A comment first: the follower knows it is subscribed, so it can
563
        // read the state and miss nothing that happens after.
5642×
        let hello = futures::stream::once(async { Ok::<_, std::convert::Infallible>(SseEvent::default().comment("subscribed")) });
5652×
        return Sse::new(hello.chain(stream)).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response();
5662×
    }
5672×
    let limit = q.limit.unwrap_or(100).clamp(1, 1000);
5682×
    let from = q.after.map(|a| a + 1).unwrap_or(1);
5692×
    let evs = match &q.kind {
5701×
        Some(k) => st.daemon.ledger.events_of_kinds(from, &[k.as_str()], limit).await,
5711×
        None => st.daemon.ledger.events(from, limit).await,
572
    };
5732×
    match evs {
5742×
        Ok(evs) => {
5752×
            let next = (evs.len() as u32 == limit).then(|| evs.last().map(|e| e.seq)).flatten();
5762×
            json_etag(&headers, &api::Events { items: evs.iter().map(summary).collect(), next })
577
        }
5780×
        Err(e) => bad(e),
579
    }
5805×
}