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