OXYGENHUB
# 8cfb9269bb14 spectra / crates / radar / src / lib.rs
21.9 KiB · Rust · last changed Radar: Live, channels, threads and the log for the owner, signed in by a CLI-signed link; ledger writes survive a cancelled caller (#10) 2 days 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
pub mod session;
11
12
use askama::Template;
13
use axum::body::Bytes;
14
use axum::extract::connect_info::ConnectInfo;
15
use axum::extract::{Form, Path, Query, State};
16
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
17
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
18
use axum::response::{Html, IntoResponse, Redirect, Response};
19
use axum::routing::{get, post};
20
use axum::Router;
21
use futures::StreamExt;
22
use serde::Deserialize;
23
use session::{Session, Sessions};
24
use spectra_core::conversation::{self as conv, Author, Post, Signer};
25
use spectra_core::event::{Auth, Method};
26
use spectra_core::ids::{format_time, now_ms};
27
use spectra_core::principals::{Principal, Principals};
28
use spectra_mcp::http::{AppState, PeerInfo};
29
use std::sync::Arc;
30
use std::time::Duration;
31
32
const MARK: &str = include_str!("../assets/spectra.svg");
33
const STYLE: &str = include_str!("../assets/style.css");
34
const SCRIPT: &str = include_str!("../assets/radar.js");
35
const COOKIE: &str = "spectra_session";
36
37
#[derive(Clone)]
38
pub struct RadarState {
39
    pub app: AppState,
40
    pub sessions: Arc<Sessions>,
41
}
42
43
/// Radar's routes.
44
pub fn router(app: AppState) -> Router {
45
    let state = RadarState { app, sessions: Arc::new(Sessions::default()) };
46
    Router::new()
47
        .route("/", get(live))
48
        .route("/channels", get(channels))
49
        .route("/c/{name}", get(channel).post(post_channel))
50
        .route("/c/{name}/t/{id}", get(thread).post(post_thread))
51
        .route("/log", get(log))
52
        .route("/log/verify", post(verify))
53
        .route("/radar/link", post(link))
54
        .route("/signin", get(signin))
55
        .route("/signout", post(signout))
56
        .route("/radar/events", get(events))
57
        .route("/radar/style.css", get(|| async { asset("text/css; charset=utf-8", STYLE) }))
58
        .route("/radar/radar.js", get(|| async { asset("text/javascript; charset=utf-8", SCRIPT) }))
59
        .route("/radar/spectra.svg", get(|| async { asset("image/svg+xml", MARK) }))
60
        .layer(axum::middleware::map_response(security_headers))
61
        .with_state(state)
62
}
63
64
fn asset(kind: &'static str, body: &'static str) -> Response {
65
    ([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "max-age=300")], body).into_response()
66
}
67
68
/// CSP and friends on every response (spec §21.2).
69
async fn security_headers(mut r: Response) -> Response {
70
    let h = r.headers_mut();
71
    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'"));
72
    h.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
73
    // `same-origin`: a form post still carries our Origin (no-referrer makes it `null`).
74
    h.insert("referrer-policy", HeaderValue::from_static("same-origin"));
75
    h.insert("x-frame-options", HeaderValue::from_static("DENY"));
76
    r
77
}
78
79
/// What every page's frame shows.
80
pub struct Shell {
81
    pub page: &'static str,
82
    pub owner: String,
83
    pub head: u64,
84
    pub version: &'static str,
85
    pub mark: &'static str,
86
}
87
88
/// The signed-in owner, or the page that says how to sign in.
89
struct Owner {
90
    session: Session,
91
    principal: Principal,
92
    shell: Shell,
93
}
94
95
impl Owner {
96
    fn author(&self) -> Author<'static> {
97
        Author {
98
            principal: self.principal.clone(),
99
            signer: Signer::Request(Auth {
100
                key: Some(self.session.key),
101
                method: Method::Session,
102
                assurance: 2,
103
                proof: Some(self.session.proof.clone()),
104
            }),
105
        }
106
    }
107
}
108
109
fn cookie(headers: &HeaderMap) -> Option<String> {
110
    let c = headers.get(header::COOKIE)?.to_str().ok()?;
111
    c.split(';').map(str::trim).find_map(|kv| kv.strip_prefix(&format!("{COOKIE}=")).map(String::from))
112
}
113
114
async fn owner(st: &RadarState, headers: &HeaderMap, page: &'static str) -> Result<Owner, Response> {
115
    let now = now_ms();
116
    let session = cookie(headers)
117
        .and_then(|id| st.sessions.get(&id, now))
118
        .ok_or_else(|| signin_page("Radar shows Spectra to its owner only.", StatusCode::UNAUTHORIZED))?;
119
    let d = &st.app.daemon;
120
    let principal = d
121
        .principals
122
        .read()
123
        .await
124
        .principal(&oxy_core_key(session.key), now)
125
        .map_err(|why| signin_page(&format!("This session no longer belongs to the owner: {why}."), StatusCode::UNAUTHORIZED))?;
126
    if !matches!(principal, Principal::Owner { .. }) {
127
        return Err(signin_page("Radar shows Spectra to its owner only.", StatusCode::FORBIDDEN));
128
    }
129
    let shell = Shell { page, owner: principal.display(), head: d.ledger.head().await.0, version: env!("CARGO_PKG_VERSION"), mark: MARK };
130
    Ok(Owner { session, principal, shell })
131
}
132
133
fn oxy_core_key(k: [u8; 32]) -> oxy_core::hash::KeyId {
134
    oxy_core::hash::ObjectRef(k)
135
}
136
137
#[derive(Template)]
138
#[template(path = "signin.html")]
139
struct SigninPage<'a> {
140
    mark: &'static str,
141
    message: &'a str,
142
}
143
144
fn signin_page(message: &str, status: StatusCode) -> Response {
145
    (status, render(&SigninPage { mark: MARK, message })).into_response()
146
}
147
148
fn render<T: Template>(t: &T) -> Response {
149
    match t.render() {
150
        Ok(html) => Html(html).into_response(),
151
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("could not render the page: {e}")).into_response(),
152
    }
153
}
154
155
/// Unsafe requests: the session's CSRF token and our own origin.
156
/// The refusal, if the form may not go through.
157
fn check_form(st: &RadarState, o: &Owner, headers: &HeaderMap, csrf: &str) -> Option<Response> {
158
    if !spectra_mcp::http::origin_ok(headers, &st.app.origins) {
159
        return Some((StatusCode::FORBIDDEN, "this origin may not change Spectra").into_response());
160
    }
161
    if csrf != o.session.csrf {
162
        return Some((StatusCode::FORBIDDEN, "the form is stale: reload the page").into_response());
163
    }
164
    None
165
}
166
167
/// Markdown, sanitised: what a message's body becomes on a page.
168
fn markdown(body: &str) -> String {
169
    let mut html = String::new();
170
    pulldown_cmark::html::push_html(
171
        &mut html,
172
        pulldown_cmark::Parser::new_ext(body, pulldown_cmark::Options::ENABLE_STRIKETHROUGH | pulldown_cmark::Options::ENABLE_TABLES),
173
    );
174
    ammonia::clean(&html)
175
}
176
177
// ---- Live ----
178
179
struct AgentCard {
180
    name: String,
181
    owner: String,
182
    kind: String,
183
    project: String,
184
    presence: String,
185
    status: Option<String>,
186
    last_tool: Option<String>,
187
    last_at: String,
188
}
189
190
struct EventRow {
191
    seq: i64,
192
    at: String,
193
    kind: String,
194
    actor: String,
195
    subject: Option<String>,
196
}
197
198
#[derive(Template)]
199
#[template(path = "live.html")]
200
struct LivePage {
201
    shell: Shell,
202
    agents: Vec<AgentCard>,
203
    unread: i64,
204
    events: Vec<EventRow>,
205
}
206
207
async fn live(State(st): State<RadarState>, headers: HeaderMap) -> Response {
208
    let o = match owner(&st, &headers, "live").await {
209
        Ok(o) => o,
210
        Err(r) => return r,
211
    };
212
    let d = &st.app.daemon;
213
    let pool = d.ledger.pool();
214
    let rows: Vec<(String, String, String, String, Option<String>)> =
215
        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")
216
            .fetch_all(pool)
217
            .await
218
            .unwrap_or_default();
219
    let mut agents = Vec::new();
220
    {
221
        let now = now_ms();
222
        for (name, owner, kind, project, status) in rows {
223
            let last: Option<(String, i64)> =
224
                sqlx::query_as("SELECT tool, at_ms FROM tool_calls WHERE agent = ? ORDER BY seq DESC LIMIT 1")
225
                    .bind(&name)
226
                    .fetch_optional(pool)
227
                    .await
228
                    .ok()
229
                    .flatten();
230
            let presence = d.presence.lock().await.state(&name, now).to_string();
231
            agents.push(AgentCard {
232
                name,
233
                owner,
234
                kind,
235
                project,
236
                presence,
237
                status,
238
                last_tool: last.as_ref().map(|(t, _)| t.clone()),
239
                last_at: last.map(|(_, at)| format_time(at as u64)).unwrap_or_default(),
240
            });
241
        }
242
    }
243
    let unread = conv::unread(&d.ledger, "human").await.unwrap_or(0);
244
    let events = recent(&st, None, None, 50)
245
        .await
246
        .into_iter()
247
        .map(|e| EventRow { seq: e.seq, at: e.at, kind: e.kind, actor: e.actor, subject: e.subject })
248
        .collect();
249
    render(&LivePage { shell: o.shell, agents, unread, events })
250
}
251
252
// ---- Channels ----
253
254
struct ChannelRow {
255
    name: String,
256
    display: String,
257
    kind: String,
258
    purpose: String,
259
    count: i64,
260
    last: String,
261
}
262
263
/// A channel with its message count and last message time.
264
type ChannelCount = (String, String, String, Option<String>, i64, Option<i64>);
265
266
#[derive(Template)]
267
#[template(path = "channels.html")]
268
struct ChannelsPage {
269
    shell: Shell,
270
    channels: Vec<ChannelRow>,
271
}
272
273
async fn channels(State(st): State<RadarState>, headers: HeaderMap) -> Response {
274
    let o = match owner(&st, &headers, "channels").await {
275
        Ok(o) => o,
276
        Err(r) => return r,
277
    };
278
    let rows: Vec<ChannelCount> = sqlx::query_as(
279
        "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) \
280
         FROM channels c ORDER BY c.kind = 'direct', c.kind = 'system', c.name",
281
    )
282
    .fetch_all(st.app.daemon.ledger.pool())
283
    .await
284
    .unwrap_or_default();
285
    let channels = rows
286
        .into_iter()
287
        .map(|(_, name, kind, purpose, count, last)| ChannelRow {
288
            display: match name.strip_prefix("dm:") {
289
                Some(p) => format!("@{p}"),
290
                None => format!("#{name}"),
291
            },
292
            name,
293
            kind,
294
            purpose: purpose.unwrap_or_default(),
295
            count,
296
            last: last.map(|t| format_time(t as u64)).unwrap_or_default(),
297
        })
298
        .collect();
299
    render(&ChannelsPage { shell: o.shell, channels })
300
}
301
302
// ---- A channel and a thread ----
303
304
struct MessageRow {
305
    id: String,
306
    author: String,
307
    at: String,
308
    kind: String,
309
    replies: i64,
310
    redacted: bool,
311
    body_html: String,
312
}
313
314
#[derive(Template)]
315
#[template(path = "channel.html")]
316
struct ChannelPage {
317
    shell: Shell,
318
    name: String,
319
    display: String,
320
    purpose: Option<String>,
321
    thread: Option<String>,
322
    messages: Vec<MessageRow>,
323
    can_post: bool,
324
    action: String,
325
    csrf: String,
326
}
327
328
async fn show(st: &RadarState, o: Owner, name: &str, thread: Option<&str>) -> Response {
329
    let d = &st.app.daemon;
330
    let (ch, msgs) = match conv::read(&d.ledger, &o.principal, if thread.is_some() { None } else { Some(name) }, thread, None, 200).await {
331
        Ok(r) => r,
332
        Err(e) => return (StatusCode::NOT_FOUND, format!("{e:#}")).into_response(),
333
    };
334
    let mut messages = Vec::new();
335
    for m in msgs {
336
        messages.push(MessageRow {
337
            author: spectra_core::conversation::display_name(&d.ledger, &m.author).await,
338
            at: format_time(m.at_ms as u64),
339
            redacted: m.redacted.is_some(),
340
            body_html: if m.redacted.is_some() { ammonia::clean_text(&m.body) } else { markdown(&m.body) },
341
            id: m.id,
342
            kind: m.kind,
343
            replies: m.replies,
344
        });
345
    }
346
    let can_post = conv::can_post(&o.principal, &ch).is_ok();
347
    let action = match thread {
348
        Some(t) => format!("/c/{}/t/{t}", ch.name),
349
        None => format!("/c/{}", ch.name),
350
    };
351
    let csrf = o.session.csrf.clone();
352
    render(&ChannelPage {
353
        shell: o.shell,
354
        name: ch.name.clone(),
355
        display: ch.display(),
356
        purpose: ch.purpose.clone(),
357
        thread: thread.map(String::from),
358
        messages,
359
        can_post,
360
        action,
361
        csrf,
362
    })
363
}
364
365
async fn channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>) -> Response {
366
    match owner(&st, &headers, "channels").await {
367
        Ok(o) => show(&st, o, &name, None).await,
368
        Err(r) => r,
369
    }
370
}
371
372
async fn thread(State(st): State<RadarState>, headers: HeaderMap, Path((name, id)): Path<(String, String)>) -> Response {
373
    match owner(&st, &headers, "channels").await {
374
        Ok(o) => show(&st, o, &name, Some(&id)).await,
375
        Err(r) => r,
376
    }
377
}
378
379
#[derive(Deserialize)]
380
struct PostForm {
381
    _csrf: String,
382
    body: String,
383
    reply_to: Option<String>,
384
}
385
386
async fn post_to(st: &RadarState, headers: &HeaderMap, name: &str, form: PostForm, back: String) -> Response {
387
    let o = match owner(st, headers, "channels").await {
388
        Ok(o) => o,
389
        Err(r) => return r,
390
    };
391
    if let Some(r) = check_form(st, &o, headers, &form._csrf) {
392
        return r;
393
    }
394
    let post = Post {
395
        channel: if form.reply_to.is_some() { None } else { Some(name.to_string()) },
396
        body: form.body,
397
        reply_to: form.reply_to,
398
        ..Default::default()
399
    };
400
    match conv::post(&st.app.daemon.ledger, &o.author(), post, now_ms()).await {
401
        Ok(_) => Redirect::to(&back).into_response(),
402
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
403
    }
404
}
405
406
async fn post_channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>, Form(form): Form<PostForm>) -> Response {
407
    let back = format!("/c/{name}");
408
    post_to(&st, &headers, &name, form, back).await
409
}
410
411
async fn post_thread(
412
    State(st): State<RadarState>,
413
    headers: HeaderMap,
414
    Path((name, id)): Path<(String, String)>,
415
    Form(mut form): Form<PostForm>,
416
) -> Response {
417
    form.reply_to = Some(id.clone());
418
    let back = format!("/c/{name}/t/{id}");
419
    post_to(&st, &headers, &name, form, back).await
420
}
421
422
// ---- The log ----
423
424
struct LogRow {
425
    seq: i64,
426
    id: String,
427
    at: String,
428
    kind: String,
429
    actor: String,
430
    subject: Option<String>,
431
    method: String,
432
    assurance: i64,
433
    hash: String,
434
    prev: String,
435
    key: String,
436
    proof: String,
437
    payload: String,
438
}
439
440
async fn recent(st: &RadarState, kind: Option<&str>, before: Option<i64>, limit: i64) -> Vec<LogRow> {
441
    #[allow(clippy::type_complexity)]
442
    let rows: Vec<(
443
        i64,
444
        String,
445
        i64,
446
        String,
447
        String,
448
        Option<String>,
449
        i64,
450
        i64,
451
        Vec<u8>,
452
        Vec<u8>,
453
        Option<Vec<u8>>,
454
        Option<Vec<u8>>,
455
        Option<Vec<u8>>,
456
    )> = sqlx::query_as(
457
        "SELECT seq, id, at_ms, kind, actor, subject, auth_method, auth_assurance, hash, prev, auth_key, auth_proof, payload FROM events \
458
         WHERE (? IS NULL OR kind = ?) AND (? IS NULL OR seq < ?) ORDER BY seq DESC LIMIT ?",
459
    )
460
    .bind(kind)
461
    .bind(kind)
462
    .bind(before)
463
    .bind(before)
464
    .bind(limit)
465
    .fetch_all(st.app.daemon.ledger.pool())
466
    .await
467
    .unwrap_or_default();
468
    let mut out = Vec::new();
469
    for (seq, id, at, kind, actor, subject, method, assurance, hash, prev, key, proof, payload) in rows {
470
        let payload = payload
471
            .and_then(|p| serde_json::from_slice::<serde_json::Value>(&p).ok())
472
            .map(|v| serde_json::to_string_pretty(&v).unwrap_or_default())
473
            .unwrap_or_else(|| "(pruned to its stub)".into());
474
        out.push(LogRow {
475
            seq,
476
            id,
477
            at: format_time(at as u64),
478
            kind,
479
            actor: spectra_core::conversation::display_name(&st.app.daemon.ledger, &actor).await,
480
            subject,
481
            method: Method::from_u8(method as u8).map(|m| m.name()).unwrap_or("?").to_string(),
482
            assurance,
483
            hash: hex::encode(hash),
484
            prev: hex::encode(prev),
485
            key: key.map(hex::encode).unwrap_or_else(|| "none (Spectra itself; covered by the next checkpoint)".into()),
486
            proof: proof.map(|p| format!("{} bytes", p.len())).unwrap_or_else(|| "none".into()),
487
            payload,
488
        });
489
    }
490
    out
491
}
492
493
#[derive(Template)]
494
#[template(path = "log.html")]
495
struct LogPage {
496
    shell: Shell,
497
    head_hash: String,
498
    verified: String,
499
    csrf: String,
500
    kinds: Vec<String>,
501
    kind: String,
502
    events: Vec<LogRow>,
503
    older: Option<i64>,
504
}
505
506
#[derive(Deserialize)]
507
struct LogQuery {
508
    kind: Option<String>,
509
    before: Option<i64>,
510
    around: Option<i64>,
511
}
512
513
async fn log(State(st): State<RadarState>, headers: HeaderMap, Query(q): Query<LogQuery>) -> Response {
514
    let o = match owner(&st, &headers, "log").await {
515
        Ok(o) => o,
516
        Err(r) => return r,
517
    };
518
    let d = &st.app.daemon;
519
    let (_, head) = d.ledger.head().await;
520
    let last: Option<(Vec<u8>, i64)> =
521
        sqlx::query_as("SELECT payload, at_ms FROM events WHERE kind = 'ledger.verified' ORDER BY seq DESC LIMIT 1")
522
            .fetch_optional(d.ledger.pool())
523
            .await
524
            .ok()
525
            .flatten();
526
    let verified = match last {
527
        Some((p, at)) => {
528
            let v: serde_json::Value = serde_json::from_slice(&p).unwrap_or_default();
529
            let n = v["findings"].as_u64().unwrap_or(0);
530
            format!(
531
                "{} at {} over {} events",
532
                if n == 0 { "clean".to_string() } else { format!("{n} finding(s)") },
533
                format_time(at as u64),
534
                v["events"].as_u64().unwrap_or(0)
535
            )
536
        }
537
        None => "not yet".into(),
538
    };
539
    let kinds: Vec<String> =
540
        sqlx::query_scalar("SELECT kind FROM event_counts ORDER BY n DESC LIMIT 12").fetch_all(d.ledger.pool()).await.unwrap_or_default();
541
    let kind = q.kind.unwrap_or_default();
542
    let before = q.before.or(q.around.map(|a| a + 25));
543
    let events = recent(&st, (!kind.is_empty()).then_some(kind.as_str()), before, 100).await;
544
    let older = (events.len() == 100).then(|| events.last().map(|e| e.seq)).flatten();
545
    let csrf = o.session.csrf.clone();
546
    render(&LogPage { shell: o.shell, head_hash: hex::encode(head), verified, csrf, kinds, kind, events, older })
547
}
548
549
#[derive(Deserialize)]
550
struct CsrfForm {
551
    _csrf: String,
552
}
553
554
/// Verify the ledger now and record the result (`ledger.verified`).
555
async fn verify(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
556
    let o = match owner(&st, &headers, "log").await {
557
        Ok(o) => o,
558
        Err(r) => return r,
559
    };
560
    if let Some(r) = check_form(&st, &o, &headers, &f._csrf) {
561
        return r;
562
    }
563
    let ledger = &st.app.daemon.ledger;
564
    match ledger.verify(&mut Principals::default()).await {
565
        Ok(v) => {
566
            let e = spectra_core::event::NewEvent::system(
567
                "ledger.verified",
568
                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)) }),
569
            );
570
            let _ = ledger.append_one(e).await;
571
            Redirect::to("/log").into_response()
572
        }
573
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(),
574
    }
575
}
576
577
// ---- Signing in ----
578
579
/// `POST /radar/link`, signed by the owner's CLI: a one-time sign-in link.
580
async fn link(State(st): State<RadarState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, body: Bytes) -> Response {
581
    let caller = match spectra_mcp::http::authenticate(&st.app, peer, &headers, "POST /radar/link", &body).await {
582
        Ok(c) => c,
583
        Err(r) => return r,
584
    };
585
    if !matches!(caller.principal, Principal::Owner { .. }) {
586
        return (StatusCode::FORBIDDEN, axum::Json(serde_json::json!({ "title": "only the owner signs in to Radar" }))).into_response();
587
    }
588
    let (Some(key), Some(proof)) = (caller.auth.key, caller.auth.proof) else {
589
        return StatusCode::UNAUTHORIZED.into_response();
590
    };
591
    let code = st.sessions.link(key, proof, now_ms());
592
    let base = st.app.origins.first().cloned().unwrap_or_default();
593
    axum::Json(serde_json::json!({ "url": format!("{base}/signin?code={code}"), "valid_ms": session::LINK_MS })).into_response()
594
}
595
596
#[derive(Deserialize)]
597
struct SigninQuery {
598
    code: Option<String>,
599
}
600
601
async fn signin(State(st): State<RadarState>, Query(q): Query<SigninQuery>) -> Response {
602
    let Some(id) = q.code.and_then(|c| st.sessions.open(&c, now_ms())) else {
603
        return signin_page("That sign-in link has been used or has expired.", StatusCode::UNAUTHORIZED);
604
    };
605
    let cookie = format!("{COOKIE}={id}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}", session::SESSION_MS / 1000);
606
    let mut r = Redirect::to("/").into_response();
607
    r.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
608
    r
609
}
610
611
async fn signout(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
612
    if let Ok(o) = owner(&st, &headers, "live").await {
613
        if check_form(&st, &o, &headers, &f._csrf).is_none() {
614
            if let Some(id) = cookie(&headers) {
615
                st.sessions.close(&id);
616
            }
617
        }
618
    }
619
    signin_page("Signed out.", StatusCode::OK)
620
}
621
622
// ---- The event stream ----
623
624
/// `GET /radar/events`: one SSE message per appended event, carrying its
625
/// sequence number, kind, actor and subject, never its data (spec §17.3).
626
async fn events(State(st): State<RadarState>, headers: HeaderMap) -> Response {
627
    if let Err(r) = owner(&st, &headers, "live").await {
628
        return r;
629
    }
630
    let rx = st.app.daemon.ledger.subscribe();
631
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|e| async move {
632
        let e = e.ok()?;
633
        let data = serde_json::json!({ "seq": e.seq, "kind": e.kind, "actor": e.actor, "subject": e.subject });
634
        Some(Ok::<_, std::convert::Infallible>(SseEvent::default().data(data.to_string())))
635
    });
636
    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response()
637
}