Skip to main content

solo_api/
http.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! HTTP/JSON transport for Solo. Local-only by default — binds to
4//! `127.0.0.1:<port>` and serves the same operations the MCP server
5//! exposes:
6//!
7//! Episode operations:
8//!   - `POST /memory`                — remember (body: { content, source_type?, source_id? })
9//!   - `POST /memory/search`         — recall  (body: { query, limit? })
10//!   - `GET  /memory/{id}`           — inspect
11//!   - `DELETE /memory/{id}?reason=…` — forget
12//!
13//! Maintenance:
14//!   - `POST /memory/consolidate`    — trigger a consolidation pass
15//!   - `POST /backup`                — encrypted online backup
16//!
17//! Derived-layer (v0.4.0+; queries against the Steward's outputs):
18//!   - `GET  /memory/themes?window_days=N&limit=K`
19//!   - `GET  /memory/facts_about?subject=X&predicate=Y&since_ms=N&until_ms=N&include_as_object=B&limit=K`
20//!   - `GET  /memory/contradictions?limit=K`
21//!   - `GET  /memory/clusters/{cluster_id}?full_content=true` (v0.5.0+)
22//!
23//! Document operations (v0.7.0+):
24//!   - `POST   /memory/documents`               — ingest a file
25//!   - `POST   /memory/documents/search`        — vector search over chunks
26//!   - `GET    /memory/documents`               — paginate documents
27//!   - `GET    /memory/documents/{id}`          — inspect one document
28//!   - `DELETE /memory/documents/{id}`          — soft-delete a document
29//!
30//! There's no auth at this layer. The threat model is local-machine
31//! single-user; binding to `127.0.0.1` keeps the surface off the LAN.
32//! A future commit can add bearer-token auth + LAN binding.
33//!
34//! ## Lifecycle
35//!
36//! `serve_http(addr, server, shutdown)` binds to `addr`, runs axum with
37//! `with_graceful_shutdown(shutdown)`, returns when shutdown fires or
38//! the listener errors. `solo http-serve` invokes this from inside a
39//! `OneShotContext`, so writer + reader pool + lockfile stay live for
40//! the server's lifetime and clean up properly afterwards.
41
42use 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    /// Path to the live source database (`<data-dir>/solo.db`). Used by
67    /// `POST /backup` to refuse a destination that resolves to the
68    /// source file before any destructive `remove_file(dest)` step
69    /// runs. Required field as of v0.3.4 — older callers that
70    /// constructed `SoloHttpState` without this need a fix.
71    pub source_db_path: std::path::PathBuf,
72    /// Read-path aliases for the canonical `"user"` subject. Sourced
73    /// from `solo.config.toml` `[identity] user_aliases`; threaded
74    /// through to `solo_query::facts_about` so a query for `"alex"`
75    /// also surfaces rows historically extracted as `"user"`. Empty
76    /// vec = behave as today. Wrapped in `Arc` so handler `clone()`s
77    /// stay cheap. v0.5.0 Priority 1 sub-step 1C.
78    pub user_aliases: Arc<Vec<String>>,
79}
80
81/// Build the router with optional bearer-token auth.
82///
83/// When `bearer_token` is `Some(t)`, every request except `GET /health`
84/// (the unauthenticated liveness probe) requires
85/// `Authorization: Bearer t`. The /health exemption keeps load
86/// balancers and uptime monitors from needing a credential.
87///
88/// `tower_http::validate_request::ValidateRequestHeaderLayer::bearer`
89/// returns 401 with a WWW-Authenticate header on missing/wrong token.
90pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
91    let cors = build_cors_layer();
92    // Public, always-unauthenticated routes:
93    //   - GET /health: liveness probe (load balancers, uptime monitors).
94    //   - GET /openapi.json: machine-readable API description for client
95    //     codegen + browser-UI tooling (TypeScript / OpenAPI Generator,
96    //     curl-tools, etc.). The spec describes the API shape, not
97    //     secrets — fine to serve unauthenticated even on a LAN-bound
98    //     instance.
99    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        // Path 1 derived-layer endpoints (v0.4.0+). GET-shaped because
110        // these are pure read-only queries; query-string params for
111        // simple filters keep them curl-friendly without a JSON body.
112        .route("/memory/themes", get(themes_handler))
113        .route("/memory/facts_about", get(facts_about_handler))
114        .route("/memory/contradictions", get(contradictions_handler))
115        // v0.5.0 Priority 3: drill into one cluster + abstraction +
116        // episodes. Two-segment path (`/memory/clusters/{id}`) so it
117        // does not shadow the single-segment `/memory/{id}` UUID
118        // inspect route.
119        .route(
120            "/memory/clusters/{cluster_id}",
121            get(inspect_cluster_handler),
122        )
123        // v0.7.0 P6: document operations. Two-segment paths
124        // (`/memory/documents/...`) so they don't shadow the
125        // single-segment `/memory/{id}` episode-inspect route. Order
126        // matters: register the literal `/memory/documents/search`
127        // ahead of `/memory/documents/{id}` so axum's matcher prefers
128        // the literal over the path parameter.
129        .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        // Custom validator (the helper-shaped `::bearer` constructor is
144        // deprecated in tower-http ≥ 0.6.7). Returns 401 with
145        // `WWW-Authenticate: Bearer` on missing or wrong token.
146        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
155/// Convenience wrapper: no auth (loopback-only deployments).
156pub fn router(state: SoloHttpState) -> Router {
157    router_with_auth(state, None)
158}
159
160fn build_cors_layer() -> CorsLayer {
161    // Permissive-localhost CORS: allow any localhost / 127.0.0.1 origin so
162    // browser-based UIs running on a different local port can call the API
163    // without preflight friction. We do NOT use `Any` because that would
164    // allow arbitrary remote origins to talk to our localhost server via
165    // a victim's browser. With bearer-token auth enabled the practical
166    // impact is reduced (the cross-origin attacker still can't supply
167    // the token), but principle of least privilege says refuse anyway.
168    //
169    // When the server is bound to a non-loopback address (auth required),
170    // the same CORS predicate keeps localhost-only browser clients —
171    // suitable for trusted-LAN deployments where the LAN client itself
172    // tunnels through ssh/wireguard back to localhost. Wider CORS for
173    // genuine cross-origin browser use is a future config knob.
174    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/// `tower_http::validate_request::ValidateRequest` impl that accepts
189/// any request whose `Authorization` header equals `Bearer <token>`
190/// (constant-time comparison via `subtle`-style byte equality —
191/// String == is constant-time in Rust for equal-length operands; for
192/// unequal lengths the early-return is fine here because the token
193/// length isn't sensitive). On miss, returns 401 with
194/// `WWW-Authenticate: Bearer realm="solo"`.
195#[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
231/// True if `origin` is `http(s)://localhost[:port]` or
232/// `http(s)://127.0.0.1[:port]` or `http(s)://[::1][:port]` (loopback IPv6).
233/// Anything else (incl. nip.io tricks like `127.0.0.1.nip.io`) is rejected.
234fn 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    // Strip path (shouldn't appear on Origin headers but defend anyway).
243    let host = host.split('/').next().unwrap_or(host);
244    // Strip port.
245    let host = if let Some(idx) = host.rfind(':') {
246        // For [::1]:port, keep the brackets in the host part.
247        if host.starts_with('[') {
248            // Find matching ']'; everything up to and including it is the host.
249            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
261/// Bind + serve. `shutdown` is awaited inside axum's
262/// `with_graceful_shutdown`; resolving it triggers a clean drain.
263/// `bearer_token = None` runs unauthenticated (loopback default);
264/// `Some(t)` requires `Authorization: Bearer t` on every request
265/// except `GET /health`.
266pub 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
285// ---------------------------------------------------------------------------
286// OpenAPI 3.1 spec
287// ---------------------------------------------------------------------------
288
289/// Serve the hand-crafted OpenAPI 3.1 spec at `GET /openapi.json`.
290///
291/// We keep the spec hand-written (rather than deriving via `utoipa`)
292/// for v0.1: 4 simple endpoints, types live across crate boundaries
293/// (`solo_query::RecallResult`, `solo_query::EpisodeRecord`), and a
294/// `utoipa` retrofit would touch every crate. Hand-crafted is one
295/// JSON literal in this file; a smoke test in `handler_tests` parses
296/// the response and asserts the expected paths + components are
297/// present, so drift between spec and code is caught at PR time.
298async fn openapi_handler() -> Json<serde_json::Value> {
299    Json(openapi_spec())
300}
301
302/// Build the OpenAPI 3.1 spec describing Solo's HTTP transport.
303/// Public so the smoke test + future client-codegen tooling can
304/// produce the same document without spinning up the server.
305pub 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// ---------------------------------------------------------------------------
896// Handlers
897// ---------------------------------------------------------------------------
898
899#[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    // solo_query::run_recall handles empty-query rejection (returns
957    // InvalidInput → ApiError::bad_request(400)) and clamps limit
958    // upstream of the embedder call.
959    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// Path 1 derived-layer handlers (v0.4.0+). All three are GET-shaped:
984// pure read-only queries against the Steward's outputs, query-string
985// params for simple filters. Each handler delegates to a single
986// solo_query::derived pipeline and returns the result Vec as JSON.
987// Empty derived layer → 200 with `[]` body (parseable JSON array).
988
989#[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    /// v0.5.1 Priority 8 — widen the query to also match rows where
1017    /// `subject` appears as the object. Default `false`.
1018    #[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    /// Default `false` — episode `content` is truncated to
1065    /// `solo_query::EPISODE_TRUNCATE_CHARS` chars with a trailing `…`.
1066    /// `?full_content=true` returns each episode's content verbatim.
1067    #[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    // `solo_query::inspect_cluster` returns `Err(Error::NotFound)` for
1080    // unknown ids; the `From<solo_core::Error> for ApiError` impl
1081    // maps that to 404. Same path as `inspect_handler`'s unknown
1082    // memory_id behaviour.
1083    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// ---------------------------------------------------------------------------
1094// Document handlers (v0.7.0 P6)
1095// ---------------------------------------------------------------------------
1096
1097#[derive(Debug, Deserialize)]
1098struct IngestDocumentBody {
1099    /// Server-side absolute path to the file. Must be readable by the
1100    /// Solo process. The writer reads, parses, chunks, and embeds.
1101    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    // Defaults match what the daemon uses today (target 500 tokens,
1113    // 50-token overlap). A future commit may thread per-call overrides
1114    // through the body if needed.
1115    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    // `solo_query::run_doc_search` validates empty queries (returns
1136    // InvalidInput → 400 via ApiError::from).
1137    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    // Empty body = default scope (unbounded window). We parse via
1224    // `Bytes` rather than `Option<Json<T>>` because axum's `Json`
1225    // extractor 400s on an empty body when Content-Type is JSON
1226    // (it can't deserialize zero bytes as `T`), and the `Option`
1227    // wrapper doesn't reliably degrade that failure to `None`.
1228    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    /// Server-side absolute path where the backup file should be
1241    /// written. Must be writable by the Solo process. Refuses to
1242    /// overwrite an existing file unless `force = true`.
1243    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    // CRITICAL ORDER: same-file refusal MUST come BEFORE `remove_file`.
1265    // A `force: true` request pointing at the daemon's live `solo.db`
1266    // would otherwise unlink the source on Linux (the writer's open fd
1267    // keeps it accessible until shutdown, but the data dir is empty
1268    // from any new opener's perspective). See v0.3.4 release notes.
1269    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// ---------------------------------------------------------------------------
1310// Error mapping
1311// ---------------------------------------------------------------------------
1312
1313#[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// SQL helper for recall used to live here; consolidated into
1366// solo_query::recall.
1367
1368#[cfg(test)]
1369mod handler_tests {
1370    //! In-process integration tests for the HTTP handler surface. We
1371    //! drive the axum Router directly via `tower::ServiceExt::oneshot`
1372    //! — no real TCP listener needed. Same `Harness`-shape as the MCP
1373    //! tests: real WriterActor + ReaderPool + StubEmbedder + StubVectorIndex.
1374    //!
1375    //! Tests live inline in this module rather than in a `tests/` dir
1376    //! because external integration-test exes triggered Windows UAC
1377    //! ERROR_ELEVATION_REQUIRED on the dev machine.
1378    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            // Register an embedder_id so `handle_consolidate` (and any
1414            // other path that filters on `embeddings.embedder_id`) sees
1415            // a production-shaped writer. Existing handler tests pass
1416            // unchanged because the writer just additionally INSERTs
1417            // an `embeddings` row per remember — no assertions look at
1418            // that table.
1419            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); // drops state → drops pool inside runtime ctx
1465                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    /// Issue one HTTP request through the router and capture status +
1492    /// JSON body. `body` may be `None` for GET/DELETE; `auth` adds an
1493    /// `Authorization` header value verbatim (e.g. `"Bearer xyz"`).
1494    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    /// `GET /openapi.json` returns a parseable OpenAPI 3.x document with
1546    /// the four `memory.*` endpoints + their request/response schemas.
1547    /// Acts as a drift detector: if a future commit adds/removes a route
1548    /// without updating `openapi_spec`, this test fails loudly.
1549    #[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        // Top-level shape per OpenAPI 3.1.
1559        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        // Every route the router serves must be documented.
1569        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            // Path 1 derived-layer endpoints (v0.4.0+):
1581            "/memory/themes",
1582            "/memory/facts_about",
1583            "/memory/contradictions",
1584            // v0.5.0 Priority 3:
1585            "/memory/clusters/{cluster_id}",
1586            // v0.7.0 P6 — document operations:
1587            "/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        // Method coverage on /memory/documents: must document both POST
1598        // (ingest) and GET (list).
1599        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        // Method coverage on /memory/documents/{id}: must document both
1604        // GET (inspect) and DELETE (forget).
1605        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        // Method coverage on /memory/{id}: must document both GET (inspect)
1618        // and DELETE (forget).
1619        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        // Component schemas referenced from paths must be defined.
1627        for schema_name in [
1628            "RememberRequest",
1629            "RememberResponse",
1630            "RecallRequest",
1631            "RecallResult",
1632            "EpisodeRecord",
1633            "ApiError",
1634            "ConsolidationScope",
1635            "ConsolidationReport",
1636            // Path 1 derived-layer schemas (v0.4.0+):
1637            "ThemeHit",
1638            "FactHit",
1639            "ContradictionHit",
1640            // v0.5.0 Priority 3:
1641            "ClusterRecord",
1642            // v0.7.0 P6 — document schemas:
1643            "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        // bearerAuth security scheme is declared (LAN deployments need it).
1659        assert!(
1660            spec.pointer("/components/securitySchemes/bearerAuth")
1661                .is_some(),
1662            "bearerAuth security scheme missing"
1663        );
1664
1665        h.shutdown(&runtime);
1666    }
1667
1668    /// `/openapi.json` must remain unauthenticated even when bearer auth
1669    /// is enabled — the spec describes the API shape, not secrets, and
1670    /// codegen tooling shouldn't need a credential to fetch it.
1671    #[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        // No Authorization header → still 200 for /openapi.json.
1677        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    /// `POST /memory/consolidate` runs the cluster pass and returns
1781    /// the report as JSON. With an empty body, `ConsolidationScope`
1782    /// defaults to unbounded; with a non-empty body, the
1783    /// `window_days` field is honored. The Harness's writer is
1784    /// spawned without a Steward, so `abstractions_built` stays 0
1785    /// even when `clusters_built` is nonzero — same posture as the
1786    /// daemon today.
1787    #[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            // Empty DB → all-zero report; structural assertion only.
1794            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            // Non-empty body with window_days → still 200; unmistakable
1813            // shape round-trips through ConsolidationScope's serde.
1814            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            // No Authorization header → 401.
1833            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            // Wrong token → 401.
1843            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            // Correct token → handler runs (200).
1854            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        // Liveness probes should work without credentials.
1875        assert_eq!(status, StatusCode::OK);
1876        h.shutdown(&runtime);
1877    }
1878
1879    #[test]
1880    fn auth_response_includes_www_authenticate_header() {
1881        // Verify the WWW-Authenticate hint that lets a well-behaved
1882        // client know it's a bearer-auth scheme. We check via raw
1883        // request → response (oneshot returns Response, but our
1884        // call() helper drops the headers; build the request manually).
1885        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            // POST /memory
1917            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            // POST /memory/search — exact-match (StubEmbedder) returns the row.
1932            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            // GET /memory/{id}
1949            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            // DELETE /memory/{id}
1954            let (status, _body) =
1955                call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1956            assert_eq!(status, StatusCode::NO_CONTENT);
1957
1958            // GET again — still readable but status='forgotten'
1959            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            // POST /memory/search — forgotten row excluded.
1967            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    // Path 1 derived-layer endpoint tests (v0.4.0+). Wire-path only —
1986    // the actual content correctness is covered by solo-query::derived's
1987    // own tests (Sub-task A). These verify the HTTP shape: GET routing,
1988    // Query-string param parsing, JSON-array response body, validation
1989    // 400s for invalid inputs.
1990
1991    #[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        // Missing subject — axum's Query extractor 422 (Unprocessable
2026        // Entity) on missing required field; some axum versions
2027        // surface as 400. Accept either.
2028        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        // Whitespace-only subject reaches the handler then trips its
2044        // own validation → ApiError::bad_request → 400.
2045        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        // v0.5.1 P8: `?include_as_object=true` must parse cleanly
2080        // through the `Query<FactsAboutQuery>` extractor. If the
2081        // struct field is missing or wrongly typed, axum returns
2082        // 400/422 before reaching the handler. We don't seed
2083        // triples; we only need the request to reach the handler
2084        // and produce a normal 200 + empty array. Mirrors
2085        // `inspect_cluster_endpoint_passes_full_content_query_param`.
2086        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        // Maps `Error::NotFound` from `solo_query::inspect_cluster`
2107        // through `ApiError::from` → 404. Mirrors the unknown-memory
2108        // case for `GET /memory/{id}`.
2109        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        // Even with no matching cluster (→ 404), the request must
2131        // reach the handler — proves the `?full_content=true` query
2132        // string parses cleanly (Query<InspectClusterQuery>::default
2133        // path didn't choke). If we accidentally fail at the extractor
2134        // we'd get a 400/422, not the expected 404.
2135        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        // Each of the three new endpoints should reject missing token.
2170        // Per the existing tests' shutdown-timing comment: don't hold a
2171        // long-lived router clone across multiple iterations — drop the
2172        // clone before each subsequent oneshot, and don't keep a `let r =
2173        // h.router.clone()` alive across h.shutdown(). Re-clone per
2174        // iteration; the per-call clone is consumed by oneshot.
2175        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    // ---- Document endpoints (v0.7.0 P6) ----
2192    //
2193    // Wire-path coverage. The `Harness` here uses
2194    // `WriterActor::spawn_full` without an embedder — same shape as the
2195    // existing handler tests. Ingest/search would fail at the writer
2196    // boundary with "writer has no embedder", but every other path
2197    // (404s, malformed ids, route shape, bearer auth gating, OpenAPI
2198    // documentation) is exercisable. Real end-to-end ingest→search
2199    // round-trip lives in `mcp_smoke.rs` where a real subprocess runs
2200    // with a fully-wired writer.
2201
2202    #[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        // Valid UUID format; no row exists → writer's `forget_document`
2303        // returns Error::NotFound → mapped to 404 by `ApiError::from`.
2304        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        // All five doc endpoints sit behind the same authed Router and
2331        // must 401 without the bearer token. Mirrors
2332        // `derived_endpoints_require_bearer_when_auth_enabled`.
2333        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        // Sanity check: with the right token, the same five endpoints
2369        // pass auth and reach the handler. We only assert that the
2370        // status code is NOT 401 — exact downstream behaviour depends
2371        // on the harness (no embedder → ingest/search would 500; empty
2372        // DB → list/inspect/forget return 200/404).
2373        let runtime = rt();
2374        let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2375        runtime.block_on(async {
2376            // GET /memory/documents → 200 + empty array (auth passes).
2377            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            // GET /memory/documents/<unknown> → 404 (auth passes).
2388            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        // nip.io and friends — DNS that resolves to 127.0.0.1 but the
2428        // Origin header carries the public-DNS name. Rejecting these
2429        // closes the rebinding-via-Origin gap.
2430        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