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_core::VectorIndex as _;
1384    use solo_storage::test_support::StubVectorIndex;
1385    use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
1386    use std::sync::Arc as StdArc;
1387    use tower::ServiceExt;
1388
1389    struct Harness {
1390        router: axum::Router,
1391        _tmp: tempfile::TempDir,
1392        write_handle_extra: Option<solo_storage::WriteHandle>,
1393        join: Option<std::thread::JoinHandle<()>>,
1394    }
1395
1396    impl Harness {
1397        fn new(runtime: &tokio::runtime::Runtime) -> Self {
1398            Self::new_with_auth(runtime, None)
1399        }
1400
1401        fn new_with_auth(
1402            runtime: &tokio::runtime::Runtime,
1403            bearer_token: Option<String>,
1404        ) -> Self {
1405            use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
1406
1407            let tmp = tempfile::TempDir::new().unwrap();
1408            let dim = 16usize;
1409            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1410            let embedder: StdArc<dyn solo_core::Embedder> =
1411                StdArc::new(StubEmbedder::new("stub", "v1", dim));
1412            let path = tmp.path().join("test.db");
1413
1414            // Register an embedder_id so `handle_consolidate` (and any
1415            // other path that filters on `embeddings.embedder_id`) sees
1416            // a production-shaped writer. Existing handler tests pass
1417            // unchanged because the writer just additionally INSERTs
1418            // an `embeddings` row per remember — no assertions look at
1419            // that table.
1420            let embedder_id = {
1421                let conn = solo_storage::test_support::open_test_db_at(&path);
1422                get_or_insert_embedder_id(
1423                    &conn,
1424                    &EmbedderIdentity {
1425                        name: "stub".into(),
1426                        version: "v1".into(),
1427                        dim: dim as u32,
1428                        dtype: "f32".into(),
1429                    },
1430                )
1431                .unwrap()
1432            };
1433
1434            let conn = solo_storage::test_support::open_test_db_at(&path);
1435            let WriterSpawn { handle, join } = WriterActor::spawn_full(
1436                conn,
1437                hnsw.clone(),
1438                tmp.path().to_path_buf(),
1439                embedder_id,
1440            );
1441            let pool: ReaderPool =
1442                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1443            let state = SoloHttpState {
1444                write: handle.clone(),
1445                pool,
1446                embedder,
1447                hnsw,
1448                source_db_path: path.clone(),
1449                user_aliases: Arc::new(Vec::new()),
1450            };
1451            let router = router_with_auth(state, bearer_token);
1452            Harness {
1453                router,
1454                _tmp: tmp,
1455                write_handle_extra: Some(handle),
1456                join: Some(join),
1457            }
1458        }
1459
1460        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1461            let join = self.join.take();
1462            let extra = self.write_handle_extra.take();
1463            runtime.block_on(async move {
1464                drop(extra);
1465                drop(self.router); // drops state → drops pool inside runtime ctx
1466                drop(self._tmp);
1467                if let Some(join) = join {
1468                    let (tx, rx) = std::sync::mpsc::channel();
1469                    std::thread::spawn(move || {
1470                        let _ = tx.send(join.join());
1471                    });
1472                    tokio::task::spawn_blocking(move || {
1473                        rx.recv_timeout(std::time::Duration::from_secs(5))
1474                    })
1475                    .await
1476                    .expect("blocking task")
1477                    .expect("writer thread did not exit within 5s")
1478                    .expect("writer thread panicked");
1479                }
1480            });
1481        }
1482    }
1483
1484    fn rt() -> tokio::runtime::Runtime {
1485        tokio::runtime::Builder::new_multi_thread()
1486            .worker_threads(2)
1487            .enable_all()
1488            .build()
1489            .unwrap()
1490    }
1491
1492    /// Issue one HTTP request through the router and capture status +
1493    /// JSON body. `body` may be `None` for GET/DELETE; `auth` adds an
1494    /// `Authorization` header value verbatim (e.g. `"Bearer xyz"`).
1495    async fn call(
1496        router: axum::Router,
1497        method: &str,
1498        uri: &str,
1499        body: Option<Value>,
1500    ) -> (StatusCode, Value) {
1501        call_with_auth(router, method, uri, body, None).await
1502    }
1503
1504    async fn call_with_auth(
1505        router: axum::Router,
1506        method: &str,
1507        uri: &str,
1508        body: Option<Value>,
1509        auth: Option<&str>,
1510    ) -> (StatusCode, Value) {
1511        let mut req_builder = Request::builder()
1512            .method(method)
1513            .uri(uri)
1514            .header("content-type", "application/json");
1515        if let Some(a) = auth {
1516            req_builder = req_builder.header("authorization", a);
1517        }
1518        let req = if let Some(b) = body {
1519            let bytes = serde_json::to_vec(&b).unwrap();
1520            req_builder.body(Body::from(bytes)).unwrap()
1521        } else {
1522            req_builder = req_builder.header("content-length", "0");
1523            req_builder.body(Body::empty()).unwrap()
1524        };
1525        let resp = router.oneshot(req).await.expect("oneshot");
1526        let status = resp.status();
1527        let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
1528        let v: Value = if body_bytes.is_empty() {
1529            Value::Null
1530        } else {
1531            serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
1532        };
1533        (status, v)
1534    }
1535
1536    #[test]
1537    fn health_returns_ok() {
1538        let runtime = rt();
1539        let h = Harness::new(&runtime);
1540        let r = h.router.clone();
1541        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1542        assert_eq!(status, StatusCode::OK);
1543        h.shutdown(&runtime);
1544    }
1545
1546    /// `GET /openapi.json` returns a parseable OpenAPI 3.x document with
1547    /// the four `memory.*` endpoints + their request/response schemas.
1548    /// Acts as a drift detector: if a future commit adds/removes a route
1549    /// without updating `openapi_spec`, this test fails loudly.
1550    #[test]
1551    fn openapi_json_describes_all_endpoints() {
1552        let runtime = rt();
1553        let h = Harness::new(&runtime);
1554        let r = h.router.clone();
1555        let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1556        assert_eq!(status, StatusCode::OK);
1557        assert!(spec.is_object(), "openapi.json must be a JSON object");
1558
1559        // Top-level shape per OpenAPI 3.1.
1560        assert!(
1561            spec.get("openapi")
1562                .and_then(|v| v.as_str())
1563                .is_some_and(|s| s.starts_with("3.")),
1564            "missing or wrong openapi version: {spec}"
1565        );
1566        assert!(spec.pointer("/info/title").is_some());
1567        assert!(spec.pointer("/info/version").is_some());
1568
1569        // Every route the router serves must be documented.
1570        let paths = spec
1571            .get("paths")
1572            .and_then(|v| v.as_object())
1573            .expect("paths must be an object");
1574        for expected in [
1575            "/health",
1576            "/openapi.json",
1577            "/memory",
1578            "/memory/search",
1579            "/memory/consolidate",
1580            "/memory/{id}",
1581            // Path 1 derived-layer endpoints (v0.4.0+):
1582            "/memory/themes",
1583            "/memory/facts_about",
1584            "/memory/contradictions",
1585            // v0.5.0 Priority 3:
1586            "/memory/clusters/{cluster_id}",
1587            // v0.7.0 P6 — document operations:
1588            "/memory/documents",
1589            "/memory/documents/search",
1590            "/memory/documents/{id}",
1591        ] {
1592            assert!(
1593                paths.contains_key(expected),
1594                "openapi paths missing {expected}: {paths:?}"
1595            );
1596        }
1597
1598        // Method coverage on /memory/documents: must document both POST
1599        // (ingest) and GET (list).
1600        let docs = paths.get("/memory/documents").expect("/memory/documents");
1601        assert!(docs.get("post").is_some(), "POST /memory/documents undocumented");
1602        assert!(docs.get("get").is_some(), "GET /memory/documents undocumented");
1603
1604        // Method coverage on /memory/documents/{id}: must document both
1605        // GET (inspect) and DELETE (forget).
1606        let docid = paths
1607            .get("/memory/documents/{id}")
1608            .expect("/memory/documents/{id}");
1609        assert!(
1610            docid.get("get").is_some(),
1611            "GET /memory/documents/{{id}} undocumented"
1612        );
1613        assert!(
1614            docid.get("delete").is_some(),
1615            "DELETE /memory/documents/{{id}} undocumented"
1616        );
1617
1618        // Method coverage on /memory/{id}: must document both GET (inspect)
1619        // and DELETE (forget).
1620        let memid = paths.get("/memory/{id}").expect("memory/{id}");
1621        assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1622        assert!(
1623            memid.get("delete").is_some(),
1624            "DELETE /memory/{{id}} undocumented"
1625        );
1626
1627        // Component schemas referenced from paths must be defined.
1628        for schema_name in [
1629            "RememberRequest",
1630            "RememberResponse",
1631            "RecallRequest",
1632            "RecallResult",
1633            "EpisodeRecord",
1634            "ApiError",
1635            "ConsolidationScope",
1636            "ConsolidationReport",
1637            // Path 1 derived-layer schemas (v0.4.0+):
1638            "ThemeHit",
1639            "FactHit",
1640            "ContradictionHit",
1641            // v0.5.0 Priority 3:
1642            "ClusterRecord",
1643            // v0.7.0 P6 — document schemas:
1644            "IngestDocumentRequest",
1645            "IngestReport",
1646            "ForgetDocumentReport",
1647            "SearchDocsRequest",
1648            "DocSearchHit",
1649            "DocumentInspectResult",
1650            "DocumentSummary",
1651        ] {
1652            let ptr = format!("/components/schemas/{schema_name}");
1653            assert!(
1654                spec.pointer(&ptr).is_some(),
1655                "component schema {schema_name} missing"
1656            );
1657        }
1658
1659        // bearerAuth security scheme is declared (LAN deployments need it).
1660        assert!(
1661            spec.pointer("/components/securitySchemes/bearerAuth")
1662                .is_some(),
1663            "bearerAuth security scheme missing"
1664        );
1665
1666        h.shutdown(&runtime);
1667    }
1668
1669    /// `/openapi.json` must remain unauthenticated even when bearer auth
1670    /// is enabled — the spec describes the API shape, not secrets, and
1671    /// codegen tooling shouldn't need a credential to fetch it.
1672    #[test]
1673    fn openapi_json_is_exempt_from_bearer_auth() {
1674        let runtime = rt();
1675        let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1676        let r = h.router.clone();
1677        // No Authorization header → still 200 for /openapi.json.
1678        let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1679        assert_eq!(status, StatusCode::OK);
1680        h.shutdown(&runtime);
1681    }
1682
1683    #[test]
1684    fn remember_returns_memory_id() {
1685        let runtime = rt();
1686        let h = Harness::new(&runtime);
1687        let r = h.router.clone();
1688        let (status, body) = runtime.block_on(call(
1689            r,
1690            "POST",
1691            "/memory",
1692            Some(json!({ "content": "http harness test" })),
1693        ));
1694        assert_eq!(status, StatusCode::OK);
1695        let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1696        assert_eq!(mid.len(), 36, "uuid length");
1697        h.shutdown(&runtime);
1698    }
1699
1700    #[test]
1701    fn empty_content_returns_400() {
1702        let runtime = rt();
1703        let h = Harness::new(&runtime);
1704        let r = h.router.clone();
1705        let (status, body) =
1706            runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1707        assert_eq!(status, StatusCode::BAD_REQUEST);
1708        assert!(
1709            body.get("error")
1710                .and_then(|e| e.as_str())
1711                .map(|s| s.contains("must not be empty"))
1712                .unwrap_or(false),
1713            "got: {body}"
1714        );
1715        h.shutdown(&runtime);
1716    }
1717
1718    #[test]
1719    fn empty_query_returns_400() {
1720        let runtime = rt();
1721        let h = Harness::new(&runtime);
1722        let r = h.router.clone();
1723        let (status, body) = runtime.block_on(call(
1724            r,
1725            "POST",
1726            "/memory/search",
1727            Some(json!({ "query": "" })),
1728        ));
1729        assert_eq!(status, StatusCode::BAD_REQUEST);
1730        assert!(
1731            body.get("error")
1732                .and_then(|e| e.as_str())
1733                .map(|s| s.contains("must not be empty"))
1734                .unwrap_or(false),
1735            "got: {body}"
1736        );
1737        h.shutdown(&runtime);
1738    }
1739
1740    #[test]
1741    fn inspect_unknown_returns_404() {
1742        let runtime = rt();
1743        let h = Harness::new(&runtime);
1744        let r = h.router.clone();
1745        let (status, body) = runtime.block_on(call(
1746            r,
1747            "GET",
1748            "/memory/00000000-0000-7000-8000-000000000000",
1749            None,
1750        ));
1751        assert_eq!(status, StatusCode::NOT_FOUND);
1752        assert!(body.get("error").is_some(), "got: {body}");
1753        h.shutdown(&runtime);
1754    }
1755
1756    #[test]
1757    fn inspect_invalid_id_returns_400() {
1758        let runtime = rt();
1759        let h = Harness::new(&runtime);
1760        let r = h.router.clone();
1761        let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1762        assert_eq!(status, StatusCode::BAD_REQUEST);
1763        h.shutdown(&runtime);
1764    }
1765
1766    #[test]
1767    fn forget_unknown_returns_404() {
1768        let runtime = rt();
1769        let h = Harness::new(&runtime);
1770        let r = h.router.clone();
1771        let (status, _body) = runtime.block_on(call(
1772            r,
1773            "DELETE",
1774            "/memory/00000000-0000-7000-8000-000000000000",
1775            None,
1776        ));
1777        assert_eq!(status, StatusCode::NOT_FOUND);
1778        h.shutdown(&runtime);
1779    }
1780
1781    /// `POST /memory/consolidate` runs the cluster pass and returns
1782    /// the report as JSON. With an empty body, `ConsolidationScope`
1783    /// defaults to unbounded; with a non-empty body, the
1784    /// `window_days` field is honored. The Harness's writer is
1785    /// spawned without a Steward, so `abstractions_built` stays 0
1786    /// even when `clusters_built` is nonzero — same posture as the
1787    /// daemon today.
1788    #[test]
1789    fn consolidate_endpoint_returns_report() {
1790        let runtime = rt();
1791        let h = Harness::new(&runtime);
1792        let r = h.router.clone();
1793        runtime.block_on(async move {
1794            // Empty DB → all-zero report; structural assertion only.
1795            let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1796            assert_eq!(status, StatusCode::OK);
1797            for field in [
1798                "episodes_seen",
1799                "clusters_built",
1800                "episodes_clustered",
1801                "abstractions_built",
1802                "triples_built",
1803                "contradictions_found",
1804            ] {
1805                assert!(
1806                    body.get(field).and_then(|v| v.as_u64()).is_some(),
1807                    "missing field {field}: {body}"
1808                );
1809            }
1810            assert_eq!(body["episodes_seen"], 0);
1811            assert_eq!(body["clusters_built"], 0);
1812
1813            // Non-empty body with window_days → still 200; unmistakable
1814            // shape round-trips through ConsolidationScope's serde.
1815            let (status2, _body2) = call(
1816                r,
1817                "POST",
1818                "/memory/consolidate",
1819                Some(json!({ "window_days": 7 })),
1820            )
1821            .await;
1822            assert_eq!(status2, StatusCode::OK);
1823        });
1824        h.shutdown(&runtime);
1825    }
1826
1827    #[test]
1828    fn auth_required_routes_reject_missing_token() {
1829        let runtime = rt();
1830        let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1831        let r = h.router.clone();
1832        runtime.block_on(async move {
1833            // No Authorization header → 401.
1834            let (status, _body) = call(
1835                r.clone(),
1836                "POST",
1837                "/memory",
1838                Some(json!({ "content": "x" })),
1839            )
1840            .await;
1841            assert_eq!(status, StatusCode::UNAUTHORIZED);
1842
1843            // Wrong token → 401.
1844            let (status, _body) = call_with_auth(
1845                r.clone(),
1846                "POST",
1847                "/memory",
1848                Some(json!({ "content": "x" })),
1849                Some("Bearer wrong-token"),
1850            )
1851            .await;
1852            assert_eq!(status, StatusCode::UNAUTHORIZED);
1853
1854            // Correct token → handler runs (200).
1855            let (status, body) = call_with_auth(
1856                r.clone(),
1857                "POST",
1858                "/memory",
1859                Some(json!({ "content": "authed" })),
1860                Some("Bearer secret-xyz"),
1861            )
1862            .await;
1863            assert_eq!(status, StatusCode::OK);
1864            assert!(body.get("memory_id").is_some());
1865        });
1866        h.shutdown(&runtime);
1867    }
1868
1869    #[test]
1870    fn health_endpoint_does_not_require_auth() {
1871        let runtime = rt();
1872        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1873        let r = h.router.clone();
1874        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1875        // Liveness probes should work without credentials.
1876        assert_eq!(status, StatusCode::OK);
1877        h.shutdown(&runtime);
1878    }
1879
1880    #[test]
1881    fn auth_response_includes_www_authenticate_header() {
1882        // Verify the WWW-Authenticate hint that lets a well-behaved
1883        // client know it's a bearer-auth scheme. We check via raw
1884        // request → response (oneshot returns Response, but our
1885        // call() helper drops the headers; build the request manually).
1886        let runtime = rt();
1887        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1888        let r = h.router.clone();
1889        runtime.block_on(async move {
1890            let req = Request::builder()
1891                .method("POST")
1892                .uri("/memory")
1893                .header("content-type", "application/json")
1894                .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
1895                .unwrap();
1896            let resp = r.oneshot(req).await.unwrap();
1897            assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1898            let www = resp
1899                .headers()
1900                .get("www-authenticate")
1901                .and_then(|v| v.to_str().ok())
1902                .unwrap_or("");
1903            assert!(
1904                www.starts_with("Bearer"),
1905                "expected WWW-Authenticate: Bearer..., got: {www}"
1906            );
1907        });
1908        h.shutdown(&runtime);
1909    }
1910
1911    #[test]
1912    fn full_remember_recall_inspect_forget_round_trip() {
1913        let runtime = rt();
1914        let h = Harness::new(&runtime);
1915        let r = h.router.clone();
1916        runtime.block_on(async move {
1917            // POST /memory
1918            let (status, body) = call(
1919                r.clone(),
1920                "POST",
1921                "/memory",
1922                Some(json!({ "content": "round-trip content" })),
1923            )
1924            .await;
1925            assert_eq!(status, StatusCode::OK);
1926            let mid = body
1927                .get("memory_id")
1928                .and_then(|v| v.as_str())
1929                .unwrap()
1930                .to_string();
1931
1932            // POST /memory/search — exact-match (StubEmbedder) returns the row.
1933            let (status, body) = call(
1934                r.clone(),
1935                "POST",
1936                "/memory/search",
1937                Some(json!({ "query": "round-trip content", "limit": 5 })),
1938            )
1939            .await;
1940            assert_eq!(status, StatusCode::OK);
1941            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1942            assert!(
1943                hits.iter()
1944                    .any(|h| h.get("content").and_then(|c| c.as_str())
1945                        == Some("round-trip content")),
1946                "expected hit with content; got: {body}"
1947            );
1948
1949            // GET /memory/{id}
1950            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1951            assert_eq!(status, StatusCode::OK);
1952            assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
1953
1954            // DELETE /memory/{id}
1955            let (status, _body) =
1956                call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1957            assert_eq!(status, StatusCode::NO_CONTENT);
1958
1959            // GET again — still readable but status='forgotten'
1960            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1961            assert_eq!(status, StatusCode::OK);
1962            assert_eq!(
1963                body.get("status").and_then(|v| v.as_str()),
1964                Some("forgotten")
1965            );
1966
1967            // POST /memory/search — forgotten row excluded.
1968            let (status, body) = call(
1969                r.clone(),
1970                "POST",
1971                "/memory/search",
1972                Some(json!({ "query": "round-trip content", "limit": 5 })),
1973            )
1974            .await;
1975            assert_eq!(status, StatusCode::OK);
1976            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1977            assert!(
1978                hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
1979                    != Some(mid.as_str())),
1980                "forgotten row should be excluded from recall: {body}"
1981            );
1982        });
1983        h.shutdown(&runtime);
1984    }
1985
1986    // Path 1 derived-layer endpoint tests (v0.4.0+). Wire-path only —
1987    // the actual content correctness is covered by solo-query::derived's
1988    // own tests (Sub-task A). These verify the HTTP shape: GET routing,
1989    // Query-string param parsing, JSON-array response body, validation
1990    // 400s for invalid inputs.
1991
1992    #[test]
1993    fn themes_endpoint_returns_empty_array_on_empty_db() {
1994        let runtime = rt();
1995        let h = Harness::new(&runtime);
1996        let r = h.router.clone();
1997        let (status, body) =
1998            runtime.block_on(call(r, "GET", "/memory/themes", None));
1999        assert_eq!(status, StatusCode::OK);
2000        assert!(body.is_array(), "expected array, got {body}");
2001        assert_eq!(body.as_array().unwrap().len(), 0);
2002        h.shutdown(&runtime);
2003    }
2004
2005    #[test]
2006    fn themes_endpoint_passes_through_query_params() {
2007        let runtime = rt();
2008        let h = Harness::new(&runtime);
2009        let r = h.router.clone();
2010        let (status, body) = runtime.block_on(call(
2011            r,
2012            "GET",
2013            "/memory/themes?window_days=7&limit=20",
2014            None,
2015        ));
2016        assert_eq!(status, StatusCode::OK);
2017        assert!(body.is_array(), "expected array, got {body}");
2018        h.shutdown(&runtime);
2019    }
2020
2021    #[test]
2022    fn facts_about_endpoint_requires_subject() {
2023        let runtime = rt();
2024        let h = Harness::new(&runtime);
2025        let r = h.router.clone();
2026        // Missing subject — axum's Query extractor 422 (Unprocessable
2027        // Entity) on missing required field; some axum versions
2028        // surface as 400. Accept either.
2029        let (status, _body) =
2030            runtime.block_on(call(r, "GET", "/memory/facts_about", None));
2031        assert!(
2032            status == StatusCode::BAD_REQUEST
2033                || status == StatusCode::UNPROCESSABLE_ENTITY,
2034            "expected 400 or 422 for missing subject, got {status}"
2035        );
2036        h.shutdown(&runtime);
2037    }
2038
2039    #[test]
2040    fn facts_about_endpoint_rejects_blank_subject() {
2041        let runtime = rt();
2042        let h = Harness::new(&runtime);
2043        let r = h.router.clone();
2044        // Whitespace-only subject reaches the handler then trips its
2045        // own validation → ApiError::bad_request → 400.
2046        let (status, body) = runtime.block_on(call(
2047            r,
2048            "GET",
2049            "/memory/facts_about?subject=%20%20",
2050            None,
2051        ));
2052        assert_eq!(status, StatusCode::BAD_REQUEST);
2053        assert!(
2054            body.get("error")
2055                .and_then(|v| v.as_str())
2056                .is_some_and(|s| s.contains("subject")),
2057            "expected error mentioning subject, got {body}"
2058        );
2059        h.shutdown(&runtime);
2060    }
2061
2062    #[test]
2063    fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
2064        let runtime = rt();
2065        let h = Harness::new(&runtime);
2066        let r = h.router.clone();
2067        let (status, body) = runtime.block_on(call(
2068            r,
2069            "GET",
2070            "/memory/facts_about?subject=NobodyKnows",
2071            None,
2072        ));
2073        assert_eq!(status, StatusCode::OK);
2074        assert_eq!(body.as_array().unwrap().len(), 0);
2075        h.shutdown(&runtime);
2076    }
2077
2078    #[test]
2079    fn facts_about_endpoint_parses_include_as_object_query_param() {
2080        // v0.5.1 P8: `?include_as_object=true` must parse cleanly
2081        // through the `Query<FactsAboutQuery>` extractor. If the
2082        // struct field is missing or wrongly typed, axum returns
2083        // 400/422 before reaching the handler. We don't seed
2084        // triples; we only need the request to reach the handler
2085        // and produce a normal 200 + empty array. Mirrors
2086        // `inspect_cluster_endpoint_passes_full_content_query_param`.
2087        let runtime = rt();
2088        let h = Harness::new(&runtime);
2089        let r = h.router.clone();
2090        let (status, body) = runtime.block_on(call(
2091            r,
2092            "GET",
2093            "/memory/facts_about?subject=Maya&include_as_object=true",
2094            None,
2095        ));
2096        assert_eq!(
2097            status,
2098            StatusCode::OK,
2099            "expected 200 with include_as_object query param, got {status}"
2100        );
2101        assert!(body.is_array());
2102        h.shutdown(&runtime);
2103    }
2104
2105    #[test]
2106    fn inspect_cluster_endpoint_unknown_id_returns_404() {
2107        // Maps `Error::NotFound` from `solo_query::inspect_cluster`
2108        // through `ApiError::from` → 404. Mirrors the unknown-memory
2109        // case for `GET /memory/{id}`.
2110        let runtime = rt();
2111        let h = Harness::new(&runtime);
2112        let r = h.router.clone();
2113        let (status, body) = runtime.block_on(call(
2114            r,
2115            "GET",
2116            "/memory/clusters/no-such-cluster",
2117            None,
2118        ));
2119        assert_eq!(status, StatusCode::NOT_FOUND);
2120        assert!(
2121            body.get("error")
2122                .and_then(|v| v.as_str())
2123                .is_some_and(|s| s.contains("no-such-cluster")),
2124            "expected error mentioning cluster id, got {body}"
2125        );
2126        h.shutdown(&runtime);
2127    }
2128
2129    #[test]
2130    fn inspect_cluster_endpoint_passes_full_content_query_param() {
2131        // Even with no matching cluster (→ 404), the request must
2132        // reach the handler — proves the `?full_content=true` query
2133        // string parses cleanly (Query<InspectClusterQuery>::default
2134        // path didn't choke). If we accidentally fail at the extractor
2135        // we'd get a 400/422, not the expected 404.
2136        let runtime = rt();
2137        let h = Harness::new(&runtime);
2138        let r = h.router.clone();
2139        let (status, _body) = runtime.block_on(call(
2140            r,
2141            "GET",
2142            "/memory/clusters/missing?full_content=true",
2143            None,
2144        ));
2145        assert_eq!(status, StatusCode::NOT_FOUND);
2146        h.shutdown(&runtime);
2147    }
2148
2149    #[test]
2150    fn contradictions_endpoint_returns_empty_array_on_empty_db() {
2151        let runtime = rt();
2152        let h = Harness::new(&runtime);
2153        let r = h.router.clone();
2154        let (status, body) = runtime.block_on(call(
2155            r,
2156            "GET",
2157            "/memory/contradictions",
2158            None,
2159        ));
2160        assert_eq!(status, StatusCode::OK);
2161        assert!(body.is_array());
2162        assert_eq!(body.as_array().unwrap().len(), 0);
2163        h.shutdown(&runtime);
2164    }
2165
2166    #[test]
2167    fn derived_endpoints_require_bearer_when_auth_enabled() {
2168        let runtime = rt();
2169        let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
2170        // Each of the three new endpoints should reject missing token.
2171        // Per the existing tests' shutdown-timing comment: don't hold a
2172        // long-lived router clone across multiple iterations — drop the
2173        // clone before each subsequent oneshot, and don't keep a `let r =
2174        // h.router.clone()` alive across h.shutdown(). Re-clone per
2175        // iteration; the per-call clone is consumed by oneshot.
2176        for path in [
2177            "/memory/themes",
2178            "/memory/facts_about?subject=Sam",
2179            "/memory/contradictions",
2180            "/memory/clusters/any-id",
2181        ] {
2182            let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
2183            assert_eq!(
2184                status,
2185                StatusCode::UNAUTHORIZED,
2186                "{path} should 401 without token"
2187            );
2188        }
2189        h.shutdown(&runtime);
2190    }
2191
2192    // ---- Document endpoints (v0.7.0 P6) ----
2193    //
2194    // Wire-path coverage. The `Harness` here uses
2195    // `WriterActor::spawn_full` without an embedder — same shape as the
2196    // existing handler tests. Ingest/search would fail at the writer
2197    // boundary with "writer has no embedder", but every other path
2198    // (404s, malformed ids, route shape, bearer auth gating, OpenAPI
2199    // documentation) is exercisable. Real end-to-end ingest→search
2200    // round-trip lives in `mcp_smoke.rs` where a real subprocess runs
2201    // with a fully-wired writer.
2202
2203    #[test]
2204    fn list_documents_endpoint_returns_empty_array_on_empty_db() {
2205        let runtime = rt();
2206        let h = Harness::new(&runtime);
2207        let r = h.router.clone();
2208        let (status, body) = runtime.block_on(call(r, "GET", "/memory/documents", None));
2209        assert_eq!(status, StatusCode::OK);
2210        assert!(body.is_array(), "expected array, got {body}");
2211        assert_eq!(body.as_array().unwrap().len(), 0);
2212        h.shutdown(&runtime);
2213    }
2214
2215    #[test]
2216    fn list_documents_endpoint_parses_query_params() {
2217        let runtime = rt();
2218        let h = Harness::new(&runtime);
2219        let r = h.router.clone();
2220        let (status, body) = runtime.block_on(call(
2221            r,
2222            "GET",
2223            "/memory/documents?limit=5&offset=0&include_forgotten=true",
2224            None,
2225        ));
2226        assert_eq!(status, StatusCode::OK);
2227        assert!(body.is_array());
2228        h.shutdown(&runtime);
2229    }
2230
2231    #[test]
2232    fn ingest_document_endpoint_rejects_empty_path() {
2233        let runtime = rt();
2234        let h = Harness::new(&runtime);
2235        let r = h.router.clone();
2236        let (status, body) = runtime.block_on(call(
2237            r,
2238            "POST",
2239            "/memory/documents",
2240            Some(json!({ "path": "" })),
2241        ));
2242        assert_eq!(status, StatusCode::BAD_REQUEST);
2243        assert!(
2244            body.get("error")
2245                .and_then(|v| v.as_str())
2246                .is_some_and(|s| s.contains("path")),
2247            "expected error mentioning path, got {body}"
2248        );
2249        h.shutdown(&runtime);
2250    }
2251
2252    #[test]
2253    fn search_docs_endpoint_rejects_empty_query() {
2254        let runtime = rt();
2255        let h = Harness::new(&runtime);
2256        let r = h.router.clone();
2257        let (status, body) = runtime.block_on(call(
2258            r,
2259            "POST",
2260            "/memory/documents/search",
2261            Some(json!({ "query": "   " })),
2262        ));
2263        assert_eq!(status, StatusCode::BAD_REQUEST);
2264        assert!(
2265            body.get("error")
2266                .and_then(|v| v.as_str())
2267                .is_some_and(|s| s.contains("must not be empty")
2268                    || s.contains("doc_search")),
2269            "expected error mentioning empty query, got {body}"
2270        );
2271        h.shutdown(&runtime);
2272    }
2273
2274    #[test]
2275    fn inspect_document_endpoint_unknown_id_returns_404() {
2276        let runtime = rt();
2277        let h = Harness::new(&runtime);
2278        let r = h.router.clone();
2279        let (status, body) = runtime.block_on(call(
2280            r,
2281            "GET",
2282            "/memory/documents/00000000-0000-7000-8000-000000000000",
2283            None,
2284        ));
2285        assert_eq!(status, StatusCode::NOT_FOUND);
2286        assert!(body.get("error").is_some(), "got: {body}");
2287        h.shutdown(&runtime);
2288    }
2289
2290    #[test]
2291    fn inspect_document_endpoint_rejects_malformed_id() {
2292        let runtime = rt();
2293        let h = Harness::new(&runtime);
2294        let r = h.router.clone();
2295        let (status, _body) =
2296            runtime.block_on(call(r, "GET", "/memory/documents/not-a-uuid", None));
2297        assert_eq!(status, StatusCode::BAD_REQUEST);
2298        h.shutdown(&runtime);
2299    }
2300
2301    #[test]
2302    fn forget_document_endpoint_unknown_id_returns_404() {
2303        // Valid UUID format; no row exists → writer's `forget_document`
2304        // returns Error::NotFound → mapped to 404 by `ApiError::from`.
2305        let runtime = rt();
2306        let h = Harness::new(&runtime);
2307        let r = h.router.clone();
2308        let (status, _body) = runtime.block_on(call(
2309            r,
2310            "DELETE",
2311            "/memory/documents/00000000-0000-7000-8000-000000000000",
2312            None,
2313        ));
2314        assert_eq!(status, StatusCode::NOT_FOUND);
2315        h.shutdown(&runtime);
2316    }
2317
2318    #[test]
2319    fn forget_document_endpoint_rejects_malformed_id() {
2320        let runtime = rt();
2321        let h = Harness::new(&runtime);
2322        let r = h.router.clone();
2323        let (status, _body) =
2324            runtime.block_on(call(r, "DELETE", "/memory/documents/not-a-uuid", None));
2325        assert_eq!(status, StatusCode::BAD_REQUEST);
2326        h.shutdown(&runtime);
2327    }
2328
2329    #[test]
2330    fn document_endpoints_require_bearer_when_auth_enabled() {
2331        // All five doc endpoints sit behind the same authed Router and
2332        // must 401 without the bearer token. Mirrors
2333        // `derived_endpoints_require_bearer_when_auth_enabled`.
2334        let runtime = rt();
2335        let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2336        let cases: &[(&str, &str, Option<Value>)] = &[
2337            ("POST", "/memory/documents", Some(json!({ "path": "/x" }))),
2338            ("GET", "/memory/documents", None),
2339            (
2340                "POST",
2341                "/memory/documents/search",
2342                Some(json!({ "query": "x" })),
2343            ),
2344            (
2345                "GET",
2346                "/memory/documents/00000000-0000-7000-8000-000000000000",
2347                None,
2348            ),
2349            (
2350                "DELETE",
2351                "/memory/documents/00000000-0000-7000-8000-000000000000",
2352                None,
2353            ),
2354        ];
2355        for (method, path, body) in cases {
2356            let (status, _) =
2357                runtime.block_on(call(h.router.clone(), method, path, body.clone()));
2358            assert_eq!(
2359                status,
2360                StatusCode::UNAUTHORIZED,
2361                "{method} {path} should 401 without token"
2362            );
2363        }
2364        h.shutdown(&runtime);
2365    }
2366
2367    #[test]
2368    fn document_endpoints_accept_correct_bearer_token() {
2369        // Sanity check: with the right token, the same five endpoints
2370        // pass auth and reach the handler. We only assert that the
2371        // status code is NOT 401 — exact downstream behaviour depends
2372        // on the harness (no embedder → ingest/search would 500; empty
2373        // DB → list/inspect/forget return 200/404).
2374        let runtime = rt();
2375        let h = Harness::new_with_auth(&runtime, Some("doc-secret".to_string()));
2376        runtime.block_on(async {
2377            // GET /memory/documents → 200 + empty array (auth passes).
2378            let (status, _) = call_with_auth(
2379                h.router.clone(),
2380                "GET",
2381                "/memory/documents",
2382                None,
2383                Some("Bearer doc-secret"),
2384            )
2385            .await;
2386            assert_eq!(status, StatusCode::OK);
2387
2388            // GET /memory/documents/<unknown> → 404 (auth passes).
2389            let (status, _) = call_with_auth(
2390                h.router.clone(),
2391                "GET",
2392                "/memory/documents/00000000-0000-7000-8000-000000000000",
2393                None,
2394                Some("Bearer doc-secret"),
2395            )
2396            .await;
2397            assert_eq!(status, StatusCode::NOT_FOUND);
2398        });
2399        h.shutdown(&runtime);
2400    }
2401}
2402
2403#[cfg(test)]
2404mod cors_tests {
2405    use super::is_localhost_origin;
2406
2407    #[test]
2408    fn accepts_canonical_localhost_origins() {
2409        assert!(is_localhost_origin("http://localhost"));
2410        assert!(is_localhost_origin("http://localhost:3000"));
2411        assert!(is_localhost_origin("https://localhost:8443"));
2412        assert!(is_localhost_origin("http://127.0.0.1"));
2413        assert!(is_localhost_origin("http://127.0.0.1:5173"));
2414        assert!(is_localhost_origin("http://[::1]"));
2415        assert!(is_localhost_origin("http://[::1]:8080"));
2416    }
2417
2418    #[test]
2419    fn rejects_remote_origins() {
2420        assert!(!is_localhost_origin("http://example.com"));
2421        assert!(!is_localhost_origin("https://malicious.example"));
2422        assert!(!is_localhost_origin("http://192.168.1.5"));
2423        assert!(!is_localhost_origin("http://10.0.0.1"));
2424    }
2425
2426    #[test]
2427    fn rejects_dns_rebinding_tricks() {
2428        // nip.io and friends — DNS that resolves to 127.0.0.1 but the
2429        // Origin header carries the public-DNS name. Rejecting these
2430        // closes the rebinding-via-Origin gap.
2431        assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
2432        assert!(!is_localhost_origin("http://localhost.evil.com"));
2433        assert!(!is_localhost_origin("http://evil.localhost"));
2434    }
2435
2436    #[test]
2437    fn rejects_non_http_schemes() {
2438        assert!(!is_localhost_origin("file:///"));
2439        assert!(!is_localhost_origin("ws://localhost:3000"));
2440        assert!(!is_localhost_origin("javascript:alert(1)"));
2441    }
2442
2443    #[test]
2444    fn rejects_malformed() {
2445        assert!(!is_localhost_origin(""));
2446        assert!(!is_localhost_origin("localhost"));
2447        assert!(!is_localhost_origin("//localhost"));
2448    }
2449}
2450