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 }
1518 }
1519
1520 struct Harness {
1521 router: axum::Router,
1522 _tmp: tempfile::TempDir,
1523 write_handle_extra: Option<solo_storage::WriteHandle>,
1524 join: Option<std::thread::JoinHandle<()>>,
1525 }
1526
1527 impl Harness {
1528 fn new(runtime: &tokio::runtime::Runtime) -> Self {
1529 Self::new_with_auth(runtime, None)
1530 }
1531
1532 fn new_with_auth(
1533 runtime: &tokio::runtime::Runtime,
1534 bearer_token: Option<String>,
1535 ) -> Self {
1536 Self::new_with_auth_config(
1537 runtime,
1538 bearer_token.map(|token| crate::auth::AuthConfig::Bearer { token }),
1539 )
1540 }
1541
1542 fn new_with_auth_config(
1543 runtime: &tokio::runtime::Runtime,
1544 auth: Option<crate::auth::AuthConfig>,
1545 ) -> Self {
1546 use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
1547
1548 let tmp = tempfile::TempDir::new().unwrap();
1549 let dim = 16usize;
1550 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1551 let embedder: StdArc<dyn solo_core::Embedder> =
1552 StdArc::new(StubEmbedder::new("stub", "v1", dim));
1553 let path = tmp.path().join("test.db");
1554
1555 let embedder_id = {
1556 let conn = solo_storage::test_support::open_test_db_at(&path);
1557 get_or_insert_embedder_id(
1558 &conn,
1559 &EmbedderIdentity {
1560 name: "stub".into(),
1561 version: "v1".into(),
1562 dim: dim as u32,
1563 dtype: "f32".into(),
1564 },
1565 )
1566 .unwrap()
1567 };
1568
1569 let conn = solo_storage::test_support::open_test_db_at(&path);
1570 let WriterSpawn { handle, join } = WriterActor::spawn_full(
1571 conn,
1572 hnsw.clone(),
1573 tmp.path().to_path_buf(),
1574 embedder_id,
1575 );
1576 let pool: ReaderPool =
1577 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1578
1579 let tenant_id = solo_core::TenantId::default_tenant();
1582 let tenant_handle = StdArc::new(
1583 TenantHandle::from_parts_for_tests(
1584 tenant_id.clone(),
1585 fake_config(dim as u32),
1586 path.clone(),
1587 tmp.path().to_path_buf(),
1588 embedder_id,
1589 hnsw,
1590 embedder.clone(),
1591 handle.clone(),
1592 std::thread::spawn(|| {}),
1598 pool,
1599 ),
1600 );
1601
1602 let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
1606 let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
1607 tmp.path().to_path_buf(),
1608 key,
1609 embedder,
1610 tenant_handle,
1611 ));
1612
1613 let state = SoloHttpState {
1614 registry,
1615 default_tenant: tenant_id,
1616 user_aliases: Arc::new(Vec::new()),
1617 };
1618 let router = router_with_auth_config(state, auth);
1619 Harness {
1620 router,
1621 _tmp: tmp,
1622 write_handle_extra: Some(handle),
1623 join: Some(join),
1624 }
1625 }
1626
1627 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1628 let join = self.join.take();
1629 let extra = self.write_handle_extra.take();
1630 runtime.block_on(async move {
1631 drop(extra);
1632 drop(self.router); drop(self._tmp);
1634 if let Some(join) = join {
1635 let (tx, rx) = std::sync::mpsc::channel();
1636 std::thread::spawn(move || {
1637 let _ = tx.send(join.join());
1638 });
1639 tokio::task::spawn_blocking(move || {
1640 rx.recv_timeout(std::time::Duration::from_secs(5))
1641 })
1642 .await
1643 .expect("blocking task")
1644 .expect("writer thread did not exit within 5s")
1645 .expect("writer thread panicked");
1646 }
1647 });
1648 }
1649 }
1650
1651 fn rt() -> tokio::runtime::Runtime {
1652 tokio::runtime::Builder::new_multi_thread()
1653 .worker_threads(2)
1654 .enable_all()
1655 .build()
1656 .unwrap()
1657 }
1658
1659 async fn call(
1663 router: axum::Router,
1664 method: &str,
1665 uri: &str,
1666 body: Option<Value>,
1667 ) -> (StatusCode, Value) {
1668 call_with_auth(router, method, uri, body, None).await
1669 }
1670
1671 async fn call_with_auth(
1672 router: axum::Router,
1673 method: &str,
1674 uri: &str,
1675 body: Option<Value>,
1676 auth: Option<&str>,
1677 ) -> (StatusCode, Value) {
1678 let mut req_builder = Request::builder()
1679 .method(method)
1680 .uri(uri)
1681 .header("content-type", "application/json");
1682 if let Some(a) = auth {
1683 req_builder = req_builder.header("authorization", a);
1684 }
1685 let req = if let Some(b) = body {
1686 let bytes = serde_json::to_vec(&b).unwrap();
1687 req_builder.body(Body::from(bytes)).unwrap()
1688 } else {
1689 req_builder = req_builder.header("content-length", "0");
1690 req_builder.body(Body::empty()).unwrap()
1691 };
1692 let resp = router.oneshot(req).await.expect("oneshot");
1693 let status = resp.status();
1694 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
1695 let v: Value = if body_bytes.is_empty() {
1696 Value::Null
1697 } else {
1698 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
1699 };
1700 (status, v)
1701 }
1702
1703 #[test]
1704 fn health_returns_ok() {
1705 let runtime = rt();
1706 let h = Harness::new(&runtime);
1707 let r = h.router.clone();
1708 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1709 assert_eq!(status, StatusCode::OK);
1710 h.shutdown(&runtime);
1711 }
1712
1713 #[test]
1718 fn openapi_json_describes_all_endpoints() {
1719 let runtime = rt();
1720 let h = Harness::new(&runtime);
1721 let r = h.router.clone();
1722 let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1723 assert_eq!(status, StatusCode::OK);
1724 assert!(spec.is_object(), "openapi.json must be a JSON object");
1725
1726 assert!(
1728 spec.get("openapi")
1729 .and_then(|v| v.as_str())
1730 .is_some_and(|s| s.starts_with("3.")),
1731 "missing or wrong openapi version: {spec}"
1732 );
1733 assert!(spec.pointer("/info/title").is_some());
1734 assert!(spec.pointer("/info/version").is_some());
1735
1736 let paths = spec
1738 .get("paths")
1739 .and_then(|v| v.as_object())
1740 .expect("paths must be an object");
1741 for expected in [
1742 "/health",
1743 "/openapi.json",
1744 "/memory",
1745 "/memory/search",
1746 "/memory/consolidate",
1747 "/memory/{id}",
1748 "/memory/themes",
1750 "/memory/facts_about",
1751 "/memory/contradictions",
1752 "/memory/clusters/{cluster_id}",
1754 "/memory/documents",
1756 "/memory/documents/search",
1757 "/memory/documents/{id}",
1758 ] {
1759 assert!(
1760 paths.contains_key(expected),
1761 "openapi paths missing {expected}: {paths:?}"
1762 );
1763 }
1764
1765 let docs = paths.get("/memory/documents").expect("/memory/documents");
1768 assert!(docs.get("post").is_some(), "POST /memory/documents undocumented");
1769 assert!(docs.get("get").is_some(), "GET /memory/documents undocumented");
1770
1771 let docid = paths
1774 .get("/memory/documents/{id}")
1775 .expect("/memory/documents/{id}");
1776 assert!(
1777 docid.get("get").is_some(),
1778 "GET /memory/documents/{{id}} undocumented"
1779 );
1780 assert!(
1781 docid.get("delete").is_some(),
1782 "DELETE /memory/documents/{{id}} undocumented"
1783 );
1784
1785 let memid = paths.get("/memory/{id}").expect("memory/{id}");
1788 assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1789 assert!(
1790 memid.get("delete").is_some(),
1791 "DELETE /memory/{{id}} undocumented"
1792 );
1793
1794 for schema_name in [
1796 "RememberRequest",
1797 "RememberResponse",
1798 "RecallRequest",
1799 "RecallResult",
1800 "EpisodeRecord",
1801 "ApiError",
1802 "ConsolidationScope",
1803 "ConsolidationReport",
1804 "ThemeHit",
1806 "FactHit",
1807 "ContradictionHit",
1808 "ClusterRecord",
1810 "IngestDocumentRequest",
1812 "IngestReport",
1813 "ForgetDocumentReport",
1814 "SearchDocsRequest",
1815 "DocSearchHit",
1816 "DocumentInspectResult",
1817 "DocumentSummary",
1818 ] {
1819 let ptr = format!("/components/schemas/{schema_name}");
1820 assert!(
1821 spec.pointer(&ptr).is_some(),
1822 "component schema {schema_name} missing"
1823 );
1824 }
1825
1826 assert!(
1828 spec.pointer("/components/securitySchemes/bearerAuth")
1829 .is_some(),
1830 "bearerAuth security scheme missing"
1831 );
1832
1833 h.shutdown(&runtime);
1834 }
1835
1836 #[test]
1840 fn openapi_json_is_exempt_from_bearer_auth() {
1841 let runtime = rt();
1842 let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1843 let r = h.router.clone();
1844 let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1846 assert_eq!(status, StatusCode::OK);
1847 h.shutdown(&runtime);
1848 }
1849
1850 #[test]
1851 fn remember_returns_memory_id() {
1852 let runtime = rt();
1853 let h = Harness::new(&runtime);
1854 let r = h.router.clone();
1855 let (status, body) = runtime.block_on(call(
1856 r,
1857 "POST",
1858 "/memory",
1859 Some(json!({ "content": "http harness test" })),
1860 ));
1861 assert_eq!(status, StatusCode::OK);
1862 let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1863 assert_eq!(mid.len(), 36, "uuid length");
1864 h.shutdown(&runtime);
1865 }
1866
1867 #[test]
1868 fn empty_content_returns_400() {
1869 let runtime = rt();
1870 let h = Harness::new(&runtime);
1871 let r = h.router.clone();
1872 let (status, body) =
1873 runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1874 assert_eq!(status, StatusCode::BAD_REQUEST);
1875 assert!(
1876 body.get("error")
1877 .and_then(|e| e.as_str())
1878 .map(|s| s.contains("must not be empty"))
1879 .unwrap_or(false),
1880 "got: {body}"
1881 );
1882 h.shutdown(&runtime);
1883 }
1884
1885 #[test]
1886 fn empty_query_returns_400() {
1887 let runtime = rt();
1888 let h = Harness::new(&runtime);
1889 let r = h.router.clone();
1890 let (status, body) = runtime.block_on(call(
1891 r,
1892 "POST",
1893 "/memory/search",
1894 Some(json!({ "query": "" })),
1895 ));
1896 assert_eq!(status, StatusCode::BAD_REQUEST);
1897 assert!(
1898 body.get("error")
1899 .and_then(|e| e.as_str())
1900 .map(|s| s.contains("must not be empty"))
1901 .unwrap_or(false),
1902 "got: {body}"
1903 );
1904 h.shutdown(&runtime);
1905 }
1906
1907 #[test]
1908 fn inspect_unknown_returns_404() {
1909 let runtime = rt();
1910 let h = Harness::new(&runtime);
1911 let r = h.router.clone();
1912 let (status, body) = runtime.block_on(call(
1913 r,
1914 "GET",
1915 "/memory/00000000-0000-7000-8000-000000000000",
1916 None,
1917 ));
1918 assert_eq!(status, StatusCode::NOT_FOUND);
1919 assert!(body.get("error").is_some(), "got: {body}");
1920 h.shutdown(&runtime);
1921 }
1922
1923 #[test]
1924 fn inspect_invalid_id_returns_400() {
1925 let runtime = rt();
1926 let h = Harness::new(&runtime);
1927 let r = h.router.clone();
1928 let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1929 assert_eq!(status, StatusCode::BAD_REQUEST);
1930 h.shutdown(&runtime);
1931 }
1932
1933 #[test]
1934 fn forget_unknown_returns_404() {
1935 let runtime = rt();
1936 let h = Harness::new(&runtime);
1937 let r = h.router.clone();
1938 let (status, _body) = runtime.block_on(call(
1939 r,
1940 "DELETE",
1941 "/memory/00000000-0000-7000-8000-000000000000",
1942 None,
1943 ));
1944 assert_eq!(status, StatusCode::NOT_FOUND);
1945 h.shutdown(&runtime);
1946 }
1947
1948 #[test]
1956 fn consolidate_endpoint_returns_report() {
1957 let runtime = rt();
1958 let h = Harness::new(&runtime);
1959 let r = h.router.clone();
1960 runtime.block_on(async move {
1961 let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1963 assert_eq!(status, StatusCode::OK);
1964 for field in [
1965 "episodes_seen",
1966 "clusters_built",
1967 "episodes_clustered",
1968 "abstractions_built",
1969 "triples_built",
1970 "contradictions_found",
1971 ] {
1972 assert!(
1973 body.get(field).and_then(|v| v.as_u64()).is_some(),
1974 "missing field {field}: {body}"
1975 );
1976 }
1977 assert_eq!(body["episodes_seen"], 0);
1978 assert_eq!(body["clusters_built"], 0);
1979
1980 let (status2, _body2) = call(
1983 r,
1984 "POST",
1985 "/memory/consolidate",
1986 Some(json!({ "window_days": 7 })),
1987 )
1988 .await;
1989 assert_eq!(status2, StatusCode::OK);
1990 });
1991 h.shutdown(&runtime);
1992 }
1993
1994 #[test]
1995 fn auth_required_routes_reject_missing_token() {
1996 let runtime = rt();
1997 let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1998 let r = h.router.clone();
1999 runtime.block_on(async move {
2000 let (status, _body) = call(
2002 r.clone(),
2003 "POST",
2004 "/memory",
2005 Some(json!({ "content": "x" })),
2006 )
2007 .await;
2008 assert_eq!(status, StatusCode::UNAUTHORIZED);
2009
2010 let (status, _body) = call_with_auth(
2012 r.clone(),
2013 "POST",
2014 "/memory",
2015 Some(json!({ "content": "x" })),
2016 Some("Bearer wrong-token"),
2017 )
2018 .await;
2019 assert_eq!(status, StatusCode::UNAUTHORIZED);
2020
2021 let (status, body) = call_with_auth(
2023 r.clone(),
2024 "POST",
2025 "/memory",
2026 Some(json!({ "content": "authed" })),
2027 Some("Bearer secret-xyz"),
2028 )
2029 .await;
2030 assert_eq!(status, StatusCode::OK);
2031 assert!(body.get("memory_id").is_some());
2032 });
2033 h.shutdown(&runtime);
2034 }
2035
2036 #[test]
2037 fn health_endpoint_does_not_require_auth() {
2038 let runtime = rt();
2039 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
2040 let r = h.router.clone();
2041 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
2042 assert_eq!(status, StatusCode::OK);
2044 h.shutdown(&runtime);
2045 }
2046
2047 #[test]
2048 fn auth_response_includes_www_authenticate_header() {
2049 let runtime = rt();
2054 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
2055 let r = h.router.clone();
2056 runtime.block_on(async move {
2057 let req = Request::builder()
2058 .method("POST")
2059 .uri("/memory")
2060 .header("content-type", "application/json")
2061 .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
2062 .unwrap();
2063 let resp = r.oneshot(req).await.unwrap();
2064 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
2065 let www = resp
2066 .headers()
2067 .get("www-authenticate")
2068 .and_then(|v| v.to_str().ok())
2069 .unwrap_or("");
2070 assert!(
2071 www.starts_with("Bearer"),
2072 "expected WWW-Authenticate: Bearer..., got: {www}"
2073 );
2074 });
2075 h.shutdown(&runtime);
2076 }
2077
2078 fn base64_url_for_test(bytes: &[u8]) -> String {
2086 use base64::Engine;
2087 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
2088 }
2089
2090 async fn spin_fake_idp() -> (wiremock::MockServer, String, Vec<u8>, &'static str) {
2093 use wiremock::matchers::{method, path};
2094 use wiremock::{Mock, MockServer, ResponseTemplate};
2095 let server = MockServer::start().await;
2096 let secret = b"http-test-secret-for-hmac-fixture".to_vec();
2097 let kid = "http-test-kid";
2098 let discovery = serde_json::json!({
2099 "issuer": server.uri(),
2100 "jwks_uri": format!("{}/jwks", server.uri()),
2101 });
2102 Mock::given(method("GET"))
2103 .and(path("/.well-known/openid-configuration"))
2104 .respond_with(ResponseTemplate::new(200).set_body_json(discovery))
2105 .mount(&server)
2106 .await;
2107 let jwks = serde_json::json!({
2108 "keys": [
2109 {
2110 "kty": "oct",
2111 "kid": kid,
2112 "alg": "HS256",
2113 "k": base64_url_for_test(&secret),
2114 }
2115 ]
2116 });
2117 Mock::given(method("GET"))
2118 .and(path("/jwks"))
2119 .respond_with(ResponseTemplate::new(200).set_body_json(jwks))
2120 .mount(&server)
2121 .await;
2122 let discovery_url = format!("{}/.well-known/openid-configuration", server.uri());
2123 (server, discovery_url, secret, kid)
2124 }
2125
2126 fn mint_idp_token(
2127 server_uri: &str,
2128 kid: &str,
2129 secret: &[u8],
2130 tenant_claim: &str,
2131 audience: &str,
2132 ) -> String {
2133 use jsonwebtoken::{Algorithm, EncodingKey, Header};
2134 let mut header = Header::new(Algorithm::HS256);
2135 header.kid = Some(kid.to_string());
2136 let now = std::time::SystemTime::now()
2137 .duration_since(std::time::UNIX_EPOCH)
2138 .unwrap()
2139 .as_secs();
2140 let claims = serde_json::json!({
2141 "iss": server_uri,
2142 "sub": "test-user-1",
2143 "aud": audience,
2144 "exp": now + 600,
2145 "iat": now,
2146 "solo_tenant": tenant_claim,
2147 });
2148 jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(secret))
2149 .expect("mint token")
2150 }
2151
2152 #[test]
2153 fn http_oidc_accept_resolves_to_tenant_from_claim() {
2154 let runtime = rt();
2155 let (fake_server, discovery_url, secret, kid) =
2156 runtime.block_on(async { spin_fake_idp().await });
2157 let server_uri = fake_server.uri();
2158 let _server_guard = fake_server;
2160
2161 let auth = crate::auth::AuthConfig::Oidc {
2162 discovery_url,
2163 audience: "test-audience".to_string(),
2164 tenant_claim_name: "solo_tenant".to_string(),
2165 };
2166 let h = Harness::new_with_auth_config(&runtime, Some(auth));
2167 let r = h.router.clone();
2168
2169 let token = mint_idp_token(
2171 &server_uri,
2172 kid,
2173 &secret,
2174 "default",
2175 "test-audience",
2176 );
2177
2178 runtime.block_on(async move {
2179 let (status, body) = call_with_auth(
2181 r.clone(),
2182 "POST",
2183 "/memory",
2184 Some(json!({ "content": "oidc-routed content" })),
2185 Some(&format!("Bearer {token}")),
2186 )
2187 .await;
2188 assert_eq!(status, StatusCode::OK, "got body: {body}");
2189 assert!(body.get("memory_id").is_some(), "no memory_id in {body}");
2190 });
2191 h.shutdown(&runtime);
2192 }
2193
2194 #[test]
2195 fn http_oidc_reject_missing_token_returns_401() {
2196 let runtime = rt();
2197 let (fake_server, discovery_url, _secret, _kid) =
2198 runtime.block_on(async { spin_fake_idp().await });
2199 let _server_guard = fake_server;
2200 let auth = crate::auth::AuthConfig::Oidc {
2201 discovery_url,
2202 audience: "test-audience".to_string(),
2203 tenant_claim_name: "solo_tenant".to_string(),
2204 };
2205 let h = Harness::new_with_auth_config(&runtime, Some(auth));
2206 let r = h.router.clone();
2207 runtime.block_on(async move {
2208 let (status, _body) =
2210 call(r.clone(), "POST", "/memory", Some(json!({ "content": "x" }))).await;
2211 assert_eq!(status, StatusCode::UNAUTHORIZED);
2212
2213 let (status, _body) = call_with_auth(
2215 r.clone(),
2216 "POST",
2217 "/memory",
2218 Some(json!({ "content": "x" })),
2219 Some("Bearer not-a-real-jwt"),
2220 )
2221 .await;
2222 assert_eq!(status, StatusCode::UNAUTHORIZED);
2223 });
2224 h.shutdown(&runtime);
2225 }
2226
2227 #[test]
2228 fn full_remember_recall_inspect_forget_round_trip() {
2229 let runtime = rt();
2230 let h = Harness::new(&runtime);
2231 let r = h.router.clone();
2232 runtime.block_on(async move {
2233 let (status, body) = call(
2235 r.clone(),
2236 "POST",
2237 "/memory",
2238 Some(json!({ "content": "round-trip content" })),
2239 )
2240 .await;
2241 assert_eq!(status, StatusCode::OK);
2242 let mid = body
2243 .get("memory_id")
2244 .and_then(|v| v.as_str())
2245 .unwrap()
2246 .to_string();
2247
2248 let (status, body) = call(
2250 r.clone(),
2251 "POST",
2252 "/memory/search",
2253 Some(json!({ "query": "round-trip content", "limit": 5 })),
2254 )
2255 .await;
2256 assert_eq!(status, StatusCode::OK);
2257 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
2258 assert!(
2259 hits.iter()
2260 .any(|h| h.get("content").and_then(|c| c.as_str())
2261 == Some("round-trip content")),
2262 "expected hit with content; got: {body}"
2263 );
2264
2265 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
2267 assert_eq!(status, StatusCode::OK);
2268 assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
2269
2270 let (status, _body) =
2272 call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
2273 assert_eq!(status, StatusCode::NO_CONTENT);
2274
2275 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
2277 assert_eq!(status, StatusCode::OK);
2278 assert_eq!(
2279 body.get("status").and_then(|v| v.as_str()),
2280 Some("forgotten")
2281 );
2282
2283 let (status, body) = call(
2285 r.clone(),
2286 "POST",
2287 "/memory/search",
2288 Some(json!({ "query": "round-trip content", "limit": 5 })),
2289 )
2290 .await;
2291 assert_eq!(status, StatusCode::OK);
2292 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
2293 assert!(
2294 hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
2295 != Some(mid.as_str())),
2296 "forgotten row should be excluded from recall: {body}"
2297 );
2298 });
2299 h.shutdown(&runtime);
2300 }
2301
2302 #[test]
2309 fn themes_endpoint_returns_empty_array_on_empty_db() {
2310 let runtime = rt();
2311 let h = Harness::new(&runtime);
2312 let r = h.router.clone();
2313 let (status, body) =
2314 runtime.block_on(call(r, "GET", "/memory/themes", None));
2315 assert_eq!(status, StatusCode::OK);
2316 assert!(body.is_array(), "expected array, got {body}");
2317 assert_eq!(body.as_array().unwrap().len(), 0);
2318 h.shutdown(&runtime);
2319 }
2320
2321 #[test]
2322 fn themes_endpoint_passes_through_query_params() {
2323 let runtime = rt();
2324 let h = Harness::new(&runtime);
2325 let r = h.router.clone();
2326 let (status, body) = runtime.block_on(call(
2327 r,
2328 "GET",
2329 "/memory/themes?window_days=7&limit=20",
2330 None,
2331 ));
2332 assert_eq!(status, StatusCode::OK);
2333 assert!(body.is_array(), "expected array, got {body}");
2334 h.shutdown(&runtime);
2335 }
2336
2337 #[test]
2338 fn facts_about_endpoint_requires_subject() {
2339 let runtime = rt();
2340 let h = Harness::new(&runtime);
2341 let r = h.router.clone();
2342 let (status, _body) =
2346 runtime.block_on(call(r, "GET", "/memory/facts_about", None));
2347 assert!(
2348 status == StatusCode::BAD_REQUEST
2349 || status == StatusCode::UNPROCESSABLE_ENTITY,
2350 "expected 400 or 422 for missing subject, got {status}"
2351 );
2352 h.shutdown(&runtime);
2353 }
2354
2355 #[test]
2356 fn facts_about_endpoint_rejects_blank_subject() {
2357 let runtime = rt();
2358 let h = Harness::new(&runtime);
2359 let r = h.router.clone();
2360 let (status, body) = runtime.block_on(call(
2363 r,
2364 "GET",
2365 "/memory/facts_about?subject=%20%20",
2366 None,
2367 ));
2368 assert_eq!(status, StatusCode::BAD_REQUEST);
2369 assert!(
2370 body.get("error")
2371 .and_then(|v| v.as_str())
2372 .is_some_and(|s| s.contains("subject")),
2373 "expected error mentioning subject, got {body}"
2374 );
2375 h.shutdown(&runtime);
2376 }
2377
2378 #[test]
2379 fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
2380 let runtime = rt();
2381 let h = Harness::new(&runtime);
2382 let r = h.router.clone();
2383 let (status, body) = runtime.block_on(call(
2384 r,
2385 "GET",
2386 "/memory/facts_about?subject=NobodyKnows",
2387 None,
2388 ));
2389 assert_eq!(status, StatusCode::OK);
2390 assert_eq!(body.as_array().unwrap().len(), 0);
2391 h.shutdown(&runtime);
2392 }
2393
2394 #[test]
2395 fn facts_about_endpoint_parses_include_as_object_query_param() {
2396 let runtime = rt();
2404 let h = Harness::new(&runtime);
2405 let r = h.router.clone();
2406 let (status, body) = runtime.block_on(call(
2407 r,
2408 "GET",
2409 "/memory/facts_about?subject=Maya&include_as_object=true",
2410 None,
2411 ));
2412 assert_eq!(
2413 status,
2414 StatusCode::OK,
2415 "expected 200 with include_as_object query param, got {status}"
2416 );
2417 assert!(body.is_array());
2418 h.shutdown(&runtime);
2419 }
2420
2421 #[test]
2422 fn inspect_cluster_endpoint_unknown_id_returns_404() {
2423 let runtime = rt();
2427 let h = Harness::new(&runtime);
2428 let r = h.router.clone();
2429 let (status, body) = runtime.block_on(call(
2430 r,
2431 "GET",
2432 "/memory/clusters/no-such-cluster",
2433 None,
2434 ));
2435 assert_eq!(status, StatusCode::NOT_FOUND);
2436 assert!(
2437 body.get("error")
2438 .and_then(|v| v.as_str())
2439 .is_some_and(|s| s.contains("no-such-cluster")),
2440 "expected error mentioning cluster id, got {body}"
2441 );
2442 h.shutdown(&runtime);
2443 }
2444
2445 #[test]
2446 fn inspect_cluster_endpoint_passes_full_content_query_param() {
2447 let runtime = rt();
2453 let h = Harness::new(&runtime);
2454 let r = h.router.clone();
2455 let (status, _body) = runtime.block_on(call(
2456 r,
2457 "GET",
2458 "/memory/clusters/missing?full_content=true",
2459 None,
2460 ));
2461 assert_eq!(status, StatusCode::NOT_FOUND);
2462 h.shutdown(&runtime);
2463 }
2464
2465 #[test]
2466 fn contradictions_endpoint_returns_empty_array_on_empty_db() {
2467 let runtime = rt();
2468 let h = Harness::new(&runtime);
2469 let r = h.router.clone();
2470 let (status, body) = runtime.block_on(call(
2471 r,
2472 "GET",
2473 "/memory/contradictions",
2474 None,
2475 ));
2476 assert_eq!(status, StatusCode::OK);
2477 assert!(body.is_array());
2478 assert_eq!(body.as_array().unwrap().len(), 0);
2479 h.shutdown(&runtime);
2480 }
2481
2482 #[test]
2483 fn derived_endpoints_require_bearer_when_auth_enabled() {
2484 let runtime = rt();
2485 let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
2486 for path in [
2493 "/memory/themes",
2494 "/memory/facts_about?subject=Sam",
2495 "/memory/contradictions",
2496 "/memory/clusters/any-id",
2497 ] {
2498 let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
2499 assert_eq!(
2500 status,
2501 StatusCode::UNAUTHORIZED,
2502 "{path} should 401 without token"
2503 );
2504 }
2505 h.shutdown(&runtime);
2506 }
2507
2508 #[test]
2520 fn list_documents_endpoint_returns_empty_array_on_empty_db() {
2521 let runtime = rt();
2522 let h = Harness::new(&runtime);
2523 let r = h.router.clone();
2524 let (status, body) = runtime.block_on(call(r, "GET", "/memory/documents", None));
2525 assert_eq!(status, StatusCode::OK);
2526 assert!(body.is_array(), "expected array, got {body}");
2527 assert_eq!(body.as_array().unwrap().len(), 0);
2528 h.shutdown(&runtime);
2529 }
2530
2531 #[test]
2532 fn list_documents_endpoint_parses_query_params() {
2533 let runtime = rt();
2534 let h = Harness::new(&runtime);
2535 let r = h.router.clone();
2536 let (status, body) = runtime.block_on(call(
2537 r,
2538 "GET",
2539 "/memory/documents?limit=5&offset=0&include_forgotten=true",
2540 None,
2541 ));
2542 assert_eq!(status, StatusCode::OK);
2543 assert!(body.is_array());
2544 h.shutdown(&runtime);
2545 }
2546
2547 #[test]
2548 fn ingest_document_endpoint_rejects_empty_path() {
2549 let runtime = rt();
2550 let h = Harness::new(&runtime);
2551 let r = h.router.clone();
2552 let (status, body) = runtime.block_on(call(
2553 r,
2554 "POST",
2555 "/memory/documents",
2556 Some(json!({ "path": "" })),
2557 ));
2558 assert_eq!(status, StatusCode::BAD_REQUEST);
2559 assert!(
2560 body.get("error")
2561 .and_then(|v| v.as_str())
2562 .is_some_and(|s| s.contains("path")),
2563 "expected error mentioning path, got {body}"
2564 );
2565 h.shutdown(&runtime);
2566 }
2567
2568 #[test]
2569 fn search_docs_endpoint_rejects_empty_query() {
2570 let runtime = rt();
2571 let h = Harness::new(&runtime);
2572 let r = h.router.clone();
2573 let (status, body) = runtime.block_on(call(
2574 r,
2575 "POST",
2576 "/memory/documents/search",
2577 Some(json!({ "query": " " })),
2578 ));
2579 assert_eq!(status, StatusCode::BAD_REQUEST);
2580 assert!(
2581 body.get("error")
2582 .and_then(|v| v.as_str())
2583 .is_some_and(|s| s.contains("must not be empty")
2584 || s.contains("doc_search")),
2585 "expected error mentioning empty query, got {body}"
2586 );
2587 h.shutdown(&runtime);
2588 }
2589
2590 #[test]
2591 fn inspect_document_endpoint_unknown_id_returns_404() {
2592 let runtime = rt();
2593 let h = Harness::new(&runtime);
2594 let r = h.router.clone();
2595 let (status, body) = runtime.block_on(call(
2596 r,
2597 "GET",
2598 "/memory/documents/00000000-0000-7000-8000-000000000000",
2599 None,
2600 ));
2601 assert_eq!(status, StatusCode::NOT_FOUND);
2602 assert!(body.get("error").is_some(), "got: {body}");
2603 h.shutdown(&runtime);
2604 }
2605
2606 #[test]
2607 fn inspect_document_endpoint_rejects_malformed_id() {
2608 let runtime = rt();
2609 let h = Harness::new(&runtime);
2610 let r = h.router.clone();
2611 let (status, _body) =
2612 runtime.block_on(call(r, "GET", "/memory/documents/not-a-uuid", None));
2613 assert_eq!(status, StatusCode::BAD_REQUEST);
2614 h.shutdown(&runtime);
2615 }
2616
2617 #[test]
2618 fn forget_document_endpoint_unknown_id_returns_404() {
2619 let runtime = rt();
2622 let h = Harness::new(&runtime);
2623 let r = h.router.clone();
2624 let (status, _body) = runtime.block_on(call(
2625 r,
2626 "DELETE",
2627 "/memory/documents/00000000-0000-7000-8000-000000000000",
2628 None,
2629 ));
2630 assert_eq!(status, StatusCode::NOT_FOUND);
2631 h.shutdown(&runtime);
2632 }
2633
2634 #[test]
2635 fn forget_document_endpoint_rejects_malformed_id() {
2636 let runtime = rt();
2637 let h = Harness::new(&runtime);
2638 let r = h.router.clone();
2639 let (status, _body) =
2640 runtime.block_on(call(r, "DELETE", "/memory/documents/not-a-uuid", None));
2641 assert_eq!(status, StatusCode::BAD_REQUEST);
2642 h.shutdown(&runtime);
2643 }
2644
2645 #[test]
2646 fn document_endpoints_require_bearer_when_auth_enabled() {
2647 let runtime = rt();
2651 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2652 let cases: &[(&str, &str, Option<Value>)] = &[
2653 ("POST", "/memory/documents", Some(json!({ "path": "/x" }))),
2654 ("GET", "/memory/documents", None),
2655 (
2656 "POST",
2657 "/memory/documents/search",
2658 Some(json!({ "query": "x" })),
2659 ),
2660 (
2661 "GET",
2662 "/memory/documents/00000000-0000-7000-8000-000000000000",
2663 None,
2664 ),
2665 (
2666 "DELETE",
2667 "/memory/documents/00000000-0000-7000-8000-000000000000",
2668 None,
2669 ),
2670 ];
2671 for (method, path, body) in cases {
2672 let (status, _) =
2673 runtime.block_on(call(h.router.clone(), method, path, body.clone()));
2674 assert_eq!(
2675 status,
2676 StatusCode::UNAUTHORIZED,
2677 "{method} {path} should 401 without token"
2678 );
2679 }
2680 h.shutdown(&runtime);
2681 }
2682
2683 #[test]
2684 fn document_endpoints_accept_correct_bearer_token() {
2685 let runtime = rt();
2691 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2692 runtime.block_on(async {
2693 let (status, _) = call_with_auth(
2695 h.router.clone(),
2696 "GET",
2697 "/memory/documents",
2698 None,
2699 Some("Bearer doc-secret"),
2700 )
2701 .await;
2702 assert_eq!(status, StatusCode::OK);
2703
2704 let (status, _) = call_with_auth(
2706 h.router.clone(),
2707 "GET",
2708 "/memory/documents/00000000-0000-7000-8000-000000000000",
2709 None,
2710 Some("Bearer doc-secret"),
2711 )
2712 .await;
2713 assert_eq!(status, StatusCode::NOT_FOUND);
2714 });
2715 h.shutdown(&runtime);
2716 }
2717
2718 #[test]
2725 fn tenant_header_default_resolves() {
2726 let runtime = rt();
2727 let h = Harness::new(&runtime);
2728 let r = h.router.clone();
2729 let (status, _body) = runtime.block_on(async {
2730 let req = Request::builder()
2731 .method("GET")
2732 .uri("/memory/00000000-0000-7000-8000-000000000000")
2733 .header("x-solo-tenant", "default")
2734 .body(Body::empty())
2735 .unwrap();
2736 let resp = r.oneshot(req).await.expect("oneshot");
2737 let s = resp.status();
2738 let _b = resp.into_body().collect().await.unwrap().to_bytes();
2739 (s, _b)
2740 });
2741 assert_eq!(status, StatusCode::NOT_FOUND);
2745 h.shutdown(&runtime);
2746 }
2747
2748 #[test]
2750 fn tenant_header_invalid_returns_400() {
2751 let runtime = rt();
2752 let h = Harness::new(&runtime);
2753 let r = h.router.clone();
2754 let (status, body) = runtime.block_on(async {
2755 let req = Request::builder()
2756 .method("GET")
2757 .uri("/memory/00000000-0000-7000-8000-000000000000")
2758 .header("x-solo-tenant", "UPPER")
2759 .body(Body::empty())
2760 .unwrap();
2761 let resp = r.oneshot(req).await.expect("oneshot");
2762 let s = resp.status();
2763 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
2764 let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
2765 (s, v)
2766 });
2767 assert_eq!(status, StatusCode::BAD_REQUEST);
2768 let msg = body.get("error").and_then(|e| e.as_str()).unwrap_or("");
2769 assert!(
2770 msg.to_lowercase().contains("tenant") || msg.to_lowercase().contains("invalid"),
2771 "error must mention tenant/invalid: {msg}"
2772 );
2773 h.shutdown(&runtime);
2774 }
2775
2776 #[test]
2778 fn tenant_header_unknown_returns_404() {
2779 let runtime = rt();
2780 let h = Harness::new(&runtime);
2781 let r = h.router.clone();
2782 let (status, _body) = runtime.block_on(async {
2783 let req = Request::builder()
2784 .method("GET")
2785 .uri("/memory/00000000-0000-7000-8000-000000000000")
2786 .header("x-solo-tenant", "never-registered")
2787 .body(Body::empty())
2788 .unwrap();
2789 let resp = r.oneshot(req).await.expect("oneshot");
2790 let s = resp.status();
2791 let _b = resp.into_body().collect().await.unwrap().to_bytes();
2792 (s, _b)
2793 });
2794 assert_eq!(status, StatusCode::NOT_FOUND);
2795 h.shutdown(&runtime);
2796 }
2797
2798 #[test]
2802 fn tenant_header_missing_defaults_to_state_default_tenant() {
2803 let runtime = rt();
2804 let h = Harness::new(&runtime);
2805 let r = h.router.clone();
2806 let (status, _body) = runtime.block_on(async {
2807 let req = Request::builder()
2808 .method("GET")
2809 .uri("/memory/00000000-0000-7000-8000-000000000000")
2810 .body(Body::empty())
2811 .unwrap();
2812 let resp = r.oneshot(req).await.expect("oneshot");
2813 let s = resp.status();
2814 let _b = resp.into_body().collect().await.unwrap().to_bytes();
2815 (s, _b)
2816 });
2817 assert_eq!(status, StatusCode::NOT_FOUND);
2818 h.shutdown(&runtime);
2819 }
2820}
2821
2822#[cfg(test)]
2823mod cors_tests {
2824 use super::is_localhost_origin;
2825
2826 #[test]
2827 fn accepts_canonical_localhost_origins() {
2828 assert!(is_localhost_origin("http://localhost"));
2829 assert!(is_localhost_origin("http://localhost:3000"));
2830 assert!(is_localhost_origin("https://localhost:8443"));
2831 assert!(is_localhost_origin("http://127.0.0.1"));
2832 assert!(is_localhost_origin("http://127.0.0.1:5173"));
2833 assert!(is_localhost_origin("http://[::1]"));
2834 assert!(is_localhost_origin("http://[::1]:8080"));
2835 }
2836
2837 #[test]
2838 fn rejects_remote_origins() {
2839 assert!(!is_localhost_origin("http://example.com"));
2840 assert!(!is_localhost_origin("https://malicious.example"));
2841 assert!(!is_localhost_origin("http://192.168.1.5"));
2842 assert!(!is_localhost_origin("http://10.0.0.1"));
2843 }
2844
2845 #[test]
2846 fn rejects_dns_rebinding_tricks() {
2847 assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
2851 assert!(!is_localhost_origin("http://localhost.evil.com"));
2852 assert!(!is_localhost_origin("http://evil.localhost"));
2853 }
2854
2855 #[test]
2856 fn rejects_non_http_schemes() {
2857 assert!(!is_localhost_origin("file:///"));
2858 assert!(!is_localhost_origin("ws://localhost:3000"));
2859 assert!(!is_localhost_origin("javascript:alert(1)"));
2860 }
2861
2862 #[test]
2863 fn rejects_malformed() {
2864 assert!(!is_localhost_origin(""));
2865 assert!(!is_localhost_origin("localhost"));
2866 assert!(!is_localhost_origin("//localhost"));
2867 }
2868}
2869