OXYGENHUB
# c7161ec1a607 spectra / crates / radar / src / lib.rs
30.7 KiB · Rust · last changed Briefs and Radar's Constellation (#29, #30); spec Draft 0.6 1 day ago source blame history raw
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 {
40
    fn sessions(&self) -> &session::Sessions {
41
        &self.app.sessions
42
    }
43
}
44
45
/// Radar's routes.
46
pub fn router(app: AppState) -> Router {
47
    let state = RadarState { app };
48
    Router::new()
49
        .route("/", get(live))
50
        .route("/channels", get(channels))
51
        .route("/c/{name}", get(channel).post(post_channel))
52
        .route("/c/{name}/t/{id}", get(thread).post(post_thread))
53
        .route("/briefs", get(briefs).post(save_brief))
54
        .route("/constellation", get(constellation))
55
        .route("/radar/constellation.json", get(constellation_json))
56
        .route("/radar/constellation.js", get(|| async { asset("text/javascript; charset=utf-8", SKY_SCRIPT) }))
57
        .route("/log", get(log))
58
        .route("/log/verify", post(verify))
59
        .route("/radar/link", post(link))
60
        .route("/signin", get(signin))
61
        .route("/signout", post(signout))
62
        .route("/radar/events", get(events))
63
        .route("/radar/style.css", get(|| async { asset("text/css; charset=utf-8", STYLE) }))
64
        .route("/radar/radar.js", get(|| async { asset("text/javascript; charset=utf-8", SCRIPT) }))
65
        .route("/radar/spectra.svg", get(|| async { asset("image/svg+xml", MARK) }))
66
        .layer(axum::middleware::map_response(security_headers))
67
        .with_state(state)
68
}
69
70
fn asset(kind: &'static str, body: &'static str) -> Response {
71
    ([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "max-age=300")], body).into_response()
72
}
73
74
/// CSP and friends on every response (spec §21.2).
75
async fn security_headers(mut r: Response) -> Response {
76
    let h = r.headers_mut();
77
    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'"));
78
    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`).
80
    h.insert("referrer-policy", HeaderValue::from_static("same-origin"));
81
    h.insert("x-frame-options", HeaderValue::from_static("DENY"));
82
    r
83
}
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 {
102
    fn author(&self) -> Author<'static> {
103
        Author {
104
            principal: self.principal.clone(),
105
            signer: Signer::Request(Auth {
106
                key: Some(self.session.key),
107
                method: Method::Session,
108
                assurance: 2,
109
                proof: Some(self.session.proof.clone()),
110
            }),
111
        }
112
    }
113
}
114
115
fn cookie(headers: &HeaderMap) -> Option<String> {
116
    let c = headers.get(header::COOKIE)?.to_str().ok()?;
117
    c.split(';').map(str::trim).find_map(|kv| kv.strip_prefix(&format!("{COOKIE}=")).map(String::from))
118
}
119
120
async fn owner(st: &RadarState, headers: &HeaderMap, page: &'static str) -> Result<Owner, Response> {
121
    let now = now_ms();
122
    let session = cookie(headers)
123
        .and_then(|id| st.sessions().get(&id, now))
124
        .ok_or_else(|| signin_page("Radar shows Spectra to its owner only.", StatusCode::UNAUTHORIZED))?;
125
    let d = &st.app.daemon;
126
    let principal = d
127
        .principals
128
        .read()
129
        .await
130
        .principal(&key_id(session.key), now)
131
        .map_err(|why| signin_page(&format!("This session no longer belongs to the owner: {why}."), StatusCode::UNAUTHORIZED))?;
132
    if !matches!(principal, Principal::Owner { .. }) {
133
        return Err(signin_page("Radar shows Spectra to its owner only.", StatusCode::FORBIDDEN));
134
    }
135
    let shell = Shell { page, owner: principal.display(), head: d.ledger.head().await.0, version: env!("CARGO_PKG_VERSION"), mark: MARK };
136
    Ok(Owner { session, principal, shell })
137
}
138
139
fn key_id(k: [u8; 32]) -> spectra_core::identity::KeyId {
140
    spectra_core::identity::KeyId(k)
141
}
142
143
#[derive(Template)]
144
#[template(path = "signin.html")]
145
struct SigninPage<'a> {
146
    mark: &'static str,
147
    message: &'a str,
148
}
149
150
fn signin_page(message: &str, status: StatusCode) -> Response {
151
    (status, render(&SigninPage { mark: MARK, message })).into_response()
152
}
153
154
fn render<T: Template>(t: &T) -> Response {
155
    match t.render() {
156
        Ok(html) => Html(html).into_response(),
157
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("could not render the page: {e}")).into_response(),
158
    }
159
}
160
161
/// Unsafe requests: the session's CSRF token and our own origin.
162
/// The refusal, if the form may not go through.
163
fn check_form(st: &RadarState, o: &Owner, headers: &HeaderMap, csrf: &str) -> Option<Response> {
164
    if !spectra_mcp::http::origin_ok(headers, &st.app.origins) {
165
        return Some((StatusCode::FORBIDDEN, "this origin may not change Spectra").into_response());
166
    }
167
    if csrf != o.session.csrf {
168
        return Some((StatusCode::FORBIDDEN, "the form is stale: reload the page").into_response());
169
    }
170
    None
171
}
172
173
/// Markdown, sanitised: what a message's body becomes on a page.
174
fn markdown(body: &str) -> String {
175
    let mut html = String::new();
176
    pulldown_cmark::html::push_html(
177
        &mut html,
178
        pulldown_cmark::Parser::new_ext(body, pulldown_cmark::Options::ENABLE_STRIKETHROUGH | pulldown_cmark::Options::ENABLE_TABLES),
179
    );
180
    ammonia::clean(&html)
181
}
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
213
async fn live(State(st): State<RadarState>, headers: HeaderMap) -> Response {
214
    let o = match owner(&st, &headers, "live").await {
215
        Ok(o) => o,
216
        Err(r) => return r,
217
    };
218
    let d = &st.app.daemon;
219
    let pool = d.ledger.pool();
220
    let rows: Vec<(String, String, String, String, Option<String>)> =
221
        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")
222
            .fetch_all(pool)
223
            .await
224
            .unwrap_or_default();
225
    let mut agents = Vec::new();
226
    {
227
        let now = now_ms();
228
        for (name, owner, kind, project, status) in rows {
229
            let last: Option<(String, i64)> =
230
                sqlx::query_as("SELECT tool, at_ms FROM tool_calls WHERE agent = ? ORDER BY seq DESC LIMIT 1")
231
                    .bind(&name)
232
                    .fetch_optional(pool)
233
                    .await
234
                    .ok()
235
                    .flatten();
236
            let presence = d.presence.lock().await.state(&name, now).to_string();
237
            agents.push(AgentCard {
238
                name,
239
                owner,
240
                kind,
241
                project,
242
                presence,
243
                status,
244
                last_tool: last.as_ref().map(|(t, _)| t.clone()),
245
                last_at: last.map(|(_, at)| format_time(at as u64)).unwrap_or_default(),
246
            });
247
        }
248
    }
249
    let unread = conv::unread(&d.ledger, "human").await.unwrap_or(0);
250
    let events = recent(&st, None, None, 50)
251
        .await
252
        .into_iter()
253
        .map(|e| EventRow { seq: e.seq, at: e.at, kind: e.kind, actor: e.actor, subject: e.subject })
254
        .collect();
255
    render(&LivePage { shell: o.shell, agents, unread, events })
256
}
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
279
async fn channels(State(st): State<RadarState>, headers: HeaderMap) -> Response {
280
    let o = match owner(&st, &headers, "channels").await {
281
        Ok(o) => o,
282
        Err(r) => return r,
283
    };
284
    let rows: Vec<ChannelCount> = sqlx::query_as(
285
        "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) \
286
         FROM channels c ORDER BY c.kind = 'direct', c.kind = 'system', c.name",
287
    )
288
    .fetch_all(st.app.daemon.ledger.pool())
289
    .await
290
    .unwrap_or_default();
291
    let channels = rows
292
        .into_iter()
293
        .map(|(_, name, kind, purpose, count, last)| ChannelRow {
294
            display: match name.strip_prefix("dm:") {
295
                Some(p) => format!("@{p}"),
296
                None => format!("#{name}"),
297
            },
298
            name,
299
            kind,
300
            purpose: purpose.unwrap_or_default(),
301
            count,
302
            last: last.map(|t| format_time(t as u64)).unwrap_or_default(),
303
        })
304
        .collect();
305
    render(&ChannelsPage { shell: o.shell, channels })
306
}
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.
313
async fn sky(st: &RadarState, owner: &str) -> serde_json::Value {
314
    let d = &st.app.daemon;
315
    let pool = d.ledger.pool();
316
    let projects: Vec<(String, String)> =
317
        sqlx::query_as("SELECT name, description FROM projects ORDER BY seq").fetch_all(pool).await.unwrap_or_default();
318
    let links: Vec<(String, String)> =
319
        sqlx::query_as("SELECT project, depends_on FROM project_relations").fetch_all(pool).await.unwrap_or_default();
320
    let agents: Vec<(String, String, String)> =
321
        sqlx::query_as("SELECT name, kind, project FROM agents WHERE disabled_ms IS NULL ORDER BY name")
322
            .fetch_all(pool)
323
            .await
324
            .unwrap_or_default();
325
    let presence = d.presence.lock().await;
326
    let now = now_ms();
327
    let list: Vec<serde_json::Value> = projects
328
        .iter()
329
        .map(|(name, description)| {
330
            serde_json::json!({
331
                "name": name,
332
                "description": description,
333
                "depends_on": links.iter().filter(|(p, _)| p == name).map(|(_, q)| q).collect::<Vec<_>>(),
334
                "agents": agents.iter().filter(|(_, _, p)| p == name)
335
                    .map(|(a, kind, _)| serde_json::json!({ "name": a, "kind": kind, "presence": presence.state(a, now) }))
336
                    .collect::<Vec<_>>(),
337
            })
338
        })
339
        .collect();
340
    serde_json::json!({ "owner": owner, "projects": list })
341
}
342
343
fn esc(s: &str) -> String {
344
    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
345
}
346
347
const HUES: [u32; 8] = [0, 30, 55, 120, 190, 230, 275, 320];
348
349
fn presence_colour(p: &str) -> &'static str {
350
    match p {
351
        "online" => "#3fb950",
352
        "busy" => "#a371f7",
353
        "idle" | "paused" => "#d29922",
354
        "blocked" => "#f0883e",
355
        _ => "#6e7681",
356
    }
357
}
358
359
/// The still picture, for a browser without JavaScript and for the first paint.
360
fn still(sky: &serde_json::Value) -> String {
361
    let (cx, cy, ring, orbit) = (450.0f64, 320.0f64, 205.0f64, 54.0f64);
362
    let projects = sky["projects"].as_array().cloned().unwrap_or_default();
363
    let n = projects.len().max(1) as f64;
364
    let mut at = std::collections::HashMap::new();
365
    let mut nodes = String::new();
366
    let mut lines = String::new();
367
    for (i, p) in projects.iter().enumerate() {
368
        let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * i as f64 / n;
369
        let (x, y) = (cx + ring * a.cos(), cy + ring * a.sin());
370
        let name = p["name"].as_str().unwrap_or("");
371
        at.insert(name.to_string(), (x, y));
372
        let hue = HUES[i % HUES.len()];
373
        lines.push_str(&format!(r#"<line class="spoke" x1="{cx}" y1="{cy}" x2="{x:.1}" y2="{y:.1}"/>"#));
374
        let s = 24.0;
375
        nodes.push_str(&format!(
376
            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>"#,
377
            x,
378
            y - s,
379
            x + s * 0.87,
380
            y + s * 0.5,
381
            x - s * 0.87,
382
            y + s * 0.5,
383
            y + orbit + 22.0,
384
            esc(name)
385
        ));
386
        let agents = p["agents"].as_array().cloned().unwrap_or_default();
387
        for (j, ag) in agents.iter().enumerate() {
388
            let b = std::f64::consts::TAU * j as f64 / agents.len().max(1) as f64;
389
            let (ax, ay) = (x + orbit * b.cos(), y + orbit * b.sin());
390
            nodes.push_str(&format!(
391
                r#"<circle class="agent" cx="{ax:.1}" cy="{ay:.1}" r="7" fill="{}"/><text class="alabel" x="{ax:.1}" y="{:.1}">{}</text>"#,
392
                presence_colour(ag["presence"].as_str().unwrap_or("")),
393
                ay + 18.0,
394
                esc(ag["name"].as_str().unwrap_or(""))
395
            ));
396
        }
397
    }
398
    for p in &projects {
399
        let from = at.get(p["name"].as_str().unwrap_or(""));
400
        for q in p["depends_on"].as_array().cloned().unwrap_or_default() {
401
            if let (Some((x1, y1)), Some((x2, y2))) = (from, at.get(q.as_str().unwrap_or(""))) {
402
                lines.push_str(&format!(r#"<path class="link" d="M{x1:.1},{y1:.1} Q{cx},{cy} {x2:.1},{y2:.1}"/>"#));
403
            }
404
        }
405
    }
406
    format!(
407
        r#"{lines}<circle class="core" cx="{cx}" cy="{cy}" r="22"/><text class="olabel" x="{cx}" y="{:.1}">{}</text>{nodes}"#,
408
        cy + 44.0,
409
        esc(sky["owner"].as_str().unwrap_or(""))
410
    )
411
}
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
422
async fn constellation(State(st): State<RadarState>, headers: HeaderMap) -> Response {
423
    let o = match owner(&st, &headers, "constellation").await {
424
        Ok(o) => o,
425
        Err(r) => return r,
426
    };
427
    let sky = sky(&st, &o.principal.display()).await;
428
    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.
430
    let data = sky.to_string().replace('<', "\\u003c");
431
    render(&ConstellationPage { shell: o.shell, still: still(&sky), data, empty })
432
}
433
434
async fn constellation_json(State(st): State<RadarState>, headers: HeaderMap) -> Response {
435
    match owner(&st, &headers, "constellation").await {
436
        Ok(o) => axum::Json(sky(&st, &o.principal.display()).await).into_response(),
437
        Err(r) => r,
438
    }
439
}
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
458
async fn briefs(State(st): State<RadarState>, headers: HeaderMap) -> Response {
459
    let o = match owner(&st, &headers, "briefs").await {
460
        Ok(o) => o,
461
        Err(r) => return r,
462
    };
463
    let pool = st.app.daemon.ledger.pool();
464
    let overall = spectra_core::rules::layer(pool, None).await.ok().flatten().unwrap_or_default();
465
    let rows: Vec<(String, String, Option<String>)> = sqlx::query_as(
466
        "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",
467
    )
468
    .fetch_all(pool)
469
    .await
470
    .unwrap_or_default();
471
    let projects =
472
        rows.into_iter().map(|(name, description, text)| ProjectBriefRow { name, description, text: text.unwrap_or_default() }).collect();
473
    let csrf = o.session.csrf.clone();
474
    render(&BriefsPage { shell: o.shell, csrf, overall, projects })
475
}
476
477
#[derive(Deserialize)]
478
struct BriefForm {
479
    _csrf: String,
480
    project: Option<String>,
481
    text: String,
482
}
483
484
async fn save_brief(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<BriefForm>) -> Response {
485
    let o = match owner(&st, &headers, "briefs").await {
486
        Ok(o) => o,
487
        Err(r) => return r,
488
    };
489
    if let Some(r) = check_form(&st, &o, &headers, &f._csrf) {
490
        return r;
491
    }
492
    // Browsers send CRLF from a textarea; the brief is stored as written.
493
    let text = f.text.replace("\r\n", "\n");
494
    if let Err(e) = spectra_core::rules::check(&text) {
495
        return (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response();
496
    }
497
    let project = f.project.filter(|p| !p.is_empty());
498
    let pool = st.app.daemon.ledger.pool();
499
    if let Some(p) = &project {
500
        let known: Option<String> =
501
            sqlx::query_scalar("SELECT name FROM projects WHERE name = ?").bind(p).fetch_optional(pool).await.unwrap_or(None);
502
        if known.is_none() {
503
            return (StatusCode::NOT_FOUND, "no such project").into_response();
504
        }
505
    }
506
    let Signer::Request(auth) = o.author().signer else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() };
507
    let mut e =
508
        spectra_core::event::NewEvent::system("brief.set", serde_json::json!({ "project": project, "text": text })).by("human", auth);
509
    e.project = project.clone();
510
    match st.app.daemon.ledger.append_one(e).await {
511
        Ok(_) => {
512
            Redirect::to(&format!("/briefs#{}", project.map(|p| format!("p-{p}")).unwrap_or_else(|| "overall".into()))).into_response()
513
        }
514
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
515
    }
516
}
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
545
async fn show(st: &RadarState, o: Owner, name: &str, thread: Option<&str>) -> Response {
546
    let d = &st.app.daemon;
547
    let (ch, msgs) = match conv::read(&d.ledger, &o.principal, if thread.is_some() { None } else { Some(name) }, thread, None, 200).await {
548
        Ok(r) => r,
549
        Err(e) => return (StatusCode::NOT_FOUND, format!("{e:#}")).into_response(),
550
    };
551
    let mut messages = Vec::new();
552
    for m in msgs {
553
        messages.push(MessageRow {
554
            author: spectra_core::conversation::display_name(&d.ledger, &m.author).await,
555
            at: format_time(m.at_ms as u64),
556
            redacted: m.redacted.is_some(),
557
            body_html: if m.redacted.is_some() { ammonia::clean_text(&m.body) } else { markdown(&m.body) },
558
            id: m.id,
559
            kind: m.kind,
560
            replies: m.replies,
561
        });
562
    }
563
    let can_post = conv::can_post(&o.principal, &ch).is_ok();
564
    let action = match thread {
565
        Some(t) => format!("/c/{}/t/{t}", ch.name),
566
        None => format!("/c/{}", ch.name),
567
    };
568
    let csrf = o.session.csrf.clone();
569
    let brief = if ch.kind == "project" { spectra_core::rules::layer(d.ledger.pool(), Some(&ch.name)).await.ok().flatten() } else { None };
570
    let brief = brief.map(|b| markdown(&b));
571
    render(&ChannelPage {
572
        shell: o.shell,
573
        name: ch.name.clone(),
574
        display: ch.display(),
575
        purpose: ch.purpose.clone(),
576
        brief,
577
        thread: thread.map(String::from),
578
        messages,
579
        can_post,
580
        action,
581
        csrf,
582
    })
583
}
584
585
async fn channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>) -> Response {
586
    match owner(&st, &headers, "channels").await {
587
        Ok(o) => show(&st, o, &name, None).await,
588
        Err(r) => r,
589
    }
590
}
591
592
async fn thread(State(st): State<RadarState>, headers: HeaderMap, Path((name, id)): Path<(String, String)>) -> Response {
593
    match owner(&st, &headers, "channels").await {
594
        Ok(o) => show(&st, o, &name, Some(&id)).await,
595
        Err(r) => r,
596
    }
597
}
598
599
#[derive(Deserialize)]
600
struct PostForm {
601
    _csrf: String,
602
    body: String,
603
    reply_to: Option<String>,
604
}
605
606
async fn post_to(st: &RadarState, headers: &HeaderMap, name: &str, form: PostForm, back: String) -> Response {
607
    let o = match owner(st, headers, "channels").await {
608
        Ok(o) => o,
609
        Err(r) => return r,
610
    };
611
    if let Some(r) = check_form(st, &o, headers, &form._csrf) {
612
        return r;
613
    }
614
    let post = Post {
615
        channel: if form.reply_to.is_some() { None } else { Some(name.to_string()) },
616
        body: form.body,
617
        reply_to: form.reply_to,
618
        ..Default::default()
619
    };
620
    match conv::post(&st.app.daemon.ledger, &o.author(), post, now_ms()).await {
621
        Ok(_) => Redirect::to(&back).into_response(),
622
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
623
    }
624
}
625
626
async fn post_channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>, Form(form): Form<PostForm>) -> Response {
627
    let back = format!("/c/{name}");
628
    post_to(&st, &headers, &name, form, back).await
629
}
630
631
async fn post_thread(
632
    State(st): State<RadarState>,
633
    headers: HeaderMap,
634
    Path((name, id)): Path<(String, String)>,
635
    Form(mut form): Form<PostForm>,
636
) -> Response {
637
    form.reply_to = Some(id.clone());
638
    let back = format!("/c/{name}/t/{id}");
639
    post_to(&st, &headers, &name, form, back).await
640
}
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
660
async fn recent(st: &RadarState, kind: Option<&str>, before: Option<i64>, limit: i64) -> Vec<LogRow> {
661
    #[allow(clippy::type_complexity)]
662
    let rows: Vec<(
663
        i64,
664
        String,
665
        i64,
666
        String,
667
        String,
668
        Option<String>,
669
        i64,
670
        i64,
671
        Vec<u8>,
672
        Vec<u8>,
673
        Option<Vec<u8>>,
674
        Option<Vec<u8>>,
675
        Option<Vec<u8>>,
676
    )> = sqlx::query_as(
677
        "SELECT seq, id, at_ms, kind, actor, subject, auth_method, auth_assurance, hash, prev, auth_key, auth_proof, payload FROM events \
678
         WHERE (? IS NULL OR kind = ?) AND (? IS NULL OR seq < ?) ORDER BY seq DESC LIMIT ?",
679
    )
680
    .bind(kind)
681
    .bind(kind)
682
    .bind(before)
683
    .bind(before)
684
    .bind(limit)
685
    .fetch_all(st.app.daemon.ledger.pool())
686
    .await
687
    .unwrap_or_default();
688
    let mut out = Vec::new();
689
    for (seq, id, at, kind, actor, subject, method, assurance, hash, prev, key, proof, payload) in rows {
690
        let payload = payload
691
            .and_then(|p| serde_json::from_slice::<serde_json::Value>(&p).ok())
692
            .map(|v| serde_json::to_string_pretty(&v).unwrap_or_default())
693
            .unwrap_or_else(|| "(pruned to its stub)".into());
694
        out.push(LogRow {
695
            seq,
696
            id,
697
            at: format_time(at as u64),
698
            kind,
699
            actor: spectra_core::conversation::display_name(&st.app.daemon.ledger, &actor).await,
700
            subject,
701
            method: Method::from_u8(method as u8).map(|m| m.name()).unwrap_or("?").to_string(),
702
            assurance,
703
            hash: hex::encode(hash),
704
            prev: hex::encode(prev),
705
            key: key.map(hex::encode).unwrap_or_else(|| "none (Spectra itself; covered by the next checkpoint)".into()),
706
            proof: proof.map(|p| format!("{} bytes", p.len())).unwrap_or_else(|| "none".into()),
707
            payload,
708
        });
709
    }
710
    out
711
}
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
733
async fn log(State(st): State<RadarState>, headers: HeaderMap, Query(q): Query<LogQuery>) -> Response {
734
    let o = match owner(&st, &headers, "log").await {
735
        Ok(o) => o,
736
        Err(r) => return r,
737
    };
738
    let d = &st.app.daemon;
739
    let (_, head) = d.ledger.head().await;
740
    let last: Option<(Vec<u8>, i64)> =
741
        sqlx::query_as("SELECT payload, at_ms FROM events WHERE kind = 'ledger.verified' ORDER BY seq DESC LIMIT 1")
742
            .fetch_optional(d.ledger.pool())
743
            .await
744
            .ok()
745
            .flatten();
746
    let verified = match last {
747
        Some((p, at)) => {
748
            let v: serde_json::Value = serde_json::from_slice(&p).unwrap_or_default();
749
            let n = v["findings"].as_u64().unwrap_or(0);
750
            format!(
751
                "{} at {} over {} events",
752
                if n == 0 { "clean".to_string() } else { format!("{n} finding(s)") },
753
                format_time(at as u64),
754
                v["events"].as_u64().unwrap_or(0)
755
            )
756
        }
757
        None => "not yet".into(),
758
    };
759
    let kinds: Vec<String> =
760
        sqlx::query_scalar("SELECT kind FROM event_counts ORDER BY n DESC LIMIT 12").fetch_all(d.ledger.pool()).await.unwrap_or_default();
761
    let kind = q.kind.unwrap_or_default();
762
    let before = q.before.or(q.around.map(|a| a + 25));
763
    let events = recent(&st, (!kind.is_empty()).then_some(kind.as_str()), before, 100).await;
764
    let older = (events.len() == 100).then(|| events.last().map(|e| e.seq)).flatten();
765
    let csrf = o.session.csrf.clone();
766
    render(&LogPage { shell: o.shell, head_hash: hex::encode(head), verified, csrf, kinds, kind, events, older })
767
}
768
769
#[derive(Deserialize)]
770
struct CsrfForm {
771
    _csrf: String,
772
}
773
774
/// Verify the ledger now and record the result (`ledger.verified`).
775
async fn verify(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
776
    let o = match owner(&st, &headers, "log").await {
777
        Ok(o) => o,
778
        Err(r) => return r,
779
    };
780
    if let Some(r) = check_form(&st, &o, &headers, &f._csrf) {
781
        return r;
782
    }
783
    let ledger = &st.app.daemon.ledger;
784
    match ledger.verify(&mut Principals::default()).await {
785
        Ok(v) => {
786
            let e = spectra_core::event::NewEvent::system(
787
                "ledger.verified",
788
                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
            );
790
            let _ = ledger.append_one(e).await;
791
            Redirect::to("/log").into_response()
792
        }
793
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(),
794
    }
795
}
796
797
// ---- Signing in ----
798
799
/// `POST /radar/link`, signed by the owner's CLI: a one-time sign-in link.
800
async fn link(State(st): State<RadarState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, body: Bytes) -> Response {
801
    let caller = match spectra_mcp::http::authenticate(&st.app, peer, &headers, "POST /radar/link", &body).await {
802
        Ok(c) => c,
803
        Err(r) => return r,
804
    };
805
    if !matches!(caller.principal, Principal::Owner { .. }) {
806
        return (StatusCode::FORBIDDEN, axum::Json(serde_json::json!({ "title": "only the owner signs in to Radar" }))).into_response();
807
    }
808
    let (Some(key), Some(proof)) = (caller.auth.key, caller.auth.proof) else {
809
        return StatusCode::UNAUTHORIZED.into_response();
810
    };
811
    let code = st.sessions().link(key, proof, now_ms());
812
    let base = st.app.origins.first().cloned().unwrap_or_default();
813
    axum::Json(serde_json::json!({ "url": format!("{base}/signin?code={code}"), "valid_ms": session::LINK_MS })).into_response()
814
}
815
816
#[derive(Deserialize)]
817
struct SigninQuery {
818
    code: Option<String>,
819
}
820
821
async fn signin(State(st): State<RadarState>, Query(q): Query<SigninQuery>) -> Response {
822
    let Some(id) = q.code.and_then(|c| st.sessions().open(&c, now_ms())) else {
823
        return signin_page("That sign-in link has been used or has expired.", StatusCode::UNAUTHORIZED);
824
    };
825
    let cookie = format!("{COOKIE}={id}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}", session::SESSION_MS / 1000);
826
    let mut r = Redirect::to("/").into_response();
827
    r.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
828
    r
829
}
830
831
async fn signout(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
832
    if let Ok(o) = owner(&st, &headers, "live").await {
833
        if check_form(&st, &o, &headers, &f._csrf).is_none() {
834
            if let Some(id) = cookie(&headers) {
835
                st.sessions().close(&id);
836
            }
837
        }
838
    }
839
    signin_page("Signed out.", StatusCode::OK)
840
}
841
842
// ---- The event stream ----
843
844
/// `GET /radar/events`: one SSE message per appended event, carrying its
845
/// sequence number, kind, actor and subject, never its data (spec §17.3).
846
async fn events(State(st): State<RadarState>, headers: HeaderMap) -> Response {
847
    if let Err(r) = owner(&st, &headers, "live").await {
848
        return r;
849
    }
850
    let rx = st.app.daemon.ledger.subscribe();
851
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|e| async move {
852
        let e = e.ok()?;
853
        let data = serde_json::json!({ "seq": e.seq, "kind": e.kind, "actor": e.actor, "subject": e.subject, "project": e.project });
854
        Some(Ok::<_, std::convert::Infallible>(SseEvent::default().data(data.to_string())))
855
    });
856
    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response()
857
}