Skip to main content

solo_api/
http.rs

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