1use 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
58const 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];
74const MAX_LABEL_CHARS: usize = 64;
76const DEFAULT_INVESTIGATIONS_LIMIT: usize = 50;
78const MAX_INVESTIGATIONS_LIMIT: usize = 500;
79const METRICS_INVESTIGATION_WINDOW: usize = 1_000;
81const MAX_AUDIT_PATH_CHARS: usize = 256;
83
84pub struct HttpDeps {
86 pub host: Arc<dyn ChannelHost>,
88 pub store: Arc<dyn HttpStore>,
90 pub audit: SharedAuditSink,
92 pub verifier: Arc<dyn KeyVerifier>,
94 pub palace: Arc<dyn PalaceRunner>,
96}
97
98impl HttpDeps {
99 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 pub fn with_palace(mut self, palace: Arc<dyn PalaceRunner>) -> Self {
126 self.palace = palace;
127 self
128 }
129}
130
131fn 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#[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#[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 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 pub fn config(&self) -> &HttpConfig {
223 &self.0.config
224 }
225
226 pub fn lanes(&self) -> &LaneIds {
228 &self.0.lanes
229 }
230
231 pub async fn register_base_lanes(&self) {
234 let s = &self.0;
235 let group = base_group(&s.config);
236 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 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 pub fn set_base_group(&self, group: RegisteredGroup) {
262 *lock(&self.0.base_group) = Some(group);
263 }
264
265 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 pub fn user_lane_jids(&self) -> Vec<String> {
277 self.0.user_lane_jids()
278 }
279
280 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 pub async fn sweep_idle_lanes_before(&self, cutoff: Instant) -> Vec<String> {
291 self.0.sweep_idle_lanes_before(cutoff).await
292 }
293
294 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
310fn 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 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 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 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 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 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#[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
529fn 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
541fn 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
551fn 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
571pub(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
583pub 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
646async 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
668async 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
707async 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
773async 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 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 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 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 "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 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
958fn 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 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 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
1180async 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
1215async 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
1229fn 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 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
1277async 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 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
1345async 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
1397async 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
1428async 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 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 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}