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). v0.4.0+.",
619 "security": [{ "bearerAuth": [] }, {}],
620 "parameters": [
621 { "name": "subject", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Subject id to query (e.g. `Sam`)." },
622 { "name": "predicate", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional predicate filter (e.g. `works_at`)." },
623 { "name": "since_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_from_ms lower bound (epoch ms)." },
624 { "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." },
625 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
626 ],
627 "responses": {
628 "200": {
629 "description": "Array of FactHits (possibly empty).",
630 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactHit" } } } }
631 },
632 "400": { "description": "Bad request (e.g. empty subject).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
633 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
634 }
635 }
636 },
637 "/memory/contradictions": {
638 "get": {
639 "summary": "List Steward-flagged contradictions",
640 "description":
641 "Equivalent to MCP tool `memory_contradictions`. Each result includes both \
642 sides' triple SPO via LEFT JOIN for context. v0.4.0+.",
643 "security": [{ "bearerAuth": [] }, {}],
644 "parameters": [
645 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
646 ],
647 "responses": {
648 "200": {
649 "description": "Array of ContradictionHits (possibly empty).",
650 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ContradictionHit" } } } }
651 },
652 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
653 }
654 }
655 },
656 "/memory/clusters/{cluster_id}": {
657 "get": {
658 "summary": "Inspect a single cluster",
659 "description":
660 "Equivalent to MCP tool `memory_inspect_cluster`. Returns the cluster row, \
661 its (optional) abstraction, and its source episodes. By default each \
662 episode's `content` is truncated to 200 chars with a trailing `…`. Pass \
663 `?full_content=true` to get verbatim episode content. v0.5.0+.",
664 "security": [{ "bearerAuth": [] }, {}],
665 "parameters": [
666 { "name": "cluster_id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Cluster id (from a previous GET /memory/themes response)." },
667 { "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)." }
668 ],
669 "responses": {
670 "200": {
671 "description": "Cluster snapshot.",
672 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClusterRecord" } } }
673 },
674 "400": { "description": "Bad request (e.g. empty cluster_id).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
675 "404": { "description": "No such cluster.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
676 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
677 }
678 }
679 }
680 }
681 })
682}
683
684#[derive(Debug, Deserialize)]
689struct RememberBody {
690 content: String,
691 #[serde(default)]
692 source_type: Option<String>,
693 #[serde(default)]
694 source_id: Option<String>,
695}
696
697#[derive(Debug, Serialize)]
698struct RememberResponse {
699 memory_id: String,
700}
701
702async fn remember_handler(
703 State(s): State<SoloHttpState>,
704 Json(body): Json<RememberBody>,
705) -> Result<Json<RememberResponse>, ApiError> {
706 let content = body.content.trim_end().to_string();
707 if content.is_empty() {
708 return Err(ApiError::bad_request("content must not be empty"));
709 }
710 let embedding = s.embedder.embed(&content).await.map_err(ApiError::from)?;
711 let episode = Episode {
712 memory_id: MemoryId::new(),
713 ts_ms: chrono::Utc::now().timestamp_millis(),
714 source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
715 source_id: body.source_id,
716 content,
717 encoding_context: EncodingContext::default(),
718 provenance: None,
719 confidence: Confidence::new(0.9).unwrap(),
720 strength: 0.5,
721 salience: 0.5,
722 tier: Tier::Hot,
723 };
724 let mid = s.write.remember(episode, embedding).await.map_err(ApiError::from)?;
725 Ok(Json(RememberResponse {
726 memory_id: mid.to_string(),
727 }))
728}
729
730#[derive(Debug, Deserialize)]
731struct RecallBody {
732 query: String,
733 #[serde(default = "default_limit")]
734 limit: usize,
735}
736
737fn default_limit() -> usize {
738 5
739}
740
741async fn recall_handler(
742 State(s): State<SoloHttpState>,
743 Json(body): Json<RecallBody>,
744) -> Result<Json<solo_query::RecallResult>, ApiError> {
745 let result = solo_query::run_recall(
749 &s.embedder,
750 &s.hnsw,
751 &s.pool,
752 &body.query,
753 body.limit,
754 )
755 .await
756 .map_err(ApiError::from)?;
757 Ok(Json(result))
758}
759
760async fn inspect_handler(
761 State(s): State<SoloHttpState>,
762 Path(id): Path<String>,
763) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
764 let mid = MemoryId::from_str(&id)
765 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
766 let row = solo_query::inspect_one(&s.pool, mid)
767 .await
768 .map_err(ApiError::from)?;
769 Ok(Json(row))
770}
771
772#[derive(Debug, Deserialize)]
779struct ThemesQuery {
780 #[serde(default)]
781 window_days: Option<i64>,
782 #[serde(default = "default_limit")]
783 limit: usize,
784}
785
786async fn themes_handler(
787 State(s): State<SoloHttpState>,
788 Query(q): Query<ThemesQuery>,
789) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
790 let hits = solo_query::themes(&s.pool, q.window_days, q.limit)
791 .await
792 .map_err(ApiError::from)?;
793 Ok(Json(hits))
794}
795
796#[derive(Debug, Deserialize)]
797struct FactsAboutQuery {
798 subject: String,
799 #[serde(default)]
800 predicate: Option<String>,
801 #[serde(default)]
802 since_ms: Option<i64>,
803 #[serde(default)]
804 until_ms: Option<i64>,
805 #[serde(default = "default_limit")]
806 limit: usize,
807}
808
809async fn facts_about_handler(
810 State(s): State<SoloHttpState>,
811 Query(q): Query<FactsAboutQuery>,
812) -> Result<Json<Vec<solo_query::FactHit>>, ApiError> {
813 if q.subject.trim().is_empty() {
814 return Err(ApiError::bad_request("subject must not be empty"));
815 }
816 let hits = solo_query::facts_about(
817 &s.pool,
818 &q.subject,
819 &s.user_aliases,
820 q.predicate.as_deref(),
821 q.since_ms,
822 q.until_ms,
823 q.limit,
824 )
825 .await
826 .map_err(ApiError::from)?;
827 Ok(Json(hits))
828}
829
830#[derive(Debug, Deserialize)]
831struct ContradictionsQuery {
832 #[serde(default = "default_limit")]
833 limit: usize,
834}
835
836async fn contradictions_handler(
837 State(s): State<SoloHttpState>,
838 Query(q): Query<ContradictionsQuery>,
839) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
840 let hits = solo_query::contradictions(&s.pool, q.limit)
841 .await
842 .map_err(ApiError::from)?;
843 Ok(Json(hits))
844}
845
846#[derive(Debug, Deserialize, Default)]
847struct InspectClusterQuery {
848 #[serde(default)]
852 full_content: bool,
853}
854
855async fn inspect_cluster_handler(
856 State(s): State<SoloHttpState>,
857 Path(cluster_id): Path<String>,
858 Query(q): Query<InspectClusterQuery>,
859) -> Result<Json<solo_query::ClusterRecord>, ApiError> {
860 if cluster_id.trim().is_empty() {
861 return Err(ApiError::bad_request("cluster_id must not be empty"));
862 }
863 let record = solo_query::inspect_cluster(
868 &s.pool,
869 &cluster_id,
870 q.full_content,
871 )
872 .await
873 .map_err(ApiError::from)?;
874 Ok(Json(record))
875}
876
877#[derive(Debug, Deserialize)]
878struct ForgetQuery {
879 #[serde(default)]
880 reason: Option<String>,
881}
882
883async fn forget_handler(
884 State(s): State<SoloHttpState>,
885 Path(id): Path<String>,
886 Query(q): Query<ForgetQuery>,
887) -> Result<StatusCode, ApiError> {
888 let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
889 let reason = q.reason.unwrap_or_else(|| "http".into());
890 s.write.forget(mid, reason).await.map_err(ApiError::from)?;
891 Ok(StatusCode::NO_CONTENT)
892}
893
894async fn consolidate_handler(
895 State(s): State<SoloHttpState>,
896 body: axum::body::Bytes,
897) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
898 let scope = if body.is_empty() {
904 solo_storage::ConsolidationScope::default()
905 } else {
906 serde_json::from_slice(&body)
907 .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
908 };
909 let report = s.write.consolidate(scope).await.map_err(ApiError::from)?;
910 Ok(Json(report))
911}
912
913#[derive(Debug, Deserialize)]
914struct BackupBody {
915 to: String,
919 #[serde(default)]
920 force: bool,
921}
922
923#[derive(Debug, Serialize)]
924struct BackupResponse {
925 path: String,
926 elapsed_ms: u64,
927}
928
929async fn backup_handler(
930 State(s): State<SoloHttpState>,
931 Json(body): Json<BackupBody>,
932) -> Result<Json<BackupResponse>, ApiError> {
933 use std::path::PathBuf;
934
935 let dest = PathBuf::from(&body.to);
936 if dest.as_os_str().is_empty() {
937 return Err(ApiError::bad_request("`to` must not be empty"));
938 }
939 if solo_storage::paths_refer_to_same_file(&s.source_db_path, &dest) {
945 return Err(ApiError::bad_request(format!(
946 "destination {} is the same file as the source database; \
947 refusing to run (would corrupt the live database)",
948 dest.display()
949 )));
950 }
951 if dest.exists() {
952 if !body.force {
953 return Err(ApiError::bad_request(format!(
954 "destination {} exists; pass force=true to overwrite",
955 dest.display()
956 )));
957 }
958 std::fs::remove_file(&dest).map_err(|e| {
959 ApiError::internal(format!(
960 "remove existing destination {}: {e}",
961 dest.display()
962 ))
963 })?;
964 }
965 if let Some(parent) = dest.parent() {
966 if !parent.as_os_str().is_empty() && !parent.is_dir() {
967 return Err(ApiError::bad_request(format!(
968 "destination parent directory {} does not exist",
969 parent.display()
970 )));
971 }
972 }
973
974 let started = std::time::Instant::now();
975 s.write.backup(dest.clone()).await.map_err(ApiError::from)?;
976 let elapsed_ms = started.elapsed().as_millis() as u64;
977
978 Ok(Json(BackupResponse {
979 path: dest.display().to_string(),
980 elapsed_ms,
981 }))
982}
983
984#[derive(Debug)]
989pub struct ApiError {
990 status: StatusCode,
991 message: String,
992}
993
994impl ApiError {
995 fn bad_request(msg: impl Into<String>) -> Self {
996 Self {
997 status: StatusCode::BAD_REQUEST,
998 message: msg.into(),
999 }
1000 }
1001 fn not_found(msg: impl Into<String>) -> Self {
1002 Self {
1003 status: StatusCode::NOT_FOUND,
1004 message: msg.into(),
1005 }
1006 }
1007 fn internal(msg: impl Into<String>) -> Self {
1008 Self {
1009 status: StatusCode::INTERNAL_SERVER_ERROR,
1010 message: msg.into(),
1011 }
1012 }
1013}
1014
1015impl From<solo_core::Error> for ApiError {
1016 fn from(e: solo_core::Error) -> Self {
1017 use solo_core::Error;
1018 match e {
1019 Error::NotFound(msg) => ApiError::not_found(msg),
1020 Error::InvalidInput(msg) => ApiError::bad_request(msg),
1021 Error::Conflict(msg) => Self {
1022 status: StatusCode::CONFLICT,
1023 message: msg,
1024 },
1025 other => ApiError::internal(other.to_string()),
1026 }
1027 }
1028}
1029
1030impl IntoResponse for ApiError {
1031 fn into_response(self) -> Response {
1032 let body = serde_json::json!({
1033 "error": self.message,
1034 "status": self.status.as_u16(),
1035 });
1036 (self.status, Json(body)).into_response()
1037 }
1038}
1039
1040#[cfg(test)]
1044mod handler_tests {
1045 use super::*;
1054 use axum::body::Body;
1055 use axum::http::{Request, StatusCode};
1056 use http_body_util::BodyExt;
1057 use serde_json::{Value, json};
1058 use solo_core::VectorIndex as _;
1059 use solo_storage::test_support::StubVectorIndex;
1060 use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
1061 use std::sync::Arc as StdArc;
1062 use tower::ServiceExt;
1063
1064 struct Harness {
1065 router: axum::Router,
1066 _tmp: tempfile::TempDir,
1067 write_handle_extra: Option<solo_storage::WriteHandle>,
1068 join: Option<std::thread::JoinHandle<()>>,
1069 }
1070
1071 impl Harness {
1072 fn new(runtime: &tokio::runtime::Runtime) -> Self {
1073 Self::new_with_auth(runtime, None)
1074 }
1075
1076 fn new_with_auth(
1077 runtime: &tokio::runtime::Runtime,
1078 bearer_token: Option<String>,
1079 ) -> Self {
1080 use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
1081
1082 let tmp = tempfile::TempDir::new().unwrap();
1083 let dim = 16usize;
1084 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1085 let embedder: StdArc<dyn solo_core::Embedder> =
1086 StdArc::new(StubEmbedder::new("stub", "v1", dim));
1087 let path = tmp.path().join("test.db");
1088
1089 let embedder_id = {
1096 let conn = solo_storage::test_support::open_test_db_at(&path);
1097 get_or_insert_embedder_id(
1098 &conn,
1099 &EmbedderIdentity {
1100 name: "stub".into(),
1101 version: "v1".into(),
1102 dim: dim as u32,
1103 dtype: "f32".into(),
1104 },
1105 )
1106 .unwrap()
1107 };
1108
1109 let conn = solo_storage::test_support::open_test_db_at(&path);
1110 let WriterSpawn { handle, join } = WriterActor::spawn_full(
1111 conn,
1112 hnsw.clone(),
1113 tmp.path().to_path_buf(),
1114 embedder_id,
1115 );
1116 let pool: ReaderPool =
1117 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1118 let state = SoloHttpState {
1119 write: handle.clone(),
1120 pool,
1121 embedder,
1122 hnsw,
1123 source_db_path: path.clone(),
1124 user_aliases: Arc::new(Vec::new()),
1125 };
1126 let router = router_with_auth(state, bearer_token);
1127 Harness {
1128 router,
1129 _tmp: tmp,
1130 write_handle_extra: Some(handle),
1131 join: Some(join),
1132 }
1133 }
1134
1135 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1136 let join = self.join.take();
1137 let extra = self.write_handle_extra.take();
1138 runtime.block_on(async move {
1139 drop(extra);
1140 drop(self.router); drop(self._tmp);
1142 if let Some(join) = join {
1143 let (tx, rx) = std::sync::mpsc::channel();
1144 std::thread::spawn(move || {
1145 let _ = tx.send(join.join());
1146 });
1147 tokio::task::spawn_blocking(move || {
1148 rx.recv_timeout(std::time::Duration::from_secs(5))
1149 })
1150 .await
1151 .expect("blocking task")
1152 .expect("writer thread did not exit within 5s")
1153 .expect("writer thread panicked");
1154 }
1155 });
1156 }
1157 }
1158
1159 fn rt() -> tokio::runtime::Runtime {
1160 tokio::runtime::Builder::new_multi_thread()
1161 .worker_threads(2)
1162 .enable_all()
1163 .build()
1164 .unwrap()
1165 }
1166
1167 async fn call(
1171 router: axum::Router,
1172 method: &str,
1173 uri: &str,
1174 body: Option<Value>,
1175 ) -> (StatusCode, Value) {
1176 call_with_auth(router, method, uri, body, None).await
1177 }
1178
1179 async fn call_with_auth(
1180 router: axum::Router,
1181 method: &str,
1182 uri: &str,
1183 body: Option<Value>,
1184 auth: Option<&str>,
1185 ) -> (StatusCode, Value) {
1186 let mut req_builder = Request::builder()
1187 .method(method)
1188 .uri(uri)
1189 .header("content-type", "application/json");
1190 if let Some(a) = auth {
1191 req_builder = req_builder.header("authorization", a);
1192 }
1193 let req = if let Some(b) = body {
1194 let bytes = serde_json::to_vec(&b).unwrap();
1195 req_builder.body(Body::from(bytes)).unwrap()
1196 } else {
1197 req_builder = req_builder.header("content-length", "0");
1198 req_builder.body(Body::empty()).unwrap()
1199 };
1200 let resp = router.oneshot(req).await.expect("oneshot");
1201 let status = resp.status();
1202 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
1203 let v: Value = if body_bytes.is_empty() {
1204 Value::Null
1205 } else {
1206 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
1207 };
1208 (status, v)
1209 }
1210
1211 #[test]
1212 fn health_returns_ok() {
1213 let runtime = rt();
1214 let h = Harness::new(&runtime);
1215 let r = h.router.clone();
1216 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1217 assert_eq!(status, StatusCode::OK);
1218 h.shutdown(&runtime);
1219 }
1220
1221 #[test]
1226 fn openapi_json_describes_all_endpoints() {
1227 let runtime = rt();
1228 let h = Harness::new(&runtime);
1229 let r = h.router.clone();
1230 let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1231 assert_eq!(status, StatusCode::OK);
1232 assert!(spec.is_object(), "openapi.json must be a JSON object");
1233
1234 assert!(
1236 spec.get("openapi")
1237 .and_then(|v| v.as_str())
1238 .is_some_and(|s| s.starts_with("3.")),
1239 "missing or wrong openapi version: {spec}"
1240 );
1241 assert!(spec.pointer("/info/title").is_some());
1242 assert!(spec.pointer("/info/version").is_some());
1243
1244 let paths = spec
1246 .get("paths")
1247 .and_then(|v| v.as_object())
1248 .expect("paths must be an object");
1249 for expected in [
1250 "/health",
1251 "/openapi.json",
1252 "/memory",
1253 "/memory/search",
1254 "/memory/consolidate",
1255 "/memory/{id}",
1256 "/memory/themes",
1258 "/memory/facts_about",
1259 "/memory/contradictions",
1260 "/memory/clusters/{cluster_id}",
1262 ] {
1263 assert!(
1264 paths.contains_key(expected),
1265 "openapi paths missing {expected}: {paths:?}"
1266 );
1267 }
1268
1269 let memid = paths.get("/memory/{id}").expect("memory/{id}");
1272 assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1273 assert!(
1274 memid.get("delete").is_some(),
1275 "DELETE /memory/{{id}} undocumented"
1276 );
1277
1278 for schema_name in [
1280 "RememberRequest",
1281 "RememberResponse",
1282 "RecallRequest",
1283 "RecallResult",
1284 "EpisodeRecord",
1285 "ApiError",
1286 "ConsolidationScope",
1287 "ConsolidationReport",
1288 "ThemeHit",
1290 "FactHit",
1291 "ContradictionHit",
1292 "ClusterRecord",
1294 ] {
1295 let ptr = format!("/components/schemas/{schema_name}");
1296 assert!(
1297 spec.pointer(&ptr).is_some(),
1298 "component schema {schema_name} missing"
1299 );
1300 }
1301
1302 assert!(
1304 spec.pointer("/components/securitySchemes/bearerAuth")
1305 .is_some(),
1306 "bearerAuth security scheme missing"
1307 );
1308
1309 h.shutdown(&runtime);
1310 }
1311
1312 #[test]
1316 fn openapi_json_is_exempt_from_bearer_auth() {
1317 let runtime = rt();
1318 let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1319 let r = h.router.clone();
1320 let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1322 assert_eq!(status, StatusCode::OK);
1323 h.shutdown(&runtime);
1324 }
1325
1326 #[test]
1327 fn remember_returns_memory_id() {
1328 let runtime = rt();
1329 let h = Harness::new(&runtime);
1330 let r = h.router.clone();
1331 let (status, body) = runtime.block_on(call(
1332 r,
1333 "POST",
1334 "/memory",
1335 Some(json!({ "content": "http harness test" })),
1336 ));
1337 assert_eq!(status, StatusCode::OK);
1338 let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1339 assert_eq!(mid.len(), 36, "uuid length");
1340 h.shutdown(&runtime);
1341 }
1342
1343 #[test]
1344 fn empty_content_returns_400() {
1345 let runtime = rt();
1346 let h = Harness::new(&runtime);
1347 let r = h.router.clone();
1348 let (status, body) =
1349 runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1350 assert_eq!(status, StatusCode::BAD_REQUEST);
1351 assert!(
1352 body.get("error")
1353 .and_then(|e| e.as_str())
1354 .map(|s| s.contains("must not be empty"))
1355 .unwrap_or(false),
1356 "got: {body}"
1357 );
1358 h.shutdown(&runtime);
1359 }
1360
1361 #[test]
1362 fn empty_query_returns_400() {
1363 let runtime = rt();
1364 let h = Harness::new(&runtime);
1365 let r = h.router.clone();
1366 let (status, body) = runtime.block_on(call(
1367 r,
1368 "POST",
1369 "/memory/search",
1370 Some(json!({ "query": "" })),
1371 ));
1372 assert_eq!(status, StatusCode::BAD_REQUEST);
1373 assert!(
1374 body.get("error")
1375 .and_then(|e| e.as_str())
1376 .map(|s| s.contains("must not be empty"))
1377 .unwrap_or(false),
1378 "got: {body}"
1379 );
1380 h.shutdown(&runtime);
1381 }
1382
1383 #[test]
1384 fn inspect_unknown_returns_404() {
1385 let runtime = rt();
1386 let h = Harness::new(&runtime);
1387 let r = h.router.clone();
1388 let (status, body) = runtime.block_on(call(
1389 r,
1390 "GET",
1391 "/memory/00000000-0000-7000-8000-000000000000",
1392 None,
1393 ));
1394 assert_eq!(status, StatusCode::NOT_FOUND);
1395 assert!(body.get("error").is_some(), "got: {body}");
1396 h.shutdown(&runtime);
1397 }
1398
1399 #[test]
1400 fn inspect_invalid_id_returns_400() {
1401 let runtime = rt();
1402 let h = Harness::new(&runtime);
1403 let r = h.router.clone();
1404 let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1405 assert_eq!(status, StatusCode::BAD_REQUEST);
1406 h.shutdown(&runtime);
1407 }
1408
1409 #[test]
1410 fn forget_unknown_returns_404() {
1411 let runtime = rt();
1412 let h = Harness::new(&runtime);
1413 let r = h.router.clone();
1414 let (status, _body) = runtime.block_on(call(
1415 r,
1416 "DELETE",
1417 "/memory/00000000-0000-7000-8000-000000000000",
1418 None,
1419 ));
1420 assert_eq!(status, StatusCode::NOT_FOUND);
1421 h.shutdown(&runtime);
1422 }
1423
1424 #[test]
1432 fn consolidate_endpoint_returns_report() {
1433 let runtime = rt();
1434 let h = Harness::new(&runtime);
1435 let r = h.router.clone();
1436 runtime.block_on(async move {
1437 let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1439 assert_eq!(status, StatusCode::OK);
1440 for field in [
1441 "episodes_seen",
1442 "clusters_built",
1443 "episodes_clustered",
1444 "abstractions_built",
1445 "triples_built",
1446 "contradictions_found",
1447 ] {
1448 assert!(
1449 body.get(field).and_then(|v| v.as_u64()).is_some(),
1450 "missing field {field}: {body}"
1451 );
1452 }
1453 assert_eq!(body["episodes_seen"], 0);
1454 assert_eq!(body["clusters_built"], 0);
1455
1456 let (status2, _body2) = call(
1459 r,
1460 "POST",
1461 "/memory/consolidate",
1462 Some(json!({ "window_days": 7 })),
1463 )
1464 .await;
1465 assert_eq!(status2, StatusCode::OK);
1466 });
1467 h.shutdown(&runtime);
1468 }
1469
1470 #[test]
1471 fn auth_required_routes_reject_missing_token() {
1472 let runtime = rt();
1473 let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1474 let r = h.router.clone();
1475 runtime.block_on(async move {
1476 let (status, _body) = call(
1478 r.clone(),
1479 "POST",
1480 "/memory",
1481 Some(json!({ "content": "x" })),
1482 )
1483 .await;
1484 assert_eq!(status, StatusCode::UNAUTHORIZED);
1485
1486 let (status, _body) = call_with_auth(
1488 r.clone(),
1489 "POST",
1490 "/memory",
1491 Some(json!({ "content": "x" })),
1492 Some("Bearer wrong-token"),
1493 )
1494 .await;
1495 assert_eq!(status, StatusCode::UNAUTHORIZED);
1496
1497 let (status, body) = call_with_auth(
1499 r.clone(),
1500 "POST",
1501 "/memory",
1502 Some(json!({ "content": "authed" })),
1503 Some("Bearer secret-xyz"),
1504 )
1505 .await;
1506 assert_eq!(status, StatusCode::OK);
1507 assert!(body.get("memory_id").is_some());
1508 });
1509 h.shutdown(&runtime);
1510 }
1511
1512 #[test]
1513 fn health_endpoint_does_not_require_auth() {
1514 let runtime = rt();
1515 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1516 let r = h.router.clone();
1517 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1518 assert_eq!(status, StatusCode::OK);
1520 h.shutdown(&runtime);
1521 }
1522
1523 #[test]
1524 fn auth_response_includes_www_authenticate_header() {
1525 let runtime = rt();
1530 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1531 let r = h.router.clone();
1532 runtime.block_on(async move {
1533 let req = Request::builder()
1534 .method("POST")
1535 .uri("/memory")
1536 .header("content-type", "application/json")
1537 .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
1538 .unwrap();
1539 let resp = r.oneshot(req).await.unwrap();
1540 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1541 let www = resp
1542 .headers()
1543 .get("www-authenticate")
1544 .and_then(|v| v.to_str().ok())
1545 .unwrap_or("");
1546 assert!(
1547 www.starts_with("Bearer"),
1548 "expected WWW-Authenticate: Bearer..., got: {www}"
1549 );
1550 });
1551 h.shutdown(&runtime);
1552 }
1553
1554 #[test]
1555 fn full_remember_recall_inspect_forget_round_trip() {
1556 let runtime = rt();
1557 let h = Harness::new(&runtime);
1558 let r = h.router.clone();
1559 runtime.block_on(async move {
1560 let (status, body) = call(
1562 r.clone(),
1563 "POST",
1564 "/memory",
1565 Some(json!({ "content": "round-trip content" })),
1566 )
1567 .await;
1568 assert_eq!(status, StatusCode::OK);
1569 let mid = body
1570 .get("memory_id")
1571 .and_then(|v| v.as_str())
1572 .unwrap()
1573 .to_string();
1574
1575 let (status, body) = call(
1577 r.clone(),
1578 "POST",
1579 "/memory/search",
1580 Some(json!({ "query": "round-trip content", "limit": 5 })),
1581 )
1582 .await;
1583 assert_eq!(status, StatusCode::OK);
1584 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1585 assert!(
1586 hits.iter()
1587 .any(|h| h.get("content").and_then(|c| c.as_str())
1588 == Some("round-trip content")),
1589 "expected hit with content; got: {body}"
1590 );
1591
1592 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1594 assert_eq!(status, StatusCode::OK);
1595 assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
1596
1597 let (status, _body) =
1599 call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1600 assert_eq!(status, StatusCode::NO_CONTENT);
1601
1602 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1604 assert_eq!(status, StatusCode::OK);
1605 assert_eq!(
1606 body.get("status").and_then(|v| v.as_str()),
1607 Some("forgotten")
1608 );
1609
1610 let (status, body) = call(
1612 r.clone(),
1613 "POST",
1614 "/memory/search",
1615 Some(json!({ "query": "round-trip content", "limit": 5 })),
1616 )
1617 .await;
1618 assert_eq!(status, StatusCode::OK);
1619 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1620 assert!(
1621 hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
1622 != Some(mid.as_str())),
1623 "forgotten row should be excluded from recall: {body}"
1624 );
1625 });
1626 h.shutdown(&runtime);
1627 }
1628
1629 #[test]
1636 fn themes_endpoint_returns_empty_array_on_empty_db() {
1637 let runtime = rt();
1638 let h = Harness::new(&runtime);
1639 let r = h.router.clone();
1640 let (status, body) =
1641 runtime.block_on(call(r, "GET", "/memory/themes", None));
1642 assert_eq!(status, StatusCode::OK);
1643 assert!(body.is_array(), "expected array, got {body}");
1644 assert_eq!(body.as_array().unwrap().len(), 0);
1645 h.shutdown(&runtime);
1646 }
1647
1648 #[test]
1649 fn themes_endpoint_passes_through_query_params() {
1650 let runtime = rt();
1651 let h = Harness::new(&runtime);
1652 let r = h.router.clone();
1653 let (status, body) = runtime.block_on(call(
1654 r,
1655 "GET",
1656 "/memory/themes?window_days=7&limit=20",
1657 None,
1658 ));
1659 assert_eq!(status, StatusCode::OK);
1660 assert!(body.is_array(), "expected array, got {body}");
1661 h.shutdown(&runtime);
1662 }
1663
1664 #[test]
1665 fn facts_about_endpoint_requires_subject() {
1666 let runtime = rt();
1667 let h = Harness::new(&runtime);
1668 let r = h.router.clone();
1669 let (status, _body) =
1673 runtime.block_on(call(r, "GET", "/memory/facts_about", None));
1674 assert!(
1675 status == StatusCode::BAD_REQUEST
1676 || status == StatusCode::UNPROCESSABLE_ENTITY,
1677 "expected 400 or 422 for missing subject, got {status}"
1678 );
1679 h.shutdown(&runtime);
1680 }
1681
1682 #[test]
1683 fn facts_about_endpoint_rejects_blank_subject() {
1684 let runtime = rt();
1685 let h = Harness::new(&runtime);
1686 let r = h.router.clone();
1687 let (status, body) = runtime.block_on(call(
1690 r,
1691 "GET",
1692 "/memory/facts_about?subject=%20%20",
1693 None,
1694 ));
1695 assert_eq!(status, StatusCode::BAD_REQUEST);
1696 assert!(
1697 body.get("error")
1698 .and_then(|v| v.as_str())
1699 .is_some_and(|s| s.contains("subject")),
1700 "expected error mentioning subject, got {body}"
1701 );
1702 h.shutdown(&runtime);
1703 }
1704
1705 #[test]
1706 fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
1707 let runtime = rt();
1708 let h = Harness::new(&runtime);
1709 let r = h.router.clone();
1710 let (status, body) = runtime.block_on(call(
1711 r,
1712 "GET",
1713 "/memory/facts_about?subject=NobodyKnows",
1714 None,
1715 ));
1716 assert_eq!(status, StatusCode::OK);
1717 assert_eq!(body.as_array().unwrap().len(), 0);
1718 h.shutdown(&runtime);
1719 }
1720
1721 #[test]
1722 fn inspect_cluster_endpoint_unknown_id_returns_404() {
1723 let runtime = rt();
1727 let h = Harness::new(&runtime);
1728 let r = h.router.clone();
1729 let (status, body) = runtime.block_on(call(
1730 r,
1731 "GET",
1732 "/memory/clusters/no-such-cluster",
1733 None,
1734 ));
1735 assert_eq!(status, StatusCode::NOT_FOUND);
1736 assert!(
1737 body.get("error")
1738 .and_then(|v| v.as_str())
1739 .is_some_and(|s| s.contains("no-such-cluster")),
1740 "expected error mentioning cluster id, got {body}"
1741 );
1742 h.shutdown(&runtime);
1743 }
1744
1745 #[test]
1746 fn inspect_cluster_endpoint_passes_full_content_query_param() {
1747 let runtime = rt();
1753 let h = Harness::new(&runtime);
1754 let r = h.router.clone();
1755 let (status, _body) = runtime.block_on(call(
1756 r,
1757 "GET",
1758 "/memory/clusters/missing?full_content=true",
1759 None,
1760 ));
1761 assert_eq!(status, StatusCode::NOT_FOUND);
1762 h.shutdown(&runtime);
1763 }
1764
1765 #[test]
1766 fn contradictions_endpoint_returns_empty_array_on_empty_db() {
1767 let runtime = rt();
1768 let h = Harness::new(&runtime);
1769 let r = h.router.clone();
1770 let (status, body) = runtime.block_on(call(
1771 r,
1772 "GET",
1773 "/memory/contradictions",
1774 None,
1775 ));
1776 assert_eq!(status, StatusCode::OK);
1777 assert!(body.is_array());
1778 assert_eq!(body.as_array().unwrap().len(), 0);
1779 h.shutdown(&runtime);
1780 }
1781
1782 #[test]
1783 fn derived_endpoints_require_bearer_when_auth_enabled() {
1784 let runtime = rt();
1785 let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
1786 for path in [
1793 "/memory/themes",
1794 "/memory/facts_about?subject=Sam",
1795 "/memory/contradictions",
1796 "/memory/clusters/any-id",
1797 ] {
1798 let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
1799 assert_eq!(
1800 status,
1801 StatusCode::UNAUTHORIZED,
1802 "{path} should 401 without token"
1803 );
1804 }
1805 h.shutdown(&runtime);
1806 }
1807}
1808
1809#[cfg(test)]
1810mod cors_tests {
1811 use super::is_localhost_origin;
1812
1813 #[test]
1814 fn accepts_canonical_localhost_origins() {
1815 assert!(is_localhost_origin("http://localhost"));
1816 assert!(is_localhost_origin("http://localhost:3000"));
1817 assert!(is_localhost_origin("https://localhost:8443"));
1818 assert!(is_localhost_origin("http://127.0.0.1"));
1819 assert!(is_localhost_origin("http://127.0.0.1:5173"));
1820 assert!(is_localhost_origin("http://[::1]"));
1821 assert!(is_localhost_origin("http://[::1]:8080"));
1822 }
1823
1824 #[test]
1825 fn rejects_remote_origins() {
1826 assert!(!is_localhost_origin("http://example.com"));
1827 assert!(!is_localhost_origin("https://malicious.example"));
1828 assert!(!is_localhost_origin("http://192.168.1.5"));
1829 assert!(!is_localhost_origin("http://10.0.0.1"));
1830 }
1831
1832 #[test]
1833 fn rejects_dns_rebinding_tricks() {
1834 assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
1838 assert!(!is_localhost_origin("http://localhost.evil.com"));
1839 assert!(!is_localhost_origin("http://evil.localhost"));
1840 }
1841
1842 #[test]
1843 fn rejects_non_http_schemes() {
1844 assert!(!is_localhost_origin("file:///"));
1845 assert!(!is_localhost_origin("ws://localhost:3000"));
1846 assert!(!is_localhost_origin("javascript:alert(1)"));
1847 }
1848
1849 #[test]
1850 fn rejects_malformed() {
1851 assert!(!is_localhost_origin(""));
1852 assert!(!is_localhost_origin("localhost"));
1853 assert!(!is_localhost_origin("//localhost"));
1854 }
1855}
1856