1use std::convert::Infallible;
4use std::str::FromStr;
5use std::time::Duration;
6
7use axum::extract::{Query, State};
8use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
9use futures_util::stream::{Stream, StreamExt};
10use serde::Deserialize;
11use serde::de::{self, Deserializer};
12use serde_json::json;
13use tokio::sync::broadcast;
14use tokio_stream::wrappers::BroadcastStream;
15use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
16use uuid::Uuid;
17
18use crate::authorization::AuthorizeExt;
19use crate::error::ApiError;
20use crate::state::AppState;
21use ironflow_auth::extractor::Authenticated;
22use ironflow_engine::notify::Event;
23use ironflow_store::entities::ApiKeyScope;
24
25pub use ironflow_store::entities::EventKind;
26
27fn deserialize_comma_event_kinds<'de, D>(
29 deserializer: D,
30) -> Result<Option<Vec<EventKind>>, D::Error>
31where
32 D: Deserializer<'de>,
33{
34 let opt: Option<String> = Option::deserialize(deserializer)?;
35 match opt {
36 None => Ok(None),
37 Some(raw) => {
38 let kinds: Result<Vec<EventKind>, _> = raw
39 .split(',')
40 .map(|s| s.trim())
41 .filter(|s| !s.is_empty())
42 .map(EventKind::from_str)
43 .collect();
44 kinds.map(Some).map_err(de::Error::custom)
45 }
46 }
47}
48
49#[derive(Debug, Deserialize)]
64pub struct EventsQuery {
65 pub run_id: Option<Uuid>,
67 #[serde(default, deserialize_with = "deserialize_comma_event_kinds")]
69 pub types: Option<Vec<EventKind>>,
70}
71
72pub async fn events(
100 auth: Authenticated,
101 State(state): State<AppState>,
102 Query(query): Query<EventsQuery>,
103) -> Result<Sse<impl Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
104 auth.require(ApiKeyScope::RunsRead, false)?;
105 let connection = state.sse_limiter.try_acquire(auth.user_id)?;
106 let receiver = state.event_sender.subscribe();
107 let sees_provider_account_events = auth.require(ApiKeyScope::Admin, true).is_ok();
110 let stream = event_stream(
111 receiver,
112 query.run_id,
113 query.types,
114 sees_provider_account_events,
115 );
116 let stream = stream.map(move |item| {
118 let _held = &connection;
119 item
120 });
121
122 let sse = Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(30)));
123 Ok(sse)
124}
125
126const LAGGED_EVENT: &str = "lagged";
128
129fn event_stream(
135 receiver: broadcast::Receiver<Event>,
136 run_id_filter: Option<Uuid>,
137 type_filter: Option<Vec<EventKind>>,
138 sees_provider_account_events: bool,
139) -> impl Stream<Item = Result<SseEvent, Infallible>> {
140 BroadcastStream::new(receiver).filter_map(move |result: Result<Event, _>| {
141 let type_filter = type_filter.clone();
142 async move {
143 let event = match result {
144 Ok(event) => event,
145 Err(BroadcastStreamRecvError::Lagged(missed)) => {
146 let data = json!({ "missed": missed }).to_string();
147 let sse_event = SseEvent::default().event(LAGGED_EVENT).data(data);
148 return Some(Ok(sse_event));
149 }
150 };
151
152 if !sees_provider_account_events
153 && matches!(
154 event,
155 Event::ProviderAccountUpdated(_) | Event::ProviderAccountUsageUpdated(_)
156 )
157 {
158 return None;
159 }
160
161 if let Some(ref rid) = run_id_filter
162 && event.run_id() != Some(*rid)
163 {
164 return None;
165 }
166
167 if let Some(ref kinds) = type_filter {
168 let event_type = event.event_type();
169 if !kinds.iter().any(|k| k.as_str() == event_type) {
170 return None;
171 }
172 }
173
174 let data = serde_json::to_string(&event).ok()?;
175 let sse_event = SseEvent::default().event(event.event_type()).data(data);
176
177 Some(Ok::<_, Infallible>(sse_event))
178 }
179 })
180}
181
182#[cfg(test)]
183mod tests {
184 use std::collections::HashMap;
185 use std::sync::Arc;
186 use std::time::Duration;
187
188 use axum::Router;
189 use axum::body::Body;
190 use axum::response::IntoResponse;
191 use axum::response::sse::Sse;
192 use axum::routing::get;
193 use chrono::Utc;
194 use http_body_util::BodyExt;
195 use ironflow_auth::extractor::{API_KEY_PREFIX, API_KEY_SUFFIX_LEN};
196 use ironflow_auth::password;
197 use ironflow_core::providers::claude::ClaudeCodeProvider;
198 use ironflow_engine::engine::Engine;
199 use ironflow_engine::notify::{
200 Event, ProviderAccountChange, ProviderAccountUpdatedEvent, RunStatusChangedEvent,
201 UserSignedInEvent,
202 };
203 use ironflow_store::entities::{ApiKeyScope, NewApiKey, NewUser};
204 use ironflow_store::memory::InMemoryStore;
205 use ironflow_store::models::RunStatus;
206 use rust_decimal::Decimal;
207 use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
208 use tokio::net::{TcpListener, TcpStream};
209 use tokio::sync::broadcast;
210 use tokio::time::{sleep, timeout};
211 use uuid::Uuid;
212
213 use super::{EventKind, event_stream, events};
214 use crate::routes::test_helpers::create_user_auth_header;
215 use crate::sse_limiter::SseLimits;
216 use crate::state::AppState;
217
218 fn test_state() -> AppState {
219 let store = Arc::new(InMemoryStore::new());
220 let provider = Arc::new(ClaudeCodeProvider::new());
221 let engine = Arc::new(Engine::new(store.clone(), provider));
222 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
223 secret: "test-secret".to_string(),
224 access_token_ttl_secs: 900,
225 refresh_token_ttl_secs: 604800,
226 cookie_domain: None,
227 cookie_secure: false,
228 });
229 let (event_sender, _) = broadcast::channel::<Event>(16);
230 AppState::new(
231 store,
232 engine,
233 jwt_config,
234 "test-worker-token".to_string(),
235 event_sender,
236 )
237 }
238
239 fn sample_run_event(run_id: Uuid) -> Event {
240 Event::RunStatusChanged(RunStatusChangedEvent {
241 run_id,
242 workflow_name: "deploy".to_string(),
243 from: RunStatus::Running,
244 to: RunStatus::Completed,
245 error: None,
246 cost_usd: Decimal::ZERO,
247 duration_ms: 1000,
248 labels: HashMap::new(),
249 at: Utc::now(),
250 })
251 }
252
253 fn sample_user_event() -> Event {
254 Event::UserSignedIn(UserSignedInEvent {
255 user_id: Uuid::now_v7(),
256 username: "alice".to_string(),
257 at: Utc::now(),
258 })
259 }
260
261 fn sample_provider_account_event() -> Event {
262 Event::ProviderAccountUpdated(ProviderAccountUpdatedEvent {
263 account_id: Uuid::now_v7(),
264 name: "acct".into(),
265 change: ProviderAccountChange::Updated,
266 at: Utc::now(),
267 })
268 }
269
270 async fn api_key_header(
276 state: &AppState,
277 owner_is_admin: bool,
278 scopes: Vec<ApiKeyScope>,
279 ) -> String {
280 let suffix = Uuid::now_v7().simple().to_string();
281 let owner = state
282 .store
283 .create_user(NewUser {
284 email: format!("owner-{suffix}@example.com"),
285 username: format!("owner-{suffix}"),
286 password_hash: "argon2hash".to_string(),
287 is_admin: Some(owner_is_admin),
288 })
289 .await
290 .unwrap();
291 let raw_key = format!("irfl_{}rest-of-secret-key", &suffix[24..]);
292 let prefix = &raw_key[..API_KEY_PREFIX.len() + API_KEY_SUFFIX_LEN];
293 state
294 .store
295 .create_api_key(NewApiKey {
296 user_id: owner.id,
297 name: format!("key-{suffix}"),
298 key_hash: password::hash(&raw_key).unwrap(),
299 key_prefix: prefix.to_string(),
300 scopes,
301 expires_at: None,
302 rate_limit_override: None,
303 })
304 .await
305 .unwrap();
306 format!("Bearer {raw_key}")
307 }
308
309 async fn start_sse_server(state: AppState) -> (String, broadcast::Sender<Event>, String) {
311 let sender = state.event_sender.clone();
312 let auth = create_user_auth_header(&state, "testuser", false).await;
313 let app = Router::new()
314 .route("/events", get(events))
315 .with_state(state);
316
317 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
318 let addr = listener.local_addr().unwrap().to_string();
319 tokio::spawn(async move {
320 axum::serve(listener, app).await.unwrap();
321 });
322 (addr, sender, auth)
323 }
324
325 async fn connect_sse(addr: &str, query: &str, auth: &str) -> BufReader<TcpStream> {
327 let stream = TcpStream::connect(addr).await.unwrap();
328 let (reader, mut writer) = stream.into_split();
329
330 use tokio::io::AsyncWriteExt;
331 writer
332 .write_all(
333 format!(
334 "GET /events{query} HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\nAuthorization: {auth}\r\n\r\n"
335 )
336 .as_bytes(),
337 )
338 .await
339 .unwrap();
340
341 BufReader::new(reader.reunite(writer).unwrap())
342 }
343
344 async fn read_until_contains(
347 reader: &mut BufReader<TcpStream>,
348 needle: &str,
349 dur: Duration,
350 ) -> String {
351 let mut accumulated = String::new();
352 let result = timeout(dur, async {
353 loop {
354 let mut line = String::new();
355 let n = reader.read_line(&mut line).await.unwrap();
356 if n == 0 {
357 break;
358 }
359 accumulated.push_str(&line);
360 if accumulated.contains(needle) {
361 break;
362 }
363 }
364 })
365 .await;
366 if result.is_err() {
367 panic!("timeout waiting for '{needle}' in SSE stream. Data so far:\n{accumulated}");
368 }
369 accumulated
370 }
371
372 async fn wait_for_subscriber(sender: &broadcast::Sender<Event>) {
378 let ready = timeout(Duration::from_secs(5), async {
379 while sender.receiver_count() == 0 {
380 sleep(Duration::from_millis(5)).await;
381 }
382 })
383 .await;
384 assert!(ready.is_ok(), "SSE handler did not subscribe in time");
385 }
386
387 #[tokio::test]
388 async fn sse_stream_receives_events() {
389 let state = test_state();
390 let (addr, sender, auth) = start_sse_server(state).await;
391 let mut reader = connect_sse(&addr, "", &auth).await;
392
393 wait_for_subscriber(&sender).await;
394
395 let run_id = Uuid::now_v7();
396 sender.send(sample_run_event(run_id)).unwrap();
397
398 let text =
399 read_until_contains(&mut reader, &run_id.to_string(), Duration::from_secs(5)).await;
400
401 assert!(text.contains("run_status_changed"));
402 assert!(text.contains(&run_id.to_string()));
403 }
404
405 #[tokio::test]
406 async fn sse_filters_by_run_id() {
407 let state = test_state();
408 let (addr, sender, auth) = start_sse_server(state).await;
409
410 let target_run = Uuid::now_v7();
411 let other_run = Uuid::now_v7();
412
413 let mut reader = connect_sse(&addr, &format!("?run_id={target_run}"), &auth).await;
414 wait_for_subscriber(&sender).await;
415
416 sender.send(sample_run_event(other_run)).unwrap();
417 sender.send(sample_run_event(target_run)).unwrap();
418
419 let text =
420 read_until_contains(&mut reader, &target_run.to_string(), Duration::from_secs(5)).await;
421
422 assert!(text.contains(&target_run.to_string()));
423 assert!(!text.contains(&other_run.to_string()));
424 }
425
426 #[tokio::test]
427 async fn sse_filters_by_event_type() {
428 let state = test_state();
429 let (addr, sender, auth) = start_sse_server(state).await;
430
431 let mut reader = connect_sse(&addr, "?types=user_signed_in", &auth).await;
432 wait_for_subscriber(&sender).await;
433
434 let run_id = Uuid::now_v7();
435 sender.send(sample_run_event(run_id)).unwrap();
436 sender.send(sample_user_event()).unwrap();
437
438 let text = read_until_contains(&mut reader, "user_signed_in", Duration::from_secs(5)).await;
439
440 assert!(text.contains("user_signed_in"));
441 assert!(!text.contains("run_status_changed"));
442 }
443
444 #[tokio::test]
445 async fn sse_returns_correct_content_type() {
446 let state = test_state();
447 let (addr, _sender, auth) = start_sse_server(state).await;
448 let mut reader = connect_sse(&addr, "", &auth).await;
449
450 let text =
451 read_until_contains(&mut reader, "text/event-stream", Duration::from_secs(5)).await;
452
453 assert!(text.contains("text/event-stream"));
454 }
455
456 #[tokio::test]
457 async fn sse_rejects_unauthenticated() {
458 let state = test_state();
459 let (addr, _sender, _auth) = start_sse_server(state).await;
460 let stream = TcpStream::connect(&addr).await.unwrap();
462 let (reader, mut writer) = stream.into_split();
463
464 use tokio::io::AsyncWriteExt;
465 writer
466 .write_all(
467 format!(
468 "GET /events HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\n\r\n"
469 )
470 .as_bytes(),
471 )
472 .await
473 .unwrap();
474
475 let mut buf_reader = BufReader::new(reader.reunite(writer).unwrap());
476 let text = read_until_contains(&mut buf_reader, "401", Duration::from_secs(5)).await;
477
478 assert!(text.contains("401"));
479 }
480
481 async fn assert_provider_account_events_hidden(state: AppState, auth: &str) {
487 let (addr, sender, _jwt) = start_sse_server(state).await;
488 let mut reader = connect_sse(&addr, "", auth).await;
489 wait_for_subscriber(&sender).await;
490
491 let run_id = Uuid::now_v7();
492 sender.send(sample_provider_account_event()).unwrap();
493 sender.send(sample_run_event(run_id)).unwrap();
494
495 let text =
496 read_until_contains(&mut reader, &run_id.to_string(), Duration::from_secs(5)).await;
497
498 assert!(!text.contains("provider_account"));
499 }
500
501 #[tokio::test]
502 async fn sse_scope_hides_provider_account_events_from_a_runs_read_key() {
503 let state = test_state();
504 let auth = api_key_header(&state, true, vec![ApiKeyScope::RunsRead]).await;
505 assert_provider_account_events_hidden(state, &auth).await;
506 }
507
508 #[tokio::test]
509 async fn sse_scope_hides_provider_account_events_from_an_admin_scope_key_of_a_member() {
510 let state = test_state();
511 let auth = api_key_header(&state, false, vec![ApiKeyScope::Admin]).await;
512 assert_provider_account_events_hidden(state, &auth).await;
513 }
514
515 #[tokio::test]
516 async fn sse_scope_streams_provider_account_events_to_an_admin_scope_key() {
517 let state = test_state();
518 let auth = api_key_header(&state, true, vec![ApiKeyScope::Admin]).await;
519 let (addr, sender, _jwt) = start_sse_server(state).await;
520 let mut reader = connect_sse(&addr, "", &auth).await;
521 wait_for_subscriber(&sender).await;
522
523 sender.send(sample_provider_account_event()).unwrap();
524
525 let text =
526 read_until_contains(&mut reader, "provider_account", Duration::from_secs(5)).await;
527
528 assert!(text.contains("provider_account"));
529 }
530
531 #[tokio::test]
532 async fn sse_scope_rejects_a_key_without_runs_read() {
533 let state = test_state();
534 let auth = api_key_header(&state, true, vec![ApiKeyScope::StatsRead]).await;
535 let (addr, _sender, _jwt) = start_sse_server(state).await;
536 let mut reader = connect_sse(&addr, "", &auth).await;
537
538 let text = read_until_contains(&mut reader, "\r\n", Duration::from_secs(5)).await;
539
540 assert!(text.starts_with("HTTP/1.1 403"), "status line: {text}");
541 }
542
543 fn lagging_receiver() -> (broadcast::Sender<Event>, broadcast::Receiver<Event>) {
546 let (sender, receiver) = broadcast::channel::<Event>(2);
547 for _ in 0..5 {
548 sender.send(sample_run_event(Uuid::now_v7())).unwrap();
549 }
550 (sender, receiver)
551 }
552
553 async fn read_body_until(body: &mut Body, needle: &str) -> String {
555 let mut accumulated = String::new();
556 let result = timeout(Duration::from_secs(5), async {
557 while !accumulated.contains(needle) {
558 let Some(frame) = body.frame().await else {
559 break;
560 };
561 if let Ok(data) = frame.unwrap().into_data() {
562 accumulated.push_str(&String::from_utf8_lossy(&data));
563 }
564 }
565 })
566 .await;
567 assert!(
568 result.is_ok(),
569 "timeout waiting for '{needle}'. Data so far:\n{accumulated}"
570 );
571 accumulated
572 }
573
574 #[tokio::test]
575 async fn lagged_event_is_sent_when_the_subscriber_falls_behind() {
576 let (sender, receiver) = lagging_receiver();
577 let response = Sse::new(event_stream(receiver, None, None, false)).into_response();
578 let mut body = response.into_body();
579
580 let text = read_body_until(&mut body, "\"missed\"").await;
581 assert!(text.contains("lagged"), "got {text}");
582 assert!(text.contains("\"missed\":3"), "got {text}");
583
584 let run_id = Uuid::now_v7();
586 sender.send(sample_run_event(run_id)).unwrap();
587 let text = read_body_until(&mut body, &run_id.to_string()).await;
588 assert!(text.contains("run_status_changed"), "got {text}");
589 }
590
591 #[tokio::test]
592 async fn lagged_event_bypasses_the_run_and_type_filters() {
593 let (_sender, receiver) = lagging_receiver();
594 let stream = event_stream(
595 receiver,
596 Some(Uuid::now_v7()),
597 Some(vec![EventKind::UserSignedIn]),
598 false,
599 );
600 let mut body = Sse::new(stream).into_response().into_body();
601
602 let text = read_body_until(&mut body, "\"missed\"").await;
603 assert!(text.contains("lagged"), "got {text}");
604 assert!(!text.contains("run_status_changed"), "got {text}");
605 }
606
607 async fn status_line(reader: &mut BufReader<TcpStream>) -> String {
609 read_until_contains(reader, "\r\n", Duration::from_secs(5)).await
610 }
611
612 async fn read_body(reader: &mut BufReader<TcpStream>) -> String {
614 timeout(Duration::from_secs(5), async {
615 let mut content_length = 0;
616 loop {
617 let mut line = String::new();
618 reader.read_line(&mut line).await.unwrap();
619 if line.is_empty() || line == "\r\n" {
620 break;
621 }
622 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
623 content_length = value.trim().parse().unwrap();
624 }
625 }
626 let mut body = vec![0; content_length];
627 reader.read_exact(&mut body).await.unwrap();
628 String::from_utf8(body).unwrap()
629 })
630 .await
631 .expect("timeout reading the response body")
632 }
633
634 fn limited_state(per_principal: usize) -> AppState {
635 test_state().with_sse_limits(SseLimits {
636 per_principal: Some(per_principal),
637 global: None,
638 })
639 }
640
641 #[tokio::test]
642 async fn events_returns_429_past_the_per_principal_limit() {
643 let state = limited_state(2);
644 let other_auth = create_user_auth_header(&state, "otheruser", false).await;
645 let (addr, _sender, auth) = start_sse_server(state).await;
646
647 let mut first = connect_sse(&addr, "", &auth).await;
648 assert!(status_line(&mut first).await.starts_with("HTTP/1.1 200"));
649 let mut second = connect_sse(&addr, "", &auth).await;
650 assert!(status_line(&mut second).await.starts_with("HTTP/1.1 200"));
651
652 let mut third = connect_sse(&addr, "", &auth).await;
653 let status = status_line(&mut third).await;
654 assert!(status.starts_with("HTTP/1.1 429"), "status line: {status}");
655 let body = read_body(&mut third).await;
656 assert!(body.contains("SSE_CONNECTION_LIMIT"), "body: {body}");
657
658 let mut other = connect_sse(&addr, "", &other_auth).await;
659 let status = status_line(&mut other).await;
660 assert!(
661 status.starts_with("HTTP/1.1 200"),
662 "another user keeps its own quota: {status}"
663 );
664 }
665
666 #[tokio::test]
667 async fn events_frees_the_slot_when_the_client_disconnects() {
668 let state = limited_state(1);
669 let (addr, _sender, auth) = start_sse_server(state).await;
670
671 let mut first = connect_sse(&addr, "", &auth).await;
672 assert!(status_line(&mut first).await.starts_with("HTTP/1.1 200"));
673 let mut refused = connect_sse(&addr, "", &auth).await;
674 assert!(status_line(&mut refused).await.starts_with("HTTP/1.1 429"));
675
676 drop(first);
677
678 let reopened = timeout(Duration::from_secs(5), async {
679 loop {
680 let mut retry = connect_sse(&addr, "", &auth).await;
681 if status_line(&mut retry).await.starts_with("HTTP/1.1 200") {
682 return retry;
683 }
684 sleep(Duration::from_millis(20)).await;
685 }
686 })
687 .await;
688 assert!(reopened.is_ok(), "the slot was not freed after disconnect");
689 }
690}