use std::net::SocketAddr;
use std::str::FromStr;
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderValue, Method, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use solo_core::{
Confidence, DocumentId, Embedder, EncodingContext, Episode, MemoryId, Tier, VectorIndex,
};
use solo_storage::{ReaderPool, WriteHandle};
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::trace::TraceLayer;
use tower_http::validate_request::{ValidateRequest, ValidateRequestHeaderLayer};
#[derive(Clone)]
pub struct SoloHttpState {
pub write: WriteHandle,
pub pool: ReaderPool,
pub embedder: Arc<dyn Embedder>,
pub hnsw: Arc<dyn VectorIndex + Send + Sync>,
pub source_db_path: std::path::PathBuf,
pub user_aliases: Arc<Vec<String>>,
}
pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
let cors = build_cors_layer();
let public = Router::new()
.route("/health", get(|| async { "ok" }))
.route("/openapi.json", get(openapi_handler));
let mut authed = Router::new()
.route("/memory", post(remember_handler))
.route("/memory/search", post(recall_handler))
.route("/memory/consolidate", post(consolidate_handler))
.route("/memory/{id}", get(inspect_handler).delete(forget_handler))
.route("/backup", post(backup_handler))
.route("/memory/themes", get(themes_handler))
.route("/memory/facts_about", get(facts_about_handler))
.route("/memory/contradictions", get(contradictions_handler))
.route(
"/memory/clusters/{cluster_id}",
get(inspect_cluster_handler),
)
.route(
"/memory/documents/search",
post(search_docs_handler),
)
.route(
"/memory/documents",
post(ingest_document_handler).get(list_documents_handler),
)
.route(
"/memory/documents/{id}",
get(inspect_document_handler).delete(forget_document_handler),
)
.with_state(state);
if let Some(token) = bearer_token {
authed = authed.layer(ValidateRequestHeaderLayer::custom(BearerToken::new(token)));
}
public
.merge(authed)
.layer(cors)
.layer(TraceLayer::new_for_http())
}
pub fn router(state: SoloHttpState) -> Router {
router_with_auth(state, None)
}
fn build_cors_layer() -> CorsLayer {
CorsLayer::new()
.allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
origin
.to_str()
.map(is_localhost_origin)
.unwrap_or(false)
}))
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
.allow_headers([
axum::http::header::CONTENT_TYPE,
axum::http::header::AUTHORIZATION,
])
}
#[derive(Clone)]
struct BearerToken {
expected: HeaderValue,
}
impl BearerToken {
fn new(token: String) -> Self {
let expected = HeaderValue::try_from(format!("Bearer {token}"))
.expect("bearer token must be a valid HTTP header value");
Self { expected }
}
}
impl<B> ValidateRequest<B> for BearerToken {
type ResponseBody = axum::body::Body;
fn validate(
&mut self,
request: &mut axum::http::Request<B>,
) -> Result<(), axum::http::Response<Self::ResponseBody>> {
let got = request.headers().get(axum::http::header::AUTHORIZATION);
match got {
Some(value) if value == &self.expected => Ok(()),
_ => {
let mut resp = axum::http::Response::new(axum::body::Body::empty());
*resp.status_mut() = StatusCode::UNAUTHORIZED;
resp.headers_mut().insert(
axum::http::header::WWW_AUTHENTICATE,
HeaderValue::from_static(r#"Bearer realm="solo""#),
);
Err(resp)
}
}
}
}
fn is_localhost_origin(origin: &str) -> bool {
let rest = origin
.strip_prefix("http://")
.or_else(|| origin.strip_prefix("https://"));
let host = match rest {
Some(r) => r,
None => return false,
};
let host = host.split('/').next().unwrap_or(host);
let host = if let Some(idx) = host.rfind(':') {
if host.starts_with('[') {
host.find(']')
.map(|i| &host[..=i])
.unwrap_or(host)
} else {
&host[..idx]
}
} else {
host
};
matches!(host, "localhost" | "127.0.0.1" | "[::1]")
}
pub async fn serve_http(
addr: SocketAddr,
state: SoloHttpState,
bearer_token: Option<String>,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> std::io::Result<()> {
let auth_kind = if bearer_token.is_some() {
"bearer"
} else {
"none"
};
let app = router_with_auth(state, bearer_token);
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(%addr, auth = auth_kind, "solo http: listening");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await
}
async fn openapi_handler() -> Json<serde_json::Value> {
Json(openapi_spec())
}
pub fn openapi_spec() -> serde_json::Value {
serde_json::json!({
"openapi": "3.1.0",
"info": {
"title": "Solo HTTP API",
"description":
"Local-first personal memory daemon. The HTTP transport \
mirrors the four MCP tools (memory_remember / recall / \
inspect / forget). Default deployment is loopback-only \
(127.0.0.1); LAN-bound deployments require a bearer \
token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
"version": env!("CARGO_PKG_VERSION"),
"license": { "name": "Apache-2.0" }
},
"servers": [
{ "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"description":
"Bearer-token auth. Required only on LAN-bound deployments \
(`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
the default `127.0.0.1` deployment is unauthenticated. \
`GET /health` and `GET /openapi.json` are exempt from auth even \
on bearer-protected instances."
}
},
"schemas": {
"RememberRequest": {
"type": "object",
"required": ["content"],
"properties": {
"content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
"source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
"source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
},
"additionalProperties": false
},
"RememberResponse": {
"type": "object",
"required": ["memory_id"],
"properties": {
"memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
}
},
"RecallRequest": {
"type": "object",
"required": ["query"],
"properties": {
"query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
"limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
},
"additionalProperties": false
},
"RecallResult": {
"type": "object",
"description":
"Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
see `solo_query::RecallResult` in the source for the canonical shape. \
Treat as a forward-compatible JSON object.",
"additionalProperties": true
},
"ConsolidationScope": {
"type": "object",
"description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
"properties": {
"window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." },
"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." }
},
"additionalProperties": false
},
"ConsolidationReport": {
"type": "object",
"required": [
"episodes_seen", "clusters_built", "clusters_merged",
"clusters_absorbed", "existing_clusters_merged",
"episodes_clustered", "abstractions_built",
"abstractions_regenerated", "triples_built",
"contradictions_found"
],
"properties": {
"episodes_seen": { "type": "integer", "minimum": 0 },
"clusters_built": { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
"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." },
"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." },
"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." },
"episodes_clustered": { "type": "integer", "minimum": 0 },
"abstractions_built": { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
"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." },
"triples_built": { "type": "integer", "minimum": 0 },
"contradictions_found": { "type": "integer", "minimum": 0 }
}
},
"EpisodeRecord": {
"type": "object",
"description":
"Inspect response: full episode record. Fields are stable across v0.1 but not \
exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
Treat as a forward-compatible JSON object.",
"additionalProperties": true
},
"ThemeHit": {
"type": "object",
"description":
"One cluster + its (optional) abstraction. Returned by GET /memory/themes. \
See `solo_query::ThemeHit` for the canonical shape: cluster_id, \
abstraction_id?, abstraction_text?, episode_count, coherence, created_at_ms.",
"additionalProperties": true
},
"FactHit": {
"type": "object",
"description":
"One Steward-extracted SPO triple. Returned by GET /memory/facts_about. \
See `solo_query::FactHit` for fields: triple_id, subject_id, predicate, \
object_id, object_kind, valid_from_ms, valid_to_ms?, confidence, cluster_id?.",
"additionalProperties": true
},
"ContradictionHit": {
"type": "object",
"description":
"One Steward-flagged contradiction with each side's triple LEFT JOIN'd in. \
Returned by GET /memory/contradictions. See `solo_query::ContradictionHit`: \
a_id, b_id, kind, explanation, detected_at_ms, a_triple?, b_triple?.",
"additionalProperties": true
},
"ClusterRecord": {
"type": "object",
"description":
"Snapshot of one cluster — its row, optional abstraction, and source episodes \
(content truncated to 200 chars unless ?full_content=true). Returned by \
GET /memory/clusters/{cluster_id}. See `solo_query::ClusterRecord`.",
"additionalProperties": true
},
"IngestDocumentRequest": {
"type": "object",
"required": ["path"],
"properties": {
"path": {
"type": "string",
"minLength": 1,
"description":
"Server-side absolute path to the file to ingest. The file must be \
readable by the Solo process. Supported formats: plaintext / \
markdown / code, HTML, PDF."
}
},
"additionalProperties": false
},
"IngestReport": {
"type": "object",
"description":
"Returned by POST /memory/documents. Reports the document id assigned, \
the number of chunks persisted + embedded, the total byte size, and a \
`deduped` flag (true when the same content_hash was already present and \
the existing doc_id was returned unchanged). See `solo_storage::IngestReport`.",
"required": ["doc_id", "chunks_persisted", "bytes_ingested", "deduped"],
"properties": {
"doc_id": { "type": "string", "format": "uuid" },
"chunks_persisted": { "type": "integer", "minimum": 0 },
"bytes_ingested": { "type": "integer", "minimum": 0, "format": "int64" },
"deduped": { "type": "boolean" }
},
"additionalProperties": false
},
"ForgetDocumentReport": {
"type": "object",
"description":
"Returned by DELETE /memory/documents/{id}. Reports the doc_id soft-deleted \
and how many chunk rowids were tombstoned in the HNSW index. The chunk rows \
themselves survive in SQL for forensic value. See `solo_storage::ForgetDocumentReport`.",
"required": ["doc_id", "chunks_tombstoned"],
"properties": {
"doc_id": { "type": "string", "format": "uuid" },
"chunks_tombstoned": { "type": "integer", "minimum": 0 }
},
"additionalProperties": false
},
"SearchDocsRequest": {
"type": "object",
"required": ["query"],
"properties": {
"query": { "type": "string", "minLength": 1 },
"limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 }
},
"additionalProperties": false
},
"DocSearchHit": {
"type": "object",
"description":
"One chunk hit + parent-doc context. Fields per `solo_query::DocSearchHit`: \
chunk_id, doc_id, doc_title?, doc_source?, doc_mime_type?, chunk_index, \
content, cos_distance, start_offset, end_offset.",
"additionalProperties": true
},
"DocumentInspectResult": {
"type": "object",
"description":
"Returned by GET /memory/documents/{id}. A `document` record (full metadata) \
plus an ordered list of chunk summaries (each preview truncated to 200 \
chars). See `solo_query::DocumentInspectResult`.",
"additionalProperties": true
},
"DocumentSummary": {
"type": "object",
"description":
"One row from GET /memory/documents. Fields per `solo_query::DocumentSummary`: \
doc_id, title?, source?, mime_type?, ingested_at_ms, chunk_count, status.",
"additionalProperties": true
},
"ApiError": {
"type": "object",
"required": ["error", "status"],
"properties": {
"error": { "type": "string" },
"status": { "type": "integer", "minimum": 400, "maximum": 599 }
}
}
}
},
"paths": {
"/health": {
"get": {
"summary": "Liveness probe",
"description": "Returns plain text `ok`. Always unauthenticated.",
"responses": {
"200": {
"description": "Server is up.",
"content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
}
}
}
},
"/openapi.json": {
"get": {
"summary": "Self-describing OpenAPI 3.1 spec",
"description": "Returns this document. Always unauthenticated.",
"responses": {
"200": {
"description": "OpenAPI 3.1 document.",
"content": { "application/json": { "schema": { "type": "object" } } }
}
}
}
},
"/memory": {
"post": {
"summary": "Remember (store an episode)",
"description": "Equivalent to MCP tool `memory_remember`.",
"security": [{ "bearerAuth": [] }, {}],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
},
"responses": {
"200": {
"description": "Memory stored; returns the new MemoryId.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
},
"400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
}
},
"/memory/search": {
"post": {
"summary": "Recall (vector search)",
"description": "Equivalent to MCP tool `memory_recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
"security": [{ "bearerAuth": [] }, {}],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
},
"responses": {
"200": {
"description": "Search results.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
},
"400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
}
},
"/memory/consolidate": {
"post": {
"summary": "Run a consolidation pass (clustering + abstraction)",
"description":
"Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
on the server, also runs the REM-equivalent abstraction pass that populates \
`semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
window). Equivalent to the `solo consolidate` CLI.",
"security": [{ "bearerAuth": [] }, {}],
"requestBody": {
"required": false,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
},
"responses": {
"200": {
"description": "Consolidation complete; report counts the work done.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
},
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
}
},
"/backup": {
"post": {
"summary": "Online encrypted backup",
"description":
"Run an online SQLCipher backup of the live data dir to a server-side path. \
The destination file is encrypted with the same Argon2id-derived raw key as \
the source, so it restores under the same passphrase + a copy of the source's \
`solo.config.toml`. Hot — the backup runs against the writer's existing \
connection without taking the lockfile, so the daemon keeps serving reads + \
writes during the operation. v0.3.2+.",
"security": [{ "bearerAuth": [] }, {}],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"to": { "type": "string", "description": "Server-side absolute path for the backup file." },
"force": { "type": "boolean", "description": "Overwrite an existing destination file. Default false.", "default": false }
},
"required": ["to"]
} } }
},
"responses": {
"200": {
"description": "Backup complete; reports the destination path + elapsed milliseconds.",
"content": { "application/json": { "schema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"elapsed_ms": { "type": "integer", "format": "int64" }
}
} } }
},
"400": { "description": "Destination invalid, exists without force, or its parent doesn't exist." },
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." },
"500": { "description": "Backup failed (disk full, permission denied, etc.)." }
}
}
},
"/memory/{id}": {
"get": {
"summary": "Inspect a memory by ID",
"description": "Equivalent to MCP tool `memory_inspect`.",
"security": [{ "bearerAuth": [] }, {}],
"parameters": [{
"name": "id",
"in": "path",
"required": true,
"schema": { "type": "string", "format": "uuid" },
"description": "MemoryId (UUID v7)."
}],
"responses": {
"200": {
"description": "Episode record.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
},
"400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
},
"delete": {
"summary": "Forget (soft-delete) a memory by ID",
"description":
"Equivalent to MCP tool `memory_forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
re-running `solo reembed` after this does NOT restore visibility.",
"security": [{ "bearerAuth": [] }, {}],
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
{ "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
],
"responses": {
"204": { "description": "Forgotten (or already forgotten — idempotent)." },
"400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
}
},
"/memory/themes": {
"get": {
"summary": "List recent cluster themes",
"description":
"Equivalent to MCP tool `memory_themes`. List cluster abstractions ordered by \
most-recent first. Use to surface 'what has the user been thinking about lately' \
without paging through individual episodes. v0.4.0+.",
"security": [{ "bearerAuth": [] }, {}],
"parameters": [
{ "name": "window_days", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Optional time window. Omit for unfiltered (all-time, most-recent first)." },
{ "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
],
"responses": {
"200": {
"description": "Array of ThemeHits (possibly empty).",
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ThemeHit" } } } }
},
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
}
},
"/memory/facts_about": {
"get": {
"summary": "Query the SPO knowledge graph by subject",
"description":
"Equivalent to MCP tool `memory_facts_about`. Query Steward-extracted triples by \
subject + optional predicate + optional time window. Subject is required \
(predicate-only scans not supported). Pass `include_as_object=true` (v0.5.1+) \
to also surface rows where `subject` appears as the object. v0.4.0+.",
"security": [{ "bearerAuth": [] }, {}],
"parameters": [
{ "name": "subject", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Subject id to query (e.g. `Sam`)." },
{ "name": "predicate", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional predicate filter (e.g. `works_at`)." },
{ "name": "since_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_from_ms lower bound (epoch ms)." },
{ "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." },
{ "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+." },
{ "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
],
"responses": {
"200": {
"description": "Array of FactHits (possibly empty).",
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactHit" } } } }
},
"400": { "description": "Bad request (e.g. empty subject).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
}
},
"/memory/contradictions": {
"get": {
"summary": "List Steward-flagged contradictions",
"description":
"Equivalent to MCP tool `memory_contradictions`. Each result includes both \
sides' triple SPO via LEFT JOIN for context. v0.4.0+.",
"security": [{ "bearerAuth": [] }, {}],
"parameters": [
{ "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
],
"responses": {
"200": {
"description": "Array of ContradictionHits (possibly empty).",
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ContradictionHit" } } } }
},
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
}
},
"/memory/clusters/{cluster_id}": {
"get": {
"summary": "Inspect a single cluster",
"description":
"Equivalent to MCP tool `memory_inspect_cluster`. Returns the cluster row, \
its (optional) abstraction, and its source episodes. By default each \
episode's `content` is truncated to 200 chars with a trailing `…`. Pass \
`?full_content=true` to get verbatim episode content. v0.5.0+.",
"security": [{ "bearerAuth": [] }, {}],
"parameters": [
{ "name": "cluster_id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Cluster id (from a previous GET /memory/themes response)." },
{ "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)." }
],
"responses": {
"200": {
"description": "Cluster snapshot.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClusterRecord" } } }
},
"400": { "description": "Bad request (e.g. empty cluster_id).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"404": { "description": "No such cluster.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
}
},
"/memory/documents": {
"post": {
"summary": "Ingest a document",
"description":
"Equivalent to MCP tool `memory_ingest_document`. Reads the file at the \
supplied server-side path, parses + chunks + embeds, and persists under \
`documents` + `document_chunks`. Returns the new doc_id, chunk count, and \
a `deduped` flag (true when an existing document with the same content_hash \
was returned without re-embedding). v0.7.0+.",
"security": [{ "bearerAuth": [] }, {}],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestDocumentRequest" } } }
},
"responses": {
"200": {
"description": "Document ingested (or deduplicated).",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestReport" } } }
},
"400": { "description": "Bad request (e.g. empty path, file unreadable, parse error).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
},
"get": {
"summary": "List ingested documents (paginated)",
"description":
"Equivalent to MCP tool `memory_list_documents`. Returns a paginated index, \
newest first. Forgotten documents are hidden by default; pass \
`?include_forgotten=true` to see them too. v0.7.0+.",
"security": [{ "bearerAuth": [] }, {}],
"parameters": [
{ "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } },
{ "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 } },
{ "name": "include_forgotten", "in": "query", "required": false, "schema": { "type": "boolean", "default": false } }
],
"responses": {
"200": {
"description": "Array of DocumentSummary (possibly empty).",
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocumentSummary" } } } }
},
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
}
},
"/memory/documents/search": {
"post": {
"summary": "Vector search across document chunks",
"description":
"Equivalent to MCP tool `memory_search_docs`. Embeds the query and returns \
up to `limit` matching chunks, best match first, each annotated with the \
parent document's title + source path. Forgotten documents are excluded. \
v0.7.0+.",
"security": [{ "bearerAuth": [] }, {}],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchDocsRequest" } } }
},
"responses": {
"200": {
"description": "Array of DocSearchHits (possibly empty).",
"content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocSearchHit" } } } }
},
"400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
}
},
"/memory/documents/{id}": {
"get": {
"summary": "Inspect one document",
"description":
"Equivalent to MCP tool `memory_inspect_document`. Returns the document's \
metadata plus a preview of every chunk (truncated to 200 chars). v0.7.0+.",
"security": [{ "bearerAuth": [] }, {}],
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "DocumentId (UUID v7)." }
],
"responses": {
"200": {
"description": "Document inspection result.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentInspectResult" } } }
},
"400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
},
"delete": {
"summary": "Forget (soft-delete) one document",
"description":
"Equivalent to MCP tool `memory_forget_document`. Flips `documents.status` \
to `forgotten` and tombstones every chunk's HNSW rowid. The chunk rows \
survive in SQL for forensic value. v0.7.0+.",
"security": [{ "bearerAuth": [] }, {}],
"parameters": [
{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
],
"responses": {
"200": {
"description": "Document soft-deleted; report counts chunks tombstoned.",
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForgetDocumentReport" } } }
},
"400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
"401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
}
}
}
}
})
}
#[derive(Debug, Deserialize)]
struct RememberBody {
content: String,
#[serde(default)]
source_type: Option<String>,
#[serde(default)]
source_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct RememberResponse {
memory_id: String,
}
async fn remember_handler(
State(s): State<SoloHttpState>,
Json(body): Json<RememberBody>,
) -> Result<Json<RememberResponse>, ApiError> {
let content = body.content.trim_end().to_string();
if content.is_empty() {
return Err(ApiError::bad_request("content must not be empty"));
}
let embedding = s.embedder.embed(&content).await.map_err(ApiError::from)?;
let episode = Episode {
memory_id: MemoryId::new(),
ts_ms: chrono::Utc::now().timestamp_millis(),
source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
source_id: body.source_id,
content,
encoding_context: EncodingContext::default(),
provenance: None,
confidence: Confidence::new(0.9).unwrap(),
strength: 0.5,
salience: 0.5,
tier: Tier::Hot,
};
let mid = s.write.remember(episode, embedding).await.map_err(ApiError::from)?;
Ok(Json(RememberResponse {
memory_id: mid.to_string(),
}))
}
#[derive(Debug, Deserialize)]
struct RecallBody {
query: String,
#[serde(default = "default_limit")]
limit: usize,
}
fn default_limit() -> usize {
5
}
async fn recall_handler(
State(s): State<SoloHttpState>,
Json(body): Json<RecallBody>,
) -> Result<Json<solo_query::RecallResult>, ApiError> {
let result = solo_query::run_recall(
&s.embedder,
&s.hnsw,
&s.pool,
&body.query,
body.limit,
)
.await
.map_err(ApiError::from)?;
Ok(Json(result))
}
async fn inspect_handler(
State(s): State<SoloHttpState>,
Path(id): Path<String>,
) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
let mid = MemoryId::from_str(&id)
.map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
let row = solo_query::inspect_one(&s.pool, mid)
.await
.map_err(ApiError::from)?;
Ok(Json(row))
}
#[derive(Debug, Deserialize)]
struct ThemesQuery {
#[serde(default)]
window_days: Option<i64>,
#[serde(default = "default_limit")]
limit: usize,
}
async fn themes_handler(
State(s): State<SoloHttpState>,
Query(q): Query<ThemesQuery>,
) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
let hits = solo_query::themes(&s.pool, q.window_days, q.limit)
.await
.map_err(ApiError::from)?;
Ok(Json(hits))
}
#[derive(Debug, Deserialize)]
struct FactsAboutQuery {
subject: String,
#[serde(default)]
predicate: Option<String>,
#[serde(default)]
since_ms: Option<i64>,
#[serde(default)]
until_ms: Option<i64>,
#[serde(default)]
include_as_object: bool,
#[serde(default = "default_limit")]
limit: usize,
}
async fn facts_about_handler(
State(s): State<SoloHttpState>,
Query(q): Query<FactsAboutQuery>,
) -> Result<Json<Vec<solo_query::FactHit>>, ApiError> {
if q.subject.trim().is_empty() {
return Err(ApiError::bad_request("subject must not be empty"));
}
let hits = solo_query::facts_about(
&s.pool,
&q.subject,
&s.user_aliases,
q.include_as_object,
q.predicate.as_deref(),
q.since_ms,
q.until_ms,
q.limit,
)
.await
.map_err(ApiError::from)?;
Ok(Json(hits))
}
#[derive(Debug, Deserialize)]
struct ContradictionsQuery {
#[serde(default = "default_limit")]
limit: usize,
}
async fn contradictions_handler(
State(s): State<SoloHttpState>,
Query(q): Query<ContradictionsQuery>,
) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
let hits = solo_query::contradictions(&s.pool, q.limit)
.await
.map_err(ApiError::from)?;
Ok(Json(hits))
}
#[derive(Debug, Deserialize, Default)]
struct InspectClusterQuery {
#[serde(default)]
full_content: bool,
}
async fn inspect_cluster_handler(
State(s): State<SoloHttpState>,
Path(cluster_id): Path<String>,
Query(q): Query<InspectClusterQuery>,
) -> Result<Json<solo_query::ClusterRecord>, ApiError> {
if cluster_id.trim().is_empty() {
return Err(ApiError::bad_request("cluster_id must not be empty"));
}
let record = solo_query::inspect_cluster(
&s.pool,
&cluster_id,
q.full_content,
)
.await
.map_err(ApiError::from)?;
Ok(Json(record))
}
#[derive(Debug, Deserialize)]
struct IngestDocumentBody {
path: String,
}
async fn ingest_document_handler(
State(s): State<SoloHttpState>,
Json(body): Json<IngestDocumentBody>,
) -> Result<Json<solo_storage::IngestReport>, ApiError> {
if body.path.trim().is_empty() {
return Err(ApiError::bad_request("path must not be empty"));
}
let path = std::path::PathBuf::from(body.path);
let chunk_config = solo_storage::document::ChunkConfig::default();
let report = s
.write
.ingest_document(path, chunk_config)
.await
.map_err(ApiError::from)?;
Ok(Json(report))
}
#[derive(Debug, Deserialize)]
struct SearchDocsBody {
query: String,
#[serde(default = "default_limit")]
limit: usize,
}
async fn search_docs_handler(
State(s): State<SoloHttpState>,
Json(body): Json<SearchDocsBody>,
) -> Result<Json<Vec<solo_query::DocSearchHit>>, ApiError> {
let hits = solo_query::run_doc_search(
&s.embedder,
&s.hnsw,
&s.pool,
&body.query,
body.limit,
)
.await
.map_err(ApiError::from)?;
Ok(Json(hits))
}
async fn inspect_document_handler(
State(s): State<SoloHttpState>,
Path(id): Path<String>,
) -> Result<Json<solo_query::DocumentInspectResult>, ApiError> {
let doc_id = DocumentId::from_str(&id)
.map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
let result_opt = solo_query::inspect_document(&s.pool, &doc_id)
.await
.map_err(ApiError::from)?;
match result_opt {
Some(record) => Ok(Json(record)),
None => Err(ApiError::not_found(format!("document {doc_id} not found"))),
}
}
#[derive(Debug, Deserialize)]
struct ListDocumentsQuery {
#[serde(default = "default_list_documents_limit")]
limit: usize,
#[serde(default)]
offset: usize,
#[serde(default)]
include_forgotten: bool,
}
fn default_list_documents_limit() -> usize {
20
}
async fn list_documents_handler(
State(s): State<SoloHttpState>,
Query(q): Query<ListDocumentsQuery>,
) -> Result<Json<Vec<solo_query::DocumentSummary>>, ApiError> {
let rows = solo_query::list_documents(&s.pool, q.limit, q.offset, q.include_forgotten)
.await
.map_err(ApiError::from)?;
Ok(Json(rows))
}
async fn forget_document_handler(
State(s): State<SoloHttpState>,
Path(id): Path<String>,
) -> Result<Json<solo_storage::ForgetDocumentReport>, ApiError> {
let doc_id = DocumentId::from_str(&id)
.map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
let report = s
.write
.forget_document(doc_id)
.await
.map_err(ApiError::from)?;
Ok(Json(report))
}
#[derive(Debug, Deserialize)]
struct ForgetQuery {
#[serde(default)]
reason: Option<String>,
}
async fn forget_handler(
State(s): State<SoloHttpState>,
Path(id): Path<String>,
Query(q): Query<ForgetQuery>,
) -> Result<StatusCode, ApiError> {
let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
let reason = q.reason.unwrap_or_else(|| "http".into());
s.write.forget(mid, reason).await.map_err(ApiError::from)?;
Ok(StatusCode::NO_CONTENT)
}
async fn consolidate_handler(
State(s): State<SoloHttpState>,
body: axum::body::Bytes,
) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
let scope = if body.is_empty() {
solo_storage::ConsolidationScope::default()
} else {
serde_json::from_slice(&body)
.map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
};
let report = s.write.consolidate(scope).await.map_err(ApiError::from)?;
Ok(Json(report))
}
#[derive(Debug, Deserialize)]
struct BackupBody {
to: String,
#[serde(default)]
force: bool,
}
#[derive(Debug, Serialize)]
struct BackupResponse {
path: String,
elapsed_ms: u64,
}
async fn backup_handler(
State(s): State<SoloHttpState>,
Json(body): Json<BackupBody>,
) -> Result<Json<BackupResponse>, ApiError> {
use std::path::PathBuf;
let dest = PathBuf::from(&body.to);
if dest.as_os_str().is_empty() {
return Err(ApiError::bad_request("`to` must not be empty"));
}
if solo_storage::paths_refer_to_same_file(&s.source_db_path, &dest) {
return Err(ApiError::bad_request(format!(
"destination {} is the same file as the source database; \
refusing to run (would corrupt the live database)",
dest.display()
)));
}
if dest.exists() {
if !body.force {
return Err(ApiError::bad_request(format!(
"destination {} exists; pass force=true to overwrite",
dest.display()
)));
}
std::fs::remove_file(&dest).map_err(|e| {
ApiError::internal(format!(
"remove existing destination {}: {e}",
dest.display()
))
})?;
}
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() && !parent.is_dir() {
return Err(ApiError::bad_request(format!(
"destination parent directory {} does not exist",
parent.display()
)));
}
}
let started = std::time::Instant::now();
s.write.backup(dest.clone()).await.map_err(ApiError::from)?;
let elapsed_ms = started.elapsed().as_millis() as u64;
Ok(Json(BackupResponse {
path: dest.display().to_string(),
elapsed_ms,
}))
}
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn bad_request(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: msg.into(),
}
}
fn not_found(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: msg.into(),
}
}
fn internal(msg: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: msg.into(),
}
}
}
impl From<solo_core::Error> for ApiError {
fn from(e: solo_core::Error) -> Self {
use solo_core::Error;
match e {
Error::NotFound(msg) => ApiError::not_found(msg),
Error::InvalidInput(msg) => ApiError::bad_request(msg),
Error::Conflict(msg) => Self {
status: StatusCode::CONFLICT,
message: msg,
},
other => ApiError::internal(other.to_string()),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = serde_json::json!({
"error": self.message,
"status": self.status.as_u16(),
});
(self.status, Json(body)).into_response()
}
}
#[cfg(test)]
mod handler_tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use solo_core::VectorIndex as _;
use solo_storage::test_support::StubVectorIndex;
use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
use std::sync::Arc as StdArc;
use tower::ServiceExt;
struct Harness {
router: axum::Router,
_tmp: tempfile::TempDir,
write_handle_extra: Option<solo_storage::WriteHandle>,
join: Option<std::thread::JoinHandle<()>>,
}
impl Harness {
fn new(runtime: &tokio::runtime::Runtime) -> Self {
Self::new_with_auth(runtime, None)
}
fn new_with_auth(
runtime: &tokio::runtime::Runtime,
bearer_token: Option<String>,
) -> Self {
use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
let tmp = tempfile::TempDir::new().unwrap();
let dim = 16usize;
let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
let embedder: StdArc<dyn solo_core::Embedder> =
StdArc::new(StubEmbedder::new("stub", "v1", dim));
let path = tmp.path().join("test.db");
let embedder_id = {
let conn = solo_storage::test_support::open_test_db_at(&path);
get_or_insert_embedder_id(
&conn,
&EmbedderIdentity {
name: "stub".into(),
version: "v1".into(),
dim: dim as u32,
dtype: "f32".into(),
},
)
.unwrap()
};
let conn = solo_storage::test_support::open_test_db_at(&path);
let WriterSpawn { handle, join } = WriterActor::spawn_full(
conn,
hnsw.clone(),
tmp.path().to_path_buf(),
embedder_id,
);
let pool: ReaderPool =
runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
let state = SoloHttpState {
write: handle.clone(),
pool,
embedder,
hnsw,
source_db_path: path.clone(),
user_aliases: Arc::new(Vec::new()),
};
let router = router_with_auth(state, bearer_token);
Harness {
router,
_tmp: tmp,
write_handle_extra: Some(handle),
join: Some(join),
}
}
fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
let join = self.join.take();
let extra = self.write_handle_extra.take();
runtime.block_on(async move {
drop(extra);
drop(self.router); drop(self._tmp);
if let Some(join) = join {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(join.join());
});
tokio::task::spawn_blocking(move || {
rx.recv_timeout(std::time::Duration::from_secs(5))
})
.await
.expect("blocking task")
.expect("writer thread did not exit within 5s")
.expect("writer thread panicked");
}
});
}
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap()
}
async fn call(
router: axum::Router,
method: &str,
uri: &str,
body: Option<Value>,
) -> (StatusCode, Value) {
call_with_auth(router, method, uri, body, None).await
}
async fn call_with_auth(
router: axum::Router,
method: &str,
uri: &str,
body: Option<Value>,
auth: Option<&str>,
) -> (StatusCode, Value) {
let mut req_builder = Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json");
if let Some(a) = auth {
req_builder = req_builder.header("authorization", a);
}
let req = if let Some(b) = body {
let bytes = serde_json::to_vec(&b).unwrap();
req_builder.body(Body::from(bytes)).unwrap()
} else {
req_builder = req_builder.header("content-length", "0");
req_builder.body(Body::empty()).unwrap()
};
let resp = router.oneshot(req).await.expect("oneshot");
let status = resp.status();
let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
let v: Value = if body_bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
};
(status, v)
}
#[test]
fn health_returns_ok() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
assert_eq!(status, StatusCode::OK);
h.shutdown(&runtime);
}
#[test]
fn openapi_json_describes_all_endpoints() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
assert_eq!(status, StatusCode::OK);
assert!(spec.is_object(), "openapi.json must be a JSON object");
assert!(
spec.get("openapi")
.and_then(|v| v.as_str())
.is_some_and(|s| s.starts_with("3.")),
"missing or wrong openapi version: {spec}"
);
assert!(spec.pointer("/info/title").is_some());
assert!(spec.pointer("/info/version").is_some());
let paths = spec
.get("paths")
.and_then(|v| v.as_object())
.expect("paths must be an object");
for expected in [
"/health",
"/openapi.json",
"/memory",
"/memory/search",
"/memory/consolidate",
"/memory/{id}",
"/memory/themes",
"/memory/facts_about",
"/memory/contradictions",
"/memory/clusters/{cluster_id}",
"/memory/documents",
"/memory/documents/search",
"/memory/documents/{id}",
] {
assert!(
paths.contains_key(expected),
"openapi paths missing {expected}: {paths:?}"
);
}
let docs = paths.get("/memory/documents").expect("/memory/documents");
assert!(docs.get("post").is_some(), "POST /memory/documents undocumented");
assert!(docs.get("get").is_some(), "GET /memory/documents undocumented");
let docid = paths
.get("/memory/documents/{id}")
.expect("/memory/documents/{id}");
assert!(
docid.get("get").is_some(),
"GET /memory/documents/{{id}} undocumented"
);
assert!(
docid.get("delete").is_some(),
"DELETE /memory/documents/{{id}} undocumented"
);
let memid = paths.get("/memory/{id}").expect("memory/{id}");
assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
assert!(
memid.get("delete").is_some(),
"DELETE /memory/{{id}} undocumented"
);
for schema_name in [
"RememberRequest",
"RememberResponse",
"RecallRequest",
"RecallResult",
"EpisodeRecord",
"ApiError",
"ConsolidationScope",
"ConsolidationReport",
"ThemeHit",
"FactHit",
"ContradictionHit",
"ClusterRecord",
"IngestDocumentRequest",
"IngestReport",
"ForgetDocumentReport",
"SearchDocsRequest",
"DocSearchHit",
"DocumentInspectResult",
"DocumentSummary",
] {
let ptr = format!("/components/schemas/{schema_name}");
assert!(
spec.pointer(&ptr).is_some(),
"component schema {schema_name} missing"
);
}
assert!(
spec.pointer("/components/securitySchemes/bearerAuth")
.is_some(),
"bearerAuth security scheme missing"
);
h.shutdown(&runtime);
}
#[test]
fn openapi_json_is_exempt_from_bearer_auth() {
let runtime = rt();
let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
let r = h.router.clone();
let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
assert_eq!(status, StatusCode::OK);
h.shutdown(&runtime);
}
#[test]
fn remember_returns_memory_id() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"POST",
"/memory",
Some(json!({ "content": "http harness test" })),
));
assert_eq!(status, StatusCode::OK);
let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
assert_eq!(mid.len(), 36, "uuid length");
h.shutdown(&runtime);
}
#[test]
fn empty_content_returns_400() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) =
runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body.get("error")
.and_then(|e| e.as_str())
.map(|s| s.contains("must not be empty"))
.unwrap_or(false),
"got: {body}"
);
h.shutdown(&runtime);
}
#[test]
fn empty_query_returns_400() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"POST",
"/memory/search",
Some(json!({ "query": "" })),
));
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body.get("error")
.and_then(|e| e.as_str())
.map(|s| s.contains("must not be empty"))
.unwrap_or(false),
"got: {body}"
);
h.shutdown(&runtime);
}
#[test]
fn inspect_unknown_returns_404() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"GET",
"/memory/00000000-0000-7000-8000-000000000000",
None,
));
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.get("error").is_some(), "got: {body}");
h.shutdown(&runtime);
}
#[test]
fn inspect_invalid_id_returns_400() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
assert_eq!(status, StatusCode::BAD_REQUEST);
h.shutdown(&runtime);
}
#[test]
fn forget_unknown_returns_404() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, _body) = runtime.block_on(call(
r,
"DELETE",
"/memory/00000000-0000-7000-8000-000000000000",
None,
));
assert_eq!(status, StatusCode::NOT_FOUND);
h.shutdown(&runtime);
}
#[test]
fn consolidate_endpoint_returns_report() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
runtime.block_on(async move {
let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
assert_eq!(status, StatusCode::OK);
for field in [
"episodes_seen",
"clusters_built",
"episodes_clustered",
"abstractions_built",
"triples_built",
"contradictions_found",
] {
assert!(
body.get(field).and_then(|v| v.as_u64()).is_some(),
"missing field {field}: {body}"
);
}
assert_eq!(body["episodes_seen"], 0);
assert_eq!(body["clusters_built"], 0);
let (status2, _body2) = call(
r,
"POST",
"/memory/consolidate",
Some(json!({ "window_days": 7 })),
)
.await;
assert_eq!(status2, StatusCode::OK);
});
h.shutdown(&runtime);
}
#[test]
fn auth_required_routes_reject_missing_token() {
let runtime = rt();
let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
let r = h.router.clone();
runtime.block_on(async move {
let (status, _body) = call(
r.clone(),
"POST",
"/memory",
Some(json!({ "content": "x" })),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let (status, _body) = call_with_auth(
r.clone(),
"POST",
"/memory",
Some(json!({ "content": "x" })),
Some("Bearer wrong-token"),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
let (status, body) = call_with_auth(
r.clone(),
"POST",
"/memory",
Some(json!({ "content": "authed" })),
Some("Bearer secret-xyz"),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body.get("memory_id").is_some());
});
h.shutdown(&runtime);
}
#[test]
fn health_endpoint_does_not_require_auth() {
let runtime = rt();
let h = Harness::new_with_auth(&runtime, Some("secret".into()));
let r = h.router.clone();
let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
assert_eq!(status, StatusCode::OK);
h.shutdown(&runtime);
}
#[test]
fn auth_response_includes_www_authenticate_header() {
let runtime = rt();
let h = Harness::new_with_auth(&runtime, Some("secret".into()));
let r = h.router.clone();
runtime.block_on(async move {
let req = Request::builder()
.method("POST")
.uri("/memory")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
.unwrap();
let resp = r.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let www = resp
.headers()
.get("www-authenticate")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
www.starts_with("Bearer"),
"expected WWW-Authenticate: Bearer..., got: {www}"
);
});
h.shutdown(&runtime);
}
#[test]
fn full_remember_recall_inspect_forget_round_trip() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
runtime.block_on(async move {
let (status, body) = call(
r.clone(),
"POST",
"/memory",
Some(json!({ "content": "round-trip content" })),
)
.await;
assert_eq!(status, StatusCode::OK);
let mid = body
.get("memory_id")
.and_then(|v| v.as_str())
.unwrap()
.to_string();
let (status, body) = call(
r.clone(),
"POST",
"/memory/search",
Some(json!({ "query": "round-trip content", "limit": 5 })),
)
.await;
assert_eq!(status, StatusCode::OK);
let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
assert!(
hits.iter()
.any(|h| h.get("content").and_then(|c| c.as_str())
== Some("round-trip content")),
"expected hit with content; got: {body}"
);
let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
let (status, _body) =
call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
assert_eq!(status, StatusCode::NO_CONTENT);
let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body.get("status").and_then(|v| v.as_str()),
Some("forgotten")
);
let (status, body) = call(
r.clone(),
"POST",
"/memory/search",
Some(json!({ "query": "round-trip content", "limit": 5 })),
)
.await;
assert_eq!(status, StatusCode::OK);
let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
assert!(
hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
!= Some(mid.as_str())),
"forgotten row should be excluded from recall: {body}"
);
});
h.shutdown(&runtime);
}
#[test]
fn themes_endpoint_returns_empty_array_on_empty_db() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) =
runtime.block_on(call(r, "GET", "/memory/themes", None));
assert_eq!(status, StatusCode::OK);
assert!(body.is_array(), "expected array, got {body}");
assert_eq!(body.as_array().unwrap().len(), 0);
h.shutdown(&runtime);
}
#[test]
fn themes_endpoint_passes_through_query_params() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"GET",
"/memory/themes?window_days=7&limit=20",
None,
));
assert_eq!(status, StatusCode::OK);
assert!(body.is_array(), "expected array, got {body}");
h.shutdown(&runtime);
}
#[test]
fn facts_about_endpoint_requires_subject() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, _body) =
runtime.block_on(call(r, "GET", "/memory/facts_about", None));
assert!(
status == StatusCode::BAD_REQUEST
|| status == StatusCode::UNPROCESSABLE_ENTITY,
"expected 400 or 422 for missing subject, got {status}"
);
h.shutdown(&runtime);
}
#[test]
fn facts_about_endpoint_rejects_blank_subject() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"GET",
"/memory/facts_about?subject=%20%20",
None,
));
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body.get("error")
.and_then(|v| v.as_str())
.is_some_and(|s| s.contains("subject")),
"expected error mentioning subject, got {body}"
);
h.shutdown(&runtime);
}
#[test]
fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"GET",
"/memory/facts_about?subject=NobodyKnows",
None,
));
assert_eq!(status, StatusCode::OK);
assert_eq!(body.as_array().unwrap().len(), 0);
h.shutdown(&runtime);
}
#[test]
fn facts_about_endpoint_parses_include_as_object_query_param() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"GET",
"/memory/facts_about?subject=Maya&include_as_object=true",
None,
));
assert_eq!(
status,
StatusCode::OK,
"expected 200 with include_as_object query param, got {status}"
);
assert!(body.is_array());
h.shutdown(&runtime);
}
#[test]
fn inspect_cluster_endpoint_unknown_id_returns_404() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"GET",
"/memory/clusters/no-such-cluster",
None,
));
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(
body.get("error")
.and_then(|v| v.as_str())
.is_some_and(|s| s.contains("no-such-cluster")),
"expected error mentioning cluster id, got {body}"
);
h.shutdown(&runtime);
}
#[test]
fn inspect_cluster_endpoint_passes_full_content_query_param() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, _body) = runtime.block_on(call(
r,
"GET",
"/memory/clusters/missing?full_content=true",
None,
));
assert_eq!(status, StatusCode::NOT_FOUND);
h.shutdown(&runtime);
}
#[test]
fn contradictions_endpoint_returns_empty_array_on_empty_db() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"GET",
"/memory/contradictions",
None,
));
assert_eq!(status, StatusCode::OK);
assert!(body.is_array());
assert_eq!(body.as_array().unwrap().len(), 0);
h.shutdown(&runtime);
}
#[test]
fn derived_endpoints_require_bearer_when_auth_enabled() {
let runtime = rt();
let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
for path in [
"/memory/themes",
"/memory/facts_about?subject=Sam",
"/memory/contradictions",
"/memory/clusters/any-id",
] {
let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"{path} should 401 without token"
);
}
h.shutdown(&runtime);
}
#[test]
fn list_documents_endpoint_returns_empty_array_on_empty_db() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(r, "GET", "/memory/documents", None));
assert_eq!(status, StatusCode::OK);
assert!(body.is_array(), "expected array, got {body}");
assert_eq!(body.as_array().unwrap().len(), 0);
h.shutdown(&runtime);
}
#[test]
fn list_documents_endpoint_parses_query_params() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"GET",
"/memory/documents?limit=5&offset=0&include_forgotten=true",
None,
));
assert_eq!(status, StatusCode::OK);
assert!(body.is_array());
h.shutdown(&runtime);
}
#[test]
fn ingest_document_endpoint_rejects_empty_path() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"POST",
"/memory/documents",
Some(json!({ "path": "" })),
));
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body.get("error")
.and_then(|v| v.as_str())
.is_some_and(|s| s.contains("path")),
"expected error mentioning path, got {body}"
);
h.shutdown(&runtime);
}
#[test]
fn search_docs_endpoint_rejects_empty_query() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"POST",
"/memory/documents/search",
Some(json!({ "query": " " })),
));
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(
body.get("error")
.and_then(|v| v.as_str())
.is_some_and(|s| s.contains("must not be empty")
|| s.contains("doc_search")),
"expected error mentioning empty query, got {body}"
);
h.shutdown(&runtime);
}
#[test]
fn inspect_document_endpoint_unknown_id_returns_404() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(call(
r,
"GET",
"/memory/documents/00000000-0000-7000-8000-000000000000",
None,
));
assert_eq!(status, StatusCode::NOT_FOUND);
assert!(body.get("error").is_some(), "got: {body}");
h.shutdown(&runtime);
}
#[test]
fn inspect_document_endpoint_rejects_malformed_id() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, _body) =
runtime.block_on(call(r, "GET", "/memory/documents/not-a-uuid", None));
assert_eq!(status, StatusCode::BAD_REQUEST);
h.shutdown(&runtime);
}
#[test]
fn forget_document_endpoint_unknown_id_returns_404() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, _body) = runtime.block_on(call(
r,
"DELETE",
"/memory/documents/00000000-0000-7000-8000-000000000000",
None,
));
assert_eq!(status, StatusCode::NOT_FOUND);
h.shutdown(&runtime);
}
#[test]
fn forget_document_endpoint_rejects_malformed_id() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, _body) =
runtime.block_on(call(r, "DELETE", "/memory/documents/not-a-uuid", None));
assert_eq!(status, StatusCode::BAD_REQUEST);
h.shutdown(&runtime);
}
#[test]
fn document_endpoints_require_bearer_when_auth_enabled() {
let runtime = rt();
let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
let cases: &[(&str, &str, Option<Value>)] = &[
("POST", "/memory/documents", Some(json!({ "path": "/x" }))),
("GET", "/memory/documents", None),
(
"POST",
"/memory/documents/search",
Some(json!({ "query": "x" })),
),
(
"GET",
"/memory/documents/00000000-0000-7000-8000-000000000000",
None,
),
(
"DELETE",
"/memory/documents/00000000-0000-7000-8000-000000000000",
None,
),
];
for (method, path, body) in cases {
let (status, _) =
runtime.block_on(call(h.router.clone(), method, path, body.clone()));
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"{method} {path} should 401 without token"
);
}
h.shutdown(&runtime);
}
#[test]
fn document_endpoints_accept_correct_bearer_token() {
let runtime = rt();
let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
runtime.block_on(async {
let (status, _) = call_with_auth(
h.router.clone(),
"GET",
"/memory/documents",
None,
Some("Bearer doc-secret"),
)
.await;
assert_eq!(status, StatusCode::OK);
let (status, _) = call_with_auth(
h.router.clone(),
"GET",
"/memory/documents/00000000-0000-7000-8000-000000000000",
None,
Some("Bearer doc-secret"),
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
});
h.shutdown(&runtime);
}
}
#[cfg(test)]
mod cors_tests {
use super::is_localhost_origin;
#[test]
fn accepts_canonical_localhost_origins() {
assert!(is_localhost_origin("http://localhost"));
assert!(is_localhost_origin("http://localhost:3000"));
assert!(is_localhost_origin("https://localhost:8443"));
assert!(is_localhost_origin("http://127.0.0.1"));
assert!(is_localhost_origin("http://127.0.0.1:5173"));
assert!(is_localhost_origin("http://[::1]"));
assert!(is_localhost_origin("http://[::1]:8080"));
}
#[test]
fn rejects_remote_origins() {
assert!(!is_localhost_origin("http://example.com"));
assert!(!is_localhost_origin("https://malicious.example"));
assert!(!is_localhost_origin("http://192.168.1.5"));
assert!(!is_localhost_origin("http://10.0.0.1"));
}
#[test]
fn rejects_dns_rebinding_tricks() {
assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
assert!(!is_localhost_origin("http://localhost.evil.com"));
assert!(!is_localhost_origin("http://evil.localhost"));
}
#[test]
fn rejects_non_http_schemes() {
assert!(!is_localhost_origin("file:///"));
assert!(!is_localhost_origin("ws://localhost:3000"));
assert!(!is_localhost_origin("javascript:alert(1)"));
}
#[test]
fn rejects_malformed() {
assert!(!is_localhost_origin(""));
assert!(!is_localhost_origin("localhost"));
assert!(!is_localhost_origin("//localhost"));
}
}