use std::net::SocketAddr;
use std::str::FromStr;
use std::sync::Arc;
use axum::extract::{FromRequestParts, Path, Query, State};
use axum::http::request::Parts;
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, EncodingContext, Episode, MemoryId, TenantId, Tier,
};
use solo_storage::{TenantHandle, TenantRegistry};
use tower_http::cors::{AllowOrigin, CorsLayer};
use tower_http::trace::TraceLayer;
use crate::auth::{AuthConfig, AuthenticatedPrincipal, middleware::AuthValidator};
#[derive(Clone)]
pub struct SoloHttpState {
pub registry: Arc<TenantRegistry>,
pub default_tenant: TenantId,
pub user_aliases: Arc<Vec<String>>,
}
pub const TENANT_HEADER: &str = "x-solo-tenant";
pub struct TenantExtractor(pub Arc<TenantHandle>);
impl<S> FromRequestParts<S> for TenantExtractor
where
SoloHttpState: FromRef<S>,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let state = SoloHttpState::from_ref(state);
let resolved = if let Some(principal) = parts.extensions.get::<AuthenticatedPrincipal>()
&& let Some(claim) = principal.tenant_claim.clone()
{
claim
} else {
match parts.headers.get(TENANT_HEADER) {
None => state.default_tenant.clone(),
Some(raw) => {
let s = raw.to_str().map_err(|e| {
ApiError::bad_request(format!(
"{TENANT_HEADER}: header value must be ASCII ({e})"
))
})?;
TenantId::new(s.to_string()).map_err(|e| {
ApiError::bad_request(format!("{TENANT_HEADER}: invalid tenant id: {e}"))
})?
}
}
};
let handle = state.registry.get_or_open(&resolved).await.map_err(|e| {
use solo_core::Error;
match &e {
Error::NotFound(_) => ApiError::not_found(e.to_string()),
Error::InvalidInput(_) => ApiError::bad_request(e.to_string()),
_ => ApiError::internal(e.to_string()),
}
})?;
Ok(TenantExtractor(handle))
}
}
use axum::extract::FromRef;
pub struct AuditPrincipal(pub Option<String>);
impl<S> FromRequestParts<S> for AuditPrincipal
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
Ok(AuditPrincipal(
parts
.extensions
.get::<AuthenticatedPrincipal>()
.map(|p| p.subject.clone()),
))
}
}
pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
let auth = bearer_token.map(|token| AuthConfig::Bearer { token });
router_with_auth_config(state, auth)
}
pub fn router_with_auth_config(state: SoloHttpState, auth: Option<AuthConfig>) -> Router {
let cors = build_cors_layer();
let public = Router::new()
.route("/health", get(|| async { "ok" }))
.route("/openapi.json", get(openapi_handler));
let 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.clone());
let authed = if let Some(cfg) = auth {
let validator = Arc::new(AuthValidator::from_config(
&cfg,
state.default_tenant.clone(),
));
authed.layer(axum::middleware::from_fn_with_state(
validator,
crate::auth::middleware::auth_middleware,
))
} else {
authed
};
public
.merge(authed)
.layer(cors)
.layer(TraceLayer::new_for_http())
}
pub fn router(state: SoloHttpState) -> Router {
router_with_auth_config(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,
])
}
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 = bearer_token.map(|token| AuthConfig::Bearer { token });
serve_http_with_auth_config(addr, state, auth, shutdown).await
}
pub async fn serve_http_with_auth_config(
addr: SocketAddr,
state: SoloHttpState,
auth: Option<AuthConfig>,
shutdown: impl std::future::Future<Output = ()> + Send + 'static,
) -> std::io::Result<()> {
let auth_kind = match &auth {
Some(AuthConfig::Bearer { .. }) => "bearer",
Some(AuthConfig::Oidc { .. }) => "oidc",
None => "none",
};
let app = router_with_auth_config(state, auth);
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(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
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 = tenant.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 = tenant
.write()
.remember_as(principal, 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(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
Json(body): Json<RecallBody>,
) -> Result<Json<solo_query::RecallResult>, ApiError> {
let result = solo_query::run_recall(tenant.as_ref(), principal, &body.query, body.limit)
.await
.map_err(ApiError::from)?;
Ok(Json(result))
}
async fn inspect_handler(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
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(tenant.read(), tenant.audit(), principal, 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(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
Query(q): Query<ThemesQuery>,
) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
let hits = solo_query::themes(
tenant.read(),
tenant.audit(),
principal,
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>,
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
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(
tenant.read(),
tenant.audit(),
principal,
&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(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
Query(q): Query<ContradictionsQuery>,
) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
let hits = solo_query::contradictions(tenant.read(), tenant.audit(), principal, 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(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
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(
tenant.read(),
tenant.audit(),
principal,
&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(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
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 = tenant
.write()
.ingest_document_as(principal, 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(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
Json(body): Json<SearchDocsBody>,
) -> Result<Json<Vec<solo_query::DocSearchHit>>, ApiError> {
let hits = solo_query::run_doc_search(tenant.as_ref(), principal, &body.query, body.limit)
.await
.map_err(ApiError::from)?;
Ok(Json(hits))
}
async fn inspect_document_handler(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
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(tenant.read(), tenant.audit(), principal, &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(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
Query(q): Query<ListDocumentsQuery>,
) -> Result<Json<Vec<solo_query::DocumentSummary>>, ApiError> {
let rows = solo_query::list_documents(
tenant.read(),
tenant.audit(),
principal,
q.limit,
q.offset,
q.include_forgotten,
)
.await
.map_err(ApiError::from)?;
Ok(Json(rows))
}
async fn forget_document_handler(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
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 = tenant
.write()
.forget_document_as(principal, doc_id)
.await
.map_err(ApiError::from)?;
Ok(Json(report))
}
#[derive(Debug, Deserialize)]
struct ForgetQuery {
#[serde(default)]
reason: Option<String>,
}
async fn forget_handler(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
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());
tenant
.write()
.forget_as(principal, mid, reason)
.await
.map_err(ApiError::from)?;
Ok(StatusCode::NO_CONTENT)
}
async fn consolidate_handler(
TenantExtractor(tenant): TenantExtractor,
AuditPrincipal(principal): AuditPrincipal,
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 = tenant
.write()
.consolidate_as(principal, 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(
TenantExtractor(tenant): TenantExtractor,
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(tenant.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();
tenant.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_storage::test_support::StubVectorIndex;
use solo_storage::{
EmbedderConfig, IdentityConfig, KeyMaterial, ReaderPool, SoloConfig,
StubEmbedder, TenantHandle, TenantRegistry, WriterActor, WriterSpawn,
};
use solo_core::VectorIndex;
use std::sync::Arc as StdArc;
use tower::ServiceExt;
fn fake_config(dim: u32) -> SoloConfig {
SoloConfig {
schema_version: 1,
salt_hex: "00000000000000000000000000000000".to_string(),
embedder: EmbedderConfig {
name: "stub".to_string(),
version: "v1".to_string(),
dim,
dtype: "f32".to_string(),
},
identity: IdentityConfig::default(),
documents: solo_storage::DocumentConfig::default(),
auth: None,
audit: solo_storage::AuditSettings::default(),
redaction: solo_storage::RedactionConfig::default(),
}
}
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 {
Self::new_with_auth_config(
runtime,
bearer_token.map(|token| crate::auth::AuthConfig::Bearer { token }),
)
}
fn new_with_auth_config(
runtime: &tokio::runtime::Runtime,
auth: Option<crate::auth::AuthConfig>,
) -> 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 tenant_id = solo_core::TenantId::default_tenant();
let tenant_handle = StdArc::new(
TenantHandle::from_parts_for_tests(
tenant_id.clone(),
fake_config(dim as u32),
path.clone(),
tmp.path().to_path_buf(),
embedder_id,
hnsw,
embedder.clone(),
handle.clone(),
std::thread::spawn(|| {}),
pool,
),
);
let key = KeyMaterial::from_bytes_for_tests([0u8; 32]);
let registry = StdArc::new(TenantRegistry::for_tests_with_single_tenant(
tmp.path().to_path_buf(),
key,
embedder,
tenant_handle,
));
let state = SoloHttpState {
registry,
default_tenant: tenant_id,
user_aliases: Arc::new(Vec::new()),
};
let router = router_with_auth_config(state, auth);
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);
}
fn base64_url_for_test(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
async fn spin_fake_idp() -> (wiremock::MockServer, String, Vec<u8>, &'static str) {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let secret = b"http-test-secret-for-hmac-fixture".to_vec();
let kid = "http-test-kid";
let discovery = serde_json::json!({
"issuer": server.uri(),
"jwks_uri": format!("{}/jwks", server.uri()),
});
Mock::given(method("GET"))
.and(path("/.well-known/openid-configuration"))
.respond_with(ResponseTemplate::new(200).set_body_json(discovery))
.mount(&server)
.await;
let jwks = serde_json::json!({
"keys": [
{
"kty": "oct",
"kid": kid,
"alg": "HS256",
"k": base64_url_for_test(&secret),
}
]
});
Mock::given(method("GET"))
.and(path("/jwks"))
.respond_with(ResponseTemplate::new(200).set_body_json(jwks))
.mount(&server)
.await;
let discovery_url = format!("{}/.well-known/openid-configuration", server.uri());
(server, discovery_url, secret, kid)
}
fn mint_idp_token(
server_uri: &str,
kid: &str,
secret: &[u8],
tenant_claim: &str,
audience: &str,
) -> String {
use jsonwebtoken::{Algorithm, EncodingKey, Header};
let mut header = Header::new(Algorithm::HS256);
header.kid = Some(kid.to_string());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = serde_json::json!({
"iss": server_uri,
"sub": "test-user-1",
"aud": audience,
"exp": now + 600,
"iat": now,
"solo_tenant": tenant_claim,
});
jsonwebtoken::encode(&header, &claims, &EncodingKey::from_secret(secret))
.expect("mint token")
}
#[test]
fn http_oidc_accept_resolves_to_tenant_from_claim() {
let runtime = rt();
let (fake_server, discovery_url, secret, kid) =
runtime.block_on(async { spin_fake_idp().await });
let server_uri = fake_server.uri();
let _server_guard = fake_server;
let auth = crate::auth::AuthConfig::Oidc {
discovery_url,
audience: "test-audience".to_string(),
tenant_claim_name: "solo_tenant".to_string(),
};
let h = Harness::new_with_auth_config(&runtime, Some(auth));
let r = h.router.clone();
let token = mint_idp_token(
&server_uri,
kid,
&secret,
"default",
"test-audience",
);
runtime.block_on(async move {
let (status, body) = call_with_auth(
r.clone(),
"POST",
"/memory",
Some(json!({ "content": "oidc-routed content" })),
Some(&format!("Bearer {token}")),
)
.await;
assert_eq!(status, StatusCode::OK, "got body: {body}");
assert!(body.get("memory_id").is_some(), "no memory_id in {body}");
});
h.shutdown(&runtime);
}
#[test]
fn http_oidc_reject_missing_token_returns_401() {
let runtime = rt();
let (fake_server, discovery_url, _secret, _kid) =
runtime.block_on(async { spin_fake_idp().await });
let _server_guard = fake_server;
let auth = crate::auth::AuthConfig::Oidc {
discovery_url,
audience: "test-audience".to_string(),
tenant_claim_name: "solo_tenant".to_string(),
};
let h = Harness::new_with_auth_config(&runtime, Some(auth));
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 not-a-real-jwt"),
)
.await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
});
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);
}
#[test]
fn tenant_header_default_resolves() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, _body) = runtime.block_on(async {
let req = Request::builder()
.method("GET")
.uri("/memory/00000000-0000-7000-8000-000000000000")
.header("x-solo-tenant", "default")
.body(Body::empty())
.unwrap();
let resp = r.oneshot(req).await.expect("oneshot");
let s = resp.status();
let _b = resp.into_body().collect().await.unwrap().to_bytes();
(s, _b)
});
assert_eq!(status, StatusCode::NOT_FOUND);
h.shutdown(&runtime);
}
#[test]
fn tenant_header_invalid_returns_400() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, body) = runtime.block_on(async {
let req = Request::builder()
.method("GET")
.uri("/memory/00000000-0000-7000-8000-000000000000")
.header("x-solo-tenant", "UPPER")
.body(Body::empty())
.unwrap();
let resp = r.oneshot(req).await.expect("oneshot");
let s = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
(s, v)
});
assert_eq!(status, StatusCode::BAD_REQUEST);
let msg = body.get("error").and_then(|e| e.as_str()).unwrap_or("");
assert!(
msg.to_lowercase().contains("tenant") || msg.to_lowercase().contains("invalid"),
"error must mention tenant/invalid: {msg}"
);
h.shutdown(&runtime);
}
#[test]
fn tenant_header_unknown_returns_404() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, _body) = runtime.block_on(async {
let req = Request::builder()
.method("GET")
.uri("/memory/00000000-0000-7000-8000-000000000000")
.header("x-solo-tenant", "never-registered")
.body(Body::empty())
.unwrap();
let resp = r.oneshot(req).await.expect("oneshot");
let s = resp.status();
let _b = resp.into_body().collect().await.unwrap().to_bytes();
(s, _b)
});
assert_eq!(status, StatusCode::NOT_FOUND);
h.shutdown(&runtime);
}
#[test]
fn tenant_header_missing_defaults_to_state_default_tenant() {
let runtime = rt();
let h = Harness::new(&runtime);
let r = h.router.clone();
let (status, _body) = runtime.block_on(async {
let req = Request::builder()
.method("GET")
.uri("/memory/00000000-0000-7000-8000-000000000000")
.body(Body::empty())
.unwrap();
let resp = r.oneshot(req).await.expect("oneshot");
let s = resp.status();
let _b = resp.into_body().collect().await.unwrap().to_bytes();
(s, _b)
});
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"));
}
}