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 |