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