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 tokio_stream::wrappers::BroadcastStream;
13use uuid::Uuid;
14
15use crate::authorization::AuthorizeExt;
16use crate::error::ApiError;
17use crate::state::AppState;
18use ironflow_auth::extractor::Authenticated;
19use ironflow_engine::notify::Event;
20use ironflow_store::entities::ApiKeyScope;
21
22pub use ironflow_store::entities::EventKind;
23
24fn deserialize_comma_event_kinds<'de, D>(
26 deserializer: D,
27) -> Result<Option<Vec<EventKind>>, D::Error>
28where
29 D: Deserializer<'de>,
30{
31 let opt: Option<String> = Option::deserialize(deserializer)?;
32 match opt {
33 None => Ok(None),
34 Some(raw) => {
35 let kinds: Result<Vec<EventKind>, _> = raw
36 .split(',')
37 .map(|s| s.trim())
38 .filter(|s| !s.is_empty())
39 .map(EventKind::from_str)
40 .collect();
41 kinds.map(Some).map_err(de::Error::custom)
42 }
43 }
44}
45
46#[derive(Debug, Deserialize)]
61pub struct EventsQuery {
62 pub run_id: Option<Uuid>,
64 #[serde(default, deserialize_with = "deserialize_comma_event_kinds")]
66 pub types: Option<Vec<EventKind>>,
67}
68
69pub async fn events(
92 auth: Authenticated,
93 State(state): State<AppState>,
94 Query(query): Query<EventsQuery>,
95) -> Result<Sse<impl Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
96 auth.require(ApiKeyScope::RunsRead, false)?;
97 let connection = state.sse_limiter.try_acquire(auth.user_id)?;
98 let receiver = state.event_sender.subscribe();
99 let sees_provider_account_events = auth.require(ApiKeyScope::Admin, true).is_ok();
102 let type_filter = query.types;
103
104 let stream = BroadcastStream::new(receiver).filter_map(move |result: Result<Event, _>| {
105 let type_filter = type_filter.clone();
106 let run_id_filter = query.run_id;
107 async move {
108 let event = result.ok()?;
109
110 if !sees_provider_account_events
111 && matches!(
112 event,
113 Event::ProviderAccountUpdated(_) | Event::ProviderAccountUsageUpdated(_)
114 )
115 {
116 return None;
117 }
118
119 if let Some(ref rid) = run_id_filter
120 && event.run_id() != Some(*rid)
121 {
122 return None;
123 }
124
125 if let Some(ref kinds) = type_filter {
126 let event_type = event.event_type();
127 if !kinds.iter().any(|k| k.as_str() == event_type) {
128 return None;
129 }
130 }
131
132 let data = serde_json::to_string(&event).ok()?;
133 let sse_event = SseEvent::default().event(event.event_type()).data(data);
134
135 Some(Ok::<_, Infallible>(sse_event))
136 }
137 });
138 let stream = stream.map(move |item| {
140 let _held = &connection;
141 item
142 });
143
144 let sse = Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(30)));
145 Ok(sse)
146}
147
148#[cfg(test)]
149mod tests {
150 use std::collections::HashMap;
151 use std::sync::Arc;
152 use std::time::Duration;
153
154 use axum::Router;
155 use axum::routing::get;
156 use chrono::Utc;
157 use ironflow_auth::extractor::{API_KEY_PREFIX, API_KEY_SUFFIX_LEN};
158 use ironflow_auth::password;
159 use ironflow_core::providers::claude::ClaudeCodeProvider;
160 use ironflow_engine::engine::Engine;
161 use ironflow_engine::notify::{
162 Event, ProviderAccountChange, ProviderAccountUpdatedEvent, RunStatusChangedEvent,
163 UserSignedInEvent,
164 };
165 use ironflow_store::entities::{ApiKeyScope, NewApiKey, NewUser};
166 use ironflow_store::memory::InMemoryStore;
167 use ironflow_store::models::RunStatus;
168 use rust_decimal::Decimal;
169 use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
170 use tokio::net::{TcpListener, TcpStream};
171 use tokio::sync::broadcast;
172 use tokio::time::{sleep, timeout};
173 use uuid::Uuid;
174
175 use super::events;
176 use crate::routes::test_helpers::create_user_auth_header;
177 use crate::sse_limiter::SseLimits;
178 use crate::state::AppState;
179
180 fn test_state() -> AppState {
181 let store = Arc::new(InMemoryStore::new());
182 let provider = Arc::new(ClaudeCodeProvider::new());
183 let engine = Arc::new(Engine::new(store.clone(), provider));
184 let jwt_config = Arc::new(ironflow_auth::jwt::JwtConfig {
185 secret: "test-secret".to_string(),
186 access_token_ttl_secs: 900,
187 refresh_token_ttl_secs: 604800,
188 cookie_domain: None,
189 cookie_secure: false,
190 });
191 let (event_sender, _) = broadcast::channel::<Event>(16);
192 AppState::new(
193 store,
194 engine,
195 jwt_config,
196 "test-worker-token".to_string(),
197 event_sender,
198 )
199 }
200
201 fn sample_run_event(run_id: Uuid) -> Event {
202 Event::RunStatusChanged(RunStatusChangedEvent {
203 run_id,
204 workflow_name: "deploy".to_string(),
205 from: RunStatus::Running,
206 to: RunStatus::Completed,
207 error: None,
208 cost_usd: Decimal::ZERO,
209 duration_ms: 1000,
210 labels: HashMap::new(),
211 at: Utc::now(),
212 })
213 }
214
215 fn sample_user_event() -> Event {
216 Event::UserSignedIn(UserSignedInEvent {
217 user_id: Uuid::now_v7(),
218 username: "alice".to_string(),
219 at: Utc::now(),
220 })
221 }
222
223 fn sample_provider_account_event() -> Event {
224 Event::ProviderAccountUpdated(ProviderAccountUpdatedEvent {
225 account_id: Uuid::now_v7(),
226 name: "acct".into(),
227 change: ProviderAccountChange::Updated,
228 at: Utc::now(),
229 })
230 }
231
232 async fn api_key_header(
238 state: &AppState,
239 owner_is_admin: bool,
240 scopes: Vec<ApiKeyScope>,
241 ) -> String {
242 let suffix = Uuid::now_v7().simple().to_string();
243 let owner = state
244 .store
245 .create_user(NewUser {
246 email: format!("owner-{suffix}@example.com"),
247 username: format!("owner-{suffix}"),
248 password_hash: "argon2hash".to_string(),
249 is_admin: Some(owner_is_admin),
250 })
251 .await
252 .unwrap();
253 let raw_key = format!("irfl_{}rest-of-secret-key", &suffix[24..]);
254 let prefix = &raw_key[..API_KEY_PREFIX.len() + API_KEY_SUFFIX_LEN];
255 state
256 .store
257 .create_api_key(NewApiKey {
258 user_id: owner.id,
259 name: format!("key-{suffix}"),
260 key_hash: password::hash(&raw_key).unwrap(),
261 key_prefix: prefix.to_string(),
262 scopes,
263 expires_at: None,
264 rate_limit_override: None,
265 })
266 .await
267 .unwrap();
268 format!("Bearer {raw_key}")
269 }
270
271 async fn start_sse_server(state: AppState) -> (String, broadcast::Sender<Event>, String) {
273 let sender = state.event_sender.clone();
274 let auth = create_user_auth_header(&state, "testuser", false).await;
275 let app = Router::new()
276 .route("/events", get(events))
277 .with_state(state);
278
279 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
280 let addr = listener.local_addr().unwrap().to_string();
281 tokio::spawn(async move {
282 axum::serve(listener, app).await.unwrap();
283 });
284 (addr, sender, auth)
285 }
286
287 async fn connect_sse(addr: &str, query: &str, auth: &str) -> BufReader<TcpStream> {
289 let stream = TcpStream::connect(addr).await.unwrap();
290 let (reader, mut writer) = stream.into_split();
291
292 use tokio::io::AsyncWriteExt;
293 writer
294 .write_all(
295 format!(
296 "GET /events{query} HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\nAuthorization: {auth}\r\n\r\n"
297 )
298 .as_bytes(),
299 )
300 .await
301 .unwrap();
302
303 BufReader::new(reader.reunite(writer).unwrap())
304 }
305
306 async fn read_until_contains(
309 reader: &mut BufReader<TcpStream>,
310 needle: &str,
311 dur: Duration,
312 ) -> String {
313 let mut accumulated = String::new();
314 let result = timeout(dur, async {
315 loop {
316 let mut line = String::new();
317 let n = reader.read_line(&mut line).await.unwrap();
318 if n == 0 {
319 break;
320 }
321 accumulated.push_str(&line);
322 if accumulated.contains(needle) {
323 break;
324 }
325 }
326 })
327 .await;
328 if result.is_err() {
329 panic!("timeout waiting for '{needle}' in SSE stream. Data so far:\n{accumulated}");
330 }
331 accumulated
332 }
333
334 async fn wait_for_subscriber(sender: &broadcast::Sender<Event>) {
340 let ready = timeout(Duration::from_secs(5), async {
341 while sender.receiver_count() == 0 {
342 sleep(Duration::from_millis(5)).await;
343 }
344 })
345 .await;
346 assert!(ready.is_ok(), "SSE handler did not subscribe in time");
347 }
348
349 #[tokio::test]
350 async fn sse_stream_receives_events() {
351 let state = test_state();
352 let (addr, sender, auth) = start_sse_server(state).await;
353 let mut reader = connect_sse(&addr, "", &auth).await;
354
355 wait_for_subscriber(&sender).await;
356
357 let run_id = Uuid::now_v7();
358 sender.send(sample_run_event(run_id)).unwrap();
359
360 let text =
361 read_until_contains(&mut reader, &run_id.to_string(), Duration::from_secs(5)).await;
362
363 assert!(text.contains("run_status_changed"));
364 assert!(text.contains(&run_id.to_string()));
365 }
366
367 #[tokio::test]
368 async fn sse_filters_by_run_id() {
369 let state = test_state();
370 let (addr, sender, auth) = start_sse_server(state).await;
371
372 let target_run = Uuid::now_v7();
373 let other_run = Uuid::now_v7();
374
375 let mut reader = connect_sse(&addr, &format!("?run_id={target_run}"), &auth).await;
376 wait_for_subscriber(&sender).await;
377
378 sender.send(sample_run_event(other_run)).unwrap();
379 sender.send(sample_run_event(target_run)).unwrap();
380
381 let text =
382 read_until_contains(&mut reader, &target_run.to_string(), Duration::from_secs(5)).await;
383
384 assert!(text.contains(&target_run.to_string()));
385 assert!(!text.contains(&other_run.to_string()));
386 }
387
388 #[tokio::test]
389 async fn sse_filters_by_event_type() {
390 let state = test_state();
391 let (addr, sender, auth) = start_sse_server(state).await;
392
393 let mut reader = connect_sse(&addr, "?types=user_signed_in", &auth).await;
394 wait_for_subscriber(&sender).await;
395
396 let run_id = Uuid::now_v7();
397 sender.send(sample_run_event(run_id)).unwrap();
398 sender.send(sample_user_event()).unwrap();
399
400 let text = read_until_contains(&mut reader, "user_signed_in", Duration::from_secs(5)).await;
401
402 assert!(text.contains("user_signed_in"));
403 assert!(!text.contains("run_status_changed"));
404 }
405
406 #[tokio::test]
407 async fn sse_returns_correct_content_type() {
408 let state = test_state();
409 let (addr, _sender, auth) = start_sse_server(state).await;
410 let mut reader = connect_sse(&addr, "", &auth).await;
411
412 let text =
413 read_until_contains(&mut reader, "text/event-stream", Duration::from_secs(5)).await;
414
415 assert!(text.contains("text/event-stream"));
416 }
417
418 #[tokio::test]
419 async fn sse_rejects_unauthenticated() {
420 let state = test_state();
421 let (addr, _sender, _auth) = start_sse_server(state).await;
422 let stream = TcpStream::connect(&addr).await.unwrap();
424 let (reader, mut writer) = stream.into_split();
425
426 use tokio::io::AsyncWriteExt;
427 writer
428 .write_all(
429 format!(
430 "GET /events HTTP/1.1\r\nHost: {addr}\r\nAccept: text/event-stream\r\n\r\n"
431 )
432 .as_bytes(),
433 )
434 .await
435 .unwrap();
436
437 let mut buf_reader = BufReader::new(reader.reunite(writer).unwrap());
438 let text = read_until_contains(&mut buf_reader, "401", Duration::from_secs(5)).await;
439
440 assert!(text.contains("401"));
441 }
442
443 async fn assert_provider_account_events_hidden(state: AppState, auth: &str) {
449 let (addr, sender, _jwt) = start_sse_server(state).await;
450 let mut reader = connect_sse(&addr, "", auth).await;
451 wait_for_subscriber(&sender).await;
452
453 let run_id = Uuid::now_v7();
454 sender.send(sample_provider_account_event()).unwrap();
455 sender.send(sample_run_event(run_id)).unwrap();
456
457 let text =
458 read_until_contains(&mut reader, &run_id.to_string(), Duration::from_secs(5)).await;
459
460 assert!(!text.contains("provider_account"));
461 }
462
463 #[tokio::test]
464 async fn sse_scope_hides_provider_account_events_from_a_runs_read_key() {
465 let state = test_state();
466 let auth = api_key_header(&state, true, vec![ApiKeyScope::RunsRead]).await;
467 assert_provider_account_events_hidden(state, &auth).await;
468 }
469
470 #[tokio::test]
471 async fn sse_scope_hides_provider_account_events_from_an_admin_scope_key_of_a_member() {
472 let state = test_state();
473 let auth = api_key_header(&state, false, vec![ApiKeyScope::Admin]).await;
474 assert_provider_account_events_hidden(state, &auth).await;
475 }
476
477 #[tokio::test]
478 async fn sse_scope_streams_provider_account_events_to_an_admin_scope_key() {
479 let state = test_state();
480 let auth = api_key_header(&state, true, vec![ApiKeyScope::Admin]).await;
481 let (addr, sender, _jwt) = start_sse_server(state).await;
482 let mut reader = connect_sse(&addr, "", &auth).await;
483 wait_for_subscriber(&sender).await;
484
485 sender.send(sample_provider_account_event()).unwrap();
486
487 let text =
488 read_until_contains(&mut reader, "provider_account", Duration::from_secs(5)).await;
489
490 assert!(text.contains("provider_account"));
491 }
492
493 #[tokio::test]
494 async fn sse_scope_rejects_a_key_without_runs_read() {
495 let state = test_state();
496 let auth = api_key_header(&state, true, vec![ApiKeyScope::StatsRead]).await;
497 let (addr, _sender, _jwt) = start_sse_server(state).await;
498 let mut reader = connect_sse(&addr, "", &auth).await;
499
500 let text = read_until_contains(&mut reader, "\r\n", Duration::from_secs(5)).await;
501
502 assert!(text.starts_with("HTTP/1.1 403"), "status line: {text}");
503 }
504
505 async fn status_line(reader: &mut BufReader<TcpStream>) -> String {
507 read_until_contains(reader, "\r\n", Duration::from_secs(5)).await
508 }
509
510 async fn read_body(reader: &mut BufReader<TcpStream>) -> String {
512 timeout(Duration::from_secs(5), async {
513 let mut content_length = 0;
514 loop {
515 let mut line = String::new();
516 reader.read_line(&mut line).await.unwrap();
517 if line.is_empty() || line == "\r\n" {
518 break;
519 }
520 if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") {
521 content_length = value.trim().parse().unwrap();
522 }
523 }
524 let mut body = vec![0; content_length];
525 reader.read_exact(&mut body).await.unwrap();
526 String::from_utf8(body).unwrap()
527 })
528 .await
529 .expect("timeout reading the response body")
530 }
531
532 fn limited_state(per_principal: usize) -> AppState {
533 test_state().with_sse_limits(SseLimits {
534 per_principal: Some(per_principal),
535 global: None,
536 })
537 }
538
539 #[tokio::test]
540 async fn events_returns_429_past_the_per_principal_limit() {
541 let state = limited_state(2);
542 let other_auth = create_user_auth_header(&state, "otheruser", false).await;
543 let (addr, _sender, auth) = start_sse_server(state).await;
544
545 let mut first = connect_sse(&addr, "", &auth).await;
546 assert!(status_line(&mut first).await.starts_with("HTTP/1.1 200"));
547 let mut second = connect_sse(&addr, "", &auth).await;
548 assert!(status_line(&mut second).await.starts_with("HTTP/1.1 200"));
549
550 let mut third = connect_sse(&addr, "", &auth).await;
551 let status = status_line(&mut third).await;
552 assert!(status.starts_with("HTTP/1.1 429"), "status line: {status}");
553 let body = read_body(&mut third).await;
554 assert!(body.contains("SSE_CONNECTION_LIMIT"), "body: {body}");
555
556 let mut other = connect_sse(&addr, "", &other_auth).await;
557 let status = status_line(&mut other).await;
558 assert!(
559 status.starts_with("HTTP/1.1 200"),
560 "another user keeps its own quota: {status}"
561 );
562 }
563
564 #[tokio::test]
565 async fn events_frees_the_slot_when_the_client_disconnects() {
566 let state = limited_state(1);
567 let (addr, _sender, auth) = start_sse_server(state).await;
568
569 let mut first = connect_sse(&addr, "", &auth).await;
570 assert!(status_line(&mut first).await.starts_with("HTTP/1.1 200"));
571 let mut refused = connect_sse(&addr, "", &auth).await;
572 assert!(status_line(&mut refused).await.starts_with("HTTP/1.1 429"));
573
574 drop(first);
575
576 let reopened = timeout(Duration::from_secs(5), async {
577 loop {
578 let mut retry = connect_sse(&addr, "", &auth).await;
579 if status_line(&mut retry).await.starts_with("HTTP/1.1 200") {
580 return retry;
581 }
582 sleep(Duration::from_millis(20)).await;
583 }
584 })
585 .await;
586 assert!(reopened.is_ok(), "the slot was not freed after disconnect");
587 }
588}