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