OXYGENHUB

The JSON API and its TypeScript types: brief, agents, projects, channels, messages, threads, inbox, search, the log and its stream (#11) e071e91a14ef

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 87673e242e46
When
· 2026-11-02 23:19 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
e071e91a14ef9104476bc11f314d5fb63c5619d018f715748694e70972b5b57b
Depends on
258bb3824813 36537112fcac 39f5c913f617 571572f5fb2c 8dc6df8c4d4e
Depended on by
1876f2ce71e4 862f0db224aa c7161ec1a607 b044d90930d9 98a0fb9f0948 01320eefd505 67bfa06a894f 77df591d5a2f b576d05cf862 da84e98f104e
In bookmarks
main
In tags
v0.1.0
Edits
34 atoms, 7 tree operations, 16 files

Browse the tree at this change

added crates/spectra-api/src/bin/typescript.rs +79 −0
1 +
//! Emit TypeScript declarations for every API type, from their JSON
2 +
//! Schemas, so spectra-vscode and spectrahub-vscode can never drift from the server:
3 +
//!
4 +
//! `cargo run -p spectra-api --bin spectra-api-typescript > ts/index.d.ts`
5 +
6 +
use serde_json::Value;
7 +
8 +
fn ts_type(schema: &Value) -> String {
9 +
    if let Some(r) = schema.get("$ref").and_then(Value::as_str) {
10 +
        return r.rsplit('/').next().unwrap_or("unknown").to_string();
11 +
    }
12 +
    if let Some(e) = schema.get("enum").and_then(Value::as_array) {
13 +
        return e.iter().map(|v| serde_json::to_string(v).unwrap_or_default()).collect::<Vec<_>>().join(" | ");
14 +
    }
15 +
    if let Some(c) = schema.get("const") {
16 +
        return serde_json::to_string(c).unwrap_or_default();
17 +
    }
18 +
    if let Some(variants) = schema.get("oneOf").or_else(|| schema.get("anyOf")).and_then(Value::as_array) {
19 +
        return variants.iter().map(ts_type).collect::<Vec<_>>().join(" | ");
20 +
    }
21 +
    let nullable = schema.get("type").and_then(Value::as_array).is_some_and(|a| a.iter().any(|t| t == "null"));
22 +
    let ty = match schema.get("type") {
23 +
        Some(Value::String(t)) => t.clone(),
24 +
        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).find(|t| *t != "null").unwrap_or("unknown").to_string(),
25 +
        _ => {
26 +
            if schema.get("properties").is_some() {
27 +
                "object".into()
28 +
            } else {
29 +
                "unknown".into()
30 +
            }
31 +
        }
32 +
    };
33 +
    let base = match ty.as_str() {
34 +
        "string" => "string".to_string(),
35 +
        "integer" | "number" => "number".to_string(),
36 +
        "boolean" => "boolean".to_string(),
37 +
        "null" => "null".to_string(),
38 +
        "array" => format!("{}[]", schema.get("items").map(ts_type).unwrap_or_else(|| "unknown".into())),
39 +
        "object" => {
40 +
            let props = schema.get("properties").and_then(Value::as_object);
41 +
            let required: Vec<&str> =
42 +
                schema.get("required").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
43 +
            match props {
44 +
                Some(p) => {
45 +
                    let fields: Vec<String> = p
46 +
                        .iter()
47 +
                        .map(|(k, v)| format!("  {k}{}: {};", if required.contains(&k.as_str()) { "" } else { "?" }, ts_type(v)))
48 +
                        .collect();
49 +
                    format!("{{\n{}\n}}", fields.join("\n"))
50 +
                }
51 +
                None => "Record<string, unknown>".into(),
52 +
            }
53 +
        }
54 +
        _ => "unknown".into(),
55 +
    };
56 +
    if nullable {
57 +
        format!("{base} | null")
58 +
    } else {
59 +
        base
60 +
    }
61 +
}
62 +
63 +
fn main() {
64 +
    println!("// Generated by `cargo run -p spectra-api --bin spectra-api-typescript`; do not edit.");
65 +
    println!("// Types of the Spectra JSON API (spec §18).\n");
66 +
    let mut printed = std::collections::BTreeSet::new();
67 +
    for (name, schema) in spectra_api::schemas() {
68 +
        let v = serde_json::to_value(&schema).unwrap();
69 +
        let defs = v.get("$defs").and_then(Value::as_object).cloned().unwrap_or_default();
70 +
        for (dname, dschema) in &defs {
71 +
            if printed.insert(dname.clone()) {
72 +
                println!("export type {dname} = {};\n", ts_type(dschema));
73 +
            }
74 +
        }
75 +
        if printed.insert(name.to_string()) {
76 +
            println!("export type {name} = {};\n", ts_type(&v));
77 +
        }
78 +
    }
79 +
}
added crates/spectra-api/ts/index.d.ts +129 −0
1 +
// Generated by `cargo run -p spectra-api --bin spectra-api-typescript`; do not edit.
2 +
// Types of the Spectra JSON API (spec §18).
3 +
4 +
export type Problem = {
5 +
  detail?: string | null;
6 +
  status: number;
7 +
  title: string;
8 +
  type: string;
9 +
};
10 +
11 +
export type Version = {
12 +
  ledger_format: number;
13 +
  mcp_protocol_versions: string[];
14 +
  version: string;
15 +
};
16 +
17 +
export type Agent = {
18 +
  disabled: boolean;
19 +
  display: string;
20 +
  key: string;
21 +
  kind: string;
22 +
  name: string;
23 +
  owner: string;
24 +
  presence: string;
25 +
  project: string;
26 +
  status?: string | null;
27 +
};
28 +
29 +
export type Project = {
30 +
  depended_on_by: string[];
31 +
  depends_on: string[];
32 +
  name: string;
33 +
  path: string;
34 +
  remote?: string | null;
35 +
};
36 +
37 +
export type Channel = {
38 +
  display: string;
39 +
  id: string;
40 +
  kind: string;
41 +
  messages: number;
42 +
  name: string;
43 +
  purpose?: string | null;
44 +
};
45 +
46 +
export type Message = {
47 +
  at_ms: number;
48 +
  author: string;
49 +
  author_display: string;
50 +
  body: string;
51 +
  channel: string;
52 +
  id: string;
53 +
  kind: string;
54 +
  mentions: string[];
55 +
  redacted: boolean;
56 +
  refs: string[];
57 +
  replies: number;
58 +
  reply_to?: string | null;
59 +
};
60 +
61 +
export type Messages = {
62 +
  channel: string;
63 +
  items: Message[];
64 +
  next?: string | null;
65 +
};
66 +
67 +
export type NewMessage = {
68 +
  body: string;
69 +
  kind?: string | null;
70 +
  reply_to?: string | null;
71 +
};
72 +
73 +
export type Posted = {
74 +
  channel: string;
75 +
  duplicate: boolean;
76 +
  id: string;
77 +
};
78 +
79 +
export type InboxItem = {
80 +
  at_ms: number;
81 +
  channel: string;
82 +
  from: string;
83 +
  id: string;
84 +
  kind: string;
85 +
  summary: string;
86 +
};
87 +
88 +
export type Ack = {
89 +
  ids: string[];
90 +
  reaction?: string | null;
91 +
};
92 +
93 +
export type Hit = {
94 +
  at_ms: number;
95 +
  author: string;
96 +
  channel: string;
97 +
  id: string;
98 +
  snippet: string;
99 +
  thread?: string | null;
100 +
};
101 +
102 +
export type EventSummary = {
103 +
  actor: string;
104 +
  assurance: number;
105 +
  at_ms: number;
106 +
  hash: string;
107 +
  id: string;
108 +
  kind: string;
109 +
  method: string;
110 +
  seq: number;
111 +
  subject?: string | null;
112 +
};
113 +
114 +
export type Events = {
115 +
  items: EventSummary[];
116 +
  next?: number | null;
117 +
};
118 +
119 +
export type Brief = {
120 +
  actor: string;
121 +
  agents: Agent[];
122 +
  head: number;
123 +
  inbox: InboxItem[];
124 +
  project?: string | null;
125 +
  rules_version: string;
126 +
  unread: number;
127 +
  you: string;
128 +
};
129 +
added crates/spectra-mcp/src/api.rs +473 −0
1 +
//! The JSON API (spec §18): what spectra-vscode, scripts and SpectraHub
2 +
//! read and write. Every request is a signed request (spec §8.5); a read
3 +
//! may instead carry the owner's Radar session. Errors are RFC 7807
4 +
//! problems; lists page with `?after=`; reads carry an ETag.
5 +
6 +
use crate::http::{authenticate, AppState, PeerInfo};
7 +
use axum::body::Bytes;
8 +
use axum::extract::connect_info::ConnectInfo;
9 +
use axum::extract::{Path, Query, State};
10 +
use axum::http::{header, HeaderMap, StatusCode};
11 +
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
12 +
use axum::response::{IntoResponse, Response};
13 +
use axum::routing::{get, post};
14 +
use axum::Router;
15 +
use futures::StreamExt;
16 +
use serde::Deserialize;
17 +
use spectra_api as api;
18 +
use spectra_core::authn::Caller;
19 +
use spectra_core::conversation::{self as conv, Author, Post, Posted, Signer};
20 +
use spectra_core::event::{Auth, Method};
21 +
use spectra_core::ids::now_ms;
22 +
use spectra_core::principals::Principal;
23 +
use std::time::Duration;
24 +
25 +
pub fn router(state: AppState) -> Router {
26 +
    Router::new()
27 +
        .route("/api/v1/health", get(health))
28 +
        .route("/api/v1/version", get(version))
29 +
        .route("/api/v1/brief", get(brief))
30 +
        .route("/api/v1/agents", get(agents))
31 +
        .route("/api/v1/projects", get(projects))
32 +
        .route("/api/v1/channels", get(channels))
33 +
        .route("/api/v1/channels/{name}/messages", get(messages).post(post_message))
34 +
        .route("/api/v1/threads/{id}", get(thread))
35 +
        .route("/api/v1/inbox", get(inbox))
36 +
        .route("/api/v1/inbox/ack", post(ack))
37 +
        .route("/api/v1/search", get(search))
38 +
        .route("/api/v1/events", get(events))
39 +
        .with_state(state)
40 +
}
41 +
42 +
/// An RFC 7807 problem.
43 +
pub fn problem(status: StatusCode, title: &str, detail: Option<String>) -> Response {
44 +
    let p = api::Problem { kind: "about:blank".into(), title: title.into(), status: status.as_u16(), detail };
45 +
    let mut r = (status, axum::Json(p)).into_response();
46 +
    r.headers_mut().insert(header::CONTENT_TYPE, "application/problem+json".parse().unwrap());
47 +
    r
48 +
}
49 +
50 +
fn bad(e: anyhow::Error) -> Response {
51 +
    problem(StatusCode::BAD_REQUEST, "the request cannot be done", Some(format!("{e:#}")))
52 +
}
53 +
54 +
/// JSON with an ETag; 304 when the client has it already.
55 +
fn json_etag<T: serde::Serialize>(headers: &HeaderMap, v: &T) -> Response {
56 +
    let body = serde_json::to_vec(v).unwrap_or_default();
57 +
    let tag = format!("\"{}\"", &blake3_hex(&body)[..32]);
58 +
    if headers.get(header::IF_NONE_MATCH).and_then(|h| h.to_str().ok()) == Some(tag.as_str()) {
59 +
        return StatusCode::NOT_MODIFIED.into_response();
60 +
    }
61 +
    ([(header::CONTENT_TYPE, "application/json".to_string()), (header::ETAG, tag)], body).into_response()
62 +
}
63 +
64 +
fn blake3_hex(b: &[u8]) -> String {
65 +
    hex::encode(*spectra_core::event::hash_body_plain(b))
66 +
}
67 +
68 +
/// Who is asking: a signed request, or for a read the owner's session.
69 +
async fn caller(
70 +
    st: &AppState,
71 +
    peer: PeerInfo,
72 +
    headers: &HeaderMap,
73 +
    method_path: &str,
74 +
    body: &[u8],
75 +
    read: bool,
76 +
) -> Result<Caller, Response> {
77 +
    if read && headers.get("spectra-key").is_none() {
78 +
        let cookie = headers.get(header::COOKIE).and_then(|c| c.to_str().ok()).unwrap_or("");
79 +
        let id = cookie.split(';').map(str::trim).find_map(|kv| kv.strip_prefix("spectra_session=")).unwrap_or("");
80 +
        if let Some(s) = st.sessions.get(id, now_ms()) {
81 +
            if !crate::http::origin_ok(headers, &st.origins) {
82 +
                return Err(problem(StatusCode::FORBIDDEN, "this origin may not read Spectra", None));
83 +
            }
84 +
            let key = oxy_core::hash::ObjectRef(s.key);
85 +
            let principal = st
86 +
                .daemon
87 +
                .principals
88 +
                .read()
89 +
                .await
90 +
                .principal(&key, now_ms())
91 +
                .map_err(|why| problem(StatusCode::UNAUTHORIZED, "not authenticated", Some(why)))?;
92 +
            return Ok(Caller {
93 +
                principal,
94 +
                auth: Auth { key: Some(s.key), method: Method::Session, assurance: 2, proof: Some(s.proof) },
95 +
                learn_uid: None,
96 +
            });
97 +
        }
98 +
    }
99 +
    authenticate(st, peer, headers, method_path, body).await.map_err(|_| {
100 +
        problem(
101 +
            StatusCode::UNAUTHORIZED,
102 +
            "not authenticated",
103 +
            Some("sign the request with your key (spectra's client does), or open Radar with `spectra open`".into()),
104 +
        )
105 +
    })
106 +
}
107 +
108 +
fn path_of(uri: &axum::http::Uri) -> String {
109 +
    uri.path_and_query().map(|p| p.as_str().to_string()).unwrap_or_else(|| uri.path().to_string())
110 +
}
111 +
112 +
macro_rules! who {
113 +
    ($st:expr, $peer:expr, $headers:expr, $uri:expr, $method:literal, $body:expr, $read:expr) => {
114 +
        match caller(&$st, $peer, &$headers, &format!("{} {}", $method, path_of(&$uri)), $body, $read).await {
115 +
            Ok(c) => c,
116 +
            Err(r) => return r,
117 +
        }
118 +
    };
119 +
}
120 +
121 +
async fn health() -> Response {
122 +
    axum::Json(serde_json::json!({ "ok": true })).into_response()
123 +
}
124 +
125 +
async fn version() -> Response {
126 +
    axum::Json(api::Version {
127 +
        version: env!("CARGO_PKG_VERSION").into(),
128 +
        ledger_format: spectra_core::LEDGER_FORMAT,
129 +
        mcp_protocol_versions: crate::PROTOCOL_VERSIONS.iter().map(|v| v.to_string()).collect(),
130 +
    })
131 +
    .into_response()
132 +
}
133 +
134 +
async fn agent_list(st: &AppState) -> Vec<api::Agent> {
135 +
    #[allow(clippy::type_complexity)]
136 +
    let rows: Vec<(String, String, String, String, String, Option<String>, Option<i64>)> = sqlx::query_as(
137 +
        "SELECT a.name, a.owner_name, a.key, a.kind, a.project, p.status_text, a.disabled_ms FROM agents a LEFT JOIN presence p ON p.agent = a.name ORDER BY a.name",
138 +
    )
139 +
    .fetch_all(st.daemon.ledger.pool())
140 +
    .await
141 +
    .unwrap_or_default();
142 +
    let presence = st.daemon.presence.lock().await;
143 +
    let now = now_ms();
144 +
    rows.into_iter()
145 +
        .map(|(name, owner, key, kind, project, status, disabled)| api::Agent {
146 +
            display: format!("{name} · {owner}'s agent"),
147 +
            presence: if disabled.is_some() { "offline".into() } else { presence.state(&name, now).into() },
148 +
            disabled: disabled.is_some(),
149 +
            name,
150 +
            owner,
151 +
            key,
152 +
            kind,
153 +
            project,
154 +
            status,
155 +
        })
156 +
        .collect()
157 +
}
158 +
159 +
async fn agents(
160 +
    State(st): State<AppState>,
161 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
162 +
    headers: HeaderMap,
163 +
    uri: axum::http::Uri,
164 +
) -> Response {
165 +
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
166 +
    json_etag(&headers, &agent_list(&st).await)
167 +
}
168 +
169 +
async fn projects(
170 +
    State(st): State<AppState>,
171 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
172 +
    headers: HeaderMap,
173 +
    uri: axum::http::Uri,
174 +
) -> Response {
175 +
    let _ = who!(st, peer, headers, uri, "GET", b"", true);
176 +
    let pool = st.daemon.ledger.pool();
177 +
    let rows: Vec<(String, String, Option<String>)> =
178 +
        sqlx::query_as("SELECT name, path, remote FROM projects ORDER BY name").fetch_all(pool).await.unwrap_or_default();
179 +
    let mut out = Vec::new();
180 +
    for (name, path, remote) in rows {
181 +
        let depends_on = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ? ORDER BY 1")
182 +
            .bind(&name)
183 +
            .fetch_all(pool)
184 +
            .await
185 +
            .unwrap_or_default();
186 +
        let depended_on_by = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ? ORDER BY 1")
187 +
            .bind(&name)
188 +
            .fetch_all(pool)
189 +
            .await
190 +
            .unwrap_or_default();
191 +
        out.push(api::Project { name, path, remote, depends_on, depended_on_by });
192 +
    }
193 +
    json_etag(&headers, &out)
194 +
}
195 +
196 +
async fn channels(
197 +
    State(st): State<AppState>,
198 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
199 +
    headers: HeaderMap,
200 +
    uri: axum::http::Uri,
201 +
) -> Response {
202 +
    let c = who!(st, peer, headers, uri, "GET", b"", true);
203 +
    let all = match conv::channels(&st.daemon.ledger).await {
204 +
        Ok(a) => a,
205 +
        Err(e) => return bad(e),
206 +
    };
207 +
    let mut out = Vec::new();
208 +
    for ch in all.into_iter().filter(|ch| conv::can_read(&c.principal, ch)) {
209 +
        let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE channel = ?")
210 +
            .bind(&ch.id)
211 +
            .fetch_one(st.daemon.ledger.pool())
212 +
            .await
213 +
            .unwrap_or(0);
214 +
        out.push(api::Channel { display: ch.display(), id: ch.id, name: ch.name, kind: ch.kind, purpose: ch.purpose, messages: n });
215 +
    }
216 +
    json_etag(&headers, &out)
217 +
}
218 +
219 +
async fn to_api(st: &AppState, m: conv::Message, ch: &str) -> api::Message {
220 +
    api::Message {
221 +
        author_display: conv::display_name(&st.daemon.ledger, &m.author).await,
222 +
        mentions: serde_json::from_str(&m.mentions).unwrap_or_default(),
223 +
        refs: serde_json::from_str(&m.refs).unwrap_or_default(),
224 +
        redacted: m.redacted.is_some(),
225 +
        id: m.id,
226 +
        channel: ch.to_string(),
227 +
        reply_to: m.reply_to,
228 +
        author: m.author,
229 +
        kind: m.kind,
230 +
        body: m.body,
231 +
        at_ms: m.at_ms,
232 +
        replies: m.replies,
233 +
    }
234 +
}
235 +
236 +
#[derive(Deserialize)]
237 +
struct PageQuery {
238 +
    after: Option<String>,
239 +
    limit: Option<u32>,
240 +
}
241 +
242 +
async fn messages(
243 +
    State(st): State<AppState>,
244 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
245 +
    headers: HeaderMap,
246 +
    uri: axum::http::Uri,
247 +
    Path(name): Path<String>,
248 +
    Query(q): Query<PageQuery>,
249 +
) -> Response {
250 +
    let c = who!(st, peer, headers, uri, "GET", b"", true);
251 +
    let limit = q.limit.unwrap_or(50).clamp(1, 500);
252 +
    match conv::read(&st.daemon.ledger, &c.principal, Some(&name), None, q.after.as_deref(), limit).await {
253 +
        Ok((ch, msgs)) => {
254 +
            let next = (msgs.len() as u32 == limit).then(|| msgs.last().map(|m| m.id.clone())).flatten();
255 +
            let mut items = Vec::new();
256 +
            for m in msgs {
257 +
                items.push(to_api(&st, m, &ch.display()).await);
258 +
            }
259 +
            json_etag(&headers, &api::Messages { channel: ch.display(), items, next })
260 +
        }
261 +
        Err(e) => problem(StatusCode::NOT_FOUND, "no such channel, or not yours to read", Some(format!("{e:#}"))),
262 +
    }
263 +
}
264 +
265 +
async fn thread(
266 +
    State(st): State<AppState>,
267 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
268 +
    headers: HeaderMap,
269 +
    uri: axum::http::Uri,
270 +
    Path(id): Path<String>,
271 +
    Query(q): Query<PageQuery>,
272 +
) -> Response {
273 +
    let c = who!(st, peer, headers, uri, "GET", b"", true);
274 +
    let limit = q.limit.unwrap_or(200).clamp(1, 500);
275 +
    match conv::read(&st.daemon.ledger, &c.principal, None, Some(&id), q.after.as_deref(), limit).await {
276 +
        Ok((ch, msgs)) => {
277 +
            let next = (msgs.len() as u32 == limit).then(|| msgs.last().map(|m| m.id.clone())).flatten();
278 +
            let mut items = Vec::new();
279 +
            for m in msgs {
280 +
                items.push(to_api(&st, m, &ch.display()).await);
281 +
            }
282 +
            json_etag(&headers, &api::Messages { channel: ch.display(), items, next })
283 +
        }
284 +
        Err(e) => problem(StatusCode::NOT_FOUND, "no such thread, or not yours to read", Some(format!("{e:#}"))),
285 +
    }
286 +
}
287 +
288 +
async fn post_message(
289 +
    State(st): State<AppState>,
290 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
291 +
    headers: HeaderMap,
292 +
    uri: axum::http::Uri,
293 +
    Path(name): Path<String>,
294 +
    body: Bytes,
295 +
) -> Response {
296 +
    let c = who!(st, peer, headers, uri, "POST", &body, false);
297 +
    let n: api::NewMessage = match serde_json::from_slice(&body) {
298 +
        Ok(n) => n,
299 +
        Err(e) => return problem(StatusCode::BAD_REQUEST, "not a message", Some(e.to_string())),
300 +
    };
301 +
    let author = Author { principal: c.principal.clone(), signer: Signer::Request(c.auth.clone()) };
302 +
    let post = Post {
303 +
        channel: if n.reply_to.is_some() { None } else { Some(name) },
304 +
        body: n.body,
305 +
        reply_to: n.reply_to,
306 +
        kind: n.kind,
307 +
        ..Default::default()
308 +
    };
309 +
    match conv::post(&st.daemon.ledger, &author, post, now_ms()).await {
310 +
        Ok(Posted::Message { id, channel }) => {
311 +
            (StatusCode::CREATED, axum::Json(api::Posted { id, channel, duplicate: false })).into_response()
312 +
        }
313 +
        Ok(Posted::Duplicate { id }) => axum::Json(api::Posted { id, channel: String::new(), duplicate: true }).into_response(),
314 +
        Err(e) => bad(e),
315 +
    }
316 +
}
317 +
318 +
async fn inbox_items(st: &AppState, actor: &str) -> Vec<api::InboxItem> {
319 +
    conv::inbox(&st.daemon.ledger, actor, None)
320 +
        .await
321 +
        .unwrap_or_default()
322 +
        .into_iter()
323 +
        .map(|i| api::InboxItem { id: i.id, kind: i.kind, at_ms: i.at_ms, from: i.from, channel: i.channel, summary: i.summary })
324 +
        .collect()
325 +
}
326 +
327 +
async fn inbox(State(st): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, uri: axum::http::Uri) -> Response {
328 +
    let c = who!(st, peer, headers, uri, "GET", b"", true);
329 +
    json_etag(&headers, &inbox_items(&st, &c.principal.actor()).await)
330 +
}
331 +
332 +
async fn ack(
333 +
    State(st): State<AppState>,
334 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
335 +
    headers: HeaderMap,
336 +
    uri: axum::http::Uri,
337 +
    body: Bytes,
338 +
) -> Response {
339 +
    let c = who!(st, peer, headers, uri, "POST", &body, false);
340 +
    let a: api::Ack = match serde_json::from_slice(&body) {
341 +
        Ok(a) => a,
342 +
        Err(e) => return problem(StatusCode::BAD_REQUEST, "not an ack", Some(e.to_string())),
343 +
    };
344 +
    let author = Author { principal: c.principal, signer: Signer::Request(c.auth) };
345 +
    match conv::ack(&st.daemon.ledger, &author, &a.ids, a.reaction.as_deref()).await {
346 +
        Ok(n) => axum::Json(serde_json::json!({ "read": n })).into_response(),
347 +
        Err(e) => bad(e),
348 +
    }
349 +
}
350 +
351 +
async fn brief(State(st): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, uri: axum::http::Uri) -> Response {
352 +
    let c = who!(st, peer, headers, uri, "GET", b"", true);
353 +
    let project = match &c.principal {
354 +
        Principal::Agent { project, .. } => Some(project.clone()),
355 +
        _ => None,
356 +
    };
357 +
    let rules = spectra_core::rules::compile(&st.daemon.data).map(|r| r.version).unwrap_or_default();
358 +
    let inbox = inbox_items(&st, &c.principal.actor()).await;
359 +
    let b = api::Brief {
360 +
        you: c.principal.display(),
361 +
        actor: c.principal.actor(),
362 +
        project,
363 +
        rules_version: rules,
364 +
        unread: inbox.len() as i64,
365 +
        inbox,
366 +
        agents: agent_list(&st).await,
367 +
        head: st.daemon.ledger.head().await.0,
368 +
    };
369 +
    json_etag(&headers, &b)
370 +
}
371 +
372 +
#[derive(Deserialize)]
373 +
struct SearchQuery {
374 +
    q: String,
375 +
    channel: Option<String>,
376 +
    author: Option<String>,
377 +
    since: Option<String>,
378 +
    limit: Option<u32>,
379 +
}
380 +
381 +
async fn search(
382 +
    State(st): State<AppState>,
383 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
384 +
    headers: HeaderMap,
385 +
    uri: axum::http::Uri,
386 +
    Query(q): Query<SearchQuery>,
387 +
) -> Response {
388 +
    let c = who!(st, peer, headers, uri, "GET", b"", true);
389 +
    let since = q.since.as_deref().and_then(|s| crate::tools::parse_since(s, now_ms()));
390 +
    match conv::search(&st.daemon.ledger, &c.principal, &q.q, q.channel.as_deref(), q.author.as_deref(), since, q.limit.unwrap_or(20)).await
391 +
    {
392 +
        Ok(hits) => {
393 +
            let out: Vec<api::Hit> = hits
394 +
                .into_iter()
395 +
                .map(|h| api::Hit { id: h.id, channel: h.channel, author: h.author, at_ms: h.at_ms, snippet: h.snippet, thread: h.thread })
396 +
                .collect();
397 +
            json_etag(&headers, &out)
398 +
        }
399 +
        Err(e) => bad(e),
400 +
    }
401 +
}
402 +
403 +
#[derive(Deserialize)]
404 +
struct EventsQuery {
405 +
    after: Option<u64>,
406 +
    kind: Option<String>,
407 +
    limit: Option<u32>,
408 +
    follow: Option<u8>,
409 +
    /// With `follow`, end the stream after this many events.
410 +
    max: Option<usize>,
411 +
}
412 +
413 +
fn summary(e: &spectra_core::event::Event) -> api::EventSummary {
414 +
    api::EventSummary {
415 +
        seq: e.seq,
416 +
        id: e.id.clone(),
417 +
        at_ms: e.at_ms,
418 +
        kind: e.kind.clone(),
419 +
        actor: e.actor.clone(),
420 +
        subject: e.subject.clone(),
421 +
        method: e.auth.method.name().into(),
422 +
        assurance: e.auth.assurance,
423 +
        hash: hex::encode(e.hash),
424 +
    }
425 +
}
426 +
427 +
/// The log as summaries; `follow=1` streams new ones as SSE instead.
428 +
async fn events(
429 +
    State(st): State<AppState>,
430 +
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
431 +
    headers: HeaderMap,
432 +
    uri: axum::http::Uri,
433 +
    Query(q): Query<EventsQuery>,
434 +
) -> Response {
435 +
    let c = who!(st, peer, headers, uri, "GET", b"", true);
436 +
    // The log is the owner's to read; an agent reads what concerns it
437 +
    // through the conversation and its inbox.
438 +
    if !matches!(c.principal, Principal::Owner { .. }) {
439 +
        return problem(StatusCode::FORBIDDEN, "the event log is the owner's", None);
440 +
    }
441 +
    if q.follow == Some(1) {
442 +
        let kind = q.kind.clone();
443 +
        let rx = st.daemon.ledger.subscribe();
444 +
        let stream = tokio_stream::wrappers::BroadcastStream::new(rx)
445 +
            .filter_map(move |e| {
446 +
                let kind = kind.clone();
447 +
                async move {
448 +
                    let e = e.ok()?;
449 +
                    if kind.as_ref().is_some_and(|k| *k != e.kind) {
450 +
                        return None;
451 +
                    }
452 +
                    Some(Ok::<_, std::convert::Infallible>(
453 +
                        SseEvent::default().data(serde_json::to_string(&summary(&e)).unwrap_or_default()),
454 +
                    ))
455 +
                }
456 +
            })
457 +
            .take(q.max.unwrap_or(usize::MAX));
458 +
        return Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response();
459 +
    }
460 +
    let limit = q.limit.unwrap_or(100).clamp(1, 1000);
461 +
    let from = q.after.map(|a| a + 1).unwrap_or(1);
462 +
    let evs = match &q.kind {
463 +
        Some(k) => st.daemon.ledger.events_of_kinds(from, &[k.as_str()], limit).await,
464 +
        None => st.daemon.ledger.events(from, limit).await,
465 +
    };
466 +
    match evs {
467 +
        Ok(evs) => {
468 +
            let next = (evs.len() as u32 == limit).then(|| evs.last().map(|e| e.seq)).flatten();
469 +
            json_etag(&headers, &api::Events { items: evs.iter().map(summary).collect(), next })
470 +
        }
471 +
        Err(e) => bad(e),
472 +
    }
473 +
}
renamed crates/radar/src/session.rs → crates/spectra-mcp/src/session.rs +1 −1
1 -
//! Radar's sign-in (spec §8.3): the owner's CLI signs a request for a
1 +
//! The owner's browser sign-in, shared by Radar and the API (spec §8.3): the owner's CLI signs a request for a
2 2
//! one-time link (`POST /radar/link`, valid 60 s); opening it starts a
3 3
//! browser session whose events carry that signed request as their proof
4 4
//! (level 2). The passkey (level 3) is 0.2. Sessions live in memory: a
…
82 unchanged lines
added tests/api.rs +108 −0
1 +
//! The JSON API (spec §18) against the real daemon: every endpoint, signed
2 +
//! by an agent and by the owner; problems for refusals; paging; ETags; the
3 +
//! event log as the owner's; and the live stream.
4 +
5 +
mod common;
6 +
7 +
use common::{add_agent, setup, Serving};
8 +
use serde_json::{json, Value};
9 +
use spectra_core::keys::KeyPair;
10 +
use spectra_mcp::client::Client;
11 +
12 +
async fn get(c: &Client, path: &str) -> Value {
13 +
    let r = c.send("GET", path, &[], vec![]).await.unwrap();
14 +
    assert_eq!(r.status, 200, "{path}: {}", String::from_utf8_lossy(&r.body));
15 +
    r.json().unwrap()
16 +
}
17 +
18 +
#[tokio::test]
19 +
async fn every_endpoint_for_an_agent_and_the_owner() {
20 +
    let dir = setup();
21 +
    let d = dir.path();
22 +
    let claude = add_agent(d, "claude-oxygen", &[]);
23 +
    let srv = Serving::start(d);
24 +
    let a = Client::new(&srv.socket, Some(claude));
25 +
    let owner = Client::new(&srv.socket, Some(KeyPair::load_home(&d.join("owner")).unwrap()));
26 +
    let anon = Client::new(&srv.socket, None);
27 +
28 +
    assert_eq!(get(&anon, "/api/v1/health").await["ok"], true);
29 +
    assert_eq!(get(&anon, "/api/v1/version").await["ledger_format"], 1);
30 +
    let r = anon.send("GET", "/api/v1/agents", &[], vec![]).await.unwrap();
31 +
    assert_eq!(r.status, 401);
32 +
    assert_eq!(r.header("content-type"), Some("application/problem+json"));
33 +
    assert_eq!(r.json().unwrap()["status"], 401);
34 +
35 +
    let b = get(&a, "/api/v1/brief").await;
36 +
    assert_eq!(b["you"], "claude-oxygen · Andy McCall's agent");
37 +
    assert_eq!(b["project"], "oxygen");
38 +
    assert_eq!(get(&a, "/api/v1/agents").await[0]["display"], "claude-oxygen · Andy McCall's agent");
39 +
    assert_eq!(get(&a, "/api/v1/projects").await[0]["name"], "oxygen");
40 +
    let chans = get(&a, "/api/v1/channels").await;
41 +
    assert!(chans.as_array().unwrap().iter().any(|c| c["display"] == "#general"));
42 +
43 +
    // Post, page, and read a thread.
44 +
    let mut first = String::new();
45 +
    for i in 0..5 {
46 +
        let r = a
47 +
            .send(
48 +
                "POST",
49 +
                "/api/v1/channels/general/messages",
50 +
                &[],
51 +
                serde_json::to_vec(&json!({ "body": format!("note {i} for @andy") })).unwrap(),
52 +
            )
53 +
            .await
54 +
            .unwrap();
55 +
        assert_eq!(r.status, 201, "{}", String::from_utf8_lossy(&r.body));
56 +
        if i == 0 {
57 +
            first = r.json().unwrap()["id"].as_str().unwrap().to_string();
58 +
        }
59 +
    }
60 +
    let p1 = get(&a, "/api/v1/channels/general/messages?limit=2").await;
61 +
    assert_eq!(p1["items"].as_array().unwrap().len(), 2);
62 +
    let r = a
63 +
        .send(
64 +
            "POST",
65 +
            "/api/v1/channels/general/messages",
66 +
            &[],
67 +
            serde_json::to_vec(&json!({ "body": "a reply", "reply_to": first })).unwrap(),
68 +
        )
69 +
        .await
70 +
        .unwrap();
71 +
    assert_eq!(r.status, 201);
72 +
    let t = get(&a, &format!("/api/v1/threads/{first}")).await;
73 +
    assert_eq!(t["items"].as_array().unwrap().len(), 2);
74 +
    let r = a.send("POST", "/api/v1/channels/system/messages", &[], serde_json::to_vec(&json!({ "body": "x" })).unwrap()).await.unwrap();
75 +
    assert_eq!((r.status, r.json().unwrap()["status"].as_u64()), (400, Some(400)), "#system refuses, as a problem");
76 +
77 +
    // The owner's inbox, acked; search.
78 +
    let inbox = get(&owner, "/api/v1/inbox").await;
79 +
    assert_eq!(inbox.as_array().unwrap().len(), 5);
80 +
    let ids: Vec<Value> = inbox.as_array().unwrap().iter().map(|i| i["id"].clone()).collect();
81 +
    let r = owner.send("POST", "/api/v1/inbox/ack", &[], serde_json::to_vec(&json!({ "ids": ids })).unwrap()).await.unwrap();
82 +
    assert_eq!(r.json().unwrap()["read"], 5);
83 +
    assert_eq!(get(&owner, "/api/v1/inbox").await.as_array().unwrap().len(), 0);
84 +
    assert_eq!(get(&a, "/api/v1/search?q=note&limit=3").await.as_array().unwrap().len(), 3);
85 +
86 +
    // ETags: the same answer twice is a 304.
87 +
    let r = a.send("GET", "/api/v1/projects", &[], vec![]).await.unwrap();
88 +
    let tag = r.header("etag").unwrap().to_string();
89 +
    assert_eq!(a.send("GET", "/api/v1/projects", &[("if-none-match", tag)], vec![]).await.unwrap().status, 304);
90 +
91 +
    // The log is the owner's; it pages; the stream follows.
92 +
    assert_eq!(a.send("GET", "/api/v1/events", &[], vec![]).await.unwrap().status, 403);
93 +
    let ev = get(&owner, "/api/v1/events?limit=5").await;
94 +
    assert_eq!(ev["items"][0]["kind"], "identity.owner");
95 +
    assert_eq!(ev["next"], 5);
96 +
    let posted = get(&owner, "/api/v1/events?kind=message.posted&limit=50").await;
97 +
    assert_eq!(posted["items"].as_array().unwrap().len(), 6);
98 +
    assert!(posted["items"].as_array().unwrap().iter().all(|e| e["method"] == "signature" && e["assurance"] == 2));
99 +
    let stream = tokio::spawn({
100 +
        let owner = Client::new(&srv.socket, Some(KeyPair::load_home(&d.join("owner")).unwrap()));
101 +
        async move { owner.send("GET", "/api/v1/events?follow=1&max=1&kind=message.posted", &[], vec![]).await.unwrap() }
102 +
    });
103 +
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
104 +
    a.send("POST", "/api/v1/channels/general/messages", &[], serde_json::to_vec(&json!({ "body": "streamed" })).unwrap()).await.unwrap();
105 +
    let s = tokio::time::timeout(std::time::Duration::from_secs(10), stream).await.expect("the stream ends after max events").unwrap();
106 +
    assert!(String::from_utf8_lossy(&s.body).contains("\"kind\":\"message.posted\""), "{}", String::from_utf8_lossy(&s.body));
107 +
    srv.stop();
108 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: the JSON API and its TypeScript types (#11).** `/api/v1` has health, version, brief, agents, projects, channels, a channel's messages (read and post), threads, the inbox and ack, search, and the event log, which follows live as SSE (`?follow=1`). Requests are signed, or a read carries the owner's Radar session; refusals are RFC 7807 problems; lists page with `?after=`; reads carry an ETag. `crates/spectra-api` holds the types, and `make ts` writes `ts/index.d.ts` for the editor extensions.
5 6
- **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 7
- **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.
7 8
- **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.
…
9 unchanged lines
modified Cargo.lock +64 −0
…
650 unchanged lines
651 651
]
652 652
653 653
[[package]]
654 +
name = "dyn-clone"
655 +
version = "1.0.20"
656 +
source = "registry+https://github.com/rust-lang/crates.io-index"
657 +
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
658 +
659 +
[[package]]
654 660
name = "ed25519"
655 661
version = "3.0.0"
656 662
source = "registry+https://github.com/rust-lang/crates.io-index"
…
1305 unchanged lines
1962 1968
]
1963 1969
1964 1970
[[package]]
1971 +
name = "ref-cast"
1972 +
version = "1.0.27"
1973 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1974 +
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
1975 +
dependencies = [
1976 +
 "ref-cast-impl",
1977 +
]
1978 +
1979 +
[[package]]
1980 +
name = "ref-cast-impl"
1981 +
version = "1.0.27"
1982 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1983 +
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
1984 +
dependencies = [
1985 +
 "proc-macro2",
1986 +
 "quote",
1987 +
 "syn 3.0.6",
1988 +
]
1989 +
1990 +
[[package]]
1965 1991
name = "regex-automata"
1966 1992
version = "0.4.18"
1967 1993
source = "registry+https://github.com/rust-lang/crates.io-index"
…
147 unchanged lines
2115 2141
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
2116 2142
dependencies = [
2117 2143
 "winapi-util",
2144 +
]
2145 +
2146 +
[[package]]
2147 +
name = "schemars"
2148 +
version = "1.2.2"
2149 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2150 +
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
2151 +
dependencies = [
2152 +
 "dyn-clone",
2153 +
 "ref-cast",
2154 +
 "schemars_derive",
2155 +
 "serde",
2156 +
 "serde_json",
2118 2157
]
2119 2158
2120 2159
[[package]]
2160 +
name = "schemars_derive"
2161 +
version = "1.2.2"
2162 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2163 +
checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba"
2164 +
dependencies = [
2165 +
 "proc-macro2",
2166 +
 "quote",
2167 +
 "serde_derive_internals",
2168 +
 "syn 3.0.6",
2169 +
]
2170 +
2171 +
[[package]]
2121 2172
name = "scopeguard"
2122 2173
version = "1.2.0"
2123 2174
source = "registry+https://github.com/rust-lang/crates.io-index"
…
29 unchanged lines
2153 2204
version = "1.0.229"
2154 2205
source = "registry+https://github.com/rust-lang/crates.io-index"
2155 2206
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
2207 +
dependencies = [
2208 +
 "proc-macro2",
2209 +
 "quote",
2210 +
 "syn 3.0.6",
2211 +
]
2212 +
2213 +
[[package]]
2214 +
name = "serde_derive_internals"
2215 +
version = "0.30.0"
2216 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2217 +
checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd"
2156 2218
dependencies = [
2157 2219
 "proc-macro2",
2158 2220
 "quote",
…
207 unchanged lines
2366 2428
name = "spectra-api"
2367 2429
version = "0.1.0"
2368 2430
dependencies = [
2431 +
 "schemars",
2369 2432
 "serde",
2370 2433
 "serde_json",
2371 2434
]
…
32 unchanged lines
2404 2467
 "rand 0.8.8",
2405 2468
 "serde",
2406 2469
 "serde_json",
2470 +
 "spectra-api",
2407 2471
 "spectra-core",
2408 2472
 "sqlx",
2409 2473
 "tempfile",
…
1187 unchanged lines
modified crates/radar/src/lib.rs +12 −10
…
6 unchanged lines
7 7
//! its proof (spec §8.3). Every unsafe request carries the session's CSRF
8 8
//! token and a same-origin `Origin`.
9 9
10 -
pub mod session;
11 -
12 10
use askama::Template;
13 11
use axum::body::Bytes;
14 12
use axum::extract::connect_info::ConnectInfo;
…
5 unchanged lines
20 18
use axum::Router;
21 19
use futures::StreamExt;
22 20
use serde::Deserialize;
23 -
use session::{Session, Sessions};
24 21
use spectra_core::conversation::{self as conv, Author, Post, Signer};
25 22
use spectra_core::event::{Auth, Method};
26 23
use spectra_core::ids::{format_time, now_ms};
27 24
use spectra_core::principals::{Principal, Principals};
28 25
use spectra_mcp::http::{AppState, PeerInfo};
29 -
use std::sync::Arc;
26 +
use spectra_mcp::session::{self, Session};
30 27
use std::time::Duration;
31 28
32 29
const MARK: &str = include_str!("../assets/spectra.svg");
…
4 unchanged lines
37 34
#[derive(Clone)]
38 35
pub struct RadarState {
39 36
    pub app: AppState,
40 -
    pub sessions: Arc<Sessions>,
37 +
}
38 +
39 +
impl RadarState {
40 +
    fn sessions(&self) -> &session::Sessions {
41 +
        &self.app.sessions
42 +
    }
41 43
}
42 44
43 45
/// Radar's routes.
44 46
pub fn router(app: AppState) -> Router {
45 -
    let state = RadarState { app, sessions: Arc::new(Sessions::default()) };
47 +
    let state = RadarState { app };
46 48
    Router::new()
47 49
        .route("/", get(live))
48 50
        .route("/channels", get(channels))
…
65 unchanged lines
114 116
async fn owner(st: &RadarState, headers: &HeaderMap, page: &'static str) -> Result<Owner, Response> {
115 117
    let now = now_ms();
116 118
    let session = cookie(headers)
117 -
        .and_then(|id| st.sessions.get(&id, now))
119 +
        .and_then(|id| st.sessions().get(&id, now))
118 120
        .ok_or_else(|| signin_page("Radar shows Spectra to its owner only.", StatusCode::UNAUTHORIZED))?;
119 121
    let d = &st.app.daemon;
120 122
    let principal = d
…
467 unchanged lines
588 590
    let (Some(key), Some(proof)) = (caller.auth.key, caller.auth.proof) else {
589 591
        return StatusCode::UNAUTHORIZED.into_response();
590 592
    };
591 -
    let code = st.sessions.link(key, proof, now_ms());
593 +
    let code = st.sessions().link(key, proof, now_ms());
592 594
    let base = st.app.origins.first().cloned().unwrap_or_default();
593 595
    axum::Json(serde_json::json!({ "url": format!("{base}/signin?code={code}"), "valid_ms": session::LINK_MS })).into_response()
594 596
}
…
4 unchanged lines
599 601
}
600 602
601 603
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 {
604 +
    let Some(id) = q.code.and_then(|c| st.sessions().open(&c, now_ms())) else {
603 605
        return signin_page("That sign-in link has been used or has expired.", StatusCode::UNAUTHORIZED);
604 606
    };
605 607
    let cookie = format!("{COOKIE}={id}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}", session::SESSION_MS / 1000);
…
6 unchanged lines
612 614
    if let Ok(o) = owner(&st, &headers, "live").await {
613 615
        if check_form(&st, &o, &headers, &f._csrf).is_none() {
614 616
            if let Some(id) = cookie(&headers) {
615 -
                st.sessions.close(&id);
617 +
                st.sessions().close(&id);
616 618
            }
617 619
        }
618 620
    }
…
19 unchanged lines
modified crates/spectra-api/Cargo.toml +5 −0
…
7 unchanged lines
8 8
[dependencies]
9 9
serde.workspace = true
10 10
serde_json.workspace = true
11 +
schemars = { workspace = true, features = ["derive"] }
12 +
13 +
[[bin]]
14 +
name = "spectra-api-typescript"
15 +
path = "src/bin/typescript.rs"
modified crates/spectra-api/src/lib.rs +194 −2
1 1
//! The JSON API's types (spec §18), with serde and JSON Schema derives, as
2 -
//! `hub-api` is for OxygenHub. `make ts` writes `ts/index.d.ts` from them for
3 -
//! spectra-vscode and spectrahub-vscode.
2 +
//! `hub-api` is for OxygenHub. `make ts` writes `ts/index.d.ts` from them
3 +
//! for spectra-vscode and spectrahub-vscode, so the extensions can never
4 +
//! drift from the server.
5 +
6 +
use schemars::JsonSchema;
7 +
use serde::{Deserialize, Serialize};
8 +
9 +
/// An error, as RFC 7807 has it.
10 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
11 +
pub struct Problem {
12 +
    #[serde(rename = "type")]
13 +
    pub kind: String,
14 +
    pub title: String,
15 +
    pub status: u16,
16 +
    #[serde(skip_serializing_if = "Option::is_none")]
17 +
    pub detail: Option<String>,
18 +
}
19 +
20 +
/// `GET /api/v1/version`.
21 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
22 +
pub struct Version {
23 +
    pub version: String,
24 +
    pub ledger_format: u32,
25 +
    pub mcp_protocol_versions: Vec<String>,
26 +
}
27 +
28 +
/// An agent and its owner (spec §8.1).
29 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
30 +
pub struct Agent {
31 +
    pub name: String,
32 +
    /// `claude-oxygen · Andy McCall's agent`.
33 +
    pub display: String,
34 +
    pub owner: String,
35 +
    pub key: String,
36 +
    pub kind: String,
37 +
    pub project: String,
38 +
    /// online, busy, idle, blocked, paused or offline.
39 +
    pub presence: String,
40 +
    pub status: Option<String>,
41 +
    pub disabled: bool,
42 +
}
43 +
44 +
/// A project (spec §23.1).
45 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
46 +
pub struct Project {
47 +
    pub name: String,
48 +
    pub path: String,
49 +
    pub remote: Option<String>,
50 +
    pub depends_on: Vec<String>,
51 +
    pub depended_on_by: Vec<String>,
52 +
}
53 +
54 +
/// A channel (spec §10.1).
55 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
56 +
pub struct Channel {
57 +
    pub id: String,
58 +
    pub name: String,
59 +
    /// `#general`, `#oxygen`, or `@a+b` for a direct channel.
60 +
    pub display: String,
61 +
    pub kind: String,
62 +
    pub purpose: Option<String>,
63 +
    pub messages: i64,
64 +
}
65 +
66 +
/// A message (spec §10.3); a redacted one's body says so.
67 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
68 +
pub struct Message {
69 +
    pub id: String,
70 +
    pub channel: String,
71 +
    pub reply_to: Option<String>,
72 +
    pub author: String,
73 +
    pub author_display: String,
74 +
    pub kind: String,
75 +
    pub body: String,
76 +
    pub mentions: Vec<String>,
77 +
    pub refs: Vec<String>,
78 +
    pub at_ms: i64,
79 +
    pub replies: i64,
80 +
    pub redacted: bool,
81 +
}
82 +
83 +
/// A page of messages, oldest first; `next` is the cursor for the next page.
84 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
85 +
pub struct Messages {
86 +
    pub channel: String,
87 +
    pub items: Vec<Message>,
88 +
    pub next: Option<String>,
89 +
}
90 +
91 +
/// `POST /api/v1/channels/{name}/messages`.
92 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
93 +
pub struct NewMessage {
94 +
    pub body: String,
95 +
    #[serde(default)]
96 +
    pub reply_to: Option<String>,
97 +
    #[serde(default)]
98 +
    pub kind: Option<String>,
99 +
}
100 +
101 +
/// What a post did.
102 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
103 +
pub struct Posted {
104 +
    pub id: String,
105 +
    pub channel: String,
106 +
    /// True when it was dropped as a repeat of `id` within a minute.
107 +
    pub duplicate: bool,
108 +
}
109 +
110 +
/// An unread mention or direct message (spec §14.5).
111 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
112 +
pub struct InboxItem {
113 +
    pub id: String,
114 +
    pub kind: String,
115 +
    pub at_ms: i64,
116 +
    pub from: String,
117 +
    pub channel: String,
118 +
    pub summary: String,
119 +
}
120 +
121 +
/// `POST /api/v1/inbox/ack`.
122 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
123 +
pub struct Ack {
124 +
    pub ids: Vec<String>,
125 +
    #[serde(default)]
126 +
    pub reaction: Option<String>,
127 +
}
128 +
129 +
/// A search hit (spec §13.1).
130 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
131 +
pub struct Hit {
132 +
    pub id: String,
133 +
    pub channel: String,
134 +
    pub author: String,
135 +
    pub at_ms: i64,
136 +
    pub snippet: String,
137 +
    pub thread: Option<String>,
138 +
}
139 +
140 +
/// An event as the log and the stream show it: never its payload's data on
141 +
/// the stream, which says only what changed (spec §17.3).
142 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
143 +
pub struct EventSummary {
144 +
    pub seq: u64,
145 +
    pub id: String,
146 +
    pub at_ms: u64,
147 +
    pub kind: String,
148 +
    pub actor: String,
149 +
    pub subject: Option<String>,
150 +
    /// system, token, signature, passkey or session.
151 +
    pub method: String,
152 +
    pub assurance: u8,
153 +
    pub hash: String,
154 +
}
155 +
156 +
/// A page of the log, oldest first.
157 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
158 +
pub struct Events {
159 +
    pub items: Vec<EventSummary>,
160 +
    pub next: Option<u64>,
161 +
}
162 +
163 +
/// `GET /api/v1/brief`: who the caller is and what is waiting.
164 +
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
165 +
pub struct Brief {
166 +
    pub you: String,
167 +
    pub actor: String,
168 +
    pub project: Option<String>,
169 +
    pub rules_version: String,
170 +
    pub unread: i64,
171 +
    pub inbox: Vec<InboxItem>,
172 +
    pub agents: Vec<Agent>,
173 +
    pub head: u64,
174 +
}
175 +
176 +
/// Every type, with its schema, for `make ts`.
177 +
pub fn schemas() -> Vec<(&'static str, schemars::Schema)> {
178 +
    vec![
179 +
        ("Problem", schemars::schema_for!(Problem)),
180 +
        ("Version", schemars::schema_for!(Version)),
181 +
        ("Agent", schemars::schema_for!(Agent)),
182 +
        ("Project", schemars::schema_for!(Project)),
183 +
        ("Channel", schemars::schema_for!(Channel)),
184 +
        ("Message", schemars::schema_for!(Message)),
185 +
        ("Messages", schemars::schema_for!(Messages)),
186 +
        ("NewMessage", schemars::schema_for!(NewMessage)),
187 +
        ("Posted", schemars::schema_for!(Posted)),
188 +
        ("InboxItem", schemars::schema_for!(InboxItem)),
189 +
        ("Ack", schemars::schema_for!(Ack)),
190 +
        ("Hit", schemars::schema_for!(Hit)),
191 +
        ("EventSummary", schemars::schema_for!(EventSummary)),
192 +
        ("Events", schemars::schema_for!(Events)),
193 +
        ("Brief", schemars::schema_for!(Brief)),
194 +
    ]
195 +
}
modified crates/spectra-mcp/Cargo.toml +1 −0
…
6 unchanged lines
7 7
8 8
[dependencies]
9 9
spectra-core.workspace = true
10 +
spectra-api.workspace = true
10 11
oxy-core.workspace = true
11 12
anyhow.workspace = true
12 13
serde.workspace = true
…
15 unchanged lines
modified crates/spectra-mcp/src/http.rs +2 −0
…
44 unchanged lines
45 45
pub struct AppState {
46 46
    pub daemon: Arc<Daemon>,
47 47
    pub origins: Arc<Vec<String>>,
48 +
    /// The owner's browser sessions (Radar's sign-in link).
49 +
    pub sessions: Arc<crate::session::Sessions>,
48 50
}
49 51
50 52
/// Tuner's routes; the binary adds the API and Radar beside them.
…
154 unchanged lines
modified crates/spectra-mcp/src/lib.rs +2 −0
…
1 unchanged lines
2 2
//! the client the stdio shim and the CLI use to reach the daemon, signing
3 3
//! every request with their own key (spec §8.5).
4 4
5 +
pub mod api;
5 6
pub mod client;
6 7
pub mod http;
7 8
pub mod protocol;
9 +
pub mod session;
8 10
pub mod tools;
9 11
10 12
/// The MCP revisions Tuner speaks, newest first (spec §15.1).
…
9 unchanged lines
modified docs/plan.md +1 −1
…
23 unchanged lines
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 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 -
- [ ] #11 JSON API and spectra-api types
27 +
- [x] #11 JSON API and spectra-api types — done 2026-11-02: every 0.1 endpoint, problems, paging, ETags, the live log; types and ts/index.d.ts. How spectra-vscode will sign is spec open question 11.
28 28
- [ ] #12 Human CLI
29 29
- [ ] #13 Acceptance (with Andy)
30 30
…
6 unchanged lines
modified docs/spec.md +1 −0
…
988 unchanged lines
989 989
8. **A `spectra` delegation scope in Oxygen.** Today an agent is Spectra's because its owner delegated to it with any Oxygen scope (`changes`, usually). A scope that says "may act for me in Spectra" would let an owner give an agent Spectra without `oxy`, or `oxy` without Spectra, and would be the natural place for an expiry that Spectra alone honours. It is a one-line change to oxygen spec §14.2 and `oxy identity delegate add`; propose it to Oxygen once the shape here has settled.
990 990
9. **The owner's CLI key.** Level 2 from the CLI means an agent under the owner's user could sign as the owner for chat and tasks. A hardware-backed key for the owner (a YubiKey through PKCS#11, or the platform keystore) would raise the CLI to level 3; so would running agents under their own users (§21.6). Decide after living with the passkey in 0.2.
991 991
10. **Witnessing without OxygenHub.** Checkpoints are witnessed by OxygenHub's transparency log. A machine with no hub could publish them to a plain append-only file on a second machine, or to a public log. Not before 0.5.
992 +
11. **How spectra-vscode signs.** The API takes signed requests (§8.5), and an extension in TypeScript has no ML-DSA-65 of its own. Recommended: the extension runs `spectra` as a signing proxy over the socket (a `spectra api METHOD PATH` command that signs with the owner's key and prints the JSON), so no key ever leaves Rust; the alternative, a WebAssembly build of `oxy-core`'s signing, is heavier. Decide before spectra-vscode 0.1.
992 993
993 994
## 27. Changelog
994 995
…
6 unchanged lines
modified src/cli/serve.rs +2 −2
…
31 unchanged lines
32 32
    let tcp = tokio::net::TcpListener::bind(addr).await.with_context(|| format!("binding {addr}"))?;
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 -
    let state = AppState { daemon: daemon.clone(), origins: Arc::new(origins) };
36 -
    let app = spectra_mcp::http::router(state.clone()).merge(radar::router(state));
35 +
    let state = AppState { daemon: daemon.clone(), origins: Arc::new(origins), sessions: Default::default() };
36 +
    let app = spectra_mcp::http::router(state.clone()).merge(spectra_mcp::api::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")
…
69 unchanged lines