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 proactive notifications (requires read)
16//!
17//! ## Authentication
18//! All `/v1/*` routes require `Authorization: Bearer <api-key>` header.
19//! The demo key `demokey123` (read+write) is pre-configured in `default.yaml`.
20
21use std::{
22    collections::HashMap,
23    net::SocketAddr,
24    sync::{
25        atomic::{AtomicU64, Ordering},
26        Arc,
27    },
28    time::Instant,
29};
30
31use axum::{
32    extract::{Path, State},
33    http::{HeaderMap, StatusCode},
34    response::{
35        sse::{Event, KeepAlive, Sse},
36        IntoResponse, Json,
37    },
38    routing::{get, post},
39    Router,
40};
41use brain_core::ApiKeyConfig;
42use serde::{Deserialize, Serialize};
43use tokio::sync::Mutex;
44use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
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}
72
73/// Search request body (POST /v1/memory/search).
74#[derive(Debug, Deserialize)]
75pub struct SearchRequest {
76    pub query: String,
77    pub top_k: Option<usize>,
78    /// Filter results to this namespace only (optional).
79    pub namespace: Option<String>,
80}
81
82/// Namespace statistics (GET /v1/memory/namespaces).
83#[derive(Debug, Serialize)]
84pub struct NamespaceJson {
85    pub namespace: String,
86    pub fact_count: i64,
87    pub episode_count: i64,
88}
89
90/// A single fact in JSON form (GET /v1/memory/facts, search results).
91#[derive(Debug, Serialize)]
92pub struct FactJson {
93    pub id: String,
94    pub namespace: String,
95    pub category: String,
96    pub subject: String,
97    pub predicate: String,
98    pub object: String,
99    pub confidence: f64,
100    pub distance: Option<f32>,
101}
102
103/// Health check response.
104#[derive(Debug, Serialize)]
105pub struct HealthResponse {
106    pub status: &'static str,
107    pub version: &'static str,
108}
109
110// ─── Metrics ─────────────────────────────────────────────────────────────────
111
112/// Atomic counters exposed at `GET /metrics` in Prometheus text format.
113#[derive(Default)]
114pub struct Metrics {
115    /// Total POST /v1/signals requests processed.
116    pub signals_total: AtomicU64,
117    /// Signals that returned a non-5xx response.
118    pub signals_ok: AtomicU64,
119    /// Signals that returned a 5xx error.
120    pub signals_error: AtomicU64,
121    /// Total POST /v1/memory/search requests.
122    pub search_total: AtomicU64,
123    /// Total GET /v1/memory/facts requests.
124    pub facts_total: AtomicU64,
125    /// Cumulative POST /v1/signals processing time in milliseconds.
126    pub signals_latency_ms_total: AtomicU64,
127}
128
129impl Metrics {
130    /// Render counters as Prometheus plain-text format (text/plain; version=0.0.4).
131    pub fn render(&self) -> String {
132        let signals_total = self.signals_total.load(Ordering::Relaxed);
133        let signals_ok = self.signals_ok.load(Ordering::Relaxed);
134        let signals_error = self.signals_error.load(Ordering::Relaxed);
135        let search_total = self.search_total.load(Ordering::Relaxed);
136        let facts_total = self.facts_total.load(Ordering::Relaxed);
137        let latency_ms = self.signals_latency_ms_total.load(Ordering::Relaxed);
138
139        format!(
140            "# HELP brain_signals_total Total signal requests received.\n\
141             # TYPE brain_signals_total counter\n\
142             brain_signals_total {signals_total}\n\
143             # HELP brain_signals_ok_total Successful signal requests.\n\
144             # TYPE brain_signals_ok_total counter\n\
145             brain_signals_ok_total {signals_ok}\n\
146             # HELP brain_signals_error_total Failed signal requests (5xx).\n\
147             # TYPE brain_signals_error_total counter\n\
148             brain_signals_error_total {signals_error}\n\
149             # HELP brain_search_total Total memory search requests.\n\
150             # TYPE brain_search_total counter\n\
151             brain_search_total {search_total}\n\
152             # HELP brain_facts_total Total memory facts requests.\n\
153             # TYPE brain_facts_total counter\n\
154             brain_facts_total {facts_total}\n\
155             # HELP brain_signals_latency_ms_total Cumulative signal processing latency in ms.\n\
156             # TYPE brain_signals_latency_ms_total counter\n\
157             brain_signals_latency_ms_total {latency_ms}\n"
158        )
159    }
160}
161
162// ─── App State ───────────────────────────────────────────────────────────────
163
164/// Shared state for all HTTP handlers.
165pub struct AppState {
166    processor: Arc<signal::SignalProcessor>,
167    /// In-memory cache: signal_id → SignalResponse.
168    cache: Mutex<HashMap<Uuid, SignalResponse>>,
169    /// Configured API keys (loaded from BrainConfig).
170    api_keys: Vec<ApiKeyConfig>,
171    /// Request counters and latency.
172    metrics: Arc<Metrics>,
173}
174
175// ─── Auth helpers ─────────────────────────────────────────────────────────────
176
177/// Extract the raw key from `Authorization: Bearer <key>`.
178fn extract_bearer(headers: &HeaderMap) -> Option<&str> {
179    headers
180        .get("authorization")
181        .and_then(|v| v.to_str().ok())
182        .and_then(|s| s.strip_prefix("Bearer "))
183}
184
185/// Check that the request carries a valid key with the given permission.
186/// Returns `Err((StatusCode::UNAUTHORIZED, message))` on failure.
187fn check_auth(
188    state: &AppState,
189    headers: &HeaderMap,
190    permission: &str,
191) -> Result<(), (StatusCode, String)> {
192    let raw_key = extract_bearer(headers).ok_or_else(|| {
193        (
194            StatusCode::UNAUTHORIZED,
195            "Missing Authorization: Bearer <key> header".to_string(),
196        )
197    })?;
198
199    match state.api_keys.iter().find(|k| k.key == raw_key) {
200        None => Err((StatusCode::UNAUTHORIZED, "Invalid API key".to_string())),
201        Some(k) if !k.has_permission(permission) => Err((
202            StatusCode::UNAUTHORIZED,
203            format!("API key does not have '{}' permission", permission),
204        )),
205        Some(_) => Ok(()),
206    }
207}
208
209// ─── Router builder ──────────────────────────────────────────────────────────
210
211/// CORS restricted to localhost origins — Brain is a local daemon, not a public service.
212/// Remote origins are blocked to prevent cross-site requests from untrusted web pages.
213fn localhost_cors() -> CorsLayer {
214    CorsLayer::new()
215        .allow_origin(AllowOrigin::predicate(|origin, _req| {
216            let bytes = origin.as_bytes();
217            bytes.starts_with(b"http://127.0.0.1")
218                || bytes.starts_with(b"http://localhost")
219                || bytes.starts_with(b"https://127.0.0.1")
220                || bytes.starts_with(b"https://localhost")
221        }))
222        .allow_methods(AllowMethods::any())
223        .allow_headers(AllowHeaders::any())
224}
225
226/// Build the axum router with all routes and CORS enabled.
227///
228/// `api_keys` is taken from `BrainConfig.access.api_keys` by the caller.
229pub fn create_router(
230    processor: Arc<signal::SignalProcessor>,
231    api_keys: Vec<ApiKeyConfig>,
232) -> Router {
233    let state = Arc::new(AppState {
234        processor,
235        cache: Mutex::new(HashMap::new()),
236        api_keys,
237        metrics: Arc::new(Metrics::default()),
238    });
239
240    Router::new()
241        .route("/health", get(health_handler))
242        .route("/metrics", get(metrics_handler))
243        .route("/ui", get(ui_handler))
244        .route("/openapi.json", get(openapi_handler))
245        .route("/api", get(swagger_ui_handler))
246        .route("/v1/signals", post(post_signal_handler))
247        .route("/v1/signals/:id", get(get_signal_handler))
248        .route("/v1/memory/search", post(search_memory_handler))
249        .route("/v1/memory/facts", get(get_facts_handler))
250        .route("/v1/memory/namespaces", get(get_namespaces_handler))
251        .route("/v1/events", get(sse_events_handler))
252        .with_state(state)
253        .layer(localhost_cors())
254}
255
256/// Start the HTTP server, binding to `host:port`.
257///
258/// Blocks until the server shuts down.
259pub async fn serve(
260    processor: Arc<signal::SignalProcessor>,
261    host: &str,
262    port: u16,
263) -> anyhow::Result<()> {
264    let api_keys = processor.config().access.api_keys.clone();
265    let router = create_router(processor, api_keys);
266    let addr: SocketAddr = format!("{host}:{port}").parse()?;
267    tracing::info!("Synapse HTTP online at http://{addr}");
268    let listener = tokio::net::TcpListener::bind(addr).await?;
269    axum::serve(listener, router).await?;
270    Ok(())
271}
272
273// ─── Handlers ────────────────────────────────────────────────────────────────
274
275/// GET /health — no authentication required
276async fn health_handler() -> Json<HealthResponse> {
277    Json(HealthResponse {
278        status: "ok",
279        version: env!("CARGO_PKG_VERSION"),
280    })
281}
282
283/// GET /metrics — Prometheus text format, no authentication required
284async fn metrics_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
285    (
286        [("content-type", "text/plain; version=0.0.4; charset=utf-8")],
287        state.metrics.render(),
288    )
289}
290
291/// Embedded single-page memory explorer UI.
292const UI_HTML: &str = r#"<!DOCTYPE html>
293<html lang="en">
294<head>
295<meta charset="UTF-8">
296<meta name="viewport" content="width=device-width, initial-scale=1.0">
297<title>Brain Memory Explorer</title>
298<style>
299  *{box-sizing:border-box;margin:0;padding:0}
300  body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh}
301  header{background:#1e293b;border-bottom:1px solid #334155;padding:1rem 2rem;display:flex;align-items:center;gap:1rem}
302  header h1{font-size:1.25rem;font-weight:700;color:#38bdf8}
303  header span{color:#94a3b8;font-size:.875rem}
304  main{max-width:1200px;margin:0 auto;padding:2rem}
305  .search-bar{display:flex;gap:.5rem;margin-bottom:2rem}
306  .search-bar input{flex:1;background:#1e293b;border:1px solid #334155;border-radius:.5rem;padding:.75rem 1rem;color:#e2e8f0;font-size:1rem;outline:none}
307  .search-bar input:focus{border-color:#38bdf8}
308  .search-bar button{background:#0ea5e9;border:none;border-radius:.5rem;padding:.75rem 1.5rem;color:#fff;font-size:1rem;cursor:pointer}
309  .search-bar button:hover{background:#0284c7}
310  .tabs{display:flex;gap:.5rem;margin-bottom:1.5rem}
311  .tab{background:#1e293b;border:1px solid #334155;border-radius:.5rem;padding:.5rem 1rem;cursor:pointer;font-size:.875rem;color:#94a3b8}
312  .tab.active{background:#0ea5e9;border-color:#0ea5e9;color:#fff}
313  #status{color:#94a3b8;font-size:.875rem;margin-bottom:1rem;min-height:1.25rem}
314  #status.error{color:#f87171}
315  .grid{display:grid;gap:1rem}
316  .card{background:#1e293b;border:1px solid #334155;border-radius:.75rem;padding:1.25rem}
317  .card .meta{font-size:.75rem;color:#64748b;margin-bottom:.5rem}
318  .card .subject{font-weight:600;color:#38bdf8;margin-bottom:.25rem}
319  .card .predicate{color:#94a3b8;font-size:.875rem;margin-bottom:.25rem}
320  .card .object{color:#e2e8f0}
321  .card .badge{display:inline-block;background:#0f172a;border:1px solid #334155;border-radius:.25rem;padding:.15rem .4rem;font-size:.7rem;color:#64748b;margin-right:.25rem}
322  .card .conf{color:#4ade80;font-size:.75rem}
323  .empty{color:#475569;text-align:center;padding:3rem;font-size:.9rem}
324  .key-bar{display:flex;align-items:center;gap:.5rem;margin-left:auto}
325  .key-bar label{font-size:.75rem;color:#64748b}
326  .key-bar input{background:#0f172a;border:1px solid #334155;border-radius:.25rem;padding:.35rem .5rem;color:#94a3b8;font-size:.75rem;width:10rem;outline:none}
327  .key-bar input:focus{border-color:#38bdf8}
328</style>
329</head>
330<body>
331<header>
332  <h1>🧠 Brain</h1>
333  <span>Memory Explorer</span>
334  <div class="key-bar">
335    <label for="api-key">API Key</label>
336    <input id="api-key" type="password" placeholder="Enter API key">
337    <span id="api-status" style="font-size:.75rem"></span>
338  </div>
339</header>
340<main>
341  <div class="search-bar">
342    <input id="q" type="search" placeholder="Search memory… (e.g. Rust, project goals)" autofocus>
343    <button onclick="search()">Search</button>
344  </div>
345  <div class="tabs">
346    <div class="tab active" id="tab-search" onclick="switchTab('search')">Search Results</div>
347    <div class="tab" id="tab-facts" onclick="switchTab('facts')">All Facts</div>
348  </div>
349  <div id="status"></div>
350  <div id="results" class="grid"></div>
351</main>
352<script>
353const API = '';
354let apiKey = localStorage.getItem('brain_api_key') || '';
355
356const keyInput = document.getElementById('api-key');
357keyInput.value = apiKey;
358function applyKey(){
359  apiKey = keyInput.value.trim();
360  localStorage.setItem('brain_api_key', apiKey);
361  keyInput.style.borderColor='';
362  loadFacts();
363}
364keyInput.addEventListener('change', applyKey);
365keyInput.addEventListener('keydown', function(e){ if(e.key==='Enter') applyKey(); });
366
367function hdr(){ return {'Authorization':'Bearer '+apiKey,'Content-Type':'application/json'} }
368
369function flagKeyInput(msg){
370  keyInput.style.borderColor='#f87171';
371  keyInput.focus();
372  setStatus(msg || 'Invalid API key — enter your key above',true);
373}
374
375async function checkHealth(){
376  try{
377    const r=await fetch(API+'/health');
378    const d=await r.json();
379    document.getElementById('api-status').textContent='● '+d.status+' v'+d.version;
380    document.getElementById('api-status').style.color='#4ade80';
381  }catch(e){
382    document.getElementById('api-status').textContent='● unreachable';
383    document.getElementById('api-status').style.color='#f87171';
384  }
385}
386
387function setStatus(msg,err){
388  const el=document.getElementById('status');
389  el.textContent=msg;
390  el.className=err?'error':'';
391}
392
393function renderFact(f){
394  const dist=f.distance!=null?' · dist '+f.distance.toFixed(3):'';
395  return `<div class="card">
396    <div class="meta"><span class="badge">${f.namespace}</span><span class="badge">${f.category}</span><span class="conf">conf ${f.confidence.toFixed(2)}</span>${dist}</div>
397    <div class="subject">${esc(f.subject)}</div>
398    <div class="predicate">${esc(f.predicate)}</div>
399    <div class="object">${esc(f.object)}</div>
400  </div>`;
401}
402
403function esc(s){ const d=document.createElement('div');d.textContent=s;return d.innerHTML; }
404
405async function search(){
406  const q=document.getElementById('q').value.trim();
407  if(!q){await loadFacts();return;}
408  if(!apiKey){flagKeyInput('Enter your API key above to search');return;}
409  setStatus('Searching…');
410  try{
411    const r=await fetch(API+'/v1/memory/search',{method:'POST',headers:hdr(),body:JSON.stringify({query:q,top_k:20})});
412    if(r.status===401){flagKeyInput();return;}
413    if(!r.ok){setStatus('Search failed: '+r.status,true);return;}
414    const facts=await r.json();
415    setStatus(facts.length+' result'+(facts.length!==1?'s':''));
416    document.getElementById('results').innerHTML=facts.length?facts.map(renderFact).join(''):'<p class="empty">No matching facts found.</p>';
417  }catch(e){setStatus('Error: '+e.message,true);}
418}
419
420async function loadFacts(){
421  if(!apiKey){flagKeyInput('Enter your API key above to browse memories');return;}
422  setStatus('Loading…');
423  try{
424    const r=await fetch(API+'/v1/memory/facts',{headers:hdr()});
425    if(r.status===401){flagKeyInput();return;}
426    if(!r.ok){setStatus('Failed to load facts: '+r.status,true);return;}
427    const facts=await r.json();
428    keyInput.style.borderColor='#4ade80';
429    setStatus(facts.length+' stored fact'+(facts.length!==1?'s':''));
430    document.getElementById('results').innerHTML=facts.length?facts.map(renderFact).join(''):'<p class="empty">No facts stored yet. Send a "Remember…" signal to add some.</p>';
431  }catch(e){setStatus('Error: '+e.message,true);}
432}
433
434function switchTab(t){
435  document.querySelectorAll('.tab').forEach(el=>el.classList.remove('active'));
436  document.getElementById('tab-'+t).classList.add('active');
437  if(t==='facts')loadFacts();
438  else{document.getElementById('results').innerHTML='';setStatus('');}
439}
440
441document.getElementById('q').addEventListener('keydown',e=>{if(e.key==='Enter')search();});
442checkHealth();
443loadFacts();
444</script>
445</body>
446</html>"#;
447
448/// GET /ui — embedded single-page memory explorer (no auth required)
449async fn ui_handler() -> impl IntoResponse {
450    ([("content-type", "text/html; charset=utf-8")], UI_HTML)
451}
452
453// ─── OpenAPI spec ─────────────────────────────────────────────────────────────
454
455/// Build the OpenAPI 3.0 document for the Brain HTTP API.
456fn build_openapi() -> serde_json::Value {
457    serde_json::json!({
458        "openapi": "3.0.3",
459        "info": {
460            "title": "Brain OS — Synapse HTTP API",
461            "description": "Your AI's long-term memory — signal processing, semantic search, and episodic recall.",
462            "version": env!("CARGO_PKG_VERSION"),
463            "contact": { "name": "Brain OS", "url": "https://github.com/keshavashiya/brain" }
464        },
465        "servers": [{ "url": "/", "description": "Local Brain instance" }],
466        "components": {
467            "securitySchemes": {
468                "BearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "APIKey" }
469            },
470            "schemas": {
471                "HealthResponse": {
472                    "type": "object", "required": ["status","version"],
473                    "properties": {
474                        "status": { "type": "string", "example": "ok" },
475                        "version": { "type": "string", "example": "0.1.0" }
476                    }
477                },
478                "SignalRequest": {
479                    "type": "object", "required": ["content"],
480                    "properties": {
481                        "content": { "type": "string", "example": "Remember that Rust is memory-safe" },
482                        "source": { "type": "string", "enum": ["http","cli","ws","mcp","grpc"] },
483                        "channel": { "type": "string" },
484                        "sender": { "type": "string" },
485                        "namespace": { "type": "string", "default": "personal" },
486                        "metadata": { "type": "object", "additionalProperties": { "type": "string" } }
487                    }
488                },
489                "SignalResponse": {
490                    "type": "object", "required": ["signal_id","status","response","memory_context"],
491                    "properties": {
492                        "signal_id": { "type": "string", "format": "uuid" },
493                        "status": { "type": "string", "enum": ["Ok","Error"] },
494                        "response": {
495                            "type": "object",
496                            "properties": {
497                                "type": { "type": "string", "enum": ["Text","Json","Error"] },
498                                "value": {}
499                            }
500                        },
501                        "memory_context": {
502                            "type": "object",
503                            "properties": {
504                                "facts_used": { "type": "integer" },
505                                "episodes_used": { "type": "integer" }
506                            }
507                        }
508                    }
509                },
510                "SearchRequest": {
511                    "type": "object", "required": ["query"],
512                    "properties": {
513                        "query": { "type": "string", "example": "Rust programming" },
514                        "top_k": { "type": "integer", "default": 10 },
515                        "namespace": { "type": "string" }
516                    }
517                },
518                "FactJson": {
519                    "type": "object",
520                    "properties": {
521                        "id": { "type": "string" },
522                        "namespace": { "type": "string" },
523                        "category": { "type": "string" },
524                        "subject": { "type": "string" },
525                        "predicate": { "type": "string" },
526                        "object": { "type": "string" },
527                        "confidence": { "type": "number", "format": "double" },
528                        "distance": { "type": "number", "format": "float", "nullable": true }
529                    }
530                },
531                "NamespaceJson": {
532                    "type": "object",
533                    "properties": {
534                        "namespace": { "type": "string" },
535                        "fact_count": { "type": "integer" },
536                        "episode_count": { "type": "integer" }
537                    }
538                }
539            }
540        },
541        "paths": {
542            "/health": {
543                "get": {
544                    "summary": "Health check",
545                    "operationId": "getHealth",
546                    "responses": {
547                        "200": { "description": "Service is healthy", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HealthResponse" } } } }
548                    }
549                }
550            },
551            "/metrics": {
552                "get": {
553                    "summary": "Prometheus metrics",
554                    "operationId": "getMetrics",
555                    "responses": {
556                        "200": { "description": "Prometheus text format metrics", "content": { "text/plain": { "schema": { "type": "string" } } } }
557                    }
558                }
559            },
560            "/v1/signals": {
561                "post": {
562                    "summary": "Submit a signal for processing",
563                    "operationId": "postSignal",
564                    "security": [{ "BearerAuth": [] }],
565                    "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignalRequest" } } } },
566                    "responses": {
567                        "200": { "description": "Signal processed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignalResponse" } } } },
568                        "401": { "description": "Unauthorized — missing or invalid API key" },
569                        "500": { "description": "Internal server error" }
570                    }
571                }
572            },
573            "/v1/signals/{id}": {
574                "get": {
575                    "summary": "Retrieve a cached signal response by ID",
576                    "operationId": "getSignalById",
577                    "security": [{ "BearerAuth": [] }],
578                    "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } }],
579                    "responses": {
580                        "200": { "description": "Signal response found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SignalResponse" } } } },
581                        "401": { "description": "Unauthorized" },
582                        "404": { "description": "Signal not found in cache" }
583                    }
584                }
585            },
586            "/v1/memory/search": {
587                "post": {
588                    "summary": "Semantic search over stored facts",
589                    "operationId": "searchMemory",
590                    "security": [{ "BearerAuth": [] }],
591                    "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SearchRequest" } } } },
592                    "responses": {
593                        "200": { "description": "Matching facts", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactJson" } } } } },
594                        "401": { "description": "Unauthorized" }
595                    }
596                }
597            },
598            "/v1/memory/facts": {
599                "get": {
600                    "summary": "List all stored semantic facts",
601                    "operationId": "listFacts",
602                    "security": [{ "BearerAuth": [] }],
603                    "parameters": [{ "name": "namespace", "in": "query", "schema": { "type": "string" } }],
604                    "responses": {
605                        "200": { "description": "List of facts", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/FactJson" } } } } },
606                        "401": { "description": "Unauthorized" }
607                    }
608                }
609            },
610            "/v1/memory/namespaces": {
611                "get": {
612                    "summary": "List memory namespaces with statistics",
613                    "operationId": "listNamespaces",
614                    "security": [{ "BearerAuth": [] }],
615                    "responses": {
616                        "200": { "description": "List of namespaces", "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/NamespaceJson" } } } } },
617                        "401": { "description": "Unauthorized" }
618                    }
619                }
620            }
621        }
622    })
623}
624
625/// GET /openapi.json — OpenAPI 3.0 specification (no auth required)
626async fn openapi_handler() -> impl IntoResponse {
627    (
628        [("content-type", "application/json")],
629        build_openapi().to_string(),
630    )
631}
632
633/// Swagger UI HTML that loads the spec from /openapi.json (CDN assets).
634const SWAGGER_UI_HTML: &str = r#"<!DOCTYPE html>
635<html lang="en">
636<head>
637  <meta charset="UTF-8">
638  <title>Brain OS API — Swagger UI</title>
639  <meta name="viewport" content="width=device-width, initial-scale=1">
640  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
641</head>
642<body>
643  <div id="swagger-ui"></div>
644  <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
645  <script>
646    SwaggerUIBundle({
647      url: '/openapi.json',
648      dom_id: '#swagger-ui',
649      presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
650      layout: 'StandaloneLayout'
651    });
652  </script>
653</body>
654</html>"#;
655
656/// GET /api — Swagger UI for interactive API exploration (no auth required)
657async fn swagger_ui_handler() -> impl IntoResponse {
658    (
659        [("content-type", "text/html; charset=utf-8")],
660        SWAGGER_UI_HTML,
661    )
662}
663
664/// POST /v1/signals — requires write permission
665async fn post_signal_handler(
666    State(state): State<Arc<AppState>>,
667    headers: HeaderMap,
668    Json(body): Json<SignalRequest>,
669) -> Result<Json<SignalResponse>, (StatusCode, String)> {
670    check_auth(&state, &headers, "write")?;
671
672    let t0 = Instant::now();
673    state.metrics.signals_total.fetch_add(1, Ordering::Relaxed);
674
675    let source = parse_source(body.source.as_deref());
676    let signal = Signal::new(
677        source,
678        body.channel.unwrap_or_else(|| "http".to_string()),
679        body.sender.unwrap_or_else(|| "apiclient".to_string()),
680        body.content,
681    )
682    .with_metadata(body.metadata.unwrap_or_default())
683    .with_namespace_opt(body.namespace)
684    .with_agent_opt(body.agent);
685
686    let signal_id = signal.id;
687    let result = state.processor.process(signal).await;
688
689    let elapsed_ms = t0.elapsed().as_millis() as u64;
690    state
691        .metrics
692        .signals_latency_ms_total
693        .fetch_add(elapsed_ms, Ordering::Relaxed);
694
695    let response = match result {
696        Ok(r) => {
697            state.metrics.signals_ok.fetch_add(1, Ordering::Relaxed);
698            tracing::info!(
699                signal_id = %signal_id,
700                latency_ms = elapsed_ms,
701                "signal processed"
702            );
703            r
704        }
705        Err(e) => {
706            state.metrics.signals_error.fetch_add(1, Ordering::Relaxed);
707            tracing::error!(
708                signal_id = %signal_id,
709                latency_ms = elapsed_ms,
710                error = %e,
711                "signal processing failed"
712            );
713            // Return an opaque error — do not leak internal details to the client.
714            return Err((
715                StatusCode::INTERNAL_SERVER_ERROR,
716                "Signal processing failed. Check server logs for details.".to_string(),
717            ));
718        }
719    };
720
721    // Cache the response so GET /v1/signals/:id can retrieve it
722    state.cache.lock().await.insert(signal_id, response.clone());
723
724    Ok(Json(response))
725}
726
727/// GET /v1/signals/:id — requires read permission
728async fn get_signal_handler(
729    State(state): State<Arc<AppState>>,
730    headers: HeaderMap,
731    Path(id): Path<String>,
732) -> Result<Json<SignalResponse>, (StatusCode, String)> {
733    check_auth(&state, &headers, "read")?;
734
735    let uuid = Uuid::parse_str(&id)
736        .map_err(|_| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {id}")))?;
737
738    let cache = state.cache.lock().await;
739    match cache.get(&uuid) {
740        Some(resp) => Ok(Json(resp.clone())),
741        None => Err((
742            StatusCode::NOT_FOUND,
743            format!("Signal {uuid} not found in cache"),
744        )),
745    }
746}
747
748/// POST /v1/memory/search — requires read permission
749async fn search_memory_handler(
750    State(state): State<Arc<AppState>>,
751    headers: HeaderMap,
752    Json(body): Json<SearchRequest>,
753) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
754    check_auth(&state, &headers, "read")?;
755
756    state.metrics.search_total.fetch_add(1, Ordering::Relaxed);
757    let t0 = Instant::now();
758    let top_k = body.top_k.unwrap_or(10);
759    let namespace = body.namespace.as_deref();
760    let results = state
761        .processor
762        .search_facts(&body.query, top_k, namespace)
763        .await;
764    tracing::debug!(latency_ms = t0.elapsed().as_millis() as u64, query = %body.query, "memory search");
765
766    let facts = results
767        .into_iter()
768        .map(|r| FactJson {
769            id: r.fact.id,
770            namespace: r.fact.namespace,
771            category: r.fact.category,
772            subject: r.fact.subject,
773            predicate: r.fact.predicate,
774            object: r.fact.object,
775            confidence: r.fact.confidence,
776            distance: Some(r.distance),
777        })
778        .collect();
779
780    Ok(Json(facts))
781}
782
783/// GET /v1/memory/facts — requires read permission
784///
785/// Accepts optional `namespace` query parameter to filter results.
786async fn get_facts_handler(
787    State(state): State<Arc<AppState>>,
788    headers: HeaderMap,
789    axum::extract::Query(params): axum::extract::Query<HashMap<String, String>>,
790) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
791    check_auth(&state, &headers, "read")?;
792
793    state.metrics.facts_total.fetch_add(1, Ordering::Relaxed);
794    let namespace = params.get("namespace").map(|s| s.as_str());
795    let facts = state
796        .processor
797        .list_facts(namespace)
798        .into_iter()
799        .map(|f| FactJson {
800            id: f.id,
801            namespace: f.namespace,
802            category: f.category,
803            subject: f.subject,
804            predicate: f.predicate,
805            object: f.object,
806            confidence: f.confidence,
807            distance: None,
808        })
809        .collect();
810
811    Ok(Json(facts))
812}
813
814/// GET /v1/memory/namespaces — requires read permission
815async fn get_namespaces_handler(
816    State(state): State<Arc<AppState>>,
817    headers: HeaderMap,
818) -> Result<Json<Vec<NamespaceJson>>, (StatusCode, String)> {
819    check_auth(&state, &headers, "read")?;
820
821    let namespaces = state
822        .processor
823        .list_namespaces()
824        .into_iter()
825        .map(|n| NamespaceJson {
826            namespace: n.namespace,
827            fact_count: n.fact_count,
828            episode_count: n.episode_count,
829        })
830        .collect();
831
832    Ok(Json(namespaces))
833}
834
835// ─── SSE event stream ───────────────────────────────────────────────────────
836
837/// `GET /v1/events` — Server-Sent Events stream of proactive notifications.
838///
839/// Subscribes to the NotificationRouter broadcast channel and streams
840/// each notification as a JSON SSE event. The connection stays open
841/// until the client disconnects.
842async fn sse_events_handler(
843    State(state): State<Arc<AppState>>,
844    headers: HeaderMap,
845) -> Result<
846    Sse<impl futures_core::Stream<Item = Result<Event, std::convert::Infallible>>>,
847    (StatusCode, String),
848> {
849    check_auth(&state, &headers, "read")?;
850
851    let router = state.processor.notification_router().ok_or_else(|| {
852        (
853            StatusCode::SERVICE_UNAVAILABLE,
854            "Proactive notifications not configured".to_string(),
855        )
856    })?;
857
858    let mut rx = router.subscribe();
859
860    let stream = async_stream::stream! {
861        loop {
862            match rx.recv().await {
863                Ok(notification) => {
864                    let payload = serde_json::json!({
865                        "type": "proactive",
866                        "content": notification.content,
867                        "triggered_by": notification.triggered_by,
868                        "priority": notification.priority,
869                        "agent": notification.agent,
870                    });
871                    yield Ok(Event::default()
872                        .event("notification")
873                        .json_data(payload)
874                        .unwrap_or_else(|_| Event::default().data("{}")));
875                }
876                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
877                    tracing::warn!(skipped = n, "SSE client lagged behind");
878                    yield Ok(Event::default()
879                        .event("error")
880                        .data(format!("{{\"lagged\":{n}}}")));
881                }
882                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
883            }
884        }
885    };
886
887    Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
888}
889
890// ─── Helpers ─────────────────────────────────────────────────────────────────
891
892fn parse_source(s: Option<&str>) -> SignalSource {
893    match s {
894        Some("http") | None => SignalSource::Http,
895        Some("cli") => SignalSource::Cli,
896        Some("ws") | Some("websocket") => SignalSource::WebSocket,
897        Some("mcp") => SignalSource::Mcp,
898        Some("grpc") => SignalSource::Grpc,
899        _ => SignalSource::Http,
900    }
901}
902
903// ─── Tests ───────────────────────────────────────────────────────────────────
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    /// Build a test router with the demo key pre-loaded.
910    async fn make_router() -> (Router, tempfile::TempDir) {
911        let temp = tempfile::tempdir().unwrap();
912        let mut config = brain_core::BrainConfig::default();
913        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
914        let api_keys = config.access.api_keys.clone();
915        let processor = signal::SignalProcessor::new(config).await.unwrap();
916        let router = create_router(Arc::new(processor), api_keys);
917        (router, temp)
918    }
919
920    #[test]
921    fn test_parse_source_defaults_to_http() {
922        assert_eq!(parse_source(None), SignalSource::Http);
923        assert_eq!(parse_source(Some("http")), SignalSource::Http);
924    }
925
926    #[test]
927    fn test_parse_source_all_variants() {
928        assert_eq!(parse_source(Some("cli")), SignalSource::Cli);
929        assert_eq!(parse_source(Some("ws")), SignalSource::WebSocket);
930        assert_eq!(parse_source(Some("mcp")), SignalSource::Mcp);
931        assert_eq!(parse_source(Some("grpc")), SignalSource::Grpc);
932    }
933
934    #[test]
935    fn test_health_response_serializes() {
936        let h = HealthResponse {
937            status: "ok",
938            version: "1.0.0",
939        };
940        let json = serde_json::to_string(&h).unwrap();
941        assert!(json.contains("\"status\":\"ok\""));
942        assert!(json.contains("\"version\""));
943    }
944
945    #[test]
946    fn test_fact_json_serializes() {
947        let f = FactJson {
948            id: "abc".into(),
949            namespace: "personal".into(),
950            category: "personal".into(),
951            subject: "user".into(),
952            predicate: "likes".into(),
953            object: "Rust".into(),
954            confidence: 0.9,
955            distance: Some(0.05),
956        };
957        let json = serde_json::to_string(&f).unwrap();
958        assert!(json.contains("\"subject\":\"user\""));
959        assert!(json.contains("\"namespace\":\"personal\""));
960        assert!(json.contains("\"distance\":0.05"));
961    }
962
963    /// GET /openapi.json — no auth required, returns valid OpenAPI spec.
964    #[tokio::test]
965    async fn test_openapi_endpoint() {
966        use axum::body::Body;
967        use axum::http::{self, Request};
968        use tower::util::ServiceExt;
969
970        let (router, _tmp) = make_router().await;
971
972        let request = Request::builder()
973            .method(http::Method::GET)
974            .uri("/openapi.json")
975            .body(Body::empty())
976            .unwrap();
977
978        let response = router.oneshot(request).await.unwrap();
979        assert_eq!(response.status(), StatusCode::OK);
980
981        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
982            .await
983            .unwrap();
984        let spec: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
985        assert_eq!(spec["openapi"], "3.0.3");
986        assert!(
987            spec["paths"]["/v1/signals"].is_object(),
988            "missing /v1/signals path"
989        );
990        assert!(
991            spec["components"]["schemas"]["FactJson"].is_object(),
992            "missing FactJson schema"
993        );
994    }
995
996    /// GET /api — no auth required, returns Swagger UI HTML.
997    #[tokio::test]
998    async fn test_swagger_ui_endpoint() {
999        use axum::body::Body;
1000        use axum::http::{self, Request};
1001        use tower::util::ServiceExt;
1002
1003        let (router, _tmp) = make_router().await;
1004
1005        let request = Request::builder()
1006            .method(http::Method::GET)
1007            .uri("/api")
1008            .body(Body::empty())
1009            .unwrap();
1010
1011        let response = router.oneshot(request).await.unwrap();
1012        assert_eq!(response.status(), StatusCode::OK);
1013
1014        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1015            .await
1016            .unwrap();
1017        let body = std::str::from_utf8(&bytes).unwrap();
1018        assert!(body.contains("swagger-ui"), "missing Swagger UI element");
1019        assert!(body.contains("/openapi.json"), "missing spec URL reference");
1020    }
1021
1022    /// GET /ui — no auth required, returns HTML page.
1023    #[tokio::test]
1024    async fn test_ui_endpoint() {
1025        use axum::body::Body;
1026        use axum::http::{self, Request};
1027        use tower::util::ServiceExt;
1028
1029        let (router, _tmp) = make_router().await;
1030
1031        let request = Request::builder()
1032            .method(http::Method::GET)
1033            .uri("/ui")
1034            .body(Body::empty())
1035            .unwrap();
1036
1037        let response = router.oneshot(request).await.unwrap();
1038        assert_eq!(response.status(), StatusCode::OK);
1039
1040        let ct = response
1041            .headers()
1042            .get("content-type")
1043            .unwrap()
1044            .to_str()
1045            .unwrap();
1046        assert!(ct.contains("text/html"), "expected text/html, got: {ct}");
1047
1048        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1049            .await
1050            .unwrap();
1051        let body = std::str::from_utf8(&bytes).unwrap();
1052        assert!(body.contains("Brain Memory Explorer"), "missing page title");
1053        assert!(
1054            body.contains("/v1/memory/search"),
1055            "missing API endpoint reference"
1056        );
1057    }
1058
1059    /// GET /metrics — no auth required, returns Prometheus text.
1060    #[tokio::test]
1061    async fn test_metrics_endpoint() {
1062        use axum::body::Body;
1063        use axum::http::{self, Request};
1064        use tower::util::ServiceExt;
1065
1066        let (router, _tmp) = make_router().await;
1067
1068        let request = Request::builder()
1069            .method(http::Method::GET)
1070            .uri("/metrics")
1071            .body(Body::empty())
1072            .unwrap();
1073
1074        let response = router.oneshot(request).await.unwrap();
1075        assert_eq!(response.status(), StatusCode::OK);
1076
1077        let ct = response
1078            .headers()
1079            .get("content-type")
1080            .unwrap()
1081            .to_str()
1082            .unwrap();
1083        assert!(ct.contains("text/plain"), "expected text/plain, got: {ct}");
1084
1085        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1086            .await
1087            .unwrap();
1088        let body = std::str::from_utf8(&bytes).unwrap();
1089        assert!(
1090            body.contains("brain_signals_total"),
1091            "missing counter in metrics output"
1092        );
1093        assert!(
1094            body.contains("brain_search_total"),
1095            "missing search counter"
1096        );
1097    }
1098
1099    /// GET /health — no auth required, always returns 200.
1100    #[tokio::test]
1101    async fn test_health_endpoint() {
1102        use axum::body::Body;
1103        use axum::http::{self, Request};
1104        use tower::util::ServiceExt;
1105
1106        let (router, _tmp) = make_router().await;
1107
1108        let request = Request::builder()
1109            .method(http::Method::GET)
1110            .uri("/health")
1111            .body(Body::empty())
1112            .unwrap();
1113
1114        let response = router.oneshot(request).await.unwrap();
1115        assert_eq!(response.status(), StatusCode::OK);
1116
1117        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1118            .await
1119            .unwrap();
1120        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1121        assert_eq!(body["status"], "ok");
1122    }
1123
1124    /// POST /v1/signals without auth → 401.
1125    #[tokio::test]
1126    async fn test_post_signal_no_auth_returns_401() {
1127        use axum::body::Body;
1128        use axum::http::{self, Request};
1129        use tower::util::ServiceExt;
1130
1131        let (router, _tmp) = make_router().await;
1132
1133        let payload = serde_json::json!({"content": "Remember Rust is fast"});
1134        let request = Request::builder()
1135            .method(http::Method::POST)
1136            .uri("/v1/signals")
1137            .header("content-type", "application/json")
1138            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1139            .unwrap();
1140
1141        let response = router.oneshot(request).await.unwrap();
1142        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1143    }
1144
1145    /// POST /v1/signals with invalid key → 401.
1146    #[tokio::test]
1147    async fn test_post_signal_invalid_key_returns_401() {
1148        use axum::body::Body;
1149        use axum::http::{self, Request};
1150        use tower::util::ServiceExt;
1151
1152        let (router, _tmp) = make_router().await;
1153
1154        let payload = serde_json::json!({"content": "Remember Rust is fast"});
1155        let request = Request::builder()
1156            .method(http::Method::POST)
1157            .uri("/v1/signals")
1158            .header("content-type", "application/json")
1159            .header("authorization", "Bearer wrong-key")
1160            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1161            .unwrap();
1162
1163        let response = router.oneshot(request).await.unwrap();
1164        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1165    }
1166
1167    /// POST /v1/signals with valid demo key → 200.
1168    #[tokio::test]
1169    async fn test_post_signal_store_fact_with_auth() {
1170        use axum::body::Body;
1171        use axum::http::{self, Request};
1172        use tower::util::ServiceExt;
1173
1174        let (router, _tmp) = make_router().await;
1175
1176        let payload = serde_json::json!({"content": "Remember that Rust is fast"});
1177        let request = Request::builder()
1178            .method(http::Method::POST)
1179            .uri("/v1/signals")
1180            .header("content-type", "application/json")
1181            .header("authorization", "Bearer demokey123")
1182            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1183            .unwrap();
1184
1185        let response = router.oneshot(request).await.unwrap();
1186        assert_eq!(response.status(), StatusCode::OK);
1187
1188        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1189            .await
1190            .unwrap();
1191        let resp: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1192        assert_eq!(resp["status"], "Ok");
1193    }
1194
1195    /// GET /v1/memory/facts with no auth → 401.
1196    #[tokio::test]
1197    async fn test_get_facts_no_auth_returns_401() {
1198        use axum::body::Body;
1199        use axum::http::{self, Request};
1200        use tower::util::ServiceExt;
1201
1202        let (router, _tmp) = make_router().await;
1203
1204        let request = Request::builder()
1205            .method(http::Method::GET)
1206            .uri("/v1/memory/facts")
1207            .body(Body::empty())
1208            .unwrap();
1209
1210        let response = router.oneshot(request).await.unwrap();
1211        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1212    }
1213
1214    /// GET /v1/memory/facts with valid demo key → 200.
1215    #[tokio::test]
1216    async fn test_get_facts_endpoint_with_auth() {
1217        use axum::body::Body;
1218        use axum::http::{self, Request};
1219        use tower::util::ServiceExt;
1220
1221        let (router, _tmp) = make_router().await;
1222
1223        let request = Request::builder()
1224            .method(http::Method::GET)
1225            .uri("/v1/memory/facts")
1226            .header("authorization", "Bearer demokey123")
1227            .body(Body::empty())
1228            .unwrap();
1229
1230        let response = router.oneshot(request).await.unwrap();
1231        assert_eq!(response.status(), StatusCode::OK);
1232
1233        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1234            .await
1235            .unwrap();
1236        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1237        assert!(body.is_array());
1238    }
1239
1240    /// POST /v1/memory/search with valid read-only key → 200.
1241    #[tokio::test]
1242    async fn test_search_with_read_only_key() {
1243        use axum::body::Body;
1244        use axum::http::{self, Request};
1245        use tower::util::ServiceExt;
1246
1247        let temp = tempfile::tempdir().unwrap();
1248        let mut config = brain_core::BrainConfig::default();
1249        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1250        // Add a read-only key
1251        config.access.api_keys.push(ApiKeyConfig {
1252            key: "read-only-key".to_string(),
1253            name: "Read Only".to_string(),
1254            permissions: vec!["read".to_string()],
1255        });
1256        let api_keys = config.access.api_keys.clone();
1257        let processor = signal::SignalProcessor::new(config).await.unwrap();
1258        let router = create_router(Arc::new(processor), api_keys);
1259
1260        let payload = serde_json::json!({"query": "Rust", "top_k": 5});
1261        let request = Request::builder()
1262            .method(http::Method::POST)
1263            .uri("/v1/memory/search")
1264            .header("content-type", "application/json")
1265            .header("authorization", "Bearer read-only-key")
1266            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1267            .unwrap();
1268
1269        let response = router.oneshot(request).await.unwrap();
1270        assert_eq!(response.status(), StatusCode::OK);
1271    }
1272
1273    /// POST /v1/signals with read-only key → 401 (missing write permission).
1274    #[tokio::test]
1275    async fn test_post_signal_read_only_key_returns_401() {
1276        use axum::body::Body;
1277        use axum::http::{self, Request};
1278        use tower::util::ServiceExt;
1279
1280        let temp = tempfile::tempdir().unwrap();
1281        let mut config = brain_core::BrainConfig::default();
1282        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1283        config.access.api_keys.push(ApiKeyConfig {
1284            key: "read-only-key".to_string(),
1285            name: "Read Only".to_string(),
1286            permissions: vec!["read".to_string()],
1287        });
1288        let api_keys = config.access.api_keys.clone();
1289        let processor = signal::SignalProcessor::new(config).await.unwrap();
1290        let router = create_router(Arc::new(processor), api_keys);
1291
1292        let payload = serde_json::json!({"content": "Remember something"});
1293        let request = Request::builder()
1294            .method(http::Method::POST)
1295            .uri("/v1/signals")
1296            .header("content-type", "application/json")
1297            .header("authorization", "Bearer read-only-key")
1298            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1299            .unwrap();
1300
1301        let response = router.oneshot(request).await.unwrap();
1302        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1303    }
1304
1305    /// Integration test: HTTP POST /v1/signals (store intent) → fact persisted in DB.
1306    ///
1307    /// Stores a fact via the HTTP signal endpoint, then verifies it appears in
1308    /// GET /v1/memory/facts. Uses shared AppState so both requests hit the same
1309    /// SignalProcessor and SQLite database.
1310    #[tokio::test]
1311    async fn test_http_store_signal_fact_persisted_in_db() {
1312        use axum::body::Body;
1313        use axum::http::{self, Request};
1314        use tower::util::ServiceExt;
1315
1316        let temp = tempfile::tempdir().unwrap();
1317        let mut config = brain_core::BrainConfig::default();
1318        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1319        let api_keys = config.access.api_keys.clone();
1320        let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1321        let state = Arc::new(AppState {
1322            processor,
1323            cache: Mutex::new(HashMap::new()),
1324            api_keys,
1325            metrics: Arc::new(Metrics::default()),
1326        });
1327
1328        // POST /v1/signals with a store-fact intent
1329        let payload = serde_json::json!({"content": "Remember that Rust is fast"});
1330        let post_req = Request::builder()
1331            .method(http::Method::POST)
1332            .uri("/v1/signals")
1333            .header("content-type", "application/json")
1334            .header("authorization", "Bearer demokey123")
1335            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1336            .unwrap();
1337
1338        let router = Router::new()
1339            .route("/v1/signals", post(post_signal_handler))
1340            .route("/v1/memory/facts", get(get_facts_handler))
1341            .with_state(state.clone());
1342
1343        let post_resp = router.clone().oneshot(post_req).await.unwrap();
1344        assert_eq!(post_resp.status(), StatusCode::OK);
1345
1346        let bytes = axum::body::to_bytes(post_resp.into_body(), usize::MAX)
1347            .await
1348            .unwrap();
1349        let resp_json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1350        assert_eq!(resp_json["status"], "Ok");
1351        // Signal was processed — memory_context is present (facts_used depends on embeddings)
1352        assert!(resp_json["memory_context"].is_object());
1353
1354        // GET /v1/memory/facts → fact should now be persisted in DB
1355        let get_req = Request::builder()
1356            .method(http::Method::GET)
1357            .uri("/v1/memory/facts")
1358            .header("authorization", "Bearer demokey123")
1359            .body(Body::empty())
1360            .unwrap();
1361
1362        let get_resp = router.oneshot(get_req).await.unwrap();
1363        assert_eq!(get_resp.status(), StatusCode::OK);
1364
1365        let bytes = axum::body::to_bytes(get_resp.into_body(), usize::MAX)
1366            .await
1367            .unwrap();
1368        let facts: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1369        assert!(facts.is_array(), "Expected array of facts");
1370        assert!(
1371            !facts.as_array().unwrap().is_empty(),
1372            "Stored fact should appear in GET /v1/memory/facts"
1373        );
1374    }
1375
1376    /// Integration test: HTTP POST /v1/memory/search → returns relevant fact.
1377    ///
1378    /// Stores a fact via the SignalProcessor directly (bypassing HTTP for setup),
1379    /// then calls POST /v1/memory/search and verifies the fact is returned.
1380    #[tokio::test]
1381    async fn test_http_memory_search_returns_stored_fact() {
1382        use axum::body::Body;
1383        use axum::http::{self, Request};
1384        use tower::util::ServiceExt;
1385
1386        let temp = tempfile::tempdir().unwrap();
1387        let mut config = brain_core::BrainConfig::default();
1388        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1389        let api_keys = config.access.api_keys.clone();
1390        let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1391
1392        // Pre-store a fact directly so search has something to find
1393        let _ = processor
1394            .store_fact_direct("personal", "test", "Ferris", "is", "the Rust mascot", None)
1395            .await
1396            .unwrap();
1397
1398        let state = Arc::new(AppState {
1399            processor,
1400            cache: Mutex::new(HashMap::new()),
1401            api_keys,
1402            metrics: Arc::new(Metrics::default()),
1403        });
1404
1405        let router = Router::new()
1406            .route("/v1/memory/search", post(search_memory_handler))
1407            .with_state(state);
1408
1409        // Search for the stored fact
1410        let payload = serde_json::json!({"query": "Ferris Rust mascot", "top_k": 5});
1411        let request = Request::builder()
1412            .method(http::Method::POST)
1413            .uri("/v1/memory/search")
1414            .header("content-type", "application/json")
1415            .header("authorization", "Bearer demokey123")
1416            .body(Body::from(serde_json::to_string(&payload).unwrap()))
1417            .unwrap();
1418
1419        let response = router.oneshot(request).await.unwrap();
1420        assert_eq!(response.status(), StatusCode::OK);
1421
1422        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
1423            .await
1424            .unwrap();
1425        let results: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1426        // Endpoint must return a JSON array. Result count depends on embedding quality —
1427        // with no real embeddings available in unit tests, HNSW may return 0 matches.
1428        assert!(results.is_array(), "Expected array of search results");
1429    }
1430
1431    /// Integration test: cached signal can be retrieved by GET /v1/signals/:id.
1432    #[tokio::test]
1433    async fn test_get_cached_signal_with_auth() {
1434        use axum::body::Body;
1435        use axum::http::{self, Request};
1436        use tower::util::ServiceExt;
1437
1438        let temp = tempfile::tempdir().unwrap();
1439        let mut config = brain_core::BrainConfig::default();
1440        config.brain.data_dir = temp.path().to_str().unwrap().to_string();
1441        let api_keys = config.access.api_keys.clone();
1442        let processor = Arc::new(signal::SignalProcessor::new(config).await.unwrap());
1443        let state = Arc::new(AppState {
1444            processor,
1445            cache: Mutex::new(HashMap::new()),
1446            api_keys,
1447            metrics: Arc::new(Metrics::default()),
1448        });
1449
1450        // Manually insert a response into the cache
1451        let id = Uuid::new_v4();
1452        let fake_resp = SignalResponse::ok(id, "test response");
1453        state.cache.lock().await.insert(id, fake_resp);
1454
1455        let router = Router::new()
1456            .route("/v1/signals/:id", get(get_signal_handler))
1457            .with_state(state);
1458
1459        let request = Request::builder()
1460            .method(http::Method::GET)
1461            .uri(format!("/v1/signals/{id}"))
1462            .header("authorization", "Bearer demokey123")
1463            .body(Body::empty())
1464            .unwrap();
1465
1466        let response = router.oneshot(request).await.unwrap();
1467        assert_eq!(response.status(), StatusCode::OK);
1468    }
1469}