1use 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 pub source_db_path: std::path::PathBuf,
54}
55
56pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
66 let cors = build_cors_layer();
67 let public = Router::new()
75 .route("/health", get(|| async { "ok" }))
76 .route("/openapi.json", get(openapi_handler));
77
78 let mut authed = Router::new()
79 .route("/memory", post(remember_handler))
80 .route("/memory/search", post(recall_handler))
81 .route("/memory/consolidate", post(consolidate_handler))
82 .route("/memory/{id}", get(inspect_handler).delete(forget_handler))
83 .route("/backup", post(backup_handler))
84 .with_state(state);
85 if let Some(token) = bearer_token {
86 authed = authed.layer(ValidateRequestHeaderLayer::custom(BearerToken::new(token)));
90 }
91
92 public
93 .merge(authed)
94 .layer(cors)
95 .layer(TraceLayer::new_for_http())
96}
97
98pub fn router(state: SoloHttpState) -> Router {
100 router_with_auth(state, None)
101}
102
103fn build_cors_layer() -> CorsLayer {
104 CorsLayer::new()
118 .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
119 origin
120 .to_str()
121 .map(is_localhost_origin)
122 .unwrap_or(false)
123 }))
124 .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
125 .allow_headers([
126 axum::http::header::CONTENT_TYPE,
127 axum::http::header::AUTHORIZATION,
128 ])
129}
130
131#[derive(Clone)]
139struct BearerToken {
140 expected: HeaderValue,
141}
142
143impl BearerToken {
144 fn new(token: String) -> Self {
145 let expected = HeaderValue::try_from(format!("Bearer {token}"))
146 .expect("bearer token must be a valid HTTP header value");
147 Self { expected }
148 }
149}
150
151impl<B> ValidateRequest<B> for BearerToken {
152 type ResponseBody = axum::body::Body;
153
154 fn validate(
155 &mut self,
156 request: &mut axum::http::Request<B>,
157 ) -> Result<(), axum::http::Response<Self::ResponseBody>> {
158 let got = request.headers().get(axum::http::header::AUTHORIZATION);
159 match got {
160 Some(value) if value == &self.expected => Ok(()),
161 _ => {
162 let mut resp = axum::http::Response::new(axum::body::Body::empty());
163 *resp.status_mut() = StatusCode::UNAUTHORIZED;
164 resp.headers_mut().insert(
165 axum::http::header::WWW_AUTHENTICATE,
166 HeaderValue::from_static(r#"Bearer realm="solo""#),
167 );
168 Err(resp)
169 }
170 }
171 }
172}
173
174fn is_localhost_origin(origin: &str) -> bool {
178 let rest = origin
179 .strip_prefix("http://")
180 .or_else(|| origin.strip_prefix("https://"));
181 let host = match rest {
182 Some(r) => r,
183 None => return false,
184 };
185 let host = host.split('/').next().unwrap_or(host);
187 let host = if let Some(idx) = host.rfind(':') {
189 if host.starts_with('[') {
191 host.find(']')
193 .map(|i| &host[..=i])
194 .unwrap_or(host)
195 } else {
196 &host[..idx]
197 }
198 } else {
199 host
200 };
201 matches!(host, "localhost" | "127.0.0.1" | "[::1]")
202}
203
204pub async fn serve_http(
210 addr: SocketAddr,
211 state: SoloHttpState,
212 bearer_token: Option<String>,
213 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
214) -> std::io::Result<()> {
215 let auth_kind = if bearer_token.is_some() {
216 "bearer"
217 } else {
218 "none"
219 };
220 let app = router_with_auth(state, bearer_token);
221 let listener = tokio::net::TcpListener::bind(addr).await?;
222 tracing::info!(%addr, auth = auth_kind, "solo http: listening");
223 axum::serve(listener, app)
224 .with_graceful_shutdown(shutdown)
225 .await
226}
227
228async fn openapi_handler() -> Json<serde_json::Value> {
242 Json(openapi_spec())
243}
244
245pub fn openapi_spec() -> serde_json::Value {
249 serde_json::json!({
250 "openapi": "3.1.0",
251 "info": {
252 "title": "Solo HTTP API",
253 "description":
254 "Local-first personal memory daemon. The HTTP transport \
255 mirrors the four MCP tools (memory.remember / recall / \
256 inspect / forget). Default deployment is loopback-only \
257 (127.0.0.1); LAN-bound deployments require a bearer \
258 token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
259 "version": env!("CARGO_PKG_VERSION"),
260 "license": { "name": "Apache-2.0" }
261 },
262 "servers": [
263 { "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
264 ],
265 "components": {
266 "securitySchemes": {
267 "bearerAuth": {
268 "type": "http",
269 "scheme": "bearer",
270 "description":
271 "Bearer-token auth. Required only on LAN-bound deployments \
272 (`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
273 the default `127.0.0.1` deployment is unauthenticated. \
274 `GET /health` and `GET /openapi.json` are exempt from auth even \
275 on bearer-protected instances."
276 }
277 },
278 "schemas": {
279 "RememberRequest": {
280 "type": "object",
281 "required": ["content"],
282 "properties": {
283 "content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
284 "source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
285 "source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
286 },
287 "additionalProperties": false
288 },
289 "RememberResponse": {
290 "type": "object",
291 "required": ["memory_id"],
292 "properties": {
293 "memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
294 }
295 },
296 "RecallRequest": {
297 "type": "object",
298 "required": ["query"],
299 "properties": {
300 "query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
301 "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
302 },
303 "additionalProperties": false
304 },
305 "RecallResult": {
306 "type": "object",
307 "description":
308 "Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
309 see `solo_query::RecallResult` in the source for the canonical shape. \
310 Treat as a forward-compatible JSON object.",
311 "additionalProperties": true
312 },
313 "ConsolidationScope": {
314 "type": "object",
315 "description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
316 "properties": {
317 "window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." },
318 "force_merge": { "type": "boolean", "default": false, "description": "Run the existing-vs-existing merge + abstraction-regen passes even with zero unclustered candidates. Drift catch-up on quiet corpora. Added in 0.3.1." }
319 },
320 "additionalProperties": false
321 },
322 "ConsolidationReport": {
323 "type": "object",
324 "required": [
325 "episodes_seen", "clusters_built", "clusters_merged",
326 "clusters_absorbed", "existing_clusters_merged",
327 "episodes_clustered", "abstractions_built",
328 "abstractions_regenerated", "triples_built",
329 "contradictions_found"
330 ],
331 "properties": {
332 "episodes_seen": { "type": "integer", "minimum": 0 },
333 "clusters_built": { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
334 "clusters_merged": { "type": "integer", "minimum": 0, "description": "In-run merge: clusters absorbed into a sibling within this consolidate run (cross-UTC-bucket case). Counts losers." },
335 "clusters_absorbed": { "type": "integer", "minimum": 0, "description": "Cross-run absorb: freshly-built clusters folded into a pre-existing DB cluster with a similar centroid. Counts new-side clusters." },
336 "existing_clusters_merged": { "type": "integer", "minimum": 0, "description": "Existing-vs-existing merge: pre-existing DB clusters that drifted toward each other and now coalesce. Counts losers." },
337 "episodes_clustered": { "type": "integer", "minimum": 0 },
338 "abstractions_built": { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
339 "abstractions_regenerated": { "type": "integer", "minimum": 0, "description": "Existing clusters whose stale abstractions were dropped and rebuilt because absorb or existing-merge changed their episode set. 0 without an LlmClient." },
340 "triples_built": { "type": "integer", "minimum": 0 },
341 "contradictions_found": { "type": "integer", "minimum": 0 }
342 }
343 },
344 "EpisodeRecord": {
345 "type": "object",
346 "description":
347 "Inspect response: full episode record. Fields are stable across v0.1 but not \
348 exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
349 Treat as a forward-compatible JSON object.",
350 "additionalProperties": true
351 },
352 "ApiError": {
353 "type": "object",
354 "required": ["error", "status"],
355 "properties": {
356 "error": { "type": "string" },
357 "status": { "type": "integer", "minimum": 400, "maximum": 599 }
358 }
359 }
360 }
361 },
362 "paths": {
363 "/health": {
364 "get": {
365 "summary": "Liveness probe",
366 "description": "Returns plain text `ok`. Always unauthenticated.",
367 "responses": {
368 "200": {
369 "description": "Server is up.",
370 "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
371 }
372 }
373 }
374 },
375 "/openapi.json": {
376 "get": {
377 "summary": "Self-describing OpenAPI 3.1 spec",
378 "description": "Returns this document. Always unauthenticated.",
379 "responses": {
380 "200": {
381 "description": "OpenAPI 3.1 document.",
382 "content": { "application/json": { "schema": { "type": "object" } } }
383 }
384 }
385 }
386 },
387 "/memory": {
388 "post": {
389 "summary": "Remember (store an episode)",
390 "description": "Equivalent to MCP tool `memory.remember`.",
391 "security": [{ "bearerAuth": [] }, {}],
392 "requestBody": {
393 "required": true,
394 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
395 },
396 "responses": {
397 "200": {
398 "description": "Memory stored; returns the new MemoryId.",
399 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
400 },
401 "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
402 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
403 }
404 }
405 },
406 "/memory/search": {
407 "post": {
408 "summary": "Recall (vector search)",
409 "description": "Equivalent to MCP tool `memory.recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
410 "security": [{ "bearerAuth": [] }, {}],
411 "requestBody": {
412 "required": true,
413 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
414 },
415 "responses": {
416 "200": {
417 "description": "Search results.",
418 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
419 },
420 "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
421 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
422 }
423 }
424 },
425 "/memory/consolidate": {
426 "post": {
427 "summary": "Run a consolidation pass (clustering + abstraction)",
428 "description":
429 "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
430 on the server, also runs the REM-equivalent abstraction pass that populates \
431 `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
432 window). Equivalent to the `solo consolidate` CLI.",
433 "security": [{ "bearerAuth": [] }, {}],
434 "requestBody": {
435 "required": false,
436 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
437 },
438 "responses": {
439 "200": {
440 "description": "Consolidation complete; report counts the work done.",
441 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
442 },
443 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
444 }
445 }
446 },
447 "/backup": {
448 "post": {
449 "summary": "Online encrypted backup",
450 "description":
451 "Run an online SQLCipher backup of the live data dir to a server-side path. \
452 The destination file is encrypted with the same Argon2id-derived raw key as \
453 the source, so it restores under the same passphrase + a copy of the source's \
454 `solo.config.toml`. Hot — the backup runs against the writer's existing \
455 connection without taking the lockfile, so the daemon keeps serving reads + \
456 writes during the operation. v0.3.2+.",
457 "security": [{ "bearerAuth": [] }, {}],
458 "requestBody": {
459 "required": true,
460 "content": { "application/json": { "schema": {
461 "type": "object",
462 "properties": {
463 "to": { "type": "string", "description": "Server-side absolute path for the backup file." },
464 "force": { "type": "boolean", "description": "Overwrite an existing destination file. Default false.", "default": false }
465 },
466 "required": ["to"]
467 } } }
468 },
469 "responses": {
470 "200": {
471 "description": "Backup complete; reports the destination path + elapsed milliseconds.",
472 "content": { "application/json": { "schema": {
473 "type": "object",
474 "properties": {
475 "path": { "type": "string" },
476 "elapsed_ms": { "type": "integer", "format": "int64" }
477 }
478 } } }
479 },
480 "400": { "description": "Destination invalid, exists without force, or its parent doesn't exist." },
481 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." },
482 "500": { "description": "Backup failed (disk full, permission denied, etc.)." }
483 }
484 }
485 },
486 "/memory/{id}": {
487 "get": {
488 "summary": "Inspect a memory by ID",
489 "description": "Equivalent to MCP tool `memory.inspect`.",
490 "security": [{ "bearerAuth": [] }, {}],
491 "parameters": [{
492 "name": "id",
493 "in": "path",
494 "required": true,
495 "schema": { "type": "string", "format": "uuid" },
496 "description": "MemoryId (UUID v7)."
497 }],
498 "responses": {
499 "200": {
500 "description": "Episode record.",
501 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
502 },
503 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
504 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
505 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
506 }
507 },
508 "delete": {
509 "summary": "Forget (soft-delete) a memory by ID",
510 "description":
511 "Equivalent to MCP tool `memory.forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
512 and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
513 re-running `solo reembed` after this does NOT restore visibility.",
514 "security": [{ "bearerAuth": [] }, {}],
515 "parameters": [
516 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
517 { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
518 ],
519 "responses": {
520 "204": { "description": "Forgotten (or already forgotten — idempotent)." },
521 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
522 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
523 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
524 }
525 }
526 }
527 }
528 })
529}
530
531#[derive(Debug, Deserialize)]
536struct RememberBody {
537 content: String,
538 #[serde(default)]
539 source_type: Option<String>,
540 #[serde(default)]
541 source_id: Option<String>,
542}
543
544#[derive(Debug, Serialize)]
545struct RememberResponse {
546 memory_id: String,
547}
548
549async fn remember_handler(
550 State(s): State<SoloHttpState>,
551 Json(body): Json<RememberBody>,
552) -> Result<Json<RememberResponse>, ApiError> {
553 let content = body.content.trim_end().to_string();
554 if content.is_empty() {
555 return Err(ApiError::bad_request("content must not be empty"));
556 }
557 let embedding = s.embedder.embed(&content).await.map_err(ApiError::from)?;
558 let episode = Episode {
559 memory_id: MemoryId::new(),
560 ts_ms: chrono::Utc::now().timestamp_millis(),
561 source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
562 source_id: body.source_id,
563 content,
564 encoding_context: EncodingContext::default(),
565 provenance: None,
566 confidence: Confidence::new(0.9).unwrap(),
567 strength: 0.5,
568 salience: 0.5,
569 tier: Tier::Hot,
570 };
571 let mid = s.write.remember(episode, embedding).await.map_err(ApiError::from)?;
572 Ok(Json(RememberResponse {
573 memory_id: mid.to_string(),
574 }))
575}
576
577#[derive(Debug, Deserialize)]
578struct RecallBody {
579 query: String,
580 #[serde(default = "default_limit")]
581 limit: usize,
582}
583
584fn default_limit() -> usize {
585 5
586}
587
588async fn recall_handler(
589 State(s): State<SoloHttpState>,
590 Json(body): Json<RecallBody>,
591) -> Result<Json<solo_query::RecallResult>, ApiError> {
592 let result = solo_query::run_recall(
596 &s.embedder,
597 &s.hnsw,
598 &s.pool,
599 &body.query,
600 body.limit,
601 )
602 .await
603 .map_err(ApiError::from)?;
604 Ok(Json(result))
605}
606
607async fn inspect_handler(
608 State(s): State<SoloHttpState>,
609 Path(id): Path<String>,
610) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
611 let mid = MemoryId::from_str(&id)
612 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
613 let row = solo_query::inspect_one(&s.pool, mid)
614 .await
615 .map_err(ApiError::from)?;
616 Ok(Json(row))
617}
618
619#[derive(Debug, Deserialize)]
620struct ForgetQuery {
621 #[serde(default)]
622 reason: Option<String>,
623}
624
625async fn forget_handler(
626 State(s): State<SoloHttpState>,
627 Path(id): Path<String>,
628 Query(q): Query<ForgetQuery>,
629) -> Result<StatusCode, ApiError> {
630 let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
631 let reason = q.reason.unwrap_or_else(|| "http".into());
632 s.write.forget(mid, reason).await.map_err(ApiError::from)?;
633 Ok(StatusCode::NO_CONTENT)
634}
635
636async fn consolidate_handler(
637 State(s): State<SoloHttpState>,
638 body: axum::body::Bytes,
639) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
640 let scope = if body.is_empty() {
646 solo_storage::ConsolidationScope::default()
647 } else {
648 serde_json::from_slice(&body)
649 .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
650 };
651 let report = s.write.consolidate(scope).await.map_err(ApiError::from)?;
652 Ok(Json(report))
653}
654
655#[derive(Debug, Deserialize)]
656struct BackupBody {
657 to: String,
661 #[serde(default)]
662 force: bool,
663}
664
665#[derive(Debug, Serialize)]
666struct BackupResponse {
667 path: String,
668 elapsed_ms: u64,
669}
670
671async fn backup_handler(
672 State(s): State<SoloHttpState>,
673 Json(body): Json<BackupBody>,
674) -> Result<Json<BackupResponse>, ApiError> {
675 use std::path::PathBuf;
676
677 let dest = PathBuf::from(&body.to);
678 if dest.as_os_str().is_empty() {
679 return Err(ApiError::bad_request("`to` must not be empty"));
680 }
681 if solo_storage::paths_refer_to_same_file(&s.source_db_path, &dest) {
687 return Err(ApiError::bad_request(format!(
688 "destination {} is the same file as the source database; \
689 refusing to run (would corrupt the live database)",
690 dest.display()
691 )));
692 }
693 if dest.exists() {
694 if !body.force {
695 return Err(ApiError::bad_request(format!(
696 "destination {} exists; pass force=true to overwrite",
697 dest.display()
698 )));
699 }
700 std::fs::remove_file(&dest).map_err(|e| {
701 ApiError::internal(format!(
702 "remove existing destination {}: {e}",
703 dest.display()
704 ))
705 })?;
706 }
707 if let Some(parent) = dest.parent() {
708 if !parent.as_os_str().is_empty() && !parent.is_dir() {
709 return Err(ApiError::bad_request(format!(
710 "destination parent directory {} does not exist",
711 parent.display()
712 )));
713 }
714 }
715
716 let started = std::time::Instant::now();
717 s.write.backup(dest.clone()).await.map_err(ApiError::from)?;
718 let elapsed_ms = started.elapsed().as_millis() as u64;
719
720 Ok(Json(BackupResponse {
721 path: dest.display().to_string(),
722 elapsed_ms,
723 }))
724}
725
726#[derive(Debug)]
731pub struct ApiError {
732 status: StatusCode,
733 message: String,
734}
735
736impl ApiError {
737 fn bad_request(msg: impl Into<String>) -> Self {
738 Self {
739 status: StatusCode::BAD_REQUEST,
740 message: msg.into(),
741 }
742 }
743 fn not_found(msg: impl Into<String>) -> Self {
744 Self {
745 status: StatusCode::NOT_FOUND,
746 message: msg.into(),
747 }
748 }
749 fn internal(msg: impl Into<String>) -> Self {
750 Self {
751 status: StatusCode::INTERNAL_SERVER_ERROR,
752 message: msg.into(),
753 }
754 }
755}
756
757impl From<solo_core::Error> for ApiError {
758 fn from(e: solo_core::Error) -> Self {
759 use solo_core::Error;
760 match e {
761 Error::NotFound(msg) => ApiError::not_found(msg),
762 Error::InvalidInput(msg) => ApiError::bad_request(msg),
763 Error::Conflict(msg) => Self {
764 status: StatusCode::CONFLICT,
765 message: msg,
766 },
767 other => ApiError::internal(other.to_string()),
768 }
769 }
770}
771
772impl IntoResponse for ApiError {
773 fn into_response(self) -> Response {
774 let body = serde_json::json!({
775 "error": self.message,
776 "status": self.status.as_u16(),
777 });
778 (self.status, Json(body)).into_response()
779 }
780}
781
782#[cfg(test)]
786mod handler_tests {
787 use super::*;
796 use axum::body::Body;
797 use axum::http::{Request, StatusCode};
798 use http_body_util::BodyExt;
799 use serde_json::{Value, json};
800 use solo_core::VectorIndex as _;
801 use solo_storage::test_support::StubVectorIndex;
802 use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
803 use std::sync::Arc as StdArc;
804 use tower::ServiceExt;
805
806 struct Harness {
807 router: axum::Router,
808 _tmp: tempfile::TempDir,
809 write_handle_extra: Option<solo_storage::WriteHandle>,
810 join: Option<std::thread::JoinHandle<()>>,
811 }
812
813 impl Harness {
814 fn new(runtime: &tokio::runtime::Runtime) -> Self {
815 Self::new_with_auth(runtime, None)
816 }
817
818 fn new_with_auth(
819 runtime: &tokio::runtime::Runtime,
820 bearer_token: Option<String>,
821 ) -> Self {
822 use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
823
824 let tmp = tempfile::TempDir::new().unwrap();
825 let dim = 16usize;
826 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
827 let embedder: StdArc<dyn solo_core::Embedder> =
828 StdArc::new(StubEmbedder::new("stub", "v1", dim));
829 let path = tmp.path().join("test.db");
830
831 let embedder_id = {
838 let conn = solo_storage::test_support::open_test_db_at(&path);
839 get_or_insert_embedder_id(
840 &conn,
841 &EmbedderIdentity {
842 name: "stub".into(),
843 version: "v1".into(),
844 dim: dim as u32,
845 dtype: "f32".into(),
846 },
847 )
848 .unwrap()
849 };
850
851 let conn = solo_storage::test_support::open_test_db_at(&path);
852 let WriterSpawn { handle, join } = WriterActor::spawn_full(
853 conn,
854 hnsw.clone(),
855 tmp.path().to_path_buf(),
856 embedder_id,
857 );
858 let pool: ReaderPool =
859 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
860 let state = SoloHttpState {
861 write: handle.clone(),
862 pool,
863 embedder,
864 hnsw,
865 source_db_path: path.clone(),
866 };
867 let router = router_with_auth(state, bearer_token);
868 Harness {
869 router,
870 _tmp: tmp,
871 write_handle_extra: Some(handle),
872 join: Some(join),
873 }
874 }
875
876 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
877 let join = self.join.take();
878 let extra = self.write_handle_extra.take();
879 runtime.block_on(async move {
880 drop(extra);
881 drop(self.router); drop(self._tmp);
883 if let Some(join) = join {
884 let (tx, rx) = std::sync::mpsc::channel();
885 std::thread::spawn(move || {
886 let _ = tx.send(join.join());
887 });
888 tokio::task::spawn_blocking(move || {
889 rx.recv_timeout(std::time::Duration::from_secs(5))
890 })
891 .await
892 .expect("blocking task")
893 .expect("writer thread did not exit within 5s")
894 .expect("writer thread panicked");
895 }
896 });
897 }
898 }
899
900 fn rt() -> tokio::runtime::Runtime {
901 tokio::runtime::Builder::new_multi_thread()
902 .worker_threads(2)
903 .enable_all()
904 .build()
905 .unwrap()
906 }
907
908 async fn call(
912 router: axum::Router,
913 method: &str,
914 uri: &str,
915 body: Option<Value>,
916 ) -> (StatusCode, Value) {
917 call_with_auth(router, method, uri, body, None).await
918 }
919
920 async fn call_with_auth(
921 router: axum::Router,
922 method: &str,
923 uri: &str,
924 body: Option<Value>,
925 auth: Option<&str>,
926 ) -> (StatusCode, Value) {
927 let mut req_builder = Request::builder()
928 .method(method)
929 .uri(uri)
930 .header("content-type", "application/json");
931 if let Some(a) = auth {
932 req_builder = req_builder.header("authorization", a);
933 }
934 let req = if let Some(b) = body {
935 let bytes = serde_json::to_vec(&b).unwrap();
936 req_builder.body(Body::from(bytes)).unwrap()
937 } else {
938 req_builder = req_builder.header("content-length", "0");
939 req_builder.body(Body::empty()).unwrap()
940 };
941 let resp = router.oneshot(req).await.expect("oneshot");
942 let status = resp.status();
943 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
944 let v: Value = if body_bytes.is_empty() {
945 Value::Null
946 } else {
947 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
948 };
949 (status, v)
950 }
951
952 #[test]
953 fn health_returns_ok() {
954 let runtime = rt();
955 let h = Harness::new(&runtime);
956 let r = h.router.clone();
957 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
958 assert_eq!(status, StatusCode::OK);
959 h.shutdown(&runtime);
960 }
961
962 #[test]
967 fn openapi_json_describes_all_endpoints() {
968 let runtime = rt();
969 let h = Harness::new(&runtime);
970 let r = h.router.clone();
971 let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
972 assert_eq!(status, StatusCode::OK);
973 assert!(spec.is_object(), "openapi.json must be a JSON object");
974
975 assert!(
977 spec.get("openapi")
978 .and_then(|v| v.as_str())
979 .is_some_and(|s| s.starts_with("3.")),
980 "missing or wrong openapi version: {spec}"
981 );
982 assert!(spec.pointer("/info/title").is_some());
983 assert!(spec.pointer("/info/version").is_some());
984
985 let paths = spec
987 .get("paths")
988 .and_then(|v| v.as_object())
989 .expect("paths must be an object");
990 for expected in [
991 "/health",
992 "/openapi.json",
993 "/memory",
994 "/memory/search",
995 "/memory/consolidate",
996 "/memory/{id}",
997 ] {
998 assert!(
999 paths.contains_key(expected),
1000 "openapi paths missing {expected}: {paths:?}"
1001 );
1002 }
1003
1004 let memid = paths.get("/memory/{id}").expect("memory/{id}");
1007 assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1008 assert!(
1009 memid.get("delete").is_some(),
1010 "DELETE /memory/{{id}} undocumented"
1011 );
1012
1013 for schema_name in [
1015 "RememberRequest",
1016 "RememberResponse",
1017 "RecallRequest",
1018 "RecallResult",
1019 "EpisodeRecord",
1020 "ApiError",
1021 "ConsolidationScope",
1022 "ConsolidationReport",
1023 ] {
1024 let ptr = format!("/components/schemas/{schema_name}");
1025 assert!(
1026 spec.pointer(&ptr).is_some(),
1027 "component schema {schema_name} missing"
1028 );
1029 }
1030
1031 assert!(
1033 spec.pointer("/components/securitySchemes/bearerAuth")
1034 .is_some(),
1035 "bearerAuth security scheme missing"
1036 );
1037
1038 h.shutdown(&runtime);
1039 }
1040
1041 #[test]
1045 fn openapi_json_is_exempt_from_bearer_auth() {
1046 let runtime = rt();
1047 let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1048 let r = h.router.clone();
1049 let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1051 assert_eq!(status, StatusCode::OK);
1052 h.shutdown(&runtime);
1053 }
1054
1055 #[test]
1056 fn remember_returns_memory_id() {
1057 let runtime = rt();
1058 let h = Harness::new(&runtime);
1059 let r = h.router.clone();
1060 let (status, body) = runtime.block_on(call(
1061 r,
1062 "POST",
1063 "/memory",
1064 Some(json!({ "content": "http harness test" })),
1065 ));
1066 assert_eq!(status, StatusCode::OK);
1067 let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1068 assert_eq!(mid.len(), 36, "uuid length");
1069 h.shutdown(&runtime);
1070 }
1071
1072 #[test]
1073 fn empty_content_returns_400() {
1074 let runtime = rt();
1075 let h = Harness::new(&runtime);
1076 let r = h.router.clone();
1077 let (status, body) =
1078 runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1079 assert_eq!(status, StatusCode::BAD_REQUEST);
1080 assert!(
1081 body.get("error")
1082 .and_then(|e| e.as_str())
1083 .map(|s| s.contains("must not be empty"))
1084 .unwrap_or(false),
1085 "got: {body}"
1086 );
1087 h.shutdown(&runtime);
1088 }
1089
1090 #[test]
1091 fn empty_query_returns_400() {
1092 let runtime = rt();
1093 let h = Harness::new(&runtime);
1094 let r = h.router.clone();
1095 let (status, body) = runtime.block_on(call(
1096 r,
1097 "POST",
1098 "/memory/search",
1099 Some(json!({ "query": "" })),
1100 ));
1101 assert_eq!(status, StatusCode::BAD_REQUEST);
1102 assert!(
1103 body.get("error")
1104 .and_then(|e| e.as_str())
1105 .map(|s| s.contains("must not be empty"))
1106 .unwrap_or(false),
1107 "got: {body}"
1108 );
1109 h.shutdown(&runtime);
1110 }
1111
1112 #[test]
1113 fn inspect_unknown_returns_404() {
1114 let runtime = rt();
1115 let h = Harness::new(&runtime);
1116 let r = h.router.clone();
1117 let (status, body) = runtime.block_on(call(
1118 r,
1119 "GET",
1120 "/memory/00000000-0000-7000-8000-000000000000",
1121 None,
1122 ));
1123 assert_eq!(status, StatusCode::NOT_FOUND);
1124 assert!(body.get("error").is_some(), "got: {body}");
1125 h.shutdown(&runtime);
1126 }
1127
1128 #[test]
1129 fn inspect_invalid_id_returns_400() {
1130 let runtime = rt();
1131 let h = Harness::new(&runtime);
1132 let r = h.router.clone();
1133 let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1134 assert_eq!(status, StatusCode::BAD_REQUEST);
1135 h.shutdown(&runtime);
1136 }
1137
1138 #[test]
1139 fn forget_unknown_returns_404() {
1140 let runtime = rt();
1141 let h = Harness::new(&runtime);
1142 let r = h.router.clone();
1143 let (status, _body) = runtime.block_on(call(
1144 r,
1145 "DELETE",
1146 "/memory/00000000-0000-7000-8000-000000000000",
1147 None,
1148 ));
1149 assert_eq!(status, StatusCode::NOT_FOUND);
1150 h.shutdown(&runtime);
1151 }
1152
1153 #[test]
1161 fn consolidate_endpoint_returns_report() {
1162 let runtime = rt();
1163 let h = Harness::new(&runtime);
1164 let r = h.router.clone();
1165 runtime.block_on(async move {
1166 let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1168 assert_eq!(status, StatusCode::OK);
1169 for field in [
1170 "episodes_seen",
1171 "clusters_built",
1172 "episodes_clustered",
1173 "abstractions_built",
1174 "triples_built",
1175 "contradictions_found",
1176 ] {
1177 assert!(
1178 body.get(field).and_then(|v| v.as_u64()).is_some(),
1179 "missing field {field}: {body}"
1180 );
1181 }
1182 assert_eq!(body["episodes_seen"], 0);
1183 assert_eq!(body["clusters_built"], 0);
1184
1185 let (status2, _body2) = call(
1188 r,
1189 "POST",
1190 "/memory/consolidate",
1191 Some(json!({ "window_days": 7 })),
1192 )
1193 .await;
1194 assert_eq!(status2, StatusCode::OK);
1195 });
1196 h.shutdown(&runtime);
1197 }
1198
1199 #[test]
1200 fn auth_required_routes_reject_missing_token() {
1201 let runtime = rt();
1202 let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1203 let r = h.router.clone();
1204 runtime.block_on(async move {
1205 let (status, _body) = call(
1207 r.clone(),
1208 "POST",
1209 "/memory",
1210 Some(json!({ "content": "x" })),
1211 )
1212 .await;
1213 assert_eq!(status, StatusCode::UNAUTHORIZED);
1214
1215 let (status, _body) = call_with_auth(
1217 r.clone(),
1218 "POST",
1219 "/memory",
1220 Some(json!({ "content": "x" })),
1221 Some("Bearer wrong-token"),
1222 )
1223 .await;
1224 assert_eq!(status, StatusCode::UNAUTHORIZED);
1225
1226 let (status, body) = call_with_auth(
1228 r.clone(),
1229 "POST",
1230 "/memory",
1231 Some(json!({ "content": "authed" })),
1232 Some("Bearer secret-xyz"),
1233 )
1234 .await;
1235 assert_eq!(status, StatusCode::OK);
1236 assert!(body.get("memory_id").is_some());
1237 });
1238 h.shutdown(&runtime);
1239 }
1240
1241 #[test]
1242 fn health_endpoint_does_not_require_auth() {
1243 let runtime = rt();
1244 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1245 let r = h.router.clone();
1246 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1247 assert_eq!(status, StatusCode::OK);
1249 h.shutdown(&runtime);
1250 }
1251
1252 #[test]
1253 fn auth_response_includes_www_authenticate_header() {
1254 let runtime = rt();
1259 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1260 let r = h.router.clone();
1261 runtime.block_on(async move {
1262 let req = Request::builder()
1263 .method("POST")
1264 .uri("/memory")
1265 .header("content-type", "application/json")
1266 .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
1267 .unwrap();
1268 let resp = r.oneshot(req).await.unwrap();
1269 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1270 let www = resp
1271 .headers()
1272 .get("www-authenticate")
1273 .and_then(|v| v.to_str().ok())
1274 .unwrap_or("");
1275 assert!(
1276 www.starts_with("Bearer"),
1277 "expected WWW-Authenticate: Bearer..., got: {www}"
1278 );
1279 });
1280 h.shutdown(&runtime);
1281 }
1282
1283 #[test]
1284 fn full_remember_recall_inspect_forget_round_trip() {
1285 let runtime = rt();
1286 let h = Harness::new(&runtime);
1287 let r = h.router.clone();
1288 runtime.block_on(async move {
1289 let (status, body) = call(
1291 r.clone(),
1292 "POST",
1293 "/memory",
1294 Some(json!({ "content": "round-trip content" })),
1295 )
1296 .await;
1297 assert_eq!(status, StatusCode::OK);
1298 let mid = body
1299 .get("memory_id")
1300 .and_then(|v| v.as_str())
1301 .unwrap()
1302 .to_string();
1303
1304 let (status, body) = call(
1306 r.clone(),
1307 "POST",
1308 "/memory/search",
1309 Some(json!({ "query": "round-trip content", "limit": 5 })),
1310 )
1311 .await;
1312 assert_eq!(status, StatusCode::OK);
1313 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1314 assert!(
1315 hits.iter()
1316 .any(|h| h.get("content").and_then(|c| c.as_str())
1317 == Some("round-trip content")),
1318 "expected hit with content; got: {body}"
1319 );
1320
1321 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1323 assert_eq!(status, StatusCode::OK);
1324 assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
1325
1326 let (status, _body) =
1328 call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1329 assert_eq!(status, StatusCode::NO_CONTENT);
1330
1331 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1333 assert_eq!(status, StatusCode::OK);
1334 assert_eq!(
1335 body.get("status").and_then(|v| v.as_str()),
1336 Some("forgotten")
1337 );
1338
1339 let (status, body) = call(
1341 r.clone(),
1342 "POST",
1343 "/memory/search",
1344 Some(json!({ "query": "round-trip content", "limit": 5 })),
1345 )
1346 .await;
1347 assert_eq!(status, StatusCode::OK);
1348 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1349 assert!(
1350 hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
1351 != Some(mid.as_str())),
1352 "forgotten row should be excluded from recall: {body}"
1353 );
1354 });
1355 h.shutdown(&runtime);
1356 }
1357}
1358
1359#[cfg(test)]
1360mod cors_tests {
1361 use super::is_localhost_origin;
1362
1363 #[test]
1364 fn accepts_canonical_localhost_origins() {
1365 assert!(is_localhost_origin("http://localhost"));
1366 assert!(is_localhost_origin("http://localhost:3000"));
1367 assert!(is_localhost_origin("https://localhost:8443"));
1368 assert!(is_localhost_origin("http://127.0.0.1"));
1369 assert!(is_localhost_origin("http://127.0.0.1:5173"));
1370 assert!(is_localhost_origin("http://[::1]"));
1371 assert!(is_localhost_origin("http://[::1]:8080"));
1372 }
1373
1374 #[test]
1375 fn rejects_remote_origins() {
1376 assert!(!is_localhost_origin("http://example.com"));
1377 assert!(!is_localhost_origin("https://malicious.example"));
1378 assert!(!is_localhost_origin("http://192.168.1.5"));
1379 assert!(!is_localhost_origin("http://10.0.0.1"));
1380 }
1381
1382 #[test]
1383 fn rejects_dns_rebinding_tricks() {
1384 assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
1388 assert!(!is_localhost_origin("http://localhost.evil.com"));
1389 assert!(!is_localhost_origin("http://evil.localhost"));
1390 }
1391
1392 #[test]
1393 fn rejects_non_http_schemes() {
1394 assert!(!is_localhost_origin("file:///"));
1395 assert!(!is_localhost_origin("ws://localhost:3000"));
1396 assert!(!is_localhost_origin("javascript:alert(1)"));
1397 }
1398
1399 #[test]
1400 fn rejects_malformed() {
1401 assert!(!is_localhost_origin(""));
1402 assert!(!is_localhost_origin("localhost"));
1403 assert!(!is_localhost_origin("//localhost"));
1404 }
1405}
1406