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_core::VectorIndex as _;
1384 use solo_storage::test_support::StubVectorIndex;
1385 use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
1386 use std::sync::Arc as StdArc;
1387 use tower::ServiceExt;
1388
1389 struct Harness {
1390 router: axum::Router,
1391 _tmp: tempfile::TempDir,
1392 write_handle_extra: Option<solo_storage::WriteHandle>,
1393 join: Option<std::thread::JoinHandle<()>>,
1394 }
1395
1396 impl Harness {
1397 fn new(runtime: &tokio::runtime::Runtime) -> Self {
1398 Self::new_with_auth(runtime, None)
1399 }
1400
1401 fn new_with_auth(
1402 runtime: &tokio::runtime::Runtime,
1403 bearer_token: Option<String>,
1404 ) -> Self {
1405 use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
1406
1407 let tmp = tempfile::TempDir::new().unwrap();
1408 let dim = 16usize;
1409 let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1410 let embedder: StdArc<dyn solo_core::Embedder> =
1411 StdArc::new(StubEmbedder::new("stub", "v1", dim));
1412 let path = tmp.path().join("test.db");
1413
1414 let embedder_id = {
1421 let conn = solo_storage::test_support::open_test_db_at(&path);
1422 get_or_insert_embedder_id(
1423 &conn,
1424 &EmbedderIdentity {
1425 name: "stub".into(),
1426 version: "v1".into(),
1427 dim: dim as u32,
1428 dtype: "f32".into(),
1429 },
1430 )
1431 .unwrap()
1432 };
1433
1434 let conn = solo_storage::test_support::open_test_db_at(&path);
1435 let WriterSpawn { handle, join } = WriterActor::spawn_full(
1436 conn,
1437 hnsw.clone(),
1438 tmp.path().to_path_buf(),
1439 embedder_id,
1440 );
1441 let pool: ReaderPool =
1442 runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1443 let state = SoloHttpState {
1444 write: handle.clone(),
1445 pool,
1446 embedder,
1447 hnsw,
1448 source_db_path: path.clone(),
1449 user_aliases: Arc::new(Vec::new()),
1450 };
1451 let router = router_with_auth(state, bearer_token);
1452 Harness {
1453 router,
1454 _tmp: tmp,
1455 write_handle_extra: Some(handle),
1456 join: Some(join),
1457 }
1458 }
1459
1460 fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1461 let join = self.join.take();
1462 let extra = self.write_handle_extra.take();
1463 runtime.block_on(async move {
1464 drop(extra);
1465 drop(self.router); drop(self._tmp);
1467 if let Some(join) = join {
1468 let (tx, rx) = std::sync::mpsc::channel();
1469 std::thread::spawn(move || {
1470 let _ = tx.send(join.join());
1471 });
1472 tokio::task::spawn_blocking(move || {
1473 rx.recv_timeout(std::time::Duration::from_secs(5))
1474 })
1475 .await
1476 .expect("blocking task")
1477 .expect("writer thread did not exit within 5s")
1478 .expect("writer thread panicked");
1479 }
1480 });
1481 }
1482 }
1483
1484 fn rt() -> tokio::runtime::Runtime {
1485 tokio::runtime::Builder::new_multi_thread()
1486 .worker_threads(2)
1487 .enable_all()
1488 .build()
1489 .unwrap()
1490 }
1491
1492 async fn call(
1496 router: axum::Router,
1497 method: &str,
1498 uri: &str,
1499 body: Option<Value>,
1500 ) -> (StatusCode, Value) {
1501 call_with_auth(router, method, uri, body, None).await
1502 }
1503
1504 async fn call_with_auth(
1505 router: axum::Router,
1506 method: &str,
1507 uri: &str,
1508 body: Option<Value>,
1509 auth: Option<&str>,
1510 ) -> (StatusCode, Value) {
1511 let mut req_builder = Request::builder()
1512 .method(method)
1513 .uri(uri)
1514 .header("content-type", "application/json");
1515 if let Some(a) = auth {
1516 req_builder = req_builder.header("authorization", a);
1517 }
1518 let req = if let Some(b) = body {
1519 let bytes = serde_json::to_vec(&b).unwrap();
1520 req_builder.body(Body::from(bytes)).unwrap()
1521 } else {
1522 req_builder = req_builder.header("content-length", "0");
1523 req_builder.body(Body::empty()).unwrap()
1524 };
1525 let resp = router.oneshot(req).await.expect("oneshot");
1526 let status = resp.status();
1527 let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
1528 let v: Value = if body_bytes.is_empty() {
1529 Value::Null
1530 } else {
1531 serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
1532 };
1533 (status, v)
1534 }
1535
1536 #[test]
1537 fn health_returns_ok() {
1538 let runtime = rt();
1539 let h = Harness::new(&runtime);
1540 let r = h.router.clone();
1541 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1542 assert_eq!(status, StatusCode::OK);
1543 h.shutdown(&runtime);
1544 }
1545
1546 #[test]
1551 fn openapi_json_describes_all_endpoints() {
1552 let runtime = rt();
1553 let h = Harness::new(&runtime);
1554 let r = h.router.clone();
1555 let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1556 assert_eq!(status, StatusCode::OK);
1557 assert!(spec.is_object(), "openapi.json must be a JSON object");
1558
1559 assert!(
1561 spec.get("openapi")
1562 .and_then(|v| v.as_str())
1563 .is_some_and(|s| s.starts_with("3.")),
1564 "missing or wrong openapi version: {spec}"
1565 );
1566 assert!(spec.pointer("/info/title").is_some());
1567 assert!(spec.pointer("/info/version").is_some());
1568
1569 let paths = spec
1571 .get("paths")
1572 .and_then(|v| v.as_object())
1573 .expect("paths must be an object");
1574 for expected in [
1575 "/health",
1576 "/openapi.json",
1577 "/memory",
1578 "/memory/search",
1579 "/memory/consolidate",
1580 "/memory/{id}",
1581 "/memory/themes",
1583 "/memory/facts_about",
1584 "/memory/contradictions",
1585 "/memory/clusters/{cluster_id}",
1587 "/memory/documents",
1589 "/memory/documents/search",
1590 "/memory/documents/{id}",
1591 ] {
1592 assert!(
1593 paths.contains_key(expected),
1594 "openapi paths missing {expected}: {paths:?}"
1595 );
1596 }
1597
1598 let docs = paths.get("/memory/documents").expect("/memory/documents");
1601 assert!(docs.get("post").is_some(), "POST /memory/documents undocumented");
1602 assert!(docs.get("get").is_some(), "GET /memory/documents undocumented");
1603
1604 let docid = paths
1607 .get("/memory/documents/{id}")
1608 .expect("/memory/documents/{id}");
1609 assert!(
1610 docid.get("get").is_some(),
1611 "GET /memory/documents/{{id}} undocumented"
1612 );
1613 assert!(
1614 docid.get("delete").is_some(),
1615 "DELETE /memory/documents/{{id}} undocumented"
1616 );
1617
1618 let memid = paths.get("/memory/{id}").expect("memory/{id}");
1621 assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1622 assert!(
1623 memid.get("delete").is_some(),
1624 "DELETE /memory/{{id}} undocumented"
1625 );
1626
1627 for schema_name in [
1629 "RememberRequest",
1630 "RememberResponse",
1631 "RecallRequest",
1632 "RecallResult",
1633 "EpisodeRecord",
1634 "ApiError",
1635 "ConsolidationScope",
1636 "ConsolidationReport",
1637 "ThemeHit",
1639 "FactHit",
1640 "ContradictionHit",
1641 "ClusterRecord",
1643 "IngestDocumentRequest",
1645 "IngestReport",
1646 "ForgetDocumentReport",
1647 "SearchDocsRequest",
1648 "DocSearchHit",
1649 "DocumentInspectResult",
1650 "DocumentSummary",
1651 ] {
1652 let ptr = format!("/components/schemas/{schema_name}");
1653 assert!(
1654 spec.pointer(&ptr).is_some(),
1655 "component schema {schema_name} missing"
1656 );
1657 }
1658
1659 assert!(
1661 spec.pointer("/components/securitySchemes/bearerAuth")
1662 .is_some(),
1663 "bearerAuth security scheme missing"
1664 );
1665
1666 h.shutdown(&runtime);
1667 }
1668
1669 #[test]
1673 fn openapi_json_is_exempt_from_bearer_auth() {
1674 let runtime = rt();
1675 let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1676 let r = h.router.clone();
1677 let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1679 assert_eq!(status, StatusCode::OK);
1680 h.shutdown(&runtime);
1681 }
1682
1683 #[test]
1684 fn remember_returns_memory_id() {
1685 let runtime = rt();
1686 let h = Harness::new(&runtime);
1687 let r = h.router.clone();
1688 let (status, body) = runtime.block_on(call(
1689 r,
1690 "POST",
1691 "/memory",
1692 Some(json!({ "content": "http harness test" })),
1693 ));
1694 assert_eq!(status, StatusCode::OK);
1695 let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1696 assert_eq!(mid.len(), 36, "uuid length");
1697 h.shutdown(&runtime);
1698 }
1699
1700 #[test]
1701 fn empty_content_returns_400() {
1702 let runtime = rt();
1703 let h = Harness::new(&runtime);
1704 let r = h.router.clone();
1705 let (status, body) =
1706 runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1707 assert_eq!(status, StatusCode::BAD_REQUEST);
1708 assert!(
1709 body.get("error")
1710 .and_then(|e| e.as_str())
1711 .map(|s| s.contains("must not be empty"))
1712 .unwrap_or(false),
1713 "got: {body}"
1714 );
1715 h.shutdown(&runtime);
1716 }
1717
1718 #[test]
1719 fn empty_query_returns_400() {
1720 let runtime = rt();
1721 let h = Harness::new(&runtime);
1722 let r = h.router.clone();
1723 let (status, body) = runtime.block_on(call(
1724 r,
1725 "POST",
1726 "/memory/search",
1727 Some(json!({ "query": "" })),
1728 ));
1729 assert_eq!(status, StatusCode::BAD_REQUEST);
1730 assert!(
1731 body.get("error")
1732 .and_then(|e| e.as_str())
1733 .map(|s| s.contains("must not be empty"))
1734 .unwrap_or(false),
1735 "got: {body}"
1736 );
1737 h.shutdown(&runtime);
1738 }
1739
1740 #[test]
1741 fn inspect_unknown_returns_404() {
1742 let runtime = rt();
1743 let h = Harness::new(&runtime);
1744 let r = h.router.clone();
1745 let (status, body) = runtime.block_on(call(
1746 r,
1747 "GET",
1748 "/memory/00000000-0000-7000-8000-000000000000",
1749 None,
1750 ));
1751 assert_eq!(status, StatusCode::NOT_FOUND);
1752 assert!(body.get("error").is_some(), "got: {body}");
1753 h.shutdown(&runtime);
1754 }
1755
1756 #[test]
1757 fn inspect_invalid_id_returns_400() {
1758 let runtime = rt();
1759 let h = Harness::new(&runtime);
1760 let r = h.router.clone();
1761 let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1762 assert_eq!(status, StatusCode::BAD_REQUEST);
1763 h.shutdown(&runtime);
1764 }
1765
1766 #[test]
1767 fn forget_unknown_returns_404() {
1768 let runtime = rt();
1769 let h = Harness::new(&runtime);
1770 let r = h.router.clone();
1771 let (status, _body) = runtime.block_on(call(
1772 r,
1773 "DELETE",
1774 "/memory/00000000-0000-7000-8000-000000000000",
1775 None,
1776 ));
1777 assert_eq!(status, StatusCode::NOT_FOUND);
1778 h.shutdown(&runtime);
1779 }
1780
1781 #[test]
1789 fn consolidate_endpoint_returns_report() {
1790 let runtime = rt();
1791 let h = Harness::new(&runtime);
1792 let r = h.router.clone();
1793 runtime.block_on(async move {
1794 let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1796 assert_eq!(status, StatusCode::OK);
1797 for field in [
1798 "episodes_seen",
1799 "clusters_built",
1800 "episodes_clustered",
1801 "abstractions_built",
1802 "triples_built",
1803 "contradictions_found",
1804 ] {
1805 assert!(
1806 body.get(field).and_then(|v| v.as_u64()).is_some(),
1807 "missing field {field}: {body}"
1808 );
1809 }
1810 assert_eq!(body["episodes_seen"], 0);
1811 assert_eq!(body["clusters_built"], 0);
1812
1813 let (status2, _body2) = call(
1816 r,
1817 "POST",
1818 "/memory/consolidate",
1819 Some(json!({ "window_days": 7 })),
1820 )
1821 .await;
1822 assert_eq!(status2, StatusCode::OK);
1823 });
1824 h.shutdown(&runtime);
1825 }
1826
1827 #[test]
1828 fn auth_required_routes_reject_missing_token() {
1829 let runtime = rt();
1830 let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1831 let r = h.router.clone();
1832 runtime.block_on(async move {
1833 let (status, _body) = call(
1835 r.clone(),
1836 "POST",
1837 "/memory",
1838 Some(json!({ "content": "x" })),
1839 )
1840 .await;
1841 assert_eq!(status, StatusCode::UNAUTHORIZED);
1842
1843 let (status, _body) = call_with_auth(
1845 r.clone(),
1846 "POST",
1847 "/memory",
1848 Some(json!({ "content": "x" })),
1849 Some("Bearer wrong-token"),
1850 )
1851 .await;
1852 assert_eq!(status, StatusCode::UNAUTHORIZED);
1853
1854 let (status, body) = call_with_auth(
1856 r.clone(),
1857 "POST",
1858 "/memory",
1859 Some(json!({ "content": "authed" })),
1860 Some("Bearer secret-xyz"),
1861 )
1862 .await;
1863 assert_eq!(status, StatusCode::OK);
1864 assert!(body.get("memory_id").is_some());
1865 });
1866 h.shutdown(&runtime);
1867 }
1868
1869 #[test]
1870 fn health_endpoint_does_not_require_auth() {
1871 let runtime = rt();
1872 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1873 let r = h.router.clone();
1874 let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1875 assert_eq!(status, StatusCode::OK);
1877 h.shutdown(&runtime);
1878 }
1879
1880 #[test]
1881 fn auth_response_includes_www_authenticate_header() {
1882 let runtime = rt();
1887 let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1888 let r = h.router.clone();
1889 runtime.block_on(async move {
1890 let req = Request::builder()
1891 .method("POST")
1892 .uri("/memory")
1893 .header("content-type", "application/json")
1894 .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
1895 .unwrap();
1896 let resp = r.oneshot(req).await.unwrap();
1897 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1898 let www = resp
1899 .headers()
1900 .get("www-authenticate")
1901 .and_then(|v| v.to_str().ok())
1902 .unwrap_or("");
1903 assert!(
1904 www.starts_with("Bearer"),
1905 "expected WWW-Authenticate: Bearer..., got: {www}"
1906 );
1907 });
1908 h.shutdown(&runtime);
1909 }
1910
1911 #[test]
1912 fn full_remember_recall_inspect_forget_round_trip() {
1913 let runtime = rt();
1914 let h = Harness::new(&runtime);
1915 let r = h.router.clone();
1916 runtime.block_on(async move {
1917 let (status, body) = call(
1919 r.clone(),
1920 "POST",
1921 "/memory",
1922 Some(json!({ "content": "round-trip content" })),
1923 )
1924 .await;
1925 assert_eq!(status, StatusCode::OK);
1926 let mid = body
1927 .get("memory_id")
1928 .and_then(|v| v.as_str())
1929 .unwrap()
1930 .to_string();
1931
1932 let (status, body) = call(
1934 r.clone(),
1935 "POST",
1936 "/memory/search",
1937 Some(json!({ "query": "round-trip content", "limit": 5 })),
1938 )
1939 .await;
1940 assert_eq!(status, StatusCode::OK);
1941 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1942 assert!(
1943 hits.iter()
1944 .any(|h| h.get("content").and_then(|c| c.as_str())
1945 == Some("round-trip content")),
1946 "expected hit with content; got: {body}"
1947 );
1948
1949 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1951 assert_eq!(status, StatusCode::OK);
1952 assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
1953
1954 let (status, _body) =
1956 call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1957 assert_eq!(status, StatusCode::NO_CONTENT);
1958
1959 let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1961 assert_eq!(status, StatusCode::OK);
1962 assert_eq!(
1963 body.get("status").and_then(|v| v.as_str()),
1964 Some("forgotten")
1965 );
1966
1967 let (status, body) = call(
1969 r.clone(),
1970 "POST",
1971 "/memory/search",
1972 Some(json!({ "query": "round-trip content", "limit": 5 })),
1973 )
1974 .await;
1975 assert_eq!(status, StatusCode::OK);
1976 let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1977 assert!(
1978 hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
1979 != Some(mid.as_str())),
1980 "forgotten row should be excluded from recall: {body}"
1981 );
1982 });
1983 h.shutdown(&runtime);
1984 }
1985
1986 #[test]
1993 fn themes_endpoint_returns_empty_array_on_empty_db() {
1994 let runtime = rt();
1995 let h = Harness::new(&runtime);
1996 let r = h.router.clone();
1997 let (status, body) =
1998 runtime.block_on(call(r, "GET", "/memory/themes", None));
1999 assert_eq!(status, StatusCode::OK);
2000 assert!(body.is_array(), "expected array, got {body}");
2001 assert_eq!(body.as_array().unwrap().len(), 0);
2002 h.shutdown(&runtime);
2003 }
2004
2005 #[test]
2006 fn themes_endpoint_passes_through_query_params() {
2007 let runtime = rt();
2008 let h = Harness::new(&runtime);
2009 let r = h.router.clone();
2010 let (status, body) = runtime.block_on(call(
2011 r,
2012 "GET",
2013 "/memory/themes?window_days=7&limit=20",
2014 None,
2015 ));
2016 assert_eq!(status, StatusCode::OK);
2017 assert!(body.is_array(), "expected array, got {body}");
2018 h.shutdown(&runtime);
2019 }
2020
2021 #[test]
2022 fn facts_about_endpoint_requires_subject() {
2023 let runtime = rt();
2024 let h = Harness::new(&runtime);
2025 let r = h.router.clone();
2026 let (status, _body) =
2030 runtime.block_on(call(r, "GET", "/memory/facts_about", None));
2031 assert!(
2032 status == StatusCode::BAD_REQUEST
2033 || status == StatusCode::UNPROCESSABLE_ENTITY,
2034 "expected 400 or 422 for missing subject, got {status}"
2035 );
2036 h.shutdown(&runtime);
2037 }
2038
2039 #[test]
2040 fn facts_about_endpoint_rejects_blank_subject() {
2041 let runtime = rt();
2042 let h = Harness::new(&runtime);
2043 let r = h.router.clone();
2044 let (status, body) = runtime.block_on(call(
2047 r,
2048 "GET",
2049 "/memory/facts_about?subject=%20%20",
2050 None,
2051 ));
2052 assert_eq!(status, StatusCode::BAD_REQUEST);
2053 assert!(
2054 body.get("error")
2055 .and_then(|v| v.as_str())
2056 .is_some_and(|s| s.contains("subject")),
2057 "expected error mentioning subject, got {body}"
2058 );
2059 h.shutdown(&runtime);
2060 }
2061
2062 #[test]
2063 fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
2064 let runtime = rt();
2065 let h = Harness::new(&runtime);
2066 let r = h.router.clone();
2067 let (status, body) = runtime.block_on(call(
2068 r,
2069 "GET",
2070 "/memory/facts_about?subject=NobodyKnows",
2071 None,
2072 ));
2073 assert_eq!(status, StatusCode::OK);
2074 assert_eq!(body.as_array().unwrap().len(), 0);
2075 h.shutdown(&runtime);
2076 }
2077
2078 #[test]
2079 fn facts_about_endpoint_parses_include_as_object_query_param() {
2080 let runtime = rt();
2088 let h = Harness::new(&runtime);
2089 let r = h.router.clone();
2090 let (status, body) = runtime.block_on(call(
2091 r,
2092 "GET",
2093 "/memory/facts_about?subject=Maya&include_as_object=true",
2094 None,
2095 ));
2096 assert_eq!(
2097 status,
2098 StatusCode::OK,
2099 "expected 200 with include_as_object query param, got {status}"
2100 );
2101 assert!(body.is_array());
2102 h.shutdown(&runtime);
2103 }
2104
2105 #[test]
2106 fn inspect_cluster_endpoint_unknown_id_returns_404() {
2107 let runtime = rt();
2111 let h = Harness::new(&runtime);
2112 let r = h.router.clone();
2113 let (status, body) = runtime.block_on(call(
2114 r,
2115 "GET",
2116 "/memory/clusters/no-such-cluster",
2117 None,
2118 ));
2119 assert_eq!(status, StatusCode::NOT_FOUND);
2120 assert!(
2121 body.get("error")
2122 .and_then(|v| v.as_str())
2123 .is_some_and(|s| s.contains("no-such-cluster")),
2124 "expected error mentioning cluster id, got {body}"
2125 );
2126 h.shutdown(&runtime);
2127 }
2128
2129 #[test]
2130 fn inspect_cluster_endpoint_passes_full_content_query_param() {
2131 let runtime = rt();
2137 let h = Harness::new(&runtime);
2138 let r = h.router.clone();
2139 let (status, _body) = runtime.block_on(call(
2140 r,
2141 "GET",
2142 "/memory/clusters/missing?full_content=true",
2143 None,
2144 ));
2145 assert_eq!(status, StatusCode::NOT_FOUND);
2146 h.shutdown(&runtime);
2147 }
2148
2149 #[test]
2150 fn contradictions_endpoint_returns_empty_array_on_empty_db() {
2151 let runtime = rt();
2152 let h = Harness::new(&runtime);
2153 let r = h.router.clone();
2154 let (status, body) = runtime.block_on(call(
2155 r,
2156 "GET",
2157 "/memory/contradictions",
2158 None,
2159 ));
2160 assert_eq!(status, StatusCode::OK);
2161 assert!(body.is_array());
2162 assert_eq!(body.as_array().unwrap().len(), 0);
2163 h.shutdown(&runtime);
2164 }
2165
2166 #[test]
2167 fn derived_endpoints_require_bearer_when_auth_enabled() {
2168 let runtime = rt();
2169 let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
2170 for path in [
2177 "/memory/themes",
2178 "/memory/facts_about?subject=Sam",
2179 "/memory/contradictions",
2180 "/memory/clusters/any-id",
2181 ] {
2182 let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
2183 assert_eq!(
2184 status,
2185 StatusCode::UNAUTHORIZED,
2186 "{path} should 401 without token"
2187 );
2188 }
2189 h.shutdown(&runtime);
2190 }
2191
2192 #[test]
2204 fn list_documents_endpoint_returns_empty_array_on_empty_db() {
2205 let runtime = rt();
2206 let h = Harness::new(&runtime);
2207 let r = h.router.clone();
2208 let (status, body) = runtime.block_on(call(r, "GET", "/memory/documents", None));
2209 assert_eq!(status, StatusCode::OK);
2210 assert!(body.is_array(), "expected array, got {body}");
2211 assert_eq!(body.as_array().unwrap().len(), 0);
2212 h.shutdown(&runtime);
2213 }
2214
2215 #[test]
2216 fn list_documents_endpoint_parses_query_params() {
2217 let runtime = rt();
2218 let h = Harness::new(&runtime);
2219 let r = h.router.clone();
2220 let (status, body) = runtime.block_on(call(
2221 r,
2222 "GET",
2223 "/memory/documents?limit=5&offset=0&include_forgotten=true",
2224 None,
2225 ));
2226 assert_eq!(status, StatusCode::OK);
2227 assert!(body.is_array());
2228 h.shutdown(&runtime);
2229 }
2230
2231 #[test]
2232 fn ingest_document_endpoint_rejects_empty_path() {
2233 let runtime = rt();
2234 let h = Harness::new(&runtime);
2235 let r = h.router.clone();
2236 let (status, body) = runtime.block_on(call(
2237 r,
2238 "POST",
2239 "/memory/documents",
2240 Some(json!({ "path": "" })),
2241 ));
2242 assert_eq!(status, StatusCode::BAD_REQUEST);
2243 assert!(
2244 body.get("error")
2245 .and_then(|v| v.as_str())
2246 .is_some_and(|s| s.contains("path")),
2247 "expected error mentioning path, got {body}"
2248 );
2249 h.shutdown(&runtime);
2250 }
2251
2252 #[test]
2253 fn search_docs_endpoint_rejects_empty_query() {
2254 let runtime = rt();
2255 let h = Harness::new(&runtime);
2256 let r = h.router.clone();
2257 let (status, body) = runtime.block_on(call(
2258 r,
2259 "POST",
2260 "/memory/documents/search",
2261 Some(json!({ "query": " " })),
2262 ));
2263 assert_eq!(status, StatusCode::BAD_REQUEST);
2264 assert!(
2265 body.get("error")
2266 .and_then(|v| v.as_str())
2267 .is_some_and(|s| s.contains("must not be empty")
2268 || s.contains("doc_search")),
2269 "expected error mentioning empty query, got {body}"
2270 );
2271 h.shutdown(&runtime);
2272 }
2273
2274 #[test]
2275 fn inspect_document_endpoint_unknown_id_returns_404() {
2276 let runtime = rt();
2277 let h = Harness::new(&runtime);
2278 let r = h.router.clone();
2279 let (status, body) = runtime.block_on(call(
2280 r,
2281 "GET",
2282 "/memory/documents/00000000-0000-7000-8000-000000000000",
2283 None,
2284 ));
2285 assert_eq!(status, StatusCode::NOT_FOUND);
2286 assert!(body.get("error").is_some(), "got: {body}");
2287 h.shutdown(&runtime);
2288 }
2289
2290 #[test]
2291 fn inspect_document_endpoint_rejects_malformed_id() {
2292 let runtime = rt();
2293 let h = Harness::new(&runtime);
2294 let r = h.router.clone();
2295 let (status, _body) =
2296 runtime.block_on(call(r, "GET", "/memory/documents/not-a-uuid", None));
2297 assert_eq!(status, StatusCode::BAD_REQUEST);
2298 h.shutdown(&runtime);
2299 }
2300
2301 #[test]
2302 fn forget_document_endpoint_unknown_id_returns_404() {
2303 let runtime = rt();
2306 let h = Harness::new(&runtime);
2307 let r = h.router.clone();
2308 let (status, _body) = runtime.block_on(call(
2309 r,
2310 "DELETE",
2311 "/memory/documents/00000000-0000-7000-8000-000000000000",
2312 None,
2313 ));
2314 assert_eq!(status, StatusCode::NOT_FOUND);
2315 h.shutdown(&runtime);
2316 }
2317
2318 #[test]
2319 fn forget_document_endpoint_rejects_malformed_id() {
2320 let runtime = rt();
2321 let h = Harness::new(&runtime);
2322 let r = h.router.clone();
2323 let (status, _body) =
2324 runtime.block_on(call(r, "DELETE", "/memory/documents/not-a-uuid", None));
2325 assert_eq!(status, StatusCode::BAD_REQUEST);
2326 h.shutdown(&runtime);
2327 }
2328
2329 #[test]
2330 fn document_endpoints_require_bearer_when_auth_enabled() {
2331 let runtime = rt();
2335 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2336 let cases: &[(&str, &str, Option<Value>)] = &[
2337 ("POST", "/memory/documents", Some(json!({ "path": "/x" }))),
2338 ("GET", "/memory/documents", None),
2339 (
2340 "POST",
2341 "/memory/documents/search",
2342 Some(json!({ "query": "x" })),
2343 ),
2344 (
2345 "GET",
2346 "/memory/documents/00000000-0000-7000-8000-000000000000",
2347 None,
2348 ),
2349 (
2350 "DELETE",
2351 "/memory/documents/00000000-0000-7000-8000-000000000000",
2352 None,
2353 ),
2354 ];
2355 for (method, path, body) in cases {
2356 let (status, _) =
2357 runtime.block_on(call(h.router.clone(), method, path, body.clone()));
2358 assert_eq!(
2359 status,
2360 StatusCode::UNAUTHORIZED,
2361 "{method} {path} should 401 without token"
2362 );
2363 }
2364 h.shutdown(&runtime);
2365 }
2366
2367 #[test]
2368 fn document_endpoints_accept_correct_bearer_token() {
2369 let runtime = rt();
2375 let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2376 runtime.block_on(async {
2377 let (status, _) = call_with_auth(
2379 h.router.clone(),
2380 "GET",
2381 "/memory/documents",
2382 None,
2383 Some("Bearer doc-secret"),
2384 )
2385 .await;
2386 assert_eq!(status, StatusCode::OK);
2387
2388 let (status, _) = call_with_auth(
2390 h.router.clone(),
2391 "GET",
2392 "/memory/documents/00000000-0000-7000-8000-000000000000",
2393 None,
2394 Some("Bearer doc-secret"),
2395 )
2396 .await;
2397 assert_eq!(status, StatusCode::NOT_FOUND);
2398 });
2399 h.shutdown(&runtime);
2400 }
2401}
2402
2403#[cfg(test)]
2404mod cors_tests {
2405 use super::is_localhost_origin;
2406
2407 #[test]
2408 fn accepts_canonical_localhost_origins() {
2409 assert!(is_localhost_origin("http://localhost"));
2410 assert!(is_localhost_origin("http://localhost:3000"));
2411 assert!(is_localhost_origin("https://localhost:8443"));
2412 assert!(is_localhost_origin("http://127.0.0.1"));
2413 assert!(is_localhost_origin("http://127.0.0.1:5173"));
2414 assert!(is_localhost_origin("http://[::1]"));
2415 assert!(is_localhost_origin("http://[::1]:8080"));
2416 }
2417
2418 #[test]
2419 fn rejects_remote_origins() {
2420 assert!(!is_localhost_origin("http://example.com"));
2421 assert!(!is_localhost_origin("https://malicious.example"));
2422 assert!(!is_localhost_origin("http://192.168.1.5"));
2423 assert!(!is_localhost_origin("http://10.0.0.1"));
2424 }
2425
2426 #[test]
2427 fn rejects_dns_rebinding_tricks() {
2428 assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
2432 assert!(!is_localhost_origin("http://localhost.evil.com"));
2433 assert!(!is_localhost_origin("http://evil.localhost"));
2434 }
2435
2436 #[test]
2437 fn rejects_non_http_schemes() {
2438 assert!(!is_localhost_origin("file:///"));
2439 assert!(!is_localhost_origin("ws://localhost:3000"));
2440 assert!(!is_localhost_origin("javascript:alert(1)"));
2441 }
2442
2443 #[test]
2444 fn rejects_malformed() {
2445 assert!(!is_localhost_origin(""));
2446 assert!(!is_localhost_origin("localhost"));
2447 assert!(!is_localhost_origin("//localhost"));
2448 }
2449}
2450