1use std::net::SocketAddr;
35use std::str::FromStr;
36use std::sync::Arc;
37
38use axum::extract::{Path, Query, State};
39use axum::http::{HeaderValue, Method, StatusCode};
40use axum::response::{IntoResponse, Response};
41use axum::routing::{get, post};
42use axum::{Json, Router};
43use serde::{Deserialize, Serialize};
44use solo_core::{
45 Confidence, Embedder, EncodingContext, Episode, MemoryId, Tier, VectorIndex,
46};
47use solo_storage::{ReaderPool, WriteHandle};
48use tower_http::cors::{AllowOrigin, CorsLayer};
49use tower_http::trace::TraceLayer;
50use tower_http::validate_request::{ValidateRequest, ValidateRequestHeaderLayer};
51
52#[derive(Clone)]
53pub struct SoloHttpState {
54 pub write: WriteHandle,
55 pub pool: ReaderPool,
56 pub embedder: Arc<dyn Embedder>,
57 pub hnsw: Arc<dyn VectorIndex + Send + Sync>,
58 pub source_db_path: std::path::PathBuf,
64}
65
66pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
76 let cors = build_cors_layer();
77 let public = Router::new()
85 .route("/health", get(|| async { "ok" }))
86 .route("/openapi.json", get(openapi_handler));
87
88 let mut authed = Router::new()
89 .route("/memory", post(remember_handler))
90 .route("/memory/search", post(recall_handler))
91 .route("/memory/consolidate", post(consolidate_handler))
92 .route("/memory/{id}", get(inspect_handler).delete(forget_handler))
93 .route("/backup", post(backup_handler))
94 .route("/memory/themes", get(themes_handler))
98 .route("/memory/facts_about", get(facts_about_handler))
99 .route("/memory/contradictions", get(contradictions_handler))
100 .with_state(state);
101 if let Some(token) = bearer_token {
102 authed = authed.layer(ValidateRequestHeaderLayer::custom(BearerToken::new(token)));
106 }
107
108 public
109 .merge(authed)
110 .layer(cors)
111 .layer(TraceLayer::new_for_http())
112}
113
114pub fn router(state: SoloHttpState) -> Router {
116 router_with_auth(state, None)
117}
118
119fn build_cors_layer() -> CorsLayer {
120 CorsLayer::new()
134 .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
135 origin
136 .to_str()
137 .map(is_localhost_origin)
138 .unwrap_or(false)
139 }))
140 .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
141 .allow_headers([
142 axum::http::header::CONTENT_TYPE,
143 axum::http::header::AUTHORIZATION,
144 ])
145}
146
147#[derive(Clone)]
155struct BearerToken {
156 expected: HeaderValue,
157}
158
159impl BearerToken {
160 fn new(token: String) -> Self {
161 let expected = HeaderValue::try_from(format!("Bearer {token}"))
162 .expect("bearer token must be a valid HTTP header value");
163 Self { expected }
164 }
165}
166
167impl<B> ValidateRequest<B> for BearerToken {
168 type ResponseBody = axum::body::Body;
169
170 fn validate(
171 &mut self,
172 request: &mut axum::http::Request<B>,
173 ) -> Result<(), axum::http::Response<Self::ResponseBody>> {
174 let got = request.headers().get(axum::http::header::AUTHORIZATION);
175 match got {
176 Some(value) if value == &self.expected => Ok(()),
177 _ => {
178 let mut resp = axum::http::Response::new(axum::body::Body::empty());
179 *resp.status_mut() = StatusCode::UNAUTHORIZED;
180 resp.headers_mut().insert(
181 axum::http::header::WWW_AUTHENTICATE,
182 HeaderValue::from_static(r#"Bearer realm="solo""#),
183 );
184 Err(resp)
185 }
186 }
187 }
188}
189
190fn is_localhost_origin(origin: &str) -> bool {
194 let rest = origin
195 .strip_prefix("http://")
196 .or_else(|| origin.strip_prefix("https://"));
197 let host = match rest {
198 Some(r) => r,
199 None => return false,
200 };
201 let host = host.split('/').next().unwrap_or(host);
203 let host = if let Some(idx) = host.rfind(':') {
205 if host.starts_with('[') {
207 host.find(']')
209 .map(|i| &host[..=i])
210 .unwrap_or(host)
211 } else {
212 &host[..idx]
213 }
214 } else {
215 host
216 };
217 matches!(host, "localhost" | "127.0.0.1" | "[::1]")
218}
219
220pub async fn serve_http(
226 addr: SocketAddr,
227 state: SoloHttpState,
228 bearer_token: Option<String>,
229 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
230) -> std::io::Result<()> {
231 let auth_kind = if bearer_token.is_some() {
232 "bearer"
233 } else {
234 "none"
235 };
236 let app = router_with_auth(state, bearer_token);
237 let listener = tokio::net::TcpListener::bind(addr).await?;
238 tracing::info!(%addr, auth = auth_kind, "solo http: listening");
239 axum::serve(listener, app)
240 .with_graceful_shutdown(shutdown)
241 .await
242}
243
244async fn openapi_handler() -> Json<serde_json::Value> {
258 Json(openapi_spec())
259}
260
261pub fn openapi_spec() -> serde_json::Value {
265 serde_json::json!({
266 "openapi": "3.1.0",
267 "info": {
268 "title": "Solo HTTP API",
269 "description":
270 "Local-first personal memory daemon. The HTTP transport \
271 mirrors the four MCP tools (memory_remember / recall / \
272 inspect / forget). Default deployment is loopback-only \
273 (127.0.0.1); LAN-bound deployments require a bearer \
274 token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
275 "version": env!("CARGO_PKG_VERSION"),
276 "license": { "name": "Apache-2.0" }
277 },
278 "servers": [
279 { "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
280 ],
281 "components": {
282 "securitySchemes": {
283 "bearerAuth": {
284 "type": "http",
285 "scheme": "bearer",
286 "description":
287 "Bearer-token auth. Required only on LAN-bound deployments \
288 (`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
289 the default `127.0.0.1` deployment is unauthenticated. \
290 `GET /health` and `GET /openapi.json` are exempt from auth even \
291 on bearer-protected instances."
292 }
293 },
294 "schemas": {
295 "RememberRequest": {
296 "type": "object",
297 "required": ["content"],
298 "properties": {
299 "content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
300 "source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
301 "source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
302 },
303 "additionalProperties": false
304 },
305 "RememberResponse": {
306 "type": "object",
307 "required": ["memory_id"],
308 "properties": {
309 "memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
310 }
311 },
312 "RecallRequest": {
313 "type": "object",
314 "required": ["query"],
315 "properties": {
316 "query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
317 "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
318 },
319 "additionalProperties": false
320 },
321 "RecallResult": {
322 "type": "object",
323 "description":
324 "Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
325 see `solo_query::RecallResult` in the source for the canonical shape. \
326 Treat as a forward-compatible JSON object.",
327 "additionalProperties": true
328 },
329 "ConsolidationScope": {
330 "type": "object",
331 "description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
332 "properties": {
333 "window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." },
334 "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." }
335 },
336 "additionalProperties": false
337 },
338 "ConsolidationReport": {
339 "type": "object",
340 "required": [
341 "episodes_seen", "clusters_built", "clusters_merged",
342 "clusters_absorbed", "existing_clusters_merged",
343 "episodes_clustered", "abstractions_built",
344 "abstractions_regenerated", "triples_built",
345 "contradictions_found"
346 ],
347 "properties": {
348 "episodes_seen": { "type": "integer", "minimum": 0 },
349 "clusters_built": { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
350 "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." },
351 "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." },
352 "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." },
353 "episodes_clustered": { "type": "integer", "minimum": 0 },
354 "abstractions_built": { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
355 "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." },
356 "triples_built": { "type": "integer", "minimum": 0 },
357 "contradictions_found": { "type": "integer", "minimum": 0 }
358 }
359 },
360 "EpisodeRecord": {
361 "type": "object",
362 "description":
363 "Inspect response: full episode record. Fields are stable across v0.1 but not \
364 exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
365 Treat as a forward-compatible JSON object.",
366 "additionalProperties": true
367 },
368 "ThemeHit": {
369 "type": "object",
370 "description":
371 "One cluster + its (optional) abstraction. Returned by GET /memory/themes. \
372 See `solo_query::ThemeHit` for the canonical shape: cluster_id, \
373 abstraction_id?, abstraction_text?, episode_count, coherence, created_at_ms.",
374 "additionalProperties": true
375 },
376 "FactHit": {
377 "type": "object",
378 "description":
379 "One Steward-extracted SPO triple. Returned by GET /memory/facts_about. \
380 See `solo_query::FactHit` for fields: triple_id, subject_id, predicate, \
381 object_id, object_kind, valid_from_ms, valid_to_ms?, confidence, cluster_id?.",
382 "additionalProperties": true
383 },
384 "ContradictionHit": {
385 "type": "object",
386 "description":
387 "One Steward-flagged contradiction with each side's triple LEFT JOIN'd in. \
388 Returned by GET /memory/contradictions. See `solo_query::ContradictionHit`: \
389 a_id, b_id, kind, explanation, detected_at_ms, a_triple?, b_triple?.",
390 "additionalProperties": true
391 },
392 "ApiError": {
393 "type": "object",
394 "required": ["error", "status"],
395 "properties": {
396 "error": { "type": "string" },
397 "status": { "type": "integer", "minimum": 400, "maximum": 599 }
398 }
399 }
400 }
401 },
402 "paths": {
403 "/health": {
404 "get": {
405 "summary": "Liveness probe",
406 "description": "Returns plain text `ok`. Always unauthenticated.",
407 "responses": {
408 "200": {
409 "description": "Server is up.",
410 "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
411 }
412 }
413 }
414 },
415 "/openapi.json": {
416 "get": {
417 "summary": "Self-describing OpenAPI 3.1 spec",
418 "description": "Returns this document. Always unauthenticated.",
419 "responses": {
420 "200": {
421 "description": "OpenAPI 3.1 document.",
422 "content": { "application/json": { "schema": { "type": "object" } } }
423 }
424 }
425 }
426 },
427 "/memory": {
428 "post": {
429 "summary": "Remember (store an episode)",
430 "description": "Equivalent to MCP tool `memory_remember`.",
431 "security": [{ "bearerAuth": [] }, {}],
432 "requestBody": {
433 "required": true,
434 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
435 },
436 "responses": {
437 "200": {
438 "description": "Memory stored; returns the new MemoryId.",
439 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
440 },
441 "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
442 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
443 }
444 }
445 },
446 "/memory/search": {
447 "post": {
448 "summary": "Recall (vector search)",
449 "description": "Equivalent to MCP tool `memory_recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
450 "security": [{ "bearerAuth": [] }, {}],
451 "requestBody": {
452 "required": true,
453 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
454 },
455 "responses": {
456 "200": {
457 "description": "Search results.",
458 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
459 },
460 "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
461 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
462 }
463 }
464 },
465 "/memory/consolidate": {
466 "post": {
467 "summary": "Run a consolidation pass (clustering + abstraction)",
468 "description":
469 "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
470 on the server, also runs the REM-equivalent abstraction pass that populates \
471 `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
472 window). Equivalent to the `solo consolidate` CLI.",
473 "security": [{ "bearerAuth": [] }, {}],
474 "requestBody": {
475 "required": false,
476 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
477 },
478 "responses": {
479 "200": {
480 "description": "Consolidation complete; report counts the work done.",
481 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
482 },
483 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
484 }
485 }
486 },
487 "/backup": {
488 "post": {
489 "summary": "Online encrypted backup",
490 "description":
491 "Run an online SQLCipher backup of the live data dir to a server-side path. \
492 The destination file is encrypted with the same Argon2id-derived raw key as \
493 the source, so it restores under the same passphrase + a copy of the source's \
494 `solo.config.toml`. Hot — the backup runs against the writer's existing \
495 connection without taking the lockfile, so the daemon keeps serving reads + \
496 writes during the operation. v0.3.2+.",
497 "security": [{ "bearerAuth": [] }, {}],
498 "requestBody": {
499 "required": true,
500 "content": { "application/json": { "schema": {
501 "type": "object",
502 "properties": {
503 "to": { "type": "string", "description": "Server-side absolute path for the backup file." },
504 "force": { "type": "boolean", "description": "Overwrite an existing destination file. Default false.", "default": false }
505 },
506 "required": ["to"]
507 } } }
508 },
509 "responses": {
510 "200": {
511 "description": "Backup complete; reports the destination path + elapsed milliseconds.",
512 "content": { "application/json": { "schema": {
513 "type": "object",
514 "properties": {
515 "path": { "type": "string" },
516 "elapsed_ms": { "type": "integer", "format": "int64" }
517 }
518 } } }
519 },
520 "400": { "description": "Destination invalid, exists without force, or its parent doesn't exist." },
521 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." },
522 "500": { "description": "Backup failed (disk full, permission denied, etc.)." }
523 }
524 }
525 },
526 "/memory/{id}": {
527 "get": {
528 "summary": "Inspect a memory by ID",
529 "description": "Equivalent to MCP tool `memory_inspect`.",
530 "security": [{ "bearerAuth": [] }, {}],
531 "parameters": [{
532 "name": "id",
533 "in": "path",
534 "required": true,
535 "schema": { "type": "string", "format": "uuid" },
536 "description": "MemoryId (UUID v7)."
537 }],
538 "responses": {
539 "200": {
540 "description": "Episode record.",
541 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
542 },
543 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
544 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
545 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
546 }
547 },
548 "delete": {
549 "summary": "Forget (soft-delete) a memory by ID",
550 "description":
551 "Equivalent to MCP tool `memory_forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
552 and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
553 re-running `solo reembed` after this does NOT restore visibility.",
554 "security": [{ "bearerAuth": [] }, {}],
555 "parameters": [
556 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
557 { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
558 ],
559 "responses": {
560 "204": { "description": "Forgotten (or already forgotten — idempotent)." },
561 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
562 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
563 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
564 }
565 }
566 },
567 "/memory/themes": {
568 "get": {
569 "summary": "List recent cluster themes",
570 "description":
571 "Equivalent to MCP tool `memory_themes`. List cluster abstractions ordered by \
572 most-recent first. Use to surface 'what has the user been thinking about lately' \
573 without paging through individual episodes. v0.4.0+.",
574 "security": [{ "bearerAuth": [] }, {}],
575 "parameters": [
576 { "name": "window_days", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Optional time window. Omit for unfiltered (all-time, most-recent first)." },
577 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
578 ],
579 "responses": {
580 "200": {
581 "description": "Array of ThemeHits (possibly empty).",
582 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ThemeHit" } } } }
583 },
584 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
585 }
586 }
587 },
588 "/memory/facts_about": {
589 "get": {
590 "summary": "Query the SPO knowledge graph by subject",
591 "description":
592 "Equivalent to MCP tool `memory_facts_about`. Query Steward-extracted triples by \
593 subject + optional predicate + optional time window. Subject is required \
594 (predicate-only scans not supported). v0.4.0+.",
595 "security": [{ "bearerAuth": [] }, {}],
596 "parameters": [
597 { "name": "subject", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Subject id to query (e.g. `Sam`)." },
598 { "name": "predicate", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional predicate filter (e.g. `works_at`)." },
599 { "name": "since_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_from_ms lower bound (epoch ms)." },
600 { "name": "until_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through." },
601 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
602 ],
603 "responses": {
604 "200": {
605 "description": "Array of FactHits (possibly empty).",
606 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactHit" } } } }
607 },
608 "400": { "description": "Bad request (e.g. empty subject).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
609 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
610 }
611 }
612 },
613 "/memory/contradictions": {
614 "get": {
615 "summary": "List Steward-flagged contradictions",
616 "description":
617 "Equivalent to MCP tool `memory_contradictions`. Each result includes both \
618 sides' triple SPO via LEFT JOIN for context. v0.4.0+.",
619 "security": [{ "bearerAuth": [] }, {}],
620 "parameters": [
621 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
622 ],
623 "responses": {
624 "200": {
625 "description": "Array of ContradictionHits (possibly empty).",
626 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ContradictionHit" } } } }
627 },
628 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
629 }
630 }
631 }
632 }
633 })
634}
635
636#[derive(Debug, Deserialize)]
641struct RememberBody {
642 content: String,
643 #[serde(default)]
644 source_type: Option<String>,
645 #[serde(default)]
646 source_id: Option<String>,
647}
648
649#[derive(Debug, Serialize)]
650struct RememberResponse {
651 memory_id: String,
652}
653
654async fn remember_handler(
655 State(s): State<SoloHttpState>,
656 Json(body): Json<RememberBody>,
657) -> Result<Json<RememberResponse>, ApiError> {
658 let content = body.content.trim_end().to_string();
659 if content.is_empty() {
660 return Err(ApiError::bad_request("content must not be empty"));
661 }
662 let embedding = s.embedder.embed(&content).await.map_err(ApiError::from)?;
663 let episode = Episode {
664 memory_id: MemoryId::new(),
665 ts_ms: chrono::Utc::now().timestamp_millis(),
666 source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
667 source_id: body.source_id,
668 content,
669 encoding_context: EncodingContext::default(),
670 provenance: None,
671 confidence: Confidence::new(0.9).unwrap(),
672 strength: 0.5,
673 salience: 0.5,
674 tier: Tier::Hot,
675 };
676 let mid = s.write.remember(episode, embedding).await.map_err(ApiError::from)?;
677 Ok(Json(RememberResponse {
678 memory_id: mid.to_string(),
679 }))
680}
681
682#[derive(Debug, Deserialize)]
683struct RecallBody {
684 query: String,
685 #[serde(default = "default_limit")]
686 limit: usize,
687}
688
689fn default_limit() -> usize {
690 5
691}
692
693async fn recall_handler(
694 State(s): State<SoloHttpState>,
695 Json(body): Json<RecallBody>,
696) -> Result<Json<solo_query::RecallResult>, ApiError> {
697 let result = solo_query::run_recall(
701 &s.embedder,
702 &s.hnsw,
703 &s.pool,
704 &body.query,
705 body.limit,
706 )
707 .await
708 .map_err(ApiError::from)?;
709 Ok(Json(result))
710}
711
712async fn inspect_handler(
713 State(s): State<SoloHttpState>,
714 Path(id): Path<String>,
715) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
716 let mid = MemoryId::from_str(&id)
717 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
718 let row = solo_query::inspect_one(&s.pool, mid)
719 .await
720 .map_err(ApiError::from)?;
721 Ok(Json(row))
722}
723
724#[derive(Debug, Deserialize)]
731struct ThemesQuery {
732 #[serde(default)]
733 window_days: Option<i64>,
734 #[serde(default = "default_limit")]
735 limit: usize,
736}
737
738async fn themes_handler(
739 State(s): State<SoloHttpState>,
740 Query(q): Query<ThemesQuery>,
741) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
742 let hits = solo_query::themes(&s.pool, q.window_days, q.limit)
743 .await
744 .map_err(ApiError::from)?;
745 Ok(Json(hits))
746}
747
748#[derive(Debug, Deserialize)]
749struct FactsAboutQuery {
750 subject: String,
751 #[serde(default)]
752 predicate: Option<String>,
753 #[serde(default)]
754 since_ms: Option<i64>,
755 #[serde(default)]
756 until_ms: Option<i64>,
757 #[serde(default = "default_limit")]
758 limit: usize,
759}
760
761async fn facts_about_handler(
762 State(s): State<SoloHttpState>,
763 Query(q): Query<FactsAboutQuery>,
764) -> Result<Json<Vec<solo_query::FactHit>>, ApiError> {
765 if q.subject.trim().is_empty() {
766 return Err(ApiError::bad_request("subject must not be empty"));
767 }
768 let hits = solo_query::facts_about(
769 &s.pool,
770 &q.subject,
771 q.predicate.as_deref(),
772 q.since_ms,
773 q.until_ms,
774 q.limit,
775 )
776 .await
777 .map_err(ApiError::from)?;
778 Ok(Json(hits))
779}
780
781#[derive(Debug, Deserialize)]
782struct ContradictionsQuery {
783 #[serde(default = "default_limit")]
784 limit: usize,
785}
786
787async fn contradictions_handler(
788 State(s): State<SoloHttpState>,
789 Query(q): Query<ContradictionsQuery>,
790) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
791 let hits = solo_query::contradictions(&s.pool, q.limit)
792 .await
793 .map_err(ApiError::from)?;
794 Ok(Json(hits))
795}
796
797#[derive(Debug, Deserialize)]
798struct ForgetQuery {
799 #[serde(default)]
800 reason: Option<String>,
801}
802
803async fn forget_handler(
804 State(s): State<SoloHttpState>,
805 Path(id): Path<String>,
806 Query(q): Query<ForgetQuery>,
807) -> Result<StatusCode, ApiError> {
808 let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
809 let reason = q.reason.unwrap_or_else(|| "http".into());
810 s.write.forget(mid, reason).await.map_err(ApiError::from)?;
811 Ok(StatusCode::NO_CONTENT)
812}
813
814async fn consolidate_handler(
815 State(s): State<SoloHttpState>,
816 body: axum::body::Bytes,
817) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
818 let scope = if body.is_empty() {
824 solo_storage::ConsolidationScope::default()
825 } else {
826 serde_json::from_slice(&body)
827 .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
828 };
829 let report = s.write.consolidate(scope).await.map_err(ApiError::from)?;
830 Ok(Json(report))
831}
832
833#[derive(Debug, Deserialize)]
834struct BackupBody {
835 to: String,
839 #[serde(default)]
840 force: bool,
841}
842
843#[derive(Debug, Serialize)]
844struct BackupResponse {
845 path: String,
846 elapsed_ms: u64,
847}
848
849async fn backup_handler(
850 State(s): State<SoloHttpState>,
851 Json(body): Json<BackupBody>,
852) -> Result<Json<BackupResponse>, ApiError> {
853 use std::path::PathBuf;
854
855 let dest = PathBuf::from(&body.to);
856 if dest.as_os_str().is_empty() {
857 return Err(ApiError::bad_request("`to` must not be empty"));
858 }
859 if solo_storage::paths_refer_to_same_file(&s.source_db_path, &dest) {
865 return Err(ApiError::bad_request(format!(
866 "destination {} is the same file as the source database; \
867 refusing to run (would corrupt the live database)",
868 dest.display()
869 )));
870 }
871 if dest.exists() {
872 if !body.force {
873 return Err(ApiError::bad_request(format!(
874 "destination {} exists; pass force=true to overwrite",
875 dest.display()
876 )));
877 }
878 std::fs::remove_file(&dest).map_err(|e| {
879 ApiError::internal(format!(
880 "remove existing destination {}: {e}",
881 dest.display()
882 ))
883 })?;
884 }
885 if let Some(parent) = dest.parent() {
886 if !parent.as_os_str().is_empty() && !parent.is_dir() {
887 return Err(ApiError::bad_request(format!(
888 "destination parent directory {} does not exist",
889 parent.display()
890 )));
891 }
892 }
893
894 let started = std::time::Instant::now();
895 s.write.backup(dest.clone()).await.map_err(ApiError::from)?;
896 let elapsed_ms = started.elapsed().as_millis() as u64;
897
898 Ok(Json(BackupResponse {
899 path: dest.display().to_string(),
900 elapsed_ms,
901 }))
902}
903
904#[derive(Debug)]
909pub struct ApiError {
910 status: StatusCode,
911 message: String,
912}
913
914impl ApiError {
915 fn bad_request(msg: impl Into<String>) -> Self {
916 Self {
917 status: StatusCode::BAD_REQUEST,
918 message: msg.into(),
919 }
920 }
921 fn not_found(msg: impl Into<String>) -> Self {
922 Self {
923 status: StatusCode::NOT_FOUND,
924 message: msg.into(),
925 }
926 }
927 fn internal(msg: impl Into<String>) -> Self {
928 Self {
929 status: StatusCode::INTERNAL_SERVER_ERROR,
930 message: msg.into(),
931 }
932 }
933}
934
935impl From<solo_core::Error> for ApiError {
936 fn from(e: solo_core::Error) -> Self {
937 use solo_core::Error;
938 match e {
939 Error::NotFound(msg) => ApiError::not_found(msg),
940 Error::InvalidInput(msg) => ApiError::bad_request(msg),
941 Error::Conflict(msg) => Self {
942 status: StatusCode::CONFLICT,
943 message: msg,
944 },
945 other => ApiError::internal(other.to_string()),
946 }
947 }
948}
949
950impl IntoResponse for ApiError {
951 fn into_response(self) -> Response {
952 let body = serde_json::json!({
953 "error": self.message,
954 "status": self.status.as_u16(),
955 });
956 (self.status, Json(body)).into_response()
957 }
958}
959
960#[cfg(test)]
964mod handler_tests {
965 use super::*;
974 use axum::body::Body;
975 use axum::http::{Request, StatusCode};
976 use http_body_util::BodyExt;
977 use serde_json::{Value, json};
978 use solo_core::VectorIndex as _;
979 use solo_storage::test_support::StubVectorIndex;
980 use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
981 use std::sync::Arc as StdArc;
982 use tower::ServiceExt;
983
984 struct Harness {
985 router: axum::Router,
986 _tmp: tempfile::TempDir,
987 write_handle_extra: Option<solo_storage::WriteHandle>,
988 join: Option<std::thread::JoinHandle<()>>,
989 }
990
991 impl Harness {
992 fn new(runtime: &tokio::runtime::Runtime) -> Self {
993 Self::new_with_auth(runtime, None)
994 }
995
996 fn new_with_auth(
997 runtime: &tokio::runtime::Runtime,
998 bearer_token: Option<String>,
999 ) -> Self {
1000 use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
1001
1002 let tmp = tempfile::TempDir::new().unwrap();
1003 let dim = 16usize;
1004 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1005 let embedder: StdArc<dyn solo_core::Embedder> =
1006 StdArc::new(StubEmbedder::new("stub", "v1", dim));
1007 let path = tmp.path().join("test.db");
1008
1009 let embedder_id = {
1016 let conn = solo_storage::test_support::open_test_db_at(&path);
1017 get_or_insert_embedder_id(
1018 &conn,
1019 &EmbedderIdentity {
1020 name: "stub".into(),
1021 version: "v1".into(),
1022 dim: dim as u32,
1023 dtype: "f32".into(),
1024 },
1025 )
1026 .unwrap()
1027 };
1028
1029 let conn = solo_storage::test_support::open_test_db_at(&path);
1030 let WriterSpawn { handle, join } = WriterActor::spawn_full(
1031 conn,
1032 hnsw.clone(),
1033 tmp.path().to_path_buf(),
1034 embedder_id,
1035 );
1036 let pool: ReaderPool =
1037 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1038 let state = SoloHttpState {
1039 write: handle.clone(),
1040 pool,
1041 embedder,
1042 hnsw,
1043 source_db_path: path.clone(),
1044 };
1045 let router = router_with_auth(state, bearer_token);
1046 Harness {
1047 router,
1048 _tmp: tmp,
1049 write_handle_extra: Some(handle),
1050 join: Some(join),
1051 }
1052 }
1053
1054 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1055 let join = self.join.take();
1056 let extra = self.write_handle_extra.take();
1057 runtime.block_on(async move {
1058 drop(extra);
1059 drop(self.router); drop(self._tmp);
1061 if let Some(join) = join {
1062 let (tx, rx) = std::sync::mpsc::channel();
1063 std::thread::spawn(move || {
1064 let _ = tx.send(join.join());
1065 });
1066 tokio::task::spawn_blocking(move || {
1067 rx.recv_timeout(std::time::Duration::from_secs(5))
1068 })
1069 .await
1070 .expect("blocking task")
1071 .expect("writer thread did not exit within 5s")
1072 .expect("writer thread panicked");
1073 }
1074 });
1075 }
1076 }
1077
1078 fn rt() -> tokio::runtime::Runtime {
1079 tokio::runtime::Builder::new_multi_thread()
1080 .worker_threads(2)
1081 .enable_all()
1082 .build()
1083 .unwrap()
1084 }
1085
1086 async fn call(
1090 router: axum::Router,
1091 method: &str,
1092 uri: &str,
1093 body: Option<Value>,
1094 ) -> (StatusCode, Value) {
1095 call_with_auth(router, method, uri, body, None).await
1096 }
1097
1098 async fn call_with_auth(
1099 router: axum::Router,
1100 method: &str,
1101 uri: &str,
1102 body: Option<Value>,
1103 auth: Option<&str>,
1104 ) -> (StatusCode, Value) {
1105 let mut req_builder = Request::builder()
1106 .method(method)
1107 .uri(uri)
1108 .header("content-type", "application/json");
1109 if let Some(a) = auth {
1110 req_builder = req_builder.header("authorization", a);
1111 }
1112 let req = if let Some(b) = body {
1113 let bytes = serde_json::to_vec(&b).unwrap();
1114 req_builder.body(Body::from(bytes)).unwrap()
1115 } else {
1116 req_builder = req_builder.header("content-length", "0");
1117 req_builder.body(Body::empty()).unwrap()
1118 };
1119 let resp = router.oneshot(req).await.expect("oneshot");
1120 let status = resp.status();
1121 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
1122 let v: Value = if body_bytes.is_empty() {
1123 Value::Null
1124 } else {
1125 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
1126 };
1127 (status, v)
1128 }
1129
1130 #[test]
1131 fn health_returns_ok() {
1132 let runtime = rt();
1133 let h = Harness::new(&runtime);
1134 let r = h.router.clone();
1135 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1136 assert_eq!(status, StatusCode::OK);
1137 h.shutdown(&runtime);
1138 }
1139
1140 #[test]
1145 fn openapi_json_describes_all_endpoints() {
1146 let runtime = rt();
1147 let h = Harness::new(&runtime);
1148 let r = h.router.clone();
1149 let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1150 assert_eq!(status, StatusCode::OK);
1151 assert!(spec.is_object(), "openapi.json must be a JSON object");
1152
1153 assert!(
1155 spec.get("openapi")
1156 .and_then(|v| v.as_str())
1157 .is_some_and(|s| s.starts_with("3.")),
1158 "missing or wrong openapi version: {spec}"
1159 );
1160 assert!(spec.pointer("/info/title").is_some());
1161 assert!(spec.pointer("/info/version").is_some());
1162
1163 let paths = spec
1165 .get("paths")
1166 .and_then(|v| v.as_object())
1167 .expect("paths must be an object");
1168 for expected in [
1169 "/health",
1170 "/openapi.json",
1171 "/memory",
1172 "/memory/search",
1173 "/memory/consolidate",
1174 "/memory/{id}",
1175 "/memory/themes",
1177 "/memory/facts_about",
1178 "/memory/contradictions",
1179 ] {
1180 assert!(
1181 paths.contains_key(expected),
1182 "openapi paths missing {expected}: {paths:?}"
1183 );
1184 }
1185
1186 let memid = paths.get("/memory/{id}").expect("memory/{id}");
1189 assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1190 assert!(
1191 memid.get("delete").is_some(),
1192 "DELETE /memory/{{id}} undocumented"
1193 );
1194
1195 for schema_name in [
1197 "RememberRequest",
1198 "RememberResponse",
1199 "RecallRequest",
1200 "RecallResult",
1201 "EpisodeRecord",
1202 "ApiError",
1203 "ConsolidationScope",
1204 "ConsolidationReport",
1205 "ThemeHit",
1207 "FactHit",
1208 "ContradictionHit",
1209 ] {
1210 let ptr = format!("/components/schemas/{schema_name}");
1211 assert!(
1212 spec.pointer(&ptr).is_some(),
1213 "component schema {schema_name} missing"
1214 );
1215 }
1216
1217 assert!(
1219 spec.pointer("/components/securitySchemes/bearerAuth")
1220 .is_some(),
1221 "bearerAuth security scheme missing"
1222 );
1223
1224 h.shutdown(&runtime);
1225 }
1226
1227 #[test]
1231 fn openapi_json_is_exempt_from_bearer_auth() {
1232 let runtime = rt();
1233 let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1234 let r = h.router.clone();
1235 let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1237 assert_eq!(status, StatusCode::OK);
1238 h.shutdown(&runtime);
1239 }
1240
1241 #[test]
1242 fn remember_returns_memory_id() {
1243 let runtime = rt();
1244 let h = Harness::new(&runtime);
1245 let r = h.router.clone();
1246 let (status, body) = runtime.block_on(call(
1247 r,
1248 "POST",
1249 "/memory",
1250 Some(json!({ "content": "http harness test" })),
1251 ));
1252 assert_eq!(status, StatusCode::OK);
1253 let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1254 assert_eq!(mid.len(), 36, "uuid length");
1255 h.shutdown(&runtime);
1256 }
1257
1258 #[test]
1259 fn empty_content_returns_400() {
1260 let runtime = rt();
1261 let h = Harness::new(&runtime);
1262 let r = h.router.clone();
1263 let (status, body) =
1264 runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1265 assert_eq!(status, StatusCode::BAD_REQUEST);
1266 assert!(
1267 body.get("error")
1268 .and_then(|e| e.as_str())
1269 .map(|s| s.contains("must not be empty"))
1270 .unwrap_or(false),
1271 "got: {body}"
1272 );
1273 h.shutdown(&runtime);
1274 }
1275
1276 #[test]
1277 fn empty_query_returns_400() {
1278 let runtime = rt();
1279 let h = Harness::new(&runtime);
1280 let r = h.router.clone();
1281 let (status, body) = runtime.block_on(call(
1282 r,
1283 "POST",
1284 "/memory/search",
1285 Some(json!({ "query": "" })),
1286 ));
1287 assert_eq!(status, StatusCode::BAD_REQUEST);
1288 assert!(
1289 body.get("error")
1290 .and_then(|e| e.as_str())
1291 .map(|s| s.contains("must not be empty"))
1292 .unwrap_or(false),
1293 "got: {body}"
1294 );
1295 h.shutdown(&runtime);
1296 }
1297
1298 #[test]
1299 fn inspect_unknown_returns_404() {
1300 let runtime = rt();
1301 let h = Harness::new(&runtime);
1302 let r = h.router.clone();
1303 let (status, body) = runtime.block_on(call(
1304 r,
1305 "GET",
1306 "/memory/00000000-0000-7000-8000-000000000000",
1307 None,
1308 ));
1309 assert_eq!(status, StatusCode::NOT_FOUND);
1310 assert!(body.get("error").is_some(), "got: {body}");
1311 h.shutdown(&runtime);
1312 }
1313
1314 #[test]
1315 fn inspect_invalid_id_returns_400() {
1316 let runtime = rt();
1317 let h = Harness::new(&runtime);
1318 let r = h.router.clone();
1319 let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1320 assert_eq!(status, StatusCode::BAD_REQUEST);
1321 h.shutdown(&runtime);
1322 }
1323
1324 #[test]
1325 fn forget_unknown_returns_404() {
1326 let runtime = rt();
1327 let h = Harness::new(&runtime);
1328 let r = h.router.clone();
1329 let (status, _body) = runtime.block_on(call(
1330 r,
1331 "DELETE",
1332 "/memory/00000000-0000-7000-8000-000000000000",
1333 None,
1334 ));
1335 assert_eq!(status, StatusCode::NOT_FOUND);
1336 h.shutdown(&runtime);
1337 }
1338
1339 #[test]
1347 fn consolidate_endpoint_returns_report() {
1348 let runtime = rt();
1349 let h = Harness::new(&runtime);
1350 let r = h.router.clone();
1351 runtime.block_on(async move {
1352 let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1354 assert_eq!(status, StatusCode::OK);
1355 for field in [
1356 "episodes_seen",
1357 "clusters_built",
1358 "episodes_clustered",
1359 "abstractions_built",
1360 "triples_built",
1361 "contradictions_found",
1362 ] {
1363 assert!(
1364 body.get(field).and_then(|v| v.as_u64()).is_some(),
1365 "missing field {field}: {body}"
1366 );
1367 }
1368 assert_eq!(body["episodes_seen"], 0);
1369 assert_eq!(body["clusters_built"], 0);
1370
1371 let (status2, _body2) = call(
1374 r,
1375 "POST",
1376 "/memory/consolidate",
1377 Some(json!({ "window_days": 7 })),
1378 )
1379 .await;
1380 assert_eq!(status2, StatusCode::OK);
1381 });
1382 h.shutdown(&runtime);
1383 }
1384
1385 #[test]
1386 fn auth_required_routes_reject_missing_token() {
1387 let runtime = rt();
1388 let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1389 let r = h.router.clone();
1390 runtime.block_on(async move {
1391 let (status, _body) = call(
1393 r.clone(),
1394 "POST",
1395 "/memory",
1396 Some(json!({ "content": "x" })),
1397 )
1398 .await;
1399 assert_eq!(status, StatusCode::UNAUTHORIZED);
1400
1401 let (status, _body) = call_with_auth(
1403 r.clone(),
1404 "POST",
1405 "/memory",
1406 Some(json!({ "content": "x" })),
1407 Some("Bearer wrong-token"),
1408 )
1409 .await;
1410 assert_eq!(status, StatusCode::UNAUTHORIZED);
1411
1412 let (status, body) = call_with_auth(
1414 r.clone(),
1415 "POST",
1416 "/memory",
1417 Some(json!({ "content": "authed" })),
1418 Some("Bearer secret-xyz"),
1419 )
1420 .await;
1421 assert_eq!(status, StatusCode::OK);
1422 assert!(body.get("memory_id").is_some());
1423 });
1424 h.shutdown(&runtime);
1425 }
1426
1427 #[test]
1428 fn health_endpoint_does_not_require_auth() {
1429 let runtime = rt();
1430 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1431 let r = h.router.clone();
1432 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1433 assert_eq!(status, StatusCode::OK);
1435 h.shutdown(&runtime);
1436 }
1437
1438 #[test]
1439 fn auth_response_includes_www_authenticate_header() {
1440 let runtime = rt();
1445 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1446 let r = h.router.clone();
1447 runtime.block_on(async move {
1448 let req = Request::builder()
1449 .method("POST")
1450 .uri("/memory")
1451 .header("content-type", "application/json")
1452 .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
1453 .unwrap();
1454 let resp = r.oneshot(req).await.unwrap();
1455 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1456 let www = resp
1457 .headers()
1458 .get("www-authenticate")
1459 .and_then(|v| v.to_str().ok())
1460 .unwrap_or("");
1461 assert!(
1462 www.starts_with("Bearer"),
1463 "expected WWW-Authenticate: Bearer..., got: {www}"
1464 );
1465 });
1466 h.shutdown(&runtime);
1467 }
1468
1469 #[test]
1470 fn full_remember_recall_inspect_forget_round_trip() {
1471 let runtime = rt();
1472 let h = Harness::new(&runtime);
1473 let r = h.router.clone();
1474 runtime.block_on(async move {
1475 let (status, body) = call(
1477 r.clone(),
1478 "POST",
1479 "/memory",
1480 Some(json!({ "content": "round-trip content" })),
1481 )
1482 .await;
1483 assert_eq!(status, StatusCode::OK);
1484 let mid = body
1485 .get("memory_id")
1486 .and_then(|v| v.as_str())
1487 .unwrap()
1488 .to_string();
1489
1490 let (status, body) = call(
1492 r.clone(),
1493 "POST",
1494 "/memory/search",
1495 Some(json!({ "query": "round-trip content", "limit": 5 })),
1496 )
1497 .await;
1498 assert_eq!(status, StatusCode::OK);
1499 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1500 assert!(
1501 hits.iter()
1502 .any(|h| h.get("content").and_then(|c| c.as_str())
1503 == Some("round-trip content")),
1504 "expected hit with content; got: {body}"
1505 );
1506
1507 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1509 assert_eq!(status, StatusCode::OK);
1510 assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
1511
1512 let (status, _body) =
1514 call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1515 assert_eq!(status, StatusCode::NO_CONTENT);
1516
1517 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1519 assert_eq!(status, StatusCode::OK);
1520 assert_eq!(
1521 body.get("status").and_then(|v| v.as_str()),
1522 Some("forgotten")
1523 );
1524
1525 let (status, body) = call(
1527 r.clone(),
1528 "POST",
1529 "/memory/search",
1530 Some(json!({ "query": "round-trip content", "limit": 5 })),
1531 )
1532 .await;
1533 assert_eq!(status, StatusCode::OK);
1534 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1535 assert!(
1536 hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
1537 != Some(mid.as_str())),
1538 "forgotten row should be excluded from recall: {body}"
1539 );
1540 });
1541 h.shutdown(&runtime);
1542 }
1543
1544 #[test]
1551 fn themes_endpoint_returns_empty_array_on_empty_db() {
1552 let runtime = rt();
1553 let h = Harness::new(&runtime);
1554 let r = h.router.clone();
1555 let (status, body) =
1556 runtime.block_on(call(r, "GET", "/memory/themes", None));
1557 assert_eq!(status, StatusCode::OK);
1558 assert!(body.is_array(), "expected array, got {body}");
1559 assert_eq!(body.as_array().unwrap().len(), 0);
1560 h.shutdown(&runtime);
1561 }
1562
1563 #[test]
1564 fn themes_endpoint_passes_through_query_params() {
1565 let runtime = rt();
1566 let h = Harness::new(&runtime);
1567 let r = h.router.clone();
1568 let (status, body) = runtime.block_on(call(
1569 r,
1570 "GET",
1571 "/memory/themes?window_days=7&limit=20",
1572 None,
1573 ));
1574 assert_eq!(status, StatusCode::OK);
1575 assert!(body.is_array(), "expected array, got {body}");
1576 h.shutdown(&runtime);
1577 }
1578
1579 #[test]
1580 fn facts_about_endpoint_requires_subject() {
1581 let runtime = rt();
1582 let h = Harness::new(&runtime);
1583 let r = h.router.clone();
1584 let (status, _body) =
1588 runtime.block_on(call(r, "GET", "/memory/facts_about", None));
1589 assert!(
1590 status == StatusCode::BAD_REQUEST
1591 || status == StatusCode::UNPROCESSABLE_ENTITY,
1592 "expected 400 or 422 for missing subject, got {status}"
1593 );
1594 h.shutdown(&runtime);
1595 }
1596
1597 #[test]
1598 fn facts_about_endpoint_rejects_blank_subject() {
1599 let runtime = rt();
1600 let h = Harness::new(&runtime);
1601 let r = h.router.clone();
1602 let (status, body) = runtime.block_on(call(
1605 r,
1606 "GET",
1607 "/memory/facts_about?subject=%20%20",
1608 None,
1609 ));
1610 assert_eq!(status, StatusCode::BAD_REQUEST);
1611 assert!(
1612 body.get("error")
1613 .and_then(|v| v.as_str())
1614 .is_some_and(|s| s.contains("subject")),
1615 "expected error mentioning subject, got {body}"
1616 );
1617 h.shutdown(&runtime);
1618 }
1619
1620 #[test]
1621 fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
1622 let runtime = rt();
1623 let h = Harness::new(&runtime);
1624 let r = h.router.clone();
1625 let (status, body) = runtime.block_on(call(
1626 r,
1627 "GET",
1628 "/memory/facts_about?subject=NobodyKnows",
1629 None,
1630 ));
1631 assert_eq!(status, StatusCode::OK);
1632 assert_eq!(body.as_array().unwrap().len(), 0);
1633 h.shutdown(&runtime);
1634 }
1635
1636 #[test]
1637 fn contradictions_endpoint_returns_empty_array_on_empty_db() {
1638 let runtime = rt();
1639 let h = Harness::new(&runtime);
1640 let r = h.router.clone();
1641 let (status, body) = runtime.block_on(call(
1642 r,
1643 "GET",
1644 "/memory/contradictions",
1645 None,
1646 ));
1647 assert_eq!(status, StatusCode::OK);
1648 assert!(body.is_array());
1649 assert_eq!(body.as_array().unwrap().len(), 0);
1650 h.shutdown(&runtime);
1651 }
1652
1653 #[test]
1654 fn derived_endpoints_require_bearer_when_auth_enabled() {
1655 let runtime = rt();
1656 let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
1657 for path in [
1664 "/memory/themes",
1665 "/memory/facts_about?subject=Sam",
1666 "/memory/contradictions",
1667 ] {
1668 let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
1669 assert_eq!(
1670 status,
1671 StatusCode::UNAUTHORIZED,
1672 "{path} should 401 without token"
1673 );
1674 }
1675 h.shutdown(&runtime);
1676 }
1677}
1678
1679#[cfg(test)]
1680mod cors_tests {
1681 use super::is_localhost_origin;
1682
1683 #[test]
1684 fn accepts_canonical_localhost_origins() {
1685 assert!(is_localhost_origin("http://localhost"));
1686 assert!(is_localhost_origin("http://localhost:3000"));
1687 assert!(is_localhost_origin("https://localhost:8443"));
1688 assert!(is_localhost_origin("http://127.0.0.1"));
1689 assert!(is_localhost_origin("http://127.0.0.1:5173"));
1690 assert!(is_localhost_origin("http://[::1]"));
1691 assert!(is_localhost_origin("http://[::1]:8080"));
1692 }
1693
1694 #[test]
1695 fn rejects_remote_origins() {
1696 assert!(!is_localhost_origin("http://example.com"));
1697 assert!(!is_localhost_origin("https://malicious.example"));
1698 assert!(!is_localhost_origin("http://192.168.1.5"));
1699 assert!(!is_localhost_origin("http://10.0.0.1"));
1700 }
1701
1702 #[test]
1703 fn rejects_dns_rebinding_tricks() {
1704 assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
1708 assert!(!is_localhost_origin("http://localhost.evil.com"));
1709 assert!(!is_localhost_origin("http://evil.localhost"));
1710 }
1711
1712 #[test]
1713 fn rejects_non_http_schemes() {
1714 assert!(!is_localhost_origin("file:///"));
1715 assert!(!is_localhost_origin("ws://localhost:3000"));
1716 assert!(!is_localhost_origin("javascript:alert(1)"));
1717 }
1718
1719 #[test]
1720 fn rejects_malformed() {
1721 assert!(!is_localhost_origin(""));
1722 assert!(!is_localhost_origin("localhost"));
1723 assert!(!is_localhost_origin("//localhost"));
1724 }
1725}
1726