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 .with_state(state);
78 if let Some(token) = bearer_token {
79 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
91pub fn router(state: SoloHttpState) -> Router {
93 router_with_auth(state, None)
94}
95
96fn build_cors_layer() -> CorsLayer {
97 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#[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
167fn 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 let host = host.split('/').next().unwrap_or(host);
180 let host = if let Some(idx) = host.rfind(':') {
182 if host.starts_with('[') {
184 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
197pub 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
221async fn openapi_handler() -> Json<serde_json::Value> {
235 Json(openapi_spec())
236}
237
238pub 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 "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." }
312 },
313 "additionalProperties": false
314 },
315 "ConsolidationReport": {
316 "type": "object",
317 "required": [
318 "episodes_seen", "clusters_built", "clusters_merged",
319 "clusters_absorbed", "existing_clusters_merged",
320 "episodes_clustered", "abstractions_built",
321 "abstractions_regenerated", "triples_built",
322 "contradictions_found"
323 ],
324 "properties": {
325 "episodes_seen": { "type": "integer", "minimum": 0 },
326 "clusters_built": { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
327 "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." },
328 "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." },
329 "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." },
330 "episodes_clustered": { "type": "integer", "minimum": 0 },
331 "abstractions_built": { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
332 "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." },
333 "triples_built": { "type": "integer", "minimum": 0 },
334 "contradictions_found": { "type": "integer", "minimum": 0 }
335 }
336 },
337 "EpisodeRecord": {
338 "type": "object",
339 "description":
340 "Inspect response: full episode record. Fields are stable across v0.1 but not \
341 exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
342 Treat as a forward-compatible JSON object.",
343 "additionalProperties": true
344 },
345 "ApiError": {
346 "type": "object",
347 "required": ["error", "status"],
348 "properties": {
349 "error": { "type": "string" },
350 "status": { "type": "integer", "minimum": 400, "maximum": 599 }
351 }
352 }
353 }
354 },
355 "paths": {
356 "/health": {
357 "get": {
358 "summary": "Liveness probe",
359 "description": "Returns plain text `ok`. Always unauthenticated.",
360 "responses": {
361 "200": {
362 "description": "Server is up.",
363 "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
364 }
365 }
366 }
367 },
368 "/openapi.json": {
369 "get": {
370 "summary": "Self-describing OpenAPI 3.1 spec",
371 "description": "Returns this document. Always unauthenticated.",
372 "responses": {
373 "200": {
374 "description": "OpenAPI 3.1 document.",
375 "content": { "application/json": { "schema": { "type": "object" } } }
376 }
377 }
378 }
379 },
380 "/memory": {
381 "post": {
382 "summary": "Remember (store an episode)",
383 "description": "Equivalent to MCP tool `memory.remember`.",
384 "security": [{ "bearerAuth": [] }, {}],
385 "requestBody": {
386 "required": true,
387 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
388 },
389 "responses": {
390 "200": {
391 "description": "Memory stored; returns the new MemoryId.",
392 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
393 },
394 "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
395 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
396 }
397 }
398 },
399 "/memory/search": {
400 "post": {
401 "summary": "Recall (vector search)",
402 "description": "Equivalent to MCP tool `memory.recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
403 "security": [{ "bearerAuth": [] }, {}],
404 "requestBody": {
405 "required": true,
406 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
407 },
408 "responses": {
409 "200": {
410 "description": "Search results.",
411 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
412 },
413 "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
414 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
415 }
416 }
417 },
418 "/memory/consolidate": {
419 "post": {
420 "summary": "Run a consolidation pass (clustering + abstraction)",
421 "description":
422 "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
423 on the server, also runs the REM-equivalent abstraction pass that populates \
424 `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
425 window). Equivalent to the `solo consolidate` CLI.",
426 "security": [{ "bearerAuth": [] }, {}],
427 "requestBody": {
428 "required": false,
429 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
430 },
431 "responses": {
432 "200": {
433 "description": "Consolidation complete; report counts the work done.",
434 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
435 },
436 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
437 }
438 }
439 },
440 "/memory/{id}": {
441 "get": {
442 "summary": "Inspect a memory by ID",
443 "description": "Equivalent to MCP tool `memory.inspect`.",
444 "security": [{ "bearerAuth": [] }, {}],
445 "parameters": [{
446 "name": "id",
447 "in": "path",
448 "required": true,
449 "schema": { "type": "string", "format": "uuid" },
450 "description": "MemoryId (UUID v7)."
451 }],
452 "responses": {
453 "200": {
454 "description": "Episode record.",
455 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
456 },
457 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
458 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
459 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
460 }
461 },
462 "delete": {
463 "summary": "Forget (soft-delete) a memory by ID",
464 "description":
465 "Equivalent to MCP tool `memory.forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
466 and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
467 re-running `solo reembed` after this does NOT restore visibility.",
468 "security": [{ "bearerAuth": [] }, {}],
469 "parameters": [
470 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
471 { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
472 ],
473 "responses": {
474 "204": { "description": "Forgotten (or already forgotten — idempotent)." },
475 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
476 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
477 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
478 }
479 }
480 }
481 }
482 })
483}
484
485#[derive(Debug, Deserialize)]
490struct RememberBody {
491 content: String,
492 #[serde(default)]
493 source_type: Option<String>,
494 #[serde(default)]
495 source_id: Option<String>,
496}
497
498#[derive(Debug, Serialize)]
499struct RememberResponse {
500 memory_id: String,
501}
502
503async fn remember_handler(
504 State(s): State<SoloHttpState>,
505 Json(body): Json<RememberBody>,
506) -> Result<Json<RememberResponse>, ApiError> {
507 let content = body.content.trim_end().to_string();
508 if content.is_empty() {
509 return Err(ApiError::bad_request("content must not be empty"));
510 }
511 let embedding = s.embedder.embed(&content).await.map_err(ApiError::from)?;
512 let episode = Episode {
513 memory_id: MemoryId::new(),
514 ts_ms: chrono::Utc::now().timestamp_millis(),
515 source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
516 source_id: body.source_id,
517 content,
518 encoding_context: EncodingContext::default(),
519 provenance: None,
520 confidence: Confidence::new(0.9).unwrap(),
521 strength: 0.5,
522 salience: 0.5,
523 tier: Tier::Hot,
524 };
525 let mid = s.write.remember(episode, embedding).await.map_err(ApiError::from)?;
526 Ok(Json(RememberResponse {
527 memory_id: mid.to_string(),
528 }))
529}
530
531#[derive(Debug, Deserialize)]
532struct RecallBody {
533 query: String,
534 #[serde(default = "default_limit")]
535 limit: usize,
536}
537
538fn default_limit() -> usize {
539 5
540}
541
542async fn recall_handler(
543 State(s): State<SoloHttpState>,
544 Json(body): Json<RecallBody>,
545) -> Result<Json<solo_query::RecallResult>, ApiError> {
546 let result = solo_query::run_recall(
550 &s.embedder,
551 &s.hnsw,
552 &s.pool,
553 &body.query,
554 body.limit,
555 )
556 .await
557 .map_err(ApiError::from)?;
558 Ok(Json(result))
559}
560
561async fn inspect_handler(
562 State(s): State<SoloHttpState>,
563 Path(id): Path<String>,
564) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
565 let mid = MemoryId::from_str(&id)
566 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
567 let row = solo_query::inspect_one(&s.pool, mid)
568 .await
569 .map_err(ApiError::from)?;
570 Ok(Json(row))
571}
572
573#[derive(Debug, Deserialize)]
574struct ForgetQuery {
575 #[serde(default)]
576 reason: Option<String>,
577}
578
579async fn forget_handler(
580 State(s): State<SoloHttpState>,
581 Path(id): Path<String>,
582 Query(q): Query<ForgetQuery>,
583) -> Result<StatusCode, ApiError> {
584 let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
585 let reason = q.reason.unwrap_or_else(|| "http".into());
586 s.write.forget(mid, reason).await.map_err(ApiError::from)?;
587 Ok(StatusCode::NO_CONTENT)
588}
589
590async fn consolidate_handler(
591 State(s): State<SoloHttpState>,
592 body: axum::body::Bytes,
593) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
594 let scope = if body.is_empty() {
600 solo_storage::ConsolidationScope::default()
601 } else {
602 serde_json::from_slice(&body)
603 .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
604 };
605 let report = s.write.consolidate(scope).await.map_err(ApiError::from)?;
606 Ok(Json(report))
607}
608
609#[derive(Debug)]
614pub struct ApiError {
615 status: StatusCode,
616 message: String,
617}
618
619impl ApiError {
620 fn bad_request(msg: impl Into<String>) -> Self {
621 Self {
622 status: StatusCode::BAD_REQUEST,
623 message: msg.into(),
624 }
625 }
626 fn not_found(msg: impl Into<String>) -> Self {
627 Self {
628 status: StatusCode::NOT_FOUND,
629 message: msg.into(),
630 }
631 }
632 fn internal(msg: impl Into<String>) -> Self {
633 Self {
634 status: StatusCode::INTERNAL_SERVER_ERROR,
635 message: msg.into(),
636 }
637 }
638}
639
640impl From<solo_core::Error> for ApiError {
641 fn from(e: solo_core::Error) -> Self {
642 use solo_core::Error;
643 match e {
644 Error::NotFound(msg) => ApiError::not_found(msg),
645 Error::InvalidInput(msg) => ApiError::bad_request(msg),
646 Error::Conflict(msg) => Self {
647 status: StatusCode::CONFLICT,
648 message: msg,
649 },
650 other => ApiError::internal(other.to_string()),
651 }
652 }
653}
654
655impl IntoResponse for ApiError {
656 fn into_response(self) -> Response {
657 let body = serde_json::json!({
658 "error": self.message,
659 "status": self.status.as_u16(),
660 });
661 (self.status, Json(body)).into_response()
662 }
663}
664
665#[cfg(test)]
669mod handler_tests {
670 use super::*;
679 use axum::body::Body;
680 use axum::http::{Request, StatusCode};
681 use http_body_util::BodyExt;
682 use serde_json::{Value, json};
683 use solo_core::VectorIndex as _;
684 use solo_storage::test_support::StubVectorIndex;
685 use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
686 use std::sync::Arc as StdArc;
687 use tower::ServiceExt;
688
689 struct Harness {
690 router: axum::Router,
691 _tmp: tempfile::TempDir,
692 write_handle_extra: Option<solo_storage::WriteHandle>,
693 join: Option<std::thread::JoinHandle<()>>,
694 }
695
696 impl Harness {
697 fn new(runtime: &tokio::runtime::Runtime) -> Self {
698 Self::new_with_auth(runtime, None)
699 }
700
701 fn new_with_auth(
702 runtime: &tokio::runtime::Runtime,
703 bearer_token: Option<String>,
704 ) -> Self {
705 use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
706
707 let tmp = tempfile::TempDir::new().unwrap();
708 let dim = 16usize;
709 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
710 let embedder: StdArc<dyn solo_core::Embedder> =
711 StdArc::new(StubEmbedder::new("stub", "v1", dim));
712 let path = tmp.path().join("test.db");
713
714 let embedder_id = {
721 let conn = solo_storage::test_support::open_test_db_at(&path);
722 get_or_insert_embedder_id(
723 &conn,
724 &EmbedderIdentity {
725 name: "stub".into(),
726 version: "v1".into(),
727 dim: dim as u32,
728 dtype: "f32".into(),
729 },
730 )
731 .unwrap()
732 };
733
734 let conn = solo_storage::test_support::open_test_db_at(&path);
735 let WriterSpawn { handle, join } = WriterActor::spawn_full(
736 conn,
737 hnsw.clone(),
738 tmp.path().to_path_buf(),
739 embedder_id,
740 );
741 let pool: ReaderPool =
742 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
743 let state = SoloHttpState {
744 write: handle.clone(),
745 pool,
746 embedder,
747 hnsw,
748 };
749 let router = router_with_auth(state, bearer_token);
750 Harness {
751 router,
752 _tmp: tmp,
753 write_handle_extra: Some(handle),
754 join: Some(join),
755 }
756 }
757
758 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
759 let join = self.join.take();
760 let extra = self.write_handle_extra.take();
761 runtime.block_on(async move {
762 drop(extra);
763 drop(self.router); drop(self._tmp);
765 if let Some(join) = join {
766 let (tx, rx) = std::sync::mpsc::channel();
767 std::thread::spawn(move || {
768 let _ = tx.send(join.join());
769 });
770 tokio::task::spawn_blocking(move || {
771 rx.recv_timeout(std::time::Duration::from_secs(5))
772 })
773 .await
774 .expect("blocking task")
775 .expect("writer thread did not exit within 5s")
776 .expect("writer thread panicked");
777 }
778 });
779 }
780 }
781
782 fn rt() -> tokio::runtime::Runtime {
783 tokio::runtime::Builder::new_multi_thread()
784 .worker_threads(2)
785 .enable_all()
786 .build()
787 .unwrap()
788 }
789
790 async fn call(
794 router: axum::Router,
795 method: &str,
796 uri: &str,
797 body: Option<Value>,
798 ) -> (StatusCode, Value) {
799 call_with_auth(router, method, uri, body, None).await
800 }
801
802 async fn call_with_auth(
803 router: axum::Router,
804 method: &str,
805 uri: &str,
806 body: Option<Value>,
807 auth: Option<&str>,
808 ) -> (StatusCode, Value) {
809 let mut req_builder = Request::builder()
810 .method(method)
811 .uri(uri)
812 .header("content-type", "application/json");
813 if let Some(a) = auth {
814 req_builder = req_builder.header("authorization", a);
815 }
816 let req = if let Some(b) = body {
817 let bytes = serde_json::to_vec(&b).unwrap();
818 req_builder.body(Body::from(bytes)).unwrap()
819 } else {
820 req_builder = req_builder.header("content-length", "0");
821 req_builder.body(Body::empty()).unwrap()
822 };
823 let resp = router.oneshot(req).await.expect("oneshot");
824 let status = resp.status();
825 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
826 let v: Value = if body_bytes.is_empty() {
827 Value::Null
828 } else {
829 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
830 };
831 (status, v)
832 }
833
834 #[test]
835 fn health_returns_ok() {
836 let runtime = rt();
837 let h = Harness::new(&runtime);
838 let r = h.router.clone();
839 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
840 assert_eq!(status, StatusCode::OK);
841 h.shutdown(&runtime);
842 }
843
844 #[test]
849 fn openapi_json_describes_all_endpoints() {
850 let runtime = rt();
851 let h = Harness::new(&runtime);
852 let r = h.router.clone();
853 let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
854 assert_eq!(status, StatusCode::OK);
855 assert!(spec.is_object(), "openapi.json must be a JSON object");
856
857 assert!(
859 spec.get("openapi")
860 .and_then(|v| v.as_str())
861 .is_some_and(|s| s.starts_with("3.")),
862 "missing or wrong openapi version: {spec}"
863 );
864 assert!(spec.pointer("/info/title").is_some());
865 assert!(spec.pointer("/info/version").is_some());
866
867 let paths = spec
869 .get("paths")
870 .and_then(|v| v.as_object())
871 .expect("paths must be an object");
872 for expected in [
873 "/health",
874 "/openapi.json",
875 "/memory",
876 "/memory/search",
877 "/memory/consolidate",
878 "/memory/{id}",
879 ] {
880 assert!(
881 paths.contains_key(expected),
882 "openapi paths missing {expected}: {paths:?}"
883 );
884 }
885
886 let memid = paths.get("/memory/{id}").expect("memory/{id}");
889 assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
890 assert!(
891 memid.get("delete").is_some(),
892 "DELETE /memory/{{id}} undocumented"
893 );
894
895 for schema_name in [
897 "RememberRequest",
898 "RememberResponse",
899 "RecallRequest",
900 "RecallResult",
901 "EpisodeRecord",
902 "ApiError",
903 "ConsolidationScope",
904 "ConsolidationReport",
905 ] {
906 let ptr = format!("/components/schemas/{schema_name}");
907 assert!(
908 spec.pointer(&ptr).is_some(),
909 "component schema {schema_name} missing"
910 );
911 }
912
913 assert!(
915 spec.pointer("/components/securitySchemes/bearerAuth")
916 .is_some(),
917 "bearerAuth security scheme missing"
918 );
919
920 h.shutdown(&runtime);
921 }
922
923 #[test]
927 fn openapi_json_is_exempt_from_bearer_auth() {
928 let runtime = rt();
929 let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
930 let r = h.router.clone();
931 let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
933 assert_eq!(status, StatusCode::OK);
934 h.shutdown(&runtime);
935 }
936
937 #[test]
938 fn remember_returns_memory_id() {
939 let runtime = rt();
940 let h = Harness::new(&runtime);
941 let r = h.router.clone();
942 let (status, body) = runtime.block_on(call(
943 r,
944 "POST",
945 "/memory",
946 Some(json!({ "content": "http harness test" })),
947 ));
948 assert_eq!(status, StatusCode::OK);
949 let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
950 assert_eq!(mid.len(), 36, "uuid length");
951 h.shutdown(&runtime);
952 }
953
954 #[test]
955 fn empty_content_returns_400() {
956 let runtime = rt();
957 let h = Harness::new(&runtime);
958 let r = h.router.clone();
959 let (status, body) =
960 runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
961 assert_eq!(status, StatusCode::BAD_REQUEST);
962 assert!(
963 body.get("error")
964 .and_then(|e| e.as_str())
965 .map(|s| s.contains("must not be empty"))
966 .unwrap_or(false),
967 "got: {body}"
968 );
969 h.shutdown(&runtime);
970 }
971
972 #[test]
973 fn empty_query_returns_400() {
974 let runtime = rt();
975 let h = Harness::new(&runtime);
976 let r = h.router.clone();
977 let (status, body) = runtime.block_on(call(
978 r,
979 "POST",
980 "/memory/search",
981 Some(json!({ "query": "" })),
982 ));
983 assert_eq!(status, StatusCode::BAD_REQUEST);
984 assert!(
985 body.get("error")
986 .and_then(|e| e.as_str())
987 .map(|s| s.contains("must not be empty"))
988 .unwrap_or(false),
989 "got: {body}"
990 );
991 h.shutdown(&runtime);
992 }
993
994 #[test]
995 fn inspect_unknown_returns_404() {
996 let runtime = rt();
997 let h = Harness::new(&runtime);
998 let r = h.router.clone();
999 let (status, body) = runtime.block_on(call(
1000 r,
1001 "GET",
1002 "/memory/00000000-0000-7000-8000-000000000000",
1003 None,
1004 ));
1005 assert_eq!(status, StatusCode::NOT_FOUND);
1006 assert!(body.get("error").is_some(), "got: {body}");
1007 h.shutdown(&runtime);
1008 }
1009
1010 #[test]
1011 fn inspect_invalid_id_returns_400() {
1012 let runtime = rt();
1013 let h = Harness::new(&runtime);
1014 let r = h.router.clone();
1015 let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1016 assert_eq!(status, StatusCode::BAD_REQUEST);
1017 h.shutdown(&runtime);
1018 }
1019
1020 #[test]
1021 fn forget_unknown_returns_404() {
1022 let runtime = rt();
1023 let h = Harness::new(&runtime);
1024 let r = h.router.clone();
1025 let (status, _body) = runtime.block_on(call(
1026 r,
1027 "DELETE",
1028 "/memory/00000000-0000-7000-8000-000000000000",
1029 None,
1030 ));
1031 assert_eq!(status, StatusCode::NOT_FOUND);
1032 h.shutdown(&runtime);
1033 }
1034
1035 #[test]
1043 fn consolidate_endpoint_returns_report() {
1044 let runtime = rt();
1045 let h = Harness::new(&runtime);
1046 let r = h.router.clone();
1047 runtime.block_on(async move {
1048 let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1050 assert_eq!(status, StatusCode::OK);
1051 for field in [
1052 "episodes_seen",
1053 "clusters_built",
1054 "episodes_clustered",
1055 "abstractions_built",
1056 "triples_built",
1057 "contradictions_found",
1058 ] {
1059 assert!(
1060 body.get(field).and_then(|v| v.as_u64()).is_some(),
1061 "missing field {field}: {body}"
1062 );
1063 }
1064 assert_eq!(body["episodes_seen"], 0);
1065 assert_eq!(body["clusters_built"], 0);
1066
1067 let (status2, _body2) = call(
1070 r,
1071 "POST",
1072 "/memory/consolidate",
1073 Some(json!({ "window_days": 7 })),
1074 )
1075 .await;
1076 assert_eq!(status2, StatusCode::OK);
1077 });
1078 h.shutdown(&runtime);
1079 }
1080
1081 #[test]
1082 fn auth_required_routes_reject_missing_token() {
1083 let runtime = rt();
1084 let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1085 let r = h.router.clone();
1086 runtime.block_on(async move {
1087 let (status, _body) = call(
1089 r.clone(),
1090 "POST",
1091 "/memory",
1092 Some(json!({ "content": "x" })),
1093 )
1094 .await;
1095 assert_eq!(status, StatusCode::UNAUTHORIZED);
1096
1097 let (status, _body) = call_with_auth(
1099 r.clone(),
1100 "POST",
1101 "/memory",
1102 Some(json!({ "content": "x" })),
1103 Some("Bearer wrong-token"),
1104 )
1105 .await;
1106 assert_eq!(status, StatusCode::UNAUTHORIZED);
1107
1108 let (status, body) = call_with_auth(
1110 r.clone(),
1111 "POST",
1112 "/memory",
1113 Some(json!({ "content": "authed" })),
1114 Some("Bearer secret-xyz"),
1115 )
1116 .await;
1117 assert_eq!(status, StatusCode::OK);
1118 assert!(body.get("memory_id").is_some());
1119 });
1120 h.shutdown(&runtime);
1121 }
1122
1123 #[test]
1124 fn health_endpoint_does_not_require_auth() {
1125 let runtime = rt();
1126 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1127 let r = h.router.clone();
1128 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1129 assert_eq!(status, StatusCode::OK);
1131 h.shutdown(&runtime);
1132 }
1133
1134 #[test]
1135 fn auth_response_includes_www_authenticate_header() {
1136 let runtime = rt();
1141 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1142 let r = h.router.clone();
1143 runtime.block_on(async move {
1144 let req = Request::builder()
1145 .method("POST")
1146 .uri("/memory")
1147 .header("content-type", "application/json")
1148 .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
1149 .unwrap();
1150 let resp = r.oneshot(req).await.unwrap();
1151 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1152 let www = resp
1153 .headers()
1154 .get("www-authenticate")
1155 .and_then(|v| v.to_str().ok())
1156 .unwrap_or("");
1157 assert!(
1158 www.starts_with("Bearer"),
1159 "expected WWW-Authenticate: Bearer..., got: {www}"
1160 );
1161 });
1162 h.shutdown(&runtime);
1163 }
1164
1165 #[test]
1166 fn full_remember_recall_inspect_forget_round_trip() {
1167 let runtime = rt();
1168 let h = Harness::new(&runtime);
1169 let r = h.router.clone();
1170 runtime.block_on(async move {
1171 let (status, body) = call(
1173 r.clone(),
1174 "POST",
1175 "/memory",
1176 Some(json!({ "content": "round-trip content" })),
1177 )
1178 .await;
1179 assert_eq!(status, StatusCode::OK);
1180 let mid = body
1181 .get("memory_id")
1182 .and_then(|v| v.as_str())
1183 .unwrap()
1184 .to_string();
1185
1186 let (status, body) = call(
1188 r.clone(),
1189 "POST",
1190 "/memory/search",
1191 Some(json!({ "query": "round-trip content", "limit": 5 })),
1192 )
1193 .await;
1194 assert_eq!(status, StatusCode::OK);
1195 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1196 assert!(
1197 hits.iter()
1198 .any(|h| h.get("content").and_then(|c| c.as_str())
1199 == Some("round-trip content")),
1200 "expected hit with content; got: {body}"
1201 );
1202
1203 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1205 assert_eq!(status, StatusCode::OK);
1206 assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
1207
1208 let (status, _body) =
1210 call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1211 assert_eq!(status, StatusCode::NO_CONTENT);
1212
1213 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1215 assert_eq!(status, StatusCode::OK);
1216 assert_eq!(
1217 body.get("status").and_then(|v| v.as_str()),
1218 Some("forgotten")
1219 );
1220
1221 let (status, body) = call(
1223 r.clone(),
1224 "POST",
1225 "/memory/search",
1226 Some(json!({ "query": "round-trip content", "limit": 5 })),
1227 )
1228 .await;
1229 assert_eq!(status, StatusCode::OK);
1230 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1231 assert!(
1232 hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
1233 != Some(mid.as_str())),
1234 "forgotten row should be excluded from recall: {body}"
1235 );
1236 });
1237 h.shutdown(&runtime);
1238 }
1239}
1240
1241#[cfg(test)]
1242mod cors_tests {
1243 use super::is_localhost_origin;
1244
1245 #[test]
1246 fn accepts_canonical_localhost_origins() {
1247 assert!(is_localhost_origin("http://localhost"));
1248 assert!(is_localhost_origin("http://localhost:3000"));
1249 assert!(is_localhost_origin("https://localhost:8443"));
1250 assert!(is_localhost_origin("http://127.0.0.1"));
1251 assert!(is_localhost_origin("http://127.0.0.1:5173"));
1252 assert!(is_localhost_origin("http://[::1]"));
1253 assert!(is_localhost_origin("http://[::1]:8080"));
1254 }
1255
1256 #[test]
1257 fn rejects_remote_origins() {
1258 assert!(!is_localhost_origin("http://example.com"));
1259 assert!(!is_localhost_origin("https://malicious.example"));
1260 assert!(!is_localhost_origin("http://192.168.1.5"));
1261 assert!(!is_localhost_origin("http://10.0.0.1"));
1262 }
1263
1264 #[test]
1265 fn rejects_dns_rebinding_tricks() {
1266 assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
1270 assert!(!is_localhost_origin("http://localhost.evil.com"));
1271 assert!(!is_localhost_origin("http://evil.localhost"));
1272 }
1273
1274 #[test]
1275 fn rejects_non_http_schemes() {
1276 assert!(!is_localhost_origin("file:///"));
1277 assert!(!is_localhost_origin("ws://localhost:3000"));
1278 assert!(!is_localhost_origin("javascript:alert(1)"));
1279 }
1280
1281 #[test]
1282 fn rejects_malformed() {
1283 assert!(!is_localhost_origin(""));
1284 assert!(!is_localhost_origin("localhost"));
1285 assert!(!is_localhost_origin("//localhost"));
1286 }
1287}
1288