Skip to main content

brainos_httpadapter/
handlers.rs

1//! HTTP route handlers for the Brain REST API.
2
3use std::{collections::HashMap, sync::atomic::Ordering, sync::Arc, time::Instant};
4
5use axum::{
6    body::Bytes,
7    extract::{Path, Query, State},
8    http::{HeaderMap, Response, StatusCode},
9    response::{
10        sse::{Event, KeepAlive, Sse},
11        IntoResponse, Json,
12    },
13};
14use uuid::Uuid;
15
16use crate::auth;
17use crate::state::AppState;
18use crate::types::*;
19use crate::validate;
20
21// ─── Embedded assets ─────────────────────────────────────────────────────────
22
23const UI_HTML: &str = include_str!("../assets/ui.html");
24static OPENAPI_JSON: &str = include_str!("../assets/openapi.json");
25const SWAGGER_UI_HTML: &str = include_str!("../assets/swagger.html");
26
27// ─── Public endpoints (no auth) ──────────────────────────────────────────────
28
29/// GET /health
30pub async fn health_handler() -> Json<HealthResponse> {
31    Json(HealthResponse {
32        status: "ok",
33        version: env!("CARGO_PKG_VERSION"),
34    })
35}
36
37/// GET /metrics — Prometheus text format
38pub async fn metrics_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
39    state.metrics.refresh_gauges(&state.processor);
40    let body = state.metrics.render(state.processor.metrics());
41    (
42        [("content-type", "text/plain; version=0.0.4; charset=utf-8")],
43        body,
44    )
45}
46
47/// GET /ui — embedded single-page memory explorer
48pub async fn ui_handler() -> impl IntoResponse {
49    ([("content-type", "text/html; charset=utf-8")], UI_HTML)
50}
51
52/// GET / — soft redirect to the diagnostic UI so the bare host:port lands somewhere.
53pub async fn root_handler() -> axum::response::Redirect {
54    axum::response::Redirect::temporary("/ui")
55}
56
57/// GET /openapi.json — OpenAPI 3.0 specification
58pub async fn openapi_handler() -> impl IntoResponse {
59    let spec = OPENAPI_JSON.replace("{{VERSION}}", env!("CARGO_PKG_VERSION"));
60    ([("content-type", "application/json")], spec)
61}
62
63/// GET /api — Swagger UI for interactive API exploration
64pub async fn swagger_ui_handler() -> impl IntoResponse {
65    (
66        [("content-type", "text/html; charset=utf-8")],
67        SWAGGER_UI_HTML,
68    )
69}
70
71// ─── Protected /v1/* endpoints ───────────────────────────────────────────────
72
73/// POST /v1/signals — requires write permission
74pub async fn post_signal_handler(
75    State(state): State<Arc<AppState>>,
76    headers: HeaderMap,
77    Json(body): Json<SignalRequest>,
78) -> Result<Json<signal::SignalResponse>, (StatusCode, String)> {
79    auth::check_auth(&state, &headers, "write")?;
80
81    validate::check_content(&body.content)?;
82    if let Some(ns) = body.namespace.as_deref() {
83        validate::check_short_ident("namespace", ns)?;
84    }
85    if let Some(agent) = body.agent.as_deref() {
86        validate::check_short_ident("agent", agent)?;
87    }
88    if let Some(sid) = body.session_id.as_deref() {
89        validate::check_short_ident("session_id", sid)?;
90    }
91    if let Some(sender) = body.sender.as_deref() {
92        validate::check_short_ident("sender", sender)?;
93    }
94    if let Some(channel) = body.channel.as_deref() {
95        validate::check_short_ident("channel", channel)?;
96    }
97    if let Some(source) = body.source.as_deref() {
98        validate::check_short_ident("source", source)?;
99    }
100
101    let t0 = Instant::now();
102    state.metrics.signals_total.fetch_add(1, Ordering::Relaxed);
103
104    // Resolve Principal from the API-key → agent_id mapping before
105    // constructing the Signal. None when the key has no agent_id configured
106    // (back-compat) or no IdentityStore is wired.
107    let principal = auth::resolve_principal(&state, &headers).await;
108
109    let source = signal::SignalSource::parse(body.source.as_deref(), signal::SignalSource::Http);
110    let sig = signal::Signal::from_adapter_request(signal::AdapterRequest {
111        source,
112        content: body.content,
113        channel: body.channel,
114        sender: body.sender,
115        metadata: body.metadata,
116        namespace: body.namespace,
117        agent: body.agent,
118        session_id: body.session_id,
119        default_channel: "http".to_string(),
120        default_sender: "apiclient".to_string(),
121    })
122    .with_principal_opt(principal);
123
124    let signal_id = sig.id;
125    let result = state.processor.process(sig).await;
126
127    let elapsed_ms = t0.elapsed().as_millis() as u64;
128    state
129        .metrics
130        .signals_latency_ms_total
131        .fetch_add(elapsed_ms, Ordering::Relaxed);
132
133    let response = match result {
134        Ok(r) => {
135            state.metrics.signals_ok.fetch_add(1, Ordering::Relaxed);
136            tracing::info!(
137                signal_id = %signal_id,
138                latency_ms = elapsed_ms,
139                "signal processed"
140            );
141            r
142        }
143        Err(e) => {
144            state.metrics.signals_error.fetch_add(1, Ordering::Relaxed);
145            tracing::error!(
146                signal_id = %signal_id,
147                latency_ms = elapsed_ms,
148                error = %e,
149                "signal processing failed"
150            );
151            let public = e.to_public();
152            return Err((
153                StatusCode::INTERNAL_SERVER_ERROR,
154                serde_json::json!({
155                    "error": public.code,
156                    "message": public.message,
157                })
158                .to_string(),
159            ));
160        }
161    };
162
163    state.cache.lock().await.put(signal_id, response.clone());
164    Ok(Json(response))
165}
166
167/// GET /v1/signals/:id — requires read permission
168pub async fn get_signal_handler(
169    State(state): State<Arc<AppState>>,
170    headers: HeaderMap,
171    Path(id): Path<String>,
172) -> Result<Json<signal::SignalResponse>, (StatusCode, String)> {
173    auth::check_auth(&state, &headers, "read")?;
174
175    let uuid = Uuid::parse_str(&id)
176        .map_err(|_| (StatusCode::BAD_REQUEST, format!("Invalid UUID: {id}")))?;
177
178    let mut cache = state.cache.lock().await;
179    match cache.get(&uuid) {
180        Some(resp) => Ok(Json(resp.clone())),
181        None => Err((
182            StatusCode::NOT_FOUND,
183            format!("Signal {uuid} not found in cache"),
184        )),
185    }
186}
187
188/// POST /v1/memory/search — requires read permission
189pub async fn search_memory_handler(
190    State(state): State<Arc<AppState>>,
191    headers: HeaderMap,
192    Json(body): Json<SearchRequest>,
193) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
194    auth::check_auth(&state, &headers, "read")?;
195
196    validate::check_query(&body.query)?;
197    validate::check_top_k(body.top_k)?;
198    if let Some(ns) = body.namespace.as_deref() {
199        validate::check_short_ident("namespace", ns)?;
200    }
201
202    state.metrics.search_total.fetch_add(1, Ordering::Relaxed);
203    let t0 = Instant::now();
204    let top_k = body.top_k.unwrap_or(10);
205    let namespace = body.namespace.as_deref();
206    let results = state
207        .processor
208        .search_facts(&body.query, top_k, namespace)
209        .await;
210    tracing::debug!(latency_ms = t0.elapsed().as_millis() as u64, query = %body.query, "memory search");
211
212    let facts = results
213        .into_iter()
214        .map(|r| FactJson {
215            id: r.fact.id,
216            namespace: r.fact.namespace,
217            category: r.fact.category,
218            subject: r.fact.subject,
219            predicate: r.fact.predicate,
220            object: r.fact.object,
221            confidence: r.fact.confidence,
222            distance: Some(r.distance),
223        })
224        .collect();
225
226    Ok(Json(facts))
227}
228
229/// GET /v1/memory/facts — requires read permission
230pub async fn get_facts_handler(
231    State(state): State<Arc<AppState>>,
232    headers: HeaderMap,
233    Query(params): Query<HashMap<String, String>>,
234) -> Result<Json<Vec<FactJson>>, (StatusCode, String)> {
235    auth::check_auth(&state, &headers, "read")?;
236
237    state.metrics.facts_total.fetch_add(1, Ordering::Relaxed);
238    let namespace = params.get("namespace").map(|s| s.as_str());
239    // Default page size protects an unscoped GET from streaming the
240    // whole fact table on a multi-thousand-row store. Callers can pass
241    // `?limit=N&offset=M` (limit capped at 1000) to walk pages, or
242    // `?limit=0` to opt back into the unbounded list.
243    const DEFAULT_LIMIT: usize = 100;
244    const MAX_LIMIT: usize = 1000;
245    let limit_param = params.get("limit").and_then(|s| s.parse::<usize>().ok());
246    let offset = params
247        .get("offset")
248        .and_then(|s| s.parse::<usize>().ok())
249        .unwrap_or(0);
250    let limit = match limit_param {
251        Some(0) => None,
252        Some(n) => Some(n.min(MAX_LIMIT)),
253        None => Some(DEFAULT_LIMIT),
254    };
255    let facts = state
256        .processor
257        .list_facts_paginated(namespace, limit, offset)
258        .into_iter()
259        .map(|f| FactJson {
260            id: f.id,
261            namespace: f.namespace,
262            category: f.category,
263            subject: f.subject,
264            predicate: f.predicate,
265            object: f.object,
266            confidence: f.confidence,
267            distance: None,
268        })
269        .collect();
270
271    Ok(Json(facts))
272}
273
274/// GET /v1/memory/namespaces — requires read permission
275pub async fn get_namespaces_handler(
276    State(state): State<Arc<AppState>>,
277    headers: HeaderMap,
278) -> Result<Json<Vec<NamespaceJson>>, (StatusCode, String)> {
279    auth::check_auth(&state, &headers, "read")?;
280
281    let namespaces = state
282        .processor
283        .list_namespaces()
284        .into_iter()
285        .map(|n| NamespaceJson {
286            namespace: n.namespace,
287            fact_count: n.fact_count,
288            episode_count: n.episode_count,
289        })
290        .collect();
291
292    Ok(Json(namespaces))
293}
294
295/// GET /v1/memory/export — requires `export` permission (Issue 123).
296///
297/// Bulk export is gated behind its own scope so a daily `read` key can't
298/// be used to exfil the entire memory store. Mint an API key with
299/// `permissions: ["export"]` (or `["admin"]`) to call this endpoint.
300pub async fn export_memory_handler(
301    State(state): State<Arc<AppState>>,
302    headers: HeaderMap,
303) -> Result<Json<ExportJson>, (StatusCode, String)> {
304    auth::check_auth(&state, &headers, "export")?;
305
306    let facts = state.processor.export_facts().map_err(|e| {
307        (
308            StatusCode::INTERNAL_SERVER_ERROR,
309            format!("Export failed: {e}"),
310        )
311    })?;
312    let episodes = state.processor.export_episodes().map_err(|e| {
313        (
314            StatusCode::INTERNAL_SERVER_ERROR,
315            format!("Export failed: {e}"),
316        )
317    })?;
318
319    Ok(Json(ExportJson {
320        version: env!("CARGO_PKG_VERSION").to_string(),
321        exported_at: chrono::Utc::now().to_rfc3339(),
322        facts,
323        episodes,
324    }))
325}
326
327/// POST /v1/memory/import — requires write permission
328pub async fn import_memory_handler(
329    State(state): State<Arc<AppState>>,
330    headers: HeaderMap,
331    Json(body): Json<ImportRequest>,
332) -> Result<Json<ImportResponse>, (StatusCode, String)> {
333    auth::check_auth(&state, &headers, "write")?;
334
335    if body.dry_run {
336        return Ok(Json(ImportResponse {
337            facts_imported: body.facts.len(),
338            episodes_imported: body.episodes.len(),
339            facts_already_existed: 0,
340            episodes_already_existed: 0,
341            embedded: 0,
342            embed_failed: 0,
343        }));
344    }
345
346    let (facts_imported, new_fact_indices) =
347        state.processor.import_facts(&body.facts).map_err(|e| {
348            (
349                StatusCode::INTERNAL_SERVER_ERROR,
350                format!("Import failed: {e}"),
351            )
352        })?;
353    let episodes_imported = state
354        .processor
355        .import_episodes(&body.episodes)
356        .map_err(|e| {
357            (
358                StatusCode::INTERNAL_SERVER_ERROR,
359                format!("Import failed: {e}"),
360            )
361        })?;
362
363    let mut embedded = 0;
364    let mut embed_failed = 0;
365    if !new_fact_indices.is_empty() {
366        let new_facts: Vec<signal::ExportedFact> = new_fact_indices
367            .iter()
368            .map(|&idx| body.facts[idx].clone())
369            .collect();
370        let (e, f) = state.processor.reembed_facts(&new_facts).await;
371        embedded = e;
372        embed_failed = f;
373    }
374
375    Ok(Json(ImportResponse {
376        facts_imported,
377        episodes_imported,
378        facts_already_existed: body.facts.len() - facts_imported,
379        episodes_already_existed: body.episodes.len() - episodes_imported,
380        embedded,
381        embed_failed,
382    }))
383}
384
385/// GET /v1/schedules — list scheduled intents (requires read)
386pub async fn list_schedules_handler(
387    State(state): State<Arc<AppState>>,
388    headers: HeaderMap,
389    Query(params): Query<HashMap<String, String>>,
390) -> Result<Json<Vec<serde_json::Value>>, (StatusCode, String)> {
391    auth::check_auth(&state, &headers, "read")?;
392
393    let namespace = params.get("namespace").map(|s| s.as_str());
394    let intents = state
395        .processor
396        .list_scheduled_intents(namespace)
397        .map_err(|e| {
398            (
399                StatusCode::INTERNAL_SERVER_ERROR,
400                format!("Failed to list schedules: {e}"),
401            )
402        })?;
403
404    let json: Vec<serde_json::Value> = intents
405        .iter()
406        .map(|i| {
407            serde_json::json!({
408                "id": i.id,
409                "description": i.description,
410                "cron": i.cron,
411                "namespace": i.namespace,
412                "created_at": i.created_at,
413                "status": i.status,
414            })
415        })
416        .collect();
417
418    Ok(Json(json))
419}
420
421/// DELETE /v1/schedules/:id — cancel a scheduled intent (requires write)
422pub async fn cancel_schedule_handler(
423    State(state): State<Arc<AppState>>,
424    headers: HeaderMap,
425    Path(id): Path<String>,
426) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
427    auth::check_auth(&state, &headers, "write")?;
428
429    let cancelled = state.processor.cancel_scheduled_intent(&id).map_err(|e| {
430        (
431            StatusCode::INTERNAL_SERVER_ERROR,
432            format!("Failed to cancel schedule: {e}"),
433        )
434    })?;
435
436    if cancelled {
437        Ok(Json(serde_json::json!({"cancelled": true, "id": id})))
438    } else {
439        Err((
440            StatusCode::NOT_FOUND,
441            format!("No scheduled intent found with ID: {id}"),
442        ))
443    }
444}
445
446/// Filter parameters for `GET /v1/events`. All fields optional.
447#[derive(Debug, Default, serde::Deserialize)]
448pub struct EventQuery {
449    /// BrainEvent variant discriminant, e.g. `signal_received`, `tool_call_started`.
450    pub kind: Option<String>,
451    /// Filter to a specific tool_id (only applies to tool-bound BrainEvents).
452    pub tool_id: Option<String>,
453    /// Correlation-id filter. Every event in one signal flow shares the
454    /// originating signal's id via [`observe::BrainEvent::id`], so
455    /// `?correlation=<uuid>` reconstructs a single turn end-to-end. Matched as
456    /// a string so a malformed value simply matches nothing rather than 400.
457    pub correlation: Option<String>,
458    /// Principal filter — accepted for forward compatibility; current events
459    /// do not yet carry a principal on the bus, so this filter matches
460    /// nothing when set.
461    pub principal: Option<String>,
462    /// RFC3339 timestamp; only events with `ts >= since` are forwarded.
463    pub since: Option<chrono::DateTime<chrono::Utc>>,
464}
465
466impl EventQuery {
467    /// Returns `true` if the event should be forwarded to the client.
468    pub fn matches(&self, ev: &observe::BrainEvent) -> bool {
469        if let Some(k) = &self.kind {
470            if ev.kind() != k.as_str() {
471                return false;
472            }
473        }
474        if let Some(t) = &self.tool_id {
475            if ev.tool_id() != Some(t.as_str()) {
476                return false;
477            }
478        }
479        if let Some(c) = &self.correlation {
480            if ev.id().to_string() != *c {
481                return false;
482            }
483        }
484        if let Some(since) = self.since {
485            let ts = brain_event_ts(ev);
486            if ts < since {
487                return false;
488            }
489        }
490        // Principal filter: bus events don't carry a principal yet.
491        if self.principal.is_some() {
492            return false;
493        }
494        true
495    }
496}
497
498fn brain_event_ts(ev: &observe::BrainEvent) -> chrono::DateTime<chrono::Utc> {
499    use observe::BrainEvent::*;
500    match ev {
501        SignalReceived { ts, .. }
502        | IntentClassified { ts, .. }
503        | ToolRouteResolved { ts, .. }
504        | ConfirmationRequested { ts, .. }
505        | ConfirmationResolved { ts, .. }
506        | ToolCallStarted { ts, .. }
507        | ToolCallFinished { ts, .. }
508        | ReflexFired { ts, .. }
509        | AuditAppended { ts, .. }
510        | BudgetCrossed { ts, .. }
511        | ResourcePressure { ts, .. }
512        | BreakerStateChange { ts, .. }
513        | Error { ts, .. }
514        | TerminalSessionOpened { ts, .. }
515        | TerminalSessionClosed { ts, .. }
516        | TaskStateChange { ts, .. }
517        | ServiceHealthChanged { ts, .. } => *ts,
518    }
519}
520
521/// `GET /v1/events` — Server-Sent Events stream.
522///
523/// Surfaces three classes of events on a single connection:
524/// - `brain_event` — structured `BrainEvent`s from the v1.0.0 Observer bus
525///   (filterable via `?kind=`, `?tool_id=`, `?principal=`, `?since=`).
526/// - `signal` — legacy `SignalProcessedEvent` (kept for existing consumers).
527/// - `notification` — proactive notifications.
528pub async fn sse_events_handler(
529    State(state): State<Arc<AppState>>,
530    Query(filter): Query<EventQuery>,
531    headers: HeaderMap,
532) -> Result<
533    Sse<impl futures_core::Stream<Item = Result<Event, std::convert::Infallible>>>,
534    (StatusCode, String),
535> {
536    auth::check_auth(&state, &headers, "read")?;
537
538    let mut signal_rx = state.processor.subscribe_events();
539    let mut notif_rx = state.processor.notification_router().map(|r| r.subscribe());
540    let mut brain_rx = state.processor.subscribe_brain_events();
541    // Issue 131: when on, content previews are scrubbed before serialization
542    // so a read-scoped observer only sees the event shape.
543    let redact = state.processor.config().adapters.http.sse_redact_previews;
544
545    let stream = async_stream::stream! {
546        loop {
547            tokio::select! {
548                result = async {
549                    match brain_rx.as_mut() {
550                        Some(rx) => rx.recv().await,
551                        None => std::future::pending().await,
552                    }
553                } => {
554                    match result {
555                        Ok(ev) => {
556                            if !filter.matches(&ev) { continue; }
557                            yield Ok(Event::default()
558                                .event("brain_event")
559                                .json_data(&ev)
560                                .unwrap_or_else(|_| Event::default().data("{}")));
561                        }
562                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
563                            tracing::warn!(skipped = n, "SSE brain_event stream lagged");
564                            yield Ok(Event::default()
565                                .event("error")
566                                .data(format!("{{\"lagged\":{n}}}")));
567                        }
568                        Err(tokio::sync::broadcast::error::RecvError::Closed) => {
569                            brain_rx = None;
570                        }
571                    }
572                }
573                result = signal_rx.recv() => {
574                    match result {
575                        Ok(event) => {
576                            let response_field = if redact {
577                                serde_json::json!({ "redacted": true, "len": event.response.len() })
578                            } else {
579                                serde_json::Value::String(event.response.clone())
580                            };
581                            let payload = serde_json::json!({
582                                "type": "signal",
583                                "signal_id": event.signal_id.to_string(),
584                                "source": format!("{:?}", event.source),
585                                "status": format!("{:?}", event.status),
586                                "response": response_field,
587                                "facts_used": event.facts_used,
588                                "episodes_used": event.episodes_used,
589                            });
590                            yield Ok(Event::default()
591                                .event("signal")
592                                .json_data(payload)
593                                .unwrap_or_else(|_| Event::default().data("{}")));
594                        }
595                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
596                            tracing::warn!(skipped = n, "SSE signal stream lagged");
597                            yield Ok(Event::default()
598                                .event("error")
599                                .data(format!("{{\"lagged\":{n}}}")));
600                        }
601                        Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
602                    }
603                }
604                result = async {
605                    match notif_rx.as_mut() {
606                        Some(rx) => rx.recv().await,
607                        None => std::future::pending().await,
608                    }
609                } => {
610                    match result {
611                        Ok(notification) => {
612                            let content_field = if redact {
613                                serde_json::json!({ "redacted": true, "len": notification.content.len() })
614                            } else {
615                                serde_json::Value::String(notification.content.clone())
616                            };
617                            let payload = serde_json::json!({
618                                "type": "proactive",
619                                "content": content_field,
620                                "triggered_by": notification.triggered_by,
621                                "priority": notification.priority,
622                                "agent": notification.agent,
623                            });
624                            yield Ok(Event::default()
625                                .event("notification")
626                                .json_data(payload)
627                                .unwrap_or_else(|_| Event::default().data("{}")));
628                        }
629                        Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
630                            tracing::warn!(skipped = n, "SSE notification stream lagged");
631                            yield Ok(Event::default()
632                                .event("error")
633                                .data(format!("{{\"lagged\":{n}}}")));
634                        }
635                        Err(tokio::sync::broadcast::error::RecvError::Closed) => {
636                            notif_rx = None;
637                        }
638                    }
639                }
640            }
641        }
642    };
643
644    Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
645}
646
647// ─── Webhook handlers ────────────────────────────────────────────────────────
648
649/// POST /v1/webhooks/:id — ingest inbound messages from external platforms.
650///
651/// This endpoint finds the registered `WebhookInboundTransport` for the given
652/// ID and delegates signature verification and message extraction to it.
653///
654/// Per Issue 52: when the transport has no signature verifier configured
655/// (`PreparedVerifier::None`), we additionally require a Brain
656/// `Authorization: Bearer <api-key>` with `write` permission so the
657/// endpoint can't be abused as an open inbound message queue. Verified
658/// transports (HMAC, Ed25519) keep their existing flow — the verifier
659/// itself is the auth gate.
660pub async fn post_webhook_handler(
661    State(state): State<Arc<AppState>>,
662    Path(id): Path<String>,
663    headers: HeaderMap,
664    body: Bytes,
665) -> impl IntoResponse {
666    let transport = match state.webhook_handlers.get(&id) {
667        Some(t) => t,
668        None => {
669            return build_webhook_response(
670                StatusCode::NOT_FOUND,
671                None,
672                format!("Unknown transport ID: {id}"),
673            )
674            .into_response();
675        }
676    };
677
678    if !transport.has_verifier() {
679        if let Err((status, msg)) = auth::check_auth(&state, &headers, "write") {
680            tracing::warn!(
681                transport = %id,
682                status = %status,
683                "webhook rejected — verifier-less transport requires Bearer auth"
684            );
685            return build_webhook_response(status, None, msg).into_response();
686        }
687    }
688
689    let resp = transport.handle_request(&headers, &body).await;
690    let status = StatusCode::from_u16(resp.status).unwrap_or(StatusCode::OK);
691    build_webhook_response(status, Some(resp.content_type), resp.body).into_response()
692}
693
694/// Build a webhook response, falling back to a 500 if the supplied
695/// status/headers/body are unrepresentable (e.g. invalid `Content-Type` value).
696fn build_webhook_response(
697    status: StatusCode,
698    content_type: Option<String>,
699    body: String,
700) -> Response<String> {
701    let mut builder = Response::builder().status(status);
702    if let Some(ct) = content_type {
703        builder = builder.header("content-type", ct);
704    }
705    builder.body(body).unwrap_or_else(|err| {
706        tracing::error!(error = %err, "failed to build webhook response");
707        let mut fallback = Response::new("Internal Server Error".to_string());
708        *fallback.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
709        fallback
710    })
711}
712
713#[cfg(test)]
714mod event_query_tests {
715    use super::EventQuery;
716    use chrono::Utc;
717    use uuid::Uuid;
718
719    fn signal_received(id: Uuid) -> observe::BrainEvent {
720        observe::BrainEvent::SignalReceived {
721            id,
722            signal: observe::SignalSummary {
723                source: "cli".into(),
724                channel: "c".into(),
725                sender: "s".into(),
726                namespace: "personal".into(),
727                content_preview: "hi".into(),
728            },
729            ts: Utc::now(),
730        }
731    }
732
733    #[test]
734    fn correlation_filter_matches_only_its_flow() {
735        let mine = Uuid::new_v4();
736        let other = Uuid::new_v4();
737        let filter = EventQuery {
738            correlation: Some(mine.to_string()),
739            ..Default::default()
740        };
741        assert!(filter.matches(&signal_received(mine)));
742        assert!(!filter.matches(&signal_received(other)));
743    }
744
745    #[test]
746    fn malformed_correlation_matches_nothing_rather_than_erroring() {
747        let filter = EventQuery {
748            correlation: Some("not-a-uuid".into()),
749            ..Default::default()
750        };
751        assert!(!filter.matches(&signal_received(Uuid::new_v4())));
752    }
753
754    #[test]
755    fn absent_correlation_does_not_constrain() {
756        let filter = EventQuery::default();
757        assert!(filter.matches(&signal_received(Uuid::new_v4())));
758    }
759}