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&limit=K`
20//!   - `GET  /memory/contradictions?limit=K`
21//!   - `GET  /memory/clusters/{cluster_id}?full_content=true` (v0.5.0+)
22//!
23//! There's no auth at this layer. The threat model is local-machine
24//! single-user; binding to `127.0.0.1` keeps the surface off the LAN.
25//! A future commit can add bearer-token auth + LAN binding.
26//!
27//! ## Lifecycle
28//!
29//! `serve_http(addr, server, shutdown)` binds to `addr`, runs axum with
30//! `with_graceful_shutdown(shutdown)`, returns when shutdown fires or
31//! the listener errors. `solo http-serve` invokes this from inside a
32//! `OneShotContext`, so writer + reader pool + lockfile stay live for
33//! the server's lifetime and clean up properly afterwards.
34
35use std::net::SocketAddr;
36use std::str::FromStr;
37use std::sync::Arc;
38
39use axum::extract::{Path, Query, State};
40use axum::http::{HeaderValue, Method, StatusCode};
41use axum::response::{IntoResponse, Response};
42use axum::routing::{get, post};
43use axum::{Json, Router};
44use serde::{Deserialize, Serialize};
45use solo_core::{
46    Confidence, Embedder, EncodingContext, Episode, MemoryId, Tier, VectorIndex,
47};
48use solo_storage::{ReaderPool, WriteHandle};
49use tower_http::cors::{AllowOrigin, CorsLayer};
50use tower_http::trace::TraceLayer;
51use tower_http::validate_request::{ValidateRequest, ValidateRequestHeaderLayer};
52
53#[derive(Clone)]
54pub struct SoloHttpState {
55    pub write: WriteHandle,
56    pub pool: ReaderPool,
57    pub embedder: Arc<dyn Embedder>,
58    pub hnsw: Arc<dyn VectorIndex + Send + Sync>,
59    /// Path to the live source database (`<data-dir>/solo.db`). Used by
60    /// `POST /backup` to refuse a destination that resolves to the
61    /// source file before any destructive `remove_file(dest)` step
62    /// runs. Required field as of v0.3.4 — older callers that
63    /// constructed `SoloHttpState` without this need a fix.
64    pub source_db_path: std::path::PathBuf,
65    /// Read-path aliases for the canonical `"user"` subject. Sourced
66    /// from `solo.config.toml` `[identity] user_aliases`; threaded
67    /// through to `solo_query::facts_about` so a query for `"alex"`
68    /// also surfaces rows historically extracted as `"user"`. Empty
69    /// vec = behave as today. Wrapped in `Arc` so handler `clone()`s
70    /// stay cheap. v0.5.0 Priority 1 sub-step 1C.
71    pub user_aliases: Arc<Vec<String>>,
72}
73
74/// Build the router with optional bearer-token auth.
75///
76/// When `bearer_token` is `Some(t)`, every request except `GET /health`
77/// (the unauthenticated liveness probe) requires
78/// `Authorization: Bearer t`. The /health exemption keeps load
79/// balancers and uptime monitors from needing a credential.
80///
81/// `tower_http::validate_request::ValidateRequestHeaderLayer::bearer`
82/// returns 401 with a WWW-Authenticate header on missing/wrong token.
83pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
84    let cors = build_cors_layer();
85    // Public, always-unauthenticated routes:
86    //   - GET /health: liveness probe (load balancers, uptime monitors).
87    //   - GET /openapi.json: machine-readable API description for client
88    //     codegen + browser-UI tooling (TypeScript / OpenAPI Generator,
89    //     curl-tools, etc.). The spec describes the API shape, not
90    //     secrets — fine to serve unauthenticated even on a LAN-bound
91    //     instance.
92    let public = Router::new()
93        .route("/health", get(|| async { "ok" }))
94        .route("/openapi.json", get(openapi_handler));
95
96    let mut authed = Router::new()
97        .route("/memory", post(remember_handler))
98        .route("/memory/search", post(recall_handler))
99        .route("/memory/consolidate", post(consolidate_handler))
100        .route("/memory/{id}", get(inspect_handler).delete(forget_handler))
101        .route("/backup", post(backup_handler))
102        // Path 1 derived-layer endpoints (v0.4.0+). GET-shaped because
103        // these are pure read-only queries; query-string params for
104        // simple filters keep them curl-friendly without a JSON body.
105        .route("/memory/themes", get(themes_handler))
106        .route("/memory/facts_about", get(facts_about_handler))
107        .route("/memory/contradictions", get(contradictions_handler))
108        // v0.5.0 Priority 3: drill into one cluster + abstraction +
109        // episodes. Two-segment path (`/memory/clusters/{id}`) so it
110        // does not shadow the single-segment `/memory/{id}` UUID
111        // inspect route.
112        .route(
113            "/memory/clusters/{cluster_id}",
114            get(inspect_cluster_handler),
115        )
116        .with_state(state);
117    if let Some(token) = bearer_token {
118        // Custom validator (the helper-shaped `::bearer` constructor is
119        // deprecated in tower-http ≥ 0.6.7). Returns 401 with
120        // `WWW-Authenticate: Bearer` on missing or wrong token.
121        authed = authed.layer(ValidateRequestHeaderLayer::custom(BearerToken::new(token)));
122    }
123
124    public
125        .merge(authed)
126        .layer(cors)
127        .layer(TraceLayer::new_for_http())
128}
129
130/// Convenience wrapper: no auth (loopback-only deployments).
131pub fn router(state: SoloHttpState) -> Router {
132    router_with_auth(state, None)
133}
134
135fn build_cors_layer() -> CorsLayer {
136    // Permissive-localhost CORS: allow any localhost / 127.0.0.1 origin so
137    // browser-based UIs running on a different local port can call the API
138    // without preflight friction. We do NOT use `Any` because that would
139    // allow arbitrary remote origins to talk to our localhost server via
140    // a victim's browser. With bearer-token auth enabled the practical
141    // impact is reduced (the cross-origin attacker still can't supply
142    // the token), but principle of least privilege says refuse anyway.
143    //
144    // When the server is bound to a non-loopback address (auth required),
145    // the same CORS predicate keeps localhost-only browser clients —
146    // suitable for trusted-LAN deployments where the LAN client itself
147    // tunnels through ssh/wireguard back to localhost. Wider CORS for
148    // genuine cross-origin browser use is a future config knob.
149    CorsLayer::new()
150        .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
151            origin
152                .to_str()
153                .map(is_localhost_origin)
154                .unwrap_or(false)
155        }))
156        .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
157        .allow_headers([
158            axum::http::header::CONTENT_TYPE,
159            axum::http::header::AUTHORIZATION,
160        ])
161}
162
163/// `tower_http::validate_request::ValidateRequest` impl that accepts
164/// any request whose `Authorization` header equals `Bearer <token>`
165/// (constant-time comparison via `subtle`-style byte equality —
166/// String == is constant-time in Rust for equal-length operands; for
167/// unequal lengths the early-return is fine here because the token
168/// length isn't sensitive). On miss, returns 401 with
169/// `WWW-Authenticate: Bearer realm="solo"`.
170#[derive(Clone)]
171struct BearerToken {
172    expected: HeaderValue,
173}
174
175impl BearerToken {
176    fn new(token: String) -> Self {
177        let expected = HeaderValue::try_from(format!("Bearer {token}"))
178            .expect("bearer token must be a valid HTTP header value");
179        Self { expected }
180    }
181}
182
183impl<B> ValidateRequest<B> for BearerToken {
184    type ResponseBody = axum::body::Body;
185
186    fn validate(
187        &mut self,
188        request: &mut axum::http::Request<B>,
189    ) -> Result<(), axum::http::Response<Self::ResponseBody>> {
190        let got = request.headers().get(axum::http::header::AUTHORIZATION);
191        match got {
192            Some(value) if value == &self.expected => Ok(()),
193            _ => {
194                let mut resp = axum::http::Response::new(axum::body::Body::empty());
195                *resp.status_mut() = StatusCode::UNAUTHORIZED;
196                resp.headers_mut().insert(
197                    axum::http::header::WWW_AUTHENTICATE,
198                    HeaderValue::from_static(r#"Bearer realm="solo""#),
199                );
200                Err(resp)
201            }
202        }
203    }
204}
205
206/// True if `origin` is `http(s)://localhost[:port]` or
207/// `http(s)://127.0.0.1[:port]` or `http(s)://[::1][:port]` (loopback IPv6).
208/// Anything else (incl. nip.io tricks like `127.0.0.1.nip.io`) is rejected.
209fn is_localhost_origin(origin: &str) -> bool {
210    let rest = origin
211        .strip_prefix("http://")
212        .or_else(|| origin.strip_prefix("https://"));
213    let host = match rest {
214        Some(r) => r,
215        None => return false,
216    };
217    // Strip path (shouldn't appear on Origin headers but defend anyway).
218    let host = host.split('/').next().unwrap_or(host);
219    // Strip port.
220    let host = if let Some(idx) = host.rfind(':') {
221        // For [::1]:port, keep the brackets in the host part.
222        if host.starts_with('[') {
223            // Find matching ']'; everything up to and including it is the host.
224            host.find(']')
225                .map(|i| &host[..=i])
226                .unwrap_or(host)
227        } else {
228            &host[..idx]
229        }
230    } else {
231        host
232    };
233    matches!(host, "localhost" | "127.0.0.1" | "[::1]")
234}
235
236/// Bind + serve. `shutdown` is awaited inside axum's
237/// `with_graceful_shutdown`; resolving it triggers a clean drain.
238/// `bearer_token = None` runs unauthenticated (loopback default);
239/// `Some(t)` requires `Authorization: Bearer t` on every request
240/// except `GET /health`.
241pub async fn serve_http(
242    addr: SocketAddr,
243    state: SoloHttpState,
244    bearer_token: Option<String>,
245    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
246) -> std::io::Result<()> {
247    let auth_kind = if bearer_token.is_some() {
248        "bearer"
249    } else {
250        "none"
251    };
252    let app = router_with_auth(state, bearer_token);
253    let listener = tokio::net::TcpListener::bind(addr).await?;
254    tracing::info!(%addr, auth = auth_kind, "solo http: listening");
255    axum::serve(listener, app)
256        .with_graceful_shutdown(shutdown)
257        .await
258}
259
260// ---------------------------------------------------------------------------
261// OpenAPI 3.1 spec
262// ---------------------------------------------------------------------------
263
264/// Serve the hand-crafted OpenAPI 3.1 spec at `GET /openapi.json`.
265///
266/// We keep the spec hand-written (rather than deriving via `utoipa`)
267/// for v0.1: 4 simple endpoints, types live across crate boundaries
268/// (`solo_query::RecallResult`, `solo_query::EpisodeRecord`), and a
269/// `utoipa` retrofit would touch every crate. Hand-crafted is one
270/// JSON literal in this file; a smoke test in `handler_tests` parses
271/// the response and asserts the expected paths + components are
272/// present, so drift between spec and code is caught at PR time.
273async fn openapi_handler() -> Json<serde_json::Value> {
274    Json(openapi_spec())
275}
276
277/// Build the OpenAPI 3.1 spec describing Solo's HTTP transport.
278/// Public so the smoke test + future client-codegen tooling can
279/// produce the same document without spinning up the server.
280pub fn openapi_spec() -> serde_json::Value {
281    serde_json::json!({
282        "openapi": "3.1.0",
283        "info": {
284            "title": "Solo HTTP API",
285            "description":
286                "Local-first personal memory daemon. The HTTP transport \
287                 mirrors the four MCP tools (memory_remember / recall / \
288                 inspect / forget). Default deployment is loopback-only \
289                 (127.0.0.1); LAN-bound deployments require a bearer \
290                 token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
291            "version": env!("CARGO_PKG_VERSION"),
292            "license": { "name": "Apache-2.0" }
293        },
294        "servers": [
295            { "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
296        ],
297        "components": {
298            "securitySchemes": {
299                "bearerAuth": {
300                    "type": "http",
301                    "scheme": "bearer",
302                    "description":
303                        "Bearer-token auth. Required only on LAN-bound deployments \
304                         (`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
305                         the default `127.0.0.1` deployment is unauthenticated. \
306                         `GET /health` and `GET /openapi.json` are exempt from auth even \
307                         on bearer-protected instances."
308                }
309            },
310            "schemas": {
311                "RememberRequest": {
312                    "type": "object",
313                    "required": ["content"],
314                    "properties": {
315                        "content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
316                        "source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
317                        "source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
318                    },
319                    "additionalProperties": false
320                },
321                "RememberResponse": {
322                    "type": "object",
323                    "required": ["memory_id"],
324                    "properties": {
325                        "memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
326                    }
327                },
328                "RecallRequest": {
329                    "type": "object",
330                    "required": ["query"],
331                    "properties": {
332                        "query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
333                        "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
334                    },
335                    "additionalProperties": false
336                },
337                "RecallResult": {
338                    "type": "object",
339                    "description":
340                        "Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
341                         see `solo_query::RecallResult` in the source for the canonical shape. \
342                         Treat as a forward-compatible JSON object.",
343                    "additionalProperties": true
344                },
345                "ConsolidationScope": {
346                    "type": "object",
347                    "description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
348                    "properties": {
349                        "window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." },
350                        "force_merge": { "type": "boolean", "default": false, "description": "Run the existing-vs-existing merge + abstraction-regen passes even with zero unclustered candidates. Drift catch-up on quiet corpora. Added in 0.3.1." }
351                    },
352                    "additionalProperties": false
353                },
354                "ConsolidationReport": {
355                    "type": "object",
356                    "required": [
357                        "episodes_seen", "clusters_built", "clusters_merged",
358                        "clusters_absorbed", "existing_clusters_merged",
359                        "episodes_clustered", "abstractions_built",
360                        "abstractions_regenerated", "triples_built",
361                        "contradictions_found"
362                    ],
363                    "properties": {
364                        "episodes_seen":             { "type": "integer", "minimum": 0 },
365                        "clusters_built":            { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
366                        "clusters_merged":           { "type": "integer", "minimum": 0, "description": "In-run merge: clusters absorbed into a sibling within this consolidate run (cross-UTC-bucket case). Counts losers." },
367                        "clusters_absorbed":         { "type": "integer", "minimum": 0, "description": "Cross-run absorb: freshly-built clusters folded into a pre-existing DB cluster with a similar centroid. Counts new-side clusters." },
368                        "existing_clusters_merged":  { "type": "integer", "minimum": 0, "description": "Existing-vs-existing merge: pre-existing DB clusters that drifted toward each other and now coalesce. Counts losers." },
369                        "episodes_clustered":        { "type": "integer", "minimum": 0 },
370                        "abstractions_built":        { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
371                        "abstractions_regenerated":  { "type": "integer", "minimum": 0, "description": "Existing clusters whose stale abstractions were dropped and rebuilt because absorb or existing-merge changed their episode set. 0 without an LlmClient." },
372                        "triples_built":             { "type": "integer", "minimum": 0 },
373                        "contradictions_found":      { "type": "integer", "minimum": 0 }
374                    }
375                },
376                "EpisodeRecord": {
377                    "type": "object",
378                    "description":
379                        "Inspect response: full episode record. Fields are stable across v0.1 but not \
380                         exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
381                         Treat as a forward-compatible JSON object.",
382                    "additionalProperties": true
383                },
384                "ThemeHit": {
385                    "type": "object",
386                    "description":
387                        "One cluster + its (optional) abstraction. Returned by GET /memory/themes. \
388                         See `solo_query::ThemeHit` for the canonical shape: cluster_id, \
389                         abstraction_id?, abstraction_text?, episode_count, coherence, created_at_ms.",
390                    "additionalProperties": true
391                },
392                "FactHit": {
393                    "type": "object",
394                    "description":
395                        "One Steward-extracted SPO triple. Returned by GET /memory/facts_about. \
396                         See `solo_query::FactHit` for fields: triple_id, subject_id, predicate, \
397                         object_id, object_kind, valid_from_ms, valid_to_ms?, confidence, cluster_id?.",
398                    "additionalProperties": true
399                },
400                "ContradictionHit": {
401                    "type": "object",
402                    "description":
403                        "One Steward-flagged contradiction with each side's triple LEFT JOIN'd in. \
404                         Returned by GET /memory/contradictions. See `solo_query::ContradictionHit`: \
405                         a_id, b_id, kind, explanation, detected_at_ms, a_triple?, b_triple?.",
406                    "additionalProperties": true
407                },
408                "ClusterRecord": {
409                    "type": "object",
410                    "description":
411                        "Snapshot of one cluster — its row, optional abstraction, and source episodes \
412                         (content truncated to 200 chars unless ?full_content=true). Returned by \
413                         GET /memory/clusters/{cluster_id}. See `solo_query::ClusterRecord`.",
414                    "additionalProperties": true
415                },
416                "ApiError": {
417                    "type": "object",
418                    "required": ["error", "status"],
419                    "properties": {
420                        "error": { "type": "string" },
421                        "status": { "type": "integer", "minimum": 400, "maximum": 599 }
422                    }
423                }
424            }
425        },
426        "paths": {
427            "/health": {
428                "get": {
429                    "summary": "Liveness probe",
430                    "description": "Returns plain text `ok`. Always unauthenticated.",
431                    "responses": {
432                        "200": {
433                            "description": "Server is up.",
434                            "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
435                        }
436                    }
437                }
438            },
439            "/openapi.json": {
440                "get": {
441                    "summary": "Self-describing OpenAPI 3.1 spec",
442                    "description": "Returns this document. Always unauthenticated.",
443                    "responses": {
444                        "200": {
445                            "description": "OpenAPI 3.1 document.",
446                            "content": { "application/json": { "schema": { "type": "object" } } }
447                        }
448                    }
449                }
450            },
451            "/memory": {
452                "post": {
453                    "summary": "Remember (store an episode)",
454                    "description": "Equivalent to MCP tool `memory_remember`.",
455                    "security": [{ "bearerAuth": [] }, {}],
456                    "requestBody": {
457                        "required": true,
458                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
459                    },
460                    "responses": {
461                        "200": {
462                            "description": "Memory stored; returns the new MemoryId.",
463                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
464                        },
465                        "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
466                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
467                    }
468                }
469            },
470            "/memory/search": {
471                "post": {
472                    "summary": "Recall (vector search)",
473                    "description": "Equivalent to MCP tool `memory_recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
474                    "security": [{ "bearerAuth": [] }, {}],
475                    "requestBody": {
476                        "required": true,
477                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
478                    },
479                    "responses": {
480                        "200": {
481                            "description": "Search results.",
482                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
483                        },
484                        "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
485                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
486                    }
487                }
488            },
489            "/memory/consolidate": {
490                "post": {
491                    "summary": "Run a consolidation pass (clustering + abstraction)",
492                    "description":
493                        "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
494                         on the server, also runs the REM-equivalent abstraction pass that populates \
495                         `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
496                         window). Equivalent to the `solo consolidate` CLI.",
497                    "security": [{ "bearerAuth": [] }, {}],
498                    "requestBody": {
499                        "required": false,
500                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
501                    },
502                    "responses": {
503                        "200": {
504                            "description": "Consolidation complete; report counts the work done.",
505                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
506                        },
507                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
508                    }
509                }
510            },
511            "/backup": {
512                "post": {
513                    "summary": "Online encrypted backup",
514                    "description":
515                        "Run an online SQLCipher backup of the live data dir to a server-side path. \
516                         The destination file is encrypted with the same Argon2id-derived raw key as \
517                         the source, so it restores under the same passphrase + a copy of the source's \
518                         `solo.config.toml`. Hot — the backup runs against the writer's existing \
519                         connection without taking the lockfile, so the daemon keeps serving reads + \
520                         writes during the operation. v0.3.2+.",
521                    "security": [{ "bearerAuth": [] }, {}],
522                    "requestBody": {
523                        "required": true,
524                        "content": { "application/json": { "schema": {
525                            "type": "object",
526                            "properties": {
527                                "to": { "type": "string", "description": "Server-side absolute path for the backup file." },
528                                "force": { "type": "boolean", "description": "Overwrite an existing destination file. Default false.", "default": false }
529                            },
530                            "required": ["to"]
531                        } } }
532                    },
533                    "responses": {
534                        "200": {
535                            "description": "Backup complete; reports the destination path + elapsed milliseconds.",
536                            "content": { "application/json": { "schema": {
537                                "type": "object",
538                                "properties": {
539                                    "path": { "type": "string" },
540                                    "elapsed_ms": { "type": "integer", "format": "int64" }
541                                }
542                            } } }
543                        },
544                        "400": { "description": "Destination invalid, exists without force, or its parent doesn't exist." },
545                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." },
546                        "500": { "description": "Backup failed (disk full, permission denied, etc.)." }
547                    }
548                }
549            },
550            "/memory/{id}": {
551                "get": {
552                    "summary": "Inspect a memory by ID",
553                    "description": "Equivalent to MCP tool `memory_inspect`.",
554                    "security": [{ "bearerAuth": [] }, {}],
555                    "parameters": [{
556                        "name": "id",
557                        "in": "path",
558                        "required": true,
559                        "schema": { "type": "string", "format": "uuid" },
560                        "description": "MemoryId (UUID v7)."
561                    }],
562                    "responses": {
563                        "200": {
564                            "description": "Episode record.",
565                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
566                        },
567                        "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
568                        "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
569                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
570                    }
571                },
572                "delete": {
573                    "summary": "Forget (soft-delete) a memory by ID",
574                    "description":
575                        "Equivalent to MCP tool `memory_forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
576                         and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
577                         re-running `solo reembed` after this does NOT restore visibility.",
578                    "security": [{ "bearerAuth": [] }, {}],
579                    "parameters": [
580                        { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
581                        { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
582                    ],
583                    "responses": {
584                        "204": { "description": "Forgotten (or already forgotten — idempotent)." },
585                        "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
586                        "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
587                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
588                    }
589                }
590            },
591            "/memory/themes": {
592                "get": {
593                    "summary": "List recent cluster themes",
594                    "description":
595                        "Equivalent to MCP tool `memory_themes`. List cluster abstractions ordered by \
596                         most-recent first. Use to surface 'what has the user been thinking about lately' \
597                         without paging through individual episodes. v0.4.0+.",
598                    "security": [{ "bearerAuth": [] }, {}],
599                    "parameters": [
600                        { "name": "window_days", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1 }, "description": "Optional time window. Omit for unfiltered (all-time, most-recent first)." },
601                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
602                    ],
603                    "responses": {
604                        "200": {
605                            "description": "Array of ThemeHits (possibly empty).",
606                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ThemeHit" } } } }
607                        },
608                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
609                    }
610                }
611            },
612            "/memory/facts_about": {
613                "get": {
614                    "summary": "Query the SPO knowledge graph by subject",
615                    "description":
616                        "Equivalent to MCP tool `memory_facts_about`. Query Steward-extracted triples by \
617                         subject + optional predicate + optional time window. Subject is required \
618                         (predicate-only scans not supported). v0.4.0+.",
619                    "security": [{ "bearerAuth": [] }, {}],
620                    "parameters": [
621                        { "name": "subject", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Subject id to query (e.g. `Sam`)." },
622                        { "name": "predicate", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional predicate filter (e.g. `works_at`)." },
623                        { "name": "since_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_from_ms lower bound (epoch ms)." },
624                        { "name": "until_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_to_ms upper bound (epoch ms). NULL upper bounds (still-valid facts) pass through." },
625                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
626                    ],
627                    "responses": {
628                        "200": {
629                            "description": "Array of FactHits (possibly empty).",
630                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactHit" } } } }
631                        },
632                        "400": { "description": "Bad request (e.g. empty subject).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
633                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
634                    }
635                }
636            },
637            "/memory/contradictions": {
638                "get": {
639                    "summary": "List Steward-flagged contradictions",
640                    "description":
641                        "Equivalent to MCP tool `memory_contradictions`. Each result includes both \
642                         sides' triple SPO via LEFT JOIN for context. v0.4.0+.",
643                    "security": [{ "bearerAuth": [] }, {}],
644                    "parameters": [
645                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
646                    ],
647                    "responses": {
648                        "200": {
649                            "description": "Array of ContradictionHits (possibly empty).",
650                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ContradictionHit" } } } }
651                        },
652                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
653                    }
654                }
655            },
656            "/memory/clusters/{cluster_id}": {
657                "get": {
658                    "summary": "Inspect a single cluster",
659                    "description":
660                        "Equivalent to MCP tool `memory_inspect_cluster`. Returns the cluster row, \
661                         its (optional) abstraction, and its source episodes. By default each \
662                         episode's `content` is truncated to 200 chars with a trailing `…`. Pass \
663                         `?full_content=true` to get verbatim episode content. v0.5.0+.",
664                    "security": [{ "bearerAuth": [] }, {}],
665                    "parameters": [
666                        { "name": "cluster_id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Cluster id (from a previous GET /memory/themes response)." },
667                        { "name": "full_content", "in": "query", "required": false, "schema": { "type": "boolean", "default": false }, "description": "If true, return episode content verbatim. Default false (truncate to 200 chars + ellipsis)." }
668                    ],
669                    "responses": {
670                        "200": {
671                            "description": "Cluster snapshot.",
672                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClusterRecord" } } }
673                        },
674                        "400": { "description": "Bad request (e.g. empty cluster_id).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
675                        "404": { "description": "No such cluster.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
676                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
677                    }
678                }
679            }
680        }
681    })
682}
683
684// ---------------------------------------------------------------------------
685// Handlers
686// ---------------------------------------------------------------------------
687
688#[derive(Debug, Deserialize)]
689struct RememberBody {
690    content: String,
691    #[serde(default)]
692    source_type: Option<String>,
693    #[serde(default)]
694    source_id: Option<String>,
695}
696
697#[derive(Debug, Serialize)]
698struct RememberResponse {
699    memory_id: String,
700}
701
702async fn remember_handler(
703    State(s): State<SoloHttpState>,
704    Json(body): Json<RememberBody>,
705) -> Result<Json<RememberResponse>, ApiError> {
706    let content = body.content.trim_end().to_string();
707    if content.is_empty() {
708        return Err(ApiError::bad_request("content must not be empty"));
709    }
710    let embedding = s.embedder.embed(&content).await.map_err(ApiError::from)?;
711    let episode = Episode {
712        memory_id: MemoryId::new(),
713        ts_ms: chrono::Utc::now().timestamp_millis(),
714        source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
715        source_id: body.source_id,
716        content,
717        encoding_context: EncodingContext::default(),
718        provenance: None,
719        confidence: Confidence::new(0.9).unwrap(),
720        strength: 0.5,
721        salience: 0.5,
722        tier: Tier::Hot,
723    };
724    let mid = s.write.remember(episode, embedding).await.map_err(ApiError::from)?;
725    Ok(Json(RememberResponse {
726        memory_id: mid.to_string(),
727    }))
728}
729
730#[derive(Debug, Deserialize)]
731struct RecallBody {
732    query: String,
733    #[serde(default = "default_limit")]
734    limit: usize,
735}
736
737fn default_limit() -> usize {
738    5
739}
740
741async fn recall_handler(
742    State(s): State<SoloHttpState>,
743    Json(body): Json<RecallBody>,
744) -> Result<Json<solo_query::RecallResult>, ApiError> {
745    // solo_query::run_recall handles empty-query rejection (returns
746    // InvalidInput → ApiError::bad_request(400)) and clamps limit
747    // upstream of the embedder call.
748    let result = solo_query::run_recall(
749        &s.embedder,
750        &s.hnsw,
751        &s.pool,
752        &body.query,
753        body.limit,
754    )
755    .await
756    .map_err(ApiError::from)?;
757    Ok(Json(result))
758}
759
760async fn inspect_handler(
761    State(s): State<SoloHttpState>,
762    Path(id): Path<String>,
763) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
764    let mid = MemoryId::from_str(&id)
765        .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
766    let row = solo_query::inspect_one(&s.pool, mid)
767        .await
768        .map_err(ApiError::from)?;
769    Ok(Json(row))
770}
771
772// Path 1 derived-layer handlers (v0.4.0+). All three are GET-shaped:
773// pure read-only queries against the Steward's outputs, query-string
774// params for simple filters. Each handler delegates to a single
775// solo_query::derived pipeline and returns the result Vec as JSON.
776// Empty derived layer → 200 with `[]` body (parseable JSON array).
777
778#[derive(Debug, Deserialize)]
779struct ThemesQuery {
780    #[serde(default)]
781    window_days: Option<i64>,
782    #[serde(default = "default_limit")]
783    limit: usize,
784}
785
786async fn themes_handler(
787    State(s): State<SoloHttpState>,
788    Query(q): Query<ThemesQuery>,
789) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
790    let hits = solo_query::themes(&s.pool, q.window_days, q.limit)
791        .await
792        .map_err(ApiError::from)?;
793    Ok(Json(hits))
794}
795
796#[derive(Debug, Deserialize)]
797struct FactsAboutQuery {
798    subject: String,
799    #[serde(default)]
800    predicate: Option<String>,
801    #[serde(default)]
802    since_ms: Option<i64>,
803    #[serde(default)]
804    until_ms: Option<i64>,
805    #[serde(default = "default_limit")]
806    limit: usize,
807}
808
809async fn facts_about_handler(
810    State(s): State<SoloHttpState>,
811    Query(q): Query<FactsAboutQuery>,
812) -> Result<Json<Vec<solo_query::FactHit>>, ApiError> {
813    if q.subject.trim().is_empty() {
814        return Err(ApiError::bad_request("subject must not be empty"));
815    }
816    let hits = solo_query::facts_about(
817        &s.pool,
818        &q.subject,
819        &s.user_aliases,
820        q.predicate.as_deref(),
821        q.since_ms,
822        q.until_ms,
823        q.limit,
824    )
825    .await
826    .map_err(ApiError::from)?;
827    Ok(Json(hits))
828}
829
830#[derive(Debug, Deserialize)]
831struct ContradictionsQuery {
832    #[serde(default = "default_limit")]
833    limit: usize,
834}
835
836async fn contradictions_handler(
837    State(s): State<SoloHttpState>,
838    Query(q): Query<ContradictionsQuery>,
839) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
840    let hits = solo_query::contradictions(&s.pool, q.limit)
841        .await
842        .map_err(ApiError::from)?;
843    Ok(Json(hits))
844}
845
846#[derive(Debug, Deserialize, Default)]
847struct InspectClusterQuery {
848    /// Default `false` — episode `content` is truncated to
849    /// `solo_query::EPISODE_TRUNCATE_CHARS` chars with a trailing `…`.
850    /// `?full_content=true` returns each episode's content verbatim.
851    #[serde(default)]
852    full_content: bool,
853}
854
855async fn inspect_cluster_handler(
856    State(s): State<SoloHttpState>,
857    Path(cluster_id): Path<String>,
858    Query(q): Query<InspectClusterQuery>,
859) -> Result<Json<solo_query::ClusterRecord>, ApiError> {
860    if cluster_id.trim().is_empty() {
861        return Err(ApiError::bad_request("cluster_id must not be empty"));
862    }
863    // `solo_query::inspect_cluster` returns `Err(Error::NotFound)` for
864    // unknown ids; the `From<solo_core::Error> for ApiError` impl
865    // maps that to 404. Same path as `inspect_handler`'s unknown
866    // memory_id behaviour.
867    let record = solo_query::inspect_cluster(
868        &s.pool,
869        &cluster_id,
870        q.full_content,
871    )
872    .await
873    .map_err(ApiError::from)?;
874    Ok(Json(record))
875}
876
877#[derive(Debug, Deserialize)]
878struct ForgetQuery {
879    #[serde(default)]
880    reason: Option<String>,
881}
882
883async fn forget_handler(
884    State(s): State<SoloHttpState>,
885    Path(id): Path<String>,
886    Query(q): Query<ForgetQuery>,
887) -> Result<StatusCode, ApiError> {
888    let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
889    let reason = q.reason.unwrap_or_else(|| "http".into());
890    s.write.forget(mid, reason).await.map_err(ApiError::from)?;
891    Ok(StatusCode::NO_CONTENT)
892}
893
894async fn consolidate_handler(
895    State(s): State<SoloHttpState>,
896    body: axum::body::Bytes,
897) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
898    // Empty body = default scope (unbounded window). We parse via
899    // `Bytes` rather than `Option<Json<T>>` because axum's `Json`
900    // extractor 400s on an empty body when Content-Type is JSON
901    // (it can't deserialize zero bytes as `T`), and the `Option`
902    // wrapper doesn't reliably degrade that failure to `None`.
903    let scope = if body.is_empty() {
904        solo_storage::ConsolidationScope::default()
905    } else {
906        serde_json::from_slice(&body)
907            .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
908    };
909    let report = s.write.consolidate(scope).await.map_err(ApiError::from)?;
910    Ok(Json(report))
911}
912
913#[derive(Debug, Deserialize)]
914struct BackupBody {
915    /// Server-side absolute path where the backup file should be
916    /// written. Must be writable by the Solo process. Refuses to
917    /// overwrite an existing file unless `force = true`.
918    to: String,
919    #[serde(default)]
920    force: bool,
921}
922
923#[derive(Debug, Serialize)]
924struct BackupResponse {
925    path: String,
926    elapsed_ms: u64,
927}
928
929async fn backup_handler(
930    State(s): State<SoloHttpState>,
931    Json(body): Json<BackupBody>,
932) -> Result<Json<BackupResponse>, ApiError> {
933    use std::path::PathBuf;
934
935    let dest = PathBuf::from(&body.to);
936    if dest.as_os_str().is_empty() {
937        return Err(ApiError::bad_request("`to` must not be empty"));
938    }
939    // CRITICAL ORDER: same-file refusal MUST come BEFORE `remove_file`.
940    // A `force: true` request pointing at the daemon's live `solo.db`
941    // would otherwise unlink the source on Linux (the writer's open fd
942    // keeps it accessible until shutdown, but the data dir is empty
943    // from any new opener's perspective). See v0.3.4 release notes.
944    if solo_storage::paths_refer_to_same_file(&s.source_db_path, &dest) {
945        return Err(ApiError::bad_request(format!(
946            "destination {} is the same file as the source database; \
947             refusing to run (would corrupt the live database)",
948            dest.display()
949        )));
950    }
951    if dest.exists() {
952        if !body.force {
953            return Err(ApiError::bad_request(format!(
954                "destination {} exists; pass force=true to overwrite",
955                dest.display()
956            )));
957        }
958        std::fs::remove_file(&dest).map_err(|e| {
959            ApiError::internal(format!(
960                "remove existing destination {}: {e}",
961                dest.display()
962            ))
963        })?;
964    }
965    if let Some(parent) = dest.parent() {
966        if !parent.as_os_str().is_empty() && !parent.is_dir() {
967            return Err(ApiError::bad_request(format!(
968                "destination parent directory {} does not exist",
969                parent.display()
970            )));
971        }
972    }
973
974    let started = std::time::Instant::now();
975    s.write.backup(dest.clone()).await.map_err(ApiError::from)?;
976    let elapsed_ms = started.elapsed().as_millis() as u64;
977
978    Ok(Json(BackupResponse {
979        path: dest.display().to_string(),
980        elapsed_ms,
981    }))
982}
983
984// ---------------------------------------------------------------------------
985// Error mapping
986// ---------------------------------------------------------------------------
987
988#[derive(Debug)]
989pub struct ApiError {
990    status: StatusCode,
991    message: String,
992}
993
994impl ApiError {
995    fn bad_request(msg: impl Into<String>) -> Self {
996        Self {
997            status: StatusCode::BAD_REQUEST,
998            message: msg.into(),
999        }
1000    }
1001    fn not_found(msg: impl Into<String>) -> Self {
1002        Self {
1003            status: StatusCode::NOT_FOUND,
1004            message: msg.into(),
1005        }
1006    }
1007    fn internal(msg: impl Into<String>) -> Self {
1008        Self {
1009            status: StatusCode::INTERNAL_SERVER_ERROR,
1010            message: msg.into(),
1011        }
1012    }
1013}
1014
1015impl From<solo_core::Error> for ApiError {
1016    fn from(e: solo_core::Error) -> Self {
1017        use solo_core::Error;
1018        match e {
1019            Error::NotFound(msg) => ApiError::not_found(msg),
1020            Error::InvalidInput(msg) => ApiError::bad_request(msg),
1021            Error::Conflict(msg) => Self {
1022                status: StatusCode::CONFLICT,
1023                message: msg,
1024            },
1025            other => ApiError::internal(other.to_string()),
1026        }
1027    }
1028}
1029
1030impl IntoResponse for ApiError {
1031    fn into_response(self) -> Response {
1032        let body = serde_json::json!({
1033            "error": self.message,
1034            "status": self.status.as_u16(),
1035        });
1036        (self.status, Json(body)).into_response()
1037    }
1038}
1039
1040// SQL helper for recall used to live here; consolidated into
1041// solo_query::recall.
1042
1043#[cfg(test)]
1044mod handler_tests {
1045    //! In-process integration tests for the HTTP handler surface. We
1046    //! drive the axum Router directly via `tower::ServiceExt::oneshot`
1047    //! — no real TCP listener needed. Same `Harness`-shape as the MCP
1048    //! tests: real WriterActor + ReaderPool + StubEmbedder + StubVectorIndex.
1049    //!
1050    //! Tests live inline in this module rather than in a `tests/` dir
1051    //! because external integration-test exes triggered Windows UAC
1052    //! ERROR_ELEVATION_REQUIRED on the dev machine.
1053    use super::*;
1054    use axum::body::Body;
1055    use axum::http::{Request, StatusCode};
1056    use http_body_util::BodyExt;
1057    use serde_json::{Value, json};
1058    use solo_core::VectorIndex as _;
1059    use solo_storage::test_support::StubVectorIndex;
1060    use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
1061    use std::sync::Arc as StdArc;
1062    use tower::ServiceExt;
1063
1064    struct Harness {
1065        router: axum::Router,
1066        _tmp: tempfile::TempDir,
1067        write_handle_extra: Option<solo_storage::WriteHandle>,
1068        join: Option<std::thread::JoinHandle<()>>,
1069    }
1070
1071    impl Harness {
1072        fn new(runtime: &tokio::runtime::Runtime) -> Self {
1073            Self::new_with_auth(runtime, None)
1074        }
1075
1076        fn new_with_auth(
1077            runtime: &tokio::runtime::Runtime,
1078            bearer_token: Option<String>,
1079        ) -> Self {
1080            use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
1081
1082            let tmp = tempfile::TempDir::new().unwrap();
1083            let dim = 16usize;
1084            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1085            let embedder: StdArc<dyn solo_core::Embedder> =
1086                StdArc::new(StubEmbedder::new("stub", "v1", dim));
1087            let path = tmp.path().join("test.db");
1088
1089            // Register an embedder_id so `handle_consolidate` (and any
1090            // other path that filters on `embeddings.embedder_id`) sees
1091            // a production-shaped writer. Existing handler tests pass
1092            // unchanged because the writer just additionally INSERTs
1093            // an `embeddings` row per remember — no assertions look at
1094            // that table.
1095            let embedder_id = {
1096                let conn = solo_storage::test_support::open_test_db_at(&path);
1097                get_or_insert_embedder_id(
1098                    &conn,
1099                    &EmbedderIdentity {
1100                        name: "stub".into(),
1101                        version: "v1".into(),
1102                        dim: dim as u32,
1103                        dtype: "f32".into(),
1104                    },
1105                )
1106                .unwrap()
1107            };
1108
1109            let conn = solo_storage::test_support::open_test_db_at(&path);
1110            let WriterSpawn { handle, join } = WriterActor::spawn_full(
1111                conn,
1112                hnsw.clone(),
1113                tmp.path().to_path_buf(),
1114                embedder_id,
1115            );
1116            let pool: ReaderPool =
1117                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1118            let state = SoloHttpState {
1119                write: handle.clone(),
1120                pool,
1121                embedder,
1122                hnsw,
1123                source_db_path: path.clone(),
1124                user_aliases: Arc::new(Vec::new()),
1125            };
1126            let router = router_with_auth(state, bearer_token);
1127            Harness {
1128                router,
1129                _tmp: tmp,
1130                write_handle_extra: Some(handle),
1131                join: Some(join),
1132            }
1133        }
1134
1135        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1136            let join = self.join.take();
1137            let extra = self.write_handle_extra.take();
1138            runtime.block_on(async move {
1139                drop(extra);
1140                drop(self.router); // drops state → drops pool inside runtime ctx
1141                drop(self._tmp);
1142                if let Some(join) = join {
1143                    let (tx, rx) = std::sync::mpsc::channel();
1144                    std::thread::spawn(move || {
1145                        let _ = tx.send(join.join());
1146                    });
1147                    tokio::task::spawn_blocking(move || {
1148                        rx.recv_timeout(std::time::Duration::from_secs(5))
1149                    })
1150                    .await
1151                    .expect("blocking task")
1152                    .expect("writer thread did not exit within 5s")
1153                    .expect("writer thread panicked");
1154                }
1155            });
1156        }
1157    }
1158
1159    fn rt() -> tokio::runtime::Runtime {
1160        tokio::runtime::Builder::new_multi_thread()
1161            .worker_threads(2)
1162            .enable_all()
1163            .build()
1164            .unwrap()
1165    }
1166
1167    /// Issue one HTTP request through the router and capture status +
1168    /// JSON body. `body` may be `None` for GET/DELETE; `auth` adds an
1169    /// `Authorization` header value verbatim (e.g. `"Bearer xyz"`).
1170    async fn call(
1171        router: axum::Router,
1172        method: &str,
1173        uri: &str,
1174        body: Option<Value>,
1175    ) -> (StatusCode, Value) {
1176        call_with_auth(router, method, uri, body, None).await
1177    }
1178
1179    async fn call_with_auth(
1180        router: axum::Router,
1181        method: &str,
1182        uri: &str,
1183        body: Option<Value>,
1184        auth: Option<&str>,
1185    ) -> (StatusCode, Value) {
1186        let mut req_builder = Request::builder()
1187            .method(method)
1188            .uri(uri)
1189            .header("content-type", "application/json");
1190        if let Some(a) = auth {
1191            req_builder = req_builder.header("authorization", a);
1192        }
1193        let req = if let Some(b) = body {
1194            let bytes = serde_json::to_vec(&b).unwrap();
1195            req_builder.body(Body::from(bytes)).unwrap()
1196        } else {
1197            req_builder = req_builder.header("content-length", "0");
1198            req_builder.body(Body::empty()).unwrap()
1199        };
1200        let resp = router.oneshot(req).await.expect("oneshot");
1201        let status = resp.status();
1202        let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
1203        let v: Value = if body_bytes.is_empty() {
1204            Value::Null
1205        } else {
1206            serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
1207        };
1208        (status, v)
1209    }
1210
1211    #[test]
1212    fn health_returns_ok() {
1213        let runtime = rt();
1214        let h = Harness::new(&runtime);
1215        let r = h.router.clone();
1216        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1217        assert_eq!(status, StatusCode::OK);
1218        h.shutdown(&runtime);
1219    }
1220
1221    /// `GET /openapi.json` returns a parseable OpenAPI 3.x document with
1222    /// the four `memory.*` endpoints + their request/response schemas.
1223    /// Acts as a drift detector: if a future commit adds/removes a route
1224    /// without updating `openapi_spec`, this test fails loudly.
1225    #[test]
1226    fn openapi_json_describes_all_endpoints() {
1227        let runtime = rt();
1228        let h = Harness::new(&runtime);
1229        let r = h.router.clone();
1230        let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1231        assert_eq!(status, StatusCode::OK);
1232        assert!(spec.is_object(), "openapi.json must be a JSON object");
1233
1234        // Top-level shape per OpenAPI 3.1.
1235        assert!(
1236            spec.get("openapi")
1237                .and_then(|v| v.as_str())
1238                .is_some_and(|s| s.starts_with("3.")),
1239            "missing or wrong openapi version: {spec}"
1240        );
1241        assert!(spec.pointer("/info/title").is_some());
1242        assert!(spec.pointer("/info/version").is_some());
1243
1244        // Every route the router serves must be documented.
1245        let paths = spec
1246            .get("paths")
1247            .and_then(|v| v.as_object())
1248            .expect("paths must be an object");
1249        for expected in [
1250            "/health",
1251            "/openapi.json",
1252            "/memory",
1253            "/memory/search",
1254            "/memory/consolidate",
1255            "/memory/{id}",
1256            // Path 1 derived-layer endpoints (v0.4.0+):
1257            "/memory/themes",
1258            "/memory/facts_about",
1259            "/memory/contradictions",
1260            // v0.5.0 Priority 3:
1261            "/memory/clusters/{cluster_id}",
1262        ] {
1263            assert!(
1264                paths.contains_key(expected),
1265                "openapi paths missing {expected}: {paths:?}"
1266            );
1267        }
1268
1269        // Method coverage on /memory/{id}: must document both GET (inspect)
1270        // and DELETE (forget).
1271        let memid = paths.get("/memory/{id}").expect("memory/{id}");
1272        assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1273        assert!(
1274            memid.get("delete").is_some(),
1275            "DELETE /memory/{{id}} undocumented"
1276        );
1277
1278        // Component schemas referenced from paths must be defined.
1279        for schema_name in [
1280            "RememberRequest",
1281            "RememberResponse",
1282            "RecallRequest",
1283            "RecallResult",
1284            "EpisodeRecord",
1285            "ApiError",
1286            "ConsolidationScope",
1287            "ConsolidationReport",
1288            // Path 1 derived-layer schemas (v0.4.0+):
1289            "ThemeHit",
1290            "FactHit",
1291            "ContradictionHit",
1292            // v0.5.0 Priority 3:
1293            "ClusterRecord",
1294        ] {
1295            let ptr = format!("/components/schemas/{schema_name}");
1296            assert!(
1297                spec.pointer(&ptr).is_some(),
1298                "component schema {schema_name} missing"
1299            );
1300        }
1301
1302        // bearerAuth security scheme is declared (LAN deployments need it).
1303        assert!(
1304            spec.pointer("/components/securitySchemes/bearerAuth")
1305                .is_some(),
1306            "bearerAuth security scheme missing"
1307        );
1308
1309        h.shutdown(&runtime);
1310    }
1311
1312    /// `/openapi.json` must remain unauthenticated even when bearer auth
1313    /// is enabled — the spec describes the API shape, not secrets, and
1314    /// codegen tooling shouldn't need a credential to fetch it.
1315    #[test]
1316    fn openapi_json_is_exempt_from_bearer_auth() {
1317        let runtime = rt();
1318        let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1319        let r = h.router.clone();
1320        // No Authorization header → still 200 for /openapi.json.
1321        let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1322        assert_eq!(status, StatusCode::OK);
1323        h.shutdown(&runtime);
1324    }
1325
1326    #[test]
1327    fn remember_returns_memory_id() {
1328        let runtime = rt();
1329        let h = Harness::new(&runtime);
1330        let r = h.router.clone();
1331        let (status, body) = runtime.block_on(call(
1332            r,
1333            "POST",
1334            "/memory",
1335            Some(json!({ "content": "http harness test" })),
1336        ));
1337        assert_eq!(status, StatusCode::OK);
1338        let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1339        assert_eq!(mid.len(), 36, "uuid length");
1340        h.shutdown(&runtime);
1341    }
1342
1343    #[test]
1344    fn empty_content_returns_400() {
1345        let runtime = rt();
1346        let h = Harness::new(&runtime);
1347        let r = h.router.clone();
1348        let (status, body) =
1349            runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1350        assert_eq!(status, StatusCode::BAD_REQUEST);
1351        assert!(
1352            body.get("error")
1353                .and_then(|e| e.as_str())
1354                .map(|s| s.contains("must not be empty"))
1355                .unwrap_or(false),
1356            "got: {body}"
1357        );
1358        h.shutdown(&runtime);
1359    }
1360
1361    #[test]
1362    fn empty_query_returns_400() {
1363        let runtime = rt();
1364        let h = Harness::new(&runtime);
1365        let r = h.router.clone();
1366        let (status, body) = runtime.block_on(call(
1367            r,
1368            "POST",
1369            "/memory/search",
1370            Some(json!({ "query": "" })),
1371        ));
1372        assert_eq!(status, StatusCode::BAD_REQUEST);
1373        assert!(
1374            body.get("error")
1375                .and_then(|e| e.as_str())
1376                .map(|s| s.contains("must not be empty"))
1377                .unwrap_or(false),
1378            "got: {body}"
1379        );
1380        h.shutdown(&runtime);
1381    }
1382
1383    #[test]
1384    fn inspect_unknown_returns_404() {
1385        let runtime = rt();
1386        let h = Harness::new(&runtime);
1387        let r = h.router.clone();
1388        let (status, body) = runtime.block_on(call(
1389            r,
1390            "GET",
1391            "/memory/00000000-0000-7000-8000-000000000000",
1392            None,
1393        ));
1394        assert_eq!(status, StatusCode::NOT_FOUND);
1395        assert!(body.get("error").is_some(), "got: {body}");
1396        h.shutdown(&runtime);
1397    }
1398
1399    #[test]
1400    fn inspect_invalid_id_returns_400() {
1401        let runtime = rt();
1402        let h = Harness::new(&runtime);
1403        let r = h.router.clone();
1404        let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1405        assert_eq!(status, StatusCode::BAD_REQUEST);
1406        h.shutdown(&runtime);
1407    }
1408
1409    #[test]
1410    fn forget_unknown_returns_404() {
1411        let runtime = rt();
1412        let h = Harness::new(&runtime);
1413        let r = h.router.clone();
1414        let (status, _body) = runtime.block_on(call(
1415            r,
1416            "DELETE",
1417            "/memory/00000000-0000-7000-8000-000000000000",
1418            None,
1419        ));
1420        assert_eq!(status, StatusCode::NOT_FOUND);
1421        h.shutdown(&runtime);
1422    }
1423
1424    /// `POST /memory/consolidate` runs the cluster pass and returns
1425    /// the report as JSON. With an empty body, `ConsolidationScope`
1426    /// defaults to unbounded; with a non-empty body, the
1427    /// `window_days` field is honored. The Harness's writer is
1428    /// spawned without a Steward, so `abstractions_built` stays 0
1429    /// even when `clusters_built` is nonzero — same posture as the
1430    /// daemon today.
1431    #[test]
1432    fn consolidate_endpoint_returns_report() {
1433        let runtime = rt();
1434        let h = Harness::new(&runtime);
1435        let r = h.router.clone();
1436        runtime.block_on(async move {
1437            // Empty DB → all-zero report; structural assertion only.
1438            let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1439            assert_eq!(status, StatusCode::OK);
1440            for field in [
1441                "episodes_seen",
1442                "clusters_built",
1443                "episodes_clustered",
1444                "abstractions_built",
1445                "triples_built",
1446                "contradictions_found",
1447            ] {
1448                assert!(
1449                    body.get(field).and_then(|v| v.as_u64()).is_some(),
1450                    "missing field {field}: {body}"
1451                );
1452            }
1453            assert_eq!(body["episodes_seen"], 0);
1454            assert_eq!(body["clusters_built"], 0);
1455
1456            // Non-empty body with window_days → still 200; unmistakable
1457            // shape round-trips through ConsolidationScope's serde.
1458            let (status2, _body2) = call(
1459                r,
1460                "POST",
1461                "/memory/consolidate",
1462                Some(json!({ "window_days": 7 })),
1463            )
1464            .await;
1465            assert_eq!(status2, StatusCode::OK);
1466        });
1467        h.shutdown(&runtime);
1468    }
1469
1470    #[test]
1471    fn auth_required_routes_reject_missing_token() {
1472        let runtime = rt();
1473        let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1474        let r = h.router.clone();
1475        runtime.block_on(async move {
1476            // No Authorization header → 401.
1477            let (status, _body) = call(
1478                r.clone(),
1479                "POST",
1480                "/memory",
1481                Some(json!({ "content": "x" })),
1482            )
1483            .await;
1484            assert_eq!(status, StatusCode::UNAUTHORIZED);
1485
1486            // Wrong token → 401.
1487            let (status, _body) = call_with_auth(
1488                r.clone(),
1489                "POST",
1490                "/memory",
1491                Some(json!({ "content": "x" })),
1492                Some("Bearer wrong-token"),
1493            )
1494            .await;
1495            assert_eq!(status, StatusCode::UNAUTHORIZED);
1496
1497            // Correct token → handler runs (200).
1498            let (status, body) = call_with_auth(
1499                r.clone(),
1500                "POST",
1501                "/memory",
1502                Some(json!({ "content": "authed" })),
1503                Some("Bearer secret-xyz"),
1504            )
1505            .await;
1506            assert_eq!(status, StatusCode::OK);
1507            assert!(body.get("memory_id").is_some());
1508        });
1509        h.shutdown(&runtime);
1510    }
1511
1512    #[test]
1513    fn health_endpoint_does_not_require_auth() {
1514        let runtime = rt();
1515        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1516        let r = h.router.clone();
1517        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1518        // Liveness probes should work without credentials.
1519        assert_eq!(status, StatusCode::OK);
1520        h.shutdown(&runtime);
1521    }
1522
1523    #[test]
1524    fn auth_response_includes_www_authenticate_header() {
1525        // Verify the WWW-Authenticate hint that lets a well-behaved
1526        // client know it's a bearer-auth scheme. We check via raw
1527        // request → response (oneshot returns Response, but our
1528        // call() helper drops the headers; build the request manually).
1529        let runtime = rt();
1530        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1531        let r = h.router.clone();
1532        runtime.block_on(async move {
1533            let req = Request::builder()
1534                .method("POST")
1535                .uri("/memory")
1536                .header("content-type", "application/json")
1537                .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
1538                .unwrap();
1539            let resp = r.oneshot(req).await.unwrap();
1540            assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1541            let www = resp
1542                .headers()
1543                .get("www-authenticate")
1544                .and_then(|v| v.to_str().ok())
1545                .unwrap_or("");
1546            assert!(
1547                www.starts_with("Bearer"),
1548                "expected WWW-Authenticate: Bearer..., got: {www}"
1549            );
1550        });
1551        h.shutdown(&runtime);
1552    }
1553
1554    #[test]
1555    fn full_remember_recall_inspect_forget_round_trip() {
1556        let runtime = rt();
1557        let h = Harness::new(&runtime);
1558        let r = h.router.clone();
1559        runtime.block_on(async move {
1560            // POST /memory
1561            let (status, body) = call(
1562                r.clone(),
1563                "POST",
1564                "/memory",
1565                Some(json!({ "content": "round-trip content" })),
1566            )
1567            .await;
1568            assert_eq!(status, StatusCode::OK);
1569            let mid = body
1570                .get("memory_id")
1571                .and_then(|v| v.as_str())
1572                .unwrap()
1573                .to_string();
1574
1575            // POST /memory/search — exact-match (StubEmbedder) returns the row.
1576            let (status, body) = call(
1577                r.clone(),
1578                "POST",
1579                "/memory/search",
1580                Some(json!({ "query": "round-trip content", "limit": 5 })),
1581            )
1582            .await;
1583            assert_eq!(status, StatusCode::OK);
1584            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1585            assert!(
1586                hits.iter()
1587                    .any(|h| h.get("content").and_then(|c| c.as_str())
1588                        == Some("round-trip content")),
1589                "expected hit with content; got: {body}"
1590            );
1591
1592            // GET /memory/{id}
1593            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1594            assert_eq!(status, StatusCode::OK);
1595            assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
1596
1597            // DELETE /memory/{id}
1598            let (status, _body) =
1599                call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1600            assert_eq!(status, StatusCode::NO_CONTENT);
1601
1602            // GET again — still readable but status='forgotten'
1603            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1604            assert_eq!(status, StatusCode::OK);
1605            assert_eq!(
1606                body.get("status").and_then(|v| v.as_str()),
1607                Some("forgotten")
1608            );
1609
1610            // POST /memory/search — forgotten row excluded.
1611            let (status, body) = call(
1612                r.clone(),
1613                "POST",
1614                "/memory/search",
1615                Some(json!({ "query": "round-trip content", "limit": 5 })),
1616            )
1617            .await;
1618            assert_eq!(status, StatusCode::OK);
1619            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1620            assert!(
1621                hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
1622                    != Some(mid.as_str())),
1623                "forgotten row should be excluded from recall: {body}"
1624            );
1625        });
1626        h.shutdown(&runtime);
1627    }
1628
1629    // Path 1 derived-layer endpoint tests (v0.4.0+). Wire-path only —
1630    // the actual content correctness is covered by solo-query::derived's
1631    // own tests (Sub-task A). These verify the HTTP shape: GET routing,
1632    // Query-string param parsing, JSON-array response body, validation
1633    // 400s for invalid inputs.
1634
1635    #[test]
1636    fn themes_endpoint_returns_empty_array_on_empty_db() {
1637        let runtime = rt();
1638        let h = Harness::new(&runtime);
1639        let r = h.router.clone();
1640        let (status, body) =
1641            runtime.block_on(call(r, "GET", "/memory/themes", None));
1642        assert_eq!(status, StatusCode::OK);
1643        assert!(body.is_array(), "expected array, got {body}");
1644        assert_eq!(body.as_array().unwrap().len(), 0);
1645        h.shutdown(&runtime);
1646    }
1647
1648    #[test]
1649    fn themes_endpoint_passes_through_query_params() {
1650        let runtime = rt();
1651        let h = Harness::new(&runtime);
1652        let r = h.router.clone();
1653        let (status, body) = runtime.block_on(call(
1654            r,
1655            "GET",
1656            "/memory/themes?window_days=7&limit=20",
1657            None,
1658        ));
1659        assert_eq!(status, StatusCode::OK);
1660        assert!(body.is_array(), "expected array, got {body}");
1661        h.shutdown(&runtime);
1662    }
1663
1664    #[test]
1665    fn facts_about_endpoint_requires_subject() {
1666        let runtime = rt();
1667        let h = Harness::new(&runtime);
1668        let r = h.router.clone();
1669        // Missing subject — axum's Query extractor 422 (Unprocessable
1670        // Entity) on missing required field; some axum versions
1671        // surface as 400. Accept either.
1672        let (status, _body) =
1673            runtime.block_on(call(r, "GET", "/memory/facts_about", None));
1674        assert!(
1675            status == StatusCode::BAD_REQUEST
1676                || status == StatusCode::UNPROCESSABLE_ENTITY,
1677            "expected 400 or 422 for missing subject, got {status}"
1678        );
1679        h.shutdown(&runtime);
1680    }
1681
1682    #[test]
1683    fn facts_about_endpoint_rejects_blank_subject() {
1684        let runtime = rt();
1685        let h = Harness::new(&runtime);
1686        let r = h.router.clone();
1687        // Whitespace-only subject reaches the handler then trips its
1688        // own validation → ApiError::bad_request → 400.
1689        let (status, body) = runtime.block_on(call(
1690            r,
1691            "GET",
1692            "/memory/facts_about?subject=%20%20",
1693            None,
1694        ));
1695        assert_eq!(status, StatusCode::BAD_REQUEST);
1696        assert!(
1697            body.get("error")
1698                .and_then(|v| v.as_str())
1699                .is_some_and(|s| s.contains("subject")),
1700            "expected error mentioning subject, got {body}"
1701        );
1702        h.shutdown(&runtime);
1703    }
1704
1705    #[test]
1706    fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
1707        let runtime = rt();
1708        let h = Harness::new(&runtime);
1709        let r = h.router.clone();
1710        let (status, body) = runtime.block_on(call(
1711            r,
1712            "GET",
1713            "/memory/facts_about?subject=NobodyKnows",
1714            None,
1715        ));
1716        assert_eq!(status, StatusCode::OK);
1717        assert_eq!(body.as_array().unwrap().len(), 0);
1718        h.shutdown(&runtime);
1719    }
1720
1721    #[test]
1722    fn inspect_cluster_endpoint_unknown_id_returns_404() {
1723        // Maps `Error::NotFound` from `solo_query::inspect_cluster`
1724        // through `ApiError::from` → 404. Mirrors the unknown-memory
1725        // case for `GET /memory/{id}`.
1726        let runtime = rt();
1727        let h = Harness::new(&runtime);
1728        let r = h.router.clone();
1729        let (status, body) = runtime.block_on(call(
1730            r,
1731            "GET",
1732            "/memory/clusters/no-such-cluster",
1733            None,
1734        ));
1735        assert_eq!(status, StatusCode::NOT_FOUND);
1736        assert!(
1737            body.get("error")
1738                .and_then(|v| v.as_str())
1739                .is_some_and(|s| s.contains("no-such-cluster")),
1740            "expected error mentioning cluster id, got {body}"
1741        );
1742        h.shutdown(&runtime);
1743    }
1744
1745    #[test]
1746    fn inspect_cluster_endpoint_passes_full_content_query_param() {
1747        // Even with no matching cluster (→ 404), the request must
1748        // reach the handler — proves the `?full_content=true` query
1749        // string parses cleanly (Query<InspectClusterQuery>::default
1750        // path didn't choke). If we accidentally fail at the extractor
1751        // we'd get a 400/422, not the expected 404.
1752        let runtime = rt();
1753        let h = Harness::new(&runtime);
1754        let r = h.router.clone();
1755        let (status, _body) = runtime.block_on(call(
1756            r,
1757            "GET",
1758            "/memory/clusters/missing?full_content=true",
1759            None,
1760        ));
1761        assert_eq!(status, StatusCode::NOT_FOUND);
1762        h.shutdown(&runtime);
1763    }
1764
1765    #[test]
1766    fn contradictions_endpoint_returns_empty_array_on_empty_db() {
1767        let runtime = rt();
1768        let h = Harness::new(&runtime);
1769        let r = h.router.clone();
1770        let (status, body) = runtime.block_on(call(
1771            r,
1772            "GET",
1773            "/memory/contradictions",
1774            None,
1775        ));
1776        assert_eq!(status, StatusCode::OK);
1777        assert!(body.is_array());
1778        assert_eq!(body.as_array().unwrap().len(), 0);
1779        h.shutdown(&runtime);
1780    }
1781
1782    #[test]
1783    fn derived_endpoints_require_bearer_when_auth_enabled() {
1784        let runtime = rt();
1785        let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
1786        // Each of the three new endpoints should reject missing token.
1787        // Per the existing tests' shutdown-timing comment: don't hold a
1788        // long-lived router clone across multiple iterations — drop the
1789        // clone before each subsequent oneshot, and don't keep a `let r =
1790        // h.router.clone()` alive across h.shutdown(). Re-clone per
1791        // iteration; the per-call clone is consumed by oneshot.
1792        for path in [
1793            "/memory/themes",
1794            "/memory/facts_about?subject=Sam",
1795            "/memory/contradictions",
1796            "/memory/clusters/any-id",
1797        ] {
1798            let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
1799            assert_eq!(
1800                status,
1801                StatusCode::UNAUTHORIZED,
1802                "{path} should 401 without token"
1803            );
1804        }
1805        h.shutdown(&runtime);
1806    }
1807}
1808
1809#[cfg(test)]
1810mod cors_tests {
1811    use super::is_localhost_origin;
1812
1813    #[test]
1814    fn accepts_canonical_localhost_origins() {
1815        assert!(is_localhost_origin("http://localhost"));
1816        assert!(is_localhost_origin("http://localhost:3000"));
1817        assert!(is_localhost_origin("https://localhost:8443"));
1818        assert!(is_localhost_origin("http://127.0.0.1"));
1819        assert!(is_localhost_origin("http://127.0.0.1:5173"));
1820        assert!(is_localhost_origin("http://[::1]"));
1821        assert!(is_localhost_origin("http://[::1]:8080"));
1822    }
1823
1824    #[test]
1825    fn rejects_remote_origins() {
1826        assert!(!is_localhost_origin("http://example.com"));
1827        assert!(!is_localhost_origin("https://malicious.example"));
1828        assert!(!is_localhost_origin("http://192.168.1.5"));
1829        assert!(!is_localhost_origin("http://10.0.0.1"));
1830    }
1831
1832    #[test]
1833    fn rejects_dns_rebinding_tricks() {
1834        // nip.io and friends — DNS that resolves to 127.0.0.1 but the
1835        // Origin header carries the public-DNS name. Rejecting these
1836        // closes the rebinding-via-Origin gap.
1837        assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
1838        assert!(!is_localhost_origin("http://localhost.evil.com"));
1839        assert!(!is_localhost_origin("http://evil.localhost"));
1840    }
1841
1842    #[test]
1843    fn rejects_non_http_schemes() {
1844        assert!(!is_localhost_origin("file:///"));
1845        assert!(!is_localhost_origin("ws://localhost:3000"));
1846        assert!(!is_localhost_origin("javascript:alert(1)"));
1847    }
1848
1849    #[test]
1850    fn rejects_malformed() {
1851        assert!(!is_localhost_origin(""));
1852        assert!(!is_localhost_origin("localhost"));
1853        assert!(!is_localhost_origin("//localhost"));
1854    }
1855}
1856