Skip to main content

ironflow_api/routes/
events.rs

1//! SSE endpoint for real-time event streaming.
2
3use 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
24/// Deserialize a comma-separated string into `Option<Vec<EventKind>>`.
25fn 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/// Query parameters for the SSE events endpoint.
47///
48/// Both fields are optional. When set, only matching events are streamed.
49///
50/// # Examples
51///
52/// ```
53/// use ironflow_api::routes::events::EventsQuery;
54///
55/// let query = EventsQuery {
56///     run_id: None,
57///     types: None,
58/// };
59/// ```
60#[derive(Debug, Deserialize)]
61pub struct EventsQuery {
62    /// Only stream events related to this run.
63    pub run_id: Option<Uuid>,
64    /// Comma-separated list of event types to include (e.g. `?types=run_status_changed,step_completed`).
65    #[serde(default, deserialize_with = "deserialize_comma_event_kinds")]
66    pub types: Option<Vec<EventKind>>,
67}
68
69/// `GET /api/v1/events` -- Server-Sent Events stream.
70///
71/// Streams domain events in real time. Supports optional filtering:
72/// - `?run_id=<uuid>` -- only events for that run
73/// - `?types=run_status_changed,step_completed` -- only those event types
74///
75/// Each SSE message has:
76/// - `event:` set to the event type (e.g. `run_status_changed`)
77/// - `data:` JSON-serialized event payload
78///
79/// A keep-alive comment is sent every 30 seconds.
80///
81/// Provider Account events reach an admin session, or an API key holding the
82/// `admin` scope whose owner is an admin.
83///
84/// # Errors
85///
86/// - 401 if the request is not authenticated
87/// - 403 `INSUFFICIENT_SCOPE` if the API key lacks `runs_read`
88/// - 429 `SSE_CONNECTION_LIMIT` if the caller (user or API key owner) or the
89///   server already holds as many event streams as
90///   [`SseLimits`](crate::sse_limiter::SseLimits) allows
91pub 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    // Provider Account events describe admin-only resources, which an API
100    // key reaches only through the `admin` scope.
101    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    // The stream owns the slot: axum drops it when the client disconnects.
139    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    /// A `Bearer` header for an API key holding `scopes`, owned by a fresh
233    /// user who is an admin when `owner_is_admin` is set.
234    ///
235    /// The key is written to the store directly, so a member may hold scopes
236    /// the `/api-keys` route would refuse them.
237    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    /// Start a real TCP server and return (address, sender, auth header).
272    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    /// Connect to the SSE endpoint with auth and return a line reader.
288    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    /// Read all available data from the SSE stream until `needle` is found
307    /// in the accumulated text, or timeout.
308    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    /// Wait until the SSE handler has subscribed to the broadcast channel.
335    ///
336    /// Sending before a receiver exists makes `broadcast::Sender::send` return
337    /// `SendError`, so a fixed sleep is racy on slow runners. Poll the receiver
338    /// count instead.
339    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        // Connect without auth header
423        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    /// Stream a provider account event, then a run event, to a client
444    /// authenticated with `auth`, and assert only the run event arrives.
445    ///
446    /// Both events go through the same channel in order: had the provider
447    /// account event not been filtered out, it would arrive first.
448    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    /// Read the HTTP status line of a response.
506    async fn status_line(reader: &mut BufReader<TcpStream>) -> String {
507        read_until_contains(reader, "\r\n", Duration::from_secs(5)).await
508    }
509
510    /// Read the headers left after the status line, then the body they announce.
511    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}