1use std::net::SocketAddr;
43use std::str::FromStr;
44use std::sync::Arc;
45
46use axum::extract::{Path, Query, State};
47use axum::http::{HeaderValue, Method, StatusCode};
48use axum::response::{IntoResponse, Response};
49use axum::routing::{get, post};
50use axum::{Json, Router};
51use serde::{Deserialize, Serialize};
52use solo_core::{
53 Confidence, DocumentId, Embedder, EncodingContext, Episode, MemoryId, Tier, VectorIndex,
54};
55use solo_storage::{ReaderPool, WriteHandle};
56use tower_http::cors::{AllowOrigin, CorsLayer};
57use tower_http::trace::TraceLayer;
58use tower_http::validate_request::{ValidateRequest, ValidateRequestHeaderLayer};
59
60#[derive(Clone)]
61pub struct SoloHttpState {
62 pub write: WriteHandle,
63 pub pool: ReaderPool,
64 pub embedder: Arc<dyn Embedder>,
65 pub hnsw: Arc<dyn VectorIndex + Send + Sync>,
66 pub source_db_path: std::path::PathBuf,
72 pub user_aliases: Arc<Vec<String>>,
79}
80
81pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
91 let cors = build_cors_layer();
92 let public = Router::new()
100 .route("/health", get(|| async { "ok" }))
101 .route("/openapi.json", get(openapi_handler));
102
103 let mut authed = Router::new()
104 .route("/memory", post(remember_handler))
105 .route("/memory/search", post(recall_handler))
106 .route("/memory/consolidate", post(consolidate_handler))
107 .route("/memory/{id}", get(inspect_handler).delete(forget_handler))
108 .route("/backup", post(backup_handler))
109 .route("/memory/themes", get(themes_handler))
113 .route("/memory/facts_about", get(facts_about_handler))
114 .route("/memory/contradictions", get(contradictions_handler))
115 .route(
120 "/memory/clusters/{cluster_id}",
121 get(inspect_cluster_handler),
122 )
123 .route(
130 "/memory/documents/search",
131 post(search_docs_handler),
132 )
133 .route(
134 "/memory/documents",
135 post(ingest_document_handler).get(list_documents_handler),
136 )
137 .route(
138 "/memory/documents/{id}",
139 get(inspect_document_handler).delete(forget_document_handler),
140 )
141 .with_state(state);
142 if let Some(token) = bearer_token {
143 authed = authed.layer(ValidateRequestHeaderLayer::custom(BearerToken::new(token)));
147 }
148
149 public
150 .merge(authed)
151 .layer(cors)
152 .layer(TraceLayer::new_for_http())
153}
154
155pub fn router(state: SoloHttpState) -> Router {
157 router_with_auth(state, None)
158}
159
160fn build_cors_layer() -> CorsLayer {
161 CorsLayer::new()
175 .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
176 origin
177 .to_str()
178 .map(is_localhost_origin)
179 .unwrap_or(false)
180 }))
181 .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
182 .allow_headers([
183 axum::http::header::CONTENT_TYPE,
184 axum::http::header::AUTHORIZATION,
185 ])
186}
187
188#[derive(Clone)]
196struct BearerToken {
197 expected: HeaderValue,
198}
199
200impl BearerToken {
201 fn new(token: String) -> Self {
202 let expected = HeaderValue::try_from(format!("Bearer {token}"))
203 .expect("bearer token must be a valid HTTP header value");
204 Self { expected }
205 }
206}
207
208impl<B> ValidateRequest<B> for BearerToken {
209 type ResponseBody = axum::body::Body;
210
211 fn validate(
212 &mut self,
213 request: &mut axum::http::Request<B>,
214 ) -> Result<(), axum::http::Response<Self::ResponseBody>> {
215 let got = request.headers().get(axum::http::header::AUTHORIZATION);
216 match got {
217 Some(value) if value == &self.expected => Ok(()),
218 _ => {
219 let mut resp = axum::http::Response::new(axum::body::Body::empty());
220 *resp.status_mut() = StatusCode::UNAUTHORIZED;
221 resp.headers_mut().insert(
222 axum::http::header::WWW_AUTHENTICATE,
223 HeaderValue::from_static(r#"Bearer realm="solo""#),
224 );
225 Err(resp)
226 }
227 }
228 }
229}
230
231fn is_localhost_origin(origin: &str) -> bool {
235 let rest = origin
236 .strip_prefix("http://")
237 .or_else(|| origin.strip_prefix("https://"));
238 let host = match rest {
239 Some(r) => r,
240 None => return false,
241 };
242 let host = host.split('/').next().unwrap_or(host);
244 let host = if let Some(idx) = host.rfind(':') {
246 if host.starts_with('[') {
248 host.find(']')
250 .map(|i| &host[..=i])
251 .unwrap_or(host)
252 } else {
253 &host[..idx]
254 }
255 } else {
256 host
257 };
258 matches!(host, "localhost" | "127.0.0.1" | "[::1]")
259}
260
261pub async fn serve_http(
267 addr: SocketAddr,
268 state: SoloHttpState,
269 bearer_token: Option<String>,
270 shutdown: impl std::future::Future<Output = ()> + Send + 'static,
271) -> std::io::Result<()> {
272 let auth_kind = if bearer_token.is_some() {
273 "bearer"
274 } else {
275 "none"
276 };
277 let app = router_with_auth(state, bearer_token);
278 let listener = tokio::net::TcpListener::bind(addr).await?;
279 tracing::info!(%addr, auth = auth_kind, "solo http: listening");
280 axum::serve(listener, app)
281 .with_graceful_shutdown(shutdown)
282 .await
283}
284
285async fn openapi_handler() -> Json<serde_json::Value> {
299 Json(openapi_spec())
300}
301
302pub fn openapi_spec() -> serde_json::Value {
306 serde_json::json!({
307 "openapi": "3.1.0",
308 "info": {
309 "title": "Solo HTTP API",
310 "description":
311 "Local-first personal memory daemon. The HTTP transport \
312 mirrors the four MCP tools (memory_remember / recall / \
313 inspect / forget). Default deployment is loopback-only \
314 (127.0.0.1); LAN-bound deployments require a bearer \
315 token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
316 "version": env!("CARGO_PKG_VERSION"),
317 "license": { "name": "Apache-2.0" }
318 },
319 "servers": [
320 { "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
321 ],
322 "components": {
323 "securitySchemes": {
324 "bearerAuth": {
325 "type": "http",
326 "scheme": "bearer",
327 "description":
328 "Bearer-token auth. Required only on LAN-bound deployments \
329 (`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
330 the default `127.0.0.1` deployment is unauthenticated. \
331 `GET /health` and `GET /openapi.json` are exempt from auth even \
332 on bearer-protected instances."
333 }
334 },
335 "schemas": {
336 "RememberRequest": {
337 "type": "object",
338 "required": ["content"],
339 "properties": {
340 "content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
341 "source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
342 "source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
343 },
344 "additionalProperties": false
345 },
346 "RememberResponse": {
347 "type": "object",
348 "required": ["memory_id"],
349 "properties": {
350 "memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
351 }
352 },
353 "RecallRequest": {
354 "type": "object",
355 "required": ["query"],
356 "properties": {
357 "query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
358 "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
359 },
360 "additionalProperties": false
361 },
362 "RecallResult": {
363 "type": "object",
364 "description":
365 "Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
366 see `solo_query::RecallResult` in the source for the canonical shape. \
367 Treat as a forward-compatible JSON object.",
368 "additionalProperties": true
369 },
370 "ConsolidationScope": {
371 "type": "object",
372 "description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
373 "properties": {
374 "window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." },
375 "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." }
376 },
377 "additionalProperties": false
378 },
379 "ConsolidationReport": {
380 "type": "object",
381 "required": [
382 "episodes_seen", "clusters_built", "clusters_merged",
383 "clusters_absorbed", "existing_clusters_merged",
384 "episodes_clustered", "abstractions_built",
385 "abstractions_regenerated", "triples_built",
386 "contradictions_found"
387 ],
388 "properties": {
389 "episodes_seen": { "type": "integer", "minimum": 0 },
390 "clusters_built": { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
391 "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." },
392 "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." },
393 "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." },
394 "episodes_clustered": { "type": "integer", "minimum": 0 },
395 "abstractions_built": { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
396 "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." },
397 "triples_built": { "type": "integer", "minimum": 0 },
398 "contradictions_found": { "type": "integer", "minimum": 0 }
399 }
400 },
401 "EpisodeRecord": {
402 "type": "object",
403 "description":
404 "Inspect response: full episode record. Fields are stable across v0.1 but not \
405 exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
406 Treat as a forward-compatible JSON object.",
407 "additionalProperties": true
408 },
409 "ThemeHit": {
410 "type": "object",
411 "description":
412 "One cluster + its (optional) abstraction. Returned by GET /memory/themes. \
413 See `solo_query::ThemeHit` for the canonical shape: cluster_id, \
414 abstraction_id?, abstraction_text?, episode_count, coherence, created_at_ms.",
415 "additionalProperties": true
416 },
417 "FactHit": {
418 "type": "object",
419 "description":
420 "One Steward-extracted SPO triple. Returned by GET /memory/facts_about. \
421 See `solo_query::FactHit` for fields: triple_id, subject_id, predicate, \
422 object_id, object_kind, valid_from_ms, valid_to_ms?, confidence, cluster_id?.",
423 "additionalProperties": true
424 },
425 "ContradictionHit": {
426 "type": "object",
427 "description":
428 "One Steward-flagged contradiction with each side's triple LEFT JOIN'd in. \
429 Returned by GET /memory/contradictions. See `solo_query::ContradictionHit`: \
430 a_id, b_id, kind, explanation, detected_at_ms, a_triple?, b_triple?.",
431 "additionalProperties": true
432 },
433 "ClusterRecord": {
434 "type": "object",
435 "description":
436 "Snapshot of one cluster — its row, optional abstraction, and source episodes \
437 (content truncated to 200 chars unless ?full_content=true). Returned by \
438 GET /memory/clusters/{cluster_id}. See `solo_query::ClusterRecord`.",
439 "additionalProperties": true
440 },
441 "IngestDocumentRequest": {
442 "type": "object",
443 "required": ["path"],
444 "properties": {
445 "path": {
446 "type": "string",
447 "minLength": 1,
448 "description":
449 "Server-side absolute path to the file to ingest. The file must be \
450 readable by the Solo process. Supported formats: plaintext / \
451 markdown / code, HTML, PDF."
452 }
453 },
454 "additionalProperties": false
455 },
456 "IngestReport": {
457 "type": "object",
458 "description":
459 "Returned by POST /memory/documents. Reports the document id assigned, \
460 the number of chunks persisted + embedded, the total byte size, and a \
461 `deduped` flag (true when the same content_hash was already present and \
462 the existing doc_id was returned unchanged). See `solo_storage::IngestReport`.",
463 "required": ["doc_id", "chunks_persisted", "bytes_ingested", "deduped"],
464 "properties": {
465 "doc_id": { "type": "string", "format": "uuid" },
466 "chunks_persisted": { "type": "integer", "minimum": 0 },
467 "bytes_ingested": { "type": "integer", "minimum": 0, "format": "int64" },
468 "deduped": { "type": "boolean" }
469 },
470 "additionalProperties": false
471 },
472 "ForgetDocumentReport": {
473 "type": "object",
474 "description":
475 "Returned by DELETE /memory/documents/{id}. Reports the doc_id soft-deleted \
476 and how many chunk rowids were tombstoned in the HNSW index. The chunk rows \
477 themselves survive in SQL for forensic value. See `solo_storage::ForgetDocumentReport`.",
478 "required": ["doc_id", "chunks_tombstoned"],
479 "properties": {
480 "doc_id": { "type": "string", "format": "uuid" },
481 "chunks_tombstoned": { "type": "integer", "minimum": 0 }
482 },
483 "additionalProperties": false
484 },
485 "SearchDocsRequest": {
486 "type": "object",
487 "required": ["query"],
488 "properties": {
489 "query": { "type": "string", "minLength": 1 },
490 "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 }
491 },
492 "additionalProperties": false
493 },
494 "DocSearchHit": {
495 "type": "object",
496 "description":
497 "One chunk hit + parent-doc context. Fields per `solo_query::DocSearchHit`: \
498 chunk_id, doc_id, doc_title?, doc_source?, doc_mime_type?, chunk_index, \
499 content, cos_distance, start_offset, end_offset.",
500 "additionalProperties": true
501 },
502 "DocumentInspectResult": {
503 "type": "object",
504 "description":
505 "Returned by GET /memory/documents/{id}. A `document` record (full metadata) \
506 plus an ordered list of chunk summaries (each preview truncated to 200 \
507 chars). See `solo_query::DocumentInspectResult`.",
508 "additionalProperties": true
509 },
510 "DocumentSummary": {
511 "type": "object",
512 "description":
513 "One row from GET /memory/documents. Fields per `solo_query::DocumentSummary`: \
514 doc_id, title?, source?, mime_type?, ingested_at_ms, chunk_count, status.",
515 "additionalProperties": true
516 },
517 "ApiError": {
518 "type": "object",
519 "required": ["error", "status"],
520 "properties": {
521 "error": { "type": "string" },
522 "status": { "type": "integer", "minimum": 400, "maximum": 599 }
523 }
524 }
525 }
526 },
527 "paths": {
528 "/health": {
529 "get": {
530 "summary": "Liveness probe",
531 "description": "Returns plain text `ok`. Always unauthenticated.",
532 "responses": {
533 "200": {
534 "description": "Server is up.",
535 "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
536 }
537 }
538 }
539 },
540 "/openapi.json": {
541 "get": {
542 "summary": "Self-describing OpenAPI 3.1 spec",
543 "description": "Returns this document. Always unauthenticated.",
544 "responses": {
545 "200": {
546 "description": "OpenAPI 3.1 document.",
547 "content": { "application/json": { "schema": { "type": "object" } } }
548 }
549 }
550 }
551 },
552 "/memory": {
553 "post": {
554 "summary": "Remember (store an episode)",
555 "description": "Equivalent to MCP tool `memory_remember`.",
556 "security": [{ "bearerAuth": [] }, {}],
557 "requestBody": {
558 "required": true,
559 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
560 },
561 "responses": {
562 "200": {
563 "description": "Memory stored; returns the new MemoryId.",
564 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
565 },
566 "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
567 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
568 }
569 }
570 },
571 "/memory/search": {
572 "post": {
573 "summary": "Recall (vector search)",
574 "description": "Equivalent to MCP tool `memory_recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
575 "security": [{ "bearerAuth": [] }, {}],
576 "requestBody": {
577 "required": true,
578 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
579 },
580 "responses": {
581 "200": {
582 "description": "Search results.",
583 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
584 },
585 "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
586 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
587 }
588 }
589 },
590 "/memory/consolidate": {
591 "post": {
592 "summary": "Run a consolidation pass (clustering + abstraction)",
593 "description":
594 "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
595 on the server, also runs the REM-equivalent abstraction pass that populates \
596 `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
597 window). Equivalent to the `solo consolidate` CLI.",
598 "security": [{ "bearerAuth": [] }, {}],
599 "requestBody": {
600 "required": false,
601 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
602 },
603 "responses": {
604 "200": {
605 "description": "Consolidation complete; report counts the work done.",
606 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
607 },
608 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
609 }
610 }
611 },
612 "/backup": {
613 "post": {
614 "summary": "Online encrypted backup",
615 "description":
616 "Run an online SQLCipher backup of the live data dir to a server-side path. \
617 The destination file is encrypted with the same Argon2id-derived raw key as \
618 the source, so it restores under the same passphrase + a copy of the source's \
619 `solo.config.toml`. Hot — the backup runs against the writer's existing \
620 connection without taking the lockfile, so the daemon keeps serving reads + \
621 writes during the operation. v0.3.2+.",
622 "security": [{ "bearerAuth": [] }, {}],
623 "requestBody": {
624 "required": true,
625 "content": { "application/json": { "schema": {
626 "type": "object",
627 "properties": {
628 "to": { "type": "string", "description": "Server-side absolute path for the backup file." },
629 "force": { "type": "boolean", "description": "Overwrite an existing destination file. Default false.", "default": false }
630 },
631 "required": ["to"]
632 } } }
633 },
634 "responses": {
635 "200": {
636 "description": "Backup complete; reports the destination path + elapsed milliseconds.",
637 "content": { "application/json": { "schema": {
638 "type": "object",
639 "properties": {
640 "path": { "type": "string" },
641 "elapsed_ms": { "type": "integer", "format": "int64" }
642 }
643 } } }
644 },
645 "400": { "description": "Destination invalid, exists without force, or its parent doesn't exist." },
646 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." },
647 "500": { "description": "Backup failed (disk full, permission denied, etc.)." }
648 }
649 }
650 },
651 "/memory/{id}": {
652 "get": {
653 "summary": "Inspect a memory by ID",
654 "description": "Equivalent to MCP tool `memory_inspect`.",
655 "security": [{ "bearerAuth": [] }, {}],
656 "parameters": [{
657 "name": "id",
658 "in": "path",
659 "required": true,
660 "schema": { "type": "string", "format": "uuid" },
661 "description": "MemoryId (UUID v7)."
662 }],
663 "responses": {
664 "200": {
665 "description": "Episode record.",
666 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
667 },
668 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
669 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
670 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
671 }
672 },
673 "delete": {
674 "summary": "Forget (soft-delete) a memory by ID",
675 "description":
676 "Equivalent to MCP tool `memory_forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
677 and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
678 re-running `solo reembed` after this does NOT restore visibility.",
679 "security": [{ "bearerAuth": [] }, {}],
680 "parameters": [
681 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
682 { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
683 ],
684 "responses": {
685 "204": { "description": "Forgotten (or already forgotten — idempotent)." },
686 "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
687 "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
688 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
689 }
690 }
691 },
692 "/memory/themes": {
693 "get": {
694 "summary": "List recent cluster themes",
695 "description":
696 "Equivalent to MCP tool `memory_themes`. List cluster abstractions ordered by \
697 most-recent first. Use to surface 'what has the user been thinking about lately' \
698 without paging through individual episodes. v0.4.0+.",
699 "security": [{ "bearerAuth": [] }, {}],
700 "parameters": [
701 { "name": "window_days", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Optional time window. Omit for unfiltered (all-time, most-recent first)." },
702 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
703 ],
704 "responses": {
705 "200": {
706 "description": "Array of ThemeHits (possibly empty).",
707 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ThemeHit" } } } }
708 },
709 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
710 }
711 }
712 },
713 "/memory/facts_about": {
714 "get": {
715 "summary": "Query the SPO knowledge graph by subject",
716 "description":
717 "Equivalent to MCP tool `memory_facts_about`. Query Steward-extracted triples by \
718 subject + optional predicate + optional time window. Subject is required \
719 (predicate-only scans not supported). Pass `include_as_object=true` (v0.5.1+) \
720 to also surface rows where `subject` appears as the object. v0.4.0+.",
721 "security": [{ "bearerAuth": [] }, {}],
722 "parameters": [
723 { "name": "subject", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Subject id to query (e.g. `Sam`)." },
724 { "name": "predicate", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional predicate filter (e.g. `works_at`)." },
725 { "name": "since_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_from_ms lower bound (epoch ms)." },
726 { "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." },
727 { "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+." },
728 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
729 ],
730 "responses": {
731 "200": {
732 "description": "Array of FactHits (possibly empty).",
733 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactHit" } } } }
734 },
735 "400": { "description": "Bad request (e.g. empty subject).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
736 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
737 }
738 }
739 },
740 "/memory/contradictions": {
741 "get": {
742 "summary": "List Steward-flagged contradictions",
743 "description":
744 "Equivalent to MCP tool `memory_contradictions`. Each result includes both \
745 sides' triple SPO via LEFT JOIN for context. v0.4.0+.",
746 "security": [{ "bearerAuth": [] }, {}],
747 "parameters": [
748 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
749 ],
750 "responses": {
751 "200": {
752 "description": "Array of ContradictionHits (possibly empty).",
753 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ContradictionHit" } } } }
754 },
755 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
756 }
757 }
758 },
759 "/memory/clusters/{cluster_id}": {
760 "get": {
761 "summary": "Inspect a single cluster",
762 "description":
763 "Equivalent to MCP tool `memory_inspect_cluster`. Returns the cluster row, \
764 its (optional) abstraction, and its source episodes. By default each \
765 episode's `content` is truncated to 200 chars with a trailing `…`. Pass \
766 `?full_content=true` to get verbatim episode content. v0.5.0+.",
767 "security": [{ "bearerAuth": [] }, {}],
768 "parameters": [
769 { "name": "cluster_id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Cluster id (from a previous GET /memory/themes response)." },
770 { "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)." }
771 ],
772 "responses": {
773 "200": {
774 "description": "Cluster snapshot.",
775 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClusterRecord" } } }
776 },
777 "400": { "description": "Bad request (e.g. empty cluster_id).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
778 "404": { "description": "No such cluster.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
779 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
780 }
781 }
782 },
783 "/memory/documents": {
784 "post": {
785 "summary": "Ingest a document",
786 "description":
787 "Equivalent to MCP tool `memory_ingest_document`. Reads the file at the \
788 supplied server-side path, parses + chunks + embeds, and persists under \
789 `documents` + `document_chunks`. Returns the new doc_id, chunk count, and \
790 a `deduped` flag (true when an existing document with the same content_hash \
791 was returned without re-embedding). v0.7.0+.",
792 "security": [{ "bearerAuth": [] }, {}],
793 "requestBody": {
794 "required": true,
795 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestDocumentRequest" } } }
796 },
797 "responses": {
798 "200": {
799 "description": "Document ingested (or deduplicated).",
800 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IngestReport" } } }
801 },
802 "400": { "description": "Bad request (e.g. empty path, file unreadable, parse error).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
803 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
804 }
805 },
806 "get": {
807 "summary": "List ingested documents (paginated)",
808 "description":
809 "Equivalent to MCP tool `memory_list_documents`. Returns a paginated index, \
810 newest first. Forgotten documents are hidden by default; pass \
811 `?include_forgotten=true` to see them too. v0.7.0+.",
812 "security": [{ "bearerAuth": [] }, {}],
813 "parameters": [
814 { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } },
815 { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 0, "default": 0 } },
816 { "name": "include_forgotten", "in": "query", "required": false, "schema": { "type": "boolean", "default": false } }
817 ],
818 "responses": {
819 "200": {
820 "description": "Array of DocumentSummary (possibly empty).",
821 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocumentSummary" } } } }
822 },
823 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
824 }
825 }
826 },
827 "/memory/documents/search": {
828 "post": {
829 "summary": "Vector search across document chunks",
830 "description":
831 "Equivalent to MCP tool `memory_search_docs`. Embeds the query and returns \
832 up to `limit` matching chunks, best match first, each annotated with the \
833 parent document's title + source path. Forgotten documents are excluded. \
834 v0.7.0+.",
835 "security": [{ "bearerAuth": [] }, {}],
836 "requestBody": {
837 "required": true,
838 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchDocsRequest" } } }
839 },
840 "responses": {
841 "200": {
842 "description": "Array of DocSearchHits (possibly empty).",
843 "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/DocSearchHit" } } } }
844 },
845 "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
846 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
847 }
848 }
849 },
850 "/memory/documents/{id}": {
851 "get": {
852 "summary": "Inspect one document",
853 "description":
854 "Equivalent to MCP tool `memory_inspect_document`. Returns the document's \
855 metadata plus a preview of every chunk (truncated to 200 chars). v0.7.0+.",
856 "security": [{ "bearerAuth": [] }, {}],
857 "parameters": [
858 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "DocumentId (UUID v7)." }
859 ],
860 "responses": {
861 "200": {
862 "description": "Document inspection result.",
863 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DocumentInspectResult" } } }
864 },
865 "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
866 "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
867 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
868 }
869 },
870 "delete": {
871 "summary": "Forget (soft-delete) one document",
872 "description":
873 "Equivalent to MCP tool `memory_forget_document`. Flips `documents.status` \
874 to `forgotten` and tombstones every chunk's HNSW rowid. The chunk rows \
875 survive in SQL for forensic value. v0.7.0+.",
876 "security": [{ "bearerAuth": [] }, {}],
877 "parameters": [
878 { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }
879 ],
880 "responses": {
881 "200": {
882 "description": "Document soft-deleted; report counts chunks tombstoned.",
883 "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForgetDocumentReport" } } }
884 },
885 "400": { "description": "Malformed id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
886 "404": { "description": "No such document.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
887 "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
888 }
889 }
890 }
891 }
892 })
893}
894
895#[derive(Debug, Deserialize)]
900struct RememberBody {
901 content: String,
902 #[serde(default)]
903 source_type: Option<String>,
904 #[serde(default)]
905 source_id: Option<String>,
906}
907
908#[derive(Debug, Serialize)]
909struct RememberResponse {
910 memory_id: String,
911}
912
913async fn remember_handler(
914 State(s): State<SoloHttpState>,
915 Json(body): Json<RememberBody>,
916) -> Result<Json<RememberResponse>, ApiError> {
917 let content = body.content.trim_end().to_string();
918 if content.is_empty() {
919 return Err(ApiError::bad_request("content must not be empty"));
920 }
921 let embedding = s.embedder.embed(&content).await.map_err(ApiError::from)?;
922 let episode = Episode {
923 memory_id: MemoryId::new(),
924 ts_ms: chrono::Utc::now().timestamp_millis(),
925 source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
926 source_id: body.source_id,
927 content,
928 encoding_context: EncodingContext::default(),
929 provenance: None,
930 confidence: Confidence::new(0.9).unwrap(),
931 strength: 0.5,
932 salience: 0.5,
933 tier: Tier::Hot,
934 };
935 let mid = s.write.remember(episode, embedding).await.map_err(ApiError::from)?;
936 Ok(Json(RememberResponse {
937 memory_id: mid.to_string(),
938 }))
939}
940
941#[derive(Debug, Deserialize)]
942struct RecallBody {
943 query: String,
944 #[serde(default = "default_limit")]
945 limit: usize,
946}
947
948fn default_limit() -> usize {
949 5
950}
951
952async fn recall_handler(
953 State(s): State<SoloHttpState>,
954 Json(body): Json<RecallBody>,
955) -> Result<Json<solo_query::RecallResult>, ApiError> {
956 let result = solo_query::run_recall(
960 &s.embedder,
961 &s.hnsw,
962 &s.pool,
963 &body.query,
964 body.limit,
965 )
966 .await
967 .map_err(ApiError::from)?;
968 Ok(Json(result))
969}
970
971async fn inspect_handler(
972 State(s): State<SoloHttpState>,
973 Path(id): Path<String>,
974) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
975 let mid = MemoryId::from_str(&id)
976 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
977 let row = solo_query::inspect_one(&s.pool, mid)
978 .await
979 .map_err(ApiError::from)?;
980 Ok(Json(row))
981}
982
983#[derive(Debug, Deserialize)]
990struct ThemesQuery {
991 #[serde(default)]
992 window_days: Option<i64>,
993 #[serde(default = "default_limit")]
994 limit: usize,
995}
996
997async fn themes_handler(
998 State(s): State<SoloHttpState>,
999 Query(q): Query<ThemesQuery>,
1000) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
1001 let hits = solo_query::themes(&s.pool, q.window_days, q.limit)
1002 .await
1003 .map_err(ApiError::from)?;
1004 Ok(Json(hits))
1005}
1006
1007#[derive(Debug, Deserialize)]
1008struct FactsAboutQuery {
1009 subject: String,
1010 #[serde(default)]
1011 predicate: Option<String>,
1012 #[serde(default)]
1013 since_ms: Option<i64>,
1014 #[serde(default)]
1015 until_ms: Option<i64>,
1016 #[serde(default)]
1019 include_as_object: bool,
1020 #[serde(default = "default_limit")]
1021 limit: usize,
1022}
1023
1024async fn facts_about_handler(
1025 State(s): State<SoloHttpState>,
1026 Query(q): Query<FactsAboutQuery>,
1027) -> Result<Json<Vec<solo_query::FactHit>>, ApiError> {
1028 if q.subject.trim().is_empty() {
1029 return Err(ApiError::bad_request("subject must not be empty"));
1030 }
1031 let hits = solo_query::facts_about(
1032 &s.pool,
1033 &q.subject,
1034 &s.user_aliases,
1035 q.include_as_object,
1036 q.predicate.as_deref(),
1037 q.since_ms,
1038 q.until_ms,
1039 q.limit,
1040 )
1041 .await
1042 .map_err(ApiError::from)?;
1043 Ok(Json(hits))
1044}
1045
1046#[derive(Debug, Deserialize)]
1047struct ContradictionsQuery {
1048 #[serde(default = "default_limit")]
1049 limit: usize,
1050}
1051
1052async fn contradictions_handler(
1053 State(s): State<SoloHttpState>,
1054 Query(q): Query<ContradictionsQuery>,
1055) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
1056 let hits = solo_query::contradictions(&s.pool, q.limit)
1057 .await
1058 .map_err(ApiError::from)?;
1059 Ok(Json(hits))
1060}
1061
1062#[derive(Debug, Deserialize, Default)]
1063struct InspectClusterQuery {
1064 #[serde(default)]
1068 full_content: bool,
1069}
1070
1071async fn inspect_cluster_handler(
1072 State(s): State<SoloHttpState>,
1073 Path(cluster_id): Path<String>,
1074 Query(q): Query<InspectClusterQuery>,
1075) -> Result<Json<solo_query::ClusterRecord>, ApiError> {
1076 if cluster_id.trim().is_empty() {
1077 return Err(ApiError::bad_request("cluster_id must not be empty"));
1078 }
1079 let record = solo_query::inspect_cluster(
1084 &s.pool,
1085 &cluster_id,
1086 q.full_content,
1087 )
1088 .await
1089 .map_err(ApiError::from)?;
1090 Ok(Json(record))
1091}
1092
1093#[derive(Debug, Deserialize)]
1098struct IngestDocumentBody {
1099 path: String,
1102}
1103
1104async fn ingest_document_handler(
1105 State(s): State<SoloHttpState>,
1106 Json(body): Json<IngestDocumentBody>,
1107) -> Result<Json<solo_storage::IngestReport>, ApiError> {
1108 if body.path.trim().is_empty() {
1109 return Err(ApiError::bad_request("path must not be empty"));
1110 }
1111 let path = std::path::PathBuf::from(body.path);
1112 let chunk_config = solo_storage::document::ChunkConfig::default();
1116 let report = s
1117 .write
1118 .ingest_document(path, chunk_config)
1119 .await
1120 .map_err(ApiError::from)?;
1121 Ok(Json(report))
1122}
1123
1124#[derive(Debug, Deserialize)]
1125struct SearchDocsBody {
1126 query: String,
1127 #[serde(default = "default_limit")]
1128 limit: usize,
1129}
1130
1131async fn search_docs_handler(
1132 State(s): State<SoloHttpState>,
1133 Json(body): Json<SearchDocsBody>,
1134) -> Result<Json<Vec<solo_query::DocSearchHit>>, ApiError> {
1135 let hits = solo_query::run_doc_search(
1138 &s.embedder,
1139 &s.hnsw,
1140 &s.pool,
1141 &body.query,
1142 body.limit,
1143 )
1144 .await
1145 .map_err(ApiError::from)?;
1146 Ok(Json(hits))
1147}
1148
1149async fn inspect_document_handler(
1150 State(s): State<SoloHttpState>,
1151 Path(id): Path<String>,
1152) -> Result<Json<solo_query::DocumentInspectResult>, ApiError> {
1153 let doc_id = DocumentId::from_str(&id)
1154 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1155 let result_opt = solo_query::inspect_document(&s.pool, &doc_id)
1156 .await
1157 .map_err(ApiError::from)?;
1158 match result_opt {
1159 Some(record) => Ok(Json(record)),
1160 None => Err(ApiError::not_found(format!("document {doc_id} not found"))),
1161 }
1162}
1163
1164#[derive(Debug, Deserialize)]
1165struct ListDocumentsQuery {
1166 #[serde(default = "default_list_documents_limit")]
1167 limit: usize,
1168 #[serde(default)]
1169 offset: usize,
1170 #[serde(default)]
1171 include_forgotten: bool,
1172}
1173
1174fn default_list_documents_limit() -> usize {
1175 20
1176}
1177
1178async fn list_documents_handler(
1179 State(s): State<SoloHttpState>,
1180 Query(q): Query<ListDocumentsQuery>,
1181) -> Result<Json<Vec<solo_query::DocumentSummary>>, ApiError> {
1182 let rows = solo_query::list_documents(&s.pool, q.limit, q.offset, q.include_forgotten)
1183 .await
1184 .map_err(ApiError::from)?;
1185 Ok(Json(rows))
1186}
1187
1188async fn forget_document_handler(
1189 State(s): State<SoloHttpState>,
1190 Path(id): Path<String>,
1191) -> Result<Json<solo_storage::ForgetDocumentReport>, ApiError> {
1192 let doc_id = DocumentId::from_str(&id)
1193 .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1194 let report = s
1195 .write
1196 .forget_document(doc_id)
1197 .await
1198 .map_err(ApiError::from)?;
1199 Ok(Json(report))
1200}
1201
1202#[derive(Debug, Deserialize)]
1203struct ForgetQuery {
1204 #[serde(default)]
1205 reason: Option<String>,
1206}
1207
1208async fn forget_handler(
1209 State(s): State<SoloHttpState>,
1210 Path(id): Path<String>,
1211 Query(q): Query<ForgetQuery>,
1212) -> Result<StatusCode, ApiError> {
1213 let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
1214 let reason = q.reason.unwrap_or_else(|| "http".into());
1215 s.write.forget(mid, reason).await.map_err(ApiError::from)?;
1216 Ok(StatusCode::NO_CONTENT)
1217}
1218
1219async fn consolidate_handler(
1220 State(s): State<SoloHttpState>,
1221 body: axum::body::Bytes,
1222) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
1223 let scope = if body.is_empty() {
1229 solo_storage::ConsolidationScope::default()
1230 } else {
1231 serde_json::from_slice(&body)
1232 .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
1233 };
1234 let report = s.write.consolidate(scope).await.map_err(ApiError::from)?;
1235 Ok(Json(report))
1236}
1237
1238#[derive(Debug, Deserialize)]
1239struct BackupBody {
1240 to: String,
1244 #[serde(default)]
1245 force: bool,
1246}
1247
1248#[derive(Debug, Serialize)]
1249struct BackupResponse {
1250 path: String,
1251 elapsed_ms: u64,
1252}
1253
1254async fn backup_handler(
1255 State(s): State<SoloHttpState>,
1256 Json(body): Json<BackupBody>,
1257) -> Result<Json<BackupResponse>, ApiError> {
1258 use std::path::PathBuf;
1259
1260 let dest = PathBuf::from(&body.to);
1261 if dest.as_os_str().is_empty() {
1262 return Err(ApiError::bad_request("`to` must not be empty"));
1263 }
1264 if solo_storage::paths_refer_to_same_file(&s.source_db_path, &dest) {
1270 return Err(ApiError::bad_request(format!(
1271 "destination {} is the same file as the source database; \
1272 refusing to run (would corrupt the live database)",
1273 dest.display()
1274 )));
1275 }
1276 if dest.exists() {
1277 if !body.force {
1278 return Err(ApiError::bad_request(format!(
1279 "destination {} exists; pass force=true to overwrite",
1280 dest.display()
1281 )));
1282 }
1283 std::fs::remove_file(&dest).map_err(|e| {
1284 ApiError::internal(format!(
1285 "remove existing destination {}: {e}",
1286 dest.display()
1287 ))
1288 })?;
1289 }
1290 if let Some(parent) = dest.parent() {
1291 if !parent.as_os_str().is_empty() && !parent.is_dir() {
1292 return Err(ApiError::bad_request(format!(
1293 "destination parent directory {} does not exist",
1294 parent.display()
1295 )));
1296 }
1297 }
1298
1299 let started = std::time::Instant::now();
1300 s.write.backup(dest.clone()).await.map_err(ApiError::from)?;
1301 let elapsed_ms = started.elapsed().as_millis() as u64;
1302
1303 Ok(Json(BackupResponse {
1304 path: dest.display().to_string(),
1305 elapsed_ms,
1306 }))
1307}
1308
1309#[derive(Debug)]
1314pub struct ApiError {
1315 status: StatusCode,
1316 message: String,
1317}
1318
1319impl ApiError {
1320 fn bad_request(msg: impl Into<String>) -> Self {
1321 Self {
1322 status: StatusCode::BAD_REQUEST,
1323 message: msg.into(),
1324 }
1325 }
1326 fn not_found(msg: impl Into<String>) -> Self {
1327 Self {
1328 status: StatusCode::NOT_FOUND,
1329 message: msg.into(),
1330 }
1331 }
1332 fn internal(msg: impl Into<String>) -> Self {
1333 Self {
1334 status: StatusCode::INTERNAL_SERVER_ERROR,
1335 message: msg.into(),
1336 }
1337 }
1338}
1339
1340impl From<solo_core::Error> for ApiError {
1341 fn from(e: solo_core::Error) -> Self {
1342 use solo_core::Error;
1343 match e {
1344 Error::NotFound(msg) => ApiError::not_found(msg),
1345 Error::InvalidInput(msg) => ApiError::bad_request(msg),
1346 Error::Conflict(msg) => Self {
1347 status: StatusCode::CONFLICT,
1348 message: msg,
1349 },
1350 other => ApiError::internal(other.to_string()),
1351 }
1352 }
1353}
1354
1355impl IntoResponse for ApiError {
1356 fn into_response(self) -> Response {
1357 let body = serde_json::json!({
1358 "error": self.message,
1359 "status": self.status.as_u16(),
1360 });
1361 (self.status, Json(body)).into_response()
1362 }
1363}
1364
1365#[cfg(test)]
1369mod handler_tests {
1370 use super::*;
1379 use axum::body::Body;
1380 use axum::http::{Request, StatusCode};
1381 use http_body_util::BodyExt;
1382 use serde_json::{Value, json};
1383 use solo_storage::test_support::StubVectorIndex;
1384 use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
1385 use std::sync::Arc as StdArc;
1386 use tower::ServiceExt;
1387
1388 struct Harness {
1389 router: axum::Router,
1390 _tmp: tempfile::TempDir,
1391 write_handle_extra: Option<solo_storage::WriteHandle>,
1392 join: Option<std::thread::JoinHandle<()>>,
1393 }
1394
1395 impl Harness {
1396 fn new(runtime: &tokio::runtime::Runtime) -> Self {
1397 Self::new_with_auth(runtime, None)
1398 }
1399
1400 fn new_with_auth(
1401 runtime: &tokio::runtime::Runtime,
1402 bearer_token: Option<String>,
1403 ) -> Self {
1404 use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
1405
1406 let tmp = tempfile::TempDir::new().unwrap();
1407 let dim = 16usize;
1408 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1409 let embedder: StdArc<dyn solo_core::Embedder> =
1410 StdArc::new(StubEmbedder::new("stub", "v1", dim));
1411 let path = tmp.path().join("test.db");
1412
1413 let embedder_id = {
1420 let conn = solo_storage::test_support::open_test_db_at(&path);
1421 get_or_insert_embedder_id(
1422 &conn,
1423 &EmbedderIdentity {
1424 name: "stub".into(),
1425 version: "v1".into(),
1426 dim: dim as u32,
1427 dtype: "f32".into(),
1428 },
1429 )
1430 .unwrap()
1431 };
1432
1433 let conn = solo_storage::test_support::open_test_db_at(&path);
1434 let WriterSpawn { handle, join } = WriterActor::spawn_full(
1435 conn,
1436 hnsw.clone(),
1437 tmp.path().to_path_buf(),
1438 embedder_id,
1439 );
1440 let pool: ReaderPool =
1441 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1442 let state = SoloHttpState {
1443 write: handle.clone(),
1444 pool,
1445 embedder,
1446 hnsw,
1447 source_db_path: path.clone(),
1448 user_aliases: Arc::new(Vec::new()),
1449 };
1450 let router = router_with_auth(state, bearer_token);
1451 Harness {
1452 router,
1453 _tmp: tmp,
1454 write_handle_extra: Some(handle),
1455 join: Some(join),
1456 }
1457 }
1458
1459 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1460 let join = self.join.take();
1461 let extra = self.write_handle_extra.take();
1462 runtime.block_on(async move {
1463 drop(extra);
1464 drop(self.router); drop(self._tmp);
1466 if let Some(join) = join {
1467 let (tx, rx) = std::sync::mpsc::channel();
1468 std::thread::spawn(move || {
1469 let _ = tx.send(join.join());
1470 });
1471 tokio::task::spawn_blocking(move || {
1472 rx.recv_timeout(std::time::Duration::from_secs(5))
1473 })
1474 .await
1475 .expect("blocking task")
1476 .expect("writer thread did not exit within 5s")
1477 .expect("writer thread panicked");
1478 }
1479 });
1480 }
1481 }
1482
1483 fn rt() -> tokio::runtime::Runtime {
1484 tokio::runtime::Builder::new_multi_thread()
1485 .worker_threads(2)
1486 .enable_all()
1487 .build()
1488 .unwrap()
1489 }
1490
1491 async fn call(
1495 router: axum::Router,
1496 method: &str,
1497 uri: &str,
1498 body: Option<Value>,
1499 ) -> (StatusCode, Value) {
1500 call_with_auth(router, method, uri, body, None).await
1501 }
1502
1503 async fn call_with_auth(
1504 router: axum::Router,
1505 method: &str,
1506 uri: &str,
1507 body: Option<Value>,
1508 auth: Option<&str>,
1509 ) -> (StatusCode, Value) {
1510 let mut req_builder = Request::builder()
1511 .method(method)
1512 .uri(uri)
1513 .header("content-type", "application/json");
1514 if let Some(a) = auth {
1515 req_builder = req_builder.header("authorization", a);
1516 }
1517 let req = if let Some(b) = body {
1518 let bytes = serde_json::to_vec(&b).unwrap();
1519 req_builder.body(Body::from(bytes)).unwrap()
1520 } else {
1521 req_builder = req_builder.header("content-length", "0");
1522 req_builder.body(Body::empty()).unwrap()
1523 };
1524 let resp = router.oneshot(req).await.expect("oneshot");
1525 let status = resp.status();
1526 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
1527 let v: Value = if body_bytes.is_empty() {
1528 Value::Null
1529 } else {
1530 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
1531 };
1532 (status, v)
1533 }
1534
1535 #[test]
1536 fn health_returns_ok() {
1537 let runtime = rt();
1538 let h = Harness::new(&runtime);
1539 let r = h.router.clone();
1540 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1541 assert_eq!(status, StatusCode::OK);
1542 h.shutdown(&runtime);
1543 }
1544
1545 #[test]
1550 fn openapi_json_describes_all_endpoints() {
1551 let runtime = rt();
1552 let h = Harness::new(&runtime);
1553 let r = h.router.clone();
1554 let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1555 assert_eq!(status, StatusCode::OK);
1556 assert!(spec.is_object(), "openapi.json must be a JSON object");
1557
1558 assert!(
1560 spec.get("openapi")
1561 .and_then(|v| v.as_str())
1562 .is_some_and(|s| s.starts_with("3.")),
1563 "missing or wrong openapi version: {spec}"
1564 );
1565 assert!(spec.pointer("/info/title").is_some());
1566 assert!(spec.pointer("/info/version").is_some());
1567
1568 let paths = spec
1570 .get("paths")
1571 .and_then(|v| v.as_object())
1572 .expect("paths must be an object");
1573 for expected in [
1574 "/health",
1575 "/openapi.json",
1576 "/memory",
1577 "/memory/search",
1578 "/memory/consolidate",
1579 "/memory/{id}",
1580 "/memory/themes",
1582 "/memory/facts_about",
1583 "/memory/contradictions",
1584 "/memory/clusters/{cluster_id}",
1586 "/memory/documents",
1588 "/memory/documents/search",
1589 "/memory/documents/{id}",
1590 ] {
1591 assert!(
1592 paths.contains_key(expected),
1593 "openapi paths missing {expected}: {paths:?}"
1594 );
1595 }
1596
1597 let docs = paths.get("/memory/documents").expect("/memory/documents");
1600 assert!(docs.get("post").is_some(), "POST /memory/documents undocumented");
1601 assert!(docs.get("get").is_some(), "GET /memory/documents undocumented");
1602
1603 let docid = paths
1606 .get("/memory/documents/{id}")
1607 .expect("/memory/documents/{id}");
1608 assert!(
1609 docid.get("get").is_some(),
1610 "GET /memory/documents/{{id}} undocumented"
1611 );
1612 assert!(
1613 docid.get("delete").is_some(),
1614 "DELETE /memory/documents/{{id}} undocumented"
1615 );
1616
1617 let memid = paths.get("/memory/{id}").expect("memory/{id}");
1620 assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1621 assert!(
1622 memid.get("delete").is_some(),
1623 "DELETE /memory/{{id}} undocumented"
1624 );
1625
1626 for schema_name in [
1628 "RememberRequest",
1629 "RememberResponse",
1630 "RecallRequest",
1631 "RecallResult",
1632 "EpisodeRecord",
1633 "ApiError",
1634 "ConsolidationScope",
1635 "ConsolidationReport",
1636 "ThemeHit",
1638 "FactHit",
1639 "ContradictionHit",
1640 "ClusterRecord",
1642 "IngestDocumentRequest",
1644 "IngestReport",
1645 "ForgetDocumentReport",
1646 "SearchDocsRequest",
1647 "DocSearchHit",
1648 "DocumentInspectResult",
1649 "DocumentSummary",
1650 ] {
1651 let ptr = format!("/components/schemas/{schema_name}");
1652 assert!(
1653 spec.pointer(&ptr).is_some(),
1654 "component schema {schema_name} missing"
1655 );
1656 }
1657
1658 assert!(
1660 spec.pointer("/components/securitySchemes/bearerAuth")
1661 .is_some(),
1662 "bearerAuth security scheme missing"
1663 );
1664
1665 h.shutdown(&runtime);
1666 }
1667
1668 #[test]
1672 fn openapi_json_is_exempt_from_bearer_auth() {
1673 let runtime = rt();
1674 let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1675 let r = h.router.clone();
1676 let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1678 assert_eq!(status, StatusCode::OK);
1679 h.shutdown(&runtime);
1680 }
1681
1682 #[test]
1683 fn remember_returns_memory_id() {
1684 let runtime = rt();
1685 let h = Harness::new(&runtime);
1686 let r = h.router.clone();
1687 let (status, body) = runtime.block_on(call(
1688 r,
1689 "POST",
1690 "/memory",
1691 Some(json!({ "content": "http harness test" })),
1692 ));
1693 assert_eq!(status, StatusCode::OK);
1694 let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1695 assert_eq!(mid.len(), 36, "uuid length");
1696 h.shutdown(&runtime);
1697 }
1698
1699 #[test]
1700 fn empty_content_returns_400() {
1701 let runtime = rt();
1702 let h = Harness::new(&runtime);
1703 let r = h.router.clone();
1704 let (status, body) =
1705 runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1706 assert_eq!(status, StatusCode::BAD_REQUEST);
1707 assert!(
1708 body.get("error")
1709 .and_then(|e| e.as_str())
1710 .map(|s| s.contains("must not be empty"))
1711 .unwrap_or(false),
1712 "got: {body}"
1713 );
1714 h.shutdown(&runtime);
1715 }
1716
1717 #[test]
1718 fn empty_query_returns_400() {
1719 let runtime = rt();
1720 let h = Harness::new(&runtime);
1721 let r = h.router.clone();
1722 let (status, body) = runtime.block_on(call(
1723 r,
1724 "POST",
1725 "/memory/search",
1726 Some(json!({ "query": "" })),
1727 ));
1728 assert_eq!(status, StatusCode::BAD_REQUEST);
1729 assert!(
1730 body.get("error")
1731 .and_then(|e| e.as_str())
1732 .map(|s| s.contains("must not be empty"))
1733 .unwrap_or(false),
1734 "got: {body}"
1735 );
1736 h.shutdown(&runtime);
1737 }
1738
1739 #[test]
1740 fn inspect_unknown_returns_404() {
1741 let runtime = rt();
1742 let h = Harness::new(&runtime);
1743 let r = h.router.clone();
1744 let (status, body) = runtime.block_on(call(
1745 r,
1746 "GET",
1747 "/memory/00000000-0000-7000-8000-000000000000",
1748 None,
1749 ));
1750 assert_eq!(status, StatusCode::NOT_FOUND);
1751 assert!(body.get("error").is_some(), "got: {body}");
1752 h.shutdown(&runtime);
1753 }
1754
1755 #[test]
1756 fn inspect_invalid_id_returns_400() {
1757 let runtime = rt();
1758 let h = Harness::new(&runtime);
1759 let r = h.router.clone();
1760 let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1761 assert_eq!(status, StatusCode::BAD_REQUEST);
1762 h.shutdown(&runtime);
1763 }
1764
1765 #[test]
1766 fn forget_unknown_returns_404() {
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 "DELETE",
1773 "/memory/00000000-0000-7000-8000-000000000000",
1774 None,
1775 ));
1776 assert_eq!(status, StatusCode::NOT_FOUND);
1777 h.shutdown(&runtime);
1778 }
1779
1780 #[test]
1788 fn consolidate_endpoint_returns_report() {
1789 let runtime = rt();
1790 let h = Harness::new(&runtime);
1791 let r = h.router.clone();
1792 runtime.block_on(async move {
1793 let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1795 assert_eq!(status, StatusCode::OK);
1796 for field in [
1797 "episodes_seen",
1798 "clusters_built",
1799 "episodes_clustered",
1800 "abstractions_built",
1801 "triples_built",
1802 "contradictions_found",
1803 ] {
1804 assert!(
1805 body.get(field).and_then(|v| v.as_u64()).is_some(),
1806 "missing field {field}: {body}"
1807 );
1808 }
1809 assert_eq!(body["episodes_seen"], 0);
1810 assert_eq!(body["clusters_built"], 0);
1811
1812 let (status2, _body2) = call(
1815 r,
1816 "POST",
1817 "/memory/consolidate",
1818 Some(json!({ "window_days": 7 })),
1819 )
1820 .await;
1821 assert_eq!(status2, StatusCode::OK);
1822 });
1823 h.shutdown(&runtime);
1824 }
1825
1826 #[test]
1827 fn auth_required_routes_reject_missing_token() {
1828 let runtime = rt();
1829 let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1830 let r = h.router.clone();
1831 runtime.block_on(async move {
1832 let (status, _body) = call(
1834 r.clone(),
1835 "POST",
1836 "/memory",
1837 Some(json!({ "content": "x" })),
1838 )
1839 .await;
1840 assert_eq!(status, StatusCode::UNAUTHORIZED);
1841
1842 let (status, _body) = call_with_auth(
1844 r.clone(),
1845 "POST",
1846 "/memory",
1847 Some(json!({ "content": "x" })),
1848 Some("Bearer wrong-token"),
1849 )
1850 .await;
1851 assert_eq!(status, StatusCode::UNAUTHORIZED);
1852
1853 let (status, body) = call_with_auth(
1855 r.clone(),
1856 "POST",
1857 "/memory",
1858 Some(json!({ "content": "authed" })),
1859 Some("Bearer secret-xyz"),
1860 )
1861 .await;
1862 assert_eq!(status, StatusCode::OK);
1863 assert!(body.get("memory_id").is_some());
1864 });
1865 h.shutdown(&runtime);
1866 }
1867
1868 #[test]
1869 fn health_endpoint_does_not_require_auth() {
1870 let runtime = rt();
1871 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1872 let r = h.router.clone();
1873 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1874 assert_eq!(status, StatusCode::OK);
1876 h.shutdown(&runtime);
1877 }
1878
1879 #[test]
1880 fn auth_response_includes_www_authenticate_header() {
1881 let runtime = rt();
1886 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1887 let r = h.router.clone();
1888 runtime.block_on(async move {
1889 let req = Request::builder()
1890 .method("POST")
1891 .uri("/memory")
1892 .header("content-type", "application/json")
1893 .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
1894 .unwrap();
1895 let resp = r.oneshot(req).await.unwrap();
1896 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1897 let www = resp
1898 .headers()
1899 .get("www-authenticate")
1900 .and_then(|v| v.to_str().ok())
1901 .unwrap_or("");
1902 assert!(
1903 www.starts_with("Bearer"),
1904 "expected WWW-Authenticate: Bearer..., got: {www}"
1905 );
1906 });
1907 h.shutdown(&runtime);
1908 }
1909
1910 #[test]
1911 fn full_remember_recall_inspect_forget_round_trip() {
1912 let runtime = rt();
1913 let h = Harness::new(&runtime);
1914 let r = h.router.clone();
1915 runtime.block_on(async move {
1916 let (status, body) = call(
1918 r.clone(),
1919 "POST",
1920 "/memory",
1921 Some(json!({ "content": "round-trip content" })),
1922 )
1923 .await;
1924 assert_eq!(status, StatusCode::OK);
1925 let mid = body
1926 .get("memory_id")
1927 .and_then(|v| v.as_str())
1928 .unwrap()
1929 .to_string();
1930
1931 let (status, body) = call(
1933 r.clone(),
1934 "POST",
1935 "/memory/search",
1936 Some(json!({ "query": "round-trip content", "limit": 5 })),
1937 )
1938 .await;
1939 assert_eq!(status, StatusCode::OK);
1940 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1941 assert!(
1942 hits.iter()
1943 .any(|h| h.get("content").and_then(|c| c.as_str())
1944 == Some("round-trip content")),
1945 "expected hit with content; got: {body}"
1946 );
1947
1948 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1950 assert_eq!(status, StatusCode::OK);
1951 assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
1952
1953 let (status, _body) =
1955 call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1956 assert_eq!(status, StatusCode::NO_CONTENT);
1957
1958 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1960 assert_eq!(status, StatusCode::OK);
1961 assert_eq!(
1962 body.get("status").and_then(|v| v.as_str()),
1963 Some("forgotten")
1964 );
1965
1966 let (status, body) = call(
1968 r.clone(),
1969 "POST",
1970 "/memory/search",
1971 Some(json!({ "query": "round-trip content", "limit": 5 })),
1972 )
1973 .await;
1974 assert_eq!(status, StatusCode::OK);
1975 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1976 assert!(
1977 hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
1978 != Some(mid.as_str())),
1979 "forgotten row should be excluded from recall: {body}"
1980 );
1981 });
1982 h.shutdown(&runtime);
1983 }
1984
1985 #[test]
1992 fn themes_endpoint_returns_empty_array_on_empty_db() {
1993 let runtime = rt();
1994 let h = Harness::new(&runtime);
1995 let r = h.router.clone();
1996 let (status, body) =
1997 runtime.block_on(call(r, "GET", "/memory/themes", None));
1998 assert_eq!(status, StatusCode::OK);
1999 assert!(body.is_array(), "expected array, got {body}");
2000 assert_eq!(body.as_array().unwrap().len(), 0);
2001 h.shutdown(&runtime);
2002 }
2003
2004 #[test]
2005 fn themes_endpoint_passes_through_query_params() {
2006 let runtime = rt();
2007 let h = Harness::new(&runtime);
2008 let r = h.router.clone();
2009 let (status, body) = runtime.block_on(call(
2010 r,
2011 "GET",
2012 "/memory/themes?window_days=7&limit=20",
2013 None,
2014 ));
2015 assert_eq!(status, StatusCode::OK);
2016 assert!(body.is_array(), "expected array, got {body}");
2017 h.shutdown(&runtime);
2018 }
2019
2020 #[test]
2021 fn facts_about_endpoint_requires_subject() {
2022 let runtime = rt();
2023 let h = Harness::new(&runtime);
2024 let r = h.router.clone();
2025 let (status, _body) =
2029 runtime.block_on(call(r, "GET", "/memory/facts_about", None));
2030 assert!(
2031 status == StatusCode::BAD_REQUEST
2032 || status == StatusCode::UNPROCESSABLE_ENTITY,
2033 "expected 400 or 422 for missing subject, got {status}"
2034 );
2035 h.shutdown(&runtime);
2036 }
2037
2038 #[test]
2039 fn facts_about_endpoint_rejects_blank_subject() {
2040 let runtime = rt();
2041 let h = Harness::new(&runtime);
2042 let r = h.router.clone();
2043 let (status, body) = runtime.block_on(call(
2046 r,
2047 "GET",
2048 "/memory/facts_about?subject=%20%20",
2049 None,
2050 ));
2051 assert_eq!(status, StatusCode::BAD_REQUEST);
2052 assert!(
2053 body.get("error")
2054 .and_then(|v| v.as_str())
2055 .is_some_and(|s| s.contains("subject")),
2056 "expected error mentioning subject, got {body}"
2057 );
2058 h.shutdown(&runtime);
2059 }
2060
2061 #[test]
2062 fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
2063 let runtime = rt();
2064 let h = Harness::new(&runtime);
2065 let r = h.router.clone();
2066 let (status, body) = runtime.block_on(call(
2067 r,
2068 "GET",
2069 "/memory/facts_about?subject=NobodyKnows",
2070 None,
2071 ));
2072 assert_eq!(status, StatusCode::OK);
2073 assert_eq!(body.as_array().unwrap().len(), 0);
2074 h.shutdown(&runtime);
2075 }
2076
2077 #[test]
2078 fn facts_about_endpoint_parses_include_as_object_query_param() {
2079 let runtime = rt();
2087 let h = Harness::new(&runtime);
2088 let r = h.router.clone();
2089 let (status, body) = runtime.block_on(call(
2090 r,
2091 "GET",
2092 "/memory/facts_about?subject=Maya&include_as_object=true",
2093 None,
2094 ));
2095 assert_eq!(
2096 status,
2097 StatusCode::OK,
2098 "expected 200 with include_as_object query param, got {status}"
2099 );
2100 assert!(body.is_array());
2101 h.shutdown(&runtime);
2102 }
2103
2104 #[test]
2105 fn inspect_cluster_endpoint_unknown_id_returns_404() {
2106 let runtime = rt();
2110 let h = Harness::new(&runtime);
2111 let r = h.router.clone();
2112 let (status, body) = runtime.block_on(call(
2113 r,
2114 "GET",
2115 "/memory/clusters/no-such-cluster",
2116 None,
2117 ));
2118 assert_eq!(status, StatusCode::NOT_FOUND);
2119 assert!(
2120 body.get("error")
2121 .and_then(|v| v.as_str())
2122 .is_some_and(|s| s.contains("no-such-cluster")),
2123 "expected error mentioning cluster id, got {body}"
2124 );
2125 h.shutdown(&runtime);
2126 }
2127
2128 #[test]
2129 fn inspect_cluster_endpoint_passes_full_content_query_param() {
2130 let runtime = rt();
2136 let h = Harness::new(&runtime);
2137 let r = h.router.clone();
2138 let (status, _body) = runtime.block_on(call(
2139 r,
2140 "GET",
2141 "/memory/clusters/missing?full_content=true",
2142 None,
2143 ));
2144 assert_eq!(status, StatusCode::NOT_FOUND);
2145 h.shutdown(&runtime);
2146 }
2147
2148 #[test]
2149 fn contradictions_endpoint_returns_empty_array_on_empty_db() {
2150 let runtime = rt();
2151 let h = Harness::new(&runtime);
2152 let r = h.router.clone();
2153 let (status, body) = runtime.block_on(call(
2154 r,
2155 "GET",
2156 "/memory/contradictions",
2157 None,
2158 ));
2159 assert_eq!(status, StatusCode::OK);
2160 assert!(body.is_array());
2161 assert_eq!(body.as_array().unwrap().len(), 0);
2162 h.shutdown(&runtime);
2163 }
2164
2165 #[test]
2166 fn derived_endpoints_require_bearer_when_auth_enabled() {
2167 let runtime = rt();
2168 let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
2169 for path in [
2176 "/memory/themes",
2177 "/memory/facts_about?subject=Sam",
2178 "/memory/contradictions",
2179 "/memory/clusters/any-id",
2180 ] {
2181 let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
2182 assert_eq!(
2183 status,
2184 StatusCode::UNAUTHORIZED,
2185 "{path} should 401 without token"
2186 );
2187 }
2188 h.shutdown(&runtime);
2189 }
2190
2191 #[test]
2203 fn list_documents_endpoint_returns_empty_array_on_empty_db() {
2204 let runtime = rt();
2205 let h = Harness::new(&runtime);
2206 let r = h.router.clone();
2207 let (status, body) = runtime.block_on(call(r, "GET", "/memory/documents", None));
2208 assert_eq!(status, StatusCode::OK);
2209 assert!(body.is_array(), "expected array, got {body}");
2210 assert_eq!(body.as_array().unwrap().len(), 0);
2211 h.shutdown(&runtime);
2212 }
2213
2214 #[test]
2215 fn list_documents_endpoint_parses_query_params() {
2216 let runtime = rt();
2217 let h = Harness::new(&runtime);
2218 let r = h.router.clone();
2219 let (status, body) = runtime.block_on(call(
2220 r,
2221 "GET",
2222 "/memory/documents?limit=5&offset=0&include_forgotten=true",
2223 None,
2224 ));
2225 assert_eq!(status, StatusCode::OK);
2226 assert!(body.is_array());
2227 h.shutdown(&runtime);
2228 }
2229
2230 #[test]
2231 fn ingest_document_endpoint_rejects_empty_path() {
2232 let runtime = rt();
2233 let h = Harness::new(&runtime);
2234 let r = h.router.clone();
2235 let (status, body) = runtime.block_on(call(
2236 r,
2237 "POST",
2238 "/memory/documents",
2239 Some(json!({ "path": "" })),
2240 ));
2241 assert_eq!(status, StatusCode::BAD_REQUEST);
2242 assert!(
2243 body.get("error")
2244 .and_then(|v| v.as_str())
2245 .is_some_and(|s| s.contains("path")),
2246 "expected error mentioning path, got {body}"
2247 );
2248 h.shutdown(&runtime);
2249 }
2250
2251 #[test]
2252 fn search_docs_endpoint_rejects_empty_query() {
2253 let runtime = rt();
2254 let h = Harness::new(&runtime);
2255 let r = h.router.clone();
2256 let (status, body) = runtime.block_on(call(
2257 r,
2258 "POST",
2259 "/memory/documents/search",
2260 Some(json!({ "query": " " })),
2261 ));
2262 assert_eq!(status, StatusCode::BAD_REQUEST);
2263 assert!(
2264 body.get("error")
2265 .and_then(|v| v.as_str())
2266 .is_some_and(|s| s.contains("must not be empty")
2267 || s.contains("doc_search")),
2268 "expected error mentioning empty query, got {body}"
2269 );
2270 h.shutdown(&runtime);
2271 }
2272
2273 #[test]
2274 fn inspect_document_endpoint_unknown_id_returns_404() {
2275 let runtime = rt();
2276 let h = Harness::new(&runtime);
2277 let r = h.router.clone();
2278 let (status, body) = runtime.block_on(call(
2279 r,
2280 "GET",
2281 "/memory/documents/00000000-0000-7000-8000-000000000000",
2282 None,
2283 ));
2284 assert_eq!(status, StatusCode::NOT_FOUND);
2285 assert!(body.get("error").is_some(), "got: {body}");
2286 h.shutdown(&runtime);
2287 }
2288
2289 #[test]
2290 fn inspect_document_endpoint_rejects_malformed_id() {
2291 let runtime = rt();
2292 let h = Harness::new(&runtime);
2293 let r = h.router.clone();
2294 let (status, _body) =
2295 runtime.block_on(call(r, "GET", "/memory/documents/not-a-uuid", None));
2296 assert_eq!(status, StatusCode::BAD_REQUEST);
2297 h.shutdown(&runtime);
2298 }
2299
2300 #[test]
2301 fn forget_document_endpoint_unknown_id_returns_404() {
2302 let runtime = rt();
2305 let h = Harness::new(&runtime);
2306 let r = h.router.clone();
2307 let (status, _body) = runtime.block_on(call(
2308 r,
2309 "DELETE",
2310 "/memory/documents/00000000-0000-7000-8000-000000000000",
2311 None,
2312 ));
2313 assert_eq!(status, StatusCode::NOT_FOUND);
2314 h.shutdown(&runtime);
2315 }
2316
2317 #[test]
2318 fn forget_document_endpoint_rejects_malformed_id() {
2319 let runtime = rt();
2320 let h = Harness::new(&runtime);
2321 let r = h.router.clone();
2322 let (status, _body) =
2323 runtime.block_on(call(r, "DELETE", "/memory/documents/not-a-uuid", None));
2324 assert_eq!(status, StatusCode::BAD_REQUEST);
2325 h.shutdown(&runtime);
2326 }
2327
2328 #[test]
2329 fn document_endpoints_require_bearer_when_auth_enabled() {
2330 let runtime = rt();
2334 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2335 let cases: &[(&str, &str, Option<Value>)] = &[
2336 ("POST", "/memory/documents", Some(json!({ "path": "/x" }))),
2337 ("GET", "/memory/documents", None),
2338 (
2339 "POST",
2340 "/memory/documents/search",
2341 Some(json!({ "query": "x" })),
2342 ),
2343 (
2344 "GET",
2345 "/memory/documents/00000000-0000-7000-8000-000000000000",
2346 None,
2347 ),
2348 (
2349 "DELETE",
2350 "/memory/documents/00000000-0000-7000-8000-000000000000",
2351 None,
2352 ),
2353 ];
2354 for (method, path, body) in cases {
2355 let (status, _) =
2356 runtime.block_on(call(h.router.clone(), method, path, body.clone()));
2357 assert_eq!(
2358 status,
2359 StatusCode::UNAUTHORIZED,
2360 "{method} {path} should 401 without token"
2361 );
2362 }
2363 h.shutdown(&runtime);
2364 }
2365
2366 #[test]
2367 fn document_endpoints_accept_correct_bearer_token() {
2368 let runtime = rt();
2374 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2375 runtime.block_on(async {
2376 let (status, _) = call_with_auth(
2378 h.router.clone(),
2379 "GET",
2380 "/memory/documents",
2381 None,
2382 Some("Bearer doc-secret"),
2383 )
2384 .await;
2385 assert_eq!(status, StatusCode::OK);
2386
2387 let (status, _) = call_with_auth(
2389 h.router.clone(),
2390 "GET",
2391 "/memory/documents/00000000-0000-7000-8000-000000000000",
2392 None,
2393 Some("Bearer doc-secret"),
2394 )
2395 .await;
2396 assert_eq!(status, StatusCode::NOT_FOUND);
2397 });
2398 h.shutdown(&runtime);
2399 }
2400}
2401
2402#[cfg(test)]
2403mod cors_tests {
2404 use super::is_localhost_origin;
2405
2406 #[test]
2407 fn accepts_canonical_localhost_origins() {
2408 assert!(is_localhost_origin("http://localhost"));
2409 assert!(is_localhost_origin("http://localhost:3000"));
2410 assert!(is_localhost_origin("https://localhost:8443"));
2411 assert!(is_localhost_origin("http://127.0.0.1"));
2412 assert!(is_localhost_origin("http://127.0.0.1:5173"));
2413 assert!(is_localhost_origin("http://[::1]"));
2414 assert!(is_localhost_origin("http://[::1]:8080"));
2415 }
2416
2417 #[test]
2418 fn rejects_remote_origins() {
2419 assert!(!is_localhost_origin("http://example.com"));
2420 assert!(!is_localhost_origin("https://malicious.example"));
2421 assert!(!is_localhost_origin("http://192.168.1.5"));
2422 assert!(!is_localhost_origin("http://10.0.0.1"));
2423 }
2424
2425 #[test]
2426 fn rejects_dns_rebinding_tricks() {
2427 assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
2431 assert!(!is_localhost_origin("http://localhost.evil.com"));
2432 assert!(!is_localhost_origin("http://evil.localhost"));
2433 }
2434
2435 #[test]
2436 fn rejects_non_http_schemes() {
2437 assert!(!is_localhost_origin("file:///"));
2438 assert!(!is_localhost_origin("ws://localhost:3000"));
2439 assert!(!is_localhost_origin("javascript:alert(1)"));
2440 }
2441
2442 #[test]
2443 fn rejects_malformed() {
2444 assert!(!is_localhost_origin(""));
2445 assert!(!is_localhost_origin("localhost"));
2446 assert!(!is_localhost_origin("//localhost"));
2447 }
2448}
2449