Skip to main content

talon_http/
server.rs

1//! The axum application: shared state, middleware and request handlers.
2//!
3//! Middleware, outermost first: response counting, security headers,
4//! panic containment, CORS (answers preflights before authentication),
5//! API-key authentication (everything except `GET /health`), body limit.
6
7use std::collections::{BTreeMap, HashMap};
8use std::convert::Infallible;
9use std::fmt;
10use std::net::{IpAddr, Ipv4Addr, SocketAddr};
11use std::panic::AssertUnwindSafe;
12use std::sync::atomic::{AtomicI64, Ordering};
13use std::sync::{Arc, Mutex, MutexGuard};
14use std::time::{Duration, Instant};
15
16use axum::body::Bytes;
17use axum::extract::rejection::{BytesRejection, QueryRejection};
18use axum::extract::{ConnectInfo, DefaultBodyLimit, FromRequestParts, Path, Query, Request, State};
19use axum::http::request::Parts;
20use axum::http::{HeaderValue, Method, StatusCode, header};
21use axum::middleware::{self, Next};
22use axum::response::sse::{KeepAlive, Sse};
23use axum::response::{IntoResponse, Response};
24use axum::routing::{get, post};
25use axum::{Json, Router};
26use chrono::{DateTime, Utc};
27use futures::FutureExt;
28use serde_json::{Map, Value, json};
29use talon_core::audit::{SharedAuditSink, event};
30use talon_core::channel::ChannelHost;
31use talon_core::time::now_iso;
32use talon_core::types::{
33    AdditionalMount, ChatMetadata, ContainerConfig, NewMessage, RegisteredGroup, TaskStatus,
34};
35use tokio::sync::Mutex as AsyncMutex;
36
37use crate::auth::{AuthThrottle, KeyVerifier, SecurityKeyVerifier};
38use crate::config::HttpConfig;
39use crate::files;
40use crate::investigate::{
41    self, InvestigationRequest, STATUS_QUEUED, STATUS_RUNNING, investigation_prompt,
42    investigation_record, investigation_task, is_duplicate, job_id,
43};
44use crate::lanes::{LaneIds, OpenError, StreamRegistry, USER_SESSION_KEY_PREFIX, UserId};
45use crate::metrics::{self, Gauges, Metrics};
46use crate::palace::{
47    DisabledPalaceRunner, PalaceError, PalaceRunner, ScriptPalaceRunner, add_lesson_request,
48    forget_request, search_request,
49};
50use crate::sse::{WriterGuard, WriterStream};
51use crate::store::{HttpStore, run_blocking};
52use crate::validate::{
53    LESSON_TYPES, MAX_LESSON_CHARS, MAX_QUERY_CHARS, is_valid_customer_code, is_valid_drawer_id,
54    is_valid_investigation_id, is_valid_lesson_type, is_valid_template_name, is_valid_triggered_by,
55    parse_alert_id, sanitize_label, truncate_chars,
56};
57
58/// Mounts every CoPilot lane gets (`(directory under project root, read-only)`).
59/// `mempalace-data` stays read-write for the `mcp-palace` MCP server: the
60/// container entrypoint chowns it to `mcp-palace`, so the agent uid cannot
61/// reach it, and the host never runs palace code against it by default
62/// (MYTH-01, see [`crate::palace`]).
63const COPILOT_MOUNTS: &[(&str, bool)] = &[
64    ("siem", true),
65    ("mysql", true),
66    ("copilot-mcp", true),
67    ("wazuh-mcp", true),
68    ("velociraptor-mcp", true),
69    ("mempalace", true),
70    ("mempalace-data", false),
71    ("shuffle-mcp", true),
72    ("cve-mcp", true),
73];
74/// Max characters of a user display name kept in a lane name.
75const MAX_LABEL_CHARS: usize = 64;
76/// Default/maximum page size of `GET /investigations`.
77const DEFAULT_INVESTIGATIONS_LIMIT: usize = 50;
78const MAX_INVESTIGATIONS_LIMIT: usize = 500;
79/// Investigations sampled for the `/metrics` status gauge.
80const METRICS_INVESTIGATION_WINDOW: usize = 1_000;
81/// Max characters of a request path recorded in an audit event.
82const MAX_AUDIT_PATH_CHARS: usize = 256;
83
84/// Everything the HTTP channel talks to.
85pub struct HttpDeps {
86    /// Host orchestrator callbacks.
87    pub host: Arc<dyn ChannelHost>,
88    /// Persistence (normally a [`talon_db::Db`]).
89    pub store: Arc<dyn HttpStore>,
90    /// Audit sink for security-relevant decisions.
91    pub audit: SharedAuditSink,
92    /// API key verification backend.
93    pub verifier: Arc<dyn KeyVerifier>,
94    /// MemPalace bridge ([`DisabledPalaceRunner`] unless one is supplied).
95    pub palace: Arc<dyn PalaceRunner>,
96}
97
98impl HttpDeps {
99    /// Production wiring: the given store and [`SecurityKeyVerifier`].
100    ///
101    /// The palace is **disabled** (`/palace/*` answers 503) until a runner is
102    /// supplied with [`Self::with_palace`] — normally a
103    /// [`crate::CommandPalaceRunner`] that runs the palace in a hardened
104    /// container. Only if the operator set `TALON_PALACE_HOST_EXEC=unsafe`
105    /// ([`HttpConfig::palace_host_exec`]) does the default become a
106    /// [`ScriptPalaceRunner`] executing `palace-call.sh` on the host
107    /// (dangerous, MYTH-01).
108    pub fn new(
109        config: &HttpConfig,
110        host: Arc<dyn ChannelHost>,
111        store: Arc<dyn HttpStore>,
112        audit: SharedAuditSink,
113    ) -> Self {
114        Self {
115            host,
116            store,
117            audit,
118            verifier: Arc::new(SecurityKeyVerifier),
119            palace: default_palace_runner(config),
120        }
121    }
122
123    /// Use `palace` for the `/palace/*` endpoints (replaces the default,
124    /// including the `TALON_PALACE_HOST_EXEC=unsafe` script runner).
125    pub fn with_palace(mut self, palace: Arc<dyn PalaceRunner>) -> Self {
126        self.palace = palace;
127        self
128    }
129}
130
131/// [`DisabledPalaceRunner`], or the host script runner when the operator
132/// explicitly opted into host execution.
133fn default_palace_runner(config: &HttpConfig) -> Arc<dyn PalaceRunner> {
134    if !config.palace_host_exec {
135        return Arc::new(DisabledPalaceRunner);
136    }
137    tracing::warn!(
138        script = %config.palace_script.display(),
139        "TALON_PALACE_HOST_EXEC=unsafe: MemPalace calls run palace-call.sh ON THE HOST against \
140         agent-writable mempalace-data; a compromised agent can get code executed as this user. \
141         Use a containerized palace runner instead"
142    );
143    Arc::new(ScriptPalaceRunner::new(
144        config.palace_script.clone(),
145        config.palace_timeout,
146        config.palace_max_output_bytes,
147        config.palace_max_concurrency,
148    ))
149}
150
151impl fmt::Debug for HttpDeps {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        f.debug_struct("HttpDeps").finish_non_exhaustive()
154    }
155}
156
157/// A user lane could not be created because the lane cap was reached.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub struct LaneLimitReached;
160
161pub(crate) struct Shared {
162    pub config: HttpConfig,
163    pub lanes: LaneIds,
164    pub host: Arc<dyn ChannelHost>,
165    pub store: Arc<dyn HttpStore>,
166    pub audit: SharedAuditSink,
167    pub verifier: Arc<dyn KeyVerifier>,
168    pub palace: Arc<dyn PalaceRunner>,
169    pub streams: Arc<StreamRegistry>,
170    pub throttle: AuthThrottle,
171    pub metrics: Metrics,
172    pub started_at: Instant,
173    base_group: Mutex<Option<RegisteredGroup>>,
174    last_seen: Mutex<HashMap<String, Instant>>,
175    lane_lock: AsyncMutex<()>,
176    investigate_lock: AsyncMutex<()>,
177    last_job_millis: AtomicI64,
178}
179
180fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
181    m.lock().unwrap_or_else(|e| e.into_inner())
182}
183
184/// Cloneable handle to the channel's state. Build the server with
185/// [`router`]; the [`crate::HttpChannel`] shares the same state.
186#[derive(Clone)]
187pub struct HttpState(pub(crate) Arc<Shared>);
188
189impl fmt::Debug for HttpState {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        f.debug_struct("HttpState")
192            .field("config", &self.0.config)
193            .finish_non_exhaustive()
194    }
195}
196
197impl HttpState {
198    /// Assemble the state from configuration and dependencies.
199    pub fn new(config: HttpConfig, deps: HttpDeps) -> Self {
200        let throttle = AuthThrottle::new(config.auth_max_failures, config.auth_window);
201        Self(Arc::new(Shared {
202            lanes: LaneIds::new(&config.base_jid),
203            throttle,
204            config,
205            host: deps.host,
206            store: deps.store,
207            audit: deps.audit,
208            verifier: deps.verifier,
209            palace: deps.palace,
210            streams: Arc::new(StreamRegistry::default()),
211            metrics: Metrics::default(),
212            started_at: Instant::now(),
213            base_group: Mutex::new(None),
214            last_seen: Mutex::new(HashMap::new()),
215            lane_lock: AsyncMutex::new(()),
216            investigate_lock: AsyncMutex::new(()),
217            last_job_millis: AtomicI64::new(0),
218        }))
219    }
220
221    /// The channel configuration.
222    pub fn config(&self) -> &HttpConfig {
223        &self.0.config
224    }
225
226    /// The lane JIDs.
227    pub fn lanes(&self) -> &LaneIds {
228        &self.0.lanes
229    }
230
231    /// Register the chat and investigation lanes (persisted) and capture the
232    /// template per-user lanes are cloned from.
233    pub async fn register_base_lanes(&self) {
234        let s = &self.0;
235        let group = base_group(&s.config);
236        // trusted_session_commands is safe on the chat lanes because every
237        // request to this server is authenticated with the API key.
238        let chat = RegisteredGroup {
239            trusted_session_commands: true,
240            ..group.clone()
241        };
242        *lock(&s.base_group) = Some(chat.clone());
243        s.register_lane(&s.lanes.base, chat, true).await;
244        // Investigation lane: same folder and mounts, separate ephemeral
245        // session; nothing types slash commands at it. Its state directory
246        // (keyed by this session key) is where `GET /evals` reads from.
247        let key = s.config.investigation_session_key();
248        let investigate = RegisteredGroup {
249            name: "CoPilot Investigations".to_string(),
250            session_key: Some(key.to_string()),
251            ipc_key: Some(key.to_string()),
252            ephemeral_session: true,
253            ..group
254        };
255        s.register_lane(&s.lanes.investigate, investigate, true)
256            .await;
257    }
258
259    /// Set the template per-user lanes are cloned from (normally captured by
260    /// [`Self::register_base_lanes`]).
261    pub fn set_base_group(&self, group: RegisteredGroup) {
262        *lock(&self.0.base_group) = Some(group);
263    }
264
265    /// Resolve (registering on first use) the lane for a validated CoPilot
266    /// user id. `None` maps to the shared base lane.
267    pub async fn ensure_user_lane(
268        &self,
269        user_id: Option<&UserId>,
270        user_name: Option<&str>,
271    ) -> Result<String, LaneLimitReached> {
272        self.0.ensure_user_lane(user_id, user_name).await
273    }
274
275    /// Every registered per-user lane JID.
276    pub fn user_lane_jids(&self) -> Vec<String> {
277        self.0.user_lane_jids()
278    }
279
280    /// Drop per-user lanes idle for longer than `lane_idle`. Returns the
281    /// swept JIDs.
282    pub async fn sweep_idle_lanes(&self) -> Vec<String> {
283        match Instant::now().checked_sub(self.0.config.lane_idle) {
284            Some(cutoff) => self.0.sweep_idle_lanes_before(cutoff).await,
285            None => Vec::new(),
286        }
287    }
288
289    /// Drop per-user lanes whose last traffic is older than `cutoff`.
290    pub async fn sweep_idle_lanes_before(&self, cutoff: Instant) -> Vec<String> {
291        self.0.sweep_idle_lanes_before(cutoff).await
292    }
293
294    /// Seed the paused alert digest task if it does not exist yet.
295    pub async fn seed_digest_task(&self) -> talon_core::Result<bool> {
296        let folder = self.0.config.group_folder.clone();
297        let tz = self.0.config.timezone;
298        run_blocking(&self.0.store, move |st| {
299            investigate::seed_digest_task(st, &folder, tz, Utc::now())
300        })
301        .await
302    }
303
304    #[cfg(test)]
305    pub(crate) fn set_last_seen(&self, jid: &str, at: Instant) {
306        lock(&self.0.last_seen).insert(jid.to_string(), at);
307    }
308}
309
310/// The CoPilot group template the base lanes are registered with.
311fn base_group(config: &HttpConfig) -> RegisteredGroup {
312    let additional_mounts = COPILOT_MOUNTS
313        .iter()
314        .map(|(dir, readonly)| AdditionalMount {
315            host_path: config.project_root.join(dir).to_string_lossy().into_owned(),
316            container_path: Some((*dir).to_string()),
317            readonly: *readonly,
318        })
319        .collect();
320    RegisteredGroup {
321        name: "CoPilot".to_string(),
322        folder: config.group_folder.clone(),
323        trigger: String::new(),
324        added_at: now_iso(),
325        container_config: Some(ContainerConfig {
326            additional_mounts,
327            ..ContainerConfig::default()
328        }),
329        requires_trigger: Some(false),
330        is_main: false,
331        session_key: None,
332        ipc_key: None,
333        ephemeral_session: false,
334        trusted_session_commands: false,
335    }
336}
337
338impl Shared {
339    /// Register a lane: its group entry AND its chat row, always together
340    /// (a lane without a chat row cannot store inbound messages — the
341    /// `messages.chat_jid` foreign key rejects them). `is_group = false`
342    /// keeps lanes out of the main group's activatable-group list.
343    pub(crate) async fn register_lane(&self, jid: &str, group: RegisteredGroup, persist: bool) {
344        let name = group.name.clone();
345        self.host.register_group(jid, group, persist).await;
346        self.host
347            .on_chat_metadata(ChatMetadata {
348                chat_jid: jid.to_string(),
349                timestamp: now_iso(),
350                name: Some(name),
351                channel: Some("http".to_string()),
352                is_group: Some(false),
353            })
354            .await;
355    }
356
357    /// Session key -> exact user id of every registered per-user lane (the
358    /// key itself is one-way: lowercased, truncated and hashed).
359    fn user_ids_by_session_key(&self) -> HashMap<String, String> {
360        self.host
361            .registered_groups()
362            .into_iter()
363            .filter_map(|(jid, group)| {
364                let user = jid.strip_prefix(self.lanes.user_prefix.as_str())?;
365                if user.is_empty() {
366                    return None;
367                }
368                Some((group.session_key?, user.to_string()))
369            })
370            .collect()
371    }
372
373    pub(crate) fn user_lane_jids(&self) -> Vec<String> {
374        let mut jids: Vec<String> = self
375            .host
376            .registered_groups()
377            .into_keys()
378            .filter(|jid| self.lanes.is_user_lane(jid))
379            .collect();
380        jids.sort();
381        jids
382    }
383
384    async fn ensure_user_lane(
385        &self,
386        user_id: Option<&UserId>,
387        user_name: Option<&str>,
388    ) -> Result<String, LaneLimitReached> {
389        let Some(user_id) = user_id else {
390            return Ok(self.lanes.base.clone());
391        };
392        let jid = self.lanes.user_lane(user_id.as_str());
393        let _guard = self.lane_lock.lock().await;
394
395        if self.host.registered_groups().contains_key(&jid) {
396            lock(&self.last_seen).insert(jid.clone(), Instant::now());
397            return Ok(jid);
398        }
399        let Some(base) = lock(&self.base_group).clone() else {
400            // connect() hasn't run: nothing to clone from. Fall back rather
401            // than registering a half-built lane with no mounts.
402            tracing::warn!(%jid, "HTTP channel: no base group, using anon lane");
403            return Ok(self.lanes.base.clone());
404        };
405        if self.user_lane_jids().len() >= self.config.max_user_lanes {
406            tracing::warn!(
407                limit = self.config.max_user_lanes,
408                "HTTP channel: user lane limit reached"
409            );
410            return Err(LaneLimitReached);
411        }
412
413        let label = user_name
414            .map(|n| sanitize_label(n, MAX_LABEL_CHARS))
415            .filter(|n| !n.is_empty())
416            .unwrap_or_else(|| user_id.to_string());
417        // Collision-proof on case-insensitive filesystems (MYTH-12); the JID
418        // keeps the exact id.
419        let key = user_id.session_key();
420        let group = RegisteredGroup {
421            name: format!("CoPilot — {label}"),
422            session_key: Some(key.clone()),
423            ipc_key: Some(key),
424            trusted_session_commands: true,
425            ..base
426        };
427        lock(&self.last_seen).insert(jid.clone(), Instant::now());
428        self.register_lane(&jid, group, false).await;
429        tracing::info!(%jid, %user_id, "HTTP channel: registered user lane");
430        Ok(jid)
431    }
432
433    /// Drop lanes with no traffic since `cutoff`, releasing their stored
434    /// session and the transcript it pins. A lane with an open SSE stream is
435    /// never swept; the anon and investigation lanes are never touched.
436    async fn sweep_idle_lanes_before(&self, cutoff: Instant) -> Vec<String> {
437        let _guard = self.lane_lock.lock().await;
438        let mut swept = Vec::new();
439        for jid in self.user_lane_jids() {
440            let last_seen = lock(&self.last_seen).get(&jid).copied();
441            if last_seen.is_some_and(|t| t > cutoff) {
442                continue;
443            }
444            if self.streams.has_streams(&jid) {
445                continue;
446            }
447            self.host.clear_session(&jid).await;
448            self.host.unregister_group(&jid).await;
449            lock(&self.last_seen).remove(&jid);
450            self.streams.forget_lane(&jid);
451            tracing::info!(%jid, "HTTP channel: swept idle user lane");
452            swept.push(jid);
453        }
454        swept
455    }
456
457    fn next_job_millis(&self, now: DateTime<Utc>) -> i64 {
458        let now_ms = now.timestamp_millis();
459        let mut prev = self.last_job_millis.load(Ordering::SeqCst);
460        loop {
461            let next = now_ms.max(prev.saturating_add(1));
462            match self.last_job_millis.compare_exchange(
463                prev,
464                next,
465                Ordering::SeqCst,
466                Ordering::SeqCst,
467            ) {
468                Ok(_) => return next,
469                Err(actual) => prev = actual,
470            }
471        }
472    }
473
474    fn audit(&self, kind: &str, actor: &str, target: Option<&str>, outcome: &str, details: Value) {
475        self.audit
476            .record(event(kind, actor, target, outcome, details));
477    }
478}
479
480// ---------------------------------------------------------------------------
481// Errors and helpers
482// ---------------------------------------------------------------------------
483
484/// A JSON error response: `{"error": "..."}`.
485#[derive(Debug, Clone)]
486pub(crate) struct ApiError {
487    status: StatusCode,
488    message: String,
489}
490
491impl ApiError {
492    pub(crate) fn new(status: StatusCode, message: impl Into<String>) -> Self {
493        Self {
494            status,
495            message: message.into(),
496        }
497    }
498
499    fn bad_request(message: impl Into<String>) -> Self {
500        Self::new(StatusCode::BAD_REQUEST, message)
501    }
502
503    fn internal(message: impl Into<String>) -> Self {
504        Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
505    }
506}
507
508impl IntoResponse for ApiError {
509    fn into_response(self) -> Response {
510        (self.status, Json(json!({ "error": self.message }))).into_response()
511    }
512}
513
514type ApiResult = Result<Response, ApiError>;
515
516fn body_error(e: BytesRejection) -> ApiError {
517    let status = e.status();
518    if status == StatusCode::PAYLOAD_TOO_LARGE {
519        ApiError::new(status, "request body too large")
520    } else {
521        ApiError::new(status, "invalid request body")
522    }
523}
524
525fn query_error(_: QueryRejection) -> ApiError {
526    ApiError::bad_request("invalid query string")
527}
528
529/// Parse a JSON object body. `allow_empty` treats a blank body as `{}`.
530fn parse_object(body: &[u8], allow_empty: bool) -> Result<Map<String, Value>, ApiError> {
531    if allow_empty && body.iter().all(u8::is_ascii_whitespace) {
532        return Ok(Map::new());
533    }
534    match serde_json::from_slice::<Value>(body) {
535        Ok(Value::Object(m)) => Ok(m),
536        Ok(_) => Err(ApiError::bad_request("JSON body must be an object")),
537        Err(_) => Err(ApiError::bad_request("Invalid JSON")),
538    }
539}
540
541/// JavaScript truthiness of an optional JSON value.
542fn is_falsy(v: Option<&Value>) -> bool {
543    match v {
544        None | Some(Value::Null) | Some(Value::Bool(false)) => true,
545        Some(Value::String(s)) => s.is_empty(),
546        Some(Value::Number(n)) => n.as_f64() == Some(0.0),
547        _ => false,
548    }
549}
550
551/// Optional string field: missing/null/empty => `None`; non-string => error.
552fn optional_str<'a>(obj: &'a Map<String, Value>, key: &str) -> Result<Option<&'a str>, ()> {
553    match obj.get(key) {
554        None | Some(Value::Null) => Ok(None),
555        Some(Value::String(s)) if s.is_empty() => Ok(None),
556        Some(Value::String(s)) => Ok(Some(s.as_str())),
557        Some(_) => Err(()),
558    }
559}
560
561fn lesson_types_message(field: &str) -> String {
562    format!("{field} must be one of: {}", LESSON_TYPES.join(", "))
563}
564
565pub(crate) fn ip_from_extensions(ext: &axum::http::Extensions) -> IpAddr {
566    ext.get::<ConnectInfo<SocketAddr>>()
567        .map(|c| c.0.ip().to_canonical())
568        .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED))
569}
570
571/// The peer address of the connection (`0.0.0.0` when unknown, e.g. in
572/// tests driving the router directly).
573pub(crate) struct ClientIp(pub IpAddr);
574
575impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
576    type Rejection = Infallible;
577
578    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
579        Ok(ClientIp(ip_from_extensions(&parts.extensions)))
580    }
581}
582
583// ---------------------------------------------------------------------------
584// Router + middleware
585// ---------------------------------------------------------------------------
586
587/// Build the complete HTTP API around `state`.
588pub fn router(state: HttpState) -> Router {
589    let limit = state.0.config.max_body_bytes;
590    Router::new()
591        .route("/health", get(health))
592        .route("/status", get(status))
593        .route("/metrics", get(metrics_handler))
594        .route("/session/reset", post(session_reset))
595        .route("/investigate", post(investigate_handler))
596        .route("/investigations", get(list_investigations))
597        .route("/investigations/{id}", get(get_investigation))
598        .route("/evals/{alert_id}", get(evals))
599        .route("/templates", get(templates))
600        .route("/palace/lesson", post(palace_lesson))
601        .route("/palace/search", get(palace_search))
602        .route("/palace/forget", post(palace_forget))
603        .route("/message", post(message))
604        .fallback(not_found)
605        .layer(DefaultBodyLimit::max(limit))
606        .layer(middleware::from_fn_with_state(
607            state.clone(),
608            require_api_key,
609        ))
610        .layer(middleware::from_fn_with_state(state.clone(), cors))
611        .layer(middleware::from_fn(catch_panic))
612        .layer(middleware::from_fn(security_headers))
613        .layer(middleware::from_fn_with_state(
614            state.clone(),
615            count_responses,
616        ))
617        .with_state(state)
618}
619
620async fn count_responses(State(st): State<HttpState>, req: Request, next: Next) -> Response {
621    let res = next.run(req).await;
622    st.0.metrics.record_response(res.status().as_u16());
623    res
624}
625
626async fn security_headers(req: Request, next: Next) -> Response {
627    let mut res = next.run(req).await;
628    let h = res.headers_mut();
629    h.insert(
630        header::X_CONTENT_TYPE_OPTIONS,
631        HeaderValue::from_static("nosniff"),
632    );
633    h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
634    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
635    h.insert(
636        header::CONTENT_SECURITY_POLICY,
637        HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"),
638    );
639    h.insert(
640        header::REFERRER_POLICY,
641        HeaderValue::from_static("no-referrer"),
642    );
643    res
644}
645
646/// Turn a panic anywhere below into a 500 instead of a dropped connection.
647async fn catch_panic(req: Request, next: Next) -> Response {
648    match AssertUnwindSafe(next.run(req)).catch_unwind().await {
649        Ok(res) => res,
650        Err(_) => {
651            tracing::error!("HTTP channel: request handler panicked");
652            ApiError::internal("internal error").into_response()
653        }
654    }
655}
656
657fn allowed_origin(allowed: &[String], origin: &str) -> Option<HeaderValue> {
658    if allowed.iter().any(|o| o == "*") {
659        return Some(HeaderValue::from_static("*"));
660    }
661    allowed
662        .iter()
663        .any(|o| o.eq_ignore_ascii_case(origin))
664        .then(|| HeaderValue::from_str(origin).ok())
665        .flatten()
666}
667
668/// Allowlist CORS. Preflights are answered here, before authentication
669/// (browsers never attach credentials to them); other responses get
670/// `Access-Control-Allow-Origin` only for configured origins.
671async fn cors(State(st): State<HttpState>, req: Request, next: Next) -> Response {
672    let allow = req
673        .headers()
674        .get(header::ORIGIN)
675        .and_then(|v| v.to_str().ok())
676        .and_then(|o| allowed_origin(&st.0.config.cors_origins, o));
677    if req.method() == Method::OPTIONS {
678        let mut res = StatusCode::NO_CONTENT.into_response();
679        let h = res.headers_mut();
680        if let Some(origin) = allow {
681            h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
682            h.insert(
683                header::ACCESS_CONTROL_ALLOW_METHODS,
684                HeaderValue::from_static("GET, POST, OPTIONS"),
685            );
686            h.insert(
687                header::ACCESS_CONTROL_ALLOW_HEADERS,
688                HeaderValue::from_static("Authorization, Content-Type, X-Api-Key"),
689            );
690            h.insert(
691                header::ACCESS_CONTROL_MAX_AGE,
692                HeaderValue::from_static("600"),
693            );
694        }
695        h.insert(header::VARY, HeaderValue::from_static("Origin"));
696        return res;
697    }
698    let mut res = next.run(req).await;
699    if let Some(origin) = allow {
700        let h = res.headers_mut();
701        h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin);
702        h.append(header::VARY, HeaderValue::from_static("Origin"));
703    }
704    res
705}
706
707/// Fail-closed API key check for everything but `GET /health`, with per-IP
708/// throttling of repeated failures. Keys are never logged or audited.
709async fn require_api_key(State(st): State<HttpState>, req: Request, next: Next) -> Response {
710    if req.method() == Method::GET && req.uri().path() == "/health" {
711        return next.run(req).await;
712    }
713    let s = &st.0;
714    let ip = ip_from_extensions(req.extensions());
715
716    if let Some(retry_after) = s.throttle.blocked(ip) {
717        Metrics::inc(&s.metrics.auth_throttled);
718        let mut res = ApiError::new(
719            StatusCode::TOO_MANY_REQUESTS,
720            "too many failed authentication attempts",
721        )
722        .into_response();
723        res.headers_mut().insert(
724            header::RETRY_AFTER,
725            HeaderValue::from(retry_after.as_secs().max(1)),
726        );
727        return res;
728    }
729
730    let headers = req.headers();
731    let x_api_key = headers.get("x-api-key").and_then(|v| v.to_str().ok());
732    let authorization = headers
733        .get(header::AUTHORIZATION)
734        .and_then(|v| v.to_str().ok());
735    let provided = s.verifier.extract(x_api_key, authorization);
736    let configured = s.config.api_key.is_configured();
737    if configured
738        && s.verifier
739            .verify(provided.as_deref(), s.config.api_key.expose())
740    {
741        s.throttle.record_success(ip);
742        return next.run(req).await;
743    }
744
745    let reason = if !configured {
746        "api_key_unset"
747    } else if provided.is_none() {
748        "missing_credentials"
749    } else {
750        "invalid_credentials"
751    };
752    Metrics::inc(&s.metrics.auth_failures);
753    let record = s.throttle.record_failure(ip);
754    let path = truncate_chars(req.uri().path(), MAX_AUDIT_PATH_CHARS);
755    s.audit(
756        "http.auth.failed",
757        &ip.to_string(),
758        Some(path),
759        "denied",
760        json!({
761            "reason": reason,
762            "method": req.method().as_str(),
763            "failures": record.failures,
764            "throttled": record.now_throttled,
765        }),
766    );
767    if record.now_throttled {
768        tracing::warn!(%ip, failures = record.failures, "HTTP channel: throttling client after repeated auth failures");
769    }
770    ApiError::new(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
771}
772
773// ---------------------------------------------------------------------------
774// Handlers
775// ---------------------------------------------------------------------------
776
777async fn not_found() -> ApiError {
778    ApiError::new(StatusCode::NOT_FOUND, "Not found")
779}
780
781async fn health() -> Response {
782    Json(json!({ "status": "ok", "channel": "http" })).into_response()
783}
784
785async fn status(State(st): State<HttpState>) -> ApiResult {
786    let s = &st.0;
787    let folder = s.config.group_folder.clone();
788    let (tasks, rows, investigations) = run_blocking(&s.store, move |store| {
789        Ok((
790            store.get_tasks_for_group(&folder)?,
791            store.get_session_rows()?,
792            store.investigation_status_counts()?,
793        ))
794    })
795    .await
796    .map_err(|e| {
797        tracing::error!(err = %e, "GET /status failed");
798        ApiError::internal("failed to read status")
799    })?;
800
801    // Lane context sizes, so CoPilot can show a session growing before it
802    // starts costing minutes per reply. The investigation lane is ephemeral
803    // and so never has a stored session.
804    let folder = &s.config.group_folder;
805    let user_ids = s.user_ids_by_session_key();
806    let sessions: Vec<Value> = rows
807        .into_iter()
808        .filter(|r| {
809            r.group_folder.starts_with(folder.as_str())
810                || r.group_folder.starts_with(USER_SESSION_KEY_PREFIX)
811        })
812        .map(|r| {
813            // Only registered lanes can be mapped back to their exact id; a
814            // session left over from before a restart reports `null`.
815            let user_id = user_ids.get(&r.group_folder).cloned();
816            json!({
817                "lane": r.group_folder,
818                "user_id": user_id,
819                "session_id": r.session_id,
820                "input_tokens": r.input_tokens,
821                "updated_at": r.updated_at,
822            })
823        })
824        .collect();
825    // Finished `once` tasks (every investigation leaves one) are history,
826    // not schedule: listing them made the answer grow without bound.
827    let scheduled_tasks: Vec<Value> = tasks
828        .iter()
829        .filter(|t| t.status != TaskStatus::Completed)
830        .map(|t| {
831            json!({
832                "id": t.id,
833                "status": t.status.as_str(),
834                "schedule_type": t.schedule_type.as_str(),
835                "schedule_value": t.schedule_value,
836                "next_run": t.next_run,
837                "last_run": t.last_run,
838            })
839        })
840        .collect();
841    let counts = s.streams.counts();
842    let queue = s.host.queue_status();
843    Ok(Json(json!({
844        "status": "ok",
845        "uptime_seconds": s.started_at.elapsed().as_secs(),
846        // Investigations by their recorded state; chat requests waiting for
847        // an answer on any lane (not only the shared one).
848        "active_investigations": investigations.get(STATUS_RUNNING).copied().unwrap_or(0),
849        "queued_investigations": investigations.get(STATUS_QUEUED).copied().unwrap_or(0),
850        "pending_queue_depth": counts.pending,
851        "sessions": sessions,
852        "scheduled_tasks": scheduled_tasks,
853        "user_lanes": s.user_lane_jids().len(),
854        "queue": {
855            "active_containers": queue.active_containers,
856            "max_containers": queue.max_containers,
857            "waiting_groups": queue.waiting_groups,
858        },
859    }))
860    .into_response())
861}
862
863async fn metrics_handler(State(st): State<HttpState>) -> Response {
864    let s = &st.0;
865    let by_status = match run_blocking(&s.store, |store| {
866        store.list_investigations(METRICS_INVESTIGATION_WINDOW)
867    })
868    .await
869    {
870        Ok(list) => {
871            let mut map = BTreeMap::new();
872            for inv in list {
873                *map.entry(inv.status).or_insert(0u64) += 1;
874            }
875            Some(map)
876        }
877        Err(e) => {
878            tracing::debug!(err = %e, "metrics: investigation lookup failed");
879            None
880        }
881    };
882    let counts = s.streams.counts();
883    let gauges = Gauges {
884        uptime_seconds: s.started_at.elapsed().as_secs(),
885        queue: s.host.queue_status(),
886        sse_pending: counts.pending,
887        sse_active: counts.active,
888        lanes_in_turn: counts.lanes_in_turn,
889        user_lanes: s.user_lane_jids().len(),
890        investigations_by_status: by_status,
891        investigations_window: METRICS_INVESTIGATION_WINDOW,
892    };
893    (
894        [(
895            header::CONTENT_TYPE,
896            HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
897        )],
898        metrics::render(&s.metrics, &gauges),
899    )
900        .into_response()
901}
902
903async fn session_reset(
904    State(st): State<HttpState>,
905    ClientIp(ip): ClientIp,
906    body: Result<Bytes, BytesRejection>,
907) -> ApiResult {
908    let s = &st.0;
909    let body = body.map_err(body_error)?;
910    let obj = parse_object(&body, true)?;
911    let scope = match optional_str(&obj, "scope") {
912        Ok(v) => v.unwrap_or("chat"),
913        Err(()) => "",
914    };
915    let user_id =
916        UserId::from_json(obj.get("user_id")).map_err(|e| ApiError::bad_request(e.to_string()))?;
917
918    // 'chat' resets the caller's own lane: an analyst clearing their
919    // conversation must not clear a colleague's. Without a user_id it falls
920    // back to the shared anonymous lane.
921    let targets: Vec<String> = match scope {
922        "all" => {
923            let mut t = vec![s.lanes.base.clone(), s.lanes.investigate.clone()];
924            t.extend(s.user_lane_jids());
925            t
926        }
927        "investigate" => vec![s.lanes.investigate.clone()],
928        "chat" => vec![
929            user_id
930                .as_ref()
931                .map_or_else(|| s.lanes.base.clone(), |u| s.lanes.user_lane(u.as_str())),
932        ],
933        _ => {
934            return Err(ApiError::bad_request(
935                "scope must be one of 'chat', 'investigate', 'all'",
936            ));
937        }
938    };
939
940    let mut cleared = Vec::new();
941    for jid in targets {
942        if s.host.clear_session(&jid).await {
943            cleared.push(jid);
944        }
945    }
946    let user_id = user_id.as_ref().map(UserId::as_str);
947    tracing::info!(scope, user_id = ?user_id, cleared = ?cleared, "HTTP channel: session reset");
948    s.audit(
949        "http.session.reset",
950        &ip.to_string(),
951        None,
952        "info",
953        json!({ "scope": scope, "user_id": user_id, "lanes": cleared }),
954    );
955    Ok(Json(json!({ "cleared": cleared.len(), "lanes": cleared })).into_response())
956}
957
958/// Validate the `/investigate` body. Template existence is checked by the
959/// caller (filesystem access).
960fn parse_investigation_request(obj: &Map<String, Value>) -> Result<InvestigationRequest, ApiError> {
961    if is_falsy(obj.get("alert_id")) || is_falsy(obj.get("customer_code")) {
962        return Err(ApiError::bad_request("alert_id and customer_code required"));
963    }
964    let alert_id = obj
965        .get("alert_id")
966        .and_then(parse_alert_id)
967        .ok_or_else(|| ApiError::bad_request("alert_id must be a positive integer"))?;
968    let customer_code = obj
969        .get("customer_code")
970        .and_then(Value::as_str)
971        .filter(|c| is_valid_customer_code(c))
972        .ok_or_else(|| ApiError::bad_request("customer_code must match ^[A-Za-z0-9_-]{1,64}$"))?
973        .to_string();
974    let triggered_by = match optional_str(obj, "triggered_by") {
975        Ok(None) => "webhook".to_string(),
976        Ok(Some(t)) if is_valid_triggered_by(t) => t.to_string(),
977        _ => {
978            return Err(ApiError::bad_request(
979                "triggered_by must be one of: manual, scheduled, webhook",
980            ));
981        }
982    };
983    let template_override = match optional_str(obj, "template_override") {
984        Ok(None) => None,
985        Ok(Some(t)) if is_valid_template_name(t) => Some(t.to_string()),
986        _ => {
987            return Err(ApiError::bad_request(
988                "template_override must be a plain .txt filename (no path separators)",
989            ));
990        }
991    };
992    Ok(InvestigationRequest {
993        alert_id,
994        customer_code,
995        triggered_by,
996        template_override,
997    })
998}
999
1000async fn investigate_handler(
1001    State(st): State<HttpState>,
1002    ClientIp(ip): ClientIp,
1003    body: Result<Bytes, BytesRejection>,
1004) -> ApiResult {
1005    let s = &st.0;
1006    let actor = ip.to_string();
1007    let body = body.map_err(body_error)?;
1008    let obj = parse_object(&body, false)?;
1009    let req = match parse_investigation_request(&obj) {
1010        Ok(r) => r,
1011        Err(e) => {
1012            s.audit(
1013                "http.investigate",
1014                &actor,
1015                None,
1016                "denied",
1017                json!({ "reason": e.message }),
1018            );
1019            return Err(e);
1020        }
1021    };
1022
1023    // Validate the override against the on-disk prompts directory (regular
1024    // files only) before queuing anything.
1025    if let Some(template) = req.template_override.clone() {
1026        let dir = s.config.group_dir();
1027        let exists = tokio::task::spawn_blocking(move || files::template_exists(&dir, &template))
1028            .await
1029            .unwrap_or(false);
1030        if !exists {
1031            let name = req.template_override.as_deref().unwrap_or_default();
1032            return Err(ApiError::new(
1033                StatusCode::NOT_FOUND,
1034                format!("template_override not found: {name}"),
1035            ));
1036        }
1037    }
1038
1039    // Serialize dedup check + creation so two identical triggers racing each
1040    // other cannot both start a job.
1041    let guard = s.investigate_lock.lock().await;
1042    let now = Utc::now();
1043    let window = s.config.dedup_window;
1044    if !window.is_zero() {
1045        let alert = req.alert_id.to_string();
1046        match run_blocking(&s.store, move |st| {
1047            st.latest_investigation_for_alert(&alert)
1048        })
1049        .await
1050        {
1051            Ok(Some(existing)) if is_duplicate(&existing, &req, window, now) => {
1052                Metrics::inc(&s.metrics.investigations_deduplicated);
1053                s.audit(
1054                    "http.investigate",
1055                    &actor,
1056                    Some(&existing.id),
1057                    "info",
1058                    json!({
1059                        "alert_id": req.alert_id,
1060                        "customer_code": req.customer_code,
1061                        "deduplicated": true,
1062                    }),
1063                );
1064                tracing::info!(alert_id = req.alert_id, job_id = %existing.id, "HTTP channel: duplicate investigation trigger");
1065                return Ok((
1066                    StatusCode::OK,
1067                    Json(json!({
1068                        "job_id": existing.id,
1069                        "status": existing.status,
1070                        "template_override": existing.template,
1071                        "deduplicated": true,
1072                    })),
1073                )
1074                    .into_response());
1075            }
1076            Ok(_) => {}
1077            Err(e) => tracing::warn!(err = %e, "investigation dedup lookup failed"),
1078        }
1079    }
1080
1081    let id = job_id(req.alert_id, s.next_job_millis(now));
1082    let prompt = investigation_prompt(&req, &id);
1083    let task = investigation_task(
1084        &id,
1085        &s.config.group_folder,
1086        &s.lanes.investigate,
1087        prompt,
1088        s.config.timezone,
1089        now,
1090    )
1091    .map_err(|e| {
1092        tracing::error!(err = %e, "failed to build investigation task");
1093        ApiError::internal("failed to queue investigation")
1094    })?;
1095    let inv = investigation_record(&id, &req, now);
1096    run_blocking(&s.store, move |st| {
1097        investigate::persist_investigation(st, &task, &inv)
1098    })
1099    .await
1100    .map_err(|e| {
1101        tracing::error!(err = %e, "failed to persist investigation");
1102        ApiError::internal("failed to queue investigation")
1103    })?;
1104    drop(guard);
1105
1106    s.host.notify_tasks_changed();
1107    Metrics::inc(&s.metrics.investigations_created);
1108    s.audit(
1109        "http.investigate",
1110        &actor,
1111        Some(&id),
1112        "allowed",
1113        json!({
1114            "alert_id": req.alert_id,
1115            "customer_code": req.customer_code,
1116            "triggered_by": req.triggered_by,
1117            "template_override": req.template_override,
1118        }),
1119    );
1120    tracing::info!(
1121        alert_id = req.alert_id,
1122        customer_code = %req.customer_code,
1123        job_id = %id,
1124        triggered_by = %req.triggered_by,
1125        template_override = ?req.template_override,
1126        "HTTP channel: investigation queued"
1127    );
1128    Ok((
1129        StatusCode::ACCEPTED,
1130        Json(json!({
1131            "job_id": id,
1132            "status": investigate::STATUS_QUEUED,
1133            "template_override": req.template_override,
1134            "deduplicated": false,
1135        })),
1136    )
1137        .into_response())
1138}
1139
1140async fn list_investigations(
1141    State(st): State<HttpState>,
1142    query: Result<Query<HashMap<String, String>>, QueryRejection>,
1143) -> ApiResult {
1144    let Query(params) = query.map_err(query_error)?;
1145    let limit = match params.get("limit").map(|l| l.trim()) {
1146        None | Some("") => DEFAULT_INVESTIGATIONS_LIMIT,
1147        Some(l) => l
1148            .parse::<usize>()
1149            .map_err(|_| ApiError::bad_request("limit must be a positive integer"))?
1150            .clamp(1, MAX_INVESTIGATIONS_LIMIT),
1151    };
1152    let list = run_blocking(&st.0.store, move |s| s.list_investigations(limit))
1153        .await
1154        .map_err(|e| {
1155            tracing::error!(err = %e, "GET /investigations failed");
1156            ApiError::internal("failed to list investigations")
1157        })?;
1158    Ok(Json(json!({ "investigations": list })).into_response())
1159}
1160
1161async fn get_investigation(State(st): State<HttpState>, Path(id): Path<String>) -> ApiResult {
1162    if !is_valid_investigation_id(&id) {
1163        return Err(ApiError::bad_request("invalid investigation id"));
1164    }
1165    let found = run_blocking(&st.0.store, move |s| s.get_investigation(&id))
1166        .await
1167        .map_err(|e| {
1168            tracing::error!(err = %e, "GET /investigations/{{id}} failed");
1169            ApiError::internal("failed to read investigation")
1170        })?;
1171    match found {
1172        Some(inv) => Ok(Json(inv).into_response()),
1173        None => Err(ApiError::new(
1174            StatusCode::NOT_FOUND,
1175            "investigation not found",
1176        )),
1177    }
1178}
1179
1180/// `GET /evals/{alert_id}` — eval JSON written by the investigation agent
1181/// at the end of an investigation; replays are returned in descending job-id
1182/// order.
1183///
1184/// Records are read from the investigation lane's private state directory
1185/// (`<data_dir>/sessions/<investigation session key>/evals/`), which no other
1186/// lane can write; the shared `groups/<folder>/evals/` is consulted only when
1187/// the operator enabled `COPILOT_EVALS_LEGACY_GROUP_DIR` (N-2).
1188async fn evals(State(st): State<HttpState>, Path(alert_id): Path<String>) -> ApiResult {
1189    if alert_id.is_empty()
1190        || alert_id.len() > crate::validate::MAX_ALERT_ID_DIGITS
1191        || !alert_id.bytes().all(|b| b.is_ascii_digit())
1192    {
1193        return Err(ApiError::bad_request("invalid alert_id"));
1194    }
1195    let numeric: u64 = alert_id
1196        .parse()
1197        .map_err(|_| ApiError::bad_request("invalid alert_id"))?;
1198    let config = &st.0.config;
1199    let sources = files::EvalSources {
1200        sessions_dir: config.sessions_dir(),
1201        lane_key: config.investigation_session_key().to_string(),
1202        legacy_group_dir: config.evals_legacy_group_dir.then(|| config.group_dir()),
1203    };
1204    let evals =
1205        tokio::task::spawn_blocking(move || files::read_investigation_evals(&sources, &alert_id))
1206            .await
1207            .map_err(|_| ApiError::internal("failed to read evals"))?
1208            .map_err(|e| {
1209                tracing::error!(err = %e, "GET /evals failed");
1210                ApiError::internal("failed to read evals")
1211            })?;
1212    Ok(Json(json!({ "alert_id": numeric, "evals": evals })).into_response())
1213}
1214
1215/// `GET /templates` — template filenames + previews; bodies are never
1216/// exposed (the agent is the only consumer of the full content).
1217async fn templates(State(st): State<HttpState>) -> ApiResult {
1218    let dir = st.0.config.group_dir();
1219    let templates = tokio::task::spawn_blocking(move || files::list_templates(&dir))
1220        .await
1221        .map_err(|_| ApiError::internal("failed to list templates"))?
1222        .map_err(|e| {
1223            tracing::error!(err = %e, "GET /templates failed");
1224            ApiError::internal("failed to list templates")
1225        })?;
1226    Ok(Json(json!({ "templates": templates })).into_response())
1227}
1228
1229/// The failure a palace answer reports, if any. `palace_call.py` exits 0
1230/// and prints `{"error": ...}` (or a tool's `{"success": false, ...}`) when
1231/// the operation failed.
1232fn palace_reported_error(v: &Value) -> Option<String> {
1233    let obj = v.as_object()?;
1234    if let Some(e) = obj.get("error").filter(|e| !e.is_null()) {
1235        return Some(e.as_str().map_or_else(|| e.to_string(), str::to_string));
1236    }
1237    (obj.get("success") == Some(&Value::Bool(false))).then(|| "success: false".to_string())
1238}
1239
1240fn palace_response(s: &Shared, op: &str, result: Result<Value, PalaceError>) -> ApiResult {
1241    match result {
1242        Ok(v) => {
1243            if let Some(reason) = palace_reported_error(&v) {
1244                // A 200 would make CoPilot's drainer mark a lesson as
1245                // ingested that the palace never stored.
1246                Metrics::inc(&s.metrics.palace_failed);
1247                tracing::error!(op, error = %reason, "palace reported a failure");
1248                return Err(ApiError::new(
1249                    StatusCode::BAD_GATEWAY,
1250                    format!("palace {op} failed"),
1251                ));
1252            }
1253            Metrics::inc(&s.metrics.palace_ok);
1254            Ok(Json(v).into_response())
1255        }
1256        Err(PalaceError::Disabled) => {
1257            Metrics::inc(&s.metrics.palace_failed);
1258            tracing::debug!(op, "palace call refused: no palace runner configured");
1259            Err(ApiError::new(
1260                StatusCode::SERVICE_UNAVAILABLE,
1261                PalaceError::Disabled.to_string(),
1262            ))
1263        }
1264        Err(e) => {
1265            Metrics::inc(&s.metrics.palace_failed);
1266            tracing::error!(op, err = %e, "palace call failed");
1267            let status = match e {
1268                PalaceError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT,
1269                PalaceError::Busy => StatusCode::SERVICE_UNAVAILABLE,
1270                _ => StatusCode::INTERNAL_SERVER_ERROR,
1271            };
1272            Err(ApiError::new(status, e.to_string()))
1273        }
1274    }
1275}
1276
1277/// `POST /palace/lesson` — CoPilot's drainer ingests teach-the-palace
1278/// lessons (wraps `mempalace_add_drawer`).
1279async fn palace_lesson(
1280    State(st): State<HttpState>,
1281    ClientIp(ip): ClientIp,
1282    body: Result<Bytes, BytesRejection>,
1283) -> ApiResult {
1284    let s = &st.0;
1285    let body = body.map_err(body_error)?;
1286    let obj = parse_object(&body, false)?;
1287    if is_falsy(obj.get("customer_code"))
1288        || is_falsy(obj.get("lesson_type"))
1289        || is_falsy(obj.get("lesson_text"))
1290    {
1291        return Err(ApiError::bad_request(
1292            "customer_code, lesson_type, and lesson_text are required",
1293        ));
1294    }
1295    let lesson_type = obj
1296        .get("lesson_type")
1297        .and_then(Value::as_str)
1298        .filter(|t| is_valid_lesson_type(t))
1299        .ok_or_else(|| ApiError::bad_request(lesson_types_message("lesson_type")))?;
1300    let customer_code = obj
1301        .get("customer_code")
1302        .and_then(Value::as_str)
1303        .filter(|c| is_valid_customer_code(c))
1304        .ok_or_else(|| ApiError::bad_request("customer_code must match ^[A-Za-z0-9_-]{1,64}$"))?;
1305    let lesson_text = obj
1306        .get("lesson_text")
1307        .and_then(Value::as_str)
1308        .ok_or_else(|| ApiError::bad_request("lesson_text must be a string"))?;
1309    if lesson_text.chars().count() > MAX_LESSON_CHARS {
1310        return Err(ApiError::bad_request(format!(
1311            "lesson_text exceeds {MAX_LESSON_CHARS} characters"
1312        )));
1313    }
1314    let durability = if obj.get("durability").and_then(Value::as_str) == Some("one_off") {
1315        "one_off"
1316    } else {
1317        "durable"
1318    };
1319
1320    // Lessons are recalled into future investigation prompts: audit every
1321    // write (metadata only, never the text).
1322    s.audit(
1323        "http.palace.lesson",
1324        &ip.to_string(),
1325        Some(customer_code),
1326        "info",
1327        json!({
1328            "lesson_type": lesson_type,
1329            "durability": durability,
1330            "length": lesson_text.chars().count(),
1331        }),
1332    );
1333    let result = s
1334        .palace
1335        .call(add_lesson_request(
1336            customer_code,
1337            lesson_type,
1338            lesson_text,
1339            durability,
1340        ))
1341        .await;
1342    palace_response(s, "add_drawer", result)
1343}
1344
1345/// `GET /palace/search` — similar-lessons preview (wraps `mempalace_search`).
1346async fn palace_search(
1347    State(st): State<HttpState>,
1348    query: Result<Query<HashMap<String, String>>, QueryRejection>,
1349) -> ApiResult {
1350    let s = &st.0;
1351    let Query(params) = query.map_err(query_error)?;
1352    let customer_code = params
1353        .get("customer_code")
1354        .map(String::as_str)
1355        .unwrap_or("");
1356    let q = params.get("query").map(String::as_str).unwrap_or("");
1357    if customer_code.is_empty() || q.is_empty() {
1358        return Err(ApiError::bad_request(
1359            "customer_code and query are required",
1360        ));
1361    }
1362    if !is_valid_customer_code(customer_code) {
1363        return Err(ApiError::bad_request(
1364            "customer_code must match ^[A-Za-z0-9_-]{1,64}$",
1365        ));
1366    }
1367    if q.chars().count() > MAX_QUERY_CHARS {
1368        return Err(ApiError::bad_request(format!(
1369            "query exceeds {MAX_QUERY_CHARS} characters"
1370        )));
1371    }
1372    let room = params
1373        .get("room")
1374        .map(String::as_str)
1375        .filter(|r| !r.is_empty());
1376    if room.is_some_and(|r| !is_valid_lesson_type(r)) {
1377        return Err(ApiError::bad_request(lesson_types_message("room")));
1378    }
1379    let limit = match params
1380        .get("limit")
1381        .map(|l| l.trim())
1382        .filter(|l| !l.is_empty())
1383    {
1384        None => 5,
1385        Some(l) => l
1386            .parse::<i64>()
1387            .map_err(|_| ApiError::bad_request("limit must be an integer"))?
1388            .clamp(1, 25) as u32,
1389    };
1390    let result = s
1391        .palace
1392        .call(search_request(customer_code, room, q, limit))
1393        .await;
1394    palace_response(s, "search", result)
1395}
1396
1397/// `POST /palace/forget` — CoPilot's durability sweeper removes an expired
1398/// one-off lesson (wraps `mempalace_delete_drawer`). A missing drawer is
1399/// reported by MemPalace as `success: false`, not as an HTTP error.
1400async fn palace_forget(
1401    State(st): State<HttpState>,
1402    ClientIp(ip): ClientIp,
1403    body: Result<Bytes, BytesRejection>,
1404) -> ApiResult {
1405    let s = &st.0;
1406    let body = body.map_err(body_error)?;
1407    let obj = parse_object(&body, false)?;
1408    let drawer_id = match obj.get("drawer_id") {
1409        Some(Value::String(d)) if !d.is_empty() => d.as_str(),
1410        _ => return Err(ApiError::bad_request("drawer_id (string) is required")),
1411    };
1412    if !is_valid_drawer_id(drawer_id) {
1413        return Err(ApiError::bad_request(
1414            "drawer_id contains invalid characters",
1415        ));
1416    }
1417    s.audit(
1418        "http.palace.forget",
1419        &ip.to_string(),
1420        Some(drawer_id),
1421        "info",
1422        json!({}),
1423    );
1424    let result = s.palace.call(forget_request(drawer_id)).await;
1425    palace_response(s, "delete_drawer", result)
1426}
1427
1428/// `POST /message` — route an analyst prompt into the caller's lane and
1429/// stream the answer back as SSE (`data: {"type":"text"|"done"}`), with a
1430/// keepalive comment every `sse_keepalive`.
1431async fn message(State(st): State<HttpState>, body: Result<Bytes, BytesRejection>) -> ApiResult {
1432    let s = &st.0;
1433    let body = body.map_err(body_error)?;
1434    let obj = parse_object(&body, false)?;
1435    let text = match obj.get("message") {
1436        Some(Value::String(m)) => m.trim(),
1437        _ => "",
1438    };
1439    if text.is_empty() {
1440        return Err(ApiError::bad_request("message field required"));
1441    }
1442    let sender = obj
1443        .get("sender")
1444        .and_then(Value::as_str)
1445        .map(|v| sanitize_label(v, MAX_LABEL_CHARS))
1446        .filter(|v| !v.is_empty())
1447        .unwrap_or_else(|| "copilot".to_string());
1448    // Invalid ids are rejected, never stripped into a neighbour's lane.
1449    let user_id =
1450        UserId::from_json(obj.get("user_id")).map_err(|e| ApiError::bad_request(e.to_string()))?;
1451    let user_name = obj.get("user_name").and_then(Value::as_str);
1452
1453    // Resolve the caller's lane before opening the stream, so the writer is
1454    // queued against the same JID the reply will be written to.
1455    let lane = s
1456        .ensure_user_lane(user_id.as_ref(), user_name)
1457        .await
1458        .map_err(|_| {
1459            ApiError::new(
1460                StatusCode::SERVICE_UNAVAILABLE,
1461                "too many active user lanes",
1462            )
1463        })?;
1464    let (writer_id, rx) = s
1465        .streams
1466        .open(&lane, s.config.max_pending_per_lane, s.config.max_streams)
1467        .map_err(|e| match e {
1468            OpenError::LaneBusy => ApiError::new(
1469                StatusCode::TOO_MANY_REQUESTS,
1470                "too many requests queued on this lane",
1471            ),
1472            OpenError::TooManyStreams => {
1473                ApiError::new(StatusCode::SERVICE_UNAVAILABLE, "too many open streams")
1474            }
1475        })?;
1476    let stream = WriterStream::new(
1477        rx,
1478        WriterGuard {
1479            registry: Arc::clone(&s.streams),
1480            jid: lane.clone(),
1481            id: writer_id,
1482        },
1483    );
1484
1485    let suffix = uuid::Uuid::new_v4().simple().to_string();
1486    let msg = NewMessage {
1487        id: format!(
1488            "http-{}-{}",
1489            Utc::now().timestamp_millis(),
1490            suffix.get(..8).unwrap_or(&suffix)
1491        ),
1492        chat_jid: lane.clone(),
1493        sender: sender.clone(),
1494        sender_name: sender.clone(),
1495        content: text.to_string(),
1496        timestamp: now_iso(),
1497        is_from_me: false,
1498        ..NewMessage::default()
1499    };
1500    s.host.on_message(&lane, msg).await;
1501    Metrics::inc(&s.metrics.messages);
1502    tracing::info!(lane = %lane, sender = %sender, length = text.len(), "HTTP channel: message received");
1503
1504    let keepalive = KeepAlive::new()
1505        .interval(s.config.sse_keepalive.max(Duration::from_millis(10)))
1506        .text("keepalive");
1507    let mut res = Sse::new(stream).keep_alive(keepalive).into_response();
1508    res.headers_mut()
1509        .insert("x-accel-buffering", HeaderValue::from_static("no"));
1510    Ok(res)
1511}