1use std::net::SocketAddr;
43use std::str::FromStr;
44use std::sync::Arc;
45
46use axum::extract::{FromRequestParts, Path, Query, State};
47use axum::http::request::Parts;
48use axum::http::{HeaderValue, Method, StatusCode};
49use axum::response::{IntoResponse, Response};
50use axum::routing::{get, post};
51use axum::{Json, Router};
52use serde::{Deserialize, Serialize};
53use solo_core::{
54 Confidence, DocumentId, EncodingContext, Episode, MemoryId, TenantId, Tier,
55};
56use solo_storage::{TenantHandle, TenantRegistry};
57use tower_http::cors::{AllowOrigin, CorsLayer};
58use tower_http::trace::TraceLayer;
59
60use crate::auth::{AuthConfig, AuthenticatedPrincipal, middleware::AuthValidator};
61
62#[derive(Clone)]
66pub struct SoloHttpState {
67 pub registry: Arc<TenantRegistry>,
69 pub default_tenant: TenantId,
72 pub user_aliases: Arc<Vec<String>>,
79}
80
81pub const TENANT_HEADER: &str = "x-solo-tenant";
84
85pub struct TenantExtractor(pub Arc<TenantHandle>);
101
102impl<S> FromRequestParts<S> for TenantExtractor
103where
104 SoloHttpState: FromRef<S>,
105 S: Send + Sync,
106{
107 type Rejection = ApiError;
108
109 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
110 let state = SoloHttpState::from_ref(state);
111 let resolved = if let Some(principal) = parts.extensions.get::<AuthenticatedPrincipal>()
118 && let Some(claim) = principal.tenant_claim.clone()
119 {
120 claim
121 } else {
122 match parts.headers.get(TENANT_HEADER) {
123 None => state.default_tenant.clone(),
124 Some(raw) => {
125 let s = raw.to_str().map_err(|e| {
126 ApiError::bad_request(format!(
127 "{TENANT_HEADER}: header value must be ASCII ({e})"
128 ))
129 })?;
130 TenantId::new(s.to_string()).map_err(|e| {
131 ApiError::bad_request(format!("{TENANT_HEADER}: invalid tenant id: {e}"))
132 })?
133 }
134 }
135 };
136 let handle = state.registry.get_or_open(&resolved).await.map_err(|e| {
137 use solo_core::Error;
139 match &e {
140 Error::NotFound(_) => ApiError::not_found(e.to_string()),
141 Error::InvalidInput(_) => ApiError::bad_request(e.to_string()),
142 _ => ApiError::internal(e.to_string()),
143 }
144 })?;
145 Ok(TenantExtractor(handle))
146 }
147}
148
149use axum::extract::FromRef;
150
151pub struct AuditPrincipal(pub Option<String>);
156
157impl<S> FromRequestParts<S> for AuditPrincipal
158where
159 S: Send + Sync,
160{
161 type Rejection = std::convert::Infallible;
162
163 async fn from_request_parts(
164 parts: &mut Parts,
165 _state: &S,
166 ) -> Result<Self, Self::Rejection> {
167 Ok(AuditPrincipal(
168 parts
169 .extensions
170 .get::<AuthenticatedPrincipal>()
171 .map(|p| p.subject.clone()),
172 ))
173 }
174}
175
176pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
185 let auth = bearer_token.map(|token| AuthConfig::Bearer { token });
186 router_with_auth_config(state, auth)
187}
188
189pub fn router_with_auth_config(state: SoloHttpState, auth: Option<AuthConfig>) -> Router {
200 let cors = build_cors_layer();
201 let public = Router::new()
209 .route("/health", get(|| async { "ok" }))
210 .route("/openapi.json", get(openapi_handler));
211
212 let authed = Router::new()
213 .route("/memory", post(remember_handler))
214 .route("/memory/search", post(recall_handler))
215 .route("/memory/consolidate", post(consolidate_handler))
216 .route("/memory/{id}", get(inspect_handler).delete(forget_handler))
217 .route("/backup", post(backup_handler))
218 .route("/memory/themes", get(themes_handler))
222 .route("/memory/facts_about", get(facts_about_handler))
223 .route("/memory/contradictions", get(contradictions_handler))
224 .route(
229 "/memory/clusters/{cluster_id}",
230 get(inspect_cluster_handler),
231 )
232 .route(
239 "/memory/documents/search",
240 post(search_docs_handler),
241 )
242 .route(
243 "/memory/documents",
244 post(ingest_document_handler).get(list_documents_handler),
245 )
246 .route(
247 "/memory/documents/{id}",
248 get(inspect_document_handler).delete(forget_document_handler),
249 )
250 .with_state(state.clone());
251
252 let authed = if let Some(cfg) = auth {
253 let validator = Arc::new(AuthValidator::from_config(
257 &cfg,
258 state.default_tenant.clone(),
259 ));
260 authed.layer(axum::middleware::from_fn_with_state(
261 validator,
262 crate::auth::middleware::auth_middleware,
263 ))
264 } else {
265 authed
266 };
267
268 public
269 .merge(authed)
270 .layer(cors)
271 .layer(TraceLayer::new_for_http())
272}
273
274pub fn router(state: SoloHttpState) -> Router {
276 router_with_auth_config(state, None)
277}
278
279fn build_cors_layer() -> CorsLayer {
280 CorsLayer::new()
294 .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
295 origin
296 .to_str()
297 .map(is_localhost_origin)
298 .unwrap_or(false)
299 }))
300 .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
301 .allow_headers([
302 axum::http::header::CONTENT_TYPE,
303 axum::http::header::AUTHORIZATION,
304 ])
305}
306
307fn is_localhost_origin(origin: &str) -> bool {
311 let rest = origin
312 .strip_prefix("http://")
313 .or_else(|| origin.strip_prefix("https://"));
314 let host = match rest {
315 Some(r) => r,
316 None => return false,
317 };
318 let host = host.split('/').next().unwrap_or(host);
320 let host = if let Some(idx) = host.rfind(':') {
322 if host.starts_with('[') {
324 host.find(']')
326 .map(|i| &host[..=i])
327 .unwrap_or(host)
328 } else {
329 &host[..idx]
330 }
331 } else {
332 host
333 };
334 matches!(host, "localhost" | "127.0.0.1" | "[::1]")
335}
336
337pub async fn serve_http(
343 addr: SocketAddr,
344 state: SoloHttpState,
345 bearer_token: Option<String>,
346 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
347) -> std::io::Result<()> {
348 let auth = bearer_token.map(|token| AuthConfig::Bearer { token });
349 serve_http_with_auth_config(addr, state, auth, shutdown).await
350}
351
352pub async fn serve_http_with_auth_config(
356 addr: SocketAddr,
357 state: SoloHttpState,
358 auth: Option<AuthConfig>,
359 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
360) -> std::io::Result<()> {
361 let auth_kind = match &auth {
362 Some(AuthConfig::Bearer { .. }) => "bearer",
363 Some(AuthConfig::Oidc { .. }) => "oidc",
364 None => "none",
365 };
366 let app = router_with_auth_config(state, auth);
367 let listener = tokio::net::TcpListener::bind(addr).await?;
368 tracing::info!(%addr, auth = auth_kind, "solo http: listening");
369 axum::serve(listener, app)
370 .with_graceful_shutdown(shutdown)
371 .await
372}
373
374async fn openapi_handler() -> Json<serde_json::Value> {
388 Json(openapi_spec())
389}
390
391pub fn openapi_spec() -> serde_json::Value {
395 serde_json::json!({
396 "openapi": "3.1.0",
397 "info": {
398 "title": "Solo HTTP API",
399 "description":
400 "Local-first personal memory daemon. The HTTP transport \
401 mirrors the four MCP tools (memory_remember / recall / \
402 inspect / forget). Default deployment is loopback-only \
403 (127.0.0.1); LAN-bound deployments require a bearer \
404 token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
405 "version": env!("CARGO_PKG_VERSION"),
406 "license": { "name": "Apache-2.0" }
407 },
408 "servers": [
409 { "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
410 ],
411 "components": {
412 "securitySchemes": {
413 "bearerAuth": {
414 "type": "http",
415 "scheme": "bearer",
416 "description":
417 "Bearer-token auth. Required only on LAN-bound deployments \
418 (`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
419 the default `127.0.0.1` deployment is unauthenticated. \
420 `GET /health` and `GET /openapi.json` are exempt from auth even \
421 on bearer-protected instances."
422 }
423 },
424 "schemas": {
425 "RememberRequest": {
426 "type": "object",
427 "required": ["content"],
428 "properties": {
429 "content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
430 "source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
431 "source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
432 },
433 "additionalProperties": false
434 },
435 "RememberResponse": {
436 "type": "object",
437 "required": ["memory_id"],
438 "properties": {
439 "memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
440 }
441 },
442 "RecallRequest": {
443 "type": "object",
444 "required": ["query"],
445 "properties": {
446 "query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
447 "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
448 },
449 "additionalProperties": false
450 },
451 "RecallResult": {
452 "type": "object",
453 "description":
454 "Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
455 see `solo_query::RecallResult` in the source for the canonical shape. \
456 Treat as a forward-compatible JSON object.",
457 "additionalProperties": true
458 },
459 "ConsolidationScope": {
460 "type": "object",
461 "description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
462 "properties": {
463 "window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." },
464 "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." }
465 },
466 "additionalProperties": false
467 },
468 "ConsolidationReport": {
469 "type": "object",
470 "required": [
471 "episodes_seen", "clusters_built", "clusters_merged",
472 "clusters_absorbed", "existing_clusters_merged",
473 "episodes_clustered", "abstractions_built",
474 "abstractions_regenerated", "triples_built",
475 "contradictions_found"
476 ],
477 "properties": {
478 "episodes_seen": { "type": "integer", "minimum": 0 },
479 "clusters_built": { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
480 "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." },
481 "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." },
482 "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." },
483 "episodes_clustered": { "type": "integer", "minimum": 0 },
484 "abstractions_built": { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
485 "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." },
486 "triples_built": { "type": "integer", "minimum": 0 },
487 "contradictions_found": { "type": "integer", "minimum": 0 }
488 }
489 },
490 "EpisodeRecord": {
491 "type": "object",
492 "description":
493 "Inspect response: full episode record. Fields are stable across v0.1 but not \
494 exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
495 Treat as a forward-compatible JSON object.",
496 "additionalProperties": true
497 },
498 "ThemeHit": {
499 "type": "object",
500 "description":
501 "One cluster + its (optional) abstraction. Returned by GET /memory/themes. \
502 See `solo_query::ThemeHit` for the canonical shape: cluster_id, \
503 abstraction_id?, abstraction_text?, episode_count, coherence, created_at_ms.",
504 "additionalProperties": true
505 },
506 "FactHit": {
507 "type": "object",
508 "description":
509 "One Steward-extracted SPO triple. Returned by GET /memory/facts_about. \
510 See `solo_query::FactHit` for fields: triple_id, subject_id, predicate, \
511 object_id, object_kind, valid_from_ms, valid_to_ms?, confidence, cluster_id?.",
512 "additionalProperties": true
513 },
514 "ContradictionHit": {
515 "type": "object",
516 "description":
517 "One Steward-flagged contradiction with each side's triple LEFT JOIN'd in. \
518 Returned by GET /memory/contradictions. See `solo_query::ContradictionHit`: \
519 a_id, b_id, kind, explanation, detected_at_ms, a_triple?, b_triple?.",
520 "additionalProperties": true
521 },
522 "ClusterRecord": {
523 "type": "object",
524 "description":
525 "Snapshot of one cluster — its row, optional abstraction, and source episodes \
526 (content truncated to 200 chars unless ?full_content=true). Returned by \
527 GET /memory/clusters/{cluster_id}. See `solo_query::ClusterRecord`.",
528 "additionalProperties": true
529 },
530 "IngestDocumentRequest": {
531 "type": "object",
532 "required": ["path"],
533 "properties": {
534 "path": {
535 "type": "string",
536 "minLength": 1,
537 "description":
538 "Server-side absolute path to the file to ingest. The file must be \
539 readable by the Solo process. Supported formats: plaintext / \
540 markdown / code, HTML, PDF."
541 }
542 },
543 "additionalProperties": false
544 },
545 "IngestReport": {
546 "type": "object",
547 "description":
548 "Returned by POST /memory/documents. Reports the document id assigned, \
549 the number of chunks persisted + embedded, the total byte size, and a \
550 `deduped` flag (true when the same content_hash was already present and \
551 the existing doc_id was returned unchanged). See `solo_storage::IngestReport`.",
552 "required": ["doc_id", "chunks_persisted", "bytes_ingested", "deduped"],
553 "properties": {
554 "doc_id": { "type": "string", "format": "uuid" },
555 "chunks_persisted": { "type": "integer", "minimum": 0 },
556 "bytes_ingested": { "type": "integer", "minimum": 0, "format": "int64" },
557 "deduped": { "type": "boolean" }
558 },
559 "additionalProperties": false
560 },
561 "ForgetDocumentReport": {
562 "type": "object",
563 "description":
564 "Returned by DELETE /memory/documents/{id}. Reports the doc_id soft-deleted \
565 and how many chunk rowids were tombstoned in the HNSW index. The chunk rows \
566 themselves survive in SQL for forensic value. See `solo_storage::ForgetDocumentReport`.",
567 "required": ["doc_id", "chunks_tombstoned"],
568 "properties": {
569 "doc_id": { "type": "string", "format": "uuid" },
570 "chunks_tombstoned": { "type": "integer", "minimum": 0 }
571 },
572 "additionalProperties": false
573 },
574 "SearchDocsRequest": {
575 "type": "object",
576 "required": ["query"],
577 "properties": {
578 "query": { "type": "string", "minLength": 1 },
579 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 }
580 },
581 "additionalProperties": false
582 },
583 "DocSearchHit": {
584 "type": "object",
585 "description":
586 "One chunk hit + parent-doc context. Fields per `solo_query::DocSearchHit`: \
587 chunk_id, doc_id, doc_title?, doc_source?, doc_mime_type?, chunk_index, \
588 content, cos_distance, start_offset, end_offset.",
589 "additionalProperties": true
590 },
591 "DocumentInspectResult": {
592 "type": "object",
593 "description":
594 "Returned by GET /memory/documents/{id}. A `document` record (full metadata) \
595 plus an ordered list of chunk summaries (each preview truncated to 200 \
596 chars). See `solo_query::DocumentInspectResult`.",
597 "additionalProperties": true
598 },
599 "DocumentSummary": {
600 "type": "object",
601 "description":
602 "One row from GET /memory/documents. Fields per `solo_query::DocumentSummary`: \
603 doc_id, title?, source?, mime_type?, ingested_at_ms, chunk_count, status.",
604 "additionalProperties": true
605 },
606 "ApiError": {
607 "type": "object",
608 "required": ["error", "status"],
609 "properties": {
610 "error": { "type": "string" },
611 "status": { "type": "integer", "minimum": 400, "maximum": 599 }
612 }
613 }
614 }
615 },
616 "paths": {
617 "/health": {
618 "get": {
619 "summary": "Liveness probe",
620 "description": "Returns plain text `ok`. Always unauthenticated.",
621 "responses": {
622 "200": {
623 "description": "Server is up.",
624 "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
625 }
626 }
627 }
628 },
629 "/openapi.json": {
630 "get": {
631 "summary": "Self-describing OpenAPI 3.1 spec",
632 "description": "Returns this document. Always unauthenticated.",
633 "responses": {
634 "200": {
635 "description": "OpenAPI 3.1 document.",
636 "content": { "application/json": { "schema": { "type": "object" } } }
637 }
638 }
639 }
640 },
641 "/memory": {
642 "post": {
643 "summary": "Remember (store an episode)",
644 "description": "Equivalent to MCP tool `memory_remember`.",
645 "security": [{ "bearerAuth": [] }, {}],
646 "requestBody": {
647 "required": true,
648 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
649 },
650 "responses": {
651 "200": {
652 "description": "Memory stored; returns the new MemoryId.",
653 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
654 },
655 "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
656 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
657 }
658 }
659 },
660 "/memory/search": {
661 "post": {
662 "summary": "Recall (vector search)",
663 "description": "Equivalent to MCP tool `memory_recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
664 "security": [{ "bearerAuth": [] }, {}],
665 "requestBody": {
666 "required": true,
667 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
668 },
669 "responses": {
670 "200": {
671 "description": "Search results.",
672 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
673 },
674 "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
675 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
676 }
677 }
678 },
679 "/memory/consolidate": {
680 "post": {
681 "summary": "Run a consolidation pass (clustering + abstraction)",
682 "description":
683 "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
684 on the server, also runs the REM-equivalent abstraction pass that populates \
685 `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
686 window). Equivalent to the `solo consolidate` CLI.",
687 "security": [{ "bearerAuth": [] }, {}],
688 "requestBody": {
689 "required": false,
690 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
691 },
692 "responses": {
693 "200": {
694 "description": "Consolidation complete; report counts the work done.",
695 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
696 },
697 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
698 }
699 }
700 },
701 "/backup": {
702 "post": {
703 "summary": "Online encrypted backup",
704 "description":
705 "Run an online SQLCipher backup of the live data dir to a server-side path. \
706 The destination file is encrypted with the same Argon2id-derived raw key as \
707 the source, so it restores under the same passphrase + a copy of the source's \
708 `solo.config.toml`. Hot — the backup runs against the writer's existing \
709 connection without taking the lockfile, so the daemon keeps serving reads + \
710 writes during the operation. v0.3.2+.",
711 "security": [{ "bearerAuth": [] }, {}],
712 "requestBody": {
713 "required": true,
714 "content": { "application/json": { "schema": {
715 "type": "object",
716 "properties": {
717 "to": { "type": "string", "description": "Server-side absolute path for the backup file." },
718 "force": { "type": "boolean", "description": "Overwrite an existing destination file. Default false.", "default": false }
719 },
720 "required": ["to"]
721 } } }
722 },
723 "responses": {
724 "200": {
725 "description": "Backup complete; reports the destination path + elapsed milliseconds.",
726 "content": { "application/json": { "schema": {
727 "type": "object",
728 "properties": {
729 "path": { "type": "string" },
730 "elapsed_ms": { "type": "integer", "format": "int64" }
731 }
732 } } }
733 },
734 "400": { "description": "Destination invalid, exists without force, or its parent doesn't exist." },
735 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." },
736 "500": { "description": "Backup failed (disk full, permission denied, etc.)." }
737 }
738 }
739 },
740 "/memory/{id}": {
741 "get": {
742 "summary": "Inspect a memory by ID",
743 "description": "Equivalent to MCP tool `memory_inspect`.",
744 "security": [{ "bearerAuth": [] }, {}],
745 "parameters": [{
746 "name": "id",
747 "in": "path",
748 "required": true,
749 "schema": { "type": "string", "format": "uuid" },
750 "description": "MemoryId (UUID v7)."
751 }],
752 "responses": {
753 "200": {
754 "description": "Episode record.",
755 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
756 },
757 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
758 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
759 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
760 }
761 },
762 "delete": {
763 "summary": "Forget (soft-delete) a memory by ID",
764 "description":
765 "Equivalent to MCP tool `memory_forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
766 and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
767 re-running `solo reembed` after this does NOT restore visibility.",
768 "security": [{ "bearerAuth": [] }, {}],
769 "parameters": [
770 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
771 { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
772 ],
773 "responses": {
774 "204": { "description": "Forgotten (or already forgotten — idempotent)." },
775 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
776 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
777 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
778 }
779 }
780 },
781 "/memory/themes": {
782 "get": {
783 "summary": "List recent cluster themes",
784 "description":
785 "Equivalent to MCP tool `memory_themes`. List cluster abstractions ordered by \
786 most-recent first. Use to surface 'what has the user been thinking about lately' \
787 without paging through individual episodes. v0.4.0+.",
788 "security": [{ "bearerAuth": [] }, {}],
789 "parameters": [
790 { "name": "window_days", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Optional time window. Omit for unfiltered (all-time, most-recent first)." },
791 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
792 ],
793 "responses": {
794 "200": {
795 "description": "Array of ThemeHits (possibly empty).",
796 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ThemeHit" } } } }
797 },
798 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
799 }
800 }
801 },
802 "/memory/facts_about": {
803 "get": {
804 "summary": "Query the SPO knowledge graph by subject",
805 "description":
806 "Equivalent to MCP tool `memory_facts_about`. Query Steward-extracted triples by \
807 subject + optional predicate + optional time window. Subject is required \
808 (predicate-only scans not supported). Pass `include_as_object=true` (v0.5.1+) \
809 to also surface rows where `subject` appears as the object. v0.4.0+.",
810 "security": [{ "bearerAuth": [] }, {}],
811 "parameters": [
812 { "name": "subject", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Subject id to query (e.g. `Sam`)." },
813 { "name": "predicate", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional predicate filter (e.g. `works_at`)." },
814 { "name": "since_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_from_ms lower bound (epoch ms)." },
815 { "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." },
816 { "name": "include_as_object", "in": "query", "required": false, "schema": { "type": "boolean", "default": false }, "description": "If true, also match rows where `subject` appears as the object (e.g. surface 'Sam pushes back on PRs about Maya' under subject='Maya'). Default false. v0.5.1+." },
817 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
818 ],
819 "responses": {
820 "200": {
821 "description": "Array of FactHits (possibly empty).",
822 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactHit" } } } }
823 },
824 "400": { "description": "Bad request (e.g. empty subject).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
825 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
826 }
827 }
828 },
829 "/memory/contradictions": {
830 "get": {
831 "summary": "List Steward-flagged contradictions",
832 "description":
833 "Equivalent to MCP tool `memory_contradictions`. Each result includes both \
834 sides' triple SPO via LEFT JOIN for context. v0.4.0+.",
835 "security": [{ "bearerAuth": [] }, {}],
836 "parameters": [
837 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
838 ],
839 "responses": {
840 "200": {
841 "description": "Array of ContradictionHits (possibly empty).",
842 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ContradictionHit" } } } }
843 },
844 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
845 }
846 }
847 },
848 "/memory/clusters/{cluster_id}": {
849 "get": {
850 "summary": "Inspect a single cluster",
851 "description":
852 "Equivalent to MCP tool `memory_inspect_cluster`. Returns the cluster row, \
853 its (optional) abstraction, and its source episodes. By default each \
854 episode's `content` is truncated to 200 chars with a trailing `…`. Pass \
855 `?full_content=true` to get verbatim episode content. v0.5.0+.",
856 "security": [{ "bearerAuth": [] }, {}],
857 "parameters": [
858 { "name": "cluster_id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Cluster id (from a previous GET /memory/themes response)." },
859 { "name": "full_content", "in": "query", "required": false, "schema": { "type": "boolean", "default": false }, "description": "If true, return episode content verbatim. Default false (truncate to 200 chars + ellipsis)." }
860 ],
861 "responses": {
862 "200": {
863 "description": "Cluster snapshot.",
864 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClusterRecord" } } }
865 },
866 "400": { "description": "Bad request (e.g. empty cluster_id).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
867 "404": { "description": "No such cluster.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
868 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
869 }
870 }
871 },
872 "/memory/documents": {
873 "post": {
874 "summary": "Ingest a document",
875 "description":
876 "Equivalent to MCP tool `memory_ingest_document`. Reads the file at the \
877 supplied server-side path, parses + chunks + embeds, and persists under \
878 `documents` + `document_chunks`. Returns the new doc_id, chunk count, and \
879 a `deduped` flag (true when an existing document with the same content_hash \
880 was returned without re-embedding). v0.7.0+.",
881 "security": [{ "bearerAuth": [] }, {}],
882 "requestBody": {
883 "required": true,
884 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestDocumentRequest" } } }
885 },
886 "responses": {
887 "200": {
888 "description": "Document ingested (or deduplicated).",
889 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestReport" } } }
890 },
891 "400": { "description": "Bad request (e.g. empty path, file unreadable, parse error).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
892 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
893 }
894 },
895 "get": {
896 "summary": "List ingested documents (paginated)",
897 "description":
898 "Equivalent to MCP tool `memory_list_documents`. Returns a paginated index, \
899 newest first. Forgotten documents are hidden by default; pass \
900 `?include_forgotten=true` to see them too. v0.7.0+.",
901 "security": [{ "bearerAuth": [] }, {}],
902 "parameters": [
903 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } },
904 { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 } },
905 { "name": "include_forgotten", "in": "query", "required": false, "schema": { "type": "boolean", "default": false } }
906 ],
907 "responses": {
908 "200": {
909 "description": "Array of DocumentSummary (possibly empty).",
910 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocumentSummary" } } } }
911 },
912 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
913 }
914 }
915 },
916 "/memory/documents/search": {
917 "post": {
918 "summary": "Vector search across document chunks",
919 "description":
920 "Equivalent to MCP tool `memory_search_docs`. Embeds the query and returns \
921 up to `limit` matching chunks, best match first, each annotated with the \
922 parent document's title + source path. Forgotten documents are excluded. \
923 v0.7.0+.",
924 "security": [{ "bearerAuth": [] }, {}],
925 "requestBody": {
926 "required": true,
927 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchDocsRequest" } } }
928 },
929 "responses": {
930 "200": {
931 "description": "Array of DocSearchHits (possibly empty).",
932 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocSearchHit" } } } }
933 },
934 "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
935 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
936 }
937 }
938 },
939 "/memory/documents/{id}": {
940 "get": {
941 "summary": "Inspect one document",
942 "description":
943 "Equivalent to MCP tool `memory_inspect_document`. Returns the document's \
944 metadata plus a preview of every chunk (truncated to 200 chars). v0.7.0+.",
945 "security": [{ "bearerAuth": [] }, {}],
946 "parameters": [
947 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "DocumentId (UUID v7)." }
948 ],
949 "responses": {
950 "200": {
951 "description": "Document inspection result.",
952 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentInspectResult" } } }
953 },
954 "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
955 "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
956 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
957 }
958 },
959 "delete": {
960 "summary": "Forget (soft-delete) one document",
961 "description":
962 "Equivalent to MCP tool `memory_forget_document`. Flips `documents.status` \
963 to `forgotten` and tombstones every chunk's HNSW rowid. The chunk rows \
964 survive in SQL for forensic value. v0.7.0+.",
965 "security": [{ "bearerAuth": [] }, {}],
966 "parameters": [
967 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
968 ],
969 "responses": {
970 "200": {
971 "description": "Document soft-deleted; report counts chunks tombstoned.",
972 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForgetDocumentReport" } } }
973 },
974 "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
975 "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
976 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
977 }
978 }
979 }
980 }
981 })
982}
983
984#[derive(Debug, Deserialize)]
989struct RememberBody {
990 content: String,
991 #[serde(default)]
992 source_type: Option<String>,
993 #[serde(default)]
994 source_id: Option<String>,
995}
996
997#[derive(Debug, Serialize)]
998struct RememberResponse {
999 memory_id: String,
1000}
1001
1002async fn remember_handler(
1003 TenantExtractor(tenant): TenantExtractor,
1004 AuditPrincipal(principal): AuditPrincipal,
1005 Json(body): Json<RememberBody>,
1006) -> Result<Json<RememberResponse>, ApiError> {
1007 let content = body.content.trim_end().to_string();
1008 if content.is_empty() {
1009 return Err(ApiError::bad_request("content must not be empty"));
1010 }
1011 let embedding = tenant.embedder().embed(&content).await.map_err(ApiError::from)?;
1012 let episode = Episode {
1013 memory_id: MemoryId::new(),
1014 ts_ms: chrono::Utc::now().timestamp_millis(),
1015 source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
1016 source_id: body.source_id,
1017 content,
1018 encoding_context: EncodingContext::default(),
1019 provenance: None,
1020 confidence: Confidence::new(0.9).unwrap(),
1021 strength: 0.5,
1022 salience: 0.5,
1023 tier: Tier::Hot,
1024 };
1025 let mid = tenant
1026 .write()
1027 .remember_as(principal, episode, embedding)
1028 .await
1029 .map_err(ApiError::from)?;
1030 Ok(Json(RememberResponse {
1031 memory_id: mid.to_string(),
1032 }))
1033}
1034
1035#[derive(Debug, Deserialize)]
1036struct RecallBody {
1037 query: String,
1038 #[serde(default = "default_limit")]
1039 limit: usize,
1040}
1041
1042fn default_limit() -> usize {
1043 5
1044}
1045
1046async fn recall_handler(
1047 TenantExtractor(tenant): TenantExtractor,
1048 AuditPrincipal(principal): AuditPrincipal,
1049 Json(body): Json<RecallBody>,
1050) -> Result<Json<solo_query::RecallResult>, ApiError> {
1051 let result = solo_query::run_recall(tenant.as_ref(), principal, &body.query, body.limit)
1055 .await
1056 .map_err(ApiError::from)?;
1057 Ok(Json(result))
1058}
1059
1060async fn inspect_handler(
1061 TenantExtractor(tenant): TenantExtractor,
1062 AuditPrincipal(principal): AuditPrincipal,
1063 Path(id): Path<String>,
1064) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
1065 let mid = MemoryId::from_str(&id)
1066 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1067 let row = solo_query::inspect_one(tenant.read(), tenant.audit(), principal, mid)
1068 .await
1069 .map_err(ApiError::from)?;
1070 Ok(Json(row))
1071}
1072
1073#[derive(Debug, Deserialize)]
1080struct ThemesQuery {
1081 #[serde(default)]
1082 window_days: Option<i64>,
1083 #[serde(default = "default_limit")]
1084 limit: usize,
1085}
1086
1087async fn themes_handler(
1088 TenantExtractor(tenant): TenantExtractor,
1089 AuditPrincipal(principal): AuditPrincipal,
1090 Query(q): Query<ThemesQuery>,
1091) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
1092 let hits = solo_query::themes(
1093 tenant.read(),
1094 tenant.audit(),
1095 principal,
1096 q.window_days,
1097 q.limit,
1098 )
1099 .await
1100 .map_err(ApiError::from)?;
1101 Ok(Json(hits))
1102}
1103
1104#[derive(Debug, Deserialize)]
1105struct FactsAboutQuery {
1106 subject: String,
1107 #[serde(default)]
1108 predicate: Option<String>,
1109 #[serde(default)]
1110 since_ms: Option<i64>,
1111 #[serde(default)]
1112 until_ms: Option<i64>,
1113 #[serde(default)]
1116 include_as_object: bool,
1117 #[serde(default = "default_limit")]
1118 limit: usize,
1119}
1120
1121async fn facts_about_handler(
1122 State(s): State<SoloHttpState>,
1123 TenantExtractor(tenant): TenantExtractor,
1124 AuditPrincipal(principal): AuditPrincipal,
1125 Query(q): Query<FactsAboutQuery>,
1126) -> Result<Json<Vec<solo_query::FactHit>>, ApiError> {
1127 if q.subject.trim().is_empty() {
1128 return Err(ApiError::bad_request("subject must not be empty"));
1129 }
1130 let hits = solo_query::facts_about(
1131 tenant.read(),
1132 tenant.audit(),
1133 principal,
1134 &q.subject,
1135 &s.user_aliases,
1136 q.include_as_object,
1137 q.predicate.as_deref(),
1138 q.since_ms,
1139 q.until_ms,
1140 q.limit,
1141 )
1142 .await
1143 .map_err(ApiError::from)?;
1144 Ok(Json(hits))
1145}
1146
1147#[derive(Debug, Deserialize)]
1148struct ContradictionsQuery {
1149 #[serde(default = "default_limit")]
1150 limit: usize,
1151}
1152
1153async fn contradictions_handler(
1154 TenantExtractor(tenant): TenantExtractor,
1155 AuditPrincipal(principal): AuditPrincipal,
1156 Query(q): Query<ContradictionsQuery>,
1157) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
1158 let hits = solo_query::contradictions(tenant.read(), tenant.audit(), principal, q.limit)
1159 .await
1160 .map_err(ApiError::from)?;
1161 Ok(Json(hits))
1162}
1163
1164#[derive(Debug, Deserialize, Default)]
1165struct InspectClusterQuery {
1166 #[serde(default)]
1170 full_content: bool,
1171}
1172
1173async fn inspect_cluster_handler(
1174 TenantExtractor(tenant): TenantExtractor,
1175 AuditPrincipal(principal): AuditPrincipal,
1176 Path(cluster_id): Path<String>,
1177 Query(q): Query<InspectClusterQuery>,
1178) -> Result<Json<solo_query::ClusterRecord>, ApiError> {
1179 if cluster_id.trim().is_empty() {
1180 return Err(ApiError::bad_request("cluster_id must not be empty"));
1181 }
1182 let record = solo_query::inspect_cluster(
1183 tenant.read(),
1184 tenant.audit(),
1185 principal,
1186 &cluster_id,
1187 q.full_content,
1188 )
1189 .await
1190 .map_err(ApiError::from)?;
1191 Ok(Json(record))
1192}
1193
1194#[derive(Debug, Deserialize)]
1199struct IngestDocumentBody {
1200 path: String,
1203}
1204
1205async fn ingest_document_handler(
1206 TenantExtractor(tenant): TenantExtractor,
1207 AuditPrincipal(principal): AuditPrincipal,
1208 Json(body): Json<IngestDocumentBody>,
1209) -> Result<Json<solo_storage::IngestReport>, ApiError> {
1210 if body.path.trim().is_empty() {
1211 return Err(ApiError::bad_request("path must not be empty"));
1212 }
1213 let path = std::path::PathBuf::from(body.path);
1214 let chunk_config = solo_storage::document::ChunkConfig::default();
1215 let report = tenant
1216 .write()
1217 .ingest_document_as(principal, path, chunk_config)
1218 .await
1219 .map_err(ApiError::from)?;
1220 Ok(Json(report))
1221}
1222
1223#[derive(Debug, Deserialize)]
1224struct SearchDocsBody {
1225 query: String,
1226 #[serde(default = "default_limit")]
1227 limit: usize,
1228}
1229
1230async fn search_docs_handler(
1231 TenantExtractor(tenant): TenantExtractor,
1232 AuditPrincipal(principal): AuditPrincipal,
1233 Json(body): Json<SearchDocsBody>,
1234) -> Result<Json<Vec<solo_query::DocSearchHit>>, ApiError> {
1235 let hits = solo_query::run_doc_search(tenant.as_ref(), principal, &body.query, body.limit)
1236 .await
1237 .map_err(ApiError::from)?;
1238 Ok(Json(hits))
1239}
1240
1241async fn inspect_document_handler(
1242 TenantExtractor(tenant): TenantExtractor,
1243 AuditPrincipal(principal): AuditPrincipal,
1244 Path(id): Path<String>,
1245) -> Result<Json<solo_query::DocumentInspectResult>, ApiError> {
1246 let doc_id = DocumentId::from_str(&id)
1247 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1248 let result_opt =
1249 solo_query::inspect_document(tenant.read(), tenant.audit(), principal, &doc_id)
1250 .await
1251 .map_err(ApiError::from)?;
1252 match result_opt {
1253 Some(record) => Ok(Json(record)),
1254 None => Err(ApiError::not_found(format!("document {doc_id} not found"))),
1255 }
1256}
1257
1258#[derive(Debug, Deserialize)]
1259struct ListDocumentsQuery {
1260 #[serde(default = "default_list_documents_limit")]
1261 limit: usize,
1262 #[serde(default)]
1263 offset: usize,
1264 #[serde(default)]
1265 include_forgotten: bool,
1266}
1267
1268fn default_list_documents_limit() -> usize {
1269 20
1270}
1271
1272async fn list_documents_handler(
1273 TenantExtractor(tenant): TenantExtractor,
1274 AuditPrincipal(principal): AuditPrincipal,
1275 Query(q): Query<ListDocumentsQuery>,
1276) -> Result<Json<Vec<solo_query::DocumentSummary>>, ApiError> {
1277 let rows = solo_query::list_documents(
1278 tenant.read(),
1279 tenant.audit(),
1280 principal,
1281 q.limit,
1282 q.offset,
1283 q.include_forgotten,
1284 )
1285 .await
1286 .map_err(ApiError::from)?;
1287 Ok(Json(rows))
1288}
1289
1290async fn forget_document_handler(
1291 TenantExtractor(tenant): TenantExtractor,
1292 AuditPrincipal(principal): AuditPrincipal,
1293 Path(id): Path<String>,
1294) -> Result<Json<solo_storage::ForgetDocumentReport>, ApiError> {
1295 let doc_id = DocumentId::from_str(&id)
1296 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1297 let report = tenant
1298 .write()
1299 .forget_document_as(principal, doc_id)
1300 .await
1301 .map_err(ApiError::from)?;
1302 Ok(Json(report))
1303}
1304
1305#[derive(Debug, Deserialize)]
1306struct ForgetQuery {
1307 #[serde(default)]
1308 reason: Option<String>,
1309}
1310
1311async fn forget_handler(
1312 TenantExtractor(tenant): TenantExtractor,
1313 AuditPrincipal(principal): AuditPrincipal,
1314 Path(id): Path<String>,
1315 Query(q): Query<ForgetQuery>,
1316) -> Result<StatusCode, ApiError> {
1317 let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1318 let reason = q.reason.unwrap_or_else(|| "http".into());
1319 tenant
1320 .write()
1321 .forget_as(principal, mid, reason)
1322 .await
1323 .map_err(ApiError::from)?;
1324 Ok(StatusCode::NO_CONTENT)
1325}
1326
1327async fn consolidate_handler(
1328 TenantExtractor(tenant): TenantExtractor,
1329 AuditPrincipal(principal): AuditPrincipal,
1330 body: axum::body::Bytes,
1331) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
1332 let scope = if body.is_empty() {
1338 solo_storage::ConsolidationScope::default()
1339 } else {
1340 serde_json::from_slice(&body)
1341 .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
1342 };
1343 let report = tenant
1344 .write()
1345 .consolidate_as(principal, scope)
1346 .await
1347 .map_err(ApiError::from)?;
1348 Ok(Json(report))
1349}
1350
1351#[derive(Debug, Deserialize)]
1352struct BackupBody {
1353 to: String,
1357 #[serde(default)]
1358 force: bool,
1359}
1360
1361#[derive(Debug, Serialize)]
1362struct BackupResponse {
1363 path: String,
1364 elapsed_ms: u64,
1365}
1366
1367async fn backup_handler(
1368 TenantExtractor(tenant): TenantExtractor,
1369 Json(body): Json<BackupBody>,
1370) -> Result<Json<BackupResponse>, ApiError> {
1371 use std::path::PathBuf;
1372
1373 let dest = PathBuf::from(&body.to);
1374 if dest.as_os_str().is_empty() {
1375 return Err(ApiError::bad_request("`to` must not be empty"));
1376 }
1377 if solo_storage::paths_refer_to_same_file(tenant.db_path(), &dest) {
1380 return Err(ApiError::bad_request(format!(
1381 "destination {} is the same file as the source database; \
1382 refusing to run (would corrupt the live database)",
1383 dest.display()
1384 )));
1385 }
1386 if dest.exists() {
1387 if !body.force {
1388 return Err(ApiError::bad_request(format!(
1389 "destination {} exists; pass force=true to overwrite",
1390 dest.display()
1391 )));
1392 }
1393 std::fs::remove_file(&dest).map_err(|e| {
1394 ApiError::internal(format!(
1395 "remove existing destination {}: {e}",
1396 dest.display()
1397 ))
1398 })?;
1399 }
1400 if let Some(parent) = dest.parent() {
1401 if !parent.as_os_str().is_empty() && !parent.is_dir() {
1402 return Err(ApiError::bad_request(format!(
1403 "destination parent directory {} does not exist",
1404 parent.display()
1405 )));
1406 }
1407 }
1408
1409 let started = std::time::Instant::now();
1410 tenant.write().backup(dest.clone()).await.map_err(ApiError::from)?;
1411 let elapsed_ms = started.elapsed().as_millis() as u64;
1412
1413 Ok(Json(BackupResponse {
1414 path: dest.display().to_string(),
1415 elapsed_ms,
1416 }))
1417}
1418
1419#[derive(Debug)]
1424pub struct ApiError {
1425 status: StatusCode,
1426 message: String,
1427}
1428
1429impl ApiError {
1430 fn bad_request(msg: impl Into<String>) -> Self {
1431 Self {
1432 status: StatusCode::BAD_REQUEST,
1433 message: msg.into(),
1434 }
1435 }
1436 fn not_found(msg: impl Into<String>) -> Self {
1437 Self {
1438 status: StatusCode::NOT_FOUND,
1439 message: msg.into(),
1440 }
1441 }
1442 fn internal(msg: impl Into<String>) -> Self {
1443 Self {
1444 status: StatusCode::INTERNAL_SERVER_ERROR,
1445 message: msg.into(),
1446 }
1447 }
1448}
1449
1450impl From<solo_core::Error> for ApiError {
1451 fn from(e: solo_core::Error) -> Self {
1452 use solo_core::Error;
1453 match e {
1454 Error::NotFound(msg) => ApiError::not_found(msg),
1455 Error::InvalidInput(msg) => ApiError::bad_request(msg),
1456 Error::Conflict(msg) => Self {
1457 status: StatusCode::CONFLICT,
1458 message: msg,
1459 },
1460 other => ApiError::internal(other.to_string()),
1461 }
1462 }
1463}
1464
1465impl IntoResponse for ApiError {
1466 fn into_response(self) -> Response {
1467 let body = serde_json::json!({
1468 "error": self.message,
1469 "status": self.status.as_u16(),
1470 });
1471 (self.status, Json(body)).into_response()
1472 }
1473}
1474
1475#[cfg(test)]
1479mod handler_tests {
1480 use super::*;
1489 use axum::body::Body;
1490 use axum::http::{Request, StatusCode};
1491 use http_body_util::BodyExt;
1492 use serde_json::{Value, json};
1493 use solo_storage::test_support::StubVectorIndex;
1494 use solo_storage::{
1495 EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
1496 StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
1497 };
1498 use solo_core::VectorIndex;
1499 use std::sync::Arc as StdArc;
1500 use tower::ServiceExt;
1501
1502 fn fake_config(dim: u32) -> SoloConfig {
1503 SoloConfig {
1504 schema_version: 1,
1505 salt_hex: "00000000000000000000000000000000".to_string(),
1506 embedder: EmbedderConfig {
1507 name: "stub".to_string(),
1508 version: "v1".to_string(),
1509 dim,
1510 dtype: "f32".to_string(),
1511 },
1512 identity: IdentityConfig::default(),
1513 documents: solo_storage::DocumentConfig::default(),
1514 auth: None,
1515 audit: solo_storage::AuditSettings::default(),
1516 redaction: solo_storage::RedactionConfig::default(),
1517 llm: None,
1518 triples: solo_storage::TriplesConfig::default(),
1519 sampling: solo_storage::SamplingConfig::default(),
1520 }
1521 }
1522
1523 struct Harness {
1524 router: axum::Router,
1525 _tmp: tempfile::TempDir,
1526 write_handle_extra: Option<solo_storage::WriteHandle>,
1527 join: Option<std::thread::JoinHandle<()>>,
1528 }
1529
1530 impl Harness {
1531 fn new(runtime: &tokio::runtime::Runtime) -> Self {
1532 Self::new_with_auth(runtime, None)
1533 }
1534
1535 fn new_with_auth(
1536 runtime: &tokio::runtime::Runtime,
1537 bearer_token: Option<String>,
1538 ) -> Self {
1539 Self::new_with_auth_config(
1540 runtime,
1541 bearer_token.map(|token| crate::auth::AuthConfig::Bearer { token }),
1542 )
1543 }
1544
1545 fn new_with_auth_config(
1546 runtime: &tokio::runtime::Runtime,
1547 auth: Option<crate::auth::AuthConfig>,
1548 ) -> Self {
1549 use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
1550
1551 let tmp = tempfile::TempDir::new().unwrap();
1552 let dim = 16usize;
1553 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1554 let embedder: StdArc<dyn solo_core::Embedder> =
1555 StdArc::new(StubEmbedder::new("stub", "v1", dim));
1556 let path = tmp.path().join("test.db");
1557
1558 let embedder_id = {
1559 let conn = solo_storage::test_support::open_test_db_at(&path);
1560 get_or_insert_embedder_id(
1561 &conn,
1562 &EmbedderIdentity {
1563 name: "stub".into(),
1564 version: "v1".into(),
1565 dim: dim as u32,
1566 dtype: "f32".into(),
1567 },
1568 )
1569 .unwrap()
1570 };
1571
1572 let conn = solo_storage::test_support::open_test_db_at(&path);
1573 let WriterSpawn { handle, join } = WriterActor::spawn_full(
1574 conn,
1575 hnsw.clone(),
1576 tmp.path().to_path_buf(),
1577 embedder_id,
1578 );
1579 let pool: ReaderPool =
1580 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1581
1582 let tenant_id = solo_core::TenantId::default_tenant();
1585 let tenant_handle = StdArc::new(
1586 TenantHandle::from_parts_for_tests(
1587 tenant_id.clone(),
1588 fake_config(dim as u32),
1589 path.clone(),
1590 tmp.path().to_path_buf(),
1591 embedder_id,
1592 hnsw,
1593 embedder.clone(),
1594 handle.clone(),
1595 std::thread::spawn(|| {}),
1601 pool,
1602 ),
1603 );
1604
1605 let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
1609 let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
1610 tmp.path().to_path_buf(),
1611 key,
1612 embedder,
1613 tenant_handle,
1614 ));
1615
1616 let state = SoloHttpState {
1617 registry,
1618 default_tenant: tenant_id,
1619 user_aliases: Arc::new(Vec::new()),
1620 };
1621 let router = router_with_auth_config(state, auth);
1622 Harness {
1623 router,
1624 _tmp: tmp,
1625 write_handle_extra: Some(handle),
1626 join: Some(join),
1627 }
1628 }
1629
1630 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1631 let join = self.join.take();
1632 let extra = self.write_handle_extra.take();
1633 runtime.block_on(async move {
1634 drop(extra);
1635 drop(self.router); drop(self._tmp);
1637 if let Some(join) = join {
1638 let (tx, rx) = std::sync::mpsc::channel();
1639 std::thread::spawn(move || {
1640 let _ = tx.send(join.join());
1641 });
1642 tokio::task::spawn_blocking(move || {
1643 rx.recv_timeout(std::time::Duration::from_secs(5))
1644 })
1645 .await
1646 .expect("blocking task")
1647 .expect("writer thread did not exit within 5s")
1648 .expect("writer thread panicked");
1649 }
1650 });
1651 }
1652 }
1653
1654 fn rt() -> tokio::runtime::Runtime {
1655 tokio::runtime::Builder::new_multi_thread()
1656 .worker_threads(2)
1657 .enable_all()
1658 .build()
1659 .unwrap()
1660 }
1661
1662 async fn call(
1666 router: axum::Router,
1667 method: &str,
1668 uri: &str,
1669 body: Option<Value>,
1670 ) -> (StatusCode, Value) {
1671 call_with_auth(router, method, uri, body, None).await
1672 }
1673
1674 async fn call_with_auth(
1675 router: axum::Router,
1676 method: &str,
1677 uri: &str,
1678 body: Option<Value>,
1679 auth: Option<&str>,
1680 ) -> (StatusCode, Value) {
1681 let mut req_builder = Request::builder()
1682 .method(method)
1683 .uri(uri)
1684 .header("content-type", "application/json");
1685 if let Some(a) = auth {
1686 req_builder = req_builder.header("authorization", a);
1687 }
1688 let req = if let Some(b) = body {
1689 let bytes = serde_json::to_vec(&b).unwrap();
1690 req_builder.body(Body::from(bytes)).unwrap()
1691 } else {
1692 req_builder = req_builder.header("content-length", "0");
1693 req_builder.body(Body::empty()).unwrap()
1694 };
1695 let resp = router.oneshot(req).await.expect("oneshot");
1696 let status = resp.status();
1697 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
1698 let v: Value = if body_bytes.is_empty() {
1699 Value::Null
1700 } else {
1701 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
1702 };
1703 (status, v)
1704 }
1705
1706 #[test]
1707 fn health_returns_ok() {
1708 let runtime = rt();
1709 let h = Harness::new(&runtime);
1710 let r = h.router.clone();
1711 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1712 assert_eq!(status, StatusCode::OK);
1713 h.shutdown(&runtime);
1714 }
1715
1716 #[test]
1721 fn openapi_json_describes_all_endpoints() {
1722 let runtime = rt();
1723 let h = Harness::new(&runtime);
1724 let r = h.router.clone();
1725 let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1726 assert_eq!(status, StatusCode::OK);
1727 assert!(spec.is_object(), "openapi.json must be a JSON object");
1728
1729 assert!(
1731 spec.get("openapi")
1732 .and_then(|v| v.as_str())
1733 .is_some_and(|s| s.starts_with("3.")),
1734 "missing or wrong openapi version: {spec}"
1735 );
1736 assert!(spec.pointer("/info/title").is_some());
1737 assert!(spec.pointer("/info/version").is_some());
1738
1739 let paths = spec
1741 .get("paths")
1742 .and_then(|v| v.as_object())
1743 .expect("paths must be an object");
1744 for expected in [
1745 "/health",
1746 "/openapi.json",
1747 "/memory",
1748 "/memory/search",
1749 "/memory/consolidate",
1750 "/memory/{id}",
1751 "/memory/themes",
1753 "/memory/facts_about",
1754 "/memory/contradictions",
1755 "/memory/clusters/{cluster_id}",
1757 "/memory/documents",
1759 "/memory/documents/search",
1760 "/memory/documents/{id}",
1761 ] {
1762 assert!(
1763 paths.contains_key(expected),
1764 "openapi paths missing {expected}: {paths:?}"
1765 );
1766 }
1767
1768 let docs = paths.get("/memory/documents").expect("/memory/documents");
1771 assert!(docs.get("post").is_some(), "POST /memory/documents undocumented");
1772 assert!(docs.get("get").is_some(), "GET /memory/documents undocumented");
1773
1774 let docid = paths
1777 .get("/memory/documents/{id}")
1778 .expect("/memory/documents/{id}");
1779 assert!(
1780 docid.get("get").is_some(),
1781 "GET /memory/documents/{{id}} undocumented"
1782 );
1783 assert!(
1784 docid.get("delete").is_some(),
1785 "DELETE /memory/documents/{{id}} undocumented"
1786 );
1787
1788 let memid = paths.get("/memory/{id}").expect("memory/{id}");
1791 assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1792 assert!(
1793 memid.get("delete").is_some(),
1794 "DELETE /memory/{{id}} undocumented"
1795 );
1796
1797 for schema_name in [
1799 "RememberRequest",
1800 "RememberResponse",
1801 "RecallRequest",
1802 "RecallResult",
1803 "EpisodeRecord",
1804 "ApiError",
1805 "ConsolidationScope",
1806 "ConsolidationReport",
1807 "ThemeHit",
1809 "FactHit",
1810 "ContradictionHit",
1811 "ClusterRecord",
1813 "IngestDocumentRequest",
1815 "IngestReport",
1816 "ForgetDocumentReport",
1817 "SearchDocsRequest",
1818 "DocSearchHit",
1819 "DocumentInspectResult",
1820 "DocumentSummary",
1821 ] {
1822 let ptr = format!("/components/schemas/{schema_name}");
1823 assert!(
1824 spec.pointer(&ptr).is_some(),
1825 "component schema {schema_name} missing"
1826 );
1827 }
1828
1829 assert!(
1831 spec.pointer("/components/securitySchemes/bearerAuth")
1832 .is_some(),
1833 "bearerAuth security scheme missing"
1834 );
1835
1836 h.shutdown(&runtime);
1837 }
1838
1839 #[test]
1843 fn openapi_json_is_exempt_from_bearer_auth() {
1844 let runtime = rt();
1845 let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1846 let r = h.router.clone();
1847 let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1849 assert_eq!(status, StatusCode::OK);
1850 h.shutdown(&runtime);
1851 }
1852
1853 #[test]
1854 fn remember_returns_memory_id() {
1855 let runtime = rt();
1856 let h = Harness::new(&runtime);
1857 let r = h.router.clone();
1858 let (status, body) = runtime.block_on(call(
1859 r,
1860 "POST",
1861 "/memory",
1862 Some(json!({ "content": "http harness test" })),
1863 ));
1864 assert_eq!(status, StatusCode::OK);
1865 let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1866 assert_eq!(mid.len(), 36, "uuid length");
1867 h.shutdown(&runtime);
1868 }
1869
1870 #[test]
1871 fn empty_content_returns_400() {
1872 let runtime = rt();
1873 let h = Harness::new(&runtime);
1874 let r = h.router.clone();
1875 let (status, body) =
1876 runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1877 assert_eq!(status, StatusCode::BAD_REQUEST);
1878 assert!(
1879 body.get("error")
1880 .and_then(|e| e.as_str())
1881 .map(|s| s.contains("must not be empty"))
1882 .unwrap_or(false),
1883 "got: {body}"
1884 );
1885 h.shutdown(&runtime);
1886 }
1887
1888 #[test]
1889 fn empty_query_returns_400() {
1890 let runtime = rt();
1891 let h = Harness::new(&runtime);
1892 let r = h.router.clone();
1893 let (status, body) = runtime.block_on(call(
1894 r,
1895 "POST",
1896 "/memory/search",
1897 Some(json!({ "query": "" })),
1898 ));
1899 assert_eq!(status, StatusCode::BAD_REQUEST);
1900 assert!(
1901 body.get("error")
1902 .and_then(|e| e.as_str())
1903 .map(|s| s.contains("must not be empty"))
1904 .unwrap_or(false),
1905 "got: {body}"
1906 );
1907 h.shutdown(&runtime);
1908 }
1909
1910 #[test]
1911 fn inspect_unknown_returns_404() {
1912 let runtime = rt();
1913 let h = Harness::new(&runtime);
1914 let r = h.router.clone();
1915 let (status, body) = runtime.block_on(call(
1916 r,
1917 "GET",
1918 "/memory/00000000-0000-7000-8000-000000000000",
1919 None,
1920 ));
1921 assert_eq!(status, StatusCode::NOT_FOUND);
1922 assert!(body.get("error").is_some(), "got: {body}");
1923 h.shutdown(&runtime);
1924 }
1925
1926 #[test]
1927 fn inspect_invalid_id_returns_400() {
1928 let runtime = rt();
1929 let h = Harness::new(&runtime);
1930 let r = h.router.clone();
1931 let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1932 assert_eq!(status, StatusCode::BAD_REQUEST);
1933 h.shutdown(&runtime);
1934 }
1935
1936 #[test]
1937 fn forget_unknown_returns_404() {
1938 let runtime = rt();
1939 let h = Harness::new(&runtime);
1940 let r = h.router.clone();
1941 let (status, _body) = runtime.block_on(call(
1942 r,
1943 "DELETE",
1944 "/memory/00000000-0000-7000-8000-000000000000",
1945 None,
1946 ));
1947 assert_eq!(status, StatusCode::NOT_FOUND);
1948 h.shutdown(&runtime);
1949 }
1950
1951 #[test]
1959 fn consolidate_endpoint_returns_report() {
1960 let runtime = rt();
1961 let h = Harness::new(&runtime);
1962 let r = h.router.clone();
1963 runtime.block_on(async move {
1964 let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1966 assert_eq!(status, StatusCode::OK);
1967 for field in [
1968 "episodes_seen",
1969 "clusters_built",
1970 "episodes_clustered",
1971 "abstractions_built",
1972 "triples_built",
1973 "contradictions_found",
1974 ] {
1975 assert!(
1976 body.get(field).and_then(|v| v.as_u64()).is_some(),
1977 "missing field {field}: {body}"
1978 );
1979 }
1980 assert_eq!(body["episodes_seen"], 0);
1981 assert_eq!(body["clusters_built"], 0);
1982
1983 let (status2, _body2) = call(
1986 r,
1987 "POST",
1988 "/memory/consolidate",
1989 Some(json!({ "window_days": 7 })),
1990 )
1991 .await;
1992 assert_eq!(status2, StatusCode::OK);
1993 });
1994 h.shutdown(&runtime);
1995 }
1996
1997 #[test]
1998 fn auth_required_routes_reject_missing_token() {
1999 let runtime = rt();
2000 let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
2001 let r = h.router.clone();
2002 runtime.block_on(async move {
2003 let (status, _body) = call(
2005 r.clone(),
2006 "POST",
2007 "/memory",
2008 Some(json!({ "content": "x" })),
2009 )
2010 .await;
2011 assert_eq!(status, StatusCode::UNAUTHORIZED);
2012
2013 let (status, _body) = call_with_auth(
2015 r.clone(),
2016 "POST",
2017 "/memory",
2018 Some(json!({ "content": "x" })),
2019 Some("Bearer wrong-token"),
2020 )
2021 .await;
2022 assert_eq!(status, StatusCode::UNAUTHORIZED);
2023
2024 let (status, body) = call_with_auth(
2026 r.clone(),
2027 "POST",
2028 "/memory",
2029 Some(json!({ "content": "authed" })),
2030 Some("Bearer secret-xyz"),
2031 )
2032 .await;
2033 assert_eq!(status, StatusCode::OK);
2034 assert!(body.get("memory_id").is_some());
2035 });
2036 h.shutdown(&runtime);
2037 }
2038
2039 #[test]
2040 fn health_endpoint_does_not_require_auth() {
2041 let runtime = rt();
2042 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
2043 let r = h.router.clone();
2044 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
2045 assert_eq!(status, StatusCode::OK);
2047 h.shutdown(&runtime);
2048 }
2049
2050 #[test]
2051 fn auth_response_includes_www_authenticate_header() {
2052 let runtime = rt();
2057 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
2058 let r = h.router.clone();
2059 runtime.block_on(async move {
2060 let req = Request::builder()
2061 .method("POST")
2062 .uri("/memory")
2063 .header("content-type", "application/json")
2064 .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
2065 .unwrap();
2066 let resp = r.oneshot(req).await.unwrap();
2067 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2068 let www = resp
2069 .headers()
2070 .get("www-authenticate")
2071 .and_then(|v| v.to_str().ok())
2072 .unwrap_or("");
2073 assert!(
2074 www.starts_with("Bearer"),
2075 "expected WWW-Authenticate: Bearer..., got: {www}"
2076 );
2077 });
2078 h.shutdown(&runtime);
2079 }
2080
2081 fn base64_url_for_test(bytes: &[u8]) -> String {
2089 use base64::Engine;
2090 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
2091 }
2092
2093 async fn spin_fake_idp() -> (wiremock::MockServer, String, Vec<u8>, &'static str) {
2096 use wiremock::matchers::{method, path};
2097 use wiremock::{Mock, MockServer, ResponseTemplate};
2098 let server = MockServer::start().await;
2099 let secret = b"http-test-secret-for-hmac-fixture".to_vec();
2100 let kid = "http-test-kid";
2101 let discovery = serde_json::json!({
2102 "issuer": server.uri(),
2103 "jwks_uri": format!("{}/jwks", server.uri()),
2104 });
2105 Mock::given(method("GET"))
2106 .and(path("/.well-known/openid-configuration"))
2107 .respond_with(ResponseTemplate::new(200).set_body_json(discovery))
2108 .mount(&server)
2109 .await;
2110 let jwks = serde_json::json!({
2111 "keys": [
2112 {
2113 "kty": "oct",
2114 "kid": kid,
2115 "alg": "HS256",
2116 "k": base64_url_for_test(&secret),
2117 }
2118 ]
2119 });
2120 Mock::given(method("GET"))
2121 .and(path("/jwks"))
2122 .respond_with(ResponseTemplate::new(200).set_body_json(jwks))
2123 .mount(&server)
2124 .await;
2125 let discovery_url = format!("{}/.well-known/openid-configuration", server.uri());
2126 (server, discovery_url, secret, kid)
2127 }
2128
2129 fn mint_idp_token(
2130 server_uri: &str,
2131 kid: &str,
2132 secret: &[u8],
2133 tenant_claim: &str,
2134 audience: &str,
2135 ) -> String {
2136 use jsonwebtoken::{Algorithm, EncodingKey, Header};
2137 let mut header = Header::new(Algorithm::HS256);
2138 header.kid = Some(kid.to_string());
2139 let now = std::time::SystemTime::now()
2140 .duration_since(std::time::UNIX_EPOCH)
2141 .unwrap()
2142 .as_secs();
2143 let claims = serde_json::json!({
2144 "iss": server_uri,
2145 "sub": "test-user-1",
2146 "aud": audience,
2147 "exp": now + 600,
2148 "iat": now,
2149 "solo_tenant": tenant_claim,
2150 });
2151 jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(secret))
2152 .expect("mint token")
2153 }
2154
2155 #[test]
2156 fn http_oidc_accept_resolves_to_tenant_from_claim() {
2157 let runtime = rt();
2158 let (fake_server, discovery_url, secret, kid) =
2159 runtime.block_on(async { spin_fake_idp().await });
2160 let server_uri = fake_server.uri();
2161 let _server_guard = fake_server;
2163
2164 let auth = crate::auth::AuthConfig::Oidc {
2165 discovery_url,
2166 audience: "test-audience".to_string(),
2167 tenant_claim_name: "solo_tenant".to_string(),
2168 };
2169 let h = Harness::new_with_auth_config(&runtime, Some(auth));
2170 let r = h.router.clone();
2171
2172 let token = mint_idp_token(
2174 &server_uri,
2175 kid,
2176 &secret,
2177 "default",
2178 "test-audience",
2179 );
2180
2181 runtime.block_on(async move {
2182 let (status, body) = call_with_auth(
2184 r.clone(),
2185 "POST",
2186 "/memory",
2187 Some(json!({ "content": "oidc-routed content" })),
2188 Some(&format!("Bearer {token}")),
2189 )
2190 .await;
2191 assert_eq!(status, StatusCode::OK, "got body: {body}");
2192 assert!(body.get("memory_id").is_some(), "no memory_id in {body}");
2193 });
2194 h.shutdown(&runtime);
2195 }
2196
2197 #[test]
2198 fn http_oidc_reject_missing_token_returns_401() {
2199 let runtime = rt();
2200 let (fake_server, discovery_url, _secret, _kid) =
2201 runtime.block_on(async { spin_fake_idp().await });
2202 let _server_guard = fake_server;
2203 let auth = crate::auth::AuthConfig::Oidc {
2204 discovery_url,
2205 audience: "test-audience".to_string(),
2206 tenant_claim_name: "solo_tenant".to_string(),
2207 };
2208 let h = Harness::new_with_auth_config(&runtime, Some(auth));
2209 let r = h.router.clone();
2210 runtime.block_on(async move {
2211 let (status, _body) =
2213 call(r.clone(), "POST", "/memory", Some(json!({ "content": "x" }))).await;
2214 assert_eq!(status, StatusCode::UNAUTHORIZED);
2215
2216 let (status, _body) = call_with_auth(
2218 r.clone(),
2219 "POST",
2220 "/memory",
2221 Some(json!({ "content": "x" })),
2222 Some("Bearer not-a-real-jwt"),
2223 )
2224 .await;
2225 assert_eq!(status, StatusCode::UNAUTHORIZED);
2226 });
2227 h.shutdown(&runtime);
2228 }
2229
2230 #[test]
2231 fn full_remember_recall_inspect_forget_round_trip() {
2232 let runtime = rt();
2233 let h = Harness::new(&runtime);
2234 let r = h.router.clone();
2235 runtime.block_on(async move {
2236 let (status, body) = call(
2238 r.clone(),
2239 "POST",
2240 "/memory",
2241 Some(json!({ "content": "round-trip content" })),
2242 )
2243 .await;
2244 assert_eq!(status, StatusCode::OK);
2245 let mid = body
2246 .get("memory_id")
2247 .and_then(|v| v.as_str())
2248 .unwrap()
2249 .to_string();
2250
2251 let (status, body) = call(
2253 r.clone(),
2254 "POST",
2255 "/memory/search",
2256 Some(json!({ "query": "round-trip content", "limit": 5 })),
2257 )
2258 .await;
2259 assert_eq!(status, StatusCode::OK);
2260 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
2261 assert!(
2262 hits.iter()
2263 .any(|h| h.get("content").and_then(|c| c.as_str())
2264 == Some("round-trip content")),
2265 "expected hit with content; got: {body}"
2266 );
2267
2268 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
2270 assert_eq!(status, StatusCode::OK);
2271 assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
2272
2273 let (status, _body) =
2275 call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
2276 assert_eq!(status, StatusCode::NO_CONTENT);
2277
2278 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
2280 assert_eq!(status, StatusCode::OK);
2281 assert_eq!(
2282 body.get("status").and_then(|v| v.as_str()),
2283 Some("forgotten")
2284 );
2285
2286 let (status, body) = call(
2288 r.clone(),
2289 "POST",
2290 "/memory/search",
2291 Some(json!({ "query": "round-trip content", "limit": 5 })),
2292 )
2293 .await;
2294 assert_eq!(status, StatusCode::OK);
2295 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
2296 assert!(
2297 hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
2298 != Some(mid.as_str())),
2299 "forgotten row should be excluded from recall: {body}"
2300 );
2301 });
2302 h.shutdown(&runtime);
2303 }
2304
2305 #[test]
2312 fn themes_endpoint_returns_empty_array_on_empty_db() {
2313 let runtime = rt();
2314 let h = Harness::new(&runtime);
2315 let r = h.router.clone();
2316 let (status, body) =
2317 runtime.block_on(call(r, "GET", "/memory/themes", None));
2318 assert_eq!(status, StatusCode::OK);
2319 assert!(body.is_array(), "expected array, got {body}");
2320 assert_eq!(body.as_array().unwrap().len(), 0);
2321 h.shutdown(&runtime);
2322 }
2323
2324 #[test]
2325 fn themes_endpoint_passes_through_query_params() {
2326 let runtime = rt();
2327 let h = Harness::new(&runtime);
2328 let r = h.router.clone();
2329 let (status, body) = runtime.block_on(call(
2330 r,
2331 "GET",
2332 "/memory/themes?window_days=7&limit=20",
2333 None,
2334 ));
2335 assert_eq!(status, StatusCode::OK);
2336 assert!(body.is_array(), "expected array, got {body}");
2337 h.shutdown(&runtime);
2338 }
2339
2340 #[test]
2341 fn facts_about_endpoint_requires_subject() {
2342 let runtime = rt();
2343 let h = Harness::new(&runtime);
2344 let r = h.router.clone();
2345 let (status, _body) =
2349 runtime.block_on(call(r, "GET", "/memory/facts_about", None));
2350 assert!(
2351 status == StatusCode::BAD_REQUEST
2352 || status == StatusCode::UNPROCESSABLE_ENTITY,
2353 "expected 400 or 422 for missing subject, got {status}"
2354 );
2355 h.shutdown(&runtime);
2356 }
2357
2358 #[test]
2359 fn facts_about_endpoint_rejects_blank_subject() {
2360 let runtime = rt();
2361 let h = Harness::new(&runtime);
2362 let r = h.router.clone();
2363 let (status, body) = runtime.block_on(call(
2366 r,
2367 "GET",
2368 "/memory/facts_about?subject=%20%20",
2369 None,
2370 ));
2371 assert_eq!(status, StatusCode::BAD_REQUEST);
2372 assert!(
2373 body.get("error")
2374 .and_then(|v| v.as_str())
2375 .is_some_and(|s| s.contains("subject")),
2376 "expected error mentioning subject, got {body}"
2377 );
2378 h.shutdown(&runtime);
2379 }
2380
2381 #[test]
2382 fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
2383 let runtime = rt();
2384 let h = Harness::new(&runtime);
2385 let r = h.router.clone();
2386 let (status, body) = runtime.block_on(call(
2387 r,
2388 "GET",
2389 "/memory/facts_about?subject=NobodyKnows",
2390 None,
2391 ));
2392 assert_eq!(status, StatusCode::OK);
2393 assert_eq!(body.as_array().unwrap().len(), 0);
2394 h.shutdown(&runtime);
2395 }
2396
2397 #[test]
2398 fn facts_about_endpoint_parses_include_as_object_query_param() {
2399 let runtime = rt();
2407 let h = Harness::new(&runtime);
2408 let r = h.router.clone();
2409 let (status, body) = runtime.block_on(call(
2410 r,
2411 "GET",
2412 "/memory/facts_about?subject=Maya&include_as_object=true",
2413 None,
2414 ));
2415 assert_eq!(
2416 status,
2417 StatusCode::OK,
2418 "expected 200 with include_as_object query param, got {status}"
2419 );
2420 assert!(body.is_array());
2421 h.shutdown(&runtime);
2422 }
2423
2424 #[test]
2425 fn inspect_cluster_endpoint_unknown_id_returns_404() {
2426 let runtime = rt();
2430 let h = Harness::new(&runtime);
2431 let r = h.router.clone();
2432 let (status, body) = runtime.block_on(call(
2433 r,
2434 "GET",
2435 "/memory/clusters/no-such-cluster",
2436 None,
2437 ));
2438 assert_eq!(status, StatusCode::NOT_FOUND);
2439 assert!(
2440 body.get("error")
2441 .and_then(|v| v.as_str())
2442 .is_some_and(|s| s.contains("no-such-cluster")),
2443 "expected error mentioning cluster id, got {body}"
2444 );
2445 h.shutdown(&runtime);
2446 }
2447
2448 #[test]
2449 fn inspect_cluster_endpoint_passes_full_content_query_param() {
2450 let runtime = rt();
2456 let h = Harness::new(&runtime);
2457 let r = h.router.clone();
2458 let (status, _body) = runtime.block_on(call(
2459 r,
2460 "GET",
2461 "/memory/clusters/missing?full_content=true",
2462 None,
2463 ));
2464 assert_eq!(status, StatusCode::NOT_FOUND);
2465 h.shutdown(&runtime);
2466 }
2467
2468 #[test]
2469 fn contradictions_endpoint_returns_empty_array_on_empty_db() {
2470 let runtime = rt();
2471 let h = Harness::new(&runtime);
2472 let r = h.router.clone();
2473 let (status, body) = runtime.block_on(call(
2474 r,
2475 "GET",
2476 "/memory/contradictions",
2477 None,
2478 ));
2479 assert_eq!(status, StatusCode::OK);
2480 assert!(body.is_array());
2481 assert_eq!(body.as_array().unwrap().len(), 0);
2482 h.shutdown(&runtime);
2483 }
2484
2485 #[test]
2486 fn derived_endpoints_require_bearer_when_auth_enabled() {
2487 let runtime = rt();
2488 let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
2489 for path in [
2496 "/memory/themes",
2497 "/memory/facts_about?subject=Sam",
2498 "/memory/contradictions",
2499 "/memory/clusters/any-id",
2500 ] {
2501 let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
2502 assert_eq!(
2503 status,
2504 StatusCode::UNAUTHORIZED,
2505 "{path} should 401 without token"
2506 );
2507 }
2508 h.shutdown(&runtime);
2509 }
2510
2511 #[test]
2523 fn list_documents_endpoint_returns_empty_array_on_empty_db() {
2524 let runtime = rt();
2525 let h = Harness::new(&runtime);
2526 let r = h.router.clone();
2527 let (status, body) = runtime.block_on(call(r, "GET", "/memory/documents", None));
2528 assert_eq!(status, StatusCode::OK);
2529 assert!(body.is_array(), "expected array, got {body}");
2530 assert_eq!(body.as_array().unwrap().len(), 0);
2531 h.shutdown(&runtime);
2532 }
2533
2534 #[test]
2535 fn list_documents_endpoint_parses_query_params() {
2536 let runtime = rt();
2537 let h = Harness::new(&runtime);
2538 let r = h.router.clone();
2539 let (status, body) = runtime.block_on(call(
2540 r,
2541 "GET",
2542 "/memory/documents?limit=5&offset=0&include_forgotten=true",
2543 None,
2544 ));
2545 assert_eq!(status, StatusCode::OK);
2546 assert!(body.is_array());
2547 h.shutdown(&runtime);
2548 }
2549
2550 #[test]
2551 fn ingest_document_endpoint_rejects_empty_path() {
2552 let runtime = rt();
2553 let h = Harness::new(&runtime);
2554 let r = h.router.clone();
2555 let (status, body) = runtime.block_on(call(
2556 r,
2557 "POST",
2558 "/memory/documents",
2559 Some(json!({ "path": "" })),
2560 ));
2561 assert_eq!(status, StatusCode::BAD_REQUEST);
2562 assert!(
2563 body.get("error")
2564 .and_then(|v| v.as_str())
2565 .is_some_and(|s| s.contains("path")),
2566 "expected error mentioning path, got {body}"
2567 );
2568 h.shutdown(&runtime);
2569 }
2570
2571 #[test]
2572 fn search_docs_endpoint_rejects_empty_query() {
2573 let runtime = rt();
2574 let h = Harness::new(&runtime);
2575 let r = h.router.clone();
2576 let (status, body) = runtime.block_on(call(
2577 r,
2578 "POST",
2579 "/memory/documents/search",
2580 Some(json!({ "query": " " })),
2581 ));
2582 assert_eq!(status, StatusCode::BAD_REQUEST);
2583 assert!(
2584 body.get("error")
2585 .and_then(|v| v.as_str())
2586 .is_some_and(|s| s.contains("must not be empty")
2587 || s.contains("doc_search")),
2588 "expected error mentioning empty query, got {body}"
2589 );
2590 h.shutdown(&runtime);
2591 }
2592
2593 #[test]
2594 fn inspect_document_endpoint_unknown_id_returns_404() {
2595 let runtime = rt();
2596 let h = Harness::new(&runtime);
2597 let r = h.router.clone();
2598 let (status, body) = runtime.block_on(call(
2599 r,
2600 "GET",
2601 "/memory/documents/00000000-0000-7000-8000-000000000000",
2602 None,
2603 ));
2604 assert_eq!(status, StatusCode::NOT_FOUND);
2605 assert!(body.get("error").is_some(), "got: {body}");
2606 h.shutdown(&runtime);
2607 }
2608
2609 #[test]
2610 fn inspect_document_endpoint_rejects_malformed_id() {
2611 let runtime = rt();
2612 let h = Harness::new(&runtime);
2613 let r = h.router.clone();
2614 let (status, _body) =
2615 runtime.block_on(call(r, "GET", "/memory/documents/not-a-uuid", None));
2616 assert_eq!(status, StatusCode::BAD_REQUEST);
2617 h.shutdown(&runtime);
2618 }
2619
2620 #[test]
2621 fn forget_document_endpoint_unknown_id_returns_404() {
2622 let runtime = rt();
2625 let h = Harness::new(&runtime);
2626 let r = h.router.clone();
2627 let (status, _body) = runtime.block_on(call(
2628 r,
2629 "DELETE",
2630 "/memory/documents/00000000-0000-7000-8000-000000000000",
2631 None,
2632 ));
2633 assert_eq!(status, StatusCode::NOT_FOUND);
2634 h.shutdown(&runtime);
2635 }
2636
2637 #[test]
2638 fn forget_document_endpoint_rejects_malformed_id() {
2639 let runtime = rt();
2640 let h = Harness::new(&runtime);
2641 let r = h.router.clone();
2642 let (status, _body) =
2643 runtime.block_on(call(r, "DELETE", "/memory/documents/not-a-uuid", None));
2644 assert_eq!(status, StatusCode::BAD_REQUEST);
2645 h.shutdown(&runtime);
2646 }
2647
2648 #[test]
2649 fn document_endpoints_require_bearer_when_auth_enabled() {
2650 let runtime = rt();
2654 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2655 let cases: &[(&str, &str, Option<Value>)] = &[
2656 ("POST", "/memory/documents", Some(json!({ "path": "/x" }))),
2657 ("GET", "/memory/documents", None),
2658 (
2659 "POST",
2660 "/memory/documents/search",
2661 Some(json!({ "query": "x" })),
2662 ),
2663 (
2664 "GET",
2665 "/memory/documents/00000000-0000-7000-8000-000000000000",
2666 None,
2667 ),
2668 (
2669 "DELETE",
2670 "/memory/documents/00000000-0000-7000-8000-000000000000",
2671 None,
2672 ),
2673 ];
2674 for (method, path, body) in cases {
2675 let (status, _) =
2676 runtime.block_on(call(h.router.clone(), method, path, body.clone()));
2677 assert_eq!(
2678 status,
2679 StatusCode::UNAUTHORIZED,
2680 "{method} {path} should 401 without token"
2681 );
2682 }
2683 h.shutdown(&runtime);
2684 }
2685
2686 #[test]
2687 fn document_endpoints_accept_correct_bearer_token() {
2688 let runtime = rt();
2694 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2695 runtime.block_on(async {
2696 let (status, _) = call_with_auth(
2698 h.router.clone(),
2699 "GET",
2700 "/memory/documents",
2701 None,
2702 Some("Bearer doc-secret"),
2703 )
2704 .await;
2705 assert_eq!(status, StatusCode::OK);
2706
2707 let (status, _) = call_with_auth(
2709 h.router.clone(),
2710 "GET",
2711 "/memory/documents/00000000-0000-7000-8000-000000000000",
2712 None,
2713 Some("Bearer doc-secret"),
2714 )
2715 .await;
2716 assert_eq!(status, StatusCode::NOT_FOUND);
2717 });
2718 h.shutdown(&runtime);
2719 }
2720
2721 #[test]
2728 fn tenant_header_default_resolves() {
2729 let runtime = rt();
2730 let h = Harness::new(&runtime);
2731 let r = h.router.clone();
2732 let (status, _body) = runtime.block_on(async {
2733 let req = Request::builder()
2734 .method("GET")
2735 .uri("/memory/00000000-0000-7000-8000-000000000000")
2736 .header("x-solo-tenant", "default")
2737 .body(Body::empty())
2738 .unwrap();
2739 let resp = r.oneshot(req).await.expect("oneshot");
2740 let s = resp.status();
2741 let _b = resp.into_body().collect().await.unwrap().to_bytes();
2742 (s, _b)
2743 });
2744 assert_eq!(status, StatusCode::NOT_FOUND);
2748 h.shutdown(&runtime);
2749 }
2750
2751 #[test]
2753 fn tenant_header_invalid_returns_400() {
2754 let runtime = rt();
2755 let h = Harness::new(&runtime);
2756 let r = h.router.clone();
2757 let (status, body) = runtime.block_on(async {
2758 let req = Request::builder()
2759 .method("GET")
2760 .uri("/memory/00000000-0000-7000-8000-000000000000")
2761 .header("x-solo-tenant", "UPPER")
2762 .body(Body::empty())
2763 .unwrap();
2764 let resp = r.oneshot(req).await.expect("oneshot");
2765 let s = resp.status();
2766 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
2767 let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
2768 (s, v)
2769 });
2770 assert_eq!(status, StatusCode::BAD_REQUEST);
2771 let msg = body.get("error").and_then(|e| e.as_str()).unwrap_or("");
2772 assert!(
2773 msg.to_lowercase().contains("tenant") || msg.to_lowercase().contains("invalid"),
2774 "error must mention tenant/invalid: {msg}"
2775 );
2776 h.shutdown(&runtime);
2777 }
2778
2779 #[test]
2781 fn tenant_header_unknown_returns_404() {
2782 let runtime = rt();
2783 let h = Harness::new(&runtime);
2784 let r = h.router.clone();
2785 let (status, _body) = runtime.block_on(async {
2786 let req = Request::builder()
2787 .method("GET")
2788 .uri("/memory/00000000-0000-7000-8000-000000000000")
2789 .header("x-solo-tenant", "never-registered")
2790 .body(Body::empty())
2791 .unwrap();
2792 let resp = r.oneshot(req).await.expect("oneshot");
2793 let s = resp.status();
2794 let _b = resp.into_body().collect().await.unwrap().to_bytes();
2795 (s, _b)
2796 });
2797 assert_eq!(status, StatusCode::NOT_FOUND);
2798 h.shutdown(&runtime);
2799 }
2800
2801 #[test]
2805 fn tenant_header_missing_defaults_to_state_default_tenant() {
2806 let runtime = rt();
2807 let h = Harness::new(&runtime);
2808 let r = h.router.clone();
2809 let (status, _body) = runtime.block_on(async {
2810 let req = Request::builder()
2811 .method("GET")
2812 .uri("/memory/00000000-0000-7000-8000-000000000000")
2813 .body(Body::empty())
2814 .unwrap();
2815 let resp = r.oneshot(req).await.expect("oneshot");
2816 let s = resp.status();
2817 let _b = resp.into_body().collect().await.unwrap().to_bytes();
2818 (s, _b)
2819 });
2820 assert_eq!(status, StatusCode::NOT_FOUND);
2821 h.shutdown(&runtime);
2822 }
2823}
2824
2825#[cfg(test)]
2826mod cors_tests {
2827 use super::is_localhost_origin;
2828
2829 #[test]
2830 fn accepts_canonical_localhost_origins() {
2831 assert!(is_localhost_origin("http://localhost"));
2832 assert!(is_localhost_origin("http://localhost:3000"));
2833 assert!(is_localhost_origin("https://localhost:8443"));
2834 assert!(is_localhost_origin("http://127.0.0.1"));
2835 assert!(is_localhost_origin("http://127.0.0.1:5173"));
2836 assert!(is_localhost_origin("http://[::1]"));
2837 assert!(is_localhost_origin("http://[::1]:8080"));
2838 }
2839
2840 #[test]
2841 fn rejects_remote_origins() {
2842 assert!(!is_localhost_origin("http://example.com"));
2843 assert!(!is_localhost_origin("https://malicious.example"));
2844 assert!(!is_localhost_origin("http://192.168.1.5"));
2845 assert!(!is_localhost_origin("http://10.0.0.1"));
2846 }
2847
2848 #[test]
2849 fn rejects_dns_rebinding_tricks() {
2850 assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
2854 assert!(!is_localhost_origin("http://localhost.evil.com"));
2855 assert!(!is_localhost_origin("http://evil.localhost"));
2856 }
2857
2858 #[test]
2859 fn rejects_non_http_schemes() {
2860 assert!(!is_localhost_origin("file:///"));
2861 assert!(!is_localhost_origin("ws://localhost:3000"));
2862 assert!(!is_localhost_origin("javascript:alert(1)"));
2863 }
2864
2865 #[test]
2866 fn rejects_malformed() {
2867 assert!(!is_localhost_origin(""));
2868 assert!(!is_localhost_origin("localhost"));
2869 assert!(!is_localhost_origin("//localhost"));
2870 }
2871}
2872