Skip to main content

brainos_httpadapter/
lib.rs

1//! # Brain HTTP REST API Adapter
2//!
3//! Exposes Brain's signal processing pipeline over HTTP using axum.
4//!
5//! ## Routes
6//! - `GET  /health`             — health check (no auth required)
7//! - `GET  /metrics`            — Prometheus-format counters (no auth required)
8//! - `GET  /ui`                 — embedded memory explorer web UI (no auth required)
9//! - `GET  /openapi.json`       — OpenAPI 3.0 specification (no auth required)
10//! - `GET  /api`                 — Swagger UI (no auth required)
11//! - `POST /v1/signals`         — submit a signal (requires write)
12//! - `GET  /v1/signals/:id`     — retrieve cached signal response (requires read)
13//! - `POST /v1/memory/search`   — semantic search over stored facts (requires read)
14//! - `GET  /v1/memory/facts`    — list all semantic facts (requires read)
15//! - `GET  /v1/events`          — SSE stream of signal events + proactive notifications (requires read)
16//!
17//! ## Authentication
18//! All `/v1/*` routes require `Authorization: Bearer <api-key>` header.
19//! A random key is generated on `brain init` and printed to stdout.
20
21use std::{
22    collections::HashMap,
23    net::SocketAddr,
24    num::NonZeroUsize,
25    sync::{
26        atomic::{AtomicU64, Ordering},
27        Arc,
28    },
29    time::Instant,
30};
31
32use axum::{
33    extract::{Path, Query, State},
34    http::{HeaderMap, StatusCode},
35    response::{
36        sse::{Event, KeepAlive, Sse},
37        IntoResponse, Json,
38    },
39    routing::{delete, get, post},
40    Router,
41};
42use brain_core::ApiKeyConfig;
43use serde::{Deserialize, Serialize};
44use tokio::sync::Mutex;
45use uuid::Uuid;
46
47use signal::{Signal, SignalResponse, SignalSource};
48
49// ─── Errors ──────────────────────────────────────────────────────────────────
50
51#[derive(Debug, thiserror::Error)]
52pub enum HttpAdapterError {
53    #[error("Server error: {0}")]
54    Server(String),
55}
56
57// ─── Request / Response DTOs ─────────────────────────────────────────────────
58
59/// Incoming signal body (POST /v1/signals).
60#[derive(Debug, Deserialize)]
61pub struct SignalRequest {
62    pub source: Option<String>,
63    pub channel: Option<String>,
64    pub sender: Option<String>,
65    pub content: String,
66    pub metadata: Option<HashMap<String, String>>,
67    /// Memory namespace (default: "personal").
68    pub namespace: Option<String>,
69    /// Originating agent identity (e.g. "claude-code", "open-code").
70    pub agent: Option<String>,
71    /// Session ID for conversation continuity. Send back to reuse a session.
72    pub session_id: Option<String>,
73}
74
75/// Search request body (POST /v1/memory/search).
76#[derive(Debug, Deserialize)]
77pub struct SearchRequest {
78    pub query: String,
79    pub top_k: Option<usize>,
80    /// Filter results to this namespace only (optional).
81    pub namespace: Option<String>,
82}
83
84/// Namespace statistics (GET /v1/memory/namespaces).
85#[derive(Debug, Serialize)]
86pub struct NamespaceJson {
87    pub namespace: String,
88    pub fact_count: i64,
89    pub episode_count: i64,
90}
91
92/// Export envelope (GET /v1/memory/export).
93#[derive(Debug, Serialize)]
94pub struct ExportJson {
95    pub version: String,
96    pub exported_at: String,
97    pub facts: Vec<signal::ExportedFact>,
98    pub episodes: Vec<signal::ExportedEpisode>,
99}
100
101/// Import request body (POST /v1/memory/import).
102#[derive(Debug, Deserialize)]
103pub struct ImportRequest {
104    pub facts: Vec<signal::ExportedFact>,
105    pub episodes: Vec<signal::ExportedEpisode>,
106    /// If true, preview what would be imported without writing.
107    #[serde(default)]
108    pub dry_run: bool,
109}
110
111/// Import response (POST /v1/memory/import).
112#[derive(Debug, Serialize)]
113pub struct ImportResponse {
114    pub facts_imported: usize,
115    pub episodes_imported: usize,
116    pub facts_already_existed: usize,
117    pub episodes_already_existed: usize,
118    pub embedded: usize,
119    pub embed_failed: usize,
120}
121
122/// A single fact in JSON form (GET /v1/memory/facts, search results).
123#[derive(Debug, Serialize)]
124pub struct FactJson {
125    pub id: String,
126    pub namespace: String,
127    pub category: String,
128    pub subject: String,
129    pub predicate: String,
130    pub object: String,
131    pub confidence: f64,
132    pub distance: Option<f32>,
133}
134
135/// Health check response.
136#[derive(Debug, Serialize)]
137pub struct HealthResponse {
138    pub status: &'static str,
139    pub version: &'static str,
140}
141
142// ─── Metrics ─────────────────────────────────────────────────────────────────
143
144/// Cached gauge snapshot so `/metrics` doesn't hit SQLite on every scrape.
145#[derive(Default)]
146struct GaugeCache {
147    fact_count: i64,
148    episode_count: i64,
149    last_updated_ms: u64,
150}
151
152const GAUGE_TTL_MS: u64 = 10_000;
153
154/// Atomic counters exposed at `GET /metrics` in Prometheus text format.
155#[derive(Default)]
156pub struct Metrics {
157    /// Total POST /v1/signals requests processed.
158    pub signals_total: AtomicU64,
159    /// Signals that returned a non-5xx response.
160    pub signals_ok: AtomicU64,
161    /// Signals that returned a 5xx error.
162    pub signals_error: AtomicU64,
163    /// Total POST /v1/memory/search requests.
164    pub search_total: AtomicU64,
165    /// Total GET /v1/memory/facts requests.
166    pub facts_total: AtomicU64,
167    /// Cumulative POST /v1/signals processing time in milliseconds.
168    pub signals_latency_ms_total: AtomicU64,
169    /// Cached memory gauges (refreshed on scrape, 10s TTL).
170    gauge_cache: std::sync::Mutex<GaugeCache>,
171}
172
173impl Metrics {
174    /// Refresh cached fact/episode gauges from SQLite (throttled to every 10s).
175    pub fn refresh_gauges(&self, processor: &signal::SignalProcessor) {
176        let now_ms = std::time::SystemTime::now()
177            .duration_since(std::time::UNIX_EPOCH)
178            .unwrap_or_default()
179            .as_millis() as u64;
180        let mut guard = self.gauge_cache.lock().unwrap_or_else(|e| e.into_inner());
181        if now_ms.saturating_sub(guard.last_updated_ms) < GAUGE_TTL_MS {
182            return;
183        }
184        let pool = processor.episodic().pool();
185        let fact_count = pool
186            .with_conn(|conn| {
187                let mut stmt = conn.prepare("SELECT COUNT(*) FROM semantic_facts")?;
188                let row: i64 = stmt.query_row([], |r| r.get(0))?;
189                Ok(row)
190            })
191            .unwrap_or(0);
192        let episode_count = pool
193            .with_conn(|conn| {
194                let mut stmt = conn.prepare("SELECT COUNT(*) FROM episodes")?;
195                let row: i64 = stmt.query_row([], |r| r.get(0))?;
196                Ok(row)
197            })
198            .unwrap_or(0);
199        guard.fact_count = fact_count;
200        guard.episode_count = episode_count;
201        guard.last_updated_ms = now_ms;
202    }
203
204    /// Render counters as Prometheus plain-text format (text/plain; version=0.0.4).
205    pub fn render(&self, subsystems: &brain_core::metrics::SubsystemMetrics) -> String {
206        let signals_total = self.signals_total.load(Ordering::Relaxed);
207        let signals_ok = self.signals_ok.load(Ordering::Relaxed);
208        let signals_error = self.signals_error.load(Ordering::Relaxed);
209        let search_total = self.search_total.load(Ordering::Relaxed);
210        let facts_total = self.facts_total.load(Ordering::Relaxed);
211        let latency_ms = self.signals_latency_ms_total.load(Ordering::Relaxed);
212        let sub = subsystems.snapshot();
213        let (fact_count, episode_count) = {
214            let g = self.gauge_cache.lock().unwrap_or_else(|e| e.into_inner());
215            (g.fact_count, g.episode_count)
216        };
217
218        format!(
219            "# HELP brain_signals_total Total signal requests received.\n\
220             # TYPE brain_signals_total counter\n\
221             brain_signals_total {signals_total}\n\
222             # HELP brain_signals_ok_total Successful signal requests.\n\
223             # TYPE brain_signals_ok_total counter\n\
224             brain_signals_ok_total {signals_ok}\n\
225             # HELP brain_signals_error_total Failed signal requests (5xx).\n\
226             # TYPE brain_signals_error_total counter\n\
227             brain_signals_error_total {signals_error}\n\
228             # HELP brain_search_total Total memory search requests.\n\
229             # TYPE brain_search_total counter\n\
230             brain_search_total {search_total}\n\
231             # HELP brain_facts_total Total memory facts requests.\n\
232             # TYPE brain_facts_total counter\n\
233             brain_facts_total {facts_total}\n\
234             # HELP brain_signals_latency_ms_total Cumulative signal processing latency in ms.\n\
235             # TYPE brain_signals_latency_ms_total counter\n\
236             brain_signals_latency_ms_total {latency_ms}\n\
237             # HELP brain_facts_count Total semantic facts currently stored.\n\
238             # TYPE brain_facts_count gauge\n\
239             brain_facts_count {fact_count}\n\
240             # HELP brain_episodes_count Total episodes currently stored.\n\
241             # TYPE brain_episodes_count gauge\n\
242             brain_episodes_count {episode_count}\n\
243             # HELP brain_embedding_requests_total Total embedding requests.\n\
244             # TYPE brain_embedding_requests_total counter\n\
245             brain_embedding_requests_total {embed_req}\n\
246             # HELP brain_embedding_fallbacks_total Times deterministic fallback vectors were used.\n\
247             # TYPE brain_embedding_fallbacks_total counter\n\
248             brain_embedding_fallbacks_total {embed_fb}\n\
249             # HELP brain_consolidation_runs_total Number of consolidation cycles completed.\n\
250             # TYPE brain_consolidation_runs_total counter\n\
251             brain_consolidation_runs_total {cons_runs}\n\
252             # HELP brain_consolidation_pruned_total Episodes pruned during consolidation.\n\
253             # TYPE brain_consolidation_pruned_total counter\n\
254             brain_consolidation_pruned_total {cons_pruned}\n\
255             # HELP brain_consolidation_promoted_total Episodes promoted to semantic facts.\n\
256             # TYPE brain_consolidation_promoted_total counter\n\
257             brain_consolidation_promoted_total {cons_promoted}\n\
258             # HELP brain_circuit_open_total Times a circuit breaker opened.\n\
259             # TYPE brain_circuit_open_total counter\n\
260             brain_circuit_open_total {cb_open}\n\
261             # HELP brain_circuit_resets_total Times a circuit breaker reset on success.\n\
262             # TYPE brain_circuit_resets_total counter\n\
263             brain_circuit_resets_total {cb_reset}\n\
264             # HELP brain_intent_classifications_total Total intent classifications.\n\
265             # TYPE brain_intent_classifications_total counter\n\
266             brain_intent_classifications_total {intents}\n\
267             # HELP brain_intent_llm_fallbacks_total Intents resolved via LLM fallback.\n\
268             # TYPE brain_intent_llm_fallbacks_total counter\n\
269             brain_intent_llm_fallbacks_total {intent_llm}\n",
270            embed_req = sub.embedding_requests_total,
271            embed_fb = sub.embedding_fallbacks_total,
272            cons_runs = sub.consolidation_runs_total,
273            cons_pruned = sub.consolidation_pruned_total,
274            cons_promoted = sub.consolidation_promoted_total,
275            cb_open = sub.circuit_open_total,
276            cb_reset = sub.circuit_resets_total,
277            intents = sub.intent_classifications_total,
278            intent_llm = sub.intent_llm_fallbacks_total,
279        )
280    }
281}
282
283// ─── App State ───────────────────────────────────────────────────────────────
284
285/// Maximum number of cached signal responses before eviction.
286const CACHE_CAPACITY: usize = 1000;
287
288/// Shared state for all HTTP handlers.
289pub struct AppState {
290    processor: Arc<signal::SignalProcessor>,
291    /// LRU cache: signal_id → SignalResponse. Bounded to `CACHE_CAPACITY` entries.
292    cache: Mutex<lru::LruCache<Uuid, SignalResponse>>,
293    /// Configured API keys (loaded from BrainConfig).
294    api_keys: Vec<ApiKeyConfig>,
295    /// Request counters and latency.
296    metrics: Arc<Metrics>,
297}
298
299// ─── Auth helpers ─────────────────────────────────────────────────────────────
300
301/// Extract the raw key from `Authorization: Bearer <key>`.
302fn extract_bearer(headers: &HeaderMap) -> Option<&str> {
303    headers
304        .get("authorization")
305        .and_then(|v| v.to_str().ok())
306        .and_then(brain_core::auth::extract_bearer_from_value)
307}
308
309/// Check that the request carries a valid key with the given permission.
310/// Returns `Err((StatusCode::UNAUTHORIZED, message))` on failure.
311fn check_auth(
312    state: &AppState,
313    headers: &HeaderMap,
314    permission: &str,
315) -> Result<(), (StatusCode, String)> {
316    let provided_key = extract_bearer(headers);
317    let result = brain_core::check_auth(&state.api_keys, provided_key, permission);
318    if result.is_allowed() {
319        Ok(())
320    } else {
321        Err((
322            StatusCode::UNAUTHORIZED,
323            result
324                .error_message(permission)
325                .unwrap_or_else(|| "Unauthorized".to_string()),
326        ))
327    }
328}
329
330// ─── Router builder ──────────────────────────────────────────────────────────
331
332/// Build the axum router with all routes.
333///
334/// `api_keys` is taken from `BrainConfig.access.api_keys` by the caller.
335/// When `cors_enabled` is true, CORS is restricted to localhost origins (Brain is a local daemon).
336/// When false, no CORS layer is applied (useful for reverse-proxy setups that handle CORS externally).
337pub fn create_router(
338    processor: Arc<signal::SignalProcessor>,
339    api_keys: Vec<ApiKeyConfig>,
340    cors_enabled: bool,
341) -> Router {
342    let state = Arc::new(AppState {
343        processor,
344        cache: Mutex::new(lru::LruCache::new(
345            NonZeroUsize::new(CACHE_CAPACITY).unwrap(),
346        )),
347        api_keys,
348        metrics: Arc::new(Metrics::default()),
349    });
350
351    // Limit concurrent /v1/* requests (100 in-flight per instance)
352    // Limit request body size to 1 MB to prevent memory exhaustion
353    let v1_routes = Router::new()
354        .route("/v1/signals", post(post_signal_handler))
355        .route("/v1/signals/:id", get(get_signal_handler))
356        .route("/v1/memory/search", post(search_memory_handler))
357        .route("/v1/memory/facts", get(get_facts_handler))
358        .route("/v1/memory/namespaces", get(get_namespaces_handler))
359        .route("/v1/memory/export", get(export_memory_handler))
360        .route("/v1/memory/import", post(import_memory_handler))
361        .route("/v1/schedules", get(list_schedules_handler))
362        .route("/v1/schedules/:id", delete(cancel_schedule_handler))
363        .route("/v1/events", get(sse_events_handler))
364        .layer(axum::extract::DefaultBodyLimit::max(1_048_576))
365        .layer(tower::limit::ConcurrencyLimitLayer::new(100));
366
367    let router = Router::new()
368        .route("/health", get(health_handler))
369        .route("/metrics", get(metrics_handler))
370        .route("/ui", get(ui_handler))
371        .route("/openapi.json", get(openapi_handler))
372        .route("/api", get(swagger_ui_handler))
373        .merge(v1_routes)
374        .with_state(state);
375
376    if cors_enabled {
377        router.layer(brain_core::cors::localhost_cors())
378    } else {
379        router
380    }
381}
382
383/// Start the HTTP server, binding to `host:port`.
384///
385/// Blocks until the server shuts down.
386pub async fn serve(
387    processor: Arc<signal::SignalProcessor>,
388    host: &str,
389    port: u16,
390) -> anyhow::Result<()> {
391    let cors_enabled = processor.config().adapters.http.cors;
392    let api_keys = processor.config().access.api_keys.clone();
393    let router = create_router(processor, api_keys, cors_enabled);
394    let addr: SocketAddr = format!("{host}:{port}").parse()?;
395    tracing::info!("Synapse HTTP online at http://{addr}");
396    let listener = tokio::net::TcpListener::bind(addr).await?;
397    axum::serve(listener, router).await?;
398    Ok(())
399}
400
401// ─── Handlers ────────────────────────────────────────────────────────────────
402
403/// GET /health — no authentication required
404async fn health_handler() -> Json<HealthResponse> {
405    Json(HealthResponse {
406        status: "ok",
407        version: env!("CARGO_PKG_VERSION"),
408    })
409}
410
411/// GET /metrics — Prometheus text format, no authentication required
412async fn metrics_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
413    state.metrics.refresh_gauges(&state.processor);
414    let body = state.metrics.render(state.processor.metrics());
415    (
416        [("content-type", "text/plain; version=0.0.4; charset=utf-8")],
417        body,
418    )
419}
420
421/// Embedded single-page memory explorer UI.
422const UI_HTML: &str = include_str!("../assets/ui.html");
423
424/// GET /ui — embedded single-page memory explorer (no auth required)
425async fn ui_handler() -> impl IntoResponse {
426    ([("content-type", "text/html; charset=utf-8")], UI_HTML)
427}
428
429// ─── OpenAPI spec ─────────────────────────────────────────────────────────────
430
431/// OpenAPI 3.0 document loaded at compile time from assets/openapi.json.
432/// The placeholder `{{VERSION}}` is replaced with the actual crate version at runtime.
433static OPENAPI_JSON: &str = include_str!("../assets/openapi.json");
434
435/// GET /openapi.json — OpenAPI 3.0 specification (no auth required)
436async fn openapi_handler() -> impl IntoResponse {
437    let spec = OPENAPI_JSON.replace("{{VERSION}}", env!("CARGO_PKG_VERSION"));
438    ([("content-type", "application/json")], spec)
439}
440
441/// Swagger UI HTML that loads the spec from /openapi.json (CDN assets).
442const SWAGGER_UI_HTML: &str = include_str!("../assets/swagger.html");
443
444/// GET /api — Swagger UI for interactive API exploration (no auth required)
445async fn swagger_ui_handler() -> impl IntoResponse {
446    (
447        [("content-type", "text/html; charset=utf-8")],
448        SWAGGER_UI_HTML,
449    )
450}
451
452/// POST /v1/signals — requires write permission
453async fn post_signal_handler(
454    State(state): State<Arc<AppState>>,
455    headers: HeaderMap,
456    Json(body): Json<SignalRequest>,
457) -> Result<Json<SignalResponse>, (StatusCode, String)> {
458    check_auth(&state, &headers, "write")?;
459
460    let t0 = Instant::now();
461    state.metrics.signals_total.fetch_add(1, Ordering::Relaxed);
462
463    let source = SignalSource::parse(body.source.as_deref(), SignalSource::Http);
464    let signal = Signal::from_adapter_request(signal::AdapterRequest {
465        source,
466        content: body.content,
467        channel: body.channel,
468        sender: body.sender,
469        metadata: body.metadata,
470        namespace: body.namespace,
471        agent: body.agent,
472        session_id: body.session_id,
473        default_channel: "http".to_string(),
474        default_sender: "apiclient".to_string(),
475    });
476
477    let signal_id = signal.id;
478    let result = state.processor.process(signal).await;
479
480    let elapsed_ms = t0.elapsed().as_millis() as u64;
481    state
482        .metrics
483        .signals_latency_ms_total
484        .fetch_add(elapsed_ms, Ordering::Relaxed);
485
486    let response = match result {
487        Ok(r) => {
488            state.metrics.signals_ok.fetch_add(1, Ordering::Relaxed);
489            tracing::info!(
490                signal_id = %signal_id,
491                latency_ms = elapsed_ms,
492                "signal processed"
493            );
494            r
495        }
496        Err(e) => {
497            state.metrics.signals_error.fetch_add(1, Ordering::Relaxed);
498            tracing::error!(
499                signal_id = %signal_id,
500                latency_ms = elapsed_ms,
501                error = %e,
502                "signal processing failed"
503            );
504            // Return an opaque error as JSON — do not leak internal details.
505            return Err((
506                StatusCode::INTERNAL_SERVER_ERROR,
507                serde_json::json!({
508                    "error": "Signal processing failed",
509                    "details": e.to_string()
510                })
511                .to_string(),
512            ));
513        }
514    };
515
516    // Cache the response so GET /v1/signals/:id can retrieve it (LRU evicts oldest)
517    state.cache.lock().await.put(signal_id, response.clone());
518
519    Ok(Json(response))
520}
521
522/// GET /v1/signals/:id — requires read permission
523async fn get_signal_handler(
524    State(state): State<Arc<AppState>>,
525    headers: HeaderMap,
526    Path(id): Path<String>,
527) -> Result<Json<SignalResponse>, (StatusCode, String)> {
528    check_auth(&state, &headers, "read")?;
529
530    let uuid = Uuid::parse_str(&id)
531        .map_err(|_| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {id}")))?;
532
533    let mut cache = state.cache.lock().await;
534    match cache.get(&uuid) {
535        Some(resp) => Ok(Json(resp.clone())),
536        None => Err((
537            StatusCode::NOT_FOUND,
538            format!("Signal {uuid} not found in cache"),
539        )),
540    }
541}
542
543/// POST /v1/memory/search — requires read permission
544async fn search_memory_handler(
545    State(state): State<Arc<AppState>>,
546    headers: HeaderMap,
547    Json(body): Json<SearchRequest>,
548) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
549    check_auth(&state, &headers, "read")?;
550
551    state.metrics.search_total.fetch_add(1, Ordering::Relaxed);
552    let t0 = Instant::now();
553    let top_k = body.top_k.unwrap_or(10);
554    let namespace = body.namespace.as_deref();
555    let results = state
556        .processor
557        .search_facts(&body.query, top_k, namespace)
558        .await;
559    tracing::debug!(latency_ms = t0.elapsed().as_millis() as u64, query = %body.query, "memory search");
560
561    let facts = results
562        .into_iter()
563        .map(|r| FactJson {
564            id: r.fact.id,
565            namespace: r.fact.namespace,
566            category: r.fact.category,
567            subject: r.fact.subject,
568            predicate: r.fact.predicate,
569            object: r.fact.object,
570            confidence: r.fact.confidence,
571            distance: Some(r.distance),
572        })
573        .collect();
574
575    Ok(Json(facts))
576}
577
578/// GET /v1/memory/facts — requires read permission
579///
580/// Accepts optional `namespace` query parameter to filter results.
581async fn get_facts_handler(
582    State(state): State<Arc<AppState>>,
583    headers: HeaderMap,
584    Query(params): Query<HashMap<String, String>>,
585) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
586    check_auth(&state, &headers, "read")?;
587
588    state.metrics.facts_total.fetch_add(1, Ordering::Relaxed);
589    let namespace = params.get("namespace").map(|s| s.as_str());
590    let facts = state
591        .processor
592        .list_facts(namespace)
593        .into_iter()
594        .map(|f| FactJson {
595            id: f.id,
596            namespace: f.namespace,
597            category: f.category,
598            subject: f.subject,
599            predicate: f.predicate,
600            object: f.object,
601            confidence: f.confidence,
602            distance: None,
603        })
604        .collect();
605
606    Ok(Json(facts))
607}
608
609/// GET /v1/memory/namespaces — requires read permission
610async fn get_namespaces_handler(
611    State(state): State<Arc<AppState>>,
612    headers: HeaderMap,
613) -> Result<Json<Vec<NamespaceJson>>, (StatusCode, String)> {
614    check_auth(&state, &headers, "read")?;
615
616    let namespaces = state
617        .processor
618        .list_namespaces()
619        .into_iter()
620        .map(|n| NamespaceJson {
621            namespace: n.namespace,
622            fact_count: n.fact_count,
623            episode_count: n.episode_count,
624        })
625        .collect();
626
627    Ok(Json(namespaces))
628}
629
630/// GET /v1/memory/export — requires read permission
631async fn export_memory_handler(
632    State(state): State<Arc<AppState>>,
633    headers: HeaderMap,
634) -> Result<Json<ExportJson>, (StatusCode, String)> {
635    check_auth(&state, &headers, "read")?;
636
637    let facts = state.processor.export_facts().map_err(|e| {
638        (
639            StatusCode::INTERNAL_SERVER_ERROR,
640            format!("Export failed: {e}"),
641        )
642    })?;
643    let episodes = state.processor.export_episodes().map_err(|e| {
644        (
645            StatusCode::INTERNAL_SERVER_ERROR,
646            format!("Export failed: {e}"),
647        )
648    })?;
649
650    Ok(Json(ExportJson {
651        version: env!("CARGO_PKG_VERSION").to_string(),
652        exported_at: chrono::Utc::now().to_rfc3339(),
653        facts,
654        episodes,
655    }))
656}
657
658/// POST /v1/memory/import — requires write permission
659async fn import_memory_handler(
660    State(state): State<Arc<AppState>>,
661    headers: HeaderMap,
662    Json(body): Json<ImportRequest>,
663) -> Result<Json<ImportResponse>, (StatusCode, String)> {
664    check_auth(&state, &headers, "write")?;
665
666    if body.dry_run {
667        // Preview only — report item counts without touching the database.
668        return Ok(Json(ImportResponse {
669            facts_imported: body.facts.len(),
670            episodes_imported: body.episodes.len(),
671            facts_already_existed: 0,
672            episodes_already_existed: 0,
673            embedded: 0,
674            embed_failed: 0,
675        }));
676    }
677
678    let (facts_imported, new_fact_indices) =
679        state.processor.import_facts(&body.facts).map_err(|e| {
680            (
681                StatusCode::INTERNAL_SERVER_ERROR,
682                format!("Import failed: {e}"),
683            )
684        })?;
685    let episodes_imported = state
686        .processor
687        .import_episodes(&body.episodes)
688        .map_err(|e| {
689            (
690                StatusCode::INTERNAL_SERVER_ERROR,
691                format!("Import failed: {e}"),
692            )
693        })?;
694
695    let mut embedded = 0;
696    let mut embed_failed = 0;
697    if !new_fact_indices.is_empty() {
698        let new_facts: Vec<signal::ExportedFact> = new_fact_indices
699            .iter()
700            .map(|&idx| body.facts[idx].clone())
701            .collect();
702        let (e, f) = state.processor.reembed_facts(&new_facts).await;
703        embedded = e;
704        embed_failed = f;
705    }
706
707    Ok(Json(ImportResponse {
708        facts_imported,
709        episodes_imported,
710        facts_already_existed: body.facts.len() - facts_imported,
711        episodes_already_existed: body.episodes.len() - episodes_imported,
712        embedded,
713        embed_failed,
714    }))
715}
716
717// ─── Schedules ───────────────────────────────────────────────────────────────
718
719/// GET /v1/schedules — list scheduled intents (requires read permission)
720async fn list_schedules_handler(
721    State(state): State<Arc<AppState>>,
722    headers: HeaderMap,
723    Query(params): Query<HashMap<String, String>>,
724) -> Result<Json<Vec<serde_json::Value>>, (StatusCode, String)> {
725    check_auth(&state, &headers, "read")?;
726
727    let namespace = params.get("namespace").map(|s| s.as_str());
728    let intents = state
729        .processor
730        .list_scheduled_intents(namespace)
731        .map_err(|e| {
732            (
733                StatusCode::INTERNAL_SERVER_ERROR,
734                format!("Failed to list schedules: {e}"),
735            )
736        })?;
737
738    let json: Vec<serde_json::Value> = intents
739        .iter()
740        .map(|i| {
741            serde_json::json!({
742                "id": i.id,
743                "description": i.description,
744                "cron": i.cron,
745                "namespace": i.namespace,
746                "created_at": i.created_at,
747                "status": i.status,
748            })
749        })
750        .collect();
751
752    Ok(Json(json))
753}
754
755/// DELETE /v1/schedules/:id — cancel a scheduled intent (requires write permission)
756async fn cancel_schedule_handler(
757    State(state): State<Arc<AppState>>,
758    headers: HeaderMap,
759    Path(id): Path<String>,
760) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
761    check_auth(&state, &headers, "write")?;
762
763    let cancelled = state.processor.cancel_scheduled_intent(&id).map_err(|e| {
764        (
765            StatusCode::INTERNAL_SERVER_ERROR,
766            format!("Failed to cancel schedule: {e}"),
767        )
768    })?;
769
770    if cancelled {
771        Ok(Json(serde_json::json!({"cancelled": true, "id": id})))
772    } else {
773        Err((
774            StatusCode::NOT_FOUND,
775            format!("No scheduled intent found with ID: {id}"),
776        ))
777    }
778}
779
780// ─── SSE event stream ───────────────────────────────────────────────────────
781
782/// `GET /v1/events` — Server-Sent Events stream of proactive notifications.
783///
784/// Streams signal-processed events and (optionally) proactive notifications
785/// as JSON SSE events. The connection stays open until the client disconnects.
786///
787/// Always available — proactive notifications are included when the
788/// NotificationRouter is configured, but the endpoint works without it.
789async fn sse_events_handler(
790    State(state): State<Arc<AppState>>,
791    headers: HeaderMap,
792) -> Result<
793    Sse<impl futures_core::Stream<Item = Result<Event, std::convert::Infallible>>>,
794    (StatusCode, String),
795> {
796    check_auth(&state, &headers, "read")?;
797
798    let mut signal_rx = state.processor.subscribe_events();
799    let mut notif_rx = state.processor.notification_router().map(|r| r.subscribe());
800
801    let stream = async_stream::stream! {
802        loop {
803            tokio::select! {
804                result = signal_rx.recv() => {
805                    match result {
806                        Ok(event) => {
807                            let payload = serde_json::json!({
808                                "type": "signal",
809                                "signal_id": event.signal_id.to_string(),
810                                "source": format!("{:?}", event.source),
811                                "status": format!("{:?}", event.status),
812                                "response": event.response,
813                                "facts_used": event.facts_used,
814                                "episodes_used": event.episodes_used,
815                            });
816                            yield Ok(Event::default()
817                                .event("signal")
818                                .json_data(payload)
819                                .unwrap_or_else(|_| Event::default().data("{}")));
820                        }
821                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
822                            tracing::warn!(skipped = n, "SSE signal stream lagged");
823                            yield Ok(Event::default()
824                                .event("error")
825                                .data(format!("{{\"lagged\":{n}}}")));
826                        }
827                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
828                    }
829                }
830                result = async {
831                    match notif_rx.as_mut() {
832                        Some(rx) => rx.recv().await,
833                        None => std::future::pending().await,
834                    }
835                } => {
836                    match result {
837                        Ok(notification) => {
838                            let payload = serde_json::json!({
839                                "type": "proactive",
840                                "content": notification.content,
841                                "triggered_by": notification.triggered_by,
842                                "priority": notification.priority,
843                                "agent": notification.agent,
844                            });
845                            yield Ok(Event::default()
846                                .event("notification")
847                                .json_data(payload)
848                                .unwrap_or_else(|_| Event::default().data("{}")));
849                        }
850                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
851                            tracing::warn!(skipped = n, "SSE notification stream lagged");
852                            yield Ok(Event::default()
853                                .event("error")
854                                .data(format!("{{\"lagged\":{n}}}")));
855                        }
856                        Err(tokio::sync::broadcast::error::RecvError::Closed) => {
857                            // Notification channel closed, but signal stream may still be live.
858                            notif_rx = None;
859                        }
860                    }
861                }
862            }
863        }
864    };
865
866    Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
867}
868
869// ─── Helpers ─────────────────────────────────────────────────────────────────
870
871// ─── Tests ───────────────────────────────────────────────────────────────────
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876
877    /// Build a test router with the API key pre-loaded.
878    async fn make_router() -> (Router, tempfile::TempDir, String) {
879        let temp = tempfile::tempdir().unwrap();
880        let mut config = brain_core::BrainConfig::default();
881        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
882        let api_key = config.access.api_keys.first().unwrap().key.clone();
883        let api_keys = config.access.api_keys.clone();
884        let processor = signal::SignalProcessor::new(config).await.unwrap();
885        let router = create_router(Arc::new(processor), api_keys, true);
886        (router, temp, api_key)
887    }
888
889    #[test]
890    fn test_parse_source_defaults_to_http() {
891        assert_eq!(
892            SignalSource::parse(None, SignalSource::Http),
893            SignalSource::Http
894        );
895        assert_eq!(
896            SignalSource::parse(Some("http"), SignalSource::Http),
897            SignalSource::Http
898        );
899    }
900
901    #[test]
902    fn test_parse_source_all_variants() {
903        assert_eq!(
904            SignalSource::parse(Some("cli"), SignalSource::Http),
905            SignalSource::Cli
906        );
907        assert_eq!(
908            SignalSource::parse(Some("ws"), SignalSource::Http),
909            SignalSource::WebSocket
910        );
911        assert_eq!(
912            SignalSource::parse(Some("mcp"), SignalSource::Http),
913            SignalSource::Mcp
914        );
915        assert_eq!(
916            SignalSource::parse(Some("grpc"), SignalSource::Http),
917            SignalSource::Grpc
918        );
919    }
920
921    #[test]
922    fn test_health_response_serializes() {
923        let h = HealthResponse {
924            status: "ok",
925            version: "1.0.0",
926        };
927        let json = serde_json::to_string(&h).unwrap();
928        assert!(json.contains("\"status\":\"ok\""));
929        assert!(json.contains("\"version\""));
930    }
931
932    #[test]
933    fn test_fact_json_serializes() {
934        let f = FactJson {
935            id: "abc".into(),
936            namespace: "personal".into(),
937            category: "personal".into(),
938            subject: "user".into(),
939            predicate: "likes".into(),
940            object: "Rust".into(),
941            confidence: 0.9,
942            distance: Some(0.05),
943        };
944        let json = serde_json::to_string(&f).unwrap();
945        assert!(json.contains("\"subject\":\"user\""));
946        assert!(json.contains("\"namespace\":\"personal\""));
947        assert!(json.contains("\"distance\":0.05"));
948    }
949
950    /// GET /openapi.json — no auth required, returns valid OpenAPI spec.
951    #[tokio::test]
952    async fn test_openapi_endpoint() {
953        use axum::body::Body;
954        use axum::http::{self, Request};
955        use tower::util::ServiceExt;
956
957        let (router, _tmp, _api_key) = make_router().await;
958
959        let request = Request::builder()
960            .method(http::Method::GET)
961            .uri("/openapi.json")
962            .body(Body::empty())
963            .unwrap();
964
965        let response = router.oneshot(request).await.unwrap();
966        assert_eq!(response.status(), StatusCode::OK);
967
968        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
969            .await
970            .unwrap();
971        let spec: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
972        assert_eq!(spec["openapi"], "3.0.3");
973        assert!(
974            spec["paths"]["/v1/signals"].is_object(),
975            "missing /v1/signals path"
976        );
977        assert!(
978            spec["components"]["schemas"]["FactJson"].is_object(),
979            "missing FactJson schema"
980        );
981    }
982
983    /// GET /api — no auth required, returns Swagger UI HTML.
984    #[tokio::test]
985    async fn test_swagger_ui_endpoint() {
986        use axum::body::Body;
987        use axum::http::{self, Request};
988        use tower::util::ServiceExt;
989
990        let (router, _tmp, _api_key) = make_router().await;
991
992        let request = Request::builder()
993            .method(http::Method::GET)
994            .uri("/api")
995            .body(Body::empty())
996            .unwrap();
997
998        let response = router.oneshot(request).await.unwrap();
999        assert_eq!(response.status(), StatusCode::OK);
1000
1001        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1002            .await
1003            .unwrap();
1004        let body = std::str::from_utf8(&bytes).unwrap();
1005        assert!(body.contains("swagger-ui"), "missing Swagger UI element");
1006        assert!(body.contains("/openapi.json"), "missing spec URL reference");
1007    }
1008
1009    /// GET /ui — no auth required, returns HTML page.
1010    #[tokio::test]
1011    async fn test_ui_endpoint() {
1012        use axum::body::Body;
1013        use axum::http::{self, Request};
1014        use tower::util::ServiceExt;
1015
1016        let (router, _tmp, _api_key) = make_router().await;
1017
1018        let request = Request::builder()
1019            .method(http::Method::GET)
1020            .uri("/ui")
1021            .body(Body::empty())
1022            .unwrap();
1023
1024        let response = router.oneshot(request).await.unwrap();
1025        assert_eq!(response.status(), StatusCode::OK);
1026
1027        let ct = response
1028            .headers()
1029            .get("content-type")
1030            .unwrap()
1031            .to_str()
1032            .unwrap();
1033        assert!(ct.contains("text/html"), "expected text/html, got: {ct}");
1034
1035        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1036            .await
1037            .unwrap();
1038        let body = std::str::from_utf8(&bytes).unwrap();
1039        assert!(body.contains("Brain Memory Explorer"), "missing page title");
1040        assert!(
1041            body.contains("/v1/memory/search"),
1042            "missing API endpoint reference"
1043        );
1044    }
1045
1046    /// GET /metrics — no auth required, returns Prometheus text.
1047    #[tokio::test]
1048    async fn test_metrics_endpoint() {
1049        use axum::body::Body;
1050        use axum::http::{self, Request};
1051        use tower::util::ServiceExt;
1052
1053        let (router, _tmp, _api_key) = make_router().await;
1054
1055        let request = Request::builder()
1056            .method(http::Method::GET)
1057            .uri("/metrics")
1058            .body(Body::empty())
1059            .unwrap();
1060
1061        let response = router.oneshot(request).await.unwrap();
1062        assert_eq!(response.status(), StatusCode::OK);
1063
1064        let ct = response
1065            .headers()
1066            .get("content-type")
1067            .unwrap()
1068            .to_str()
1069            .unwrap();
1070        assert!(ct.contains("text/plain"), "expected text/plain, got: {ct}");
1071
1072        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1073            .await
1074            .unwrap();
1075        let body = std::str::from_utf8(&bytes).unwrap();
1076        assert!(
1077            body.contains("brain_signals_total"),
1078            "missing counter in metrics output"
1079        );
1080        assert!(
1081            body.contains("brain_search_total"),
1082            "missing search counter"
1083        );
1084    }
1085
1086    /// GET /health — no auth required, always returns 200.
1087    #[tokio::test]
1088    async fn test_health_endpoint() {
1089        use axum::body::Body;
1090        use axum::http::{self, Request};
1091        use tower::util::ServiceExt;
1092
1093        let (router, _tmp, _api_key) = make_router().await;
1094
1095        let request = Request::builder()
1096            .method(http::Method::GET)
1097            .uri("/health")
1098            .body(Body::empty())
1099            .unwrap();
1100
1101        let response = router.oneshot(request).await.unwrap();
1102        assert_eq!(response.status(), StatusCode::OK);
1103
1104        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1105            .await
1106            .unwrap();
1107        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1108        assert_eq!(body["status"], "ok");
1109    }
1110
1111    /// POST /v1/signals without auth → 401.
1112    #[tokio::test]
1113    async fn test_post_signal_no_auth_returns_401() {
1114        use axum::body::Body;
1115        use axum::http::{self, Request};
1116        use tower::util::ServiceExt;
1117
1118        let (router, _tmp, _api_key) = make_router().await;
1119
1120        let payload = serde_json::json!({"content": "Remember Rust is fast"});
1121        let request = Request::builder()
1122            .method(http::Method::POST)
1123            .uri("/v1/signals")
1124            .header("content-type", "application/json")
1125            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1126            .unwrap();
1127
1128        let response = router.oneshot(request).await.unwrap();
1129        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1130    }
1131
1132    /// POST /v1/signals with invalid key → 401.
1133    #[tokio::test]
1134    async fn test_post_signal_invalid_key_returns_401() {
1135        use axum::body::Body;
1136        use axum::http::{self, Request};
1137        use tower::util::ServiceExt;
1138
1139        let (router, _tmp, _api_key) = make_router().await;
1140
1141        let payload = serde_json::json!({"content": "Remember Rust is fast"});
1142        let request = Request::builder()
1143            .method(http::Method::POST)
1144            .uri("/v1/signals")
1145            .header("content-type", "application/json")
1146            .header("authorization", "Bearer wrong-key")
1147            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1148            .unwrap();
1149
1150        let response = router.oneshot(request).await.unwrap();
1151        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1152    }
1153
1154    /// POST /v1/signals with valid API key → 200.
1155    #[tokio::test]
1156    async fn test_post_signal_store_fact_with_auth() {
1157        use axum::body::Body;
1158        use axum::http::{self, Request};
1159        use tower::util::ServiceExt;
1160
1161        let (router, _tmp, api_key) = make_router().await;
1162
1163        let payload = serde_json::json!({"content": "Remember that Rust is fast"});
1164        let request = Request::builder()
1165            .method(http::Method::POST)
1166            .uri("/v1/signals")
1167            .header("content-type", "application/json")
1168            .header("authorization", format!("Bearer {api_key}"))
1169            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1170            .unwrap();
1171
1172        let response = router.oneshot(request).await.unwrap();
1173        assert_eq!(response.status(), StatusCode::OK);
1174
1175        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1176            .await
1177            .unwrap();
1178        let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1179        assert_eq!(resp["status"], "Ok");
1180    }
1181
1182    /// GET /v1/memory/facts with no auth → 401.
1183    #[tokio::test]
1184    async fn test_get_facts_no_auth_returns_401() {
1185        use axum::body::Body;
1186        use axum::http::{self, Request};
1187        use tower::util::ServiceExt;
1188
1189        let (router, _tmp, _api_key) = make_router().await;
1190
1191        let request = Request::builder()
1192            .method(http::Method::GET)
1193            .uri("/v1/memory/facts")
1194            .body(Body::empty())
1195            .unwrap();
1196
1197        let response = router.oneshot(request).await.unwrap();
1198        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1199    }
1200
1201    /// GET /v1/memory/facts with valid API key → 200.
1202    #[tokio::test]
1203    async fn test_get_facts_endpoint_with_auth() {
1204        use axum::body::Body;
1205        use axum::http::{self, Request};
1206        use tower::util::ServiceExt;
1207
1208        let (router, _tmp, api_key) = make_router().await;
1209
1210        let request = Request::builder()
1211            .method(http::Method::GET)
1212            .uri("/v1/memory/facts")
1213            .header("authorization", format!("Bearer {api_key}"))
1214            .body(Body::empty())
1215            .unwrap();
1216
1217        let response = router.oneshot(request).await.unwrap();
1218        assert_eq!(response.status(), StatusCode::OK);
1219
1220        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1221            .await
1222            .unwrap();
1223        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1224        assert!(body.is_array());
1225    }
1226
1227    /// POST /v1/memory/search with valid read-only key → 200.
1228    #[tokio::test]
1229    async fn test_search_with_read_only_key() {
1230        use axum::body::Body;
1231        use axum::http::{self, Request};
1232        use tower::util::ServiceExt;
1233
1234        let temp = tempfile::tempdir().unwrap();
1235        let mut config = brain_core::BrainConfig::default();
1236        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1237        // Add a read-only key
1238        config.access.api_keys.push(ApiKeyConfig {
1239            key: "read-only-key".to_string(),
1240            name: "Read Only".to_string(),
1241            permissions: vec!["read".to_string()],
1242        });
1243        let api_keys = config.access.api_keys.clone();
1244        let processor = signal::SignalProcessor::new(config).await.unwrap();
1245        let router = create_router(Arc::new(processor), api_keys, true);
1246
1247        let payload = serde_json::json!({"query": "Rust", "top_k": 5});
1248        let request = Request::builder()
1249            .method(http::Method::POST)
1250            .uri("/v1/memory/search")
1251            .header("content-type", "application/json")
1252            .header("authorization", "Bearer read-only-key")
1253            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1254            .unwrap();
1255
1256        let response = router.oneshot(request).await.unwrap();
1257        assert_eq!(response.status(), StatusCode::OK);
1258    }
1259
1260    /// POST /v1/signals with read-only key → 401 (missing write permission).
1261    #[tokio::test]
1262    async fn test_post_signal_read_only_key_returns_401() {
1263        use axum::body::Body;
1264        use axum::http::{self, Request};
1265        use tower::util::ServiceExt;
1266
1267        let temp = tempfile::tempdir().unwrap();
1268        let mut config = brain_core::BrainConfig::default();
1269        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1270        config.access.api_keys.push(ApiKeyConfig {
1271            key: "read-only-key".to_string(),
1272            name: "Read Only".to_string(),
1273            permissions: vec!["read".to_string()],
1274        });
1275        let api_keys = config.access.api_keys.clone();
1276        let processor = signal::SignalProcessor::new(config).await.unwrap();
1277        let router = create_router(Arc::new(processor), api_keys, true);
1278
1279        let payload = serde_json::json!({"content": "Remember something"});
1280        let request = Request::builder()
1281            .method(http::Method::POST)
1282            .uri("/v1/signals")
1283            .header("content-type", "application/json")
1284            .header("authorization", "Bearer read-only-key")
1285            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1286            .unwrap();
1287
1288        let response = router.oneshot(request).await.unwrap();
1289        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1290    }
1291
1292    /// Integration test: HTTP POST /v1/signals (store intent) → fact persisted in DB.
1293    ///
1294    /// Stores a fact via the HTTP signal endpoint, then verifies it appears in
1295    /// GET /v1/memory/facts. Uses shared AppState so both requests hit the same
1296    /// SignalProcessor and SQLite database.
1297    #[tokio::test]
1298    async fn test_http_store_signal_fact_persisted_in_db() {
1299        use axum::body::Body;
1300        use axum::http::{self, Request};
1301        use tower::util::ServiceExt;
1302
1303        let temp = tempfile::tempdir().unwrap();
1304        let mut config = brain_core::BrainConfig::default();
1305        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1306        let api_key = config.access.api_keys.first().unwrap().key.clone();
1307        let api_keys = config.access.api_keys.clone();
1308        let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1309        let state = Arc::new(AppState {
1310            processor,
1311            cache: Mutex::new(lru::LruCache::new(
1312                NonZeroUsize::new(CACHE_CAPACITY).unwrap(),
1313            )),
1314            api_keys,
1315            metrics: Arc::new(Metrics::default()),
1316        });
1317
1318        // POST /v1/signals with a store-fact intent
1319        let payload = serde_json::json!({"content": "Remember that Rust is fast"});
1320        let post_req = Request::builder()
1321            .method(http::Method::POST)
1322            .uri("/v1/signals")
1323            .header("content-type", "application/json")
1324            .header("authorization", format!("Bearer {api_key}"))
1325            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1326            .unwrap();
1327
1328        let router = Router::new()
1329            .route("/v1/signals", post(post_signal_handler))
1330            .route("/v1/memory/facts", get(get_facts_handler))
1331            .with_state(state.clone());
1332
1333        let post_resp = router.clone().oneshot(post_req).await.unwrap();
1334        assert_eq!(post_resp.status(), StatusCode::OK);
1335
1336        let bytes = axum::body::to_bytes(post_resp.into_body(), usize::MAX)
1337            .await
1338            .unwrap();
1339        let resp_json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1340        assert_eq!(resp_json["status"], "Ok");
1341        // Signal was processed — memory_context is present (facts_used depends on embeddings)
1342        assert!(resp_json["memory_context"].is_object());
1343
1344        // GET /v1/memory/facts → fact should now be persisted in DB
1345        let get_req = Request::builder()
1346            .method(http::Method::GET)
1347            .uri("/v1/memory/facts")
1348            .header("authorization", format!("Bearer {api_key}"))
1349            .body(Body::empty())
1350            .unwrap();
1351
1352        let get_resp = router.oneshot(get_req).await.unwrap();
1353        assert_eq!(get_resp.status(), StatusCode::OK);
1354
1355        let bytes = axum::body::to_bytes(get_resp.into_body(), usize::MAX)
1356            .await
1357            .unwrap();
1358        let facts: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1359        assert!(facts.is_array(), "Expected array of facts");
1360        assert!(
1361            !facts.as_array().unwrap().is_empty(),
1362            "Stored fact should appear in GET /v1/memory/facts"
1363        );
1364    }
1365
1366    /// Integration test: HTTP POST /v1/memory/search → returns relevant fact.
1367    ///
1368    /// Stores a fact via the SignalProcessor directly (bypassing HTTP for setup),
1369    /// then calls POST /v1/memory/search and verifies the fact is returned.
1370    #[tokio::test]
1371    async fn test_http_memory_search_returns_stored_fact() {
1372        use axum::body::Body;
1373        use axum::http::{self, Request};
1374        use tower::util::ServiceExt;
1375
1376        let temp = tempfile::tempdir().unwrap();
1377        let mut config = brain_core::BrainConfig::default();
1378        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1379        let api_key = config.access.api_keys.first().unwrap().key.clone();
1380        let api_keys = config.access.api_keys.clone();
1381        let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1382
1383        // Pre-store a fact directly so search has something to find
1384        let _ = processor
1385            .store_fact_direct("personal", "test", "Ferris", "is", "the Rust mascot", None)
1386            .await
1387            .unwrap();
1388
1389        let state = Arc::new(AppState {
1390            processor,
1391            cache: Mutex::new(lru::LruCache::new(
1392                NonZeroUsize::new(CACHE_CAPACITY).unwrap(),
1393            )),
1394            api_keys,
1395            metrics: Arc::new(Metrics::default()),
1396        });
1397
1398        let router = Router::new()
1399            .route("/v1/memory/search", post(search_memory_handler))
1400            .with_state(state);
1401
1402        // Search for the stored fact
1403        let payload = serde_json::json!({"query": "Ferris Rust mascot", "top_k": 5});
1404        let request = Request::builder()
1405            .method(http::Method::POST)
1406            .uri("/v1/memory/search")
1407            .header("content-type", "application/json")
1408            .header("authorization", format!("Bearer {api_key}"))
1409            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1410            .unwrap();
1411
1412        let response = router.oneshot(request).await.unwrap();
1413        assert_eq!(response.status(), StatusCode::OK);
1414
1415        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1416            .await
1417            .unwrap();
1418        let results: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1419        // Endpoint must return a JSON array. Result count depends on embedding quality —
1420        // with no real embeddings available in unit tests, HNSW may return 0 matches.
1421        assert!(results.is_array(), "Expected array of search results");
1422    }
1423
1424    /// Integration test: cached signal can be retrieved by GET /v1/signals/:id.
1425    #[tokio::test]
1426    async fn test_get_cached_signal_with_auth() {
1427        use axum::body::Body;
1428        use axum::http::{self, Request};
1429        use tower::util::ServiceExt;
1430
1431        let temp = tempfile::tempdir().unwrap();
1432        let mut config = brain_core::BrainConfig::default();
1433        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1434        let api_key = config.access.api_keys.first().unwrap().key.clone();
1435        let api_keys = config.access.api_keys.clone();
1436        let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1437        let state = Arc::new(AppState {
1438            processor,
1439            cache: Mutex::new(lru::LruCache::new(
1440                NonZeroUsize::new(CACHE_CAPACITY).unwrap(),
1441            )),
1442            api_keys,
1443            metrics: Arc::new(Metrics::default()),
1444        });
1445
1446        // Manually insert a response into the cache
1447        let id = Uuid::new_v4();
1448        let fake_resp = SignalResponse::ok(id, "test response");
1449        state.cache.lock().await.put(id, fake_resp);
1450
1451        let router = Router::new()
1452            .route("/v1/signals/:id", get(get_signal_handler))
1453            .with_state(state);
1454
1455        let request = Request::builder()
1456            .method(http::Method::GET)
1457            .uri(format!("/v1/signals/{id}"))
1458            .header("authorization", format!("Bearer {api_key}"))
1459            .body(Body::empty())
1460            .unwrap();
1461
1462        let response = router.oneshot(request).await.unwrap();
1463        assert_eq!(response.status(), StatusCode::OK);
1464    }
1465}