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