Skip to main content

scone/
serve.rs

1//! Self-hostable HTTP API (spec §8): the same core calls the CLI and MCP
2//! make — no privileged path. Every request authenticates with a Bearer
3//! key bound to exactly one space (per-key scoping; memory/bugs.md P-1),
4//! and bounds mirror the MCP surface (P-4).
5
6// Handlers return axum's Response in the error position — the idiomatic
7// axum shape. The large-Err perf hint is irrelevant on an HTTP edge.
8#![allow(clippy::result_large_err)]
9
10use std::sync::{Arc, Mutex};
11
12use axum::extract::{Path as AxPath, Query, State};
13use axum::http::{StatusCode, header};
14use axum::response::{IntoResponse, Response};
15use axum::routing::{get, post};
16use axum::{Json, Router};
17use scone_core::{Engine, IngestInput, IngestOutcome, RecallOpts, auth};
18
19const MAX_CONTENT: usize = 100_000;
20const MAX_QUERY: usize = 1_000;
21
22#[derive(Clone)]
23pub struct SpaceKey {
24    pub key: String,
25    pub space: String,
26}
27
28#[derive(Clone)]
29pub struct ServeConfig {
30    pub keys: Vec<SpaceKey>,
31}
32
33#[derive(Clone)]
34struct AppState {
35    engine: Arc<Mutex<Engine>>,
36    config: Arc<ServeConfig>,
37}
38
39pub fn router(engine: Engine, config: ServeConfig) -> Router {
40    let state = AppState {
41        engine: Arc::new(Mutex::new(engine)),
42        config: Arc::new(config),
43    };
44    Router::new()
45        .route("/v1/episodes", post(post_episode))
46        .route("/v1/recall", get(get_recall))
47        .route("/v1/facts", get(get_facts))
48        .route("/v1/facts/{id}/close", post(post_fact_close))
49        .route("/v1/profile", get(get_profile))
50        .route("/v1/status", get(get_status))
51        .with_state(state)
52}
53
54/// Error body every failure path shares — no silent shapes.
55fn err(status: StatusCode, message: impl Into<String>) -> Response {
56    (status, Json(serde_json::json!({ "error": message.into() }))).into_response()
57}
58
59/// Resolve the Bearer key to its space, or 401. The space name travels
60/// back through auth::resolve (I5) on every request.
61fn space_for(headers: &axum::http::HeaderMap, config: &ServeConfig) -> Result<String, Response> {
62    let presented = headers
63        .get(header::AUTHORIZATION)
64        .and_then(|v| v.to_str().ok())
65        .and_then(|v| v.strip_prefix("Bearer "))
66        .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "missing Bearer key"))?;
67    config
68        .keys
69        .iter()
70        .find(|k| k.key == presented)
71        .map(|k| k.space.clone())
72        .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "unknown key"))
73}
74
75fn with_engine<T>(
76    state: &AppState,
77    headers: &axum::http::HeaderMap,
78    f: impl FnOnce(&mut Engine, &auth::ScopedSpace) -> scone_core::Result<T>,
79) -> Result<T, Response> {
80    let space_name = space_for(headers, &state.config)?;
81    let mut engine = state
82        .engine
83        .lock()
84        .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "engine lock poisoned"))?;
85    let space = auth::resolve(&mut engine, &space_name, true)
86        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
87    f(&mut engine, &space).map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
88}
89
90#[derive(serde::Deserialize)]
91struct EpisodeBody {
92    content: String,
93}
94
95async fn post_episode(
96    State(state): State<AppState>,
97    headers: axum::http::HeaderMap,
98    Json(body): Json<EpisodeBody>,
99) -> Response {
100    if body.content.is_empty() || body.content.len() > MAX_CONTENT {
101        return err(
102            StatusCode::UNPROCESSABLE_ENTITY,
103            format!("content must be 1..={MAX_CONTENT} bytes"),
104        );
105    }
106    match with_engine(&state, &headers, |engine, space| {
107        engine.ingest(
108            space,
109            IngestInput::Note {
110                text: body.content.clone(),
111            },
112        )
113    }) {
114        Ok(IngestOutcome::Ingested { episode_id, chunks }) => (
115            StatusCode::CREATED,
116            Json(serde_json::json!({
117                "episode_id": episode_id, "chunks": chunks, "deduplicated": false
118            })),
119        )
120            .into_response(),
121        Ok(IngestOutcome::Deduplicated { episode_id }) => (
122            StatusCode::OK,
123            Json(serde_json::json!({
124                "episode_id": episode_id, "deduplicated": true
125            })),
126        )
127            .into_response(),
128        Err(response) => response,
129    }
130}
131
132#[derive(serde::Deserialize)]
133struct RecallQuery {
134    q: String,
135    limit: Option<usize>,
136    as_of: Option<String>,
137}
138
139async fn get_recall(
140    State(state): State<AppState>,
141    headers: axum::http::HeaderMap,
142    Query(query): Query<RecallQuery>,
143) -> Response {
144    if query.q.is_empty() || query.q.len() > MAX_QUERY {
145        return err(
146            StatusCode::UNPROCESSABLE_ENTITY,
147            format!("q must be 1..={MAX_QUERY} chars"),
148        );
149    }
150    let opts = RecallOpts {
151        limit: query.limit.unwrap_or(10).clamp(1, 50),
152        budget_bytes: None,
153        as_of: query.as_of.clone(),
154    };
155    match with_engine(&state, &headers, |engine, space| {
156        engine.recall(space, &query.q, &opts)
157    }) {
158        Ok(pack) => Json(serde_json::json!({
159            "facts": pack.facts.iter().map(|f| serde_json::json!({
160                "fact_id": f.fact_id, "subject": f.subject, "predicate": f.predicate,
161                "object": f.object, "confidence": f.confidence,
162                "valid_from": f.valid_from, "valid_until": f.valid_until,
163                "status": f.status,
164            })).collect::<Vec<_>>(),
165            "items": pack.items.iter().map(|i| serde_json::json!({
166                "episode_id": i.episode_id, "text": i.text, "score": i.score,
167                "source": i.source, "created_at": i.created_at,
168            })).collect::<Vec<_>>(),
169            "degraded": pack.degraded,
170        }))
171        .into_response(),
172        Err(response) => response,
173    }
174}
175
176#[derive(serde::Deserialize)]
177struct FactsQuery {
178    #[serde(default)]
179    all: bool,
180}
181
182async fn get_facts(
183    State(state): State<AppState>,
184    headers: axum::http::HeaderMap,
185    Query(query): Query<FactsQuery>,
186) -> Response {
187    match with_engine(&state, &headers, |engine, space| {
188        engine.facts_list(space, query.all)
189    }) {
190        Ok(facts) => Json(serde_json::json!({
191            "facts": facts.iter().map(|f| serde_json::json!({
192                "fact_id": f.fact_id, "subject": f.subject, "predicate": f.predicate,
193                "object": f.object, "confidence": f.confidence,
194                "valid_from": f.valid_from, "valid_until": f.valid_until,
195                "status": f.status,
196            })).collect::<Vec<_>>(),
197        }))
198        .into_response(),
199        Err(response) => response,
200    }
201}
202
203#[derive(serde::Deserialize)]
204struct CloseBody {
205    reason: String,
206}
207
208async fn post_fact_close(
209    State(state): State<AppState>,
210    headers: axum::http::HeaderMap,
211    AxPath(id): AxPath<i64>,
212    Json(body): Json<CloseBody>,
213) -> Response {
214    if body.reason.is_empty() || body.reason.len() > 500 {
215        return err(
216            StatusCode::UNPROCESSABLE_ENTITY,
217            "reason must be 1..=500 chars",
218        );
219    }
220    match with_engine(&state, &headers, |engine, space| {
221        engine.facts_close(space, id, &body.reason)
222    }) {
223        Ok(()) => Json(serde_json::json!({"closed": id, "reason": body.reason})).into_response(),
224        Err(response) => response,
225    }
226}
227
228async fn get_profile(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
229    match with_engine(&state, &headers, |engine, space| engine.profile(space, 8)) {
230        Ok(profile) => Json(serde_json::json!({
231            "static_facts": profile.static_facts.iter().map(|f| serde_json::json!({
232                "fact_id": f.fact_id, "subject": f.subject, "predicate": f.predicate,
233                "object": f.object, "confidence": f.confidence,
234            })).collect::<Vec<_>>(),
235            "dynamic": profile.dynamic,
236        }))
237        .into_response(),
238        Err(response) => response,
239    }
240}
241
242async fn get_status(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
243    let space_name = match space_for(&headers, &state.config) {
244        Ok(name) => name,
245        Err(response) => return response,
246    };
247    match with_engine(&state, &headers, |engine, space| {
248        let report = engine.status()?;
249        let mine = report.spaces.iter().find(|s| s.name == space.name());
250        Ok(serde_json::json!({
251            "space": space.name(),
252            "episodes": mine.map(|s| s.episodes).unwrap_or(0),
253            "chunks": mine.map(|s| s.chunks).unwrap_or(0),
254            "revision": mine.map(|s| s.revision).unwrap_or(0),
255            "semantic_lane": if report.llm_id.is_some() { "active" } else { "paused" },
256            "pending_distill": report.pending_distill,
257        }))
258    }) {
259        Ok(value) => {
260            let _ = space_name;
261            Json(value).into_response()
262        }
263        Err(response) => response,
264    }
265}