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        .route("/v1/tags", get(get_tags))
52        .with_state(state)
53}
54
55/// Error body every failure path shares — no silent shapes.
56fn err(status: StatusCode, message: impl Into<String>) -> Response {
57    (status, Json(serde_json::json!({ "error": message.into() }))).into_response()
58}
59
60/// Resolve the Bearer key to its space, or 401. The space name travels
61/// back through auth::resolve (I5) on every request.
62fn space_for(headers: &axum::http::HeaderMap, config: &ServeConfig) -> Result<String, Response> {
63    let presented = headers
64        .get(header::AUTHORIZATION)
65        .and_then(|v| v.to_str().ok())
66        .and_then(|v| v.strip_prefix("Bearer "))
67        .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "missing Bearer key"))?;
68    config
69        .keys
70        .iter()
71        .find(|k| k.key == presented)
72        .map(|k| k.space.clone())
73        .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "unknown key"))
74}
75
76fn with_engine<T>(
77    state: &AppState,
78    headers: &axum::http::HeaderMap,
79    f: impl FnOnce(&mut Engine, &auth::ScopedSpace) -> scone_core::Result<T>,
80) -> Result<T, Response> {
81    let space_name = space_for(headers, &state.config)?;
82    let mut engine = state
83        .engine
84        .lock()
85        .map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "engine lock poisoned"))?;
86    let space = auth::resolve(&mut engine, &space_name, true)
87        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
88    f(&mut engine, &space).map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
89}
90
91#[derive(serde::Deserialize)]
92struct EpisodeBody {
93    content: String,
94    #[serde(default)]
95    tags: Vec<String>,
96}
97
98async fn post_episode(
99    State(state): State<AppState>,
100    headers: axum::http::HeaderMap,
101    Json(body): Json<EpisodeBody>,
102) -> Response {
103    if body.content.is_empty() || body.content.len() > MAX_CONTENT {
104        return err(
105            StatusCode::UNPROCESSABLE_ENTITY,
106            format!("content must be 1..={MAX_CONTENT} bytes"),
107        );
108    }
109    if body.tags.len() > 10 {
110        return err(StatusCode::UNPROCESSABLE_ENTITY, "at most 10 tags");
111    }
112    match with_engine(&state, &headers, |engine, space| {
113        let outcome = engine.ingest(
114            space,
115            IngestInput::Note {
116                text: body.content.clone(),
117            },
118        )?;
119        let episode_id = match &outcome {
120            IngestOutcome::Ingested { episode_id, .. }
121            | IngestOutcome::Deduplicated { episode_id } => *episode_id,
122        };
123        if !body.tags.is_empty() {
124            let refs: Vec<&str> = body.tags.iter().map(String::as_str).collect();
125            engine.tag_episode(space, episode_id, &refs)?;
126        }
127        Ok(outcome)
128    }) {
129        Ok(IngestOutcome::Ingested { episode_id, chunks }) => (
130            StatusCode::CREATED,
131            Json(serde_json::json!({
132                "episode_id": episode_id, "chunks": chunks, "deduplicated": false
133            })),
134        )
135            .into_response(),
136        Ok(IngestOutcome::Deduplicated { episode_id }) => (
137            StatusCode::OK,
138            Json(serde_json::json!({
139                "episode_id": episode_id, "deduplicated": true
140            })),
141        )
142            .into_response(),
143        Err(response) => response,
144    }
145}
146
147#[derive(serde::Deserialize)]
148struct RecallQuery {
149    q: String,
150    limit: Option<usize>,
151    as_of: Option<String>,
152    /// Comma-separated tag filter (AND semantics).
153    tags: Option<String>,
154}
155
156async fn get_recall(
157    State(state): State<AppState>,
158    headers: axum::http::HeaderMap,
159    Query(query): Query<RecallQuery>,
160) -> Response {
161    if query.q.is_empty() || query.q.len() > MAX_QUERY {
162        return err(
163            StatusCode::UNPROCESSABLE_ENTITY,
164            format!("q must be 1..={MAX_QUERY} chars"),
165        );
166    }
167    let opts = RecallOpts {
168        limit: query.limit.unwrap_or(10).clamp(1, 50),
169        budget_bytes: None,
170        as_of: query.as_of.clone(),
171        expand_neighbors: false,
172        tags: query
173            .tags
174            .as_deref()
175            .map(|t| t.split(',').map(|x| x.trim().to_owned()).collect())
176            .unwrap_or_default(),
177    };
178    match with_engine(&state, &headers, |engine, space| {
179        engine.recall(space, &query.q, &opts)
180    }) {
181        Ok(pack) => Json(serde_json::json!({
182            "facts": pack.facts.iter().map(|f| serde_json::json!({
183                "fact_id": f.fact_id, "subject": f.subject, "predicate": f.predicate,
184                "object": f.object, "confidence": f.confidence,
185                "valid_from": f.valid_from, "valid_until": f.valid_until,
186                "status": f.status,
187            })).collect::<Vec<_>>(),
188            "items": pack.items.iter().map(|i| serde_json::json!({
189                "episode_id": i.episode_id, "text": i.text, "score": i.score,
190                "source": i.source, "created_at": i.created_at,
191            })).collect::<Vec<_>>(),
192            "degraded": pack.degraded,
193            "returned_bytes": pack.returned_bytes,
194            "space_bytes": pack.space_bytes,
195            "context_reduction": pack.context_reduction(),
196        }))
197        .into_response(),
198        Err(response) => response,
199    }
200}
201
202#[derive(serde::Deserialize)]
203struct FactsQuery {
204    #[serde(default)]
205    all: bool,
206}
207
208async fn get_facts(
209    State(state): State<AppState>,
210    headers: axum::http::HeaderMap,
211    Query(query): Query<FactsQuery>,
212) -> Response {
213    match with_engine(&state, &headers, |engine, space| {
214        engine.facts_list(space, query.all)
215    }) {
216        Ok(facts) => Json(serde_json::json!({
217            "facts": facts.iter().map(|f| serde_json::json!({
218                "fact_id": f.fact_id, "subject": f.subject, "predicate": f.predicate,
219                "object": f.object, "confidence": f.confidence,
220                "valid_from": f.valid_from, "valid_until": f.valid_until,
221                "status": f.status,
222            })).collect::<Vec<_>>(),
223        }))
224        .into_response(),
225        Err(response) => response,
226    }
227}
228
229#[derive(serde::Deserialize)]
230struct CloseBody {
231    reason: String,
232}
233
234async fn post_fact_close(
235    State(state): State<AppState>,
236    headers: axum::http::HeaderMap,
237    AxPath(id): AxPath<i64>,
238    Json(body): Json<CloseBody>,
239) -> Response {
240    if body.reason.is_empty() || body.reason.len() > 500 {
241        return err(
242            StatusCode::UNPROCESSABLE_ENTITY,
243            "reason must be 1..=500 chars",
244        );
245    }
246    match with_engine(&state, &headers, |engine, space| {
247        engine.facts_close(space, id, &body.reason)
248    }) {
249        Ok(()) => Json(serde_json::json!({"closed": id, "reason": body.reason})).into_response(),
250        Err(response) => response,
251    }
252}
253
254async fn get_profile(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
255    match with_engine(&state, &headers, |engine, space| engine.profile(space, 8)) {
256        Ok(profile) => Json(serde_json::json!({
257            "static_facts": profile.static_facts.iter().map(|f| serde_json::json!({
258                "fact_id": f.fact_id, "subject": f.subject, "predicate": f.predicate,
259                "object": f.object, "confidence": f.confidence,
260            })).collect::<Vec<_>>(),
261            "dynamic": profile.dynamic,
262        }))
263        .into_response(),
264        Err(response) => response,
265    }
266}
267
268async fn get_tags(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
269    match with_engine(&state, &headers, |engine, space| engine.tags_list(space)) {
270        Ok(tags) => Json(serde_json::json!({
271            "tags": tags.iter().map(|(name, count)| serde_json::json!({
272                "name": name, "count": count,
273            })).collect::<Vec<_>>(),
274        }))
275        .into_response(),
276        Err(response) => response,
277    }
278}
279
280async fn get_status(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
281    let space_name = match space_for(&headers, &state.config) {
282        Ok(name) => name,
283        Err(response) => return response,
284    };
285    match with_engine(&state, &headers, |engine, space| {
286        let report = engine.status()?;
287        let mine = report.spaces.iter().find(|s| s.name == space.name());
288        Ok(serde_json::json!({
289            "space": space.name(),
290            "episodes": mine.map(|s| s.episodes).unwrap_or(0),
291            "chunks": mine.map(|s| s.chunks).unwrap_or(0),
292            "revision": mine.map(|s| s.revision).unwrap_or(0),
293            "semantic_lane": if report.llm_id.is_some() { "active" } else { "paused" },
294            "pending_distill": report.pending_distill,
295        }))
296    }) {
297        Ok(value) => {
298            let _ = space_name;
299            Json(value).into_response()
300        }
301        Err(response) => response,
302    }
303}