Skip to main content

solo_api/
http.rs

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