Radar: Live, channels, threads and the log for the owner, signed in by a CLI-signed link; ledger writes survive a cancelled caller (#10) 39f5c913f617
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ed4de7d42b19
- When
- · 2026-11-02 23:10 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
39f5c913f617e503c9660cf9bb11095ee27ca3866ee694a372481d9cfe7973c8- Depends on
- 037c45d8093c 09752034b5a8 258bb3824813 2fc88a7b3a5c 36537112fcac 571572f5fb2c 5eb57578f4ef 8dc6df8c4d4e eb675dfa5e18
- Depended on by
- 862f0db224aa fe6b29c66cd4 58e1ce30ae58 b044d90930d9 98a0fb9f0948 01320eefd505 b576d05cf862 d17cbc7069ce e071e91a14ef 8cfb9269bb14
- In bookmarks
- main
- In tags
- v0.1.0
- Edits
- 79 atoms, 14 tree operations, 26 files
Browse the tree at this change
added crates/radar/assets/radar.js +17 −0
| 1 | + | // Radar's one script (spec §17.3): when the event stream says something |
|
| 2 | + | // changed, fetch this page again and replace <main>. Pages work without it. |
|
| 3 | + | (function () { |
|
| 4 | + | var main = document.querySelector("main[data-live]"); |
|
| 5 | + | if (!main || !window.EventSource) return; |
|
| 6 | + | var timer = null; |
|
| 7 | + | function refresh() { |
|
| 8 | + | fetch(location.href, { credentials: "same-origin" }).then(function (r) { return r.ok ? r.text() : null; }).then(function (html) { |
|
| 9 | + | if (!html) return; |
|
| 10 | + | var next = new DOMParser().parseFromString(html, "text/html").querySelector("main"); |
|
| 11 | + | var active = document.activeElement; |
|
| 12 | + | if (next && !(active && (active.tagName === "TEXTAREA" || active.tagName === "INPUT") && active.value)) main.innerHTML = next.innerHTML; |
|
| 13 | + | }); |
|
| 14 | + | } |
|
| 15 | + | var es = new EventSource("/radar/events"); |
|
| 16 | + | es.onmessage = function () { clearTimeout(timer); timer = setTimeout(refresh, 300); }; |
|
| 17 | + | })(); |
added crates/radar/assets/spectra.svg +13 −0
| 1 | + | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" width="48" height="32" aria-hidden="true"> |
|
| 2 | + | <!-- Spectra's mark: one line of light into a prism, out as a band of colour. --> |
|
| 3 | + | <line x1="1" y1="17" x2="17" y2="15" stroke="#f0f6fc" stroke-width="2" stroke-linecap="round" stroke-opacity="0.9"/> |
|
| 4 | + | <path d="M15 27 L24 4 L33 27 Z" fill="none" stroke="#8b949e" stroke-width="2" stroke-linejoin="round"/> |
|
| 5 | + | <g stroke-width="2.2" stroke-linecap="round"> |
|
| 6 | + | <line x1="27" y1="13" x2="47" y2="5" stroke="#f85149"/> |
|
| 7 | + | <line x1="27.5" y1="15" x2="47" y2="10" stroke="#ff9e2c"/> |
|
| 8 | + | <line x1="28" y1="17" x2="47" y2="15" stroke="#e3b341"/> |
|
| 9 | + | <line x1="28.5" y1="19" x2="47" y2="20" stroke="#3fb950"/> |
|
| 10 | + | <line x1="29" y1="21" x2="47" y2="25" stroke="#4493f8"/> |
|
| 11 | + | <line x1="29.5" y1="23" x2="47" y2="30" stroke="#a371f7"/> |
|
| 12 | + | </g> |
|
| 13 | + | </svg> |
added crates/radar/assets/style.css +70 −0
| 1 | + | /* Radar's look: OxygenHub's (Primer colours, system fonts, rounded boxes, |
|
| 2 | + | underline navigation, sentence case), light or dark with the system. */ |
|
| 3 | + | :root { |
|
| 4 | + | --bg:#ffffff; --soft:#f6f8fa; --ink:#1f2328; --mute:#59636e; --line:#d1d9e0; --dim:#818b98; |
|
| 5 | + | --acc:#0969da; --ok:#1a7f37; --bad:#d1242f; --warn:#9a6700; --busy:#8250df; |
|
| 6 | + | --header:#f6f8fa; --btn:#f6f8fa; --btn-line:#d1d9e0; --primary:#1f883d; --primary-ink:#ffffff; --banner:#fff8c5; |
|
| 7 | + | --font:-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; |
|
| 8 | + | --mono:ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; |
|
| 9 | + | color-scheme: light; |
|
| 10 | + | } |
|
| 11 | + | @media (prefers-color-scheme: dark) { |
|
| 12 | + | :root { |
|
| 13 | + | --bg:#0d1117; --soft:#151b23; --ink:#f0f6fc; --mute:#9198a1; --line:#3d444d; --dim:#656c76; |
|
| 14 | + | --acc:#4493f8; --ok:#3fb950; --bad:#f85149; --warn:#d29922; --busy:#a371f7; |
|
| 15 | + | --header:#010409; --btn:#212830; --btn-line:#3d444d; --primary:#238636; --banner:#3b2e00; |
|
| 16 | + | color-scheme: dark; |
|
| 17 | + | } |
|
| 18 | + | } |
|
| 19 | + | * { box-sizing: border-box; } |
|
| 20 | + | body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 var(--font); } |
|
| 21 | + | a { color: var(--acc); text-decoration: none; } |
|
| 22 | + | a:hover { text-decoration: underline; } |
|
| 23 | + | code, pre, .mono { font-family: var(--mono); font-size: 12px; } |
|
| 24 | + | header { display: flex; align-items: center; gap: 12px; padding: 12px 24px; background: var(--header); border-bottom: 1px solid var(--line); } |
|
| 25 | + | .brand { display: flex; align-items: center; gap: 8px; color: var(--ink); font-weight: 600; font-size: 16px; } |
|
| 26 | + | .brand svg { height: 28px; width: auto; } |
|
| 27 | + | header .grow { flex: 1; } |
|
| 28 | + | header .who { color: var(--mute); } |
|
| 29 | + | nav.band { display: flex; gap: 4px; padding: 0 24px; border-bottom: 1px solid var(--line); background: var(--header); } |
|
| 30 | + | nav.band a { padding: 8px 12px; color: var(--ink); border-bottom: 2px solid transparent; } |
|
| 31 | + | nav.band a.now { border-bottom-color: #fd8c73; font-weight: 600; } |
|
| 32 | + | nav.band a:hover { text-decoration: none; background: var(--soft); border-radius: 6px 6px 0 0; } |
|
| 33 | + | .banner { background: var(--banner); border-bottom: 1px solid var(--line); padding: 6px 24px; color: var(--ink); font-size: 13px; } |
|
| 34 | + | main { max-width: 1180px; margin: 0 auto; padding: 24px; } |
|
| 35 | + | h1 { font-size: 20px; font-weight: 600; margin: 0 0 16px; } |
|
| 36 | + | h2 { font-size: 16px; font-weight: 600; margin: 24px 0 8px; } |
|
| 37 | + | .box { border: 1px solid var(--line); border-radius: 6px; background: var(--bg); } |
|
| 38 | + | .box > .row { padding: 8px 16px; border-top: 1px solid var(--line); } |
|
| 39 | + | .box > .row:first-child { border-top: 0; } |
|
| 40 | + | .box > .head { padding: 8px 16px; background: var(--soft); border-bottom: 1px solid var(--line); border-radius: 6px 6px 0 0; font-weight: 600; } |
|
| 41 | + | .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; } |
|
| 42 | + | .card { border: 1px solid var(--line); border-radius: 6px; padding: 12px 16px; } |
|
| 43 | + | .card .name { font-weight: 600; font-size: 15px; } |
|
| 44 | + | .mute { color: var(--mute); } |
|
| 45 | + | .dim { color: var(--dim); } |
|
| 46 | + | .pill { display: inline-block; padding: 0 8px; border-radius: 2em; border: 1px solid var(--line); font-size: 12px; line-height: 20px; color: var(--mute); } |
|
| 47 | + | .p-online { color: var(--ok); border-color: var(--ok); } |
|
| 48 | + | .p-busy { color: var(--busy); border-color: var(--busy); } |
|
| 49 | + | .p-blocked, .p-paused { color: var(--warn); border-color: var(--warn); } |
|
| 50 | + | .ok { color: var(--ok); } |
|
| 51 | + | .bad { color: var(--bad); } |
|
| 52 | + | .msg { display: flex; flex-direction: column; gap: 2px; } |
|
| 53 | + | .msg .meta { color: var(--mute); font-size: 12px; } |
|
| 54 | + | .msg .body p { margin: 4px 0; } |
|
| 55 | + | .msg .body pre { background: var(--soft); padding: 8px; border-radius: 6px; overflow-x: auto; } |
|
| 56 | + | .redacted { color: var(--dim); font-style: italic; } |
|
| 57 | + | form.compose { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; } |
|
| 58 | + | textarea { width: 100%; min-height: 80px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); font: inherit; } |
|
| 59 | + | button { padding: 5px 16px; border: 1px solid var(--btn-line); border-radius: 6px; background: var(--btn); color: var(--ink); font: inherit; font-weight: 500; cursor: pointer; } |
|
| 60 | + | button.primary { background: var(--primary); border-color: rgba(31,35,40,.15); color: var(--primary-ink); align-self: flex-start; } |
|
| 61 | + | details > summary { cursor: pointer; list-style: none; } |
|
| 62 | + | details[open] > summary { margin-bottom: 6px; } |
|
| 63 | + | pre.payload { background: var(--soft); padding: 8px 12px; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; } |
|
| 64 | + | table.facts td { padding: 2px 12px 2px 0; vertical-align: top; } |
|
| 65 | + | table.facts td:first-child { color: var(--mute); white-space: nowrap; } |
|
| 66 | + | form.inline { display: inline; } |
|
| 67 | + | .filters { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; } |
|
| 68 | + | .filters a { padding: 2px 10px; border: 1px solid var(--line); border-radius: 2em; color: var(--ink); font-size: 12px; } |
|
| 69 | + | .filters a.now { background: var(--acc); border-color: var(--acc); color: #fff; } |
|
| 70 | + | footer { max-width: 1180px; margin: 24px auto; padding: 0 24px; color: var(--dim); font-size: 12px; } |
added crates/radar/src/session.rs +86 −0
| 1 | + | //! Radar's sign-in (spec §8.3): the owner's CLI signs a request for a |
|
| 2 | + | //! one-time link (`POST /radar/link`, valid 60 s); opening it starts a |
|
| 3 | + | //! browser session whose events carry that signed request as their proof |
|
| 4 | + | //! (level 2). The passkey (level 3) is 0.2. Sessions live in memory: a |
|
| 5 | + | //! restart signs the browser out. |
|
| 6 | + | ||
| 7 | + | use rand::RngCore; |
|
| 8 | + | use std::collections::HashMap; |
|
| 9 | + | use std::sync::Mutex; |
|
| 10 | + | ||
| 11 | + | /// How long a sign-in link is good for, and a session. |
|
| 12 | + | pub const LINK_MS: u64 = 60_000; |
|
| 13 | + | pub const SESSION_MS: u64 = 12 * 3_600_000; |
|
| 14 | + | ||
| 15 | + | /// What a session holds: the owner's key and the proof that opened it. |
|
| 16 | + | #[derive(Clone, Debug)] |
|
| 17 | + | pub struct Session { |
|
| 18 | + | pub key: [u8; 32], |
|
| 19 | + | pub proof: Vec<u8>, |
|
| 20 | + | pub csrf: String, |
|
| 21 | + | pub expires_ms: u64, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | #[derive(Debug, Default)] |
|
| 25 | + | pub struct Sessions { |
|
| 26 | + | codes: Mutex<HashMap<String, (Session, u64)>>, |
|
| 27 | + | live: Mutex<HashMap<String, Session>>, |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | fn random_hex() -> String { |
|
| 31 | + | let mut b = [0u8; 32]; |
|
| 32 | + | rand::rngs::OsRng.fill_bytes(&mut b); |
|
| 33 | + | hex::encode(b) |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | impl Sessions { |
|
| 37 | + | /// A one-time code for the owner, from their signed request. |
|
| 38 | + | pub fn link(&self, key: [u8; 32], proof: Vec<u8>, now: u64) -> String { |
|
| 39 | + | let code = random_hex(); |
|
| 40 | + | let s = Session { key, proof, csrf: random_hex(), expires_ms: now + SESSION_MS }; |
|
| 41 | + | let mut codes = self.codes.lock().unwrap(); |
|
| 42 | + | codes.retain(|_, (_, until)| *until > now); |
|
| 43 | + | codes.insert(code.clone(), (s, now + LINK_MS)); |
|
| 44 | + | code |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | /// Spend a code: the session id, once, within a minute of the link. |
|
| 48 | + | pub fn open(&self, code: &str, now: u64) -> Option<String> { |
|
| 49 | + | let (s, until) = self.codes.lock().unwrap().remove(code)?; |
|
| 50 | + | if until < now { |
|
| 51 | + | return None; |
|
| 52 | + | } |
|
| 53 | + | let id = random_hex(); |
|
| 54 | + | let mut live = self.live.lock().unwrap(); |
|
| 55 | + | live.retain(|_, s| s.expires_ms > now); |
|
| 56 | + | live.insert(id.clone(), s); |
|
| 57 | + | Some(id) |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | pub fn get(&self, id: &str, now: u64) -> Option<Session> { |
|
| 61 | + | self.live.lock().unwrap().get(id).filter(|s| s.expires_ms > now).cloned() |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | pub fn close(&self, id: &str) { |
|
| 65 | + | self.live.lock().unwrap().remove(id); |
|
| 66 | + | } |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | #[cfg(test)] |
|
| 70 | + | mod tests { |
|
| 71 | + | use super::*; |
|
| 72 | + | ||
| 73 | + | #[test] |
|
| 74 | + | fn a_link_opens_one_session_once_and_only_while_fresh() { |
|
| 75 | + | let s = Sessions::default(); |
|
| 76 | + | let code = s.link([1; 32], vec![9], 1_000); |
|
| 77 | + | let id = s.open(&code, 2_000).expect("fresh"); |
|
| 78 | + | assert!(s.open(&code, 2_000).is_none(), "spent"); |
|
| 79 | + | assert_eq!(s.get(&id, 3_000).unwrap().key, [1; 32]); |
|
| 80 | + | assert!(s.get(&id, 1_000 + SESSION_MS + 1).is_none(), "expired"); |
|
| 81 | + | let stale = s.link([1; 32], vec![9], 1_000); |
|
| 82 | + | assert!(s.open(&stale, 1_000 + LINK_MS + 1).is_none()); |
|
| 83 | + | s.close(&id); |
|
| 84 | + | assert!(s.get(&id, 3_000).is_none()); |
|
| 85 | + | } |
|
| 86 | + | } |
added crates/radar/templates/base.html +29 −0
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="utf-8"> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> |
|
| 6 | + | <title>{% block title %}{% endblock %} · Spectra</title> |
|
| 7 | + | <link rel="stylesheet" href="/radar/style.css"> |
|
| 8 | + | <link rel="icon" href="/radar/spectra.svg" type="image/svg+xml"> |
|
| 9 | + | <script src="/radar/radar.js" defer></script> |
|
| 10 | + | {% block head %}{% endblock %} |
|
| 11 | + | </head> |
|
| 12 | + | <body> |
|
| 13 | + | <header> |
|
| 14 | + | <a class="brand" href="/">{{ shell.mark|safe }}Spectra</a> |
|
| 15 | + | <span class="grow"></span> |
|
| 16 | + | <span class="who">{{ shell.owner }}</span> |
|
| 17 | + | </header> |
|
| 18 | + | <nav class="band" aria-label="Radar"> |
|
| 19 | + | <a href="/"{% if shell.page == "live" %} class="now"{% endif %}>Live</a> |
|
| 20 | + | <a href="/channels"{% if shell.page == "channels" %} class="now"{% endif %}>Channels</a> |
|
| 21 | + | <a href="/log"{% if shell.page == "log" %} class="now"{% endif %}>Log</a> |
|
| 22 | + | </nav> |
|
| 23 | + | <div class="banner">Signed in from the command line (level 2). Answering agents' requests and changing rules will need your passkey, which arrives in 0.2.</div> |
|
| 24 | + | <main id="main" data-live> |
|
| 25 | + | {% block content %}{% endblock %} |
|
| 26 | + | </main> |
|
| 27 | + | <footer>Spectra {{ shell.version }} · ledger head #{{ shell.head }}</footer> |
|
| 28 | + | </body> |
|
| 29 | + | </html> |
added crates/radar/templates/channel.html +23 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}{{ display }}{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <h1>{{ display }} {% if let Some(t) = thread %}<span class="mute">· thread</span>{% endif %}</h1> |
|
| 5 | + | {% if let Some(p) = purpose %}<p class="mute">{{ p }}</p>{% endif %} |
|
| 6 | + | <div class="box"> |
|
| 7 | + | {% if messages.is_empty() %}<div class="row mute">No messages yet.</div>{% endif %} |
|
| 8 | + | {% for m in messages %} |
|
| 9 | + | <div class="row msg" id="m{{ m.id }}"> |
|
| 10 | + | <div class="meta"><strong>{{ m.author }}</strong> · {{ m.at }} · <code>{{ m.kind }}</code>{% if m.replies > 0 %} · <a href="/c/{{ name }}/t/{{ m.id }}">{{ m.replies }} repl{% if m.replies == 1 %}y{% else %}ies{% endif %}</a>{% else %}{% if thread.is_none() %} · <a href="/c/{{ name }}/t/{{ m.id }}">reply</a>{% endif %}{% endif %} <span class="dim mono">{{ m.id }}</span></div> |
|
| 11 | + | {% if m.redacted %}<div class="redacted">{{ m.body_html|safe }}</div>{% else %}<div class="body">{{ m.body_html|safe }}</div>{% endif %} |
|
| 12 | + | </div> |
|
| 13 | + | {% endfor %} |
|
| 14 | + | </div> |
|
| 15 | + | {% if can_post %} |
|
| 16 | + | <form class="compose" method="post" action="{{ action }}"> |
|
| 17 | + | <input type="hidden" name="_csrf" value="{{ csrf }}"> |
|
| 18 | + | {% if let Some(t) = thread %}<input type="hidden" name="reply_to" value="{{ t }}">{% endif %} |
|
| 19 | + | <textarea name="body" placeholder="Write as {{ shell.owner }}. Markdown; @name mentions." required></textarea> |
|
| 20 | + | <button class="primary" type="submit">{% if thread.is_some() %}Reply{% else %}Post{% endif %}</button> |
|
| 21 | + | </form> |
|
| 22 | + | {% endif %} |
|
| 23 | + | {% endblock %} |
added crates/radar/templates/channels.html +10 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Channels{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <h1>Channels</h1> |
|
| 5 | + | <div class="box"> |
|
| 6 | + | {% for c in channels %} |
|
| 7 | + | <div class="row"><a href="/c/{{ c.name }}"><strong>{{ c.display }}</strong></a> <span class="pill">{{ c.kind }}</span> <span class="mute">{{ c.purpose }}</span> <span class="dim">· {{ c.count }} message(s){% if !c.last.is_empty() %}, last {{ c.last }}{% endif %}</span></div> |
|
| 8 | + | {% endfor %} |
|
| 9 | + | </div> |
|
| 10 | + | {% endblock %} |
added crates/radar/templates/live.html +28 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Live{% endblock %} |
|
| 3 | + | {% block head %}<noscript><meta http-equiv="refresh" content="30"></noscript>{% endblock %} |
|
| 4 | + | {% block content %} |
|
| 5 | + | <h1>Live</h1> |
|
| 6 | + | {% if agents.is_empty() %} |
|
| 7 | + | <p class="mute">No agents yet. Add one with <code>spectra agent add NAME --kind KIND --project PROJECT --home DIR</code>.</p> |
|
| 8 | + | {% else %} |
|
| 9 | + | <div class="cards"> |
|
| 10 | + | {% for a in agents %} |
|
| 11 | + | <div class="card"> |
|
| 12 | + | <div class="name">{{ a.name }} <span class="pill p-{{ a.presence }}">{{ a.presence }}</span></div> |
|
| 13 | + | <div class="mute">{{ a.owner }}'s agent · {{ a.kind }} · #{{ a.project }}</div> |
|
| 14 | + | {% if let Some(s) = a.status %}<div>“{{ s }}”</div>{% endif %} |
|
| 15 | + | {% if let Some(t) = a.last_tool %}<div class="dim">last call: <code>{{ t }}</code> {{ a.last_at }}</div>{% endif %} |
|
| 16 | + | </div> |
|
| 17 | + | {% endfor %} |
|
| 18 | + | </div> |
|
| 19 | + | {% endif %} |
|
| 20 | + | <h2>Your inbox</h2> |
|
| 21 | + | <p class="mute">{{ unread }} unread mention(s) and direct message(s). <a href="/channels">Channels</a></p> |
|
| 22 | + | <h2>Recent events</h2> |
|
| 23 | + | <div class="box"> |
|
| 24 | + | {% for e in events %} |
|
| 25 | + | <div class="row"><a class="mono" href="/log?around={{ e.seq }}">#{{ e.seq }}</a> <span class="dim">{{ e.at }}</span> <code>{{ e.kind }}</code> {{ e.actor }}{% if let Some(s) = e.subject %} <span class="dim">{{ s }}</span>{% endif %}</div> |
|
| 26 | + | {% endfor %} |
|
| 27 | + | </div> |
|
| 28 | + | {% endblock %} |
added crates/radar/templates/log.html +31 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Log{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <h1>Log</h1> |
|
| 5 | + | <table class="facts"> |
|
| 6 | + | <tr><td>head</td><td class="mono">#{{ shell.head }} {{ head_hash }}</td></tr> |
|
| 7 | + | <tr><td>verified</td><td>{{ verified }} |
|
| 8 | + | <form class="inline" method="post" action="/log/verify"><input type="hidden" name="_csrf" value="{{ csrf }}"> <button type="submit">Verify now</button></form></td></tr> |
|
| 9 | + | </table> |
|
| 10 | + | <div class="filters"> |
|
| 11 | + | <a href="/log"{% if kind.is_empty() %} class="now"{% endif %}>All</a> |
|
| 12 | + | {% for k in kinds %} |
|
| 13 | + | <a href="/log?kind={{ k }}"{% if kind.as_str() == k.as_str() %} class="now"{% endif %}>{{ k }}</a> |
|
| 14 | + | {% endfor %} |
|
| 15 | + | </div> |
|
| 16 | + | <div class="box"> |
|
| 17 | + | {% for e in events %} |
|
| 18 | + | <div class="row"><details><summary><span class="mono">#{{ e.seq }}</span> <span class="dim">{{ e.at }}</span> <code>{{ e.kind }}</code> {{ e.actor }}{% if let Some(s) = e.subject %} <span class="dim">{{ s }}</span>{% endif %} <span class="pill">{{ e.method }} · level {{ e.assurance }}</span></summary> |
|
| 19 | + | <table class="facts"> |
|
| 20 | + | <tr><td>hash</td><td class="mono">{{ e.hash }}</td></tr> |
|
| 21 | + | <tr><td>prev</td><td class="mono">{{ e.prev }}</td></tr> |
|
| 22 | + | <tr><td>key</td><td class="mono">{{ e.key }}</td></tr> |
|
| 23 | + | <tr><td>proof</td><td>{{ e.proof }}</td></tr> |
|
| 24 | + | <tr><td>id</td><td class="mono">{{ e.id }}</td></tr> |
|
| 25 | + | </table> |
|
| 26 | + | <pre class="payload">{{ e.payload }}</pre> |
|
| 27 | + | </details></div> |
|
| 28 | + | {% endfor %} |
|
| 29 | + | </div> |
|
| 30 | + | {% if let Some(b) = older %}<p><a href="/log?before={{ b }}{% if !kind.is_empty() %}&kind={{ kind }}{% endif %}">Older events</a></p>{% endif %} |
|
| 31 | + | {% endblock %} |
added crates/radar/templates/signin.html +12 −0
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sign in · Spectra</title><link rel="stylesheet" href="/radar/style.css"></head> |
|
| 4 | + | <body> |
|
| 5 | + | <header><a class="brand" href="/">{{ mark|safe }}Spectra</a></header> |
|
| 6 | + | <main> |
|
| 7 | + | <h1>Sign in to Radar</h1> |
|
| 8 | + | <p>{{ message }}</p> |
|
| 9 | + | <p>Run <code>spectra open</code> on this machine: it signs a one-time link with your key and opens it here.</p> |
|
| 10 | + | </main> |
|
| 11 | + | </body> |
|
| 12 | + | </html> |
added src/cli/open.rs +38 −0
| 1 | + | //! `spectra open`: sign in to Radar (spec §8.3). The owner's CLI signs a |
|
| 2 | + | //! request for a one-time link with the owner's key; the link, valid for a |
|
| 3 | + | //! minute, opens a level-2 browser session. |
|
| 4 | + | ||
| 5 | + | use super::identity::{owner_home, recorded_owner}; |
|
| 6 | + | use super::{ui, Ctx}; |
|
| 7 | + | use anyhow::{bail, Context, Result}; |
|
| 8 | + | use spectra_core::ledger::Ledger; |
|
| 9 | + | use spectra_mcp::client::Client; |
|
| 10 | + | use std::path::PathBuf; |
|
| 11 | + | ||
| 12 | + | pub async fn open(ctx: &Ctx, owner_home_flag: Option<PathBuf>, launch: bool) -> Result<i32> { |
|
| 13 | + | let socket = ctx.socket(); |
|
| 14 | + | if !Client::alive(&socket).await { |
|
| 15 | + | bail!("Spectra is not serving {} (spectra serve)", ctx.data.display()); |
|
| 16 | + | } |
|
| 17 | + | let home = owner_home(ctx, owner_home_flag); |
|
| 18 | + | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 19 | + | let owner = recorded_owner(&ledger, &home).await?; |
|
| 20 | + | let r = Client::new(&socket, Some(owner)).send("POST", "/radar/link", &[], Vec::new()).await?; |
|
| 21 | + | if r.status != 200 { |
|
| 22 | + | bail!("the daemon refused the sign-in: {}", String::from_utf8_lossy(&r.body)); |
|
| 23 | + | } |
|
| 24 | + | let url = r.json()?.get("url").and_then(|u| u.as_str()).map(String::from).context("no link in the answer")?; |
|
| 25 | + | println!("{} {}", ui::done("Signed"), ui::subject("a one-time link to Radar, good for a minute")); |
|
| 26 | + | println!(" {}", ui::id(&url)); |
|
| 27 | + | if launch |
|
| 28 | + | && std::process::Command::new("xdg-open") |
|
| 29 | + | .arg(&url) |
|
| 30 | + | .stdout(std::process::Stdio::null()) |
|
| 31 | + | .stderr(std::process::Stdio::null()) |
|
| 32 | + | .spawn() |
|
| 33 | + | .is_ok() |
|
| 34 | + | { |
|
| 35 | + | println!(" {} {}", ui::note(), ui::dim("opened in your browser")); |
|
| 36 | + | } |
|
| 37 | + | Ok(0) |
|
| 38 | + | } |
added tests/radar.rs +89 −0
| 1 | + | //! Radar end to end (spec §17, §8.3, §21.2): the owner signs in with a |
|
| 2 | + | //! one-time link their CLI signed, every page renders and none shows |
|
| 3 | + | //! without the session, a post needs the session's CSRF token and our own |
|
| 4 | + | //! origin and is recorded as a verified session event. |
|
| 5 | + | ||
| 6 | + | mod common; |
|
| 7 | + | ||
| 8 | + | use common::{add_agent, hermetic, run, setup, text, Serving}; |
|
| 9 | + | use serde_json::json; |
|
| 10 | + | use spectra_mcp::client::Client; |
|
| 11 | + | ||
| 12 | + | fn link(d: &std::path::Path) -> String { |
|
| 13 | + | let o = hermetic(d).args(["open", "--no-browser"]).output().unwrap(); |
|
| 14 | + | let t = text(&o); |
|
| 15 | + | let url = t.split_whitespace().find(|w| w.starts_with("http://")).unwrap_or_else(|| panic!("no link in {t}")).to_string(); |
|
| 16 | + | url.split_once("7350") |
|
| 17 | + | .or_else(|| url.split_once(":0")) |
|
| 18 | + | .map(|(_, p)| p.to_string()) |
|
| 19 | + | .unwrap_or_else(|| format!("/{}", url.splitn(4, '/').nth(3).unwrap())) |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | #[tokio::test] |
|
| 23 | + | async fn the_owner_signs_in_reads_every_page_and_posts() { |
|
| 24 | + | let dir = setup(); |
|
| 25 | + | let d = dir.path(); |
|
| 26 | + | let agent = add_agent(d, "claude-oxygen", &[]); |
|
| 27 | + | let srv = Serving::start(d); |
|
| 28 | + | let a = Client::new(&srv.socket, Some(agent)); |
|
| 29 | + | let say = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "say", "arguments": { "body": "@andy **ready** for review", "channel": "humans" } } }); |
|
| 30 | + | assert_eq!(a.mcp("2025-11-25", &say).await.unwrap().status, 200); |
|
| 31 | + | ||
| 32 | + | let web = Client::new(&srv.socket, None); |
|
| 33 | + | for path in ["/", "/channels", "/c/general", "/log"] { |
|
| 34 | + | let r = web.send("GET", path, &[], vec![]).await.unwrap(); |
|
| 35 | + | assert_eq!(r.status, 401, "{path} without a session"); |
|
| 36 | + | assert!(String::from_utf8_lossy(&r.body).contains("spectra open")); |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | let path = link(d); |
|
| 40 | + | let r = web.send("GET", &path, &[], vec![]).await.unwrap(); |
|
| 41 | + | assert_eq!(r.status, 303, "{}", String::from_utf8_lossy(&r.body)); |
|
| 42 | + | let cookie = r.header("set-cookie").unwrap().split(';').next().unwrap().to_string(); |
|
| 43 | + | assert!(r.header("set-cookie").unwrap().contains("HttpOnly")); |
|
| 44 | + | assert_eq!(web.send("GET", &path, &[], vec![]).await.unwrap().status, 401, "a link works once"); |
|
| 45 | + | ||
| 46 | + | let get = |p: &'static str| { |
|
| 47 | + | let (web, cookie) = (&web, cookie.clone()); |
|
| 48 | + | async move { |
|
| 49 | + | let r = web.send("GET", p, &[("cookie", cookie)], vec![]).await.unwrap(); |
|
| 50 | + | assert_eq!(r.status, 200, "{p}: {}", String::from_utf8_lossy(&r.body)); |
|
| 51 | + | assert!(r.header("content-security-policy").unwrap().contains("default-src 'self'")); |
|
| 52 | + | String::from_utf8_lossy(&r.body).to_string() |
|
| 53 | + | } |
|
| 54 | + | }; |
|
| 55 | + | let live = get("/").await; |
|
| 56 | + | assert!(live.contains("claude-oxygen") && live.contains("Andy McCall's agent"), "escaped by askama: {live}"); |
|
| 57 | + | assert!(live.contains("1 unread")); |
|
| 58 | + | assert!(get("/channels").await.contains("#humans")); |
|
| 59 | + | let humans = get("/c/humans").await; |
|
| 60 | + | assert!(humans.contains("<strong>ready</strong>"), "Markdown rendered: {humans}"); |
|
| 61 | + | let log = get("/log").await; |
|
| 62 | + | assert!(log.contains("message.posted") && log.contains("signature · level 2")); |
|
| 63 | + | ||
| 64 | + | let csrf = humans.split("name=\"_csrf\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string(); |
|
| 65 | + | let form = |csrf: &str| format!("_csrf={csrf}&body=Looks+good%2C+%40claude-oxygen").into_bytes(); |
|
| 66 | + | let h = |origin: &str| { |
|
| 67 | + | vec![("cookie", cookie.clone()), ("content-type", "application/x-www-form-urlencoded".to_string()), ("origin", origin.to_string())] |
|
| 68 | + | }; |
|
| 69 | + | assert_eq!(web.send("POST", "/c/humans", &h("http://evil.example"), form(&csrf)).await.unwrap().status, 403); |
|
| 70 | + | // Without an Origin header (not a browser) the CSRF token alone guards |
|
| 71 | + | // the form; a browser's Origin must also be the daemon's own address. |
|
| 72 | + | let no_origin = vec![("cookie", cookie.clone()), ("content-type", "application/x-www-form-urlencoded".to_string())]; |
|
| 73 | + | assert_eq!(web.send("POST", "/c/humans", &no_origin, form("stale")).await.unwrap().status, 403); |
|
| 74 | + | assert_eq!(web.send("POST", "/c/humans", &no_origin, form(&csrf)).await.unwrap().status, 303); |
|
| 75 | + | assert!(get("/c/humans").await.contains("Looks good")); |
|
| 76 | + | ||
| 77 | + | assert_eq!(web.send("POST", "/log/verify", &no_origin, format!("_csrf={csrf}").into_bytes()).await.unwrap().status, 303); |
|
| 78 | + | assert!(get("/log").await.contains("clean at")); |
|
| 79 | + | srv.stop(); |
|
| 80 | + | ||
| 81 | + | let v: serde_json::Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap(); |
|
| 82 | + | assert!(v["findings"].as_array().unwrap().is_empty(), "the session post verifies: {v}"); |
|
| 83 | + | let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap(); |
|
| 84 | + | let m: (String, i64) = sqlx::query_as("SELECT actor, auth_method FROM events WHERE kind = 'message.posted' ORDER BY seq DESC LIMIT 1") |
|
| 85 | + | .fetch_one(&pool) |
|
| 86 | + | .await |
|
| 87 | + | .unwrap(); |
|
| 88 | + | assert_eq!(m, ("human".to_string(), 4), "recorded as the owner, through a session"); |
|
| 89 | + | } |
modified CHANGELOG.md +2 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Fixed: a client hanging up mid-request could stop the ledger saving anything.** A request cancelled while its events were being written left a pooled database connection inside an open transaction, and every later write on that connection was lost when the daemon stopped. Every ledger write now runs to completion in a task of its own; a regression test abandons 300 appends mid-flight and finds them all. |
|
| 6 | + | - **Added: Radar, the owner's window (#10).** `spectra open` (or `spectra serve --open`) signs a one-time link with the owner's key; the browser session it opens shows Live (each agent with its owner, presence, status and last call; the inbox; recent events), Channels, a channel or thread with a compose box, and the Log, with each event's hash, proof and level and a Verify now button. Pages refresh from an event stream that carries ids, never data, and work without JavaScript; posts need the session's CSRF token and Spectra's own origin and are recorded as the owner's, with the signed link as proof. |
|
| 5 | 7 | - **Added: the stdio shim, `spectra mcp --agent NAME` (#9).** Any MCP client that starts stdio servers connects with one line and nothing secret in it: the shim signs every request with the agent's key from its own home and forwards it to the daemon. It speaks 2025-11-25, 2025-06-18 and 2025-03-26 (and passes 2026-07-28 through), answers requests concurrently so a `wait` holds nothing up, writes only protocol to stdout, and says plainly when the daemon is not running. |
|
| 6 | 8 | - **Added: the 0.1 tools (#8).** `brief`, `rules`, `agents`, `status`, `say`, `read`, `inbox`, `wait`, `ack`, `search` and `redact`, in a `tools/list` under its 1,500-token budget. Every call is recorded as `tool.called` with its arguments and result kept in full, content addressed. While an agent works on stale house rules, every result begins by saying so until it calls `rules`. Messages come back delimited with their author. `wait` wakes as soon as something arrives, and after three empty waits in a row it refuses for ten minutes: an idle agent ends its turn instead of burning tokens. `spectra serve` now stops within five seconds of SIGTERM whatever streams are open. |
|
| 7 | 9 | - **Added: `spectra serve` and Tuner, MCP over the Unix socket and loopback (#7).** The daemon holds the ledger and serves `POST /mcp` on `spectra.sock` (reading the caller's uid) and on `127.0.0.1:7350`. Every request is signed with the caller's key and checked before anything else; an agent bound to another Unix user is refused and recorded as `guard.impersonation`, and `--learn-uid` binds an agent on its first connection. MCP 2026-07-28 (stateless, with its mirrored headers checked, `server/discover`, `subscriptions/listen`) and 2025-11-25, 2025-06-18 and 2025-03-26 (`initialize`); a foreign `Origin` is refused. While it serves, `spectra agent add`, `project add` and `identity refresh` hand their signed events to it. It writes a checkpoint when it stops. |
|
| … | 7 unchanged lines |
modified Cargo.lock +265 −0
| … | 23 unchanged lines |
||
| 24 | 24 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" |
|
| 25 | 25 | ||
| 26 | 26 | [[package]] |
|
| 27 | + | name = "ammonia" |
|
| 28 | + | version = "4.2.4" |
|
| 29 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 30 | + | checksum = "1f45dfa685e1ec33b1bf1a3b93b8047cbdbef41b6040deebbe6bea7720a45574" |
|
| 31 | + | dependencies = [ |
|
| 32 | + | "cssparser", |
|
| 33 | + | "html5ever", |
|
| 34 | + | "maplit", |
|
| 35 | + | "url", |
|
| 36 | + | ] |
|
| 37 | + | ||
| 38 | + | [[package]] |
|
| 27 | 39 | name = "anstream" |
|
| 28 | 40 | version = "1.0.0" |
|
| 29 | 41 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 54 unchanged lines |
||
| 84 | 96 | version = "0.7.8" |
|
| 85 | 97 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 86 | 98 | checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" |
|
| 99 | + | ||
| 100 | + | [[package]] |
|
| 101 | + | name = "askama" |
|
| 102 | + | version = "0.14.0" |
|
| 103 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 104 | + | checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" |
|
| 105 | + | dependencies = [ |
|
| 106 | + | "askama_derive", |
|
| 107 | + | "itoa", |
|
| 108 | + | "percent-encoding", |
|
| 109 | + | "serde", |
|
| 110 | + | "serde_json", |
|
| 111 | + | ] |
|
| 87 | 112 | ||
| 88 | 113 | [[package]] |
|
| 114 | + | name = "askama_derive" |
|
| 115 | + | version = "0.14.0" |
|
| 116 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 117 | + | checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" |
|
| 118 | + | dependencies = [ |
|
| 119 | + | "askama_parser", |
|
| 120 | + | "basic-toml", |
|
| 121 | + | "memchr", |
|
| 122 | + | "proc-macro2", |
|
| 123 | + | "quote", |
|
| 124 | + | "rustc-hash", |
|
| 125 | + | "serde", |
|
| 126 | + | "serde_derive", |
|
| 127 | + | "syn 2.0.119", |
|
| 128 | + | ] |
|
| 129 | + | ||
| 130 | + | [[package]] |
|
| 131 | + | name = "askama_parser" |
|
| 132 | + | version = "0.14.0" |
|
| 133 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 134 | + | checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" |
|
| 135 | + | dependencies = [ |
|
| 136 | + | "memchr", |
|
| 137 | + | "serde", |
|
| 138 | + | "serde_derive", |
|
| 139 | + | "winnow", |
|
| 140 | + | ] |
|
| 141 | + | ||
| 142 | + | [[package]] |
|
| 89 | 143 | name = "atoi" |
|
| 90 | 144 | version = "2.0.0" |
|
| 91 | 145 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 91 unchanged lines |
||
| 183 | 237 | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" |
|
| 184 | 238 | ||
| 185 | 239 | [[package]] |
|
| 240 | + | name = "basic-toml" |
|
| 241 | + | version = "0.1.10" |
|
| 242 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 243 | + | checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" |
|
| 244 | + | dependencies = [ |
|
| 245 | + | "serde", |
|
| 246 | + | ] |
|
| 247 | + | ||
| 248 | + | [[package]] |
|
| 186 | 249 | name = "bit-set" |
|
| 187 | 250 | version = "0.8.0" |
|
| 188 | 251 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 276 unchanged lines |
||
| 465 | 528 | ] |
|
| 466 | 529 | ||
| 467 | 530 | [[package]] |
|
| 531 | + | name = "cssparser" |
|
| 532 | + | version = "0.38.0" |
|
| 533 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 534 | + | checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59" |
|
| 535 | + | dependencies = [ |
|
| 536 | + | "dtoa-short", |
|
| 537 | + | "itoa", |
|
| 538 | + | "smallvec", |
|
| 539 | + | ] |
|
| 540 | + | ||
| 541 | + | [[package]] |
|
| 468 | 542 | name = "ctutils" |
|
| 469 | 543 | version = "0.4.2" |
|
| 470 | 544 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 89 unchanged lines |
||
| 560 | 634 | version = "0.15.7" |
|
| 561 | 635 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 562 | 636 | checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" |
|
| 637 | + | ||
| 638 | + | [[package]] |
|
| 639 | + | name = "dtoa" |
|
| 640 | + | version = "1.0.11" |
|
| 641 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 642 | + | checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" |
|
| 563 | 643 | ||
| 564 | 644 | [[package]] |
|
| 645 | + | name = "dtoa-short" |
|
| 646 | + | version = "0.3.5" |
|
| 647 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 648 | + | checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" |
|
| 649 | + | dependencies = [ |
|
| 650 | + | "dtoa", |
|
| 651 | + | ] |
|
| 652 | + | ||
| 653 | + | [[package]] |
|
| 565 | 654 | name = "ed25519" |
|
| 566 | 655 | version = "3.0.0" |
|
| 567 | 656 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 348 unchanged lines |
||
| 916 | 1005 | checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" |
|
| 917 | 1006 | dependencies = [ |
|
| 918 | 1007 | "windows-sys 0.61.2", |
|
| 1008 | + | ] |
|
| 1009 | + | ||
| 1010 | + | [[package]] |
|
| 1011 | + | name = "html5ever" |
|
| 1012 | + | version = "0.40.1" |
|
| 1013 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1014 | + | checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130" |
|
| 1015 | + | dependencies = [ |
|
| 1016 | + | "log", |
|
| 1017 | + | "markup5ever", |
|
| 1018 | + | "memchr", |
|
| 919 | 1019 | ] |
|
| 920 | 1020 | ||
| 921 | 1021 | [[package]] |
|
| … | 352 unchanged lines |
||
| 1274 | 1374 | version = "0.4.34" |
|
| 1275 | 1375 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1276 | 1376 | checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" |
|
| 1377 | + | ||
| 1378 | + | [[package]] |
|
| 1379 | + | name = "maplit" |
|
| 1380 | + | version = "1.0.2" |
|
| 1381 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1382 | + | checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" |
|
| 1277 | 1383 | ||
| 1278 | 1384 | [[package]] |
|
| 1385 | + | name = "markup5ever" |
|
| 1386 | + | version = "0.40.0" |
|
| 1387 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1388 | + | checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842" |
|
| 1389 | + | dependencies = [ |
|
| 1390 | + | "log", |
|
| 1391 | + | "tendril", |
|
| 1392 | + | "web_atoms", |
|
| 1393 | + | ] |
|
| 1394 | + | ||
| 1395 | + | [[package]] |
|
| 1279 | 1396 | name = "matchers" |
|
| 1280 | 1397 | version = "0.2.0" |
|
| 1281 | 1398 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 86 unchanged lines |
||
| 1368 | 1485 | "hybrid-array", |
|
| 1369 | 1486 | "num-traits", |
|
| 1370 | 1487 | ] |
|
| 1488 | + | ||
| 1489 | + | [[package]] |
|
| 1490 | + | name = "new_debug_unreachable" |
|
| 1491 | + | version = "1.0.6" |
|
| 1492 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1493 | + | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" |
|
| 1371 | 1494 | ||
| 1372 | 1495 | [[package]] |
|
| 1373 | 1496 | name = "nu-ansi-term" |
|
| … | 137 unchanged lines |
||
| 1511 | 1634 | version = "2.3.2" |
|
| 1512 | 1635 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1513 | 1636 | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" |
|
| 1637 | + | ||
| 1638 | + | [[package]] |
|
| 1639 | + | name = "phf" |
|
| 1640 | + | version = "0.14.0" |
|
| 1641 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1642 | + | checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" |
|
| 1643 | + | dependencies = [ |
|
| 1644 | + | "phf_shared", |
|
| 1645 | + | "serde", |
|
| 1646 | + | ] |
|
| 1647 | + | ||
| 1648 | + | [[package]] |
|
| 1649 | + | name = "phf_codegen" |
|
| 1650 | + | version = "0.14.0" |
|
| 1651 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1652 | + | checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" |
|
| 1653 | + | dependencies = [ |
|
| 1654 | + | "phf_generator", |
|
| 1655 | + | "phf_shared", |
|
| 1656 | + | ] |
|
| 1657 | + | ||
| 1658 | + | [[package]] |
|
| 1659 | + | name = "phf_generator" |
|
| 1660 | + | version = "0.14.0" |
|
| 1661 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1662 | + | checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" |
|
| 1663 | + | dependencies = [ |
|
| 1664 | + | "fastrand", |
|
| 1665 | + | "phf_shared", |
|
| 1666 | + | ] |
|
| 1514 | 1667 | ||
| 1515 | 1668 | [[package]] |
|
| 1669 | + | name = "phf_shared" |
|
| 1670 | + | version = "0.14.0" |
|
| 1671 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1672 | + | checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" |
|
| 1673 | + | dependencies = [ |
|
| 1674 | + | "siphasher", |
|
| 1675 | + | ] |
|
| 1676 | + | ||
| 1677 | + | [[package]] |
|
| 1516 | 1678 | name = "pin-project-lite" |
|
| 1517 | 1679 | version = "0.2.17" |
|
| 1518 | 1680 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 67 unchanged lines |
||
| 1586 | 1748 | ] |
|
| 1587 | 1749 | ||
| 1588 | 1750 | [[package]] |
|
| 1751 | + | name = "precomputed-hash" |
|
| 1752 | + | version = "0.1.1" |
|
| 1753 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1754 | + | checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" |
|
| 1755 | + | ||
| 1756 | + | [[package]] |
|
| 1589 | 1757 | name = "proc-macro2" |
|
| 1590 | 1758 | version = "1.0.107" |
|
| 1591 | 1759 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 22 unchanged lines |
||
| 1614 | 1782 | ] |
|
| 1615 | 1783 | ||
| 1616 | 1784 | [[package]] |
|
| 1785 | + | name = "pulldown-cmark" |
|
| 1786 | + | version = "0.13.6" |
|
| 1787 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1788 | + | checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b" |
|
| 1789 | + | dependencies = [ |
|
| 1790 | + | "bitflags", |
|
| 1791 | + | "memchr", |
|
| 1792 | + | "pulldown-cmark-escape", |
|
| 1793 | + | "unicase", |
|
| 1794 | + | ] |
|
| 1795 | + | ||
| 1796 | + | [[package]] |
|
| 1797 | + | name = "pulldown-cmark-escape" |
|
| 1798 | + | version = "0.11.0" |
|
| 1799 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1800 | + | checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" |
|
| 1801 | + | ||
| 1802 | + | [[package]] |
|
| 1617 | 1803 | name = "quick-error" |
|
| 1618 | 1804 | version = "1.2.3" |
|
| 1619 | 1805 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 24 unchanged lines |
||
| 1644 | 1830 | name = "radar" |
|
| 1645 | 1831 | version = "0.1.0" |
|
| 1646 | 1832 | dependencies = [ |
|
| 1833 | + | "ammonia", |
|
| 1834 | + | "anyhow", |
|
| 1835 | + | "askama", |
|
| 1836 | + | "axum", |
|
| 1837 | + | "futures", |
|
| 1838 | + | "hex", |
|
| 1839 | + | "oxy-core", |
|
| 1840 | + | "pulldown-cmark", |
|
| 1841 | + | "rand 0.8.8", |
|
| 1842 | + | "serde", |
|
| 1843 | + | "serde_json", |
|
| 1647 | 1844 | "spectra-core", |
|
| 1845 | + | "spectra-mcp", |
|
| 1846 | + | "sqlx", |
|
| 1847 | + | "tokio", |
|
| 1848 | + | "tokio-stream", |
|
| 1849 | + | "tracing", |
|
| 1648 | 1850 | ] |
|
| 1649 | 1851 | ||
| 1650 | 1852 | [[package]] |
|
| … | 160 unchanged lines |
||
| 1811 | 2013 | ] |
|
| 1812 | 2014 | ||
| 1813 | 2015 | [[package]] |
|
| 2016 | + | name = "rustc-hash" |
|
| 2017 | + | version = "2.1.3" |
|
| 2018 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2019 | + | checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" |
|
| 2020 | + | ||
| 2021 | + | [[package]] |
|
| 1814 | 2022 | name = "rustc_version" |
|
| 1815 | 2023 | version = "0.4.1" |
|
| 1816 | 2024 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 281 unchanged lines |
||
| 2098 | 2306 | checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" |
|
| 2099 | 2307 | ||
| 2100 | 2308 | [[package]] |
|
| 2309 | + | name = "siphasher" |
|
| 2310 | + | version = "1.0.4" |
|
| 2311 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2312 | + | checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" |
|
| 2313 | + | ||
| 2314 | + | [[package]] |
|
| 2101 | 2315 | name = "slab" |
|
| 2102 | 2316 | version = "0.4.12" |
|
| 2103 | 2317 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 319 unchanged lines |
||
| 2423 | 2637 | version = "1.2.1" |
|
| 2424 | 2638 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2425 | 2639 | checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" |
|
| 2640 | + | ||
| 2641 | + | [[package]] |
|
| 2642 | + | name = "string_cache" |
|
| 2643 | + | version = "0.11.0" |
|
| 2644 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2645 | + | checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6" |
|
| 2646 | + | dependencies = [ |
|
| 2647 | + | "new_debug_unreachable", |
|
| 2648 | + | "parking_lot", |
|
| 2649 | + | "phf_shared", |
|
| 2650 | + | "precomputed-hash", |
|
| 2651 | + | ] |
|
| 2652 | + | ||
| 2653 | + | [[package]] |
|
| 2654 | + | name = "string_cache_codegen" |
|
| 2655 | + | version = "0.11.2" |
|
| 2656 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2657 | + | checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc" |
|
| 2658 | + | dependencies = [ |
|
| 2659 | + | "phf_generator", |
|
| 2660 | + | "phf_shared", |
|
| 2661 | + | "proc-macro2", |
|
| 2662 | + | "quote", |
|
| 2663 | + | ] |
|
| 2426 | 2664 | ||
| 2427 | 2665 | [[package]] |
|
| 2428 | 2666 | name = "stringprep" |
|
| … | 71 unchanged lines |
||
| 2500 | 2738 | ] |
|
| 2501 | 2739 | ||
| 2502 | 2740 | [[package]] |
|
| 2741 | + | name = "tendril" |
|
| 2742 | + | version = "0.5.1" |
|
| 2743 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2744 | + | checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" |
|
| 2745 | + | dependencies = [ |
|
| 2746 | + | "new_debug_unreachable", |
|
| 2747 | + | ] |
|
| 2748 | + | ||
| 2749 | + | [[package]] |
|
| 2503 | 2750 | name = "thiserror" |
|
| 2504 | 2751 | version = "1.0.69" |
|
| 2505 | 2752 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 260 unchanged lines |
||
| 2766 | 3013 | checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" |
|
| 2767 | 3014 | ||
| 2768 | 3015 | [[package]] |
|
| 3016 | + | name = "unicase" |
|
| 3017 | + | version = "2.10.0" |
|
| 3018 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3019 | + | checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" |
|
| 3020 | + | ||
| 3021 | + | [[package]] |
|
| 2769 | 3022 | name = "unicode-bidi" |
|
| 2770 | 3023 | version = "0.3.18" |
|
| 2771 | 3024 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 192 unchanged lines |
||
| 2964 | 3217 | dependencies = [ |
|
| 2965 | 3218 | "js-sys", |
|
| 2966 | 3219 | "wasm-bindgen", |
|
| 3220 | + | ] |
|
| 3221 | + | ||
| 3222 | + | [[package]] |
|
| 3223 | + | name = "web_atoms" |
|
| 3224 | + | version = "0.3.0" |
|
| 3225 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3226 | + | checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada" |
|
| 3227 | + | dependencies = [ |
|
| 3228 | + | "phf", |
|
| 3229 | + | "phf_codegen", |
|
| 3230 | + | "string_cache", |
|
| 3231 | + | "string_cache_codegen", |
|
| 2967 | 3232 | ] |
|
| 2968 | 3233 | ||
| 2969 | 3234 | [[package]] |
|
| … | 362 unchanged lines |
modified crates/radar/Cargo.toml +16 −0
| … | 6 unchanged lines |
||
| 7 | 7 | ||
| 8 | 8 | [dependencies] |
|
| 9 | 9 | spectra-core.workspace = true |
|
| 10 | + | spectra-mcp.workspace = true |
|
| 11 | + | oxy-core.workspace = true |
|
| 12 | + | anyhow.workspace = true |
|
| 13 | + | axum.workspace = true |
|
| 14 | + | askama.workspace = true |
|
| 15 | + | serde.workspace = true |
|
| 16 | + | serde_json.workspace = true |
|
| 17 | + | tokio.workspace = true |
|
| 18 | + | futures.workspace = true |
|
| 19 | + | tokio-stream.workspace = true |
|
| 20 | + | sqlx.workspace = true |
|
| 21 | + | rand.workspace = true |
|
| 22 | + | hex.workspace = true |
|
| 23 | + | tracing.workspace = true |
|
| 24 | + | pulldown-cmark.workspace = true |
|
| 25 | + | ammonia.workspace = true |
modified crates/radar/src/lib.rs +637 −3
| 1 | - | //! Radar (spec §17): the human's pages, rendered with askama, live over the |
|
| 2 | - | //! event stream, working without JavaScript. Linked by `spectra` and, later, |
|
| 3 | - | //! mounted by SpectraHub. |
|
| 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 | + | } |
modified crates/spectra-core/src/conversation.rs +22 −0
| … | 583 unchanged lines |
||
| 584 | 584 | Ok((first.as_deref() == Some(n.as_str())).then(|| "human".into())) |
|
| 585 | 585 | } |
|
| 586 | 586 | ||
| 587 | + | /// How a principal is shown to people: `claude-oxygen · Andy McCall's agent`, |
|
| 588 | + | /// the owner's name, or `Spectra`. |
|
| 589 | + | pub async fn display_name(ledger: &Ledger, actor: &str) -> String { |
|
| 590 | + | if actor == "human" { |
|
| 591 | + | let name: Option<String> = sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1") |
|
| 592 | + | .fetch_optional(ledger.pool()) |
|
| 593 | + | .await |
|
| 594 | + | .ok() |
|
| 595 | + | .flatten(); |
|
| 596 | + | return name.unwrap_or_else(|| "the owner".into()); |
|
| 597 | + | } |
|
| 598 | + | if let Some(a) = actor.strip_prefix("agent:") { |
|
| 599 | + | let owner: Option<String> = |
|
| 600 | + | sqlx::query_scalar("SELECT owner_name FROM agents WHERE name = ?").bind(a).fetch_optional(ledger.pool()).await.ok().flatten(); |
|
| 601 | + | return match owner { |
|
| 602 | + | Some(o) => format!("{a} · {o}'s agent"), |
|
| 603 | + | None => a.to_string(), |
|
| 604 | + | }; |
|
| 605 | + | } |
|
| 606 | + | "Spectra".into() |
|
| 607 | + | } |
|
| 608 | + | ||
| 587 | 609 | /// `task:ID`, `decision:ID`, `oxy:change:HASH` and URLs in a body. |
|
| 588 | 610 | pub fn parse_refs(body: &str) -> Vec<String> { |
|
| 589 | 611 | body.split(|c: char| c.is_whitespace() || "()[]<>,;\"'".contains(c)) |
|
| … | 158 unchanged lines |
modified crates/spectra-core/src/event.rs +5 −0
| … | 30 unchanged lines |
||
| 31 | 31 | Signature = 2, |
|
| 32 | 32 | /// A passkey assertion with user presence. |
|
| 33 | 33 | Passkey = 3, |
|
| 34 | + | /// A Radar session opened by a link the owner signed with their CLI key: |
|
| 35 | + | /// the proof is that signed request (spec §8.3). |
|
| 36 | + | Session = 4, |
|
| 34 | 37 | } |
|
| 35 | 38 | ||
| 36 | 39 | impl Method { |
|
| … | 3 unchanged lines |
||
| 40 | 43 | 1 => Method::Token, |
|
| 41 | 44 | 2 => Method::Signature, |
|
| 42 | 45 | 3 => Method::Passkey, |
|
| 46 | + | 4 => Method::Session, |
|
| 43 | 47 | _ => return None, |
|
| 44 | 48 | }) |
|
| 45 | 49 | } |
|
| … | 3 unchanged lines |
||
| 49 | 53 | Method::Token => "token", |
|
| 50 | 54 | Method::Signature => "signature", |
|
| 51 | 55 | Method::Passkey => "passkey", |
|
| 56 | + | Method::Session => "session", |
|
| 52 | 57 | } |
|
| 53 | 58 | } |
|
| 54 | 59 | } |
|
| … | 233 unchanged lines |
modified crates/spectra-core/src/ledger.rs +134 −91
| … | 31 unchanged lines |
||
| 32 | 32 | /// Held for as long as this ledger may append: one writer per data |
|
| 33 | 33 | /// directory, so the chain never forks (`None` when opened read-only). |
|
| 34 | 34 | _lock: Option<std::fs::File>, |
|
| 35 | + | w: Arc<Writer>, |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | /// What writing needs, shared with the task that does it. Every write runs |
|
| 39 | + | /// in a task of its own, so a caller that goes away (a client that hangs up |
|
| 40 | + | /// mid-request) can never leave a transaction half done: a cancelled |
|
| 41 | + | /// transaction on a pooled connection would swallow every later commit |
|
| 42 | + | /// made on that connection. |
|
| 43 | + | struct Writer { |
|
| 44 | + | pool: SqlitePool, |
|
| 35 | 45 | head: Mutex<Head>, |
|
| 36 | 46 | clock: Clock, |
|
| 37 | 47 | bus: broadcast::Sender<Arc<Event>>, |
|
| … | 77 unchanged lines |
||
| 115 | 125 | let since = seq.saturating_sub(checkpoint_seq as u64); |
|
| 116 | 126 | let (bus, _) = broadcast::channel(4096); |
|
| 117 | 127 | Ok(Ledger { |
|
| 118 | - | pool, |
|
| 128 | + | pool: pool.clone(), |
|
| 119 | 129 | _lock: None, |
|
| 120 | - | head: Mutex::new(Head { seq, hash, since_checkpoint: since, oldest_unchecked_ms: (since > 0).then(ids::now_ms) }), |
|
| 121 | - | clock: Clock::starting_at(hlc), |
|
| 122 | - | bus, |
|
| 123 | - | instance, |
|
| 130 | + | w: Arc::new(Writer { |
|
| 131 | + | pool, |
|
| 132 | + | head: Mutex::new(Head { seq, hash, since_checkpoint: since, oldest_unchecked_ms: (since > 0).then(ids::now_ms) }), |
|
| 133 | + | clock: Clock::starting_at(hlc), |
|
| 134 | + | bus, |
|
| 135 | + | instance, |
|
| 136 | + | }), |
|
| 124 | 137 | }) |
|
| 125 | 138 | } |
|
| 126 | 139 | ||
| … | 4 unchanged lines |
||
| 131 | 144 | ||
| 132 | 145 | /// Every appended event, as it commits. |
|
| 133 | 146 | pub fn subscribe(&self) -> broadcast::Receiver<Arc<Event>> { |
|
| 134 | - | self.bus.subscribe() |
|
| 147 | + | self.w.bus.subscribe() |
|
| 135 | 148 | } |
|
| 136 | 149 | ||
| 137 | 150 | /// The instance key, when this ledger signs checkpoints. |
|
| 138 | 151 | pub fn instance(&self) -> Option<&KeyPair> { |
|
| 139 | - | self.instance.as_ref() |
|
| 152 | + | self.w.instance.as_ref() |
|
| 140 | 153 | } |
|
| 141 | 154 | ||
| 142 | 155 | /// The newest event's sequence number and hash. |
|
| 143 | 156 | pub async fn head(&self) -> (u64, [u8; 32]) { |
|
| 144 | - | let h = self.head.lock().await; |
|
| 157 | + | let h = self.w.head.lock().await; |
|
| 145 | 158 | (h.seq, h.hash) |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | /// Append `events` in one transaction, chained and projected, then |
|
| 162 | + | /// publish them; a checkpoint follows when one is due. The work runs to |
|
| 163 | + | /// completion even if the caller is dropped. |
|
| 164 | + | pub async fn append(&self, events: Vec<NewEvent>) -> Result<Vec<Arc<Event>>> { |
|
| 165 | + | let w = self.w.clone(); |
|
| 166 | + | tokio::spawn(async move { w.append(events).await }).await.context("the ledger's writer stopped")? |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | /// Append one event. |
|
| 170 | + | pub async fn append_one(&self, e: NewEvent) -> Result<Arc<Event>> { |
|
| 171 | + | Ok(self.append(vec![e]).await?.remove(0)) |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | /// Write a checkpoint now if any event is not covered by one (at |
|
| 175 | + | /// shutdown, and from the ten-minute timer). |
|
| 176 | + | pub async fn checkpoint(&self) -> Result<Option<Arc<Event>>> { |
|
| 177 | + | let w = self.w.clone(); |
|
| 178 | + | tokio::spawn(async move { w.checkpoint().await }).await.context("the ledger's writer stopped")? |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | /// Events from `from` (inclusive), oldest first, at most `limit`. |
|
| 182 | + | pub async fn events(&self, from: u64, limit: u32) -> Result<Vec<Event>> { |
|
| 183 | + | let rows = sqlx::query(&format!("{SELECT_EVENT} WHERE seq >= ? ORDER BY seq LIMIT ?")) |
|
| 184 | + | .bind(from as i64) |
|
| 185 | + | .bind(limit as i64) |
|
| 186 | + | .fetch_all(&self.pool) |
|
| 187 | + | .await?; |
|
| 188 | + | rows.iter().map(row_to_event).collect() |
|
| 189 | + | } |
|
| 190 | + | ||
| 191 | + | /// Events of the given kinds from `from` (inclusive), oldest first. |
|
| 192 | + | pub async fn events_of_kinds(&self, from: u64, kinds: &[&str], limit: u32) -> Result<Vec<Event>> { |
|
| 193 | + | let marks = vec!["?"; kinds.len()].join(","); |
|
| 194 | + | let sql = format!("{SELECT_EVENT} WHERE seq >= ? AND kind IN ({marks}) ORDER BY seq LIMIT ?"); |
|
| 195 | + | let mut q = sqlx::query(&sql).bind(from as i64); |
|
| 196 | + | for k in kinds { |
|
| 197 | + | q = q.bind(*k); |
|
| 198 | + | } |
|
| 199 | + | let rows = q.bind(limit as i64).fetch_all(&self.pool).await?; |
|
| 200 | + | rows.iter().map(row_to_event).collect() |
|
| 201 | + | } |
|
| 202 | + | ||
| 203 | + | /// Recompute the chain and check every checkpoint and every proof it |
|
| 204 | + | /// can, from the ledger alone (spec §9.5, `spectra admin verify`). |
|
| 205 | + | pub async fn verify(&self, keys: &mut dyn KeyResolver) -> Result<Verification> { |
|
| 206 | + | let mut v = Verification::default(); |
|
| 207 | + | let mut prev = [0u8; 32]; |
|
| 208 | + | let mut expect = 1u64; |
|
| 209 | + | let mut instance: Option<Identity> = None; |
|
| 210 | + | let mut from = 1; |
|
| 211 | + | loop { |
|
| 212 | + | let batch = self.events(from, 2_000).await?; |
|
| 213 | + | if batch.is_empty() { |
|
| 214 | + | break; |
|
| 215 | + | } |
|
| 216 | + | for e in &batch { |
|
| 217 | + | v.events += 1; |
|
| 218 | + | if e.seq != expect { |
|
| 219 | + | v.finding(e.seq, Problem::Gap, format!("expected event {expect}")); |
|
| 220 | + | expect = e.seq; |
|
| 221 | + | } |
|
| 222 | + | if e.prev != prev { |
|
| 223 | + | v.finding(e.seq, Problem::ChainBreak, "prev is not the hash of the event before".into()); |
|
| 224 | + | } |
|
| 225 | + | if e.payload.is_some() && e.computed_hash() != e.hash { |
|
| 226 | + | v.finding(e.seq, Problem::HashMismatch, "the event's fields do not give its hash: it was edited".into()); |
|
| 227 | + | } |
|
| 228 | + | if e.kind == "identity.instance" { |
|
| 229 | + | instance = e |
|
| 230 | + | .json() |
|
| 231 | + | .get("identity") |
|
| 232 | + | .and_then(|h| h.as_str()) |
|
| 233 | + | .and_then(|h| hex::decode(h).ok()) |
|
| 234 | + | .and_then(|b| Identity::decode(&b).ok()); |
|
| 235 | + | } |
|
| 236 | + | if e.kind == "ledger.checkpoint" { |
|
| 237 | + | v.checkpoints += 1; |
|
| 238 | + | match check_checkpoint(e, prev, instance.as_ref()) { |
|
| 239 | + | Ok(()) => v.last_checkpoint = Some(e.seq), |
|
| 240 | + | Err(why) => v.finding(e.seq, Problem::BadCheckpoint, why), |
|
| 241 | + | } |
|
| 242 | + | } else if let Err(why) = keys.check(e) { |
|
| 243 | + | v.finding(e.seq, Problem::BadProof, why); |
|
| 244 | + | } |
|
| 245 | + | prev = e.hash; |
|
| 246 | + | expect += 1; |
|
| 247 | + | } |
|
| 248 | + | from = batch.last().map(|e| e.seq + 1).unwrap_or(from); |
|
| 249 | + | } |
|
| 250 | + | v.head = prev; |
|
| 251 | + | v.uncovered = v.events.saturating_sub(v.last_checkpoint.unwrap_or(0)); |
|
| 252 | + | Ok(v) |
|
| 253 | + | } |
|
| 254 | + | ||
| 255 | + | /// Clear every projection and replay the ledger into them. |
|
| 256 | + | pub async fn rebuild(&self) -> Result<()> { |
|
| 257 | + | let w = self.w.clone(); |
|
| 258 | + | tokio::spawn(async move { w.rebuild().await }).await.context("the ledger's writer stopped")? |
|
| 259 | + | } |
|
| 260 | + | ||
| 261 | + | /// Whether the projections equal a rebuild from the ledger: rebuild in |
|
| 262 | + | /// a transaction, compare, and roll back. Returns the tables that differ. |
|
| 263 | + | pub async fn check(&self) -> Result<Vec<String>> { |
|
| 264 | + | let w = self.w.clone(); |
|
| 265 | + | tokio::spawn(async move { w.check().await }).await.context("the ledger's writer stopped")? |
|
| 146 | 266 | } |
|
| 267 | + | } |
|
| 147 | 268 | ||
| 269 | + | impl Writer { |
|
| 148 | 270 | /// Append `events` in one transaction, chained and projected, then |
|
| 149 | 271 | /// publish them; a checkpoint follows when one is due. |
|
| 150 | - | pub async fn append(&self, events: Vec<NewEvent>) -> Result<Vec<Arc<Event>>> { |
|
| 272 | + | async fn append(&self, events: Vec<NewEvent>) -> Result<Vec<Arc<Event>>> { |
|
| 151 | 273 | let mut head = self.head.lock().await; |
|
| 152 | 274 | let mut tx = self.pool.begin().await?; |
|
| 153 | 275 | let mut out = Vec::with_capacity(events.len() + 1); |
|
| … | 30 unchanged lines |
||
| 184 | 306 | Ok(out) |
|
| 185 | 307 | } |
|
| 186 | 308 | ||
| 187 | - | /// Append one event. |
|
| 188 | - | pub async fn append_one(&self, e: NewEvent) -> Result<Arc<Event>> { |
|
| 189 | - | Ok(self.append(vec![e]).await?.remove(0)) |
|
| 190 | - | } |
|
| 191 | - | ||
| 192 | 309 | /// Write a checkpoint now if any event is not covered by one (at |
|
| 193 | 310 | /// shutdown, and from the ten-minute timer). |
|
| 194 | - | pub async fn checkpoint(&self) -> Result<Option<Arc<Event>>> { |
|
| 311 | + | async fn checkpoint(&self) -> Result<Option<Arc<Event>>> { |
|
| 195 | 312 | let Some(_) = &self.instance else { return Ok(None) }; |
|
| 196 | 313 | let mut head = self.head.lock().await; |
|
| 197 | 314 | if head.since_checkpoint == 0 { |
|
| … | 55 unchanged lines |
||
| 253 | 370 | h.since_checkpoint = 0; |
|
| 254 | 371 | h.oldest_unchecked_ms = None; |
|
| 255 | 372 | e |
|
| 256 | - | } |
|
| 257 | - | ||
| 258 | - | /// Events from `from` (inclusive), oldest first, at most `limit`. |
|
| 259 | - | pub async fn events(&self, from: u64, limit: u32) -> Result<Vec<Event>> { |
|
| 260 | - | let rows = sqlx::query(&format!("{SELECT_EVENT} WHERE seq >= ? ORDER BY seq LIMIT ?")) |
|
| 261 | - | .bind(from as i64) |
|
| 262 | - | .bind(limit as i64) |
|
| 263 | - | .fetch_all(&self.pool) |
|
| 264 | - | .await?; |
|
| 265 | - | rows.iter().map(row_to_event).collect() |
|
| 266 | - | } |
|
| 267 | - | ||
| 268 | - | /// Events of the given kinds from `from` (inclusive), oldest first. |
|
| 269 | - | pub async fn events_of_kinds(&self, from: u64, kinds: &[&str], limit: u32) -> Result<Vec<Event>> { |
|
| 270 | - | let marks = vec!["?"; kinds.len()].join(","); |
|
| 271 | - | let sql = format!("{SELECT_EVENT} WHERE seq >= ? AND kind IN ({marks}) ORDER BY seq LIMIT ?"); |
|
| 272 | - | let mut q = sqlx::query(&sql).bind(from as i64); |
|
| 273 | - | for k in kinds { |
|
| 274 | - | q = q.bind(*k); |
|
| 275 | - | } |
|
| 276 | - | let rows = q.bind(limit as i64).fetch_all(&self.pool).await?; |
|
| 277 | - | rows.iter().map(row_to_event).collect() |
|
| 278 | - | } |
|
| 279 | - | ||
| 280 | - | /// Recompute the chain and check every checkpoint and every proof it |
|
| 281 | - | /// can, from the ledger alone (spec §9.5, `spectra admin verify`). |
|
| 282 | - | pub async fn verify(&self, keys: &mut dyn KeyResolver) -> Result<Verification> { |
|
| 283 | - | let mut v = Verification::default(); |
|
| 284 | - | let mut prev = [0u8; 32]; |
|
| 285 | - | let mut expect = 1u64; |
|
| 286 | - | let mut instance: Option<Identity> = None; |
|
| 287 | - | let mut from = 1; |
|
| 288 | - | loop { |
|
| 289 | - | let batch = self.events(from, 2_000).await?; |
|
| 290 | - | if batch.is_empty() { |
|
| 291 | - | break; |
|
| 292 | - | } |
|
| 293 | - | for e in &batch { |
|
| 294 | - | v.events += 1; |
|
| 295 | - | if e.seq != expect { |
|
| 296 | - | v.finding(e.seq, Problem::Gap, format!("expected event {expect}")); |
|
| 297 | - | expect = e.seq; |
|
| 298 | - | } |
|
| 299 | - | if e.prev != prev { |
|
| 300 | - | v.finding(e.seq, Problem::ChainBreak, "prev is not the hash of the event before".into()); |
|
| 301 | - | } |
|
| 302 | - | if e.payload.is_some() && e.computed_hash() != e.hash { |
|
| 303 | - | v.finding(e.seq, Problem::HashMismatch, "the event's fields do not give its hash: it was edited".into()); |
|
| 304 | - | } |
|
| 305 | - | if e.kind == "identity.instance" { |
|
| 306 | - | instance = e |
|
| 307 | - | .json() |
|
| 308 | - | .get("identity") |
|
| 309 | - | .and_then(|h| h.as_str()) |
|
| 310 | - | .and_then(|h| hex::decode(h).ok()) |
|
| 311 | - | .and_then(|b| Identity::decode(&b).ok()); |
|
| 312 | - | } |
|
| 313 | - | if e.kind == "ledger.checkpoint" { |
|
| 314 | - | v.checkpoints += 1; |
|
| 315 | - | match check_checkpoint(e, prev, instance.as_ref()) { |
|
| 316 | - | Ok(()) => v.last_checkpoint = Some(e.seq), |
|
| 317 | - | Err(why) => v.finding(e.seq, Problem::BadCheckpoint, why), |
|
| 318 | - | } |
|
| 319 | - | } else if let Err(why) = keys.check(e) { |
|
| 320 | - | v.finding(e.seq, Problem::BadProof, why); |
|
| 321 | - | } |
|
| 322 | - | prev = e.hash; |
|
| 323 | - | expect += 1; |
|
| 324 | - | } |
|
| 325 | - | from = batch.last().map(|e| e.seq + 1).unwrap_or(from); |
|
| 326 | - | } |
|
| 327 | - | v.head = prev; |
|
| 328 | - | v.uncovered = v.events.saturating_sub(v.last_checkpoint.unwrap_or(0)); |
|
| 329 | - | Ok(v) |
|
| 330 | 373 | } |
|
| 331 | 374 | ||
| 332 | 375 | /// Clear every projection and replay the ledger into them. |
|
| 333 | - | pub async fn rebuild(&self) -> Result<()> { |
|
| 376 | + | async fn rebuild(&self) -> Result<()> { |
|
| 334 | 377 | let _head = self.head.lock().await; |
|
| 335 | 378 | let mut tx = self.pool.begin().await?; |
|
| 336 | 379 | replay(&mut tx).await?; |
|
| … | 3 unchanged lines |
||
| 340 | 383 | ||
| 341 | 384 | /// Whether the projections equal a rebuild from the ledger: rebuild in |
|
| 342 | 385 | /// a transaction, compare, and roll back. Returns the tables that differ. |
|
| 343 | - | pub async fn check(&self) -> Result<Vec<String>> { |
|
| 386 | + | async fn check(&self) -> Result<Vec<String>> { |
|
| 344 | 387 | let _head = self.head.lock().await; |
|
| 345 | 388 | let mut tx = self.pool.begin().await?; |
|
| 346 | 389 | let mut before = Vec::new(); |
|
| … | 205 unchanged lines |
modified crates/spectra-core/src/principals.rs +17 −0
| … | 244 unchanged lines |
||
| 245 | 245 | Ok(owner) |
|
| 246 | 246 | } |
|
| 247 | 247 | ||
| 248 | + | /// An event from a Radar session: its proof is the owner's signed |
|
| 249 | + | /// request for the sign-in link, so it must verify against the owner, |
|
| 250 | + | /// and only the owner acts this way. |
|
| 251 | + | pub fn check_session(&self, e: &Event) -> Result<(), String> { |
|
| 252 | + | let key = oxy_core::hash::ObjectRef(e.auth.key.ok_or("a session event names no key")?); |
|
| 253 | + | if !matches!(self.principal(&key, e.hlc.wall_ms)?, Principal::Owner { .. }) || e.actor != "human" { |
|
| 254 | + | return Err("only the owner acts through a Radar session".into()); |
|
| 255 | + | } |
|
| 256 | + | let identity = self.identity(&key).ok_or("an unknown key")?; |
|
| 257 | + | let proof = Proof::decode(e.auth.proof.as_deref().ok_or("no proof")?)?; |
|
| 258 | + | if proof.key() != key.0 || proof.method != "POST /radar/link" { |
|
| 259 | + | return Err("the session's proof is not the owner's sign-in request".into()); |
|
| 260 | + | } |
|
| 261 | + | proof.verify(identity, None) |
|
| 262 | + | } |
|
| 263 | + | ||
| 248 | 264 | /// Check a signed event's proof against the principal its key was at |
|
| 249 | 265 | /// the event's time. |
|
| 250 | 266 | pub fn check_signed(&self, e: &Event) -> Result<(), String> { |
|
| … | 20 unchanged lines |
||
| 271 | 287 | } |
|
| 272 | 288 | "agent.uid_bound" => self.observe(e), |
|
| 273 | 289 | _ if matches!(e.auth.method, Method::Signature | Method::Passkey) => self.check_signed(e), |
|
| 290 | + | _ if e.auth.method == Method::Session => self.check_session(e), |
|
| 274 | 291 | _ => Ok(()), |
|
| 275 | 292 | } |
|
| 276 | 293 | } |
|
| … | 149 unchanged lines |
modified crates/spectra-core/tests/ledger.rs +28 −0
| … | 206 unchanged lines |
||
| 207 | 207 | // The target is 10 ms with fsync (spec §24); debug builds get headroom. |
|
| 208 | 208 | assert!(each_us < 50_000, "{each_us} µs"); |
|
| 209 | 209 | } |
|
| 210 | + | ||
| 211 | + | #[tokio::test(flavor = "multi_thread", worker_threads = 4)] |
|
| 212 | + | async fn an_append_abandoned_mid_flight_still_commits_and_never_blocks_later_ones() { |
|
| 213 | + | // A client that hangs up mid-request drops the future doing its append. |
|
| 214 | + | // Before the writer ran in a task of its own, that could leave a pooled |
|
| 215 | + | // connection inside an open transaction, and every later "commit" on it |
|
| 216 | + | // went nowhere (found by hand in the Radar demo, 2026-11-02). |
|
| 217 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 218 | + | let ledger = open(dir.path()).await; |
|
| 219 | + | for i in 0..300 { |
|
| 220 | + | let f = ledger.append_one(msg(i)); |
|
| 221 | + | tokio::select! { |
|
| 222 | + | biased; |
|
| 223 | + | _ = f => {} |
|
| 224 | + | _ = tokio::task::yield_now() => {} |
|
| 225 | + | } |
|
| 226 | + | } |
|
| 227 | + | tokio::time::sleep(std::time::Duration::from_millis(300)).await; |
|
| 228 | + | let last = ledger.append_one(msg(10_000)).await.unwrap(); |
|
| 229 | + | let (head, _) = ledger.head().await; |
|
| 230 | + | assert_eq!(last.seq, head); |
|
| 231 | + | let other = sqlx::SqlitePool::connect(&format!("sqlite://{}", dir.path().join("spectra.db").display())).await.unwrap(); |
|
| 232 | + | let seen: i64 = sqlx::query_scalar("SELECT MAX(seq) FROM events").fetch_one(&other).await.unwrap(); |
|
| 233 | + | assert_eq!(seen as u64, head, "another connection sees every committed event"); |
|
| 234 | + | let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE kind = 'test.message'").fetch_one(&other).await.unwrap(); |
|
| 235 | + | assert_eq!(n, 301, "every abandoned append still committed"); |
|
| 236 | + | assert!(ledger.verify(&mut NoKeys).await.unwrap().clean()); |
|
| 237 | + | } |
modified crates/spectra-mcp/src/client.rs +5 −6
| … | 49 unchanged lines |
||
| 50 | 50 | tokio::spawn(async move { |
|
| 51 | 51 | let _ = conn.await; |
|
| 52 | 52 | }); |
|
| 53 | - | let mut req = hyper::Request::builder() |
|
| 54 | - | .method(method) |
|
| 55 | - | .uri(path) |
|
| 56 | - | .header("host", "spectra") |
|
| 57 | - | .header("content-type", "application/json") |
|
| 58 | - | .header("connection", "close"); |
|
| 59 | 53 | // One request a connection: the server closes it after answering, so |
|
| 60 | 54 | // nothing lingers to hold up its shutdown. |
|
| 55 | + | let mut req = hyper::Request::builder().method(method).uri(path).header("host", "spectra").header("connection", "close"); |
|
| 56 | + | // JSON unless the caller says otherwise (a form, say). |
|
| 57 | + | if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type")) { |
|
| 58 | + | req = req.header("content-type", "application/json"); |
|
| 59 | + | } |
|
| 61 | 60 | for (k, v) in headers { |
|
| 62 | 61 | req = req.header(*k, v); |
|
| 63 | 62 | } |
|
| … | 55 unchanged lines |
modified docs/plan.md +2 −2
| … | 5 unchanged lines |
||
| 6 | 6 | ||
| 7 | 7 | - Never run `git init` in this directory or push this project anywhere but its `oxygenhub` remote. Commit with `OXY_HOME=~/.oxy-agents/claude oxy commit -m "..." PATHS..`, never with Andy's own key. |
|
| 8 | 8 | - Tests are hermetic: a temporary data directory, a temporary socket, port 0; never `~/.local/share/spectra`, `~/.config` or the network. |
|
| 9 | - | - Gate every commit on `make check` (changelog, fmt, clippy with warnings as errors, tests). |
|
| 9 | + | - Gate every commit on `make check` (changelog, fmt, clippy with warnings as errors, tests), chained so a failing gate stops the commit: `make check && oxy commit …`. |
|
| 10 | 10 | - Every code change adds a CHANGELOG.md line under `## Unreleased`, or sets `NO_CHANGELOG=1`. |
|
| 11 | 11 | - Never weaken a security goal of spec §5 to make a test pass. |
|
| 12 | 12 | - Never commit or push anything under `docs/prompts/` (Andy's prompts; ignored in `.oxyignore`). Name the paths in every commit. |
|
| … | 10 unchanged lines |
||
| 23 | 23 | - [x] #7 Tuner: MCP over Streamable HTTP — done 2026-11-02: spectra serve, socket + loopback, signed requests, both revisions, refusals, owner events through the daemon; end-to-end tests drive the real binary. |
|
| 24 | 24 | - [x] #8 The 0.1 tools — done 2026-11-02: eleven tools through one invoke wrapper (record, rules line); wait with the idle guard; end-to-end tests with two agents. Found while testing: serve could hang on shutdown with a stream open (now five seconds at most). |
|
| 25 | 25 | - [x] #9 The stdio shim — done 2026-11-02: spectra mcp --agent NAME, driven over pipes in tests/cli_mcp.rs. Real Claude Code, Antigravity and Kimi connections are checked in #13 with Andy, since they need his MCP configuration. |
|
| 26 | - | - [ ] #10 Radar |
|
| 26 | + | - [x] #10 Radar — done 2026-11-02: Live, Channels, channel/thread with compose, Log with Verify now; spectra open; checked by hand in Chrome on a demo Spectra. That demo found the ledger cancellation bug (fixed, regression test fails on the old code) and a referrer policy that blanked the Origin of form posts. |
|
| 27 | 27 | - [ ] #11 JSON API and spectra-api types |
|
| 28 | 28 | - [ ] #12 Human CLI |
|
| 29 | 29 | - [ ] #13 Acceptance (with Andy) |
|
| … | 7 unchanged lines |
modified docs/spec.md +3 −2
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.3b | |
|
| 5 | + | | **Status** | Draft 0.3c | |
|
| 6 | 6 | | **Date** | 2026-11-02 | |
|
| 7 | 7 | | **Scope** | One Rust binary, `spectra`, that AI coding agents on one machine connect to over the Model Context Protocol (MCP) to find each other, talk, hand work over, keep to the house rules, remember what was said and decided, and let the human see all of it live. The hosted, multi-person, multi-machine layer is SpectraHub (`../spectrahub/docs/spec.md`). | |
|
| 8 | 8 | | **Companion documents** | `../spectrahub/docs/spec.md`, `../spectra-vscode/docs/spec.md`, `../spectrahub-vscode/docs/spec.md` | |
|
| … | 311 unchanged lines |
||
| 320 | 320 | ||
| 321 | 321 | **Signed requests.** A shim signs every request it forwards with the agent's key; the CLI, with the owner's. The headers are `Spectra-Key` (the KeyId), `Spectra-Time` (milliseconds), `Spectra-Nonce` (16 random bytes, hex) and `Spectra-Signature` (an Oxygen composite signature, oxygen spec §14.3, over `"spectra1 request " || method || 0x00 || BLAKE3(body) || time || nonce`, made through `oxy-core`'s challenge signing so the signed target is `BLAKE3("oxy1 challenge " || message)` and can never be taken for a change, an attestation or a policy). The daemon refuses a time more than five minutes from its clock, a nonce it has seen in that window, a key it does not know, and a key whose owner's current identity does not delegate to it now (§8.1). The signature is kept as the resulting events' proof (§9.1), so an event can be checked later against the identity in the ledger without trusting the daemon that wrote it. |
|
| 322 | 322 | ||
| 323 | - | **Passkey assertions** are the proof of the human's Radar actions (§8.3). **Tokens**, when allowed, are proof of nothing beyond possession and are recorded as such. |
|
| 323 | + | **Passkey assertions** are the proof of the human's Radar actions (§8.3). Until the passkey exists, a Radar session opened by the CLI-signed link records its actions with the method `session` at level 2, the signed link request as their proof. **Tokens**, when allowed, are proof of nothing beyond possession and are recorded as such. |
|
| 324 | 324 | ||
| 325 | 325 | **Assurance levels**, recorded on every event in `auth.assurance`: |
|
| 326 | 326 | ||
| … | 665 unchanged lines |
||
| 992 | 992 | ||
| 993 | 993 | ## 27. Changelog |
|
| 994 | 994 | ||
| 995 | + | - Draft 0.3c (2026-11-02), while building #10: a Radar session's events carry the method `session` (assurance 2) and, as proof, the owner's signed request for the sign-in link, which `verify` checks against the owner (§8.3); every ledger write runs to completion in a task of its own, so a caller that goes away cannot leave a transaction open (§9.5); Radar's templates and assets live in the `radar` crate, so SpectraHub mounts it whole (§7.3). |
|
| 995 | 996 | - Draft 0.3b (2026-11-02), while building #7: a refused request names its reason (§15.1); requests to the daemon are signed with the owner identity the ledger already records, and a rotation travels in the same batch as the event that needs it (§8.5). |
|
| 996 | 997 | - Draft 0.3a (2026-11-02), while building #3 and #4: request and checkpoint signatures go through `oxy-core`'s challenge signing with Spectra's prefix inside the message (§8.5, §9.5); CLI-written events are signed over their payload, and a signed event's actor must be its signer (§9.5); one writer per data directory under a lock (§8.5); `identity refresh` proves possession at every successor and refuses a fresh key pair in 0.1 (§8.1); `agent add` waits for the delegation and resumes (§8.1). |
|
| 997 | 998 | - Draft 0.3 (2026-11-02), security: agents are their owner's Oxygen delegates with ownership verified and shown (§8.1, G7); no owner token: the owner signs from the CLI and uses a passkey in Radar (§8.3); a Unix socket with peer credentials, signed requests, assurance levels and the policy that keeps the human's authority out of agents' reach (§8.5, G8); the ledger: hash chain, proofs on every event, checkpoints signed by an instance key and witnessed by OxygenHub's transparency log, `spectra admin verify`, pruning to stubs (§9.5, G6); full tool-call payloads content addressed (§9.2); committed rules with their `oxy` change (§12.4); claimed changes verified (§23.5); one uid per agent as the recommended deployment and the limit stated (§5.3, §21.6). |
|
| … | 2 unchanged lines |
modified src/cli/mod.rs +15 −2
| … | 8 unchanged lines |
||
| 9 | 9 | pub mod json; |
|
| 10 | 10 | pub mod marks; |
|
| 11 | 11 | pub mod mcp; |
|
| 12 | + | pub mod open; |
|
| 12 | 13 | pub mod project; |
|
| 13 | 14 | pub mod serve; |
|
| 14 | 15 | pub mod ui; |
|
| … | 46 unchanged lines |
||
| 61 | 62 | /// Allow a non-loopback address in plain HTTP |
|
| 62 | 63 | #[arg(long)] |
|
| 63 | 64 | insecure: bool, |
|
| 65 | + | /// Open Radar in the browser once serving (signs a link as the owner) |
|
| 66 | + | #[arg(long)] |
|
| 67 | + | open: bool, |
|
| 68 | + | }, |
|
| 69 | + | /// Sign in to Radar: a one-time link signed with the owner's key |
|
| 70 | + | Open { |
|
| 71 | + | #[arg(long)] |
|
| 72 | + | owner_home: Option<PathBuf>, |
|
| 73 | + | /// Print the link without opening a browser |
|
| 74 | + | #[arg(long)] |
|
| 75 | + | no_browser: bool, |
|
| 64 | 76 | }, |
|
| 65 | 77 | /// The stdio MCP server for one agent: signs with its key and forwards to the daemon |
|
| 66 | 78 | Mcp { |
|
| … | 227 unchanged lines |
||
| 294 | 306 | IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await, |
|
| 295 | 307 | } |
|
| 296 | 308 | }), |
|
| 297 | - | Command::Serve { bind, insecure } => { |
|
| 309 | + | Command::Serve { bind, insecure, open } => { |
|
| 298 | 310 | init_logging(); |
|
| 299 | - | runtime()?.block_on(serve::serve(&ctx, &bind, insecure)) |
|
| 311 | + | runtime()?.block_on(serve::serve(&ctx, &bind, insecure, open)) |
|
| 300 | 312 | } |
|
| 313 | + | Command::Open { owner_home, no_browser } => runtime()?.block_on(open::open(&ctx, owner_home, !no_browser)), |
|
| 301 | 314 | Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)), |
|
| 302 | 315 | Command::Project { command } => runtime()?.block_on(async { |
|
| 303 | 316 | match command { |
|
| … | 39 unchanged lines |
modified src/cli/serve.rs +13 −2
| … | 9 unchanged lines |
||
| 10 | 10 | use std::net::SocketAddr; |
|
| 11 | 11 | use std::sync::Arc; |
|
| 12 | 12 | ||
| 13 | - | pub async fn serve(ctx: &Ctx, bind: &str, insecure: bool) -> Result<i32> { |
|
| 13 | + | pub async fn serve(ctx: &Ctx, bind: &str, insecure: bool, open: bool) -> Result<i32> { |
|
| 14 | 14 | let addr: SocketAddr = bind.parse().with_context(|| format!("--bind {bind} is not an address and port"))?; |
|
| 15 | 15 | if !addr.ip().is_loopback() && !insecure { |
|
| 16 | 16 | bail!("Spectra serves loopback only unless it has TLS (spec §21.1); pass --insecure to bind {addr} in plain HTTP anyway"); |
|
| … | 16 unchanged lines |
||
| 33 | 33 | let port = tcp.local_addr()?.port(); |
|
| 34 | 34 | let origins = vec![format!("http://127.0.0.1:{port}"), format!("http://localhost:{port}"), format!("http://{addr}")]; |
|
| 35 | 35 | let state = AppState { daemon: daemon.clone(), origins: Arc::new(origins) }; |
|
| 36 | - | let app = spectra_mcp::http::router(state); |
|
| 36 | + | let app = spectra_mcp::http::router(state.clone()).merge(radar::router(state)); |
|
| 37 | 37 | ||
| 38 | 38 | let (head, _) = daemon.ledger.head().await; |
|
| 39 | 39 | let agents = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM agents WHERE disabled_ms IS NULL") |
|
| … | 5 unchanged lines |
||
| 45 | 45 | println!("{}", ui::kv("http", format!("http://127.0.0.1:{port}"))); |
|
| 46 | 46 | println!("{}", ui::kv("ledger", format!("{head} events{}{agents} agent(s)", ui::sep()))); |
|
| 47 | 47 | println!(" {} {}", ui::note(), ui::dim("agents connect with: spectra mcp --agent NAME")); |
|
| 48 | + | println!(" {} {}", ui::note(), ui::dim("open Radar with: spectra open")); |
|
| 49 | + | if open { |
|
| 50 | + | let ctx2 = Ctx { data: ctx.data.clone() }; |
|
| 51 | + | tokio::spawn(async move { |
|
| 52 | + | // Once the socket answers, sign the link as the owner and open it. |
|
| 53 | + | tokio::time::sleep(std::time::Duration::from_millis(200)).await; |
|
| 54 | + | if let Err(e) = super::open::open(&ctx2, None, true).await { |
|
| 55 | + | eprintln!("{} {e:#}", ui::caution()); |
|
| 56 | + | } |
|
| 57 | + | }); |
|
| 58 | + | } |
|
| 48 | 59 | ||
| 49 | 60 | let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); |
|
| 50 | 61 | let stop = |mut rx: tokio::sync::watch::Receiver<bool>| async move { |
|
| … | 47 unchanged lines |