OXYGENHUB

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

502 of 590 lines run, 78 of 202 functions · run #29 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 {
4025×
    fn sessions(&self) -> &session::Sessions {
4125×
        &self.app.sessions
4225×
    }
43
}
44
45
/// Radar's routes.
4621×
pub fn router(app: AppState) -> Router {
4721×
    let state = RadarState { app };
4821×
    Router::new()
4921×
        .route("/", get(live))
5021×
        .route("/channels", get(channels))
5121×
        .route("/c/{name}", get(channel).post(post_channel))
5221×
        .route("/c/{name}/t/{id}", get(thread).post(post_thread))
5321×
        .route("/briefs", get(briefs).post(save_brief))
5421×
        .route("/constellation", get(constellation))
5521×
        .route("/radar/constellation.json", get(constellation_json))
5621×
        .route("/radar/constellation.js", get(|| async { asset("text/javascript; charset=utf-8", SKY_SCRIPT) }))
5721×
        .route("/log", get(log))
5821×
        .route("/log/verify", post(verify))
5921×
        .route("/radar/link", post(link))
6021×
        .route("/signin", get(signin))
6121×
        .route("/signout", post(signout))
6221×
        .route("/radar/events", get(events))
6321×
        .route("/radar/style.css", get(|| async { asset("text/css; charset=utf-8", STYLE) }))
6421×
        .route("/radar/radar.js", get(|| async { asset("text/javascript; charset=utf-8", SCRIPT) }))
6521×
        .route("/radar/spectra.svg", get(|| async { asset("image/svg+xml", MARK) }))
6621×
        .layer(axum::middleware::map_response(security_headers))
6721×
        .with_state(state)
6821×
}
69
700×
fn asset(kind: &'static str, body: &'static str) -> Response {
710×
    ([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "max-age=300")], body).into_response()
720×
}
73
74
/// CSP and friends on every response (spec §21.2).
7530×
async fn security_headers(mut r: Response) -> Response {
7630×
    let h = r.headers_mut();
7730×
    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'"));
7830×
    h.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
79
    // `same-origin`: a form post still carries our Origin (no-referrer makes it `null`).
8030×
    h.insert("referrer-policy", HeaderValue::from_static("same-origin"));
8130×
    h.insert("x-frame-options", HeaderValue::from_static("DENY"));
8230×
    r
8330×
}
84
85
/// What every page's frame shows.
86
pub struct Shell {
87
    pub page: &'static str,
88
    pub owner: String,
89
    pub head: u64,
90
    pub version: &'static str,
91
    pub mark: &'static str,
92
}
93
94
/// The signed-in owner, or the page that says how to sign in.
95
struct Owner {
96
    session: Session,
97
    principal: Principal,
98
    shell: Shell,
99
}
100
101
impl Owner {
1023×
    fn author(&self) -> Author<'static> {
1033×
        Author {
1043×
            principal: self.principal.clone(),
1053×
            signer: Signer::Request(Auth {
1063×
                key: Some(self.session.key),
1073×
                method: Method::Session,
1083×
                assurance: 2,
1093×
                proof: Some(self.session.proof.clone()),
1103×
            }),
1113×
        }
1123×
    }
113
}
114
11525×
fn cookie(headers: &HeaderMap) -> Option<String> {
11625×
    let c = headers.get(header::COOKIE)?.to_str().ok()?;
11720×
    c.split(';').map(str::trim).find_map(|kv| kv.strip_prefix(&format!("{COOKIE}=")).map(String::from))
11825×
}
119
12025×
async fn owner(st: &RadarState, headers: &HeaderMap, page: &'static str) -> Result<Owner, Response> {
12125×
    let now = now_ms();
12225×
    let session = cookie(headers)
12325×
        .and_then(|id| st.sessions().get(&id, now))
12425×
        .ok_or_else(|| signin_page("Radar shows Spectra to its owner only.", StatusCode::UNAUTHORIZED))?;
12520×
    let d = &st.app.daemon;
12620×
    let principal = d
12720×
        .principals
12820×
        .read()
12920×
        .await
13020×
        .principal(&key_id(session.key), now)
13120×
        .map_err(|why| signin_page(&format!("This session no longer belongs to the owner: {why}."), StatusCode::UNAUTHORIZED))?;
13220×
    if !matches!(principal, Principal::Owner { .. }) {
1330×
        return Err(signin_page("Radar shows Spectra to its owner only.", StatusCode::FORBIDDEN));
13420×
    }
13520×
    let shell = Shell { page, owner: principal.display(), head: d.ledger.head().await.0, version: env!("CARGO_PKG_VERSION"), mark: MARK };
13620×
    Ok(Owner { session, principal, shell })
13725×
}
138
13920×
fn key_id(k: [u8; 32]) -> spectra_core::identity::KeyId {
14020×
    spectra_core::identity::KeyId(k)
14120×
}
142
143
#[derive(Template)]
144
#[template(path = "signin.html")]
145
struct SigninPage<'a> {
146
    mark: &'static str,
147
    message: &'a str,
148
}
149
1506×
fn signin_page(message: &str, status: StatusCode) -> Response {
1516×
    (status, render(&SigninPage { mark: MARK, message })).into_response()
1526×
}
153
15418×
fn render<T: Template>(t: &T) -> Response {
15518×
    match t.render() {
15618×
        Ok(html) => Html(html).into_response(),
1570×
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("could not render the page: {e}")).into_response(),
158
    }
15918×
}
160
161
/// Unsafe requests: the session's CSRF token and our own origin.
162
/// The refusal, if the form may not go through.
1637×
fn check_form(st: &RadarState, o: &Owner, headers: &HeaderMap, csrf: &str) -> Option<Response> {
1647×
    if !spectra_mcp::http::origin_ok(headers, &st.app.origins) {
1651×
        return Some((StatusCode::FORBIDDEN, "this origin may not change Spectra").into_response());
1666×
    }
1676×
    if csrf != o.session.csrf {
1682×
        return Some((StatusCode::FORBIDDEN, "the form is stale: reload the page").into_response());
1694×
    }
1704×
    None
1717×
}
172
173
/// Markdown, sanitised: what a message's body becomes on a page.
1744×
fn markdown(body: &str) -> String {
1754×
    let mut html = String::new();
1764×
    pulldown_cmark::html::push_html(
1774×
        &mut html,
1784×
        pulldown_cmark::Parser::new_ext(body, pulldown_cmark::Options::ENABLE_STRIKETHROUGH | pulldown_cmark::Options::ENABLE_TABLES),
179
    );
1804×
    ammonia::clean(&html)
1814×
}
182
183
// ---- Live ----
184
185
struct AgentCard {
186
    name: String,
187
    owner: String,
188
    kind: String,
189
    project: String,
190
    presence: String,
191
    status: Option<String>,
192
    last_tool: Option<String>,
193
    last_at: String,
194
}
195
196
struct EventRow {
197
    seq: i64,
198
    at: String,
199
    kind: String,
200
    actor: String,
201
    subject: Option<String>,
202
}
203
204
#[derive(Template)]
205
#[template(path = "live.html")]
206
struct LivePage {
207
    shell: Shell,
208
    agents: Vec<AgentCard>,
209
    unread: i64,
210
    events: Vec<EventRow>,
211
}
212
2133×
async fn live(State(st): State<RadarState>, headers: HeaderMap) -> Response {
2143×
    let o = match owner(&st, &headers, "live").await {
2152×
        Ok(o) => o,
2161×
        Err(r) => return r,
217
    };
2182×
    let d = &st.app.daemon;
2192×
    let pool = d.ledger.pool();
2202×
    let rows: Vec<(String, String, String, String, Option<String>)> =
2212×
        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")
2222×
            .fetch_all(pool)
2232×
            .await
2242×
            .unwrap_or_default();
2252×
    let mut agents = Vec::new();
226
    {
2272×
        let now = now_ms();
2282×
        for (name, owner, kind, project, status) in rows {
2291×
            let last: Option<(String, i64)> =
2301×
                sqlx::query_as("SELECT tool, at_ms FROM tool_calls WHERE agent = ? ORDER BY seq DESC LIMIT 1")
2311×
                    .bind(&name)
2321×
                    .fetch_optional(pool)
2331×
                    .await
2341×
                    .ok()
2351×
                    .flatten();
2361×
            let presence = d.presence.lock().await.state(&name, now).to_string();
2371×
            agents.push(AgentCard {
2381×
                name,
2391×
                owner,
2401×
                kind,
2411×
                project,
2421×
                presence,
2431×
                status,
2441×
                last_tool: last.as_ref().map(|(t, _)| t.clone()),
2451×
                last_at: last.map(|(_, at)| format_time(at as u64)).unwrap_or_default(),
246
            });
247
        }
248
    }
2492×
    let unread = conv::unread(&d.ledger, "human").await.unwrap_or(0);
2502×
    let events = recent(&st, None, None, 50)
2512×
        .await
2522×
        .into_iter()
25324×
        .map(|e| EventRow { seq: e.seq, at: e.at, kind: e.kind, actor: e.actor, subject: e.subject })
2542×
        .collect();
2552×
    render(&LivePage { shell: o.shell, agents, unread, events })
2563×
}
257
258
// ---- Channels ----
259
260
struct ChannelRow {
261
    name: String,
262
    display: String,
263
    kind: String,
264
    purpose: String,
265
    count: i64,
266
    last: String,
267
}
268
269
/// A channel with its message count and last message time.
270
type ChannelCount = (String, String, String, Option<String>, i64, Option<i64>);
271
272
#[derive(Template)]
273
#[template(path = "channels.html")]
274
struct ChannelsPage {
275
    shell: Shell,
276
    channels: Vec<ChannelRow>,
277
}
278
2792×
async fn channels(State(st): State<RadarState>, headers: HeaderMap) -> Response {
2802×
    let o = match owner(&st, &headers, "channels").await {
2811×
        Ok(o) => o,
2821×
        Err(r) => return r,
283
    };
2841×
    let rows: Vec<ChannelCount> = sqlx::query_as(
2851×
        "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) \
2861×
         FROM channels c ORDER BY c.kind = 'direct', c.kind = 'system', c.name",
2871×
    )
2881×
    .fetch_all(st.app.daemon.ledger.pool())
2891×
    .await
2901×
    .unwrap_or_default();
2911×
    let channels = rows
2921×
        .into_iter()
2931×
        .map(|(_, name, kind, purpose, count, last)| ChannelRow {
2944×
            display: match name.strip_prefix("dm:") {
2950×
                Some(p) => format!("@{p}"),
2964×
                None => format!("#{name}"),
297
            },
2984×
            name,
2994×
            kind,
3004×
            purpose: purpose.unwrap_or_default(),
3014×
            count,
3024×
            last: last.map(|t| format_time(t as u64)).unwrap_or_default(),
3034×
        })
3041×
        .collect();
3051×
    render(&ChannelsPage { shell: o.shell, channels })
3062×
}
307
308
// ---- Constellation (spec §17.2) ----
309
310
const SKY_SCRIPT: &str = include_str!("../assets/constellation.js");
311
312
/// The picture's data: the owner, each project with its links and agents.
3132×
async fn sky(st: &RadarState, owner: &str) -> serde_json::Value {
3142×
    let d = &st.app.daemon;
3152×
    let pool = d.ledger.pool();
3162×
    let projects: Vec<(String, String)> =
3172×
        sqlx::query_as("SELECT name, description FROM projects ORDER BY seq").fetch_all(pool).await.unwrap_or_default();
3182×
    let links: Vec<(String, String)> =
3192×
        sqlx::query_as("SELECT project, depends_on FROM project_relations").fetch_all(pool).await.unwrap_or_default();
3202×
    let agents: Vec<(String, String, String)> =
3212×
        sqlx::query_as("SELECT name, kind, project FROM agents WHERE disabled_ms IS NULL ORDER BY name")
3222×
            .fetch_all(pool)
3232×
            .await
3242×
            .unwrap_or_default();
3252×
    let presence = d.presence.lock().await;
3262×
    let now = now_ms();
3272×
    let list: Vec<serde_json::Value> = projects
3282×
        .iter()
3292×
        .map(|(name, description)| {
3302×
            serde_json::json!({
3312×
                "name": name,
3322×
                "description": description,
3332×
                "depends_on": links.iter().filter(|(p, _)| p == name).map(|(_, q)| q).collect::<Vec<_>>(),
3342×
                "agents": agents.iter().filter(|(_, _, p)| p == name)
3352×
                    .map(|(a, kind, _)| serde_json::json!({ "name": a, "kind": kind, "presence": presence.state(a, now) }))
3362×
                    .collect::<Vec<_>>(),
337
            })
3382×
        })
3392×
        .collect();
3402×
    serde_json::json!({ "owner": owner, "projects": list })
3412×
}
342
3433×
fn esc(s: &str) -> String {
3443×
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
3453×
}
346
347
const HUES: [u32; 8] = [0, 30, 55, 120, 190, 230, 275, 320];
348
3491×
fn presence_colour(p: &str) -> &'static str {
3501×
    match p {
3511×
        "online" => "#3fb950",
3520×
        "busy" => "#a371f7",
3530×
        "idle" | "paused" => "#d29922",
3540×
        "blocked" => "#f0883e",
3550×
        _ => "#6e7681",
356
    }
3571×
}
358
359
/// The still picture, for a browser without JavaScript and for the first paint.
3601×
fn still(sky: &serde_json::Value) -> String {
3611×
    let (cx, cy, ring, orbit) = (450.0f64, 320.0f64, 205.0f64, 54.0f64);
3621×
    let projects = sky["projects"].as_array().cloned().unwrap_or_default();
3631×
    let n = projects.len().max(1) as f64;
3641×
    let mut at = std::collections::HashMap::new();
3651×
    let mut nodes = String::new();
3661×
    let mut lines = String::new();
3671×
    for (i, p) in projects.iter().enumerate() {
3681×
        let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * i as f64 / n;
3691×
        let (x, y) = (cx + ring * a.cos(), cy + ring * a.sin());
3701×
        let name = p["name"].as_str().unwrap_or("");
3711×
        at.insert(name.to_string(), (x, y));
3721×
        let hue = HUES[i % HUES.len()];
3731×
        lines.push_str(&format!(r#"<line class="spoke" x1="{cx}" y1="{cy}" x2="{x:.1}" y2="{y:.1}"/>"#));
3741×
        let s = 24.0;
3751×
        nodes.push_str(&format!(
3761×
            r#"<circle class="orbit" cx="{x:.1}" cy="{y:.1}" r="{orbit}"/><polygon class="prism" fill="hsl({hue},80%,60%)" points="{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}"/><text class="plabel" x="{x:.1}" y="{:.1}">{}</text>"#,
3771×
            x,
3781×
            y - s,
3791×
            x + s * 0.87,
3801×
            y + s * 0.5,
3811×
            x - s * 0.87,
3821×
            y + s * 0.5,
3831×
            y + orbit + 22.0,
3841×
            esc(name)
3851×
        ));
3861×
        let agents = p["agents"].as_array().cloned().unwrap_or_default();
3871×
        for (j, ag) in agents.iter().enumerate() {
3881×
            let b = std::f64::consts::TAU * j as f64 / agents.len().max(1) as f64;
3891×
            let (ax, ay) = (x + orbit * b.cos(), y + orbit * b.sin());
3901×
            nodes.push_str(&format!(
3911×
                r#"<circle class="agent" cx="{ax:.1}" cy="{ay:.1}" r="7" fill="{}"/><text class="alabel" x="{ax:.1}" y="{:.1}">{}</text>"#,
3921×
                presence_colour(ag["presence"].as_str().unwrap_or("")),
3931×
                ay + 18.0,
3941×
                esc(ag["name"].as_str().unwrap_or(""))
3951×
            ));
3961×
        }
397
    }
3981×
    for p in &projects {
3991×
        let from = at.get(p["name"].as_str().unwrap_or(""));
4001×
        for q in p["depends_on"].as_array().cloned().unwrap_or_default() {
4010×
            if let (Some((x1, y1)), Some((x2, y2))) = (from, at.get(q.as_str().unwrap_or(""))) {
4020×
                lines.push_str(&format!(r#"<path class="link" d="M{x1:.1},{y1:.1} Q{cx},{cy} {x2:.1},{y2:.1}"/>"#));
4030×
            }
404
        }
405
    }
4061×
    format!(
407
        r#"{lines}<circle class="core" cx="{cx}" cy="{cy}" r="22"/><text class="olabel" x="{cx}" y="{:.1}">{}</text>{nodes}"#,
4081×
        cy + 44.0,
4091×
        esc(sky["owner"].as_str().unwrap_or(""))
410
    )
4111×
}
412
413
#[derive(Template)]
414
#[template(path = "constellation.html")]
415
struct ConstellationPage {
416
    shell: Shell,
417
    still: String,
418
    data: String,
419
    empty: bool,
420
}
421
4221×
async fn constellation(State(st): State<RadarState>, headers: HeaderMap) -> Response {
4231×
    let o = match owner(&st, &headers, "constellation").await {
4241×
        Ok(o) => o,
4250×
        Err(r) => return r,
426
    };
4271×
    let sky = sky(&st, &o.principal.display()).await;
4281×
    let empty = sky["projects"].as_array().is_none_or(|a| a.is_empty());
429
    // JSON inside a <script> data block: no `<` may close it early.
4301×
    let data = sky.to_string().replace('<', "\\u003c");
4311×
    render(&ConstellationPage { shell: o.shell, still: still(&sky), data, empty })
4321×
}
433
4342×
async fn constellation_json(State(st): State<RadarState>, headers: HeaderMap) -> Response {
4352×
    match owner(&st, &headers, "constellation").await {
4361×
        Ok(o) => axum::Json(sky(&st, &o.principal.display()).await).into_response(),
4371×
        Err(r) => r,
438
    }
4392×
}
440
441
// ---- Briefs (spec §12.1) ----
442
443
struct ProjectBriefRow {
444
    name: String,
445
    description: String,
446
    text: String,
447
}
448
449
#[derive(Template)]
450
#[template(path = "briefs.html")]
451
struct BriefsPage {
452
    shell: Shell,
453
    csrf: String,
454
    overall: String,
455
    projects: Vec<ProjectBriefRow>,
456
}
457
4582×
async fn briefs(State(st): State<RadarState>, headers: HeaderMap) -> Response {
4592×
    let o = match owner(&st, &headers, "briefs").await {
4602×
        Ok(o) => o,
4610×
        Err(r) => return r,
462
    };
4632×
    let pool = st.app.daemon.ledger.pool();
4642×
    let overall = spectra_core::rules::layer(pool, None).await.ok().flatten().unwrap_or_default();
4652×
    let rows: Vec<(String, String, Option<String>)> = sqlx::query_as(
4662×
        "SELECT p.name, p.description, b.text FROM projects p LEFT JOIN briefs b ON b.layer = 'project' AND b.project = p.name ORDER BY p.name",
4672×
    )
4682×
    .fetch_all(pool)
4692×
    .await
4702×
    .unwrap_or_default();
4712×
    let projects =
4722×
        rows.into_iter().map(|(name, description, text)| ProjectBriefRow { name, description, text: text.unwrap_or_default() }).collect();
4732×
    let csrf = o.session.csrf.clone();
4742×
    render(&BriefsPage { shell: o.shell, csrf, overall, projects })
4752×
}
476
477
#[derive(Deserialize)]
478
struct BriefForm {
479
    _csrf: String,
480
    project: Option<String>,
481
    text: String,
482
}
483
4842×
async fn save_brief(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<BriefForm>) -> Response {
4852×
    let o = match owner(&st, &headers, "briefs").await {
4862×
        Ok(o) => o,
4870×
        Err(r) => return r,
488
    };
4892×
    if let Some(r) = check_form(&st, &o, &headers, &f._csrf) {
4901×
        return r;
4911×
    }
492
    // Browsers send CRLF from a textarea; the brief is stored as written.
4931×
    let text = f.text.replace("\r\n", "\n");
4941×
    if let Err(e) = spectra_core::rules::check(&text) {
4950×
        return (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response();
4961×
    }
4971×
    let project = f.project.filter(|p| !p.is_empty());
4981×
    let pool = st.app.daemon.ledger.pool();
4991×
    if let Some(p) = &project {
5001×
        let known: Option<String> =
5011×
            sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(p).fetch_optional(pool).await.unwrap_or(None);
5021×
        if known.is_none() {
5030×
            return (StatusCode::NOT_FOUND, "no such project").into_response();
5041×
        }
5050×
    }
5061×
    let Signer::Request(auth) = o.author().signer else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() };
5071×
    let mut e =
5081×
        spectra_core::event::NewEvent::system("brief.set", serde_json::json!({ "project": project, "text": text })).by("human", auth);
5091×
    e.project = project.clone();
5101×
    match st.app.daemon.ledger.append_one(e).await {
511
        Ok(_) => {
5121×
            Redirect::to(&format!("/briefs#{}", project.map(|p| format!("p-{p}")).unwrap_or_else(|| "overall".into()))).into_response()
513
        }
5140×
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
515
    }
5162×
}
517
518
// ---- A channel and a thread ----
519
520
struct MessageRow {
521
    id: String,
522
    author: String,
523
    at: String,
524
    kind: String,
525
    replies: i64,
526
    redacted: bool,
527
    body_html: String,
528
}
529
530
#[derive(Template)]
531
#[template(path = "channel.html")]
532
struct ChannelPage {
533
    shell: Shell,
534
    name: String,
535
    display: String,
536
    purpose: Option<String>,
537
    brief: Option<String>,
538
    thread: Option<String>,
539
    messages: Vec<MessageRow>,
540
    can_post: bool,
541
    action: String,
542
    csrf: String,
543
}
544
5454×
async fn show(st: &RadarState, o: Owner, name: &str, thread: Option<&str>) -> Response {
5464×
    let d = &st.app.daemon;
5474×
    let (ch, msgs) = match conv::read(&d.ledger, &o.principal, if thread.is_some() { None } else { Some(name) }, thread, None, 200).await {
5484×
        Ok(r) => r,
5490×
        Err(e) => return (StatusCode::NOT_FOUND, format!("{e:#}")).into_response(),
550
    };
5514×
    let mut messages = Vec::new();
5524×
    for m in msgs {
5533×
        messages.push(MessageRow {
5543×
            author: spectra_core::conversation::display_name(&d.ledger, &m.author).await,
5553×
            at: format_time(m.at_ms as u64),
5563×
            redacted: m.redacted.is_some(),
5573×
            body_html: if m.redacted.is_some() { ammonia::clean_text(&m.body) } else { markdown(&m.body) },
5583×
            id: m.id,
5593×
            kind: m.kind,
5603×
            replies: m.replies,
561
        });
562
    }
5634×
    let can_post = conv::can_post(&o.principal, &ch).is_ok();
5644×
    let action = match thread {
5650×
        Some(t) => format!("/c/{}/t/{t}", ch.name),
5664×
        None => format!("/c/{}", ch.name),
567
    };
5684×
    let csrf = o.session.csrf.clone();
5694×
    let brief = if ch.kind == "project" { spectra_core::rules::layer(d.ledger.pool(), Some(&ch.name)).await.ok().flatten() } else { None };
5704×
    let brief = brief.map(|b| markdown(&b));
5714×
    render(&ChannelPage {
5724×
        shell: o.shell,
5734×
        name: ch.name.clone(),
5744×
        display: ch.display(),
5754×
        purpose: ch.purpose.clone(),
5764×
        brief,
5774×
        thread: thread.map(String::from),
5784×
        messages,
5794×
        can_post,
5804×
        action,
5814×
        csrf,
5824×
    })
5834×
}
584
5855×
async fn channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>) -> Response {
5865×
    match owner(&st, &headers, "channels").await {
5874×
        Ok(o) => show(&st, o, &name, None).await,
5881×
        Err(r) => r,
589
    }
5905×
}
591
5920×
async fn thread(State(st): State<RadarState>, headers: HeaderMap, Path((name, id)): Path<(String, String)>) -> Response {
5930×
    match owner(&st, &headers, "channels").await {
5940×
        Ok(o) => show(&st, o, &name, Some(&id)).await,
5950×
        Err(r) => r,
596
    }
5970×
}
598
599
#[derive(Deserialize)]
600
struct PostForm {
601
    _csrf: String,
602
    body: String,
603
    reply_to: Option<String>,
604
}
605
6064×
async fn post_to(st: &RadarState, headers: &HeaderMap, name: &str, form: PostForm, back: String) -> Response {
6074×
    let o = match owner(st, headers, "channels").await {
6084×
        Ok(o) => o,
6090×
        Err(r) => return r,
610
    };
6114×
    if let Some(r) = check_form(st, &o, headers, &form._csrf) {
6122×
        return r;
6132×
    }
6142×
    let post = Post {
6152×
        channel: if form.reply_to.is_some() { None } else { Some(name.to_string()) },
6162×
        body: form.body,
6172×
        reply_to: form.reply_to,
6182×
        ..Default::default()
619
    };
6202×
    match conv::post(&st.app.daemon.ledger, &o.author(), post, now_ms()).await {
6212×
        Ok(_) => Redirect::to(&back).into_response(),
6220×
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
623
    }
6244×
}
625
6264×
async fn post_channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>, Form(form): Form<PostForm>) -> Response {
6274×
    let back = format!("/c/{name}");
6284×
    post_to(&st, &headers, &name, form, back).await
6294×
}
630
6310×
async fn post_thread(
6320×
    State(st): State<RadarState>,
6330×
    headers: HeaderMap,
6340×
    Path((name, id)): Path<(String, String)>,
6350×
    Form(mut form): Form<PostForm>,
6360×
) -> Response {
6370×
    form.reply_to = Some(id.clone());
6380×
    let back = format!("/c/{name}/t/{id}");
6390×
    post_to(&st, &headers, &name, form, back).await
6400×
}
641
642
// ---- The log ----
643
644
struct LogRow {
645
    seq: i64,
646
    id: String,
647
    at: String,
648
    kind: String,
649
    actor: String,
650
    subject: Option<String>,
651
    method: String,
652
    assurance: i64,
653
    hash: String,
654
    prev: String,
655
    key: String,
656
    proof: String,
657
    payload: String,
658
}
659
6604×
async fn recent(st: &RadarState, kind: Option<&str>, before: Option<i64>, limit: i64) -> Vec<LogRow> {
661
    #[allow(clippy::type_complexity)]
6624×
    let rows: Vec<(
6634×
        i64,
6644×
        String,
6654×
        i64,
6664×
        String,
6674×
        String,
6684×
        Option<String>,
6694×
        i64,
6704×
        i64,
6714×
        Vec<u8>,
6724×
        Vec<u8>,
6734×
        Option<Vec<u8>>,
6744×
        Option<Vec<u8>>,
6754×
        Option<Vec<u8>>,
6764×
    )> = sqlx::query_as(
6774×
        "SELECT seq, id, at_ms, kind, actor, subject, auth_method, auth_assurance, hash, prev, auth_key, auth_proof, payload FROM events \
6784×
         WHERE (? IS NULL OR kind = ?) AND (? IS NULL OR seq < ?) ORDER BY seq DESC LIMIT ?",
6794×
    )
6804×
    .bind(kind)
6814×
    .bind(kind)
6824×
    .bind(before)
6834×
    .bind(before)
6844×
    .bind(limit)
6854×
    .fetch_all(st.app.daemon.ledger.pool())
6864×
    .await
6874×
    .unwrap_or_default();
6884×
    let mut out = Vec::new();
68959×
    for (seq, id, at, kind, actor, subject, method, assurance, hash, prev, key, proof, payload) in rows {
69059×
        let payload = payload
69159×
            .and_then(|p| serde_json::from_slice::<serde_json::Value>(&p).ok())
69259×
            .map(|v| serde_json::to_string_pretty(&v).unwrap_or_default())
69359×
            .unwrap_or_else(|| "(pruned to its stub)".into());
69459×
        out.push(LogRow {
69559×
            seq,
69659×
            id,
69759×
            at: format_time(at as u64),
69859×
            kind,
69959×
            actor: spectra_core::conversation::display_name(&st.app.daemon.ledger, &actor).await,
70059×
            subject,
70159×
            method: Method::from_u8(method as u8).map(|m| m.name()).unwrap_or("?").to_string(),
70259×
            assurance,
70359×
            hash: hex::encode(hash),
70459×
            prev: hex::encode(prev),
70559×
            key: key.map(hex::encode).unwrap_or_else(|| "none (Spectra itself; covered by the next checkpoint)".into()),
70659×
            proof: proof.map(|p| format!("{} bytes", p.len())).unwrap_or_else(|| "none".into()),
70759×
            payload,
708
        });
709
    }
7104×
    out
7114×
}
712
713
#[derive(Template)]
714
#[template(path = "log.html")]
715
struct LogPage {
716
    shell: Shell,
717
    head_hash: String,
718
    verified: String,
719
    csrf: String,
720
    kinds: Vec<String>,
721
    kind: String,
722
    events: Vec<LogRow>,
723
    older: Option<i64>,
724
}
725
726
#[derive(Deserialize)]
727
struct LogQuery {
728
    kind: Option<String>,
729
    before: Option<i64>,
730
    around: Option<i64>,
731
}
732
7333×
async fn log(State(st): State<RadarState>, headers: HeaderMap, Query(q): Query<LogQuery>) -> Response {
7343×
    let o = match owner(&st, &headers, "log").await {
7352×
        Ok(o) => o,
7361×
        Err(r) => return r,
737
    };
7382×
    let d = &st.app.daemon;
7392×
    let (_, head) = d.ledger.head().await;
7402×
    let last: Option<(Vec<u8>, i64)> =
7412×
        sqlx::query_as("SELECT payload, at_ms FROM events WHERE kind = 'ledger.verified' ORDER BY seq DESC LIMIT 1")
7422×
            .fetch_optional(d.ledger.pool())
7432×
            .await
7442×
            .ok()
7452×
            .flatten();
7462×
    let verified = match last {
7471×
        Some((p, at)) => {
7481×
            let v: serde_json::Value = serde_json::from_slice(&p).unwrap_or_default();
7491×
            let n = v["findings"].as_u64().unwrap_or(0);
7501×
            format!(
751
                "{} at {} over {} events",
7521×
                if n == 0 { "clean".to_string() } else { format!("{n} finding(s)") },
7531×
                format_time(at as u64),
7541×
                v["events"].as_u64().unwrap_or(0)
755
            )
756
        }
7571×
        None => "not yet".into(),
758
    };
7592×
    let kinds: Vec<String> =
7602×
        sqlx::query_scalar("SELECT kind FROM event_counts ORDER BY n DESC LIMIT 12").fetch_all(d.ledger.pool()).await.unwrap_or_default();
7612×
    let kind = q.kind.unwrap_or_default();
7622×
    let before = q.before.or(q.around.map(|a| a + 25));
7632×
    let events = recent(&st, (!kind.is_empty()).then_some(kind.as_str()), before, 100).await;
7642×
    let older = (events.len() == 100).then(|| events.last().map(|e| e.seq)).flatten();
7652×
    let csrf = o.session.csrf.clone();
7662×
    render(&LogPage { shell: o.shell, head_hash: hex::encode(head), verified, csrf, kinds, kind, events, older })
7673×
}
768
769
#[derive(Deserialize)]
770
struct CsrfForm {
771
    _csrf: String,
772
}
773
774
/// Verify the ledger now and record the result (`ledger.verified`).
7751×
async fn verify(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
7761×
    let o = match owner(&st, &headers, "log").await {
7771×
        Ok(o) => o,
7780×
        Err(r) => return r,
779
    };
7801×
    if let Some(r) = check_form(&st, &o, &headers, &f._csrf) {
7810×
        return r;
7821×
    }
7831×
    let ledger = &st.app.daemon.ledger;
7841×
    match ledger.verify(&mut Principals::default()).await {
7851×
        Ok(v) => {
7861×
            let e = spectra_core::event::NewEvent::system(
7871×
                "ledger.verified",
7881×
                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)) }),
789
            );
7901×
            let _ = ledger.append_one(e).await;
7911×
            Redirect::to("/log").into_response()
792
        }
7930×
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(),
794
    }
7951×
}
796
797
// ---- Signing in ----
798
799
/// `POST /radar/link`, signed by the owner's CLI: a one-time sign-in link.
8002×
async fn link(State(st): State<RadarState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, body: Bytes) -> Response {
8012×
    let caller = match spectra_mcp::http::authenticate(&st.app, peer, &headers, "POST /radar/link", &body).await {
8022×
        Ok(c) => c,
8030×
        Err(r) => return r,
804
    };
8052×
    if !matches!(caller.principal, Principal::Owner { .. }) {
8060×
        return (StatusCode::FORBIDDEN, axum::Json(serde_json::json!({ "title": "only the owner signs in to Radar" }))).into_response();
8072×
    }
8082×
    let (Some(key), Some(proof)) = (caller.auth.key, caller.auth.proof) else {
8090×
        return StatusCode::UNAUTHORIZED.into_response();
810
    };
8112×
    let code = st.sessions().link(key, proof, now_ms());
8122×
    let base = st.app.origins.first().cloned().unwrap_or_default();
8132×
    axum::Json(serde_json::json!({ "url": format!("{base}/signin?code={code}"), "valid_ms": session::LINK_MS })).into_response()
8142×
}
815
816
#[derive(Deserialize)]
817
struct SigninQuery {
818
    code: Option<String>,
819
    /// Where to go once signed in: one of Radar's own pages.
820
    next: Option<String>,
821
}
822
823
/// A page of Radar's own to land on, never another site.
8242×
fn landing(next: Option<&str>) -> &'static str {
8252×
    match next.unwrap_or("") {
8262×
        "constellation" | "/constellation" => "/constellation",
8272×
        "briefs" | "/briefs" => "/briefs",
8282×
        "channels" | "/channels" => "/channels",
8292×
        "log" | "/log" => "/log",
8302×
        _ => "/",
831
    }
8322×
}
833
8343×
async fn signin(State(st): State<RadarState>, Query(q): Query<SigninQuery>) -> Response {
8353×
    let Some(id) = q.code.and_then(|c| st.sessions().open(&c, now_ms())) else {
8361×
        return signin_page("That sign-in link has been used or has expired.", StatusCode::UNAUTHORIZED);
837
    };
8382×
    let cookie = format!("{COOKIE}={id}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}", session::SESSION_MS / 1000);
8392×
    let mut r = Redirect::to(landing(q.next.as_deref())).into_response();
8402×
    r.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
8412×
    r
8423×
}
843
8440×
async fn signout(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
8450×
    if let Ok(o) = owner(&st, &headers, "live").await {
8460×
        if check_form(&st, &o, &headers, &f._csrf).is_none() {
8470×
            if let Some(id) = cookie(&headers) {
8480×
                st.sessions().close(&id);
8490×
            }
8500×
        }
8510×
    }
8520×
    signin_page("Signed out.", StatusCode::OK)
8530×
}
854
855
// ---- The event stream ----
856
857
/// `GET /radar/events`: one SSE message per appended event, carrying its
858
/// sequence number, kind, actor and subject, never its data (spec §17.3).
8590×
async fn events(State(st): State<RadarState>, headers: HeaderMap) -> Response {
8600×
    if let Err(r) = owner(&st, &headers, "live").await {
8610×
        return r;
8620×
    }
8630×
    let rx = st.app.daemon.ledger.subscribe();
8640×
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|e| async move {
8650×
        let e = e.ok()?;
8660×
        let data = serde_json::json!({ "seq": e.seq, "kind": e.kind, "actor": e.actor, "subject": e.subject, "project": e.project });
8670×
        Some(Ok::<_, std::convert::Infallible>(SseEvent::default().data(data.to_string())))
8680×
    });
8690×
    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response()
8700×
}