OXYGENHUB
◆ main spectra / crates / spectra-mcp / src / api.rs
22.0 KiB · Rust · last changed The API reports each agent's extra channels 1 day ago source blame history raw
e071e91a Andy McCall ↶ 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
77df591d Andy McCall ↶ 6
use crate::http::{AppState, PeerInfo};
e071e91a Andy McCall ↶ 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
24
pub fn router(state: AppState) -> Router {
25
    Router::new()
26
        .route("/api/v1/health", get(health))
27
        .route("/api/v1/version", get(version))
28
        .route("/api/v1/brief", get(brief))
29
        .route("/api/v1/agents", get(agents))
30
        .route("/api/v1/projects", get(projects))
31
        .route("/api/v1/channels", get(channels))
32
        .route("/api/v1/channels/{name}/messages", get(messages).post(post_message))
33
        .route("/api/v1/threads/{id}", get(thread))
34
        .route("/api/v1/inbox", get(inbox))
35
        .route("/api/v1/inbox/ack", post(ack))
36
        .route("/api/v1/search", get(search))
37
        .route("/api/v1/events", get(events))
67bfa06a Andy McCall ↶ 38
        .route("/api/v1/admin/verify", get(admin_verify))
b044d909 Andy McCall ↶ 39
        .route("/api/v1/briefs", get(briefs).put(set_brief))
e071e91a Andy McCall ↶ 40
        .with_state(state)
41
}
42
43
/// An RFC 7807 problem.
44
pub fn problem(status: StatusCode, title: &str, detail: Option<String>) -> Response {
45
    let p = api::Problem { kind: "about:blank".into(), title: title.into(), status: status.as_u16(), detail };
46
    let mut r = (status, axum::Json(p)).into_response();
47
    r.headers_mut().insert(header::CONTENT_TYPE, "application/problem+json".parse().unwrap());
48
    r
49
}
50
51
fn bad(e: anyhow::Error) -> Response {
52
    problem(StatusCode::BAD_REQUEST, "the request cannot be done", Some(format!("{e:#}")))
53
}
54
55
/// JSON with an ETag; 304 when the client has it already.
56
fn json_etag<T: serde::Serialize>(headers: &HeaderMap, v: &T) -> Response {
57
    let body = serde_json::to_vec(v).unwrap_or_default();
58
    let tag = format!("\"{}\"", &blake3_hex(&body)[..32]);
59
    if headers.get(header::IF_NONE_MATCH).and_then(|h| h.to_str().ok()) == Some(tag.as_str()) {
60
        return StatusCode::NOT_MODIFIED.into_response();
61
    }
62
    ([(header::CONTENT_TYPE, "application/json".to_string()), (header::ETAG, tag)], body).into_response()
63
}
64
65
fn blake3_hex(b: &[u8]) -> String {
66
    hex::encode(*spectra_core::event::hash_body_plain(b))
67
}
68
862f0db2 Andy McCall ↶ 69
/// Who is asking: every request is signed (spec §8.5).
e071e91a Andy McCall ↶ 70
async fn caller(
71
    st: &AppState,
72
    peer: PeerInfo,
73
    headers: &HeaderMap,
74
    method_path: &str,
75
    body: &[u8],
76
    read: bool,
77
) -> Result<Caller, Response> {
862f0db2 Andy McCall ↶ 78
    let _ = read;
77df591d Andy McCall ↶ 79
    let signed = headers.get("spectra-key").is_some();
80
    crate::http::authenticate_why(st, peer, headers, method_path, body).await.map_err(|(status, why)| {
81
        if status == StatusCode::FORBIDDEN {
82
            problem(status, "this origin may not call Spectra", None)
83
        } else if signed {
84
            // A signed request is told why its signature was not enough.
85
            problem(StatusCode::UNAUTHORIZED, "not authenticated", Some(why))
86
        } else {
87
            problem(
88
                StatusCode::UNAUTHORIZED,
89
                "not authenticated",
862f0db2 Andy McCall ↶ 90
                Some("sign the request with your key: spectra's own client, its shims and `spectra bridge` do".into()),
77df591d Andy McCall ↶ 91
            )
92
        }
e071e91a Andy McCall ↶ 93
    })
94
}
95
96
fn path_of(uri: &axum::http::Uri) -> String {
97
    uri.path_and_query().map(|p| p.as_str().to_string()).unwrap_or_else(|| uri.path().to_string())
98
}
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
67bfa06a Andy McCall ↶ 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.
111
async fn admin_verify(
112
    State(st): State<AppState>,
113
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
114
    headers: HeaderMap,
115
    uri: axum::http::Uri,
116
) -> Response {
117
    let c = who!(st, peer, headers, uri, "GET", b"", false);
118
    if !matches!(c.principal, Principal::Owner { .. }) {
119
        return problem(StatusCode::FORBIDDEN, "only the owner verifies the ledger", None);
120
    }
121
    let ledger = match spectra_core::ledger::Ledger::open_read_only(&st.daemon.data.join("spectra.db")).await {
122
        Ok(l) => l,
123
        Err(e) => return bad(e),
124
    };
125
    match ledger.verify(&mut spectra_core::principals::Principals::default()).await {
126
        Ok(v) => axum::Json(v).into_response(),
127
        Err(e) => bad(e),
128
    }
129
}
130
b044d909 Andy McCall ↶ 131
/// `GET /api/v1/briefs`: the overall brief and every project's (spec §12.1).
132
async fn briefs(
133
    State(st): State<AppState>,
134
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
135
    headers: HeaderMap,
136
    uri: axum::http::Uri,
137
) -> Response {
138
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
139
    let pool = st.daemon.ledger.pool();
140
    let overall = spectra_core::rules::layer(pool, None).await.ok().flatten();
141
    let rows: Vec<(String, String)> = sqlx::query_as("SELECT project, text FROM briefs WHERE layer = 'project' ORDER BY project")
142
        .fetch_all(pool)
143
        .await
144
        .unwrap_or_default();
145
    json_etag(
146
        &headers,
147
        &api::Briefs { overall, projects: rows.into_iter().map(|(project, text)| api::ProjectBrief { project, text }).collect() },
148
    )
149
}
150
151
/// `PUT /api/v1/briefs`: the owner sets a brief; the signed request is the
152
/// event's proof.
153
async fn set_brief(
154
    State(st): State<AppState>,
155
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
156
    headers: HeaderMap,
157
    uri: axum::http::Uri,
158
    body: Bytes,
159
) -> Response {
160
    let c = who!(st, peer, headers, uri, "PUT", &body, false);
161
    if !matches!(c.principal, Principal::Owner { .. }) {
162
        return problem(StatusCode::FORBIDDEN, "only the owner sets a brief", None);
163
    }
164
    let b: api::SetBrief = match serde_json::from_slice(&body) {
165
        Ok(b) => b,
166
        Err(e) => return problem(StatusCode::BAD_REQUEST, "not a brief", Some(e.to_string())),
167
    };
168
    if let Err(e) = spectra_core::rules::check(&b.text) {
169
        return problem(StatusCode::BAD_REQUEST, "too long", Some(e.to_string()));
170
    }
171
    let project = b.project.filter(|p| !p.is_empty());
172
    if let Some(p) = &project {
173
        let known: Option<String> = sqlx::query_scalar("SELECT name FROM projects WHERE name = ?")
174
            .bind(p)
175
            .fetch_optional(st.daemon.ledger.pool())
176
            .await
177
            .unwrap_or(None);
178
        if known.is_none() {
179
            return problem(StatusCode::NOT_FOUND, "no such project", Some(p.clone()));
180
        }
181
    }
182
    let mut e = spectra_core::event::NewEvent::system("brief.set", serde_json::json!({ "project": project, "text": b.text }))
183
        .by(c.principal.actor(), c.auth.clone());
184
    e.project = project.clone();
185
    match st.daemon.ledger.append_one(e).await {
186
        Ok(ev) => axum::Json(serde_json::json!({ "seq": ev.seq })).into_response(),
187
        Err(e) => bad(e),
188
    }
189
}
190
e071e91a Andy McCall ↶ 191
async fn health() -> Response {
192
    axum::Json(serde_json::json!({ "ok": true })).into_response()
193
}
194
195
async fn version() -> Response {
196
    axum::Json(api::Version {
197
        version: env!("CARGO_PKG_VERSION").into(),
198
        ledger_format: spectra_core::LEDGER_FORMAT,
199
        mcp_protocol_versions: crate::PROTOCOL_VERSIONS.iter().map(|v| v.to_string()).collect(),
200
    })
201
    .into_response()
202
}
203
204
async fn agent_list(st: &AppState) -> Vec<api::Agent> {
205
    #[allow(clippy::type_complexity)]
1876f2ce Andy McCall ↶ 206
    let rows: Vec<(String, String, String, String, String, Option<String>, Option<i64>, String)> = sqlx::query_as(
207
        "SELECT a.name, a.owner_name, a.key, a.kind, a.project, p.status_text, a.disabled_ms, a.scopes FROM agents a LEFT JOIN presence p ON p.agent = a.name ORDER BY a.name",
e071e91a Andy McCall ↶ 208
    )
209
    .fetch_all(st.daemon.ledger.pool())
210
    .await
211
    .unwrap_or_default();
212
    let presence = st.daemon.presence.lock().await;
67bfa06a Andy McCall ↶ 213
    let principals = st.daemon.principals.read().await;
e071e91a Andy McCall ↶ 214
    let now = now_ms();
215
    rows.into_iter()
1876f2ce Andy McCall ↶ 216
        .map(|(name, owner, key, kind, project, status, disabled, scopes)| {
217
            let channels = serde_json::from_str::<Vec<String>>(&scopes)
218
                .unwrap_or_default()
219
                .into_iter()
220
                .filter_map(|s| s.strip_prefix("channel:").map(String::from))
221
                .collect();
98a0fb9f Andy McCall ↶ 222
            let k = spectra_core::identity::KeyId::from_hex(&key);
67bfa06a Andy McCall ↶ 223
            let state = match k.map(|k| principals.principal(&k, now)) {
224
                Some(Ok(_)) => "ok".to_string(),
225
                Some(Err(why)) => why,
226
                None => "a malformed key".into(),
227
            };
228
            let delegation_expires = k.and_then(|k| principals.owner().and_then(|o| o.delegate(&k)).and_then(|d| d.expires));
229
            api::Agent {
230
                display: format!("{name} · {owner}'s agent"),
231
                presence: if disabled.is_some() { "offline".into() } else { presence.state(&name, now).into() },
232
                disabled: disabled.is_some(),
233
                name,
234
                owner,
235
                key,
236
                kind,
237
                project,
238
                status,
239
                state,
240
                delegation_expires,
1876f2ce Andy McCall ↶ 241
                channels,
67bfa06a Andy McCall ↶ 242
            }
e071e91a Andy McCall ↶ 243
        })
244
        .collect()
245
}
246
247
async fn agents(
248
    State(st): State<AppState>,
249
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
250
    headers: HeaderMap,
251
    uri: axum::http::Uri,
252
) -> Response {
253
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
254
    json_etag(&headers, &agent_list(&st).await)
255
}
256
257
async fn projects(
258
    State(st): State<AppState>,
259
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
260
    headers: HeaderMap,
261
    uri: axum::http::Uri,
262
) -> Response {
263
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
264
    let pool = st.daemon.ledger.pool();
98a0fb9f Andy McCall ↶ 265
    let rows: Vec<(String, Option<String>, String, Option<String>)> =
266
        sqlx::query_as("SELECT name, path, description, url FROM projects ORDER BY name").fetch_all(pool).await.unwrap_or_default();
e071e91a Andy McCall ↶ 267
    let mut out = Vec::new();
98a0fb9f Andy McCall ↶ 268
    for (name, path, description, url) in rows {
e071e91a Andy McCall ↶ 269
        let depends_on = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ? ORDER BY 1")
270
            .bind(&name)
271
            .fetch_all(pool)
272
            .await
273
            .unwrap_or_default();
274
        let depended_on_by = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ? ORDER BY 1")
275
            .bind(&name)
276
            .fetch_all(pool)
277
            .await
278
            .unwrap_or_default();
67bfa06a Andy McCall ↶ 279
        let agents = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name")
280
            .bind(&name)
281
            .fetch_all(pool)
282
            .await
283
            .unwrap_or_default();
98a0fb9f Andy McCall ↶ 284
        out.push(api::Project { name, path, description, url, depends_on, depended_on_by, agents });
e071e91a Andy McCall ↶ 285
    }
286
    json_etag(&headers, &out)
287
}
288
289
async fn channels(
290
    State(st): State<AppState>,
291
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
292
    headers: HeaderMap,
293
    uri: axum::http::Uri,
294
) -> Response {
295
    let c = who!(st, peer, headers, uri, "GET", b"", true);
296
    let all = match conv::channels(&st.daemon.ledger).await {
297
        Ok(a) => a,
298
        Err(e) => return bad(e),
299
    };
300
    let mut out = Vec::new();
301
    for ch in all.into_iter().filter(|ch| conv::can_read(&c.principal, ch)) {
302
        let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE channel = ?")
303
            .bind(&ch.id)
304
            .fetch_one(st.daemon.ledger.pool())
305
            .await
306
            .unwrap_or(0);
307
        out.push(api::Channel { display: ch.display(), id: ch.id, name: ch.name, kind: ch.kind, purpose: ch.purpose, messages: n });
308
    }
309
    json_etag(&headers, &out)
310
}
311
312
async fn to_api(st: &AppState, m: conv::Message, ch: &str) -> api::Message {
313
    api::Message {
314
        author_display: conv::display_name(&st.daemon.ledger, &m.author).await,
315
        mentions: serde_json::from_str(&m.mentions).unwrap_or_default(),
316
        refs: serde_json::from_str(&m.refs).unwrap_or_default(),
317
        redacted: m.redacted.is_some(),
318
        id: m.id,
319
        channel: ch.to_string(),
320
        reply_to: m.reply_to,
321
        author: m.author,
322
        kind: m.kind,
323
        body: m.body,
324
        at_ms: m.at_ms,
325
        replies: m.replies,
326
    }
327
}
328
329
#[derive(Deserialize)]
330
struct PageQuery {
331
    after: Option<String>,
332
    limit: Option<u32>,
333
}
334
335
async fn messages(
336
    State(st): State<AppState>,
337
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
338
    headers: HeaderMap,
339
    uri: axum::http::Uri,
340
    Path(name): Path<String>,
341
    Query(q): Query<PageQuery>,
342
) -> Response {
343
    let c = who!(st, peer, headers, uri, "GET", b"", true);
344
    let limit = q.limit.unwrap_or(50).clamp(1, 500);
345
    match conv::read(&st.daemon.ledger, &c.principal, Some(&name), None, q.after.as_deref(), limit).await {
346
        Ok((ch, msgs)) => {
347
            let next = (msgs.len() as u32 == limit).then(|| msgs.last().map(|m| m.id.clone())).flatten();
348
            let mut items = Vec::new();
349
            for m in msgs {
350
                items.push(to_api(&st, m, &ch.display()).await);
351
            }
352
            json_etag(&headers, &api::Messages { channel: ch.display(), items, next })
353
        }
354
        Err(e) => problem(StatusCode::NOT_FOUND, "no such channel, or not yours to read", Some(format!("{e:#}"))),
355
    }
356
}
357
358
async fn thread(
359
    State(st): State<AppState>,
360
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
361
    headers: HeaderMap,
362
    uri: axum::http::Uri,
363
    Path(id): Path<String>,
364
    Query(q): Query<PageQuery>,
365
) -> Response {
366
    let c = who!(st, peer, headers, uri, "GET", b"", true);
367
    let limit = q.limit.unwrap_or(200).clamp(1, 500);
368
    match conv::read(&st.daemon.ledger, &c.principal, None, Some(&id), q.after.as_deref(), limit).await {
369
        Ok((ch, msgs)) => {
370
            let next = (msgs.len() as u32 == limit).then(|| msgs.last().map(|m| m.id.clone())).flatten();
371
            let mut items = Vec::new();
372
            for m in msgs {
373
                items.push(to_api(&st, m, &ch.display()).await);
374
            }
375
            json_etag(&headers, &api::Messages { channel: ch.display(), items, next })
376
        }
377
        Err(e) => problem(StatusCode::NOT_FOUND, "no such thread, or not yours to read", Some(format!("{e:#}"))),
378
    }
379
}
380
381
async fn post_message(
382
    State(st): State<AppState>,
383
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
384
    headers: HeaderMap,
385
    uri: axum::http::Uri,
386
    Path(name): Path<String>,
387
    body: Bytes,
388
) -> Response {
389
    let c = who!(st, peer, headers, uri, "POST", &body, false);
390
    let n: api::NewMessage = match serde_json::from_slice(&body) {
391
        Ok(n) => n,
392
        Err(e) => return problem(StatusCode::BAD_REQUEST, "not a message", Some(e.to_string())),
393
    };
394
    let author = Author { principal: c.principal.clone(), signer: Signer::Request(c.auth.clone()) };
395
    let post = Post {
396
        channel: if n.reply_to.is_some() { None } else { Some(name) },
397
        body: n.body,
398
        reply_to: n.reply_to,
399
        kind: n.kind,
400
        ..Default::default()
401
    };
402
    match conv::post(&st.daemon.ledger, &author, post, now_ms()).await {
403
        Ok(Posted::Message { id, channel }) => {
404
            (StatusCode::CREATED, axum::Json(api::Posted { id, channel, duplicate: false })).into_response()
405
        }
406
        Ok(Posted::Duplicate { id }) => axum::Json(api::Posted { id, channel: String::new(), duplicate: true }).into_response(),
407
        Err(e) => bad(e),
408
    }
409
}
410
411
async fn inbox_items(st: &AppState, actor: &str) -> Vec<api::InboxItem> {
67bfa06a Andy McCall ↶ 412
    let mut out = Vec::new();
413
    for i in conv::inbox(&st.daemon.ledger, actor, None).await.unwrap_or_default() {
414
        let from_display = conv::display_name(&st.daemon.ledger, &i.from).await;
415
        out.push(api::InboxItem {
416
            id: i.id,
417
            kind: i.kind,
418
            at_ms: i.at_ms,
419
            from: i.from,
420
            from_display,
421
            channel: i.channel,
422
            summary: i.summary,
423
        });
424
    }
425
    out
e071e91a Andy McCall ↶ 426
}
427
428
async fn inbox(State(st): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, uri: axum::http::Uri) -> Response {
429
    let c = who!(st, peer, headers, uri, "GET", b"", true);
430
    json_etag(&headers, &inbox_items(&st, &c.principal.actor()).await)
431
}
432
433
async fn ack(
434
    State(st): State<AppState>,
435
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
436
    headers: HeaderMap,
437
    uri: axum::http::Uri,
438
    body: Bytes,
439
) -> Response {
440
    let c = who!(st, peer, headers, uri, "POST", &body, false);
441
    let a: api::Ack = match serde_json::from_slice(&body) {
442
        Ok(a) => a,
443
        Err(e) => return problem(StatusCode::BAD_REQUEST, "not an ack", Some(e.to_string())),
444
    };
445
    let author = Author { principal: c.principal, signer: Signer::Request(c.auth) };
446
    match conv::ack(&st.daemon.ledger, &author, &a.ids, a.reaction.as_deref()).await {
447
        Ok(n) => axum::Json(serde_json::json!({ "read": n })).into_response(),
448
        Err(e) => bad(e),
449
    }
450
}
451
452
async fn brief(State(st): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, uri: axum::http::Uri) -> Response {
453
    let c = who!(st, peer, headers, uri, "GET", b"", true);
454
    let project = match &c.principal {
455
        Principal::Agent { project, .. } => Some(project.clone()),
456
        _ => None,
457
    };
b044d909 Andy McCall ↶ 458
    let rules = spectra_core::rules::compile(st.daemon.ledger.pool(), project.as_deref()).await.map(|r| r.version).unwrap_or_default();
e071e91a Andy McCall ↶ 459
    let inbox = inbox_items(&st, &c.principal.actor()).await;
460
    let b = api::Brief {
461
        you: c.principal.display(),
462
        actor: c.principal.actor(),
463
        project,
464
        rules_version: rules,
465
        unread: inbox.len() as i64,
466
        inbox,
467
        agents: agent_list(&st).await,
468
        head: st.daemon.ledger.head().await.0,
469
    };
470
    json_etag(&headers, &b)
471
}
472
473
#[derive(Deserialize)]
474
struct SearchQuery {
475
    q: String,
476
    channel: Option<String>,
477
    author: Option<String>,
478
    since: Option<String>,
479
    limit: Option<u32>,
480
}
481
482
async fn search(
483
    State(st): State<AppState>,
484
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
485
    headers: HeaderMap,
486
    uri: axum::http::Uri,
487
    Query(q): Query<SearchQuery>,
488
) -> Response {
489
    let c = who!(st, peer, headers, uri, "GET", b"", true);
490
    let since = q.since.as_deref().and_then(|s| crate::tools::parse_since(s, now_ms()));
491
    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
492
    {
493
        Ok(hits) => {
67bfa06a Andy McCall ↶ 494
            let mut out: Vec<api::Hit> = Vec::new();
495
            for h in hits {
496
                let author_display = conv::display_name(&st.daemon.ledger, &h.author).await;
497
                out.push(api::Hit {
498
                    id: h.id,
499
                    channel: h.channel,
500
                    author: h.author,
501
                    at_ms: h.at_ms,
502
                    snippet: h.snippet,
503
                    thread: h.thread,
504
                    author_display,
505
                });
506
            }
e071e91a Andy McCall ↶ 507
            json_etag(&headers, &out)
508
        }
509
        Err(e) => bad(e),
510
    }
511
}
512
513
#[derive(Deserialize)]
514
struct EventsQuery {
515
    after: Option<u64>,
516
    kind: Option<String>,
517
    limit: Option<u32>,
518
    follow: Option<u8>,
519
    /// With `follow`, end the stream after this many events.
520
    max: Option<usize>,
521
}
522
523
fn summary(e: &spectra_core::event::Event) -> api::EventSummary {
524
    api::EventSummary {
525
        seq: e.seq,
526
        id: e.id.clone(),
527
        at_ms: e.at_ms,
528
        kind: e.kind.clone(),
529
        actor: e.actor.clone(),
530
        subject: e.subject.clone(),
531
        method: e.auth.method.name().into(),
532
        assurance: e.auth.assurance,
533
        hash: hex::encode(e.hash),
534
    }
535
}
536
537
/// The log as summaries; `follow=1` streams new ones as SSE instead.
538
async fn events(
539
    State(st): State<AppState>,
540
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
541
    headers: HeaderMap,
542
    uri: axum::http::Uri,
543
    Query(q): Query<EventsQuery>,
544
) -> Response {
545
    let c = who!(st, peer, headers, uri, "GET", b"", true);
546
    // The log is the owner's to read; an agent reads what concerns it
547
    // through the conversation and its inbox.
548
    if !matches!(c.principal, Principal::Owner { .. }) {
549
        return problem(StatusCode::FORBIDDEN, "the event log is the owner's", None);
550
    }
551
    if q.follow == Some(1) {
552
        let kind = q.kind.clone();
553
        let rx = st.daemon.ledger.subscribe();
554
        let stream = tokio_stream::wrappers::BroadcastStream::new(rx)
555
            .filter_map(move |e| {
556
                let kind = kind.clone();
557
                async move {
558
                    let e = e.ok()?;
559
                    if kind.as_ref().is_some_and(|k| *k != e.kind) {
560
                        return None;
561
                    }
562
                    Some(Ok::<_, std::convert::Infallible>(
563
                        SseEvent::default().data(serde_json::to_string(&summary(&e)).unwrap_or_default()),
564
                    ))
565
                }
566
            })
567
            .take(q.max.unwrap_or(usize::MAX));
98a0fb9f Andy McCall ↶ 568
        // A comment first: the follower knows it is subscribed, so it can
569
        // read the state and miss nothing that happens after.
570
        let hello = futures::stream::once(async { Ok::<_, std::convert::Infallible>(SseEvent::default().comment("subscribed")) });
571
        return Sse::new(hello.chain(stream)).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response();
e071e91a Andy McCall ↶ 572
    }
573
    let limit = q.limit.unwrap_or(100).clamp(1, 1000);
574
    let from = q.after.map(|a| a + 1).unwrap_or(1);
575
    let evs = match &q.kind {
576
        Some(k) => st.daemon.ledger.events_of_kinds(from, &[k.as_str()], limit).await,
577
        None => st.daemon.ledger.events(from, limit).await,
578
    };
579
    match evs {
580
        Ok(evs) => {
581
            let next = (evs.len() as u32 == limit).then(|| evs.last().map(|e| e.seq)).flatten();
582
            json_etag(&headers, &api::Events { items: evs.iter().map(summary).collect(), next })
583
        }
584
        Err(e) => bad(e),
585
    }
586
}