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 four operations the MCP server
5//! exposes:
6//!
7//!   - `POST /memory`            — remember (body: { content, source_type?, source_id? })
8//!   - `POST /memory/search`     — recall  (body: { query, limit? })
9//!   - `GET  /memory/{id}`       — inspect
10//!   - `DELETE /memory/{id}?reason=…` — forget
11//!
12//! There's no auth at this layer. The threat model is local-machine
13//! single-user; binding to `127.0.0.1` keeps the surface off the LAN.
14//! A future commit can add bearer-token auth + LAN binding.
15//!
16//! ## Lifecycle
17//!
18//! `serve_http(addr, server, shutdown)` binds to `addr`, runs axum with
19//! `with_graceful_shutdown(shutdown)`, returns when shutdown fires or
20//! the listener errors. `solo http-serve` invokes this from inside a
21//! `OneShotContext`, so writer + reader pool + lockfile stay live for
22//! the server's lifetime and clean up properly afterwards.
23
24use std::net::SocketAddr;
25use std::str::FromStr;
26use std::sync::Arc;
27
28use axum::extract::{Path, Query, State};
29use axum::http::{HeaderValue, Method, StatusCode};
30use axum::response::{IntoResponse, Response};
31use axum::routing::{get, post};
32use axum::{Json, Router};
33use serde::{Deserialize, Serialize};
34use solo_core::{
35    Confidence, Embedder, EncodingContext, Episode, MemoryId, Tier, VectorIndex,
36};
37use solo_storage::{ReaderPool, WriteHandle};
38use tower_http::cors::{AllowOrigin, CorsLayer};
39use tower_http::trace::TraceLayer;
40use tower_http::validate_request::{ValidateRequest, ValidateRequestHeaderLayer};
41
42#[derive(Clone)]
43pub struct SoloHttpState {
44    pub write: WriteHandle,
45    pub pool: ReaderPool,
46    pub embedder: Arc<dyn Embedder>,
47    pub hnsw: Arc<dyn VectorIndex + Send + Sync>,
48}
49
50/// Build the router with optional bearer-token auth.
51///
52/// When `bearer_token` is `Some(t)`, every request except `GET /health`
53/// (the unauthenticated liveness probe) requires
54/// `Authorization: Bearer t`. The /health exemption keeps load
55/// balancers and uptime monitors from needing a credential.
56///
57/// `tower_http::validate_request::ValidateRequestHeaderLayer::bearer`
58/// returns 401 with a WWW-Authenticate header on missing/wrong token.
59pub fn router_with_auth(state: SoloHttpState, bearer_token: Option<String>) -> Router {
60    let cors = build_cors_layer();
61    // Public, always-unauthenticated routes:
62    //   - GET /health: liveness probe (load balancers, uptime monitors).
63    //   - GET /openapi.json: machine-readable API description for client
64    //     codegen + browser-UI tooling (TypeScript / OpenAPI Generator,
65    //     curl-tools, etc.). The spec describes the API shape, not
66    //     secrets — fine to serve unauthenticated even on a LAN-bound
67    //     instance.
68    let public = Router::new()
69        .route("/health", get(|| async { "ok" }))
70        .route("/openapi.json", get(openapi_handler));
71
72    let mut authed = Router::new()
73        .route("/memory", post(remember_handler))
74        .route("/memory/search", post(recall_handler))
75        .route("/memory/consolidate", post(consolidate_handler))
76        .route("/memory/{id}", get(inspect_handler).delete(forget_handler))
77        .with_state(state);
78    if let Some(token) = bearer_token {
79        // Custom validator (the helper-shaped `::bearer` constructor is
80        // deprecated in tower-http ≥ 0.6.7). Returns 401 with
81        // `WWW-Authenticate: Bearer` on missing or wrong token.
82        authed = authed.layer(ValidateRequestHeaderLayer::custom(BearerToken::new(token)));
83    }
84
85    public
86        .merge(authed)
87        .layer(cors)
88        .layer(TraceLayer::new_for_http())
89}
90
91/// Convenience wrapper: no auth (loopback-only deployments).
92pub fn router(state: SoloHttpState) -> Router {
93    router_with_auth(state, None)
94}
95
96fn build_cors_layer() -> CorsLayer {
97    // Permissive-localhost CORS: allow any localhost / 127.0.0.1 origin so
98    // browser-based UIs running on a different local port can call the API
99    // without preflight friction. We do NOT use `Any` because that would
100    // allow arbitrary remote origins to talk to our localhost server via
101    // a victim's browser. With bearer-token auth enabled the practical
102    // impact is reduced (the cross-origin attacker still can't supply
103    // the token), but principle of least privilege says refuse anyway.
104    //
105    // When the server is bound to a non-loopback address (auth required),
106    // the same CORS predicate keeps localhost-only browser clients —
107    // suitable for trusted-LAN deployments where the LAN client itself
108    // tunnels through ssh/wireguard back to localhost. Wider CORS for
109    // genuine cross-origin browser use is a future config knob.
110    CorsLayer::new()
111        .allow_origin(AllowOrigin::predicate(|origin: &HeaderValue, _req| {
112            origin
113                .to_str()
114                .map(is_localhost_origin)
115                .unwrap_or(false)
116        }))
117        .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
118        .allow_headers([
119            axum::http::header::CONTENT_TYPE,
120            axum::http::header::AUTHORIZATION,
121        ])
122}
123
124/// `tower_http::validate_request::ValidateRequest` impl that accepts
125/// any request whose `Authorization` header equals `Bearer <token>`
126/// (constant-time comparison via `subtle`-style byte equality —
127/// String == is constant-time in Rust for equal-length operands; for
128/// unequal lengths the early-return is fine here because the token
129/// length isn't sensitive). On miss, returns 401 with
130/// `WWW-Authenticate: Bearer realm="solo"`.
131#[derive(Clone)]
132struct BearerToken {
133    expected: HeaderValue,
134}
135
136impl BearerToken {
137    fn new(token: String) -> Self {
138        let expected = HeaderValue::try_from(format!("Bearer {token}"))
139            .expect("bearer token must be a valid HTTP header value");
140        Self { expected }
141    }
142}
143
144impl<B> ValidateRequest<B> for BearerToken {
145    type ResponseBody = axum::body::Body;
146
147    fn validate(
148        &mut self,
149        request: &mut axum::http::Request<B>,
150    ) -> Result<(), axum::http::Response<Self::ResponseBody>> {
151        let got = request.headers().get(axum::http::header::AUTHORIZATION);
152        match got {
153            Some(value) if value == &self.expected => Ok(()),
154            _ => {
155                let mut resp = axum::http::Response::new(axum::body::Body::empty());
156                *resp.status_mut() = StatusCode::UNAUTHORIZED;
157                resp.headers_mut().insert(
158                    axum::http::header::WWW_AUTHENTICATE,
159                    HeaderValue::from_static(r#"Bearer realm="solo""#),
160                );
161                Err(resp)
162            }
163        }
164    }
165}
166
167/// True if `origin` is `http(s)://localhost[:port]` or
168/// `http(s)://127.0.0.1[:port]` or `http(s)://[::1][:port]` (loopback IPv6).
169/// Anything else (incl. nip.io tricks like `127.0.0.1.nip.io`) is rejected.
170fn is_localhost_origin(origin: &str) -> bool {
171    let rest = origin
172        .strip_prefix("http://")
173        .or_else(|| origin.strip_prefix("https://"));
174    let host = match rest {
175        Some(r) => r,
176        None => return false,
177    };
178    // Strip path (shouldn't appear on Origin headers but defend anyway).
179    let host = host.split('/').next().unwrap_or(host);
180    // Strip port.
181    let host = if let Some(idx) = host.rfind(':') {
182        // For [::1]:port, keep the brackets in the host part.
183        if host.starts_with('[') {
184            // Find matching ']'; everything up to and including it is the host.
185            host.find(']')
186                .map(|i| &host[..=i])
187                .unwrap_or(host)
188        } else {
189            &host[..idx]
190        }
191    } else {
192        host
193    };
194    matches!(host, "localhost" | "127.0.0.1" | "[::1]")
195}
196
197/// Bind + serve. `shutdown` is awaited inside axum's
198/// `with_graceful_shutdown`; resolving it triggers a clean drain.
199/// `bearer_token = None` runs unauthenticated (loopback default);
200/// `Some(t)` requires `Authorization: Bearer t` on every request
201/// except `GET /health`.
202pub async fn serve_http(
203    addr: SocketAddr,
204    state: SoloHttpState,
205    bearer_token: Option<String>,
206    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
207) -> std::io::Result<()> {
208    let auth_kind = if bearer_token.is_some() {
209        "bearer"
210    } else {
211        "none"
212    };
213    let app = router_with_auth(state, bearer_token);
214    let listener = tokio::net::TcpListener::bind(addr).await?;
215    tracing::info!(%addr, auth = auth_kind, "solo http: listening");
216    axum::serve(listener, app)
217        .with_graceful_shutdown(shutdown)
218        .await
219}
220
221// ---------------------------------------------------------------------------
222// OpenAPI 3.1 spec
223// ---------------------------------------------------------------------------
224
225/// Serve the hand-crafted OpenAPI 3.1 spec at `GET /openapi.json`.
226///
227/// We keep the spec hand-written (rather than deriving via `utoipa`)
228/// for v0.1: 4 simple endpoints, types live across crate boundaries
229/// (`solo_query::RecallResult`, `solo_query::EpisodeRecord`), and a
230/// `utoipa` retrofit would touch every crate. Hand-crafted is one
231/// JSON literal in this file; a smoke test in `handler_tests` parses
232/// the response and asserts the expected paths + components are
233/// present, so drift between spec and code is caught at PR time.
234async fn openapi_handler() -> Json<serde_json::Value> {
235    Json(openapi_spec())
236}
237
238/// Build the OpenAPI 3.1 spec describing Solo's HTTP transport.
239/// Public so the smoke test + future client-codegen tooling can
240/// produce the same document without spinning up the server.
241pub fn openapi_spec() -> serde_json::Value {
242    serde_json::json!({
243        "openapi": "3.1.0",
244        "info": {
245            "title": "Solo HTTP API",
246            "description":
247                "Local-first personal memory daemon. The HTTP transport \
248                 mirrors the four MCP tools (memory.remember / recall / \
249                 inspect / forget). Default deployment is loopback-only \
250                 (127.0.0.1); LAN-bound deployments require a bearer \
251                 token via `solo http-serve --bind <ip> --bearer-token-file <path>`.",
252            "version": env!("CARGO_PKG_VERSION"),
253            "license": { "name": "Apache-2.0" }
254        },
255        "servers": [
256            { "url": "http://127.0.0.1:7437", "description": "Default loopback (replace port with your --http-port)" }
257        ],
258        "components": {
259            "securitySchemes": {
260                "bearerAuth": {
261                    "type": "http",
262                    "scheme": "bearer",
263                    "description":
264                        "Bearer-token auth. Required only on LAN-bound deployments \
265                         (`solo http-serve --bind <non-loopback> --bearer-token-file <path>`); \
266                         the default `127.0.0.1` deployment is unauthenticated. \
267                         `GET /health` and `GET /openapi.json` are exempt from auth even \
268                         on bearer-protected instances."
269                }
270            },
271            "schemas": {
272                "RememberRequest": {
273                    "type": "object",
274                    "required": ["content"],
275                    "properties": {
276                        "content": { "type": "string", "minLength": 1, "description": "Episode content to embed + store." },
277                        "source_type": { "type": "string", "description": "Free-form source tag (e.g. `user_message`, `tool_output`). Defaults to `user_message`." },
278                        "source_id": { "type": "string", "description": "Optional upstream ID for traceability." }
279                    },
280                    "additionalProperties": false
281                },
282                "RememberResponse": {
283                    "type": "object",
284                    "required": ["memory_id"],
285                    "properties": {
286                        "memory_id": { "type": "string", "format": "uuid", "description": "UUID v7 assigned to the new episode." }
287                    }
288                },
289                "RecallRequest": {
290                    "type": "object",
291                    "required": ["query"],
292                    "properties": {
293                        "query": { "type": "string", "minLength": 1, "description": "Natural-language query; embedded by the same model as stored episodes." },
294                        "limit": { "type": "integer", "minimum": 1, "maximum": 50, "default": 5, "description": "Max number of hits to return." }
295                    },
296                    "additionalProperties": false
297                },
298                "RecallResult": {
299                    "type": "object",
300                    "description":
301                        "Recall response. Fields are stable across v0.1 but not exhaustively documented here — \
302                         see `solo_query::RecallResult` in the source for the canonical shape. \
303                         Treat as a forward-compatible JSON object.",
304                    "additionalProperties": true
305                },
306                "ConsolidationScope": {
307                    "type": "object",
308                    "description": "Filter + flags for consolidation. All fields optional; empty body = unbounded defaults.",
309                    "properties": {
310                        "window_days": { "type": "integer", "nullable": true, "description": "Restrict to memories with ts_ms >= now - window_days * 86400000. Null/omitted = unbounded." },
311                        "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." }
312                    },
313                    "additionalProperties": false
314                },
315                "ConsolidationReport": {
316                    "type": "object",
317                    "required": [
318                        "episodes_seen", "clusters_built", "clusters_merged",
319                        "clusters_absorbed", "existing_clusters_merged",
320                        "episodes_clustered", "abstractions_built",
321                        "abstractions_regenerated", "triples_built",
322                        "contradictions_found"
323                    ],
324                    "properties": {
325                        "episodes_seen":             { "type": "integer", "minimum": 0 },
326                        "clusters_built":            { "type": "integer", "minimum": 0, "description": "Brand-new clusters that survived to be persisted (post in-run-merge, post cross-run-absorb)." },
327                        "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." },
328                        "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." },
329                        "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." },
330                        "episodes_clustered":        { "type": "integer", "minimum": 0 },
331                        "abstractions_built":        { "type": "integer", "minimum": 0, "description": "Fresh abstractions persisted for newly-built clusters. 0 when no LlmClient is wired." },
332                        "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." },
333                        "triples_built":             { "type": "integer", "minimum": 0 },
334                        "contradictions_found":      { "type": "integer", "minimum": 0 }
335                    }
336                },
337                "EpisodeRecord": {
338                    "type": "object",
339                    "description":
340                        "Inspect response: full episode record. Fields are stable across v0.1 but not \
341                         exhaustively documented here — see `solo_query::EpisodeRecord` in the source. \
342                         Treat as a forward-compatible JSON object.",
343                    "additionalProperties": true
344                },
345                "ApiError": {
346                    "type": "object",
347                    "required": ["error", "status"],
348                    "properties": {
349                        "error": { "type": "string" },
350                        "status": { "type": "integer", "minimum": 400, "maximum": 599 }
351                    }
352                }
353            }
354        },
355        "paths": {
356            "/health": {
357                "get": {
358                    "summary": "Liveness probe",
359                    "description": "Returns plain text `ok`. Always unauthenticated.",
360                    "responses": {
361                        "200": {
362                            "description": "Server is up.",
363                            "content": { "text/plain": { "schema": { "type": "string", "example": "ok" } } }
364                        }
365                    }
366                }
367            },
368            "/openapi.json": {
369                "get": {
370                    "summary": "Self-describing OpenAPI 3.1 spec",
371                    "description": "Returns this document. Always unauthenticated.",
372                    "responses": {
373                        "200": {
374                            "description": "OpenAPI 3.1 document.",
375                            "content": { "application/json": { "schema": { "type": "object" } } }
376                        }
377                    }
378                }
379            },
380            "/memory": {
381                "post": {
382                    "summary": "Remember (store an episode)",
383                    "description": "Equivalent to MCP tool `memory.remember`.",
384                    "security": [{ "bearerAuth": [] }, {}],
385                    "requestBody": {
386                        "required": true,
387                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberRequest" } } }
388                    },
389                    "responses": {
390                        "200": {
391                            "description": "Memory stored; returns the new MemoryId.",
392                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RememberResponse" } } }
393                        },
394                        "400": { "description": "Bad request (e.g. empty content).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
395                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
396                    }
397                }
398            },
399            "/memory/search": {
400                "post": {
401                    "summary": "Recall (vector search)",
402                    "description": "Equivalent to MCP tool `memory.recall`. Embeds the query, runs HNSW search, returns the top-K hits in cosine-distance order.",
403                    "security": [{ "bearerAuth": [] }, {}],
404                    "requestBody": {
405                        "required": true,
406                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallRequest" } } }
407                    },
408                    "responses": {
409                        "200": {
410                            "description": "Search results.",
411                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecallResult" } } }
412                        },
413                        "400": { "description": "Bad request (e.g. empty query).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
414                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
415                    }
416                }
417            },
418            "/memory/consolidate": {
419                "post": {
420                    "summary": "Run a consolidation pass (clustering + abstraction)",
421                    "description":
422                        "Idempotent. Triggers the SWS-equivalent clustering pass; if a `Steward` LLM is wired \
423                         on the server, also runs the REM-equivalent abstraction pass that populates \
424                         `semantic_abstractions` and `triples`. Empty request body = default scope (unbounded \
425                         window). Equivalent to the `solo consolidate` CLI.",
426                    "security": [{ "bearerAuth": [] }, {}],
427                    "requestBody": {
428                        "required": false,
429                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationScope" } } }
430                    },
431                    "responses": {
432                        "200": {
433                            "description": "Consolidation complete; report counts the work done.",
434                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConsolidationReport" } } }
435                        },
436                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
437                    }
438                }
439            },
440            "/memory/{id}": {
441                "get": {
442                    "summary": "Inspect a memory by ID",
443                    "description": "Equivalent to MCP tool `memory.inspect`.",
444                    "security": [{ "bearerAuth": [] }, {}],
445                    "parameters": [{
446                        "name": "id",
447                        "in": "path",
448                        "required": true,
449                        "schema": { "type": "string", "format": "uuid" },
450                        "description": "MemoryId (UUID v7)."
451                    }],
452                    "responses": {
453                        "200": {
454                            "description": "Episode record.",
455                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EpisodeRecord" } } }
456                        },
457                        "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
458                        "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
459                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
460                    }
461                },
462                "delete": {
463                    "summary": "Forget (soft-delete) a memory by ID",
464                    "description":
465                        "Equivalent to MCP tool `memory.forget`. Soft-delete: flips `episodes.status = 'forgotten'` \
466                         and tombstones the HNSW vector. The row + embedding are preserved for forensics; \
467                         re-running `solo reembed` after this does NOT restore visibility.",
468                    "security": [{ "bearerAuth": [] }, {}],
469                    "parameters": [
470                        { "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
471                        { "name": "reason", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Free-form reason logged via tracing (not yet persisted to the DB)." }
472                    ],
473                    "responses": {
474                        "204": { "description": "Forgotten (or already forgotten — idempotent)." },
475                        "400": { "description": "Malformed ID.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
476                        "404": { "description": "No such memory.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
477                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
478                    }
479                }
480            }
481        }
482    })
483}
484
485// ---------------------------------------------------------------------------
486// Handlers
487// ---------------------------------------------------------------------------
488
489#[derive(Debug, Deserialize)]
490struct RememberBody {
491    content: String,
492    #[serde(default)]
493    source_type: Option<String>,
494    #[serde(default)]
495    source_id: Option<String>,
496}
497
498#[derive(Debug, Serialize)]
499struct RememberResponse {
500    memory_id: String,
501}
502
503async fn remember_handler(
504    State(s): State<SoloHttpState>,
505    Json(body): Json<RememberBody>,
506) -> Result<Json<RememberResponse>, ApiError> {
507    let content = body.content.trim_end().to_string();
508    if content.is_empty() {
509        return Err(ApiError::bad_request("content must not be empty"));
510    }
511    let embedding = s.embedder.embed(&content).await.map_err(ApiError::from)?;
512    let episode = Episode {
513        memory_id: MemoryId::new(),
514        ts_ms: chrono::Utc::now().timestamp_millis(),
515        source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
516        source_id: body.source_id,
517        content,
518        encoding_context: EncodingContext::default(),
519        provenance: None,
520        confidence: Confidence::new(0.9).unwrap(),
521        strength: 0.5,
522        salience: 0.5,
523        tier: Tier::Hot,
524    };
525    let mid = s.write.remember(episode, embedding).await.map_err(ApiError::from)?;
526    Ok(Json(RememberResponse {
527        memory_id: mid.to_string(),
528    }))
529}
530
531#[derive(Debug, Deserialize)]
532struct RecallBody {
533    query: String,
534    #[serde(default = "default_limit")]
535    limit: usize,
536}
537
538fn default_limit() -> usize {
539    5
540}
541
542async fn recall_handler(
543    State(s): State<SoloHttpState>,
544    Json(body): Json<RecallBody>,
545) -> Result<Json<solo_query::RecallResult>, ApiError> {
546    // solo_query::run_recall handles empty-query rejection (returns
547    // InvalidInput → ApiError::bad_request(400)) and clamps limit
548    // upstream of the embedder call.
549    let result = solo_query::run_recall(
550        &s.embedder,
551        &s.hnsw,
552        &s.pool,
553        &body.query,
554        body.limit,
555    )
556    .await
557    .map_err(ApiError::from)?;
558    Ok(Json(result))
559}
560
561async fn inspect_handler(
562    State(s): State<SoloHttpState>,
563    Path(id): Path<String>,
564) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
565    let mid = MemoryId::from_str(&id)
566        .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
567    let row = solo_query::inspect_one(&s.pool, mid)
568        .await
569        .map_err(ApiError::from)?;
570    Ok(Json(row))
571}
572
573#[derive(Debug, Deserialize)]
574struct ForgetQuery {
575    #[serde(default)]
576    reason: Option<String>,
577}
578
579async fn forget_handler(
580    State(s): State<SoloHttpState>,
581    Path(id): Path<String>,
582    Query(q): Query<ForgetQuery>,
583) -> Result<StatusCode, ApiError> {
584    let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
585    let reason = q.reason.unwrap_or_else(|| "http".into());
586    s.write.forget(mid, reason).await.map_err(ApiError::from)?;
587    Ok(StatusCode::NO_CONTENT)
588}
589
590async fn consolidate_handler(
591    State(s): State<SoloHttpState>,
592    body: axum::body::Bytes,
593) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
594    // Empty body = default scope (unbounded window). We parse via
595    // `Bytes` rather than `Option<Json<T>>` because axum's `Json`
596    // extractor 400s on an empty body when Content-Type is JSON
597    // (it can't deserialize zero bytes as `T`), and the `Option`
598    // wrapper doesn't reliably degrade that failure to `None`.
599    let scope = if body.is_empty() {
600        solo_storage::ConsolidationScope::default()
601    } else {
602        serde_json::from_slice(&body)
603            .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
604    };
605    let report = s.write.consolidate(scope).await.map_err(ApiError::from)?;
606    Ok(Json(report))
607}
608
609// ---------------------------------------------------------------------------
610// Error mapping
611// ---------------------------------------------------------------------------
612
613#[derive(Debug)]
614pub struct ApiError {
615    status: StatusCode,
616    message: String,
617}
618
619impl ApiError {
620    fn bad_request(msg: impl Into<String>) -> Self {
621        Self {
622            status: StatusCode::BAD_REQUEST,
623            message: msg.into(),
624        }
625    }
626    fn not_found(msg: impl Into<String>) -> Self {
627        Self {
628            status: StatusCode::NOT_FOUND,
629            message: msg.into(),
630        }
631    }
632    fn internal(msg: impl Into<String>) -> Self {
633        Self {
634            status: StatusCode::INTERNAL_SERVER_ERROR,
635            message: msg.into(),
636        }
637    }
638}
639
640impl From<solo_core::Error> for ApiError {
641    fn from(e: solo_core::Error) -> Self {
642        use solo_core::Error;
643        match e {
644            Error::NotFound(msg) => ApiError::not_found(msg),
645            Error::InvalidInput(msg) => ApiError::bad_request(msg),
646            Error::Conflict(msg) => Self {
647                status: StatusCode::CONFLICT,
648                message: msg,
649            },
650            other => ApiError::internal(other.to_string()),
651        }
652    }
653}
654
655impl IntoResponse for ApiError {
656    fn into_response(self) -> Response {
657        let body = serde_json::json!({
658            "error": self.message,
659            "status": self.status.as_u16(),
660        });
661        (self.status, Json(body)).into_response()
662    }
663}
664
665// SQL helper for recall used to live here; consolidated into
666// solo_query::recall.
667
668#[cfg(test)]
669mod handler_tests {
670    //! In-process integration tests for the HTTP handler surface. We
671    //! drive the axum Router directly via `tower::ServiceExt::oneshot`
672    //! — no real TCP listener needed. Same `Harness`-shape as the MCP
673    //! tests: real WriterActor + ReaderPool + StubEmbedder + StubVectorIndex.
674    //!
675    //! Tests live inline in this module rather than in a `tests/` dir
676    //! because external integration-test exes triggered Windows UAC
677    //! ERROR_ELEVATION_REQUIRED on the dev machine.
678    use super::*;
679    use axum::body::Body;
680    use axum::http::{Request, StatusCode};
681    use http_body_util::BodyExt;
682    use serde_json::{Value, json};
683    use solo_core::VectorIndex as _;
684    use solo_storage::test_support::StubVectorIndex;
685    use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
686    use std::sync::Arc as StdArc;
687    use tower::ServiceExt;
688
689    struct Harness {
690        router: axum::Router,
691        _tmp: tempfile::TempDir,
692        write_handle_extra: Option<solo_storage::WriteHandle>,
693        join: Option<std::thread::JoinHandle<()>>,
694    }
695
696    impl Harness {
697        fn new(runtime: &tokio::runtime::Runtime) -> Self {
698            Self::new_with_auth(runtime, None)
699        }
700
701        fn new_with_auth(
702            runtime: &tokio::runtime::Runtime,
703            bearer_token: Option<String>,
704        ) -> Self {
705            use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
706
707            let tmp = tempfile::TempDir::new().unwrap();
708            let dim = 16usize;
709            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
710            let embedder: StdArc<dyn solo_core::Embedder> =
711                StdArc::new(StubEmbedder::new("stub", "v1", dim));
712            let path = tmp.path().join("test.db");
713
714            // Register an embedder_id so `handle_consolidate` (and any
715            // other path that filters on `embeddings.embedder_id`) sees
716            // a production-shaped writer. Existing handler tests pass
717            // unchanged because the writer just additionally INSERTs
718            // an `embeddings` row per remember — no assertions look at
719            // that table.
720            let embedder_id = {
721                let conn = solo_storage::test_support::open_test_db_at(&path);
722                get_or_insert_embedder_id(
723                    &conn,
724                    &EmbedderIdentity {
725                        name: "stub".into(),
726                        version: "v1".into(),
727                        dim: dim as u32,
728                        dtype: "f32".into(),
729                    },
730                )
731                .unwrap()
732            };
733
734            let conn = solo_storage::test_support::open_test_db_at(&path);
735            let WriterSpawn { handle, join } = WriterActor::spawn_full(
736                conn,
737                hnsw.clone(),
738                tmp.path().to_path_buf(),
739                embedder_id,
740            );
741            let pool: ReaderPool =
742                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
743            let state = SoloHttpState {
744                write: handle.clone(),
745                pool,
746                embedder,
747                hnsw,
748            };
749            let router = router_with_auth(state, bearer_token);
750            Harness {
751                router,
752                _tmp: tmp,
753                write_handle_extra: Some(handle),
754                join: Some(join),
755            }
756        }
757
758        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
759            let join = self.join.take();
760            let extra = self.write_handle_extra.take();
761            runtime.block_on(async move {
762                drop(extra);
763                drop(self.router); // drops state → drops pool inside runtime ctx
764                drop(self._tmp);
765                if let Some(join) = join {
766                    let (tx, rx) = std::sync::mpsc::channel();
767                    std::thread::spawn(move || {
768                        let _ = tx.send(join.join());
769                    });
770                    tokio::task::spawn_blocking(move || {
771                        rx.recv_timeout(std::time::Duration::from_secs(5))
772                    })
773                    .await
774                    .expect("blocking task")
775                    .expect("writer thread did not exit within 5s")
776                    .expect("writer thread panicked");
777                }
778            });
779        }
780    }
781
782    fn rt() -> tokio::runtime::Runtime {
783        tokio::runtime::Builder::new_multi_thread()
784            .worker_threads(2)
785            .enable_all()
786            .build()
787            .unwrap()
788    }
789
790    /// Issue one HTTP request through the router and capture status +
791    /// JSON body. `body` may be `None` for GET/DELETE; `auth` adds an
792    /// `Authorization` header value verbatim (e.g. `"Bearer xyz"`).
793    async fn call(
794        router: axum::Router,
795        method: &str,
796        uri: &str,
797        body: Option<Value>,
798    ) -> (StatusCode, Value) {
799        call_with_auth(router, method, uri, body, None).await
800    }
801
802    async fn call_with_auth(
803        router: axum::Router,
804        method: &str,
805        uri: &str,
806        body: Option<Value>,
807        auth: Option<&str>,
808    ) -> (StatusCode, Value) {
809        let mut req_builder = Request::builder()
810            .method(method)
811            .uri(uri)
812            .header("content-type", "application/json");
813        if let Some(a) = auth {
814            req_builder = req_builder.header("authorization", a);
815        }
816        let req = if let Some(b) = body {
817            let bytes = serde_json::to_vec(&b).unwrap();
818            req_builder.body(Body::from(bytes)).unwrap()
819        } else {
820            req_builder = req_builder.header("content-length", "0");
821            req_builder.body(Body::empty()).unwrap()
822        };
823        let resp = router.oneshot(req).await.expect("oneshot");
824        let status = resp.status();
825        let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
826        let v: Value = if body_bytes.is_empty() {
827            Value::Null
828        } else {
829            serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
830        };
831        (status, v)
832    }
833
834    #[test]
835    fn health_returns_ok() {
836        let runtime = rt();
837        let h = Harness::new(&runtime);
838        let r = h.router.clone();
839        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
840        assert_eq!(status, StatusCode::OK);
841        h.shutdown(&runtime);
842    }
843
844    /// `GET /openapi.json` returns a parseable OpenAPI 3.x document with
845    /// the four `memory.*` endpoints + their request/response schemas.
846    /// Acts as a drift detector: if a future commit adds/removes a route
847    /// without updating `openapi_spec`, this test fails loudly.
848    #[test]
849    fn openapi_json_describes_all_endpoints() {
850        let runtime = rt();
851        let h = Harness::new(&runtime);
852        let r = h.router.clone();
853        let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
854        assert_eq!(status, StatusCode::OK);
855        assert!(spec.is_object(), "openapi.json must be a JSON object");
856
857        // Top-level shape per OpenAPI 3.1.
858        assert!(
859            spec.get("openapi")
860                .and_then(|v| v.as_str())
861                .is_some_and(|s| s.starts_with("3.")),
862            "missing or wrong openapi version: {spec}"
863        );
864        assert!(spec.pointer("/info/title").is_some());
865        assert!(spec.pointer("/info/version").is_some());
866
867        // Every route the router serves must be documented.
868        let paths = spec
869            .get("paths")
870            .and_then(|v| v.as_object())
871            .expect("paths must be an object");
872        for expected in [
873            "/health",
874            "/openapi.json",
875            "/memory",
876            "/memory/search",
877            "/memory/consolidate",
878            "/memory/{id}",
879        ] {
880            assert!(
881                paths.contains_key(expected),
882                "openapi paths missing {expected}: {paths:?}"
883            );
884        }
885
886        // Method coverage on /memory/{id}: must document both GET (inspect)
887        // and DELETE (forget).
888        let memid = paths.get("/memory/{id}").expect("memory/{id}");
889        assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
890        assert!(
891            memid.get("delete").is_some(),
892            "DELETE /memory/{{id}} undocumented"
893        );
894
895        // Component schemas referenced from paths must be defined.
896        for schema_name in [
897            "RememberRequest",
898            "RememberResponse",
899            "RecallRequest",
900            "RecallResult",
901            "EpisodeRecord",
902            "ApiError",
903            "ConsolidationScope",
904            "ConsolidationReport",
905        ] {
906            let ptr = format!("/components/schemas/{schema_name}");
907            assert!(
908                spec.pointer(&ptr).is_some(),
909                "component schema {schema_name} missing"
910            );
911        }
912
913        // bearerAuth security scheme is declared (LAN deployments need it).
914        assert!(
915            spec.pointer("/components/securitySchemes/bearerAuth")
916                .is_some(),
917            "bearerAuth security scheme missing"
918        );
919
920        h.shutdown(&runtime);
921    }
922
923    /// `/openapi.json` must remain unauthenticated even when bearer auth
924    /// is enabled — the spec describes the API shape, not secrets, and
925    /// codegen tooling shouldn't need a credential to fetch it.
926    #[test]
927    fn openapi_json_is_exempt_from_bearer_auth() {
928        let runtime = rt();
929        let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
930        let r = h.router.clone();
931        // No Authorization header → still 200 for /openapi.json.
932        let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
933        assert_eq!(status, StatusCode::OK);
934        h.shutdown(&runtime);
935    }
936
937    #[test]
938    fn remember_returns_memory_id() {
939        let runtime = rt();
940        let h = Harness::new(&runtime);
941        let r = h.router.clone();
942        let (status, body) = runtime.block_on(call(
943            r,
944            "POST",
945            "/memory",
946            Some(json!({ "content": "http harness test" })),
947        ));
948        assert_eq!(status, StatusCode::OK);
949        let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
950        assert_eq!(mid.len(), 36, "uuid length");
951        h.shutdown(&runtime);
952    }
953
954    #[test]
955    fn empty_content_returns_400() {
956        let runtime = rt();
957        let h = Harness::new(&runtime);
958        let r = h.router.clone();
959        let (status, body) =
960            runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
961        assert_eq!(status, StatusCode::BAD_REQUEST);
962        assert!(
963            body.get("error")
964                .and_then(|e| e.as_str())
965                .map(|s| s.contains("must not be empty"))
966                .unwrap_or(false),
967            "got: {body}"
968        );
969        h.shutdown(&runtime);
970    }
971
972    #[test]
973    fn empty_query_returns_400() {
974        let runtime = rt();
975        let h = Harness::new(&runtime);
976        let r = h.router.clone();
977        let (status, body) = runtime.block_on(call(
978            r,
979            "POST",
980            "/memory/search",
981            Some(json!({ "query": "" })),
982        ));
983        assert_eq!(status, StatusCode::BAD_REQUEST);
984        assert!(
985            body.get("error")
986                .and_then(|e| e.as_str())
987                .map(|s| s.contains("must not be empty"))
988                .unwrap_or(false),
989            "got: {body}"
990        );
991        h.shutdown(&runtime);
992    }
993
994    #[test]
995    fn inspect_unknown_returns_404() {
996        let runtime = rt();
997        let h = Harness::new(&runtime);
998        let r = h.router.clone();
999        let (status, body) = runtime.block_on(call(
1000            r,
1001            "GET",
1002            "/memory/00000000-0000-7000-8000-000000000000",
1003            None,
1004        ));
1005        assert_eq!(status, StatusCode::NOT_FOUND);
1006        assert!(body.get("error").is_some(), "got: {body}");
1007        h.shutdown(&runtime);
1008    }
1009
1010    #[test]
1011    fn inspect_invalid_id_returns_400() {
1012        let runtime = rt();
1013        let h = Harness::new(&runtime);
1014        let r = h.router.clone();
1015        let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1016        assert_eq!(status, StatusCode::BAD_REQUEST);
1017        h.shutdown(&runtime);
1018    }
1019
1020    #[test]
1021    fn forget_unknown_returns_404() {
1022        let runtime = rt();
1023        let h = Harness::new(&runtime);
1024        let r = h.router.clone();
1025        let (status, _body) = runtime.block_on(call(
1026            r,
1027            "DELETE",
1028            "/memory/00000000-0000-7000-8000-000000000000",
1029            None,
1030        ));
1031        assert_eq!(status, StatusCode::NOT_FOUND);
1032        h.shutdown(&runtime);
1033    }
1034
1035    /// `POST /memory/consolidate` runs the cluster pass and returns
1036    /// the report as JSON. With an empty body, `ConsolidationScope`
1037    /// defaults to unbounded; with a non-empty body, the
1038    /// `window_days` field is honored. The Harness's writer is
1039    /// spawned without a Steward, so `abstractions_built` stays 0
1040    /// even when `clusters_built` is nonzero — same posture as the
1041    /// daemon today.
1042    #[test]
1043    fn consolidate_endpoint_returns_report() {
1044        let runtime = rt();
1045        let h = Harness::new(&runtime);
1046        let r = h.router.clone();
1047        runtime.block_on(async move {
1048            // Empty DB → all-zero report; structural assertion only.
1049            let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1050            assert_eq!(status, StatusCode::OK);
1051            for field in [
1052                "episodes_seen",
1053                "clusters_built",
1054                "episodes_clustered",
1055                "abstractions_built",
1056                "triples_built",
1057                "contradictions_found",
1058            ] {
1059                assert!(
1060                    body.get(field).and_then(|v| v.as_u64()).is_some(),
1061                    "missing field {field}: {body}"
1062                );
1063            }
1064            assert_eq!(body["episodes_seen"], 0);
1065            assert_eq!(body["clusters_built"], 0);
1066
1067            // Non-empty body with window_days → still 200; unmistakable
1068            // shape round-trips through ConsolidationScope's serde.
1069            let (status2, _body2) = call(
1070                r,
1071                "POST",
1072                "/memory/consolidate",
1073                Some(json!({ "window_days": 7 })),
1074            )
1075            .await;
1076            assert_eq!(status2, StatusCode::OK);
1077        });
1078        h.shutdown(&runtime);
1079    }
1080
1081    #[test]
1082    fn auth_required_routes_reject_missing_token() {
1083        let runtime = rt();
1084        let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1085        let r = h.router.clone();
1086        runtime.block_on(async move {
1087            // No Authorization header → 401.
1088            let (status, _body) = call(
1089                r.clone(),
1090                "POST",
1091                "/memory",
1092                Some(json!({ "content": "x" })),
1093            )
1094            .await;
1095            assert_eq!(status, StatusCode::UNAUTHORIZED);
1096
1097            // Wrong token → 401.
1098            let (status, _body) = call_with_auth(
1099                r.clone(),
1100                "POST",
1101                "/memory",
1102                Some(json!({ "content": "x" })),
1103                Some("Bearer wrong-token"),
1104            )
1105            .await;
1106            assert_eq!(status, StatusCode::UNAUTHORIZED);
1107
1108            // Correct token → handler runs (200).
1109            let (status, body) = call_with_auth(
1110                r.clone(),
1111                "POST",
1112                "/memory",
1113                Some(json!({ "content": "authed" })),
1114                Some("Bearer secret-xyz"),
1115            )
1116            .await;
1117            assert_eq!(status, StatusCode::OK);
1118            assert!(body.get("memory_id").is_some());
1119        });
1120        h.shutdown(&runtime);
1121    }
1122
1123    #[test]
1124    fn health_endpoint_does_not_require_auth() {
1125        let runtime = rt();
1126        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1127        let r = h.router.clone();
1128        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1129        // Liveness probes should work without credentials.
1130        assert_eq!(status, StatusCode::OK);
1131        h.shutdown(&runtime);
1132    }
1133
1134    #[test]
1135    fn auth_response_includes_www_authenticate_header() {
1136        // Verify the WWW-Authenticate hint that lets a well-behaved
1137        // client know it's a bearer-auth scheme. We check via raw
1138        // request → response (oneshot returns Response, but our
1139        // call() helper drops the headers; build the request manually).
1140        let runtime = rt();
1141        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1142        let r = h.router.clone();
1143        runtime.block_on(async move {
1144            let req = Request::builder()
1145                .method("POST")
1146                .uri("/memory")
1147                .header("content-type", "application/json")
1148                .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
1149                .unwrap();
1150            let resp = r.oneshot(req).await.unwrap();
1151            assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1152            let www = resp
1153                .headers()
1154                .get("www-authenticate")
1155                .and_then(|v| v.to_str().ok())
1156                .unwrap_or("");
1157            assert!(
1158                www.starts_with("Bearer"),
1159                "expected WWW-Authenticate: Bearer..., got: {www}"
1160            );
1161        });
1162        h.shutdown(&runtime);
1163    }
1164
1165    #[test]
1166    fn full_remember_recall_inspect_forget_round_trip() {
1167        let runtime = rt();
1168        let h = Harness::new(&runtime);
1169        let r = h.router.clone();
1170        runtime.block_on(async move {
1171            // POST /memory
1172            let (status, body) = call(
1173                r.clone(),
1174                "POST",
1175                "/memory",
1176                Some(json!({ "content": "round-trip content" })),
1177            )
1178            .await;
1179            assert_eq!(status, StatusCode::OK);
1180            let mid = body
1181                .get("memory_id")
1182                .and_then(|v| v.as_str())
1183                .unwrap()
1184                .to_string();
1185
1186            // POST /memory/search — exact-match (StubEmbedder) returns the row.
1187            let (status, body) = call(
1188                r.clone(),
1189                "POST",
1190                "/memory/search",
1191                Some(json!({ "query": "round-trip content", "limit": 5 })),
1192            )
1193            .await;
1194            assert_eq!(status, StatusCode::OK);
1195            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1196            assert!(
1197                hits.iter()
1198                    .any(|h| h.get("content").and_then(|c| c.as_str())
1199                        == Some("round-trip content")),
1200                "expected hit with content; got: {body}"
1201            );
1202
1203            // GET /memory/{id}
1204            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1205            assert_eq!(status, StatusCode::OK);
1206            assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
1207
1208            // DELETE /memory/{id}
1209            let (status, _body) =
1210                call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1211            assert_eq!(status, StatusCode::NO_CONTENT);
1212
1213            // GET again — still readable but status='forgotten'
1214            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1215            assert_eq!(status, StatusCode::OK);
1216            assert_eq!(
1217                body.get("status").and_then(|v| v.as_str()),
1218                Some("forgotten")
1219            );
1220
1221            // POST /memory/search — forgotten row excluded.
1222            let (status, body) = call(
1223                r.clone(),
1224                "POST",
1225                "/memory/search",
1226                Some(json!({ "query": "round-trip content", "limit": 5 })),
1227            )
1228            .await;
1229            assert_eq!(status, StatusCode::OK);
1230            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1231            assert!(
1232                hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
1233                    != Some(mid.as_str())),
1234                "forgotten row should be excluded from recall: {body}"
1235            );
1236        });
1237        h.shutdown(&runtime);
1238    }
1239}
1240
1241#[cfg(test)]
1242mod cors_tests {
1243    use super::is_localhost_origin;
1244
1245    #[test]
1246    fn accepts_canonical_localhost_origins() {
1247        assert!(is_localhost_origin("http://localhost"));
1248        assert!(is_localhost_origin("http://localhost:3000"));
1249        assert!(is_localhost_origin("https://localhost:8443"));
1250        assert!(is_localhost_origin("http://127.0.0.1"));
1251        assert!(is_localhost_origin("http://127.0.0.1:5173"));
1252        assert!(is_localhost_origin("http://[::1]"));
1253        assert!(is_localhost_origin("http://[::1]:8080"));
1254    }
1255
1256    #[test]
1257    fn rejects_remote_origins() {
1258        assert!(!is_localhost_origin("http://example.com"));
1259        assert!(!is_localhost_origin("https://malicious.example"));
1260        assert!(!is_localhost_origin("http://192.168.1.5"));
1261        assert!(!is_localhost_origin("http://10.0.0.1"));
1262    }
1263
1264    #[test]
1265    fn rejects_dns_rebinding_tricks() {
1266        // nip.io and friends — DNS that resolves to 127.0.0.1 but the
1267        // Origin header carries the public-DNS name. Rejecting these
1268        // closes the rebinding-via-Origin gap.
1269        assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
1270        assert!(!is_localhost_origin("http://localhost.evil.com"));
1271        assert!(!is_localhost_origin("http://evil.localhost"));
1272    }
1273
1274    #[test]
1275    fn rejects_non_http_schemes() {
1276        assert!(!is_localhost_origin("file:///"));
1277        assert!(!is_localhost_origin("ws://localhost:3000"));
1278        assert!(!is_localhost_origin("javascript:alert(1)"));
1279    }
1280
1281    #[test]
1282    fn rejects_malformed() {
1283        assert!(!is_localhost_origin(""));
1284        assert!(!is_localhost_origin("localhost"));
1285        assert!(!is_localhost_origin("//localhost"));
1286    }
1287}
1288