Skip to main content

solo_api/
http.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! HTTP/JSON transport for Solo. Local-only by default — binds to
4//! `127.0.0.1:<port>` and serves the same operations the MCP server
5//! exposes:
6//!
7//! Episode operations:
8//!   - `POST /memory`                — remember (body: { content, source_type?, source_id? })
9//!   - `POST /memory/search`         — recall  (body: { query, limit? })
10//!   - `GET  /memory/{id}`           — inspect
11//!   - `DELETE /memory/{id}?reason=…` — forget
12//!
13//! Maintenance:
14//!   - `POST /memory/consolidate`    — trigger a consolidation pass
15//!   - `POST /backup`                — encrypted online backup
16//!
17//! Derived-layer (v0.4.0+; queries against the Steward's outputs):
18//!   - `GET  /memory/themes?window_days=N&limit=K`
19//!   - `GET  /memory/facts_about?subject=X&predicate=Y&since_ms=N&until_ms=N&include_as_object=B&limit=K`
20//!   - `GET  /memory/contradictions?limit=K`
21//!   - `GET  /memory/clusters/{cluster_id}?full_content=true` (v0.5.0+)
22//!
23//! 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). Pass `include_as_object=true` (v0.5.1+) \
619                         to also surface rows where `subject` appears as the object. v0.4.0+.",
620                    "security": [{ "bearerAuth": [] }, {}],
621                    "parameters": [
622                        { "name": "subject", "in": "query", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Subject id to query (e.g. `Sam`)." },
623                        { "name": "predicate", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Optional predicate filter (e.g. `works_at`)." },
624                        { "name": "since_ms", "in": "query", "required": false, "schema": { "type": "integer" }, "description": "Optional valid_from_ms lower bound (epoch ms)." },
625                        { "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." },
626                        { "name": "include_as_object", "in": "query", "required": false, "schema": { "type": "boolean", "default": false }, "description": "If true, also match rows where `subject` appears as the object (e.g. surface 'Sam pushes back on PRs about Maya' under subject='Maya'). Default false. v0.5.1+." },
627                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
628                    ],
629                    "responses": {
630                        "200": {
631                            "description": "Array of FactHits (possibly empty).",
632                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactHit" } } } }
633                        },
634                        "400": { "description": "Bad request (e.g. empty subject).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
635                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
636                    }
637                }
638            },
639            "/memory/contradictions": {
640                "get": {
641                    "summary": "List Steward-flagged contradictions",
642                    "description":
643                        "Equivalent to MCP tool `memory_contradictions`. Each result includes both \
644                         sides' triple SPO via LEFT JOIN for context. v0.4.0+.",
645                    "security": [{ "bearerAuth": [] }, {}],
646                    "parameters": [
647                        { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 5 } }
648                    ],
649                    "responses": {
650                        "200": {
651                            "description": "Array of ContradictionHits (possibly empty).",
652                            "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ContradictionHit" } } } }
653                        },
654                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
655                    }
656                }
657            },
658            "/memory/clusters/{cluster_id}": {
659                "get": {
660                    "summary": "Inspect a single cluster",
661                    "description":
662                        "Equivalent to MCP tool `memory_inspect_cluster`. Returns the cluster row, \
663                         its (optional) abstraction, and its source episodes. By default each \
664                         episode's `content` is truncated to 200 chars with a trailing `…`. Pass \
665                         `?full_content=true` to get verbatim episode content. v0.5.0+.",
666                    "security": [{ "bearerAuth": [] }, {}],
667                    "parameters": [
668                        { "name": "cluster_id", "in": "path", "required": true, "schema": { "type": "string", "minLength": 1 }, "description": "Cluster id (from a previous GET /memory/themes response)." },
669                        { "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)." }
670                    ],
671                    "responses": {
672                        "200": {
673                            "description": "Cluster snapshot.",
674                            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClusterRecord" } } }
675                        },
676                        "400": { "description": "Bad request (e.g. empty cluster_id).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
677                        "404": { "description": "No such cluster.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } },
678                        "401": { "description": "Missing or invalid bearer token (LAN-bound deployments only)." }
679                    }
680                }
681            }
682        }
683    })
684}
685
686// ---------------------------------------------------------------------------
687// Handlers
688// ---------------------------------------------------------------------------
689
690#[derive(Debug, Deserialize)]
691struct RememberBody {
692    content: String,
693    #[serde(default)]
694    source_type: Option<String>,
695    #[serde(default)]
696    source_id: Option<String>,
697}
698
699#[derive(Debug, Serialize)]
700struct RememberResponse {
701    memory_id: String,
702}
703
704async fn remember_handler(
705    State(s): State<SoloHttpState>,
706    Json(body): Json<RememberBody>,
707) -> Result<Json<RememberResponse>, ApiError> {
708    let content = body.content.trim_end().to_string();
709    if content.is_empty() {
710        return Err(ApiError::bad_request("content must not be empty"));
711    }
712    let embedding = s.embedder.embed(&content).await.map_err(ApiError::from)?;
713    let episode = Episode {
714        memory_id: MemoryId::new(),
715        ts_ms: chrono::Utc::now().timestamp_millis(),
716        source_type: body.source_type.unwrap_or_else(|| "user_message".into()),
717        source_id: body.source_id,
718        content,
719        encoding_context: EncodingContext::default(),
720        provenance: None,
721        confidence: Confidence::new(0.9).unwrap(),
722        strength: 0.5,
723        salience: 0.5,
724        tier: Tier::Hot,
725    };
726    let mid = s.write.remember(episode, embedding).await.map_err(ApiError::from)?;
727    Ok(Json(RememberResponse {
728        memory_id: mid.to_string(),
729    }))
730}
731
732#[derive(Debug, Deserialize)]
733struct RecallBody {
734    query: String,
735    #[serde(default = "default_limit")]
736    limit: usize,
737}
738
739fn default_limit() -> usize {
740    5
741}
742
743async fn recall_handler(
744    State(s): State<SoloHttpState>,
745    Json(body): Json<RecallBody>,
746) -> Result<Json<solo_query::RecallResult>, ApiError> {
747    // solo_query::run_recall handles empty-query rejection (returns
748    // InvalidInput → ApiError::bad_request(400)) and clamps limit
749    // upstream of the embedder call.
750    let result = solo_query::run_recall(
751        &s.embedder,
752        &s.hnsw,
753        &s.pool,
754        &body.query,
755        body.limit,
756    )
757    .await
758    .map_err(ApiError::from)?;
759    Ok(Json(result))
760}
761
762async fn inspect_handler(
763    State(s): State<SoloHttpState>,
764    Path(id): Path<String>,
765) -> Result<Json<solo_query::EpisodeRecord>, ApiError> {
766    let mid = MemoryId::from_str(&id)
767        .map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
768    let row = solo_query::inspect_one(&s.pool, mid)
769        .await
770        .map_err(ApiError::from)?;
771    Ok(Json(row))
772}
773
774// Path 1 derived-layer handlers (v0.4.0+). All three are GET-shaped:
775// pure read-only queries against the Steward's outputs, query-string
776// params for simple filters. Each handler delegates to a single
777// solo_query::derived pipeline and returns the result Vec as JSON.
778// Empty derived layer → 200 with `[]` body (parseable JSON array).
779
780#[derive(Debug, Deserialize)]
781struct ThemesQuery {
782    #[serde(default)]
783    window_days: Option<i64>,
784    #[serde(default = "default_limit")]
785    limit: usize,
786}
787
788async fn themes_handler(
789    State(s): State<SoloHttpState>,
790    Query(q): Query<ThemesQuery>,
791) -> Result<Json<Vec<solo_query::ThemeHit>>, ApiError> {
792    let hits = solo_query::themes(&s.pool, q.window_days, q.limit)
793        .await
794        .map_err(ApiError::from)?;
795    Ok(Json(hits))
796}
797
798#[derive(Debug, Deserialize)]
799struct FactsAboutQuery {
800    subject: String,
801    #[serde(default)]
802    predicate: Option<String>,
803    #[serde(default)]
804    since_ms: Option<i64>,
805    #[serde(default)]
806    until_ms: Option<i64>,
807    /// v0.5.1 Priority 8 — widen the query to also match rows where
808    /// `subject` appears as the object. Default `false`.
809    #[serde(default)]
810    include_as_object: bool,
811    #[serde(default = "default_limit")]
812    limit: usize,
813}
814
815async fn facts_about_handler(
816    State(s): State<SoloHttpState>,
817    Query(q): Query<FactsAboutQuery>,
818) -> Result<Json<Vec<solo_query::FactHit>>, ApiError> {
819    if q.subject.trim().is_empty() {
820        return Err(ApiError::bad_request("subject must not be empty"));
821    }
822    let hits = solo_query::facts_about(
823        &s.pool,
824        &q.subject,
825        &s.user_aliases,
826        q.include_as_object,
827        q.predicate.as_deref(),
828        q.since_ms,
829        q.until_ms,
830        q.limit,
831    )
832    .await
833    .map_err(ApiError::from)?;
834    Ok(Json(hits))
835}
836
837#[derive(Debug, Deserialize)]
838struct ContradictionsQuery {
839    #[serde(default = "default_limit")]
840    limit: usize,
841}
842
843async fn contradictions_handler(
844    State(s): State<SoloHttpState>,
845    Query(q): Query<ContradictionsQuery>,
846) -> Result<Json<Vec<solo_query::ContradictionHit>>, ApiError> {
847    let hits = solo_query::contradictions(&s.pool, q.limit)
848        .await
849        .map_err(ApiError::from)?;
850    Ok(Json(hits))
851}
852
853#[derive(Debug, Deserialize, Default)]
854struct InspectClusterQuery {
855    /// Default `false` — episode `content` is truncated to
856    /// `solo_query::EPISODE_TRUNCATE_CHARS` chars with a trailing `…`.
857    /// `?full_content=true` returns each episode's content verbatim.
858    #[serde(default)]
859    full_content: bool,
860}
861
862async fn inspect_cluster_handler(
863    State(s): State<SoloHttpState>,
864    Path(cluster_id): Path<String>,
865    Query(q): Query<InspectClusterQuery>,
866) -> Result<Json<solo_query::ClusterRecord>, ApiError> {
867    if cluster_id.trim().is_empty() {
868        return Err(ApiError::bad_request("cluster_id must not be empty"));
869    }
870    // `solo_query::inspect_cluster` returns `Err(Error::NotFound)` for
871    // unknown ids; the `From<solo_core::Error> for ApiError` impl
872    // maps that to 404. Same path as `inspect_handler`'s unknown
873    // memory_id behaviour.
874    let record = solo_query::inspect_cluster(
875        &s.pool,
876        &cluster_id,
877        q.full_content,
878    )
879    .await
880    .map_err(ApiError::from)?;
881    Ok(Json(record))
882}
883
884#[derive(Debug, Deserialize)]
885struct ForgetQuery {
886    #[serde(default)]
887    reason: Option<String>,
888}
889
890async fn forget_handler(
891    State(s): State<SoloHttpState>,
892    Path(id): Path<String>,
893    Query(q): Query<ForgetQuery>,
894) -> Result<StatusCode, ApiError> {
895    let mid = MemoryId::from_str(&id).map_err(|e| ApiError::bad_request(format!("invalid id: {e}")))?;
896    let reason = q.reason.unwrap_or_else(|| "http".into());
897    s.write.forget(mid, reason).await.map_err(ApiError::from)?;
898    Ok(StatusCode::NO_CONTENT)
899}
900
901async fn consolidate_handler(
902    State(s): State<SoloHttpState>,
903    body: axum::body::Bytes,
904) -> Result<Json<solo_storage::ConsolidationReport>, ApiError> {
905    // Empty body = default scope (unbounded window). We parse via
906    // `Bytes` rather than `Option<Json<T>>` because axum's `Json`
907    // extractor 400s on an empty body when Content-Type is JSON
908    // (it can't deserialize zero bytes as `T`), and the `Option`
909    // wrapper doesn't reliably degrade that failure to `None`.
910    let scope = if body.is_empty() {
911        solo_storage::ConsolidationScope::default()
912    } else {
913        serde_json::from_slice(&body)
914            .map_err(|e| ApiError::bad_request(format!("invalid JSON: {e}")))?
915    };
916    let report = s.write.consolidate(scope).await.map_err(ApiError::from)?;
917    Ok(Json(report))
918}
919
920#[derive(Debug, Deserialize)]
921struct BackupBody {
922    /// Server-side absolute path where the backup file should be
923    /// written. Must be writable by the Solo process. Refuses to
924    /// overwrite an existing file unless `force = true`.
925    to: String,
926    #[serde(default)]
927    force: bool,
928}
929
930#[derive(Debug, Serialize)]
931struct BackupResponse {
932    path: String,
933    elapsed_ms: u64,
934}
935
936async fn backup_handler(
937    State(s): State<SoloHttpState>,
938    Json(body): Json<BackupBody>,
939) -> Result<Json<BackupResponse>, ApiError> {
940    use std::path::PathBuf;
941
942    let dest = PathBuf::from(&body.to);
943    if dest.as_os_str().is_empty() {
944        return Err(ApiError::bad_request("`to` must not be empty"));
945    }
946    // CRITICAL ORDER: same-file refusal MUST come BEFORE `remove_file`.
947    // A `force: true` request pointing at the daemon's live `solo.db`
948    // would otherwise unlink the source on Linux (the writer's open fd
949    // keeps it accessible until shutdown, but the data dir is empty
950    // from any new opener's perspective). See v0.3.4 release notes.
951    if solo_storage::paths_refer_to_same_file(&s.source_db_path, &dest) {
952        return Err(ApiError::bad_request(format!(
953            "destination {} is the same file as the source database; \
954             refusing to run (would corrupt the live database)",
955            dest.display()
956        )));
957    }
958    if dest.exists() {
959        if !body.force {
960            return Err(ApiError::bad_request(format!(
961                "destination {} exists; pass force=true to overwrite",
962                dest.display()
963            )));
964        }
965        std::fs::remove_file(&dest).map_err(|e| {
966            ApiError::internal(format!(
967                "remove existing destination {}: {e}",
968                dest.display()
969            ))
970        })?;
971    }
972    if let Some(parent) = dest.parent() {
973        if !parent.as_os_str().is_empty() && !parent.is_dir() {
974            return Err(ApiError::bad_request(format!(
975                "destination parent directory {} does not exist",
976                parent.display()
977            )));
978        }
979    }
980
981    let started = std::time::Instant::now();
982    s.write.backup(dest.clone()).await.map_err(ApiError::from)?;
983    let elapsed_ms = started.elapsed().as_millis() as u64;
984
985    Ok(Json(BackupResponse {
986        path: dest.display().to_string(),
987        elapsed_ms,
988    }))
989}
990
991// ---------------------------------------------------------------------------
992// Error mapping
993// ---------------------------------------------------------------------------
994
995#[derive(Debug)]
996pub struct ApiError {
997    status: StatusCode,
998    message: String,
999}
1000
1001impl ApiError {
1002    fn bad_request(msg: impl Into<String>) -> Self {
1003        Self {
1004            status: StatusCode::BAD_REQUEST,
1005            message: msg.into(),
1006        }
1007    }
1008    fn not_found(msg: impl Into<String>) -> Self {
1009        Self {
1010            status: StatusCode::NOT_FOUND,
1011            message: msg.into(),
1012        }
1013    }
1014    fn internal(msg: impl Into<String>) -> Self {
1015        Self {
1016            status: StatusCode::INTERNAL_SERVER_ERROR,
1017            message: msg.into(),
1018        }
1019    }
1020}
1021
1022impl From<solo_core::Error> for ApiError {
1023    fn from(e: solo_core::Error) -> Self {
1024        use solo_core::Error;
1025        match e {
1026            Error::NotFound(msg) => ApiError::not_found(msg),
1027            Error::InvalidInput(msg) => ApiError::bad_request(msg),
1028            Error::Conflict(msg) => Self {
1029                status: StatusCode::CONFLICT,
1030                message: msg,
1031            },
1032            other => ApiError::internal(other.to_string()),
1033        }
1034    }
1035}
1036
1037impl IntoResponse for ApiError {
1038    fn into_response(self) -> Response {
1039        let body = serde_json::json!({
1040            "error": self.message,
1041            "status": self.status.as_u16(),
1042        });
1043        (self.status, Json(body)).into_response()
1044    }
1045}
1046
1047// SQL helper for recall used to live here; consolidated into
1048// solo_query::recall.
1049
1050#[cfg(test)]
1051mod handler_tests {
1052    //! In-process integration tests for the HTTP handler surface. We
1053    //! drive the axum Router directly via `tower::ServiceExt::oneshot`
1054    //! — no real TCP listener needed. Same `Harness`-shape as the MCP
1055    //! tests: real WriterActor + ReaderPool + StubEmbedder + StubVectorIndex.
1056    //!
1057    //! Tests live inline in this module rather than in a `tests/` dir
1058    //! because external integration-test exes triggered Windows UAC
1059    //! ERROR_ELEVATION_REQUIRED on the dev machine.
1060    use super::*;
1061    use axum::body::Body;
1062    use axum::http::{Request, StatusCode};
1063    use http_body_util::BodyExt;
1064    use serde_json::{Value, json};
1065    use solo_core::VectorIndex as _;
1066    use solo_storage::test_support::StubVectorIndex;
1067    use solo_storage::{ReaderPool, StubEmbedder, WriterActor, WriterSpawn};
1068    use std::sync::Arc as StdArc;
1069    use tower::ServiceExt;
1070
1071    struct Harness {
1072        router: axum::Router,
1073        _tmp: tempfile::TempDir,
1074        write_handle_extra: Option<solo_storage::WriteHandle>,
1075        join: Option<std::thread::JoinHandle<()>>,
1076    }
1077
1078    impl Harness {
1079        fn new(runtime: &tokio::runtime::Runtime) -> Self {
1080            Self::new_with_auth(runtime, None)
1081        }
1082
1083        fn new_with_auth(
1084            runtime: &tokio::runtime::Runtime,
1085            bearer_token: Option<String>,
1086        ) -> Self {
1087            use solo_storage::embedder_registry::{EmbedderIdentity, get_or_insert_embedder_id};
1088
1089            let tmp = tempfile::TempDir::new().unwrap();
1090            let dim = 16usize;
1091            let hnsw: StdArc<dyn VectorIndex + Send + Sync> = StdArc::new(StubVectorIndex::new(dim));
1092            let embedder: StdArc<dyn solo_core::Embedder> =
1093                StdArc::new(StubEmbedder::new("stub", "v1", dim));
1094            let path = tmp.path().join("test.db");
1095
1096            // Register an embedder_id so `handle_consolidate` (and any
1097            // other path that filters on `embeddings.embedder_id`) sees
1098            // a production-shaped writer. Existing handler tests pass
1099            // unchanged because the writer just additionally INSERTs
1100            // an `embeddings` row per remember — no assertions look at
1101            // that table.
1102            let embedder_id = {
1103                let conn = solo_storage::test_support::open_test_db_at(&path);
1104                get_or_insert_embedder_id(
1105                    &conn,
1106                    &EmbedderIdentity {
1107                        name: "stub".into(),
1108                        version: "v1".into(),
1109                        dim: dim as u32,
1110                        dtype: "f32".into(),
1111                    },
1112                )
1113                .unwrap()
1114            };
1115
1116            let conn = solo_storage::test_support::open_test_db_at(&path);
1117            let WriterSpawn { handle, join } = WriterActor::spawn_full(
1118                conn,
1119                hnsw.clone(),
1120                tmp.path().to_path_buf(),
1121                embedder_id,
1122            );
1123            let pool: ReaderPool =
1124                runtime.block_on(async { ReaderPool::new(&path, None, hnsw.clone()).unwrap() });
1125            let state = SoloHttpState {
1126                write: handle.clone(),
1127                pool,
1128                embedder,
1129                hnsw,
1130                source_db_path: path.clone(),
1131                user_aliases: Arc::new(Vec::new()),
1132            };
1133            let router = router_with_auth(state, bearer_token);
1134            Harness {
1135                router,
1136                _tmp: tmp,
1137                write_handle_extra: Some(handle),
1138                join: Some(join),
1139            }
1140        }
1141
1142        fn shutdown(mut self, runtime: &tokio::runtime::Runtime) {
1143            let join = self.join.take();
1144            let extra = self.write_handle_extra.take();
1145            runtime.block_on(async move {
1146                drop(extra);
1147                drop(self.router); // drops state → drops pool inside runtime ctx
1148                drop(self._tmp);
1149                if let Some(join) = join {
1150                    let (tx, rx) = std::sync::mpsc::channel();
1151                    std::thread::spawn(move || {
1152                        let _ = tx.send(join.join());
1153                    });
1154                    tokio::task::spawn_blocking(move || {
1155                        rx.recv_timeout(std::time::Duration::from_secs(5))
1156                    })
1157                    .await
1158                    .expect("blocking task")
1159                    .expect("writer thread did not exit within 5s")
1160                    .expect("writer thread panicked");
1161                }
1162            });
1163        }
1164    }
1165
1166    fn rt() -> tokio::runtime::Runtime {
1167        tokio::runtime::Builder::new_multi_thread()
1168            .worker_threads(2)
1169            .enable_all()
1170            .build()
1171            .unwrap()
1172    }
1173
1174    /// Issue one HTTP request through the router and capture status +
1175    /// JSON body. `body` may be `None` for GET/DELETE; `auth` adds an
1176    /// `Authorization` header value verbatim (e.g. `"Bearer xyz"`).
1177    async fn call(
1178        router: axum::Router,
1179        method: &str,
1180        uri: &str,
1181        body: Option<Value>,
1182    ) -> (StatusCode, Value) {
1183        call_with_auth(router, method, uri, body, None).await
1184    }
1185
1186    async fn call_with_auth(
1187        router: axum::Router,
1188        method: &str,
1189        uri: &str,
1190        body: Option<Value>,
1191        auth: Option<&str>,
1192    ) -> (StatusCode, Value) {
1193        let mut req_builder = Request::builder()
1194            .method(method)
1195            .uri(uri)
1196            .header("content-type", "application/json");
1197        if let Some(a) = auth {
1198            req_builder = req_builder.header("authorization", a);
1199        }
1200        let req = if let Some(b) = body {
1201            let bytes = serde_json::to_vec(&b).unwrap();
1202            req_builder.body(Body::from(bytes)).unwrap()
1203        } else {
1204            req_builder = req_builder.header("content-length", "0");
1205            req_builder.body(Body::empty()).unwrap()
1206        };
1207        let resp = router.oneshot(req).await.expect("oneshot");
1208        let status = resp.status();
1209        let body_bytes = resp.into_body().collect().await.unwrap().to_bytes();
1210        let v: Value = if body_bytes.is_empty() {
1211            Value::Null
1212        } else {
1213            serde_json::from_slice(&body_bytes).unwrap_or(Value::Null)
1214        };
1215        (status, v)
1216    }
1217
1218    #[test]
1219    fn health_returns_ok() {
1220        let runtime = rt();
1221        let h = Harness::new(&runtime);
1222        let r = h.router.clone();
1223        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1224        assert_eq!(status, StatusCode::OK);
1225        h.shutdown(&runtime);
1226    }
1227
1228    /// `GET /openapi.json` returns a parseable OpenAPI 3.x document with
1229    /// the four `memory.*` endpoints + their request/response schemas.
1230    /// Acts as a drift detector: if a future commit adds/removes a route
1231    /// without updating `openapi_spec`, this test fails loudly.
1232    #[test]
1233    fn openapi_json_describes_all_endpoints() {
1234        let runtime = rt();
1235        let h = Harness::new(&runtime);
1236        let r = h.router.clone();
1237        let (status, spec) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1238        assert_eq!(status, StatusCode::OK);
1239        assert!(spec.is_object(), "openapi.json must be a JSON object");
1240
1241        // Top-level shape per OpenAPI 3.1.
1242        assert!(
1243            spec.get("openapi")
1244                .and_then(|v| v.as_str())
1245                .is_some_and(|s| s.starts_with("3.")),
1246            "missing or wrong openapi version: {spec}"
1247        );
1248        assert!(spec.pointer("/info/title").is_some());
1249        assert!(spec.pointer("/info/version").is_some());
1250
1251        // Every route the router serves must be documented.
1252        let paths = spec
1253            .get("paths")
1254            .and_then(|v| v.as_object())
1255            .expect("paths must be an object");
1256        for expected in [
1257            "/health",
1258            "/openapi.json",
1259            "/memory",
1260            "/memory/search",
1261            "/memory/consolidate",
1262            "/memory/{id}",
1263            // Path 1 derived-layer endpoints (v0.4.0+):
1264            "/memory/themes",
1265            "/memory/facts_about",
1266            "/memory/contradictions",
1267            // v0.5.0 Priority 3:
1268            "/memory/clusters/{cluster_id}",
1269        ] {
1270            assert!(
1271                paths.contains_key(expected),
1272                "openapi paths missing {expected}: {paths:?}"
1273            );
1274        }
1275
1276        // Method coverage on /memory/{id}: must document both GET (inspect)
1277        // and DELETE (forget).
1278        let memid = paths.get("/memory/{id}").expect("memory/{id}");
1279        assert!(memid.get("get").is_some(), "GET /memory/{{id}} undocumented");
1280        assert!(
1281            memid.get("delete").is_some(),
1282            "DELETE /memory/{{id}} undocumented"
1283        );
1284
1285        // Component schemas referenced from paths must be defined.
1286        for schema_name in [
1287            "RememberRequest",
1288            "RememberResponse",
1289            "RecallRequest",
1290            "RecallResult",
1291            "EpisodeRecord",
1292            "ApiError",
1293            "ConsolidationScope",
1294            "ConsolidationReport",
1295            // Path 1 derived-layer schemas (v0.4.0+):
1296            "ThemeHit",
1297            "FactHit",
1298            "ContradictionHit",
1299            // v0.5.0 Priority 3:
1300            "ClusterRecord",
1301        ] {
1302            let ptr = format!("/components/schemas/{schema_name}");
1303            assert!(
1304                spec.pointer(&ptr).is_some(),
1305                "component schema {schema_name} missing"
1306            );
1307        }
1308
1309        // bearerAuth security scheme is declared (LAN deployments need it).
1310        assert!(
1311            spec.pointer("/components/securitySchemes/bearerAuth")
1312                .is_some(),
1313            "bearerAuth security scheme missing"
1314        );
1315
1316        h.shutdown(&runtime);
1317    }
1318
1319    /// `/openapi.json` must remain unauthenticated even when bearer auth
1320    /// is enabled — the spec describes the API shape, not secrets, and
1321    /// codegen tooling shouldn't need a credential to fetch it.
1322    #[test]
1323    fn openapi_json_is_exempt_from_bearer_auth() {
1324        let runtime = rt();
1325        let h = Harness::new_with_auth(&runtime, Some("super-secret".into()));
1326        let r = h.router.clone();
1327        // No Authorization header → still 200 for /openapi.json.
1328        let (status, _body) = runtime.block_on(call(r, "GET", "/openapi.json", None));
1329        assert_eq!(status, StatusCode::OK);
1330        h.shutdown(&runtime);
1331    }
1332
1333    #[test]
1334    fn remember_returns_memory_id() {
1335        let runtime = rt();
1336        let h = Harness::new(&runtime);
1337        let r = h.router.clone();
1338        let (status, body) = runtime.block_on(call(
1339            r,
1340            "POST",
1341            "/memory",
1342            Some(json!({ "content": "http harness test" })),
1343        ));
1344        assert_eq!(status, StatusCode::OK);
1345        let mid = body.get("memory_id").and_then(|v| v.as_str()).unwrap();
1346        assert_eq!(mid.len(), 36, "uuid length");
1347        h.shutdown(&runtime);
1348    }
1349
1350    #[test]
1351    fn empty_content_returns_400() {
1352        let runtime = rt();
1353        let h = Harness::new(&runtime);
1354        let r = h.router.clone();
1355        let (status, body) =
1356            runtime.block_on(call(r, "POST", "/memory", Some(json!({ "content": "" }))));
1357        assert_eq!(status, StatusCode::BAD_REQUEST);
1358        assert!(
1359            body.get("error")
1360                .and_then(|e| e.as_str())
1361                .map(|s| s.contains("must not be empty"))
1362                .unwrap_or(false),
1363            "got: {body}"
1364        );
1365        h.shutdown(&runtime);
1366    }
1367
1368    #[test]
1369    fn empty_query_returns_400() {
1370        let runtime = rt();
1371        let h = Harness::new(&runtime);
1372        let r = h.router.clone();
1373        let (status, body) = runtime.block_on(call(
1374            r,
1375            "POST",
1376            "/memory/search",
1377            Some(json!({ "query": "" })),
1378        ));
1379        assert_eq!(status, StatusCode::BAD_REQUEST);
1380        assert!(
1381            body.get("error")
1382                .and_then(|e| e.as_str())
1383                .map(|s| s.contains("must not be empty"))
1384                .unwrap_or(false),
1385            "got: {body}"
1386        );
1387        h.shutdown(&runtime);
1388    }
1389
1390    #[test]
1391    fn inspect_unknown_returns_404() {
1392        let runtime = rt();
1393        let h = Harness::new(&runtime);
1394        let r = h.router.clone();
1395        let (status, body) = runtime.block_on(call(
1396            r,
1397            "GET",
1398            "/memory/00000000-0000-7000-8000-000000000000",
1399            None,
1400        ));
1401        assert_eq!(status, StatusCode::NOT_FOUND);
1402        assert!(body.get("error").is_some(), "got: {body}");
1403        h.shutdown(&runtime);
1404    }
1405
1406    #[test]
1407    fn inspect_invalid_id_returns_400() {
1408        let runtime = rt();
1409        let h = Harness::new(&runtime);
1410        let r = h.router.clone();
1411        let (status, _body) = runtime.block_on(call(r, "GET", "/memory/not-a-uuid", None));
1412        assert_eq!(status, StatusCode::BAD_REQUEST);
1413        h.shutdown(&runtime);
1414    }
1415
1416    #[test]
1417    fn forget_unknown_returns_404() {
1418        let runtime = rt();
1419        let h = Harness::new(&runtime);
1420        let r = h.router.clone();
1421        let (status, _body) = runtime.block_on(call(
1422            r,
1423            "DELETE",
1424            "/memory/00000000-0000-7000-8000-000000000000",
1425            None,
1426        ));
1427        assert_eq!(status, StatusCode::NOT_FOUND);
1428        h.shutdown(&runtime);
1429    }
1430
1431    /// `POST /memory/consolidate` runs the cluster pass and returns
1432    /// the report as JSON. With an empty body, `ConsolidationScope`
1433    /// defaults to unbounded; with a non-empty body, the
1434    /// `window_days` field is honored. The Harness's writer is
1435    /// spawned without a Steward, so `abstractions_built` stays 0
1436    /// even when `clusters_built` is nonzero — same posture as the
1437    /// daemon today.
1438    #[test]
1439    fn consolidate_endpoint_returns_report() {
1440        let runtime = rt();
1441        let h = Harness::new(&runtime);
1442        let r = h.router.clone();
1443        runtime.block_on(async move {
1444            // Empty DB → all-zero report; structural assertion only.
1445            let (status, body) = call(r.clone(), "POST", "/memory/consolidate", None).await;
1446            assert_eq!(status, StatusCode::OK);
1447            for field in [
1448                "episodes_seen",
1449                "clusters_built",
1450                "episodes_clustered",
1451                "abstractions_built",
1452                "triples_built",
1453                "contradictions_found",
1454            ] {
1455                assert!(
1456                    body.get(field).and_then(|v| v.as_u64()).is_some(),
1457                    "missing field {field}: {body}"
1458                );
1459            }
1460            assert_eq!(body["episodes_seen"], 0);
1461            assert_eq!(body["clusters_built"], 0);
1462
1463            // Non-empty body with window_days → still 200; unmistakable
1464            // shape round-trips through ConsolidationScope's serde.
1465            let (status2, _body2) = call(
1466                r,
1467                "POST",
1468                "/memory/consolidate",
1469                Some(json!({ "window_days": 7 })),
1470            )
1471            .await;
1472            assert_eq!(status2, StatusCode::OK);
1473        });
1474        h.shutdown(&runtime);
1475    }
1476
1477    #[test]
1478    fn auth_required_routes_reject_missing_token() {
1479        let runtime = rt();
1480        let h = Harness::new_with_auth(&runtime, Some("secret-xyz".into()));
1481        let r = h.router.clone();
1482        runtime.block_on(async move {
1483            // No Authorization header → 401.
1484            let (status, _body) = call(
1485                r.clone(),
1486                "POST",
1487                "/memory",
1488                Some(json!({ "content": "x" })),
1489            )
1490            .await;
1491            assert_eq!(status, StatusCode::UNAUTHORIZED);
1492
1493            // Wrong token → 401.
1494            let (status, _body) = call_with_auth(
1495                r.clone(),
1496                "POST",
1497                "/memory",
1498                Some(json!({ "content": "x" })),
1499                Some("Bearer wrong-token"),
1500            )
1501            .await;
1502            assert_eq!(status, StatusCode::UNAUTHORIZED);
1503
1504            // Correct token → handler runs (200).
1505            let (status, body) = call_with_auth(
1506                r.clone(),
1507                "POST",
1508                "/memory",
1509                Some(json!({ "content": "authed" })),
1510                Some("Bearer secret-xyz"),
1511            )
1512            .await;
1513            assert_eq!(status, StatusCode::OK);
1514            assert!(body.get("memory_id").is_some());
1515        });
1516        h.shutdown(&runtime);
1517    }
1518
1519    #[test]
1520    fn health_endpoint_does_not_require_auth() {
1521        let runtime = rt();
1522        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1523        let r = h.router.clone();
1524        let (status, _body) = runtime.block_on(call(r, "GET", "/health", None));
1525        // Liveness probes should work without credentials.
1526        assert_eq!(status, StatusCode::OK);
1527        h.shutdown(&runtime);
1528    }
1529
1530    #[test]
1531    fn auth_response_includes_www_authenticate_header() {
1532        // Verify the WWW-Authenticate hint that lets a well-behaved
1533        // client know it's a bearer-auth scheme. We check via raw
1534        // request → response (oneshot returns Response, but our
1535        // call() helper drops the headers; build the request manually).
1536        let runtime = rt();
1537        let h = Harness::new_with_auth(&runtime, Some("secret".into()));
1538        let r = h.router.clone();
1539        runtime.block_on(async move {
1540            let req = Request::builder()
1541                .method("POST")
1542                .uri("/memory")
1543                .header("content-type", "application/json")
1544                .body(Body::from(serde_json::to_vec(&json!({ "content": "x" })).unwrap()))
1545                .unwrap();
1546            let resp = r.oneshot(req).await.unwrap();
1547            assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1548            let www = resp
1549                .headers()
1550                .get("www-authenticate")
1551                .and_then(|v| v.to_str().ok())
1552                .unwrap_or("");
1553            assert!(
1554                www.starts_with("Bearer"),
1555                "expected WWW-Authenticate: Bearer..., got: {www}"
1556            );
1557        });
1558        h.shutdown(&runtime);
1559    }
1560
1561    #[test]
1562    fn full_remember_recall_inspect_forget_round_trip() {
1563        let runtime = rt();
1564        let h = Harness::new(&runtime);
1565        let r = h.router.clone();
1566        runtime.block_on(async move {
1567            // POST /memory
1568            let (status, body) = call(
1569                r.clone(),
1570                "POST",
1571                "/memory",
1572                Some(json!({ "content": "round-trip content" })),
1573            )
1574            .await;
1575            assert_eq!(status, StatusCode::OK);
1576            let mid = body
1577                .get("memory_id")
1578                .and_then(|v| v.as_str())
1579                .unwrap()
1580                .to_string();
1581
1582            // POST /memory/search — exact-match (StubEmbedder) returns the row.
1583            let (status, body) = call(
1584                r.clone(),
1585                "POST",
1586                "/memory/search",
1587                Some(json!({ "query": "round-trip content", "limit": 5 })),
1588            )
1589            .await;
1590            assert_eq!(status, StatusCode::OK);
1591            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1592            assert!(
1593                hits.iter()
1594                    .any(|h| h.get("content").and_then(|c| c.as_str())
1595                        == Some("round-trip content")),
1596                "expected hit with content; got: {body}"
1597            );
1598
1599            // GET /memory/{id}
1600            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1601            assert_eq!(status, StatusCode::OK);
1602            assert_eq!(body.get("status").and_then(|v| v.as_str()), Some("active"));
1603
1604            // DELETE /memory/{id}
1605            let (status, _body) =
1606                call(r.clone(), "DELETE", &format!("/memory/{mid}"), None).await;
1607            assert_eq!(status, StatusCode::NO_CONTENT);
1608
1609            // GET again — still readable but status='forgotten'
1610            let (status, body) = call(r.clone(), "GET", &format!("/memory/{mid}"), None).await;
1611            assert_eq!(status, StatusCode::OK);
1612            assert_eq!(
1613                body.get("status").and_then(|v| v.as_str()),
1614                Some("forgotten")
1615            );
1616
1617            // POST /memory/search — forgotten row excluded.
1618            let (status, body) = call(
1619                r.clone(),
1620                "POST",
1621                "/memory/search",
1622                Some(json!({ "query": "round-trip content", "limit": 5 })),
1623            )
1624            .await;
1625            assert_eq!(status, StatusCode::OK);
1626            let hits = body.get("hits").and_then(|v| v.as_array()).unwrap();
1627            assert!(
1628                hits.iter().all(|h| h.get("memory_id").and_then(|m| m.as_str())
1629                    != Some(mid.as_str())),
1630                "forgotten row should be excluded from recall: {body}"
1631            );
1632        });
1633        h.shutdown(&runtime);
1634    }
1635
1636    // Path 1 derived-layer endpoint tests (v0.4.0+). Wire-path only —
1637    // the actual content correctness is covered by solo-query::derived's
1638    // own tests (Sub-task A). These verify the HTTP shape: GET routing,
1639    // Query-string param parsing, JSON-array response body, validation
1640    // 400s for invalid inputs.
1641
1642    #[test]
1643    fn themes_endpoint_returns_empty_array_on_empty_db() {
1644        let runtime = rt();
1645        let h = Harness::new(&runtime);
1646        let r = h.router.clone();
1647        let (status, body) =
1648            runtime.block_on(call(r, "GET", "/memory/themes", None));
1649        assert_eq!(status, StatusCode::OK);
1650        assert!(body.is_array(), "expected array, got {body}");
1651        assert_eq!(body.as_array().unwrap().len(), 0);
1652        h.shutdown(&runtime);
1653    }
1654
1655    #[test]
1656    fn themes_endpoint_passes_through_query_params() {
1657        let runtime = rt();
1658        let h = Harness::new(&runtime);
1659        let r = h.router.clone();
1660        let (status, body) = runtime.block_on(call(
1661            r,
1662            "GET",
1663            "/memory/themes?window_days=7&limit=20",
1664            None,
1665        ));
1666        assert_eq!(status, StatusCode::OK);
1667        assert!(body.is_array(), "expected array, got {body}");
1668        h.shutdown(&runtime);
1669    }
1670
1671    #[test]
1672    fn facts_about_endpoint_requires_subject() {
1673        let runtime = rt();
1674        let h = Harness::new(&runtime);
1675        let r = h.router.clone();
1676        // Missing subject — axum's Query extractor 422 (Unprocessable
1677        // Entity) on missing required field; some axum versions
1678        // surface as 400. Accept either.
1679        let (status, _body) =
1680            runtime.block_on(call(r, "GET", "/memory/facts_about", None));
1681        assert!(
1682            status == StatusCode::BAD_REQUEST
1683                || status == StatusCode::UNPROCESSABLE_ENTITY,
1684            "expected 400 or 422 for missing subject, got {status}"
1685        );
1686        h.shutdown(&runtime);
1687    }
1688
1689    #[test]
1690    fn facts_about_endpoint_rejects_blank_subject() {
1691        let runtime = rt();
1692        let h = Harness::new(&runtime);
1693        let r = h.router.clone();
1694        // Whitespace-only subject reaches the handler then trips its
1695        // own validation → ApiError::bad_request → 400.
1696        let (status, body) = runtime.block_on(call(
1697            r,
1698            "GET",
1699            "/memory/facts_about?subject=%20%20",
1700            None,
1701        ));
1702        assert_eq!(status, StatusCode::BAD_REQUEST);
1703        assert!(
1704            body.get("error")
1705                .and_then(|v| v.as_str())
1706                .is_some_and(|s| s.contains("subject")),
1707            "expected error mentioning subject, got {body}"
1708        );
1709        h.shutdown(&runtime);
1710    }
1711
1712    #[test]
1713    fn facts_about_endpoint_returns_empty_array_for_unknown_subject() {
1714        let runtime = rt();
1715        let h = Harness::new(&runtime);
1716        let r = h.router.clone();
1717        let (status, body) = runtime.block_on(call(
1718            r,
1719            "GET",
1720            "/memory/facts_about?subject=NobodyKnows",
1721            None,
1722        ));
1723        assert_eq!(status, StatusCode::OK);
1724        assert_eq!(body.as_array().unwrap().len(), 0);
1725        h.shutdown(&runtime);
1726    }
1727
1728    #[test]
1729    fn facts_about_endpoint_parses_include_as_object_query_param() {
1730        // v0.5.1 P8: `?include_as_object=true` must parse cleanly
1731        // through the `Query<FactsAboutQuery>` extractor. If the
1732        // struct field is missing or wrongly typed, axum returns
1733        // 400/422 before reaching the handler. We don't seed
1734        // triples; we only need the request to reach the handler
1735        // and produce a normal 200 + empty array. Mirrors
1736        // `inspect_cluster_endpoint_passes_full_content_query_param`.
1737        let runtime = rt();
1738        let h = Harness::new(&runtime);
1739        let r = h.router.clone();
1740        let (status, body) = runtime.block_on(call(
1741            r,
1742            "GET",
1743            "/memory/facts_about?subject=Maya&include_as_object=true",
1744            None,
1745        ));
1746        assert_eq!(
1747            status,
1748            StatusCode::OK,
1749            "expected 200 with include_as_object query param, got {status}"
1750        );
1751        assert!(body.is_array());
1752        h.shutdown(&runtime);
1753    }
1754
1755    #[test]
1756    fn inspect_cluster_endpoint_unknown_id_returns_404() {
1757        // Maps `Error::NotFound` from `solo_query::inspect_cluster`
1758        // through `ApiError::from` → 404. Mirrors the unknown-memory
1759        // case for `GET /memory/{id}`.
1760        let runtime = rt();
1761        let h = Harness::new(&runtime);
1762        let r = h.router.clone();
1763        let (status, body) = runtime.block_on(call(
1764            r,
1765            "GET",
1766            "/memory/clusters/no-such-cluster",
1767            None,
1768        ));
1769        assert_eq!(status, StatusCode::NOT_FOUND);
1770        assert!(
1771            body.get("error")
1772                .and_then(|v| v.as_str())
1773                .is_some_and(|s| s.contains("no-such-cluster")),
1774            "expected error mentioning cluster id, got {body}"
1775        );
1776        h.shutdown(&runtime);
1777    }
1778
1779    #[test]
1780    fn inspect_cluster_endpoint_passes_full_content_query_param() {
1781        // Even with no matching cluster (→ 404), the request must
1782        // reach the handler — proves the `?full_content=true` query
1783        // string parses cleanly (Query<InspectClusterQuery>::default
1784        // path didn't choke). If we accidentally fail at the extractor
1785        // we'd get a 400/422, not the expected 404.
1786        let runtime = rt();
1787        let h = Harness::new(&runtime);
1788        let r = h.router.clone();
1789        let (status, _body) = runtime.block_on(call(
1790            r,
1791            "GET",
1792            "/memory/clusters/missing?full_content=true",
1793            None,
1794        ));
1795        assert_eq!(status, StatusCode::NOT_FOUND);
1796        h.shutdown(&runtime);
1797    }
1798
1799    #[test]
1800    fn contradictions_endpoint_returns_empty_array_on_empty_db() {
1801        let runtime = rt();
1802        let h = Harness::new(&runtime);
1803        let r = h.router.clone();
1804        let (status, body) = runtime.block_on(call(
1805            r,
1806            "GET",
1807            "/memory/contradictions",
1808            None,
1809        ));
1810        assert_eq!(status, StatusCode::OK);
1811        assert!(body.is_array());
1812        assert_eq!(body.as_array().unwrap().len(), 0);
1813        h.shutdown(&runtime);
1814    }
1815
1816    #[test]
1817    fn derived_endpoints_require_bearer_when_auth_enabled() {
1818        let runtime = rt();
1819        let h = Harness::new_with_auth(&runtime, Some("secret-token".to_string()));
1820        // Each of the three new endpoints should reject missing token.
1821        // Per the existing tests' shutdown-timing comment: don't hold a
1822        // long-lived router clone across multiple iterations — drop the
1823        // clone before each subsequent oneshot, and don't keep a `let r =
1824        // h.router.clone()` alive across h.shutdown(). Re-clone per
1825        // iteration; the per-call clone is consumed by oneshot.
1826        for path in [
1827            "/memory/themes",
1828            "/memory/facts_about?subject=Sam",
1829            "/memory/contradictions",
1830            "/memory/clusters/any-id",
1831        ] {
1832            let (status, _) = runtime.block_on(call(h.router.clone(), "GET", path, None));
1833            assert_eq!(
1834                status,
1835                StatusCode::UNAUTHORIZED,
1836                "{path} should 401 without token"
1837            );
1838        }
1839        h.shutdown(&runtime);
1840    }
1841}
1842
1843#[cfg(test)]
1844mod cors_tests {
1845    use super::is_localhost_origin;
1846
1847    #[test]
1848    fn accepts_canonical_localhost_origins() {
1849        assert!(is_localhost_origin("http://localhost"));
1850        assert!(is_localhost_origin("http://localhost:3000"));
1851        assert!(is_localhost_origin("https://localhost:8443"));
1852        assert!(is_localhost_origin("http://127.0.0.1"));
1853        assert!(is_localhost_origin("http://127.0.0.1:5173"));
1854        assert!(is_localhost_origin("http://[::1]"));
1855        assert!(is_localhost_origin("http://[::1]:8080"));
1856    }
1857
1858    #[test]
1859    fn rejects_remote_origins() {
1860        assert!(!is_localhost_origin("http://example.com"));
1861        assert!(!is_localhost_origin("https://malicious.example"));
1862        assert!(!is_localhost_origin("http://192.168.1.5"));
1863        assert!(!is_localhost_origin("http://10.0.0.1"));
1864    }
1865
1866    #[test]
1867    fn rejects_dns_rebinding_tricks() {
1868        // nip.io and friends — DNS that resolves to 127.0.0.1 but the
1869        // Origin header carries the public-DNS name. Rejecting these
1870        // closes the rebinding-via-Origin gap.
1871        assert!(!is_localhost_origin("http://127.0.0.1.nip.io"));
1872        assert!(!is_localhost_origin("http://localhost.evil.com"));
1873        assert!(!is_localhost_origin("http://evil.localhost"));
1874    }
1875
1876    #[test]
1877    fn rejects_non_http_schemes() {
1878        assert!(!is_localhost_origin("file:///"));
1879        assert!(!is_localhost_origin("ws://localhost:3000"));
1880        assert!(!is_localhost_origin("javascript:alert(1)"));
1881    }
1882
1883    #[test]
1884    fn rejects_malformed() {
1885        assert!(!is_localhost_origin(""));
1886        assert!(!is_localhost_origin("localhost"));
1887        assert!(!is_localhost_origin("//localhost"));
1888    }
1889}
1890