OXYGENHUB

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

527 of 605 lines run, 83 of 208 functions · run #33 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 {
4028×
    fn sessions(&self) -> &session::Sessions {
4128×
        &self.app.sessions
4228×
    }
43
}
44
45
/// Radar's routes.
4624×
pub fn router(app: AppState) -> Router {
4724×
    let state = RadarState { app };
4824×
    Router::new()
4924×
        .route("/", get(live))
5024×
        .route("/channels", get(channels))
5124×
        .route("/c/{name}", get(channel).post(post_channel))
5224×
        .route("/c/{name}/t/{id}", get(thread).post(post_thread))
5324×
        .route("/briefs", get(briefs).post(save_brief))
5424×
        .route("/constellation", get(constellation))
5524×
        .route("/radar/constellation.json", get(constellation_json))
5624×
        .route("/radar/constellation.js", get(|| async { asset("text/javascript; charset=utf-8", SKY_SCRIPT) }))
5724×
        .route("/log", get(log))
5824×
        .route("/log/verify", post(verify))
5924×
        .route("/radar/link", post(link))
6024×
        .route("/signin", get(signin))
6124×
        .route("/signout", post(signout))
6224×
        .route("/radar/events", get(events))
6324×
        .route("/radar/style.css", get(|| async { asset("text/css; charset=utf-8", STYLE) }))
6424×
        .route("/radar/radar.js", get(|| async { asset("text/javascript; charset=utf-8", SCRIPT) }))
6524×
        .route("/radar/spectra.svg", get(|| async { asset("image/svg+xml", MARK) }))
6624×
        .layer(axum::middleware::map_response(security_headers))
6724×
        .with_state(state)
6824×
}
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).
7533×
async fn security_headers(mut r: Response) -> Response {
7633×
    let h = r.headers_mut();
7733×
    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'"));
7833×
    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`).
8033×
    h.insert("referrer-policy", HeaderValue::from_static("same-origin"));
8133×
    h.insert("x-frame-options", HeaderValue::from_static("DENY"));
8233×
    r
8333×
}
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 {
1024×
    fn author(&self) -> Author<'static> {
1034×
        Author {
1044×
            principal: self.principal.clone(),
1054×
            signer: Signer::Request(Auth {
1064×
                key: Some(self.session.key),
1074×
                method: Method::Session,
1084×
                assurance: 2,
1094×
                proof: Some(self.session.proof.clone()),
1104×
            }),
1114×
        }
1124×
    }
113
}
114
11528×
fn cookie(headers: &HeaderMap) -> Option<String> {
11628×
    let c = headers.get(header::COOKIE)?.to_str().ok()?;
11723×
    c.split(';').map(str::trim).find_map(|kv| kv.strip_prefix(&format!("{COOKIE}=")).map(String::from))
11828×
}
119
12028×
async fn owner(st: &RadarState, headers: &HeaderMap, page: &'static str) -> Result<Owner, Response> {
12128×
    let now = now_ms();
12228×
    let session = cookie(headers)
12328×
        .and_then(|id| st.sessions().get(&id, now))
12428×
        .ok_or_else(|| signin_page("Radar shows Spectra to its owner only.", StatusCode::UNAUTHORIZED))?;
12523×
    let d = &st.app.daemon;
12623×
    let principal = d
12723×
        .principals
12823×
        .read()
12923×
        .await
13023×
        .principal(&key_id(session.key), now)
13123×
        .map_err(|why| signin_page(&format!("This session no longer belongs to the owner: {why}."), StatusCode::UNAUTHORIZED))?;
13223×
    if !matches!(principal, Principal::Owner { .. }) {
1330×
        return Err(signin_page("Radar shows Spectra to its owner only.", StatusCode::FORBIDDEN));
13423×
    }
13523×
    let shell = Shell { page, owner: principal.display(), head: d.ledger.head().await.0, version: env!("CARGO_PKG_VERSION"), mark: MARK };
13623×
    Ok(Owner { session, principal, shell })
13728×
}
138
13923×
fn key_id(k: [u8; 32]) -> spectra_core::identity::KeyId {
14023×
    spectra_core::identity::KeyId(k)
14123×
}
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
15420×
fn render<T: Template>(t: &T) -> Response {
15520×
    match t.render() {
15620×
        Ok(html) => Html(html).into_response(),
1570×
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("could not render the page: {e}")).into_response(),
158
    }
15920×
}
160
161
/// Unsafe requests: the session's CSRF token and our own origin.
162
/// The refusal, if the form may not go through.
1638×
fn check_form(st: &RadarState, o: &Owner, headers: &HeaderMap, csrf: &str) -> Option<Response> {
1648×
    if !spectra_mcp::http::origin_ok(headers, &st.app.origins) {
1651×
        return Some((StatusCode::FORBIDDEN, "this origin may not change Spectra").into_response());
1667×
    }
1677×
    if csrf != o.session.csrf {
1682×
        return Some((StatusCode::FORBIDDEN, "the form is stale: reload the page").into_response());
1695×
    }
1705×
    None
1718×
}
172
173
/// Markdown, sanitised: what a message's body becomes on a page.
1749×
fn markdown(body: &str) -> String {
1759×
    let mut html = String::new();
1769×
    pulldown_cmark::html::push_html(
1779×
        &mut html,
1789×
        pulldown_cmark::Parser::new_ext(body, pulldown_cmark::Options::ENABLE_STRIKETHROUGH | pulldown_cmark::Options::ENABLE_TABLES),
179
    );
1809×
    ammonia::clean(&html)
1819×
}
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
    /// The latest replies, shown under the message in a channel.
529
    latest: Vec<MessageRow>,
530
    /// Replies not shown inline.
531
    more: i64,
532
}
533
534
/// Replies shown under each message in a channel; the rest are a click away.
535
const INLINE_REPLIES: usize = 3;
536
5378×
async fn row(d: &spectra_core::daemon::Daemon, m: spectra_core::conversation::Message) -> MessageRow {
538
    MessageRow {
5398×
        author: spectra_core::conversation::display_name(&d.ledger, &m.author).await,
5408×
        at: format_time(m.at_ms as u64),
5418×
        redacted: m.redacted.is_some(),
5428×
        body_html: if m.redacted.is_some() { ammonia::clean_text(&m.body) } else { markdown(&m.body) },
5438×
        id: m.id,
5448×
        kind: m.kind,
5458×
        replies: m.replies,
5468×
        latest: Vec::new(),
547
        more: 0,
548
    }
5498×
}
550
551
#[derive(Template)]
552
#[template(path = "channel.html")]
553
struct ChannelPage {
554
    shell: Shell,
555
    name: String,
556
    display: String,
557
    purpose: Option<String>,
558
    brief: Option<String>,
559
    thread: Option<String>,
560
    messages: Vec<MessageRow>,
561
    can_post: bool,
562
    action: String,
563
    csrf: String,
564
}
565
5666×
async fn show(st: &RadarState, o: Owner, name: &str, thread: Option<&str>) -> Response {
5676×
    let d = &st.app.daemon;
5686×
    let (ch, msgs) = match conv::read(&d.ledger, &o.principal, if thread.is_some() { None } else { Some(name) }, thread, None, 200).await {
5696×
        Ok(r) => r,
5700×
        Err(e) => return (StatusCode::NOT_FOUND, format!("{e:#}")).into_response(),
571
    };
5726×
    let mut messages = Vec::new();
5737×
    for m in msgs {
5747×
        let mut r = row(d, m).await;
575
        // In a channel, a message's latest replies show beneath it, so a
576
        // reply is never hidden behind a link.
5777×
        if thread.is_none() && r.replies > 0 {
5781×
            if let Ok((_, all)) = conv::read(&d.ledger, &o.principal, None, Some(&r.id), None, 500).await {
5792×
                let replies: Vec<_> = all.into_iter().filter(|x| x.id != r.id).collect();
5801×
                let skip = replies.len().saturating_sub(INLINE_REPLIES);
5811×
                r.more = skip as i64;
5821×
                for x in replies.into_iter().skip(skip) {
5831×
                    r.latest.push(row(d, x).await);
584
                }
5850×
            }
5866×
        }
5877×
        messages.push(r);
588
    }
5896×
    let can_post = conv::can_post(&o.principal, &ch).is_ok();
5906×
    let action = match thread {
5910×
        Some(t) => format!("/c/{}/t/{t}", ch.name),
5926×
        None => format!("/c/{}", ch.name),
593
    };
5946×
    let csrf = o.session.csrf.clone();
5956×
    let brief = if ch.kind == "project" { spectra_core::rules::layer(d.ledger.pool(), Some(&ch.name)).await.ok().flatten() } else { None };
5966×
    let brief = brief.map(|b| markdown(&b));
5976×
    render(&ChannelPage {
5986×
        shell: o.shell,
5996×
        name: ch.name.clone(),
6006×
        display: ch.display(),
6016×
        purpose: ch.purpose.clone(),
6026×
        brief,
6036×
        thread: thread.map(String::from),
6046×
        messages,
6056×
        can_post,
6066×
        action,
6076×
        csrf,
6086×
    })
6096×
}
610
6117×
async fn channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>) -> Response {
6127×
    match owner(&st, &headers, "channels").await {
6136×
        Ok(o) => show(&st, o, &name, None).await,
6141×
        Err(r) => r,
615
    }
6167×
}
617
6180×
async fn thread(State(st): State<RadarState>, headers: HeaderMap, Path((name, id)): Path<(String, String)>) -> Response {
6190×
    match owner(&st, &headers, "channels").await {
6200×
        Ok(o) => show(&st, o, &name, Some(&id)).await,
6210×
        Err(r) => r,
622
    }
6230×
}
624
625
#[derive(Deserialize)]
626
struct PostForm {
627
    _csrf: String,
628
    body: String,
629
    reply_to: Option<String>,
630
}
631
6325×
async fn post_to(st: &RadarState, headers: &HeaderMap, name: &str, form: PostForm, back: String) -> Response {
6335×
    let o = match owner(st, headers, "channels").await {
6345×
        Ok(o) => o,
6350×
        Err(r) => return r,
636
    };
6375×
    if let Some(r) = check_form(st, &o, headers, &form._csrf) {
6382×
        return r;
6393×
    }
6403×
    let post = Post {
6413×
        channel: if form.reply_to.is_some() { None } else { Some(name.to_string()) },
6423×
        body: form.body,
6433×
        reply_to: form.reply_to,
6443×
        ..Default::default()
645
    };
6463×
    match conv::post(&st.app.daemon.ledger, &o.author(), post, now_ms()).await {
6473×
        Ok(_) => Redirect::to(&back).into_response(),
6480×
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
649
    }
6505×
}
651
6524×
async fn post_channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>, Form(form): Form<PostForm>) -> Response {
6534×
    let back = format!("/c/{name}");
6544×
    post_to(&st, &headers, &name, form, back).await
6554×
}
656
6571×
async fn post_thread(
6581×
    State(st): State<RadarState>,
6591×
    headers: HeaderMap,
6601×
    Path((name, id)): Path<(String, String)>,
6611×
    Form(mut form): Form<PostForm>,
6621×
) -> Response {
6631×
    form.reply_to = Some(id.clone());
6641×
    let back = format!("/c/{name}/t/{id}");
6651×
    post_to(&st, &headers, &name, form, back).await
6661×
}
667
668
// ---- The log ----
669
670
struct LogRow {
671
    seq: i64,
672
    id: String,
673
    at: String,
674
    kind: String,
675
    actor: String,
676
    subject: Option<String>,
677
    method: String,
678
    assurance: i64,
679
    hash: String,
680
    prev: String,
681
    key: String,
682
    proof: String,
683
    payload: String,
684
}
685
6864×
async fn recent(st: &RadarState, kind: Option<&str>, before: Option<i64>, limit: i64) -> Vec<LogRow> {
687
    #[allow(clippy::type_complexity)]
6884×
    let rows: Vec<(
6894×
        i64,
6904×
        String,
6914×
        i64,
6924×
        String,
6934×
        String,
6944×
        Option<String>,
6954×
        i64,
6964×
        i64,
6974×
        Vec<u8>,
6984×
        Vec<u8>,
6994×
        Option<Vec<u8>>,
7004×
        Option<Vec<u8>>,
7014×
        Option<Vec<u8>>,
7024×
    )> = sqlx::query_as(
7034×
        "SELECT seq, id, at_ms, kind, actor, subject, auth_method, auth_assurance, hash, prev, auth_key, auth_proof, payload FROM events \
7044×
         WHERE (? IS NULL OR kind = ?) AND (? IS NULL OR seq < ?) ORDER BY seq DESC LIMIT ?",
7054×
    )
7064×
    .bind(kind)
7074×
    .bind(kind)
7084×
    .bind(before)
7094×
    .bind(before)
7104×
    .bind(limit)
7114×
    .fetch_all(st.app.daemon.ledger.pool())
7124×
    .await
7134×
    .unwrap_or_default();
7144×
    let mut out = Vec::new();
71560×
    for (seq, id, at, kind, actor, subject, method, assurance, hash, prev, key, proof, payload) in rows {
71660×
        let payload = payload
71760×
            .and_then(|p| serde_json::from_slice::<serde_json::Value>(&p).ok())
71860×
            .map(|v| serde_json::to_string_pretty(&v).unwrap_or_default())
71960×
            .unwrap_or_else(|| "(pruned to its stub)".into());
72060×
        out.push(LogRow {
72160×
            seq,
72260×
            id,
72360×
            at: format_time(at as u64),
72460×
            kind,
72560×
            actor: spectra_core::conversation::display_name(&st.app.daemon.ledger, &actor).await,
72660×
            subject,
72760×
            method: Method::from_u8(method as u8).map(|m| m.name()).unwrap_or("?").to_string(),
72860×
            assurance,
72960×
            hash: hex::encode(hash),
73060×
            prev: hex::encode(prev),
73160×
            key: key.map(hex::encode).unwrap_or_else(|| "none (Spectra itself; covered by the next checkpoint)".into()),
73260×
            proof: proof.map(|p| format!("{} bytes", p.len())).unwrap_or_else(|| "none".into()),
73360×
            payload,
734
        });
735
    }
7364×
    out
7374×
}
738
739
#[derive(Template)]
740
#[template(path = "log.html")]
741
struct LogPage {
742
    shell: Shell,
743
    head_hash: String,
744
    verified: String,
745
    csrf: String,
746
    kinds: Vec<String>,
747
    kind: String,
748
    events: Vec<LogRow>,
749
    older: Option<i64>,
750
}
751
752
#[derive(Deserialize)]
753
struct LogQuery {
754
    kind: Option<String>,
755
    before: Option<i64>,
756
    around: Option<i64>,
757
}
758
7593×
async fn log(State(st): State<RadarState>, headers: HeaderMap, Query(q): Query<LogQuery>) -> Response {
7603×
    let o = match owner(&st, &headers, "log").await {
7612×
        Ok(o) => o,
7621×
        Err(r) => return r,
763
    };
7642×
    let d = &st.app.daemon;
7652×
    let (_, head) = d.ledger.head().await;
7662×
    let last: Option<(Vec<u8>, i64)> =
7672×
        sqlx::query_as("SELECT payload, at_ms FROM events WHERE kind = 'ledger.verified' ORDER BY seq DESC LIMIT 1")
7682×
            .fetch_optional(d.ledger.pool())
7692×
            .await
7702×
            .ok()
7712×
            .flatten();
7722×
    let verified = match last {
7731×
        Some((p, at)) => {
7741×
            let v: serde_json::Value = serde_json::from_slice(&p).unwrap_or_default();
7751×
            let n = v["findings"].as_u64().unwrap_or(0);
7761×
            format!(
777
                "{} at {} over {} events",
7781×
                if n == 0 { "clean".to_string() } else { format!("{n} finding(s)") },
7791×
                format_time(at as u64),
7801×
                v["events"].as_u64().unwrap_or(0)
781
            )
782
        }
7831×
        None => "not yet".into(),
784
    };
7852×
    let kinds: Vec<String> =
7862×
        sqlx::query_scalar("SELECT kind FROM event_counts ORDER BY n DESC LIMIT 12").fetch_all(d.ledger.pool()).await.unwrap_or_default();
7872×
    let kind = q.kind.unwrap_or_default();
7882×
    let before = q.before.or(q.around.map(|a| a + 25));
7892×
    let events = recent(&st, (!kind.is_empty()).then_some(kind.as_str()), before, 100).await;
7902×
    let older = (events.len() == 100).then(|| events.last().map(|e| e.seq)).flatten();
7912×
    let csrf = o.session.csrf.clone();
7922×
    render(&LogPage { shell: o.shell, head_hash: hex::encode(head), verified, csrf, kinds, kind, events, older })
7933×
}
794
795
#[derive(Deserialize)]
796
struct CsrfForm {
797
    _csrf: String,
798
}
799
800
/// Verify the ledger now and record the result (`ledger.verified`).
8011×
async fn verify(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
8021×
    let o = match owner(&st, &headers, "log").await {
8031×
        Ok(o) => o,
8040×
        Err(r) => return r,
805
    };
8061×
    if let Some(r) = check_form(&st, &o, &headers, &f._csrf) {
8070×
        return r;
8081×
    }
8091×
    let ledger = &st.app.daemon.ledger;
8101×
    match ledger.verify(&mut Principals::default()).await {
8111×
        Ok(v) => {
8121×
            let e = spectra_core::event::NewEvent::system(
8131×
                "ledger.verified",
8141×
                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)) }),
815
            );
8161×
            let _ = ledger.append_one(e).await;
8171×
            Redirect::to("/log").into_response()
818
        }
8190×
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(),
820
    }
8211×
}
822
823
// ---- Signing in ----
824
825
/// `POST /radar/link`, signed by the owner's CLI: a one-time sign-in link.
8262×
async fn link(State(st): State<RadarState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, body: Bytes) -> Response {
8272×
    let caller = match spectra_mcp::http::authenticate(&st.app, peer, &headers, "POST /radar/link", &body).await {
8282×
        Ok(c) => c,
8290×
        Err(r) => return r,
830
    };
8312×
    if !matches!(caller.principal, Principal::Owner { .. }) {
8320×
        return (StatusCode::FORBIDDEN, axum::Json(serde_json::json!({ "title": "only the owner signs in to Radar" }))).into_response();
8332×
    }
8342×
    let (Some(key), Some(proof)) = (caller.auth.key, caller.auth.proof) else {
8350×
        return StatusCode::UNAUTHORIZED.into_response();
836
    };
8372×
    let code = st.sessions().link(key, proof, now_ms());
8382×
    let base = st.app.origins.first().cloned().unwrap_or_default();
8392×
    axum::Json(serde_json::json!({ "url": format!("{base}/signin?code={code}"), "valid_ms": session::LINK_MS })).into_response()
8402×
}
841
842
#[derive(Deserialize)]
843
struct SigninQuery {
844
    code: Option<String>,
845
    /// Where to go once signed in: one of Radar's own pages.
846
    next: Option<String>,
847
}
848
849
/// A page of Radar's own to land on, never another site.
8502×
fn landing(next: Option<&str>) -> &'static str {
8512×
    match next.unwrap_or("") {
8522×
        "constellation" | "/constellation" => "/constellation",
8532×
        "briefs" | "/briefs" => "/briefs",
8542×
        "channels" | "/channels" => "/channels",
8552×
        "log" | "/log" => "/log",
8562×
        _ => "/",
857
    }
8582×
}
859
8603×
async fn signin(State(st): State<RadarState>, Query(q): Query<SigninQuery>) -> Response {
8613×
    let Some(id) = q.code.and_then(|c| st.sessions().open(&c, now_ms())) else {
8621×
        return signin_page("That sign-in link has been used or has expired.", StatusCode::UNAUTHORIZED);
863
    };
8642×
    let cookie = format!("{COOKIE}={id}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}", session::SESSION_MS / 1000);
8652×
    let mut r = Redirect::to(landing(q.next.as_deref())).into_response();
8662×
    r.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
8672×
    r
8683×
}
869
8700×
async fn signout(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
8710×
    if let Ok(o) = owner(&st, &headers, "live").await {
8720×
        if check_form(&st, &o, &headers, &f._csrf).is_none() {
8730×
            if let Some(id) = cookie(&headers) {
8740×
                st.sessions().close(&id);
8750×
            }
8760×
        }
8770×
    }
8780×
    signin_page("Signed out.", StatusCode::OK)
8790×
}
880
881
// ---- The event stream ----
882
883
/// `GET /radar/events`: one SSE message per appended event, carrying its
884
/// sequence number, kind, actor and subject, never its data (spec §17.3).
8850×
async fn events(State(st): State<RadarState>, headers: HeaderMap) -> Response {
8860×
    if let Err(r) = owner(&st, &headers, "live").await {
8870×
        return r;
8880×
    }
8890×
    let rx = st.app.daemon.ledger.subscribe();
8900×
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|e| async move {
8910×
        let e = e.ok()?;
8920×
        let data = serde_json::json!({ "seq": e.seq, "kind": e.kind, "actor": e.actor, "subject": e.subject, "project": e.project });
8930×
        Some(Ok::<_, std::convert::Infallible>(SseEvent::default().data(data.to_string())))
8940×
    });
8950×
    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response()
8960×
}