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