OXYGENHUB

Coverage / crates/radar/src/lib.rs 83.1%

343 of 413 lines run, 54 of 148 functions · run #17 on main · run missed

1
//! Radar (spec §17): the human's pages, rendered with askama, refreshed
2
//! from the event stream, working without JavaScript. Linked by `spectra`
3
//! and, later, mounted by SpectraHub.
4
//!
5
//! The owner signs in with a one-time link their CLI signed (`spectra
6
//! open`); everything they do here is recorded with that signed request as
7
//! its proof (spec §8.3). Every unsafe request carries the session's CSRF
8
//! token and a same-origin `Origin`.
9
10
use askama::Template;
11
use axum::body::Bytes;
12
use axum::extract::connect_info::ConnectInfo;
13
use axum::extract::{Form, Path, Query, State};
14
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
15
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
16
use axum::response::{Html, IntoResponse, Redirect, Response};
17
use axum::routing::{get, post};
18
use axum::Router;
19
use futures::StreamExt;
20
use serde::Deserialize;
21
use spectra_core::conversation::{self as conv, Author, Post, Signer};
22
use spectra_core::event::{Auth, Method};
23
use spectra_core::ids::{format_time, now_ms};
24
use spectra_core::principals::{Principal, Principals};
25
use spectra_mcp::http::{AppState, PeerInfo};
26
use spectra_mcp::session::{self, Session};
27
use std::time::Duration;
28
29
const MARK: &str = include_str!("../assets/spectra.svg");
30
const STYLE: &str = include_str!("../assets/style.css");
31
const SCRIPT: &str = include_str!("../assets/radar.js");
32
const COOKIE: &str = "spectra_session";
33
34
#[derive(Clone)]
35
pub struct RadarState {
36
    pub app: AppState,
37
}
38
39
impl RadarState {
4013×
    fn sessions(&self) -> &session::Sessions {
4113×
        &self.app.sessions
4213×
    }
43
}
44
45
/// Radar's routes.
4610×
pub fn router(app: AppState) -> Router {
4710×
    let state = RadarState { app };
4810×
    Router::new()
4910×
        .route("/", get(live))
5010×
        .route("/channels", get(channels))
5110×
        .route("/c/{name}", get(channel).post(post_channel))
5210×
        .route("/c/{name}/t/{id}", get(thread).post(post_thread))
5310×
        .route("/log", get(log))
5410×
        .route("/log/verify", post(verify))
5510×
        .route("/radar/link", post(link))
5610×
        .route("/signin", get(signin))
5710×
        .route("/signout", post(signout))
5810×
        .route("/radar/events", get(events))
5910×
        .route("/radar/style.css", get(|| async { asset("text/css; charset=utf-8", STYLE) }))
6010×
        .route("/radar/radar.js", get(|| async { asset("text/javascript; charset=utf-8", SCRIPT) }))
6110×
        .route("/radar/spectra.svg", get(|| async { asset("image/svg+xml", MARK) }))
6210×
        .layer(axum::middleware::map_response(security_headers))
6310×
        .with_state(state)
6410×
}
65
660×
fn asset(kind: &'static str, body: &'static str) -> Response {
670×
    ([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "max-age=300")], body).into_response()
680×
}
69
70
/// CSP and friends on every response (spec §21.2).
7117×
async fn security_headers(mut r: Response) -> Response {
7217×
    let h = r.headers_mut();
7317×
    h.insert("content-security-policy", HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self'; base-uri 'none'"));
7417×
    h.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
75
    // `same-origin`: a form post still carries our Origin (no-referrer makes it `null`).
7617×
    h.insert("referrer-policy", HeaderValue::from_static("same-origin"));
7717×
    h.insert("x-frame-options", HeaderValue::from_static("DENY"));
7817×
    r
7917×
}
80
81
/// What every page's frame shows.
82
pub struct Shell {
83
    pub page: &'static str,
84
    pub owner: String,
85
    pub head: u64,
86
    pub version: &'static str,
87
    pub mark: &'static str,
88
}
89
90
/// The signed-in owner, or the page that says how to sign in.
91
struct Owner {
92
    session: Session,
93
    principal: Principal,
94
    shell: Shell,
95
}
96
97
impl Owner {
981×
    fn author(&self) -> Author<'static> {
991×
        Author {
1001×
            principal: self.principal.clone(),
1011×
            signer: Signer::Request(Auth {
1021×
                key: Some(self.session.key),
1031×
                method: Method::Session,
1041×
                assurance: 2,
1051×
                proof: Some(self.session.proof.clone()),
1061×
            }),
1071×
        }
1081×
    }
109
}
110
11114×
fn cookie(headers: &HeaderMap) -> Option<String> {
11214×
    let c = headers.get(header::COOKIE)?.to_str().ok()?;
11310×
    c.split(';').map(str::trim).find_map(|kv| kv.strip_prefix(&format!("{COOKIE}=")).map(String::from))
11414×
}
115
11614×
async fn owner(st: &RadarState, headers: &HeaderMap, page: &'static str) -> Result<Owner, Response> {
11714×
    let now = now_ms();
11814×
    let session = cookie(headers)
11914×
        .and_then(|id| st.sessions().get(&id, now))
12014×
        .ok_or_else(|| signin_page("Radar shows Spectra to its owner only.", StatusCode::UNAUTHORIZED))?;
12110×
    let d = &st.app.daemon;
12210×
    let principal = d
12310×
        .principals
12410×
        .read()
12510×
        .await
12610×
        .principal(&oxy_core_key(session.key), now)
12710×
        .map_err(|why| signin_page(&format!("This session no longer belongs to the owner: {why}."), StatusCode::UNAUTHORIZED))?;
12810×
    if !matches!(principal, Principal::Owner { .. }) {
1290×
        return Err(signin_page("Radar shows Spectra to its owner only.", StatusCode::FORBIDDEN));
13010×
    }
13110×
    let shell = Shell { page, owner: principal.display(), head: d.ledger.head().await.0, version: env!("CARGO_PKG_VERSION"), mark: MARK };
13210×
    Ok(Owner { session, principal, shell })
13314×
}
134
13510×
fn oxy_core_key(k: [u8; 32]) -> oxy_core::hash::KeyId {
13610×
    oxy_core::hash::ObjectRef(k)
13710×
}
138
139
#[derive(Template)]
140
#[template(path = "signin.html")]
141
struct SigninPage<'a> {
142
    mark: &'static str,
143
    message: &'a str,
144
}
145
1465×
fn signin_page(message: &str, status: StatusCode) -> Response {
1475×
    (status, render(&SigninPage { mark: MARK, message })).into_response()
1485×
}
149
15011×
fn render<T: Template>(t: &T) -> Response {
15111×
    match t.render() {
15211×
        Ok(html) => Html(html).into_response(),
1530×
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("could not render the page: {e}")).into_response(),
154
    }
15511×
}
156
157
/// Unsafe requests: the session's CSRF token and our own origin.
158
/// The refusal, if the form may not go through.
1594×
fn check_form(st: &RadarState, o: &Owner, headers: &HeaderMap, csrf: &str) -> Option<Response> {
1604×
    if !spectra_mcp::http::origin_ok(headers, &st.app.origins) {
1611×
        return Some((StatusCode::FORBIDDEN, "this origin may not change Spectra").into_response());
1623×
    }
1633×
    if csrf != o.session.csrf {
1641×
        return Some((StatusCode::FORBIDDEN, "the form is stale: reload the page").into_response());
1652×
    }
1662×
    None
1674×
}
168
169
/// Markdown, sanitised: what a message's body becomes on a page.
1703×
fn markdown(body: &str) -> String {
1713×
    let mut html = String::new();
1723×
    pulldown_cmark::html::push_html(
1733×
        &mut html,
1743×
        pulldown_cmark::Parser::new_ext(body, pulldown_cmark::Options::ENABLE_STRIKETHROUGH | pulldown_cmark::Options::ENABLE_TABLES),
175
    );
1763×
    ammonia::clean(&html)
1773×
}
178
179
// ---- Live ----
180
181
struct AgentCard {
182
    name: String,
183
    owner: String,
184
    kind: String,
185
    project: String,
186
    presence: String,
187
    status: Option<String>,
188
    last_tool: Option<String>,
189
    last_at: String,
190
}
191
192
struct EventRow {
193
    seq: i64,
194
    at: String,
195
    kind: String,
196
    actor: String,
197
    subject: Option<String>,
198
}
199
200
#[derive(Template)]
201
#[template(path = "live.html")]
202
struct LivePage {
203
    shell: Shell,
204
    agents: Vec<AgentCard>,
205
    unread: i64,
206
    events: Vec<EventRow>,
207
}
208
2092×
async fn live(State(st): State<RadarState>, headers: HeaderMap) -> Response {
2102×
    let o = match owner(&st, &headers, "live").await {
2111×
        Ok(o) => o,
2121×
        Err(r) => return r,
213
    };
2141×
    let d = &st.app.daemon;
2151×
    let pool = d.ledger.pool();
2161×
    let rows: Vec<(String, String, String, String, Option<String>)> =
2171×
        sqlx::query_as("SELECT a.name, a.owner_name, a.kind, a.project, p.status_text FROM agents a LEFT JOIN presence p ON p.agent = a.name WHERE a.disabled_ms IS NULL ORDER BY a.name")
2181×
            .fetch_all(pool)
2191×
            .await
2201×
            .unwrap_or_default();
2211×
    let mut agents = Vec::new();
222
    {
2231×
        let now = now_ms();
2241×
        for (name, owner, kind, project, status) in rows {
2251×
            let last: Option<(String, i64)> =
2261×
                sqlx::query_as("SELECT tool, at_ms FROM tool_calls WHERE agent = ? ORDER BY seq DESC LIMIT 1")
2271×
                    .bind(&name)
2281×
                    .fetch_optional(pool)
2291×
                    .await
2301×
                    .ok()
2311×
                    .flatten();
2321×
            let presence = d.presence.lock().await.state(&name, now).to_string();
2331×
            agents.push(AgentCard {
2341×
                name,
2351×
                owner,
2361×
                kind,
2371×
                project,
2381×
                presence,
2391×
                status,
2401×
                last_tool: last.as_ref().map(|(t, _)| t.clone()),
2411×
                last_at: last.map(|(_, at)| format_time(at as u64)).unwrap_or_default(),
242
            });
243
        }
244
    }
2451×
    let unread = conv::unread(&d.ledger, "human").await.unwrap_or(0);
2461×
    let events = recent(&st, None, None, 50)
2471×
        .await
2481×
        .into_iter()
24915×
        .map(|e| EventRow { seq: e.seq, at: e.at, kind: e.kind, actor: e.actor, subject: e.subject })
2501×
        .collect();
2511×
    render(&LivePage { shell: o.shell, agents, unread, events })
2522×
}
253
254
// ---- Channels ----
255
256
struct ChannelRow {
257
    name: String,
258
    display: String,
259
    kind: String,
260
    purpose: String,
261
    count: i64,
262
    last: String,
263
}
264
265
/// A channel with its message count and last message time.
266
type ChannelCount = (String, String, String, Option<String>, i64, Option<i64>);
267
268
#[derive(Template)]
269
#[template(path = "channels.html")]
270
struct ChannelsPage {
271
    shell: Shell,
272
    channels: Vec<ChannelRow>,
273
}
274
2752×
async fn channels(State(st): State<RadarState>, headers: HeaderMap) -> Response {
2762×
    let o = match owner(&st, &headers, "channels").await {
2771×
        Ok(o) => o,
2781×
        Err(r) => return r,
279
    };
2801×
    let rows: Vec<ChannelCount> = sqlx::query_as(
2811×
        "SELECT c.id, c.name, c.kind, c.purpose, (SELECT COUNT(*) FROM messages m WHERE m.channel = c.id), (SELECT MAX(at_ms) FROM messages m WHERE m.channel = c.id) \
2821×
         FROM channels c ORDER BY c.kind = 'direct', c.kind = 'system', c.name",
2831×
    )
2841×
    .fetch_all(st.app.daemon.ledger.pool())
2851×
    .await
2861×
    .unwrap_or_default();
2871×
    let channels = rows
2881×
        .into_iter()
2891×
        .map(|(_, name, kind, purpose, count, last)| ChannelRow {
2904×
            display: match name.strip_prefix("dm:") {
2910×
                Some(p) => format!("@{p}"),
2924×
                None => format!("#{name}"),
293
            },
2944×
            name,
2954×
            kind,
2964×
            purpose: purpose.unwrap_or_default(),
2974×
            count,
2984×
            last: last.map(|t| format_time(t as u64)).unwrap_or_default(),
2994×
        })
3001×
        .collect();
3011×
    render(&ChannelsPage { shell: o.shell, channels })
3022×
}
303
304
// ---- A channel and a thread ----
305
306
struct MessageRow {
307
    id: String,
308
    author: String,
309
    at: String,
310
    kind: String,
311
    replies: i64,
312
    redacted: bool,
313
    body_html: String,
314
}
315
316
#[derive(Template)]
317
#[template(path = "channel.html")]
318
struct ChannelPage {
319
    shell: Shell,
320
    name: String,
321
    display: String,
322
    purpose: Option<String>,
323
    thread: Option<String>,
324
    messages: Vec<MessageRow>,
325
    can_post: bool,
326
    action: String,
327
    csrf: String,
328
}
329
3302×
async fn show(st: &RadarState, o: Owner, name: &str, thread: Option<&str>) -> Response {
3312×
    let d = &st.app.daemon;
3322×
    let (ch, msgs) = match conv::read(&d.ledger, &o.principal, if thread.is_some() { None } else { Some(name) }, thread, None, 200).await {
3332×
        Ok(r) => r,
3340×
        Err(e) => return (StatusCode::NOT_FOUND, format!("{e:#}")).into_response(),
335
    };
3362×
    let mut messages = Vec::new();
3373×
    for m in msgs {
3383×
        messages.push(MessageRow {
3393×
            author: spectra_core::conversation::display_name(&d.ledger, &m.author).await,
3403×
            at: format_time(m.at_ms as u64),
3413×
            redacted: m.redacted.is_some(),
3423×
            body_html: if m.redacted.is_some() { ammonia::clean_text(&m.body) } else { markdown(&m.body) },
3433×
            id: m.id,
3443×
            kind: m.kind,
3453×
            replies: m.replies,
346
        });
347
    }
3482×
    let can_post = conv::can_post(&o.principal, &ch).is_ok();
3492×
    let action = match thread {
3500×
        Some(t) => format!("/c/{}/t/{t}", ch.name),
3512×
        None => format!("/c/{}", ch.name),
352
    };
3532×
    let csrf = o.session.csrf.clone();
3542×
    render(&ChannelPage {
3552×
        shell: o.shell,
3562×
        name: ch.name.clone(),
3572×
        display: ch.display(),
3582×
        purpose: ch.purpose.clone(),
3592×
        thread: thread.map(String::from),
3602×
        messages,
3612×
        can_post,
3622×
        action,
3632×
        csrf,
3642×
    })
3652×
}
366
3673×
async fn channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>) -> Response {
3683×
    match owner(&st, &headers, "channels").await {
3692×
        Ok(o) => show(&st, o, &name, None).await,
3701×
        Err(r) => r,
371
    }
3723×
}
373
3740×
async fn thread(State(st): State<RadarState>, headers: HeaderMap, Path((name, id)): Path<(String, String)>) -> Response {
3750×
    match owner(&st, &headers, "channels").await {
3760×
        Ok(o) => show(&st, o, &name, Some(&id)).await,
3770×
        Err(r) => r,
378
    }
3790×
}
380
381
#[derive(Deserialize)]
382
struct PostForm {
383
    _csrf: String,
384
    body: String,
385
    reply_to: Option<String>,
386
}
387
3883×
async fn post_to(st: &RadarState, headers: &HeaderMap, name: &str, form: PostForm, back: String) -> Response {
3893×
    let o = match owner(st, headers, "channels").await {
3903×
        Ok(o) => o,
3910×
        Err(r) => return r,
392
    };
3933×
    if let Some(r) = check_form(st, &o, headers, &form._csrf) {
3942×
        return r;
3951×
    }
3961×
    let post = Post {
3971×
        channel: if form.reply_to.is_some() { None } else { Some(name.to_string()) },
3981×
        body: form.body,
3991×
        reply_to: form.reply_to,
4001×
        ..Default::default()
401
    };
4021×
    match conv::post(&st.app.daemon.ledger, &o.author(), post, now_ms()).await {
4031×
        Ok(_) => Redirect::to(&back).into_response(),
4040×
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
405
    }
4063×
}
407
4083×
async fn post_channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>, Form(form): Form<PostForm>) -> Response {
4093×
    let back = format!("/c/{name}");
4103×
    post_to(&st, &headers, &name, form, back).await
4113×
}
412
4130×
async fn post_thread(
4140×
    State(st): State<RadarState>,
4150×
    headers: HeaderMap,
4160×
    Path((name, id)): Path<(String, String)>,
4170×
    Form(mut form): Form<PostForm>,
4180×
) -> Response {
4190×
    form.reply_to = Some(id.clone());
4200×
    let back = format!("/c/{name}/t/{id}");
4210×
    post_to(&st, &headers, &name, form, back).await
4220×
}
423
424
// ---- The log ----
425
426
struct LogRow {
427
    seq: i64,
428
    id: String,
429
    at: String,
430
    kind: String,
431
    actor: String,
432
    subject: Option<String>,
433
    method: String,
434
    assurance: i64,
435
    hash: String,
436
    prev: String,
437
    key: String,
438
    proof: String,
439
    payload: String,
440
}
441
4423×
async fn recent(st: &RadarState, kind: Option<&str>, before: Option<i64>, limit: i64) -> Vec<LogRow> {
443
    #[allow(clippy::type_complexity)]
4443×
    let rows: Vec<(
4453×
        i64,
4463×
        String,
4473×
        i64,
4483×
        String,
4493×
        String,
4503×
        Option<String>,
4513×
        i64,
4523×
        i64,
4533×
        Vec<u8>,
4543×
        Vec<u8>,
4553×
        Option<Vec<u8>>,
4563×
        Option<Vec<u8>>,
4573×
        Option<Vec<u8>>,
4583×
    )> = sqlx::query_as(
4593×
        "SELECT seq, id, at_ms, kind, actor, subject, auth_method, auth_assurance, hash, prev, auth_key, auth_proof, payload FROM events \
4603×
         WHERE (? IS NULL OR kind = ?) AND (? IS NULL OR seq < ?) ORDER BY seq DESC LIMIT ?",
4613×
    )
4623×
    .bind(kind)
4633×
    .bind(kind)
4643×
    .bind(before)
4653×
    .bind(before)
4663×
    .bind(limit)
4673×
    .fetch_all(st.app.daemon.ledger.pool())
4683×
    .await
4693×
    .unwrap_or_default();
4703×
    let mut out = Vec::new();
47147×
    for (seq, id, at, kind, actor, subject, method, assurance, hash, prev, key, proof, payload) in rows {
47247×
        let payload = payload
47347×
            .and_then(|p| serde_json::from_slice::<serde_json::Value>(&p).ok())
47447×
            .map(|v| serde_json::to_string_pretty(&v).unwrap_or_default())
47547×
            .unwrap_or_else(|| "(pruned to its stub)".into());
47647×
        out.push(LogRow {
47747×
            seq,
47847×
            id,
47947×
            at: format_time(at as u64),
48047×
            kind,
48147×
            actor: spectra_core::conversation::display_name(&st.app.daemon.ledger, &actor).await,
48247×
            subject,
48347×
            method: Method::from_u8(method as u8).map(|m| m.name()).unwrap_or("?").to_string(),
48447×
            assurance,
48547×
            hash: hex::encode(hash),
48647×
            prev: hex::encode(prev),
48747×
            key: key.map(hex::encode).unwrap_or_else(|| "none (Spectra itself; covered by the next checkpoint)".into()),
48847×
            proof: proof.map(|p| format!("{} bytes", p.len())).unwrap_or_else(|| "none".into()),
48947×
            payload,
490
        });
491
    }
4923×
    out
4933×
}
494
495
#[derive(Template)]
496
#[template(path = "log.html")]
497
struct LogPage {
498
    shell: Shell,
499
    head_hash: String,
500
    verified: String,
501
    csrf: String,
502
    kinds: Vec<String>,
503
    kind: String,
504
    events: Vec<LogRow>,
505
    older: Option<i64>,
506
}
507
508
#[derive(Deserialize)]
509
struct LogQuery {
510
    kind: Option<String>,
511
    before: Option<i64>,
512
    around: Option<i64>,
513
}
514
5153×
async fn log(State(st): State<RadarState>, headers: HeaderMap, Query(q): Query<LogQuery>) -> Response {
5163×
    let o = match owner(&st, &headers, "log").await {
5172×
        Ok(o) => o,
5181×
        Err(r) => return r,
519
    };
5202×
    let d = &st.app.daemon;
5212×
    let (_, head) = d.ledger.head().await;
5222×
    let last: Option<(Vec<u8>, i64)> =
5232×
        sqlx::query_as("SELECT payload, at_ms FROM events WHERE kind = 'ledger.verified' ORDER BY seq DESC LIMIT 1")
5242×
            .fetch_optional(d.ledger.pool())
5252×
            .await
5262×
            .ok()
5272×
            .flatten();
5282×
    let verified = match last {
5291×
        Some((p, at)) => {
5301×
            let v: serde_json::Value = serde_json::from_slice(&p).unwrap_or_default();
5311×
            let n = v["findings"].as_u64().unwrap_or(0);
5321×
            format!(
533
                "{} at {} over {} events",
5341×
                if n == 0 { "clean".to_string() } else { format!("{n} finding(s)") },
5351×
                format_time(at as u64),
5361×
                v["events"].as_u64().unwrap_or(0)
537
            )
538
        }
5391×
        None => "not yet".into(),
540
    };
5412×
    let kinds: Vec<String> =
5422×
        sqlx::query_scalar("SELECT kind FROM event_counts ORDER BY n DESC LIMIT 12").fetch_all(d.ledger.pool()).await.unwrap_or_default();
5432×
    let kind = q.kind.unwrap_or_default();
5442×
    let before = q.before.or(q.around.map(|a| a + 25));
5452×
    let events = recent(&st, (!kind.is_empty()).then_some(kind.as_str()), before, 100).await;
5462×
    let older = (events.len() == 100).then(|| events.last().map(|e| e.seq)).flatten();
5472×
    let csrf = o.session.csrf.clone();
5482×
    render(&LogPage { shell: o.shell, head_hash: hex::encode(head), verified, csrf, kinds, kind, events, older })
5493×
}
550
551
#[derive(Deserialize)]
552
struct CsrfForm {
553
    _csrf: String,
554
}
555
556
/// Verify the ledger now and record the result (`ledger.verified`).
5571×
async fn verify(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
5581×
    let o = match owner(&st, &headers, "log").await {
5591×
        Ok(o) => o,
5600×
        Err(r) => return r,
561
    };
5621×
    if let Some(r) = check_form(&st, &o, &headers, &f._csrf) {
5630×
        return r;
5641×
    }
5651×
    let ledger = &st.app.daemon.ledger;
5661×
    match ledger.verify(&mut Principals::default()).await {
5671×
        Ok(v) => {
5681×
            let e = spectra_core::event::NewEvent::system(
5691×
                "ledger.verified",
5701×
                serde_json::json!({ "events": v.events, "checkpoints": v.checkpoints, "findings": v.findings.len(), "head": hex::encode(v.head), "first": v.findings.first().map(|f| format!("#{}: {}", f.seq, f.detail)) }),
571
            );
5721×
            let _ = ledger.append_one(e).await;
5731×
            Redirect::to("/log").into_response()
574
        }
5750×
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(),
576
    }
5771×
}
578
579
// ---- Signing in ----
580
581
/// `POST /radar/link`, signed by the owner's CLI: a one-time sign-in link.
5821×
async fn link(State(st): State<RadarState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, body: Bytes) -> Response {
5831×
    let caller = match spectra_mcp::http::authenticate(&st.app, peer, &headers, "POST /radar/link", &body).await {
5841×
        Ok(c) => c,
5850×
        Err(r) => return r,
586
    };
5871×
    if !matches!(caller.principal, Principal::Owner { .. }) {
5880×
        return (StatusCode::FORBIDDEN, axum::Json(serde_json::json!({ "title": "only the owner signs in to Radar" }))).into_response();
5891×
    }
5901×
    let (Some(key), Some(proof)) = (caller.auth.key, caller.auth.proof) else {
5910×
        return StatusCode::UNAUTHORIZED.into_response();
592
    };
5931×
    let code = st.sessions().link(key, proof, now_ms());
5941×
    let base = st.app.origins.first().cloned().unwrap_or_default();
5951×
    axum::Json(serde_json::json!({ "url": format!("{base}/signin?code={code}"), "valid_ms": session::LINK_MS })).into_response()
5961×
}
597
598
#[derive(Deserialize)]
599
struct SigninQuery {
600
    code: Option<String>,
601
}
602
6032×
async fn signin(State(st): State<RadarState>, Query(q): Query<SigninQuery>) -> Response {
6042×
    let Some(id) = q.code.and_then(|c| st.sessions().open(&c, now_ms())) else {
6051×
        return signin_page("That sign-in link has been used or has expired.", StatusCode::UNAUTHORIZED);
606
    };
6071×
    let cookie = format!("{COOKIE}={id}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}", session::SESSION_MS / 1000);
6081×
    let mut r = Redirect::to("/").into_response();
6091×
    r.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
6101×
    r
6112×
}
612
6130×
async fn signout(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
6140×
    if let Ok(o) = owner(&st, &headers, "live").await {
6150×
        if check_form(&st, &o, &headers, &f._csrf).is_none() {
6160×
            if let Some(id) = cookie(&headers) {
6170×
                st.sessions().close(&id);
6180×
            }
6190×
        }
6200×
    }
6210×
    signin_page("Signed out.", StatusCode::OK)
6220×
}
623
624
// ---- The event stream ----
625
626
/// `GET /radar/events`: one SSE message per appended event, carrying its
627
/// sequence number, kind, actor and subject, never its data (spec §17.3).
6280×
async fn events(State(st): State<RadarState>, headers: HeaderMap) -> Response {
6290×
    if let Err(r) = owner(&st, &headers, "live").await {
6300×
        return r;
6310×
    }
6320×
    let rx = st.app.daemon.ledger.subscribe();
6330×
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|e| async move {
6340×
        let e = e.ok()?;
6350×
        let data = serde_json::json!({ "seq": e.seq, "kind": e.kind, "actor": e.actor, "subject": e.subject });
6360×
        Some(Ok::<_, std::convert::Infallible>(SseEvent::default().data(data.to_string())))
6370×
    });
6380×
    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response()
6390×
}