Skip to main content

solo_api/
http.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! HTTP/JSON transport for Solo. Local-only by default — binds to
4//! `127.0.0.1:<port>` and serves the same four operations the MCP server
5//! exposes:
6//!
7//!   - `POST /memory`            — remember (body: { content, source_type?, source_id? })
8//!   - `POST /memory/search`     — recall  (body: { query, limit? })
9//!   - `GET  /memory/{id}`       — inspect
10//!   - `DELETE /memory/{id}?reason=…` — forget
11//!
12//! There's no auth at this layer. The threat model is local-machine
13//! single-user; binding to `127.0.0.1` keeps the surface off the LAN.
14//! A future commit can add bearer-token auth + LAN binding.
15//!
16//! ## Lifecycle
17//!
18//! `serve_http(addr, server, shutdown)` binds to `addr`, runs axum with
19//! `with_graceful_shutdown(shutdown)`, returns when shutdown fires or
20//! the listener errors. `solo http-serve` invokes this from inside a
21//! `OneShotContext`, so writer + reader pool + lockfile stay live for
22//! the server's lifetime and clean up properly afterwards.
23
24use std::net::SocketAddr;
25use std::str::FromStr;
26use std::sync::Arc;
27
28use axum::extract::{Path, Query, State};
29use axum::http::{HeaderValue, Method, StatusCode};
30use axum::response::{IntoResponse, Response};
31use axum::routing::{get, post};
32use axum::{Json, Router};
33use serde::{Deserialize, Serialize};
34use solo_core::{
35    Confidence, Embedder, EncodingContext, Episode, MemoryId, Tier, VectorIndex,
36};
37use solo_storage::{ReaderPool, WriteHandle};
38use tower_http::cors::{AllowOrigin, CorsLayer};
39use tower_http::trace::TraceLayer;
40use tower_http::validate_request::{ValidateRequest, ValidateRequestHeaderLayer};
41
42#[derive(Clone)]
43pub struct SoloHttpState {
44    pub write: WriteHandle,
45    pub pool: ReaderPool,
46    pub embedder: Arc<dyn Embedder>,
47    pub hnsw: Arc<dyn VectorIndex + Send + Sync>,
48}
49
50/// Build the router with optional bearer-token auth.
51///
52/// When `bearer_token` is `Some(t)`, every request except `GET /health`
53/// (the unauthenticated liveness probe) requires
54/// `Authorization: Bearer t`. The /health exemption keeps load
55/// balancers and uptime monitors from needing a credential.
56///
57/// `tower_http::validate_request::ValidateRequestHeaderLayer::bearer`
58/// returns 401 with a WWW-Authenticate header on missing/wrong token.
59pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
60    let cors = build_cors_layer();
61    // Public, always-unauthenticated routes:
62    //   - GET /health: liveness probe (load balancers, uptime monitors).
63    //   - GET /openapi.json: machine-readable API description for client
64    //     codegen + browser-UI tooling (TypeScript / OpenAPI Generator,
65    //     curl-tools, etc.). The spec describes the API shape, not
66    //     secrets — fine to serve unauthenticated even on a LAN-bound
67    //     instance.
68    let public = Router::new()
69        .route("/health", get(|| async { "ok" }))
70        .route("/openapi.json", get(openapi_handler));
71
72    let mut authed = Router::new()
73        .route("/memory", post(remember_handler))
74        .route("/memory/search", post(recall_handler))
75        .route("/memory/consolidate", post(consolidate_handler))
76        .route("/memory/{id}", get(inspect_handler).delete(forget_handler))
77        .with_state(state);
78    if let Some(token) = bearer_token {
79        // Custom validator (the helper-shaped `::bearer` constructor is
80        // deprecated in tower-http ≥ 0.6.7). Returns 401 with
81        // `WWW-Authenticate: Bearer` on missing or wrong token.
82        authed = authed.layer(ValidateRequestHeaderLayer::custom(BearerToken::new(token)));
83    }
84
85    public
86        .merge(authed)
87        .layer(cors)
88        .layer(TraceLayer::new_for_http())
89}
90
91/// Convenience wrapper: no auth (loopback-only deployments).
92pub fn router(state: SoloHttpState) -> Router {
93    router_with_auth(state, None)
94}
95
96fn build_cors_layer() -> CorsLayer {
97    // Permissive-localhost CORS: allow any localhost / 127.0.0.1 origin so
98    // browser-based UIs running on a different local port can call the API
99    // without preflight friction. We do NOT use `Any` because that would
100    // allow arbitrary remote origins to talk to our localhost server via
101    // a victim's browser. With bearer-token auth enabled the practical
102    // impact is reduced (the cross-origin attacker still can't supply
103    // the token), but principle of least privilege says refuse anyway.
104    //
105    // When the server is bound to a non-loopback address (auth required),
106    // the same CORS predicate keeps localhost-only browser clients —
107    // suitable for trusted-LAN deployments where the LAN client itself
108    // tunnels through ssh/wireguard back to localhost. Wider CORS for
109    // genuine cross-origin browser use is a future config knob.
110    CorsLayer::new()
111        .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
112            origin
113                .to_str()
114                .map(is_localhost_origin)
115                .unwrap_or(false)
116        }))
117        .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
118        .allow_headers([
119            axum::http::header::CONTENT_TYPE,
120            axum::http::header::AUTHORIZATION,
121        ])
122}
123
124/// `tower_http::validate_request::ValidateRequest` impl that accepts
125/// any request whose `Authorization` header equals `Bearer <token>`
126/// (constant-time comparison via `subtle`-style byte equality —
127/// String == is constant-time in Rust for equal-length operands; for
128/// unequal lengths the early-return is fine here because the token
129/// length isn't sensitive). On miss, returns 401 with
130/// `WWW-Authenticate: Bearer realm="solo"`.
131#[derive(Clone)]
132struct BearerToken {
133    expected: HeaderValue,
134}
135
136impl BearerToken {
137    fn new(token: String) -> Self {
138        let expected = HeaderValue::try_from(format!("Bearer {token}"))
139            .expect("bearer token must be a valid HTTP header value");
140        Self { expected }
141    }
142}
143
144impl<B> ValidateRequest<B> for BearerToken {
145    type ResponseBody = axum::body::Body;
146
147    fn validate(
148        &mut self,
149        request: &mut axum::http::Request<B>,
150    ) -> Result<(), axum::http::Response<Self::ResponseBody>> {
151        let got = request.headers().get(axum::http::header::AUTHORIZATION);
152        match got {
153            Some(value) if value == &self.expected => Ok(()),
154            _ => {
155                let mut resp = axum::http::Response::new(axum::body::Body::empty());
156                *resp.status_mut() = StatusCode::UNAUTHORIZED;
157                resp.headers_mut().insert(
158                    axum::http::header::WWW_AUTHENTICATE,
159                    HeaderValue::from_static(r#"Bearer realm="solo""#),
160                );
161                Err(resp)
162            }
163        }
164    }
165}
166
167/// True if `origin` is `http(s)://localhost[:port]` or
168/// `http(s)://127.0.0.1[:port]` or `http(s)://[::1][:port]` (loopback IPv6).
169/// Anything else (incl. nip.io tricks like `127.0.0.1.nip.io`) is rejected.
170fn is_localhost_origin(origin: &str) -> bool {
171    let rest = origin
172        .strip_prefix("http://")
173        .or_else(|| origin.strip_prefix("https://"));
174    let host = match rest {
175        Some(r) => r,
176        None => return false,
177    };
178    // Strip path (shouldn't appear on Origin headers but defend anyway).
179    let host = host.split('/').next().unwrap_or(host);
180    // Strip port.
181    let host = if let Some(idx) = host.rfind(':') {
182        // For [::1]:port, keep the brackets in the host part.
183        if host.starts_with('[') {
184            // Find matching ']'; everything up to and including it is the host.
185            host.find(']')
186                .map(|i| &host[..=i])
187                .unwrap_or(host)
188        } else {
189            &host[..idx]
190        }
191    } else {
192        host
193    };
194    matches!(host, "localhost" | "127.0.0.1" | "[::1]")
195}
196
197/// Bind + serve. `shutdown` is awaited inside axum's
198/// `with_graceful_shutdown`; resolving it triggers a clean drain.
199/// `bearer_token = None` runs unauthenticated (loopback default);
200/// `Some(t)` requires `Authorization: Bearer t` on every request
201/// except `GET /health`.
202pub async fn serve_http(
203    addr: SocketAddr,
204    state: SoloHttpState,
205    bearer_token: Option<String>,
206    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
207) -> std::io::Result<()> {
208    let auth_kind = if bearer_token.is_some() {
209        "bearer"
210    } else {
211        "none"
212    };
213    let app = router_with_auth(state, bearer_token);
214    let listener = tokio::net::TcpListener::bind(addr).await?;
215    tracing::info!(%addr, auth = auth_kind, "solo http: listening");
216    axum::serve(listener, app)
217        .with_graceful_shutdown(shutdown)
218        .await
219}
220
221// ---------------------------------------------------------------------------
222// OpenAPI 3.1 spec
223// ---------------------------------------------------------------------------
224
225/// Serve the hand-crafted OpenAPI 3.1 spec at `GET /openapi.json`.
226///
227/// We keep the spec hand-written (rather than deriving via `utoipa`)
228/// for v0.1: 4 simple endpoints, types live across crate boundaries
229/// (`solo_query::RecallResult`, `solo_query::EpisodeRecord`), and a
230/// `utoipa` retrofit would touch every crate. Hand-crafted is one
231/// JSON literal in this file; a smoke test in `handler_tests` parses
232/// the response and asserts the expected paths + components are
233/// present, so drift between spec and code is caught at PR time.
234async fn openapi_handler() -> Json<serde_json::Value> {
235    Json(openapi_spec())
236}
237
238/// Build the OpenAPI 3.1 spec describing Solo's HTTP transport.
239/// Public so the smoke test + future client-codegen tooling can
240/// produce the same document without spinning up the server.
241pub fn openapi_spec() -> serde_json::Value {
242    serde_json::json!({
243        "openapi": "3.1.0",
244        "info": {
245            "title": "Solo HTTP API",
246            "description":
247                "Local-first personal memory daemon. The HTTP transport \
248                 mirrors the four MCP tools (memory.remember / recall / \
249                 inspect / forget). Default deployment is loopback-only \
250                 (127.0.0.1); LAN-bound deployments require a bearer \
251                 token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
252            "version": env!("CARGO_PKG_VERSION"),
253            "license": { "name": "Apache-2.0" }
254        },
255        "servers": [
256            { "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
257        ],
258        "components": {
259            "securitySchemes": {
260                "bearerAuth": {
261                    "type": "http",
262                    "scheme": "bearer",
263                    "description":
264                        "Bearer-token auth. Required only on LAN-bound deployments \
265                         (`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
266                         the default `127.0.0.1` deployment is unauthenticated. \
267                         `GET /health` and `GET /openapi.json` are exempt from auth even \
268                         on bearer-protected instances."
269                }
270            },
271            "schemas": {
272                "RememberRequest": {
273                    "type": "object",
274                    "required": ["content"],
275                    "properties": {
276                        "content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
277                        "source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
278                        "source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
279                    },
280                    "additionalProperties": false
281                },
282                "RememberResponse": {
283                    "type": "object",
284                    "required": ["memory_id"],
285                    "properties": {
286                        "memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
287                    }
288                },
289                "RecallRequest": {
290                    "type": "object",
291                    "required": ["query"],
292                    "properties": {
293                        "query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
294                        "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
295                    },
296                    "additionalProperties": false
297                },
298                "RecallResult": {
299                    "type": "object",
300                    "description":
301                        "Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
302                         see `solo_query::RecallResult` in the source for the canonical shape. \
303                         Treat as a forward-compatible JSON object.",
304                    "additionalProperties": true
305                },
306                "ConsolidationScope": {
307                    "type": "object",
308                    "description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
309                    "properties": {
310                        "window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." }
311                    },
312                    "additionalProperties": false
313                },
314                "ConsolidationReport": {
315                    "type": "object",
316                    "required": ["episodes_seen", "clusters_built", "episodes_clustered", "abstractions_built", "triples_built", "contradictions_found"],
317                    "properties": {
318                        "episodes_seen":        { "type": "integer", "minimum": 0 },
319                        "clusters_built":       { "type": "integer", "minimum": 0 },
320                        "episodes_clustered":   { "type": "integer", "minimum": 0 },
321                        "abstractions_built":   { "type": "integer", "minimum": 0, "description": "Always 0 in v0.2.0 builds without a real LlmClient." },
322                        "triples_built":        { "type": "integer", "minimum": 0 },
323                        "contradictions_found": { "type": "integer", "minimum": 0, "description": "Reserved for Y.4 (contradiction detection). Always 0 today." }
324                    }
325                },
326                "EpisodeRecord": {
327                    "type": "object",
328                    "description":
329                        "Inspect response: full episode record. Fields are stable across v0.1 but not \
330                         exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
331                         Treat as a forward-compatible JSON object.",
332                    "additionalProperties": true
333                },
334                "ApiError": {
335                    "type": "object",
336                    "required": ["error", "status"],
337                    "properties": {
338                        "error": { "type": "string" },
339                        "status": { "type": "integer", "minimum": 400, "maximum": 599 }
340                    }
341                }
342            }
343        },
344        "paths": {
345            "/health": {
346                "get": {
347                    "summary": "Liveness probe",
348                    "description": "Returns plain text `ok`. Always unauthenticated.",
349                    "responses": {
350                        "200": {
351                            "description": "Server is up.",
352                            "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
353                        }
354                    }
355                }
356            },
357            "/openapi.json": {
358                "get": {
359                    "summary": "Self-describing OpenAPI 3.1 spec",
360                    "description": "Returns this document. Always unauthenticated.",
361                    "responses": {
362                        "200": {
363                            "description": "OpenAPI 3.1 document.",
364                            "content": { "application/json": { "schema": { "type": "object" } } }
365                        }
366                    }
367                }
368            },
369            "/memory": {
370                "post": {
371                    "summary": "Remember (store an episode)",
372                    "description": "Equivalent to MCP tool `memory.remember`.",
373                    "security": [{ "bearerAuth": [] }, {}],
374                    "requestBody": {
375                        "required": true,
376                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
377                    },
378                    "responses": {
379                        "200": {
380                            "description": "Memory stored; returns the new MemoryId.",
381                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
382                        },
383                        "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
384                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
385                    }
386                }
387            },
388            "/memory/search": {
389                "post": {
390                    "summary": "Recall (vector search)",
391                    "description": "Equivalent to MCP tool `memory.recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
392                    "security": [{ "bearerAuth": [] }, {}],
393                    "requestBody": {
394                        "required": true,
395                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
396                    },
397                    "responses": {
398                        "200": {
399                            "description": "Search results.",
400                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
401                        },
402                        "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
403                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
404                    }
405                }
406            },
407            "/memory/consolidate": {
408                "post": {
409                    "summary": "Run a consolidation pass (clustering + abstraction)",
410                    "description":
411                        "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
412                         on the server, also runs the REM-equivalent abstraction pass that populates \
413                         `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
414                         window). Equivalent to the `solo consolidate` CLI.",
415                    "security": [{ "bearerAuth": [] }, {}],
416                    "requestBody": {
417                        "required": false,
418                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
419                    },
420                    "responses": {
421                        "200": {
422                            "description": "Consolidation complete; report counts the work done.",
423                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
424                        },
425                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
426                    }
427                }
428            },
429            "/memory/{id}": {
430                "get": {
431                    "summary": "Inspect a memory by ID",
432                    "description": "Equivalent to MCP tool `memory.inspect`.",
433                    "security": [{ "bearerAuth": [] }, {}],
434                    "parameters": [{
435                        "name": "id",
436                        "in": "path",
437                        "required": true,
438                        "schema": { "type": "string", "format": "uuid" },
439                        "description": "MemoryId (UUID v7)."
440                    }],
441                    "responses": {
442                        "200": {
443                            "description": "Episode record.",
444                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
445                        },
446                        "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
447                        "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
448                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
449                    }
450                },
451                "delete": {
452                    "summary": "Forget (soft-delete) a memory by ID",
453                    "description":
454                        "Equivalent to MCP tool `memory.forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
455                         and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
456                         re-running `solo reembed` after this does NOT restore visibility.",
457                    "security": [{ "bearerAuth": [] }, {}],
458                    "parameters": [
459                        { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
460                        { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
461                    ],
462                    "responses": {
463                        "204": { "description": "Forgotten (or already forgotten — idempotent)." },
464                        "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
465                        "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
466                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
467                    }
468                }
469            }
470        }
471    })
472}
473
474// ---------------------------------------------------------------------------
475// Handlers
476// ---------------------------------------------------------------------------
477
478#[derive(Debug, Deserialize)]
479struct RememberBody {
480    content: String,
481    #[serde(default)]
482    source_type: Option<String>,
483    #[serde(default)]
484    source_id: Option<String>,
485}
486
487#[derive(Debug, Serialize)]
488struct RememberResponse {
489    memory_id: String,
490}
491
492async fn remember_handler(
493    State(s): State<SoloHttpState>,
494    Json(body): Json<RememberBody>,
495) -> Result<Json<RememberResponse>, ApiError> {
496    let content = body.content.trim_end().to_string();
497    if content.is_empty() {
498        return Err(ApiError::bad_request("content must not be empty"));
499    }
500    let embedding = s.embedder.embed(&content).await.map_err(ApiError::from)?;
501    let episode = Episode {
502        memory_id: MemoryId::new(),
503        ts_ms: chrono::Utc::now().timestamp_millis(),
504        source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
505        source_id: body.source_id,
506        content,
507        encoding_context: EncodingContext::default(),
508        provenance: None,
509        confidence: Confidence::new(0.9).unwrap(),
510        strength: 0.5,
511        salience: 0.5,
512        tier: Tier::Hot,
513    };
514    let mid = s.write.remember(episode, embedding).await.map_err(ApiError::from)?;
515    Ok(Json(RememberResponse {
516        memory_id: mid.to_string(),
517    }))
518}
519
520#[derive(Debug, Deserialize)]
521struct RecallBody {
522    query: String,
523    #[serde(default = "default_limit")]
524    limit: usize,
525}
526
527fn default_limit() -> usize {
528    5
529}
530
531async fn recall_handler(
532    State(s): State<SoloHttpState>,
533    Json(body): Json<RecallBody>,
534) -> Result<Json<solo_query::RecallResult>, ApiError> {
535    // solo_query::run_recall handles empty-query rejection (returns
536    // InvalidInput → ApiError::bad_request(400)) and clamps limit
537    // upstream of the embedder call.
538    let result = solo_query::run_recall(
539        &s.embedder,
540        &s.hnsw,
541        &s.pool,
542        &body.query,
543        body.limit,
544    )
545    .await
546    .map_err(ApiError::from)?;
547    Ok(Json(result))
548}
549
550async fn inspect_handler(
551    State(s): State<SoloHttpState>,
552    Path(id): Path<String>,
553) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
554    let mid = MemoryId::from_str(&id)
555        .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
556    let row = solo_query::inspect_one(&s.pool, mid)
557        .await
558        .map_err(ApiError::from)?;
559    Ok(Json(row))
560}
561
562#[derive(Debug, Deserialize)]
563struct ForgetQuery {
564    #[serde(default)]
565    reason: Option<String>,
566}
567
568async fn forget_handler(
569    State(s): State<SoloHttpState>,
570    Path(id): Path<String>,
571    Query(q): Query<ForgetQuery>,
572) -> Result<StatusCode, ApiError> {
573    let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
574    let reason = q.reason.unwrap_or_else(|| "http".into());
575    s.write.forget(mid, reason).await.map_err(ApiError::from)?;
576    Ok(StatusCode::NO_CONTENT)
577}
578
579async fn consolidate_handler(
580    State(s): State<SoloHttpState>,
581    body: axum::body::Bytes,
582) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
583    // Empty body = default scope (unbounded window). We parse via
584    // `Bytes` rather than `Option<Json<T>>` because axum's `Json`
585    // extractor 400s on an empty body when Content-Type is JSON
586    // (it can't deserialize zero bytes as `T`), and the `Option`
587    // wrapper doesn't reliably degrade that failure to `None`.
588    let scope = if body.is_empty() {
589        solo_storage::ConsolidationScope::default()
590    } else {
591        serde_json::from_slice(&body)
592            .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
593    };
594    let report = s.write.consolidate(scope).await.map_err(ApiError::from)?;
595    Ok(Json(report))
596}
597
598// ---------------------------------------------------------------------------
599// Error mapping
600// ---------------------------------------------------------------------------
601
602#[derive(Debug)]
603pub struct ApiError {
604    status: StatusCode,
605    message: String,
606}
607
608impl ApiError {
609    fn bad_request(msg: impl Into<String>) -> Self {
610        Self {
611            status: StatusCode::BAD_REQUEST,
612            message: msg.into(),
613        }
614    }
615    fn not_found(msg: impl Into<String>) -> Self {
616        Self {
617            status: StatusCode::NOT_FOUND,
618            message: msg.into(),
619        }
620    }
621    fn internal(msg: impl Into<String>) -> Self {
622        Self {
623            status: StatusCode::INTERNAL_SERVER_ERROR,
624            message: msg.into(),
625        }
626    }
627}
628
629impl From<solo_core::Error> for ApiError {
630    fn from(e: solo_core::Error) -> Self {
631        use solo_core::Error;
632        match e {
633            Error::NotFound(msg) => ApiError::not_found(msg),
634            Error::InvalidInput(msg) => ApiError::bad_request(msg),
635            Error::Conflict(msg) => Self {
636                status: StatusCode::CONFLICT,
637                message: msg,
638            },
639            other => ApiError::internal(other.to_string()),
640        }
641    }
642}
643
644impl IntoResponse for ApiError {
645    fn into_response(self) -> Response {
646        let body = serde_json::json!({
647            "error": self.message,
648            "status": self.status.as_u16(),
649        });
650        (self.status, Json(body)).into_response()
651    }
652}
653
654// SQL helper for recall used to live here; consolidated into
655// solo_query::recall.
656
657#[cfg(test)]
658mod handler_tests {
659    //! In-process integration tests for the HTTP handler surface. We
660    //! drive the axum Router directly via `tower::ServiceExt::oneshot`
661    //! — no real TCP listener needed. Same `Harness`-shape as the MCP
662    //! tests: real WriterActor + ReaderPool + StubEmbedder + StubVectorIndex.
663    //!
664    //! Tests live inline in this module rather than in a `tests/` dir
665    //! because external integration-test exes triggered Windows UAC
666    //! ERROR_ELEVATION_REQUIRED on the dev machine.
667    use super::*;
668    use axum::body::Body;
669    use axum::http::{Request, StatusCode};
670    use http_body_util::BodyExt;
671    use serde_json::{Value, json};
672    use solo_core::VectorIndex as _;
673    use solo_storage::test_support::StubVectorIndex;
674    use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
675    use std::sync::Arc as StdArc;
676    use tower::ServiceExt;
677
678    struct Harness {
679        router: axum::Router,
680        _tmp: tempfile::TempDir,
681        write_handle_extra: Option<solo_storage::WriteHandle>,
682        join: Option<std::thread::JoinHandle<()>>,
683    }
684
685    impl Harness {
686        fn new(runtime: &tokio::runtime::Runtime) -> Self {
687            Self::new_with_auth(runtime, None)
688        }
689
690        fn new_with_auth(
691            runtime: &tokio::runtime::Runtime,
692            bearer_token: Option<String>,
693        ) -> Self {
694            use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
695
696            let tmp = tempfile::TempDir::new().unwrap();
697            let dim = 16usize;
698            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
699            let embedder: StdArc<dyn solo_core::Embedder> =
700                StdArc::new(StubEmbedder::new("stub", "v1", dim));
701            let path = tmp.path().join("test.db");
702
703            // Register an embedder_id so `handle_consolidate` (and any
704            // other path that filters on `embeddings.embedder_id`) sees
705            // a production-shaped writer. Existing handler tests pass
706            // unchanged because the writer just additionally INSERTs
707            // an `embeddings` row per remember — no assertions look at
708            // that table.
709            let embedder_id = {
710                let conn = solo_storage::test_support::open_test_db_at(&path);
711                get_or_insert_embedder_id(
712                    &conn,
713                    &EmbedderIdentity {
714                        name: "stub".into(),
715                        version: "v1".into(),
716                        dim: dim as u32,
717                        dtype: "f32".into(),
718                    },
719                )
720                .unwrap()
721            };
722
723            let conn = solo_storage::test_support::open_test_db_at(&path);
724            let WriterSpawn { handle, join } = WriterActor::spawn_full(
725                conn,
726                hnsw.clone(),
727                tmp.path().to_path_buf(),
728                embedder_id,
729            );
730            let pool: ReaderPool =
731                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
732            let state = SoloHttpState {
733                write: handle.clone(),
734                pool,
735                embedder,
736                hnsw,
737            };
738            let router = router_with_auth(state, bearer_token);
739            Harness {
740                router,
741                _tmp: tmp,
742                write_handle_extra: Some(handle),
743                join: Some(join),
744            }
745        }
746
747        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
748            let join = self.join.take();
749            let extra = self.write_handle_extra.take();
750            runtime.block_on(async move {
751                drop(extra);
752                drop(self.router); // drops state → drops pool inside runtime ctx
753                drop(self._tmp);
754                if let Some(join) = join {
755                    let (tx, rx) = std::sync::mpsc::channel();
756                    std::thread::spawn(move || {
757                        let _ = tx.send(join.join());
758                    });
759                    tokio::task::spawn_blocking(move || {
760                        rx.recv_timeout(std::time::Duration::from_secs(5))
761                    })
762                    .await
763                    .expect("blocking task")
764                    .expect("writer thread did not exit within 5s")
765                    .expect("writer thread panicked");
766                }
767            });
768        }
769    }
770
771    fn rt() -> tokio::runtime::Runtime {
772        tokio::runtime::Builder::new_multi_thread()
773            .worker_threads(2)
774            .enable_all()
775            .build()
776            .unwrap()
777    }
778
779    /// Issue one HTTP request through the router and capture status +
780    /// JSON body. `body` may be `None` for GET/DELETE; `auth` adds an
781    /// `Authorization` header value verbatim (e.g. `"Bearer xyz"`).
782    async fn call(
783        router: axum::Router,
784        method: &str,
785        uri: &str,
786        body: Option<Value>,
787    ) -> (StatusCode, Value) {
788        call_with_auth(router, method, uri, body, None).await
789    }
790
791    async fn call_with_auth(
792        router: axum::Router,
793        method: &str,
794        uri: &str,
795        body: Option<Value>,
796        auth: Option<&str>,
797    ) -> (StatusCode, Value) {
798        let mut req_builder = Request::builder()
799            .method(method)
800            .uri(uri)
801            .header("content-type", "application/json");
802        if let Some(a) = auth {
803            req_builder = req_builder.header("authorization", a);
804        }
805        let req = if let Some(b) = body {
806            let bytes = serde_json::to_vec(&b).unwrap();
807            req_builder.body(Body::from(bytes)).unwrap()
808        } else {
809            req_builder = req_builder.header("content-length", "0");
810            req_builder.body(Body::empty()).unwrap()
811        };
812        let resp = router.oneshot(req).await.expect("oneshot");
813        let status = resp.status();
814        let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
815        let v: Value = if body_bytes.is_empty() {
816            Value::Null
817        } else {
818            serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
819        };
820        (status, v)
821    }
822
823    #[test]
824    fn health_returns_ok() {
825        let runtime = rt();
826        let h = Harness::new(&runtime);
827        let r = h.router.clone();
828        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
829        assert_eq!(status, StatusCode::OK);
830        h.shutdown(&runtime);
831    }
832
833    /// `GET /openapi.json` returns a parseable OpenAPI 3.x document with
834    /// the four `memory.*` endpoints + their request/response schemas.
835    /// Acts as a drift detector: if a future commit adds/removes a route
836    /// without updating `openapi_spec`, this test fails loudly.
837    #[test]
838    fn openapi_json_describes_all_endpoints() {
839        let runtime = rt();
840        let h = Harness::new(&runtime);
841        let r = h.router.clone();
842        let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
843        assert_eq!(status, StatusCode::OK);
844        assert!(spec.is_object(), "openapi.json must be a JSON object");
845
846        // Top-level shape per OpenAPI 3.1.
847        assert!(
848            spec.get("openapi")
849                .and_then(|v| v.as_str())
850                .is_some_and(|s| s.starts_with("3.")),
851            "missing or wrong openapi version: {spec}"
852        );
853        assert!(spec.pointer("/info/title").is_some());
854        assert!(spec.pointer("/info/version").is_some());
855
856        // Every route the router serves must be documented.
857        let paths = spec
858            .get("paths")
859            .and_then(|v| v.as_object())
860            .expect("paths must be an object");
861        for expected in [
862            "/health",
863            "/openapi.json",
864            "/memory",
865            "/memory/search",
866            "/memory/consolidate",
867            "/memory/{id}",
868        ] {
869            assert!(
870                paths.contains_key(expected),
871                "openapi paths missing {expected}: {paths:?}"
872            );
873        }
874
875        // Method coverage on /memory/{id}: must document both GET (inspect)
876        // and DELETE (forget).
877        let memid = paths.get("/memory/{id}").expect("memory/{id}");
878        assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
879        assert!(
880            memid.get("delete").is_some(),
881            "DELETE /memory/{{id}} undocumented"
882        );
883
884        // Component schemas referenced from paths must be defined.
885        for schema_name in [
886            "RememberRequest",
887            "RememberResponse",
888            "RecallRequest",
889            "RecallResult",
890            "EpisodeRecord",
891            "ApiError",
892            "ConsolidationScope",
893            "ConsolidationReport",
894        ] {
895            let ptr = format!("/components/schemas/{schema_name}");
896            assert!(
897                spec.pointer(&ptr).is_some(),
898                "component schema {schema_name} missing"
899            );
900        }
901
902        // bearerAuth security scheme is declared (LAN deployments need it).
903        assert!(
904            spec.pointer("/components/securitySchemes/bearerAuth")
905                .is_some(),
906            "bearerAuth security scheme missing"
907        );
908
909        h.shutdown(&runtime);
910    }
911
912    /// `/openapi.json` must remain unauthenticated even when bearer auth
913    /// is enabled — the spec describes the API shape, not secrets, and
914    /// codegen tooling shouldn't need a credential to fetch it.
915    #[test]
916    fn openapi_json_is_exempt_from_bearer_auth() {
917        let runtime = rt();
918        let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
919        let r = h.router.clone();
920        // No Authorization header → still 200 for /openapi.json.
921        let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
922        assert_eq!(status, StatusCode::OK);
923        h.shutdown(&runtime);
924    }
925
926    #[test]
927    fn remember_returns_memory_id() {
928        let runtime = rt();
929        let h = Harness::new(&runtime);
930        let r = h.router.clone();
931        let (status, body) = runtime.block_on(call(
932            r,
933            "POST",
934            "/memory",
935            Some(json!({ "content": "http harness test" })),
936        ));
937        assert_eq!(status, StatusCode::OK);
938        let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
939        assert_eq!(mid.len(), 36, "uuid length");
940        h.shutdown(&runtime);
941    }
942
943    #[test]
944    fn empty_content_returns_400() {
945        let runtime = rt();
946        let h = Harness::new(&runtime);
947        let r = h.router.clone();
948        let (status, body) =
949            runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
950        assert_eq!(status, StatusCode::BAD_REQUEST);
951        assert!(
952            body.get("error")
953                .and_then(|e| e.as_str())
954                .map(|s| s.contains("must not be empty"))
955                .unwrap_or(false),
956            "got: {body}"
957        );
958        h.shutdown(&runtime);
959    }
960
961    #[test]
962    fn empty_query_returns_400() {
963        let runtime = rt();
964        let h = Harness::new(&runtime);
965        let r = h.router.clone();
966        let (status, body) = runtime.block_on(call(
967            r,
968            "POST",
969            "/memory/search",
970            Some(json!({ "query": "" })),
971        ));
972        assert_eq!(status, StatusCode::BAD_REQUEST);
973        assert!(
974            body.get("error")
975                .and_then(|e| e.as_str())
976                .map(|s| s.contains("must not be empty"))
977                .unwrap_or(false),
978            "got: {body}"
979        );
980        h.shutdown(&runtime);
981    }
982
983    #[test]
984    fn inspect_unknown_returns_404() {
985        let runtime = rt();
986        let h = Harness::new(&runtime);
987        let r = h.router.clone();
988        let (status, body) = runtime.block_on(call(
989            r,
990            "GET",
991            "/memory/00000000-0000-7000-8000-000000000000",
992            None,
993        ));
994        assert_eq!(status, StatusCode::NOT_FOUND);
995        assert!(body.get("error").is_some(), "got: {body}");
996        h.shutdown(&runtime);
997    }
998
999    #[test]
1000    fn inspect_invalid_id_returns_400() {
1001        let runtime = rt();
1002        let h = Harness::new(&runtime);
1003        let r = h.router.clone();
1004        let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1005        assert_eq!(status, StatusCode::BAD_REQUEST);
1006        h.shutdown(&runtime);
1007    }
1008
1009    #[test]
1010    fn forget_unknown_returns_404() {
1011        let runtime = rt();
1012        let h = Harness::new(&runtime);
1013        let r = h.router.clone();
1014        let (status, _body) = runtime.block_on(call(
1015            r,
1016            "DELETE",
1017            "/memory/00000000-0000-7000-8000-000000000000",
1018            None,
1019        ));
1020        assert_eq!(status, StatusCode::NOT_FOUND);
1021        h.shutdown(&runtime);
1022    }
1023
1024    /// `POST /memory/consolidate` runs the cluster pass and returns
1025    /// the report as JSON. With an empty body, `ConsolidationScope`
1026    /// defaults to unbounded; with a non-empty body, the
1027    /// `window_days` field is honored. The Harness's writer is
1028    /// spawned without a Steward, so `abstractions_built` stays 0
1029    /// even when `clusters_built` is nonzero — same posture as the
1030    /// daemon today.
1031    #[test]
1032    fn consolidate_endpoint_returns_report() {
1033        let runtime = rt();
1034        let h = Harness::new(&runtime);
1035        let r = h.router.clone();
1036        runtime.block_on(async move {
1037            // Empty DB → all-zero report; structural assertion only.
1038            let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1039            assert_eq!(status, StatusCode::OK);
1040            for field in [
1041                "episodes_seen",
1042                "clusters_built",
1043                "episodes_clustered",
1044                "abstractions_built",
1045                "triples_built",
1046                "contradictions_found",
1047            ] {
1048                assert!(
1049                    body.get(field).and_then(|v| v.as_u64()).is_some(),
1050                    "missing field {field}: {body}"
1051                );
1052            }
1053            assert_eq!(body["episodes_seen"], 0);
1054            assert_eq!(body["clusters_built"], 0);
1055
1056            // Non-empty body with window_days → still 200; unmistakable
1057            // shape round-trips through ConsolidationScope's serde.
1058            let (status2, _body2) = call(
1059                r,
1060                "POST",
1061                "/memory/consolidate",
1062                Some(json!({ "window_days": 7 })),
1063            )
1064            .await;
1065            assert_eq!(status2, StatusCode::OK);
1066        });
1067        h.shutdown(&runtime);
1068    }
1069
1070    #[test]
1071    fn auth_required_routes_reject_missing_token() {
1072        let runtime = rt();
1073        let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1074        let r = h.router.clone();
1075        runtime.block_on(async move {
1076            // No Authorization header → 401.
1077            let (status, _body) = call(
1078                r.clone(),
1079                "POST",
1080                "/memory",
1081                Some(json!({ "content": "x" })),
1082            )
1083            .await;
1084            assert_eq!(status, StatusCode::UNAUTHORIZED);
1085
1086            // Wrong token → 401.
1087            let (status, _body) = call_with_auth(
1088                r.clone(),
1089                "POST",
1090                "/memory",
1091                Some(json!({ "content": "x" })),
1092                Some("Bearer wrong-token"),
1093            )
1094            .await;
1095            assert_eq!(status, StatusCode::UNAUTHORIZED);
1096
1097            // Correct token → handler runs (200).
1098            let (status, body) = call_with_auth(
1099                r.clone(),
1100                "POST",
1101                "/memory",
1102                Some(json!({ "content": "authed" })),
1103                Some("Bearer secret-xyz"),
1104            )
1105            .await;
1106            assert_eq!(status, StatusCode::OK);
1107            assert!(body.get("memory_id").is_some());
1108        });
1109        h.shutdown(&runtime);
1110    }
1111
1112    #[test]
1113    fn health_endpoint_does_not_require_auth() {
1114        let runtime = rt();
1115        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1116        let r = h.router.clone();
1117        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1118        // Liveness probes should work without credentials.
1119        assert_eq!(status, StatusCode::OK);
1120        h.shutdown(&runtime);
1121    }
1122
1123    #[test]
1124    fn auth_response_includes_www_authenticate_header() {
1125        // Verify the WWW-Authenticate hint that lets a well-behaved
1126        // client know it's a bearer-auth scheme. We check via raw
1127        // request → response (oneshot returns Response, but our
1128        // call() helper drops the headers; build the request manually).
1129        let runtime = rt();
1130        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1131        let r = h.router.clone();
1132        runtime.block_on(async move {
1133            let req = Request::builder()
1134                .method("POST")
1135                .uri("/memory")
1136                .header("content-type", "application/json")
1137                .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
1138                .unwrap();
1139            let resp = r.oneshot(req).await.unwrap();
1140            assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1141            let www = resp
1142                .headers()
1143                .get("www-authenticate")
1144                .and_then(|v| v.to_str().ok())
1145                .unwrap_or("");
1146            assert!(
1147                www.starts_with("Bearer"),
1148                "expected WWW-Authenticate: Bearer..., got: {www}"
1149            );
1150        });
1151        h.shutdown(&runtime);
1152    }
1153
1154    #[test]
1155    fn full_remember_recall_inspect_forget_round_trip() {
1156        let runtime = rt();
1157        let h = Harness::new(&runtime);
1158        let r = h.router.clone();
1159        runtime.block_on(async move {
1160            // POST /memory
1161            let (status, body) = call(
1162                r.clone(),
1163                "POST",
1164                "/memory",
1165                Some(json!({ "content": "round-trip content" })),
1166            )
1167            .await;
1168            assert_eq!(status, StatusCode::OK);
1169            let mid = body
1170                .get("memory_id")
1171                .and_then(|v| v.as_str())
1172                .unwrap()
1173                .to_string();
1174
1175            // POST /memory/search — exact-match (StubEmbedder) returns the row.
1176            let (status, body) = call(
1177                r.clone(),
1178                "POST",
1179                "/memory/search",
1180                Some(json!({ "query": "round-trip content", "limit": 5 })),
1181            )
1182            .await;
1183            assert_eq!(status, StatusCode::OK);
1184            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1185            assert!(
1186                hits.iter()
1187                    .any(|h| h.get("content").and_then(|c| c.as_str())
1188                        == Some("round-trip content")),
1189                "expected hit with content; got: {body}"
1190            );
1191
1192            // GET /memory/{id}
1193            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1194            assert_eq!(status, StatusCode::OK);
1195            assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
1196
1197            // DELETE /memory/{id}
1198            let (status, _body) =
1199                call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1200            assert_eq!(status, StatusCode::NO_CONTENT);
1201
1202            // GET again — still readable but status='forgotten'
1203            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1204            assert_eq!(status, StatusCode::OK);
1205            assert_eq!(
1206                body.get("status").and_then(|v| v.as_str()),
1207                Some("forgotten")
1208            );
1209
1210            // POST /memory/search — forgotten row excluded.
1211            let (status, body) = call(
1212                r.clone(),
1213                "POST",
1214                "/memory/search",
1215                Some(json!({ "query": "round-trip content", "limit": 5 })),
1216            )
1217            .await;
1218            assert_eq!(status, StatusCode::OK);
1219            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1220            assert!(
1221                hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
1222                    != Some(mid.as_str())),
1223                "forgotten row should be excluded from recall: {body}"
1224            );
1225        });
1226        h.shutdown(&runtime);
1227    }
1228}
1229
1230#[cfg(test)]
1231mod cors_tests {
1232    use super::is_localhost_origin;
1233
1234    #[test]
1235    fn accepts_canonical_localhost_origins() {
1236        assert!(is_localhost_origin("http://localhost"));
1237        assert!(is_localhost_origin("http://localhost:3000"));
1238        assert!(is_localhost_origin("https://localhost:8443"));
1239        assert!(is_localhost_origin("http://127.0.0.1"));
1240        assert!(is_localhost_origin("http://127.0.0.1:5173"));
1241        assert!(is_localhost_origin("http://[::1]"));
1242        assert!(is_localhost_origin("http://[::1]:8080"));
1243    }
1244
1245    #[test]
1246    fn rejects_remote_origins() {
1247        assert!(!is_localhost_origin("http://example.com"));
1248        assert!(!is_localhost_origin("https://malicious.example"));
1249        assert!(!is_localhost_origin("http://192.168.1.5"));
1250        assert!(!is_localhost_origin("http://10.0.0.1"));
1251    }
1252
1253    #[test]
1254    fn rejects_dns_rebinding_tricks() {
1255        // nip.io and friends — DNS that resolves to 127.0.0.1 but the
1256        // Origin header carries the public-DNS name. Rejecting these
1257        // closes the rebinding-via-Origin gap.
1258        assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
1259        assert!(!is_localhost_origin("http://localhost.evil.com"));
1260        assert!(!is_localhost_origin("http://evil.localhost"));
1261    }
1262
1263    #[test]
1264    fn rejects_non_http_schemes() {
1265        assert!(!is_localhost_origin("file:///"));
1266        assert!(!is_localhost_origin("ws://localhost:3000"));
1267        assert!(!is_localhost_origin("javascript:alert(1)"));
1268    }
1269
1270    #[test]
1271    fn rejects_malformed() {
1272        assert!(!is_localhost_origin(""));
1273        assert!(!is_localhost_origin("localhost"));
1274        assert!(!is_localhost_origin("//localhost"));
1275    }
1276}
1277