Skip to main content

rpi_http/
server.rs

1use crate::security::{self, Security};
2use axum::{
3    extract::{Path, Query, State},
4    http::StatusCode,
5    middleware::from_fn_with_state,
6    response::{
7        sse::{Event, Sse},
8        IntoResponse,
9    },
10    routing::{get, post},
11    Json, Router,
12};
13use futures_util::{
14    stream::{self, Stream},
15    StreamExt,
16};
17use rpi_extcore::{
18    durable::{DurableMutation, QueueStatus},
19    host::BufferedEvent,
20    protocol::{self, ApiCommand},
21    reply::{IntoReply, ReplyKind, ServiceReply},
22    service::{
23        self, AuthRefreshRequest, BackendConfig, CreateProjectRequest, CreateSessionRequest,
24        FileQuery, ForkRequest, MetadataRequest, ProjectFoldersQuery, RenameRequest, Service,
25        SessionListQuery,
26    },
27};
28use serde::{de::DeserializeOwned, Deserialize};
29use serde_json::{json, Value};
30use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc};
31use tokio::sync::{broadcast, watch};
32#[derive(Clone, Debug, serde::Deserialize)]
33#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
34pub struct AppConfig {
35    #[serde(skip, default = "default_service_name")]
36    pub service_name: &'static str,
37    pub project: PathBuf,
38    /// Additional workspace roots explicitly made available to clients.
39    pub projects: Vec<PathBuf>,
40    pub projects_root: Option<PathBuf>,
41    pub rpi_bin: PathBuf,
42    pub bind: String,
43    pub port: u16,
44    /// Optional Host allowlist; RPI_WEB_ALLOWED_HOSTS takes precedence.
45    pub allowed_hosts: Option<Vec<String>>,
46    /// Optional `--session-dir` forwarded to the native host.
47    pub session_dir: Option<PathBuf>,
48    /// Where the bridge remembers session ids across restarts.
49    pub registry: PathBuf,
50}
51
52fn default_service_name() -> &'static str {
53    "rpi-http"
54}
55
56impl Default for AppConfig {
57    fn default() -> Self {
58        Self {
59            service_name: "rpi-http",
60            project: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
61            projects: Vec::new(),
62            projects_root: None,
63            rpi_bin: PathBuf::from("rpi"),
64            bind: "127.0.0.1".into(),
65            port: 3030,
66            allowed_hosts: None,
67            session_dir: None,
68            registry: rpi_extcore::registry::default_path(),
69        }
70    }
71}
72
73#[derive(Clone)]
74struct AppState {
75    backend: Arc<Service>,
76    security: Arc<Security>,
77    shutdown: watch::Receiver<bool>,
78    service_name: &'static str,
79}
80pub struct Listening {
81    pub address: SocketAddr,
82    pub hosts: Vec<String>,
83    pub password_enabled: bool,
84}
85struct HttpReply(ServiceReply);
86impl IntoResponse for HttpReply {
87    fn into_response(self) -> axum::response::Response {
88        let status = match self.0.kind {
89            ReplyKind::Success => StatusCode::OK,
90            ReplyKind::Created => StatusCode::CREATED,
91            ReplyKind::InvalidInput => StatusCode::BAD_REQUEST,
92            ReplyKind::NotFound => StatusCode::NOT_FOUND,
93            ReplyKind::AccessDenied => StatusCode::FORBIDDEN,
94            ReplyKind::LimitExceeded => StatusCode::PAYLOAD_TOO_LARGE,
95            ReplyKind::UnsupportedContent => StatusCode::UNSUPPORTED_MEDIA_TYPE,
96            ReplyKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
97            ReplyKind::HostUnavailable => StatusCode::BAD_GATEWAY,
98            ReplyKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
99            ReplyKind::Unsupported => StatusCode::NOT_IMPLEMENTED,
100            ReplyKind::Conflict => StatusCode::CONFLICT,
101            ReplyKind::Empty => StatusCode::NO_CONTENT,
102            ReplyKind::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
103        };
104        if matches!(self.0.kind, ReplyKind::Empty) {
105            status.into_response()
106        } else {
107            (status, Json(self.0.value)).into_response()
108        }
109    }
110}
111pub async fn run(config: AppConfig) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
112    serve_until(
113        config,
114        Router::new(),
115        |info| println!("rpi HTTP API listening on {}", info.address),
116        async {
117            let _ = tokio::signal::ctrl_c().await;
118        },
119    )
120    .await
121}
122pub async fn serve_until(
123    config: AppConfig,
124    frontend: Router,
125    on_listen: impl FnOnce(Listening),
126    stop: impl std::future::Future<Output = ()> + Send + 'static,
127) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
128    let mut config = config;
129    let requested = SocketAddr::new(config.bind.parse()?, config.port);
130    let listener = tokio::net::TcpListener::bind(requested)
131        .await
132        .map_err(|error| {
133            std::io::Error::new(
134                error.kind(),
135                format!("cannot listen on {requested}: {error}"),
136            )
137        })?;
138    let address = listener.local_addr()?;
139    config.port = address.port();
140    let backend = Service::new(BackendConfig {
141        project: config.project.clone(),
142        projects: config.projects.clone(),
143        projects_root: config.projects_root.clone(),
144        rpi_bin: config.rpi_bin.clone(),
145        session_dir: config.session_dir.clone(),
146        registry: config.registry.clone(),
147    })?;
148    let hosts = allowed_hosts(&config);
149    let password = configured_password(&config);
150    let security = Arc::new(Security::new(hosts.clone(), password.as_deref()));
151    let (shutdown, receiver) = watch::channel(false);
152    let state = AppState {
153        backend,
154        security: security.clone(),
155        shutdown: receiver,
156        service_name: config.service_name,
157    };
158    let router = routes(state.clone())
159        .merge(frontend)
160        .layer(from_fn_with_state(security, crate::security::guard));
161    on_listen(Listening {
162        address,
163        hosts,
164        password_enabled: state.security.enabled(),
165    });
166    std::io::Write::flush(&mut std::io::stdout())?;
167    let mut shutdown_receiver = state.shutdown.clone();
168    let serving = std::future::IntoFuture::into_future(
169        axum::serve(listener, router).with_graceful_shutdown(async move {
170            if !*shutdown_receiver.borrow_and_update() {
171                let _ = shutdown_receiver.changed().await;
172            }
173        }),
174    );
175    tokio::pin!(serving);
176    tokio::select! {
177        result = &mut serving => result?,
178        _ = stop => {
179            println!("  Status      Stopping...");
180            let _ = shutdown.send(true);
181            let _ = tokio::time::timeout(std::time::Duration::from_secs(8), async {
182                state.backend.shutdown().await;
183                let _ = serving.await;
184            }).await;
185        }
186    }
187    println!("  Status      Stopped");
188    Ok(())
189}
190
191/// Build only the API routes around an existing shared service.
192/// The port must be the actual listener port when validating Host headers.
193pub fn router(
194    backend: Arc<Service>,
195    config: &AppConfig,
196    shutdown: watch::Receiver<bool>,
197) -> Router {
198    let security = Arc::new(Security::new(
199        allowed_hosts(config),
200        configured_password(config).as_deref(),
201    ));
202    let state = AppState {
203        backend,
204        security: security.clone(),
205        shutdown,
206        service_name: config.service_name,
207    };
208    routes(state).layer(from_fn_with_state(security, crate::security::guard))
209}
210fn routes(state: AppState) -> Router {
211    Router::new()
212        .route("/api/v1/health", get(health))
213        .route("/api/v1/capabilities", get(capabilities))
214        .route("/api/v1/projects", get(list_projects).post(create_project))
215        .route("/api/v1/project-folders", get(list_project_folders))
216        .route("/api/v1/projects/{id}/files", get(project_files))
217        .route("/api/v1/projects/{id}/file", get(project_file))
218        .route("/api/v1/projects/{id}/git/status", get(project_status))
219        .route("/api/v1/projects/{id}/git/diff", get(project_diff))
220        .route(
221            "/api/v1/auth",
222            get(auth_status).post(auth_login).delete(auth_logout),
223        )
224        .route("/api/v1/auth/refresh", post(refresh_auth))
225        .route("/api/v1/sessions", post(create_session).get(list_sessions))
226        .route(
227            "/api/v1/sessions/{id}",
228            get(get_session).delete(delete_session),
229        )
230        .route("/api/v1/sessions/{id}/open", post(open_session))
231        .route("/api/v1/sessions/{id}/fork", post(fork_session))
232        .route("/api/v1/sessions/{id}/clone", post(clone_session))
233        .route("/api/v1/sessions/{id}/rename", post(rename_session))
234        .route("/api/v1/sessions/{id}/metadata", post(session_metadata))
235        .route("/api/v1/sessions/{id}/commands", post(command))
236        .route("/api/v1/sessions/{id}/snapshot", get(snapshot))
237        .route(
238            "/api/v1/sessions/{id}/durable",
239            get(durable_state).post(durable_mutation),
240        )
241        .route("/api/v1/sessions/{id}/durable/events", get(durable_events))
242        .route("/api/v1/sessions/{id}/events", get(events))
243        .with_state(state)
244}
245
246fn configured_password(config: &AppConfig) -> Option<String> {
247    let (primary, legacy) = if config.service_name == "rpi-web" {
248        ("RPI_WEB_PASSWORD", "RPI_HTTP_PASSWORD")
249    } else {
250        ("RPI_HTTP_PASSWORD", "RPI_WEB_PASSWORD")
251    };
252    std::env::var(primary)
253        .or_else(|_| std::env::var(legacy))
254        .ok()
255        .map(|value| value.trim().to_string())
256        .filter(|value| !value.is_empty())
257}
258
259fn allowed_hosts(config: &AppConfig) -> Vec<String> {
260    let (primary, legacy) = if config.service_name == "rpi-web" {
261        ("RPI_WEB_ALLOWED_HOSTS", "RPI_HTTP_ALLOWED_HOSTS")
262    } else {
263        ("RPI_HTTP_ALLOWED_HOSTS", "RPI_WEB_ALLOWED_HOSTS")
264    };
265    std::env::var(primary)
266        .or_else(|_| std::env::var(legacy))
267        .ok()
268        .map(|value| {
269            value
270                .split(',')
271                .map(str::trim)
272                .filter(|host| !host.is_empty())
273                .map(str::to_string)
274                .collect()
275        })
276        .filter(|hosts: &Vec<String>| !hosts.is_empty())
277        .or_else(|| config.allowed_hosts.clone())
278        .unwrap_or_else(|| {
279            let mut hosts = vec![
280                format!("127.0.0.1:{}", config.port),
281                format!("localhost:{}", config.port),
282            ];
283            if let Ok(ip) = config.bind.parse::<std::net::IpAddr>() {
284                if ip.is_ipv6() {
285                    hosts.push(format!("[::1]:{}", config.port));
286                }
287                if !ip.is_unspecified() {
288                    let authority = SocketAddr::new(ip, config.port).to_string();
289                    if !hosts.contains(&authority) {
290                        hosts.push(authority);
291                    }
292                }
293            }
294            hosts
295        })
296}
297
298async fn auth_status(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Json<Value> {
299    security::status_for(&state.security, &headers).await
300}
301
302async fn auth_login(
303    State(state): State<AppState>,
304    headers: axum::http::HeaderMap,
305    Json(request): Json<security::LoginRequest>,
306) -> impl IntoResponse {
307    security::login_for(&state.security, &headers, request).await
308}
309
310async fn auth_logout(
311    State(state): State<AppState>,
312    headers: axum::http::HeaderMap,
313) -> axum::response::Response {
314    security::logout_for(&state.security, &headers).await
315}
316
317async fn health(State(state): State<AppState>) -> Json<Value> {
318    Json(json!({"ok":true,"service":state.service_name,"protocol":"rpi"}))
319}
320async fn capabilities() -> Json<Value> {
321    Json(protocol::capabilities())
322}
323async fn list_projects(State(state): State<AppState>) -> axum::response::Response {
324    HttpReply(service::list_projects(&state.backend).await.into_reply()).into_response()
325}
326
327async fn list_project_folders(
328    State(state): State<AppState>,
329    Query(query): Query<ProjectFoldersQuery>,
330) -> axum::response::Response {
331    HttpReply(
332        service::list_project_folders(&state.backend, query)
333            .await
334            .into_reply(),
335    )
336    .into_response()
337}
338
339async fn create_project(
340    State(state): State<AppState>,
341    Json(request): Json<CreateProjectRequest>,
342) -> axum::response::Response {
343    HttpReply(
344        service::create_project(&state.backend, request)
345            .await
346            .into_reply(),
347    )
348    .into_response()
349}
350
351async fn project_files(
352    State(state): State<AppState>,
353    Path(id): Path<String>,
354    Query(query): Query<FileQuery>,
355) -> axum::response::Response {
356    HttpReply(
357        service::project_files(&state.backend, id, query)
358            .await
359            .into_reply(),
360    )
361    .into_response()
362}
363
364async fn project_file(
365    State(state): State<AppState>,
366    Path(id): Path<String>,
367    Query(query): Query<FileQuery>,
368) -> axum::response::Response {
369    HttpReply(
370        service::project_file(&state.backend, id, query)
371            .await
372            .into_reply(),
373    )
374    .into_response()
375}
376
377async fn project_status(
378    State(state): State<AppState>,
379    Path(id): Path<String>,
380) -> axum::response::Response {
381    HttpReply(
382        service::project_status(&state.backend, id)
383            .await
384            .into_reply(),
385    )
386    .into_response()
387}
388
389async fn project_diff(
390    State(state): State<AppState>,
391    Path(id): Path<String>,
392    Query(query): Query<FileQuery>,
393) -> axum::response::Response {
394    HttpReply(
395        service::project_diff(&state.backend, id, query)
396            .await
397            .into_reply(),
398    )
399    .into_response()
400}
401
402async fn refresh_auth(
403    State(state): State<AppState>,
404    Json(request): Json<AuthRefreshRequest>,
405) -> axum::response::Response {
406    HttpReply(
407        service::refresh_auth(&state.backend, request)
408            .await
409            .into_reply(),
410    )
411    .into_response()
412}
413
414async fn create_session(
415    State(state): State<AppState>,
416    body: axum::body::Bytes,
417) -> axum::response::Response {
418    let request: CreateSessionRequest = match optional_json(&body) {
419        Ok(request) => request,
420        Err(error) => return error.into_response(),
421    };
422    HttpReply(
423        service::create_session(&state.backend, request)
424            .await
425            .into_reply(),
426    )
427    .into_response()
428}
429
430async fn rename_session(
431    State(state): State<AppState>,
432    Path(id): Path<String>,
433    Json(request): Json<RenameRequest>,
434) -> axum::response::Response {
435    HttpReply(
436        service::rename_session(&state.backend, id, request)
437            .await
438            .into_reply(),
439    )
440    .into_response()
441}
442
443async fn session_metadata(
444    State(state): State<AppState>,
445    Path(id): Path<String>,
446    Json(request): Json<MetadataRequest>,
447) -> axum::response::Response {
448    HttpReply(
449        service::session_metadata(&state.backend, id, request)
450            .await
451            .into_reply(),
452    )
453    .into_response()
454}
455
456async fn fork_session(
457    State(state): State<AppState>,
458    Path(id): Path<String>,
459    Json(request): Json<ForkRequest>,
460) -> axum::response::Response {
461    HttpReply(
462        service::fork_session(&state.backend, id, request)
463            .await
464            .into_reply(),
465    )
466    .into_response()
467}
468
469async fn clone_session(
470    State(state): State<AppState>,
471    Path(id): Path<String>,
472    body: axum::body::Bytes,
473) -> axum::response::Response {
474    let request: ForkRequest = match optional_json(&body) {
475        Ok(request) => request,
476        Err(error) => return error.into_response(),
477    };
478    HttpReply(
479        service::clone_session(&state.backend, id, request)
480            .await
481            .into_reply(),
482    )
483    .into_response()
484}
485
486async fn open_session(
487    State(state): State<AppState>,
488    Path(id): Path<String>,
489) -> axum::response::Response {
490    HttpReply(service::open_session(&state.backend, id).await.into_reply()).into_response()
491}
492
493async fn list_sessions(
494    State(state): State<AppState>,
495    Query(query): Query<SessionListQuery>,
496) -> axum::response::Response {
497    HttpReply(
498        service::list_sessions(&state.backend, query)
499            .await
500            .into_reply(),
501    )
502    .into_response()
503}
504
505async fn get_session(
506    State(state): State<AppState>,
507    Path(id): Path<String>,
508) -> axum::response::Response {
509    HttpReply(service::get_session(&state.backend, id).await.into_reply()).into_response()
510}
511
512async fn delete_session(
513    State(state): State<AppState>,
514    Path(id): Path<String>,
515) -> axum::response::Response {
516    HttpReply(
517        service::delete_session(&state.backend, id)
518            .await
519            .into_reply(),
520    )
521    .into_response()
522}
523
524async fn command(
525    State(state): State<AppState>,
526    Path(id): Path<String>,
527    Json(command): Json<ApiCommand>,
528) -> axum::response::Response {
529    HttpReply(
530        service::command(&state.backend, id, command)
531            .await
532            .into_reply(),
533    )
534    .into_response()
535}
536
537#[derive(Debug, Deserialize)]
538#[serde(rename_all = "camelCase", deny_unknown_fields)]
539struct DurableMutationRequest {
540    operation: String,
541    #[serde(default)]
542    text: Option<String>,
543    #[serde(default)]
544    item_id: Option<String>,
545    #[serde(default)]
546    status: Option<String>,
547    #[serde(default)]
548    note: Option<String>,
549}
550
551fn parse_durable_mutation(request: DurableMutationRequest) -> Result<DurableMutation, String> {
552    let item_id = || {
553        request
554            .item_id
555            .clone()
556            .filter(|value| !value.trim().is_empty())
557            .ok_or_else(|| "itemId is required".to_string())
558    };
559    match request.operation.trim() {
560        "start_run" => Ok(DurableMutation::StartRun),
561        "settle_run" => Ok(DurableMutation::SettleRun),
562        "recover_run" => Ok(DurableMutation::RecoverRun),
563        "enqueue" => Ok(DurableMutation::Enqueue {
564            text: request
565                .text
566                .filter(|value| !value.trim().is_empty())
567                .ok_or_else(|| "text is required".to_string())?,
568        }),
569        "claim" => Ok(DurableMutation::Claim { id: item_id()? }),
570        "finish" => {
571            let status = match request.status.as_deref() {
572                Some("completed") => QueueStatus::Completed,
573                Some("failed") => QueueStatus::Failed,
574                Some(other) => return Err(format!("invalid finish status: {other}")),
575                None => return Err("status is required for finish".to_string()),
576            };
577            Ok(DurableMutation::Finish {
578                id: item_id()?,
579                status,
580                note: request.note.unwrap_or_default(),
581            })
582        }
583        "cancel" => Ok(DurableMutation::Cancel {
584            id: item_id()?,
585            note: request.note.unwrap_or_default(),
586        }),
587        other => Err(format!("unsupported durable operation: {other}")),
588    }
589}
590
591async fn durable_state(
592    State(state): State<AppState>,
593    Path(id): Path<String>,
594) -> axum::response::Response {
595    HttpReply(
596        service::durable_state(&state.backend, id)
597            .await
598            .into_reply(),
599    )
600    .into_response()
601}
602
603async fn durable_mutation(
604    State(state): State<AppState>,
605    Path(id): Path<String>,
606    Json(request): Json<DurableMutationRequest>,
607) -> axum::response::Response {
608    let mutation = match parse_durable_mutation(request) {
609        Ok(mutation) => mutation,
610        Err(error) => {
611            return (StatusCode::BAD_REQUEST, Json(json!({"error": error}))).into_response()
612        }
613    };
614    HttpReply(
615        service::mutate_durable(&state.backend, id, mutation)
616            .await
617            .into_reply(),
618    )
619    .into_response()
620}
621
622async fn snapshot(
623    State(state): State<AppState>,
624    Path(id): Path<String>,
625    Query(options): Query<service::SnapshotOptions>,
626) -> axum::response::Response {
627    HttpReply(
628        service::snapshot_with_options(&state.backend, id, options)
629            .await
630            .into_reply(),
631    )
632    .into_response()
633}
634
635// An absent body keeps the legacy default, but a malformed supplied body must
636// never turn into a successful creation with silently discarded parameters.
637fn optional_json<T: DeserializeOwned + Default>(
638    body: &[u8],
639) -> Result<T, (StatusCode, Json<Value>)> {
640    if body.is_empty() {
641        return Ok(T::default());
642    }
643    serde_json::from_slice(body).map_err(|_| {
644        (
645            StatusCode::BAD_REQUEST,
646            Json(json!({"error":"invalid JSON request body"})),
647        )
648    })
649}
650
651#[derive(Debug, serde::Deserialize)]
652struct EventQuery {
653    after: Option<u64>,
654}
655
656async fn durable_events(
657    State(state): State<AppState>,
658    Path(id): Path<String>,
659    headers: axum::http::HeaderMap,
660    Query(query): Query<EventQuery>,
661) -> axum::response::Response {
662    let after = headers
663        .get("last-event-id")
664        .and_then(|value| value.to_str().ok())
665        .and_then(|value| value.parse::<u64>().ok())
666        .or(query.after);
667    let Some(subscription) = state.backend.durable_subscription(&id, after).await else {
668        let status = if state.backend.live_session(&id).await.is_some() {
669            StatusCode::SERVICE_UNAVAILABLE
670        } else {
671            StatusCode::NOT_FOUND
672        };
673        return (
674            status,
675            Json(json!({
676                "error": if status == StatusCode::NOT_FOUND {
677                    "session not found"
678                } else {
679                    "durable unavailable"
680                },
681                "code": if status == StatusCode::NOT_FOUND {
682                    "session_not_found"
683                } else {
684                    "durable_unavailable"
685                }
686            })),
687        )
688            .into_response();
689    };
690    let mut shutdown = state.shutdown;
691    Sse::new(
692        authorized_events(
693            durable_event_stream(id, subscription),
694            state.security,
695            headers,
696        )
697        .take_until(async move {
698            if !*shutdown.borrow_and_update() {
699                let _ = shutdown.changed().await;
700            }
701        }),
702    )
703    .keep_alive(axum::response::sse::KeepAlive::default())
704    .into_response()
705}
706
707fn durable_event_stream(
708    session_id: String,
709    subscription: rpi_extcore::durable::DurableSubscription,
710) -> impl Stream<Item = Result<Event, Infallible>> {
711    let rpi_extcore::durable::DurableSubscription {
712        ready,
713        replay,
714        receiver,
715        gap,
716        after,
717    } = subscription;
718    let mut head = Vec::new();
719    if after.is_none() {
720        head.push(Ok(Event::default()
721            .event("durable_ready")
722            .id(ready.revision.to_string())
723            .data(
724                json!({
725                    "sessionId": session_id,
726                    "revision": ready.revision,
727                    "state": ready,
728                })
729                .to_string(),
730            )));
731    }
732    if gap {
733        head.push(Ok(Event::default().event("resync_required").data(
734            json!({
735                "type": "resync_required",
736                "code": "durable_event_gap"
737            })
738            .to_string(),
739        )));
740    }
741    stream::iter(head).chain(
742        rpi_extcore::durable::replay_changes(replay, receiver, after.or(Some(ready.revision))).map(
743            move |item| {
744                Ok(match item {
745                    rpi_extcore::durable::DurableStreamItem::Change(change) => Event::default()
746                        .event("durable_change")
747                        .id(change.revision.to_string())
748                        .data(
749                            json!({
750                                "sessionId": session_id,
751                                "revision": change.revision,
752                                "state": change.state,
753                            })
754                            .to_string(),
755                        ),
756                    rpi_extcore::durable::DurableStreamItem::ResyncRequired { dropped } => {
757                        Event::default().event("resync_required").data(
758                            json!({
759                                "type": "resync_required",
760                                "code": "durable_event_stream_lagged",
761                                "dropped": dropped
762                            })
763                            .to_string(),
764                        )
765                    }
766                })
767            },
768        ),
769    )
770}
771
772async fn events(
773    State(state): State<AppState>,
774    Path(id): Path<String>,
775    headers: axum::http::HeaderMap,
776    Query(query): Query<EventQuery>,
777) -> axum::response::Response {
778    let after = headers
779        .get("last-event-id")
780        .and_then(|value| value.to_str().ok())
781        .and_then(|value| value.parse::<u64>().ok())
782        .or(query.after);
783    let Some(subscription) = state.backend.subscribe_events(&id, after).await else {
784        return (
785            StatusCode::NOT_FOUND,
786            Json(json!({"error":"session not found"})),
787        )
788            .into_response();
789    };
790    let mut shutdown = state.shutdown;
791    Sse::new(
792        authorized_events(
793            event_stream(
794                subscription.ready,
795                subscription.replay,
796                subscription.receiver,
797                subscription.gap,
798                subscription.after,
799            ),
800            state.security,
801            headers,
802        )
803        .take_until(async move {
804            if !*shutdown.borrow_and_update() {
805                let _ = shutdown.changed().await;
806            }
807        }),
808    )
809    .keep_alive(axum::response::sse::KeepAlive::default())
810    .into_response()
811}
812
813fn event_stream(
814    ready: Value,
815    replay: Vec<BufferedEvent>,
816    receiver: broadcast::Receiver<BufferedEvent>,
817    gap: bool,
818    after: Option<u64>,
819) -> impl Stream<Item = Result<Event, Infallible>> {
820    let head: Vec<Result<Event, Infallible>> =
821        std::iter::once(Ok(Event::default().event("ready").data(ready.to_string())))
822            .chain(gap.then(|| {
823                Ok(Event::default()
824                    .event("resync_required")
825                    .data("{\"type\":\"resync_required\"}"))
826            }))
827            .collect();
828    stream::iter(head).chain(rpi_extcore::events::replay_stream(replay,receiver,after).map(|item| {
829        Ok(match item {
830            rpi_extcore::events::StreamItem::Event(event)=>to_sse(event),
831            rpi_extcore::events::StreamItem::ResyncRequired{dropped}=>Event::default().event("resync_required").data(json!({"type":"resync_required","code":"event_stream_lagged","dropped":dropped}).to_string()),
832        })
833    }))
834}
835
836// The middleware checks only the initial HTTP request. A long-lived stream must
837// also stop after another tab logs out or the authentication session expires.
838fn authorized_events(
839    events: impl Stream<Item = Result<Event, Infallible>> + Send + 'static,
840    security: Arc<Security>,
841    headers: axum::http::HeaderMap,
842) -> impl Stream<Item = Result<Event, Infallible>> {
843    let expiry_check = tokio::time::interval(std::time::Duration::from_secs(10));
844    stream::unfold(
845        (Box::pin(events), expiry_check),
846        move |(mut events, mut expiry_check)| {
847            let security = security.clone();
848            let headers = headers.clone();
849            async move {
850                loop {
851                    tokio::select! {
852                        event = events.next() => {
853                            let event = event?;
854                            if !security.authorized(&headers).await { return None; }
855                            return Some((event, (events, expiry_check)));
856                        }
857                        _ = expiry_check.tick() => {
858                            if !security.authorized(&headers).await { return None; }
859                        }
860                    }
861                }
862            }
863        },
864    )
865}
866
867fn to_sse(mut event: BufferedEvent) -> Event {
868    rpi_extcore::timing::refresh_event(&mut event.value);
869    let event_type = event
870        .value
871        .get("type")
872        .and_then(Value::as_str)
873        .unwrap_or("message");
874    Event::default()
875        .event(event_type)
876        .id(event.seq.to_string())
877        .data(event.value.to_string())
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883
884    #[test]
885    fn durable_requests_map_to_protocol_neutral_mutations() {
886        let mutation = parse_durable_mutation(DurableMutationRequest {
887            operation: "enqueue".into(),
888            text: Some("run tests".into()),
889            item_id: None,
890            status: None,
891            note: None,
892        })
893        .unwrap();
894        assert!(matches!(mutation, DurableMutation::Enqueue { text } if text == "run tests"));
895
896        let mutation = parse_durable_mutation(DurableMutationRequest {
897            operation: "finish".into(),
898            text: None,
899            item_id: Some("item-1".into()),
900            status: Some("completed".into()),
901            note: Some("passed".into()),
902        })
903        .unwrap();
904        assert!(matches!(
905            mutation,
906            DurableMutation::Finish {
907                id,
908                status: QueueStatus::Completed,
909                note
910            } if id == "item-1" && note == "passed"
911        ));
912    }
913
914    #[test]
915    fn durable_requests_reject_invalid_operations_and_statuses() {
916        let error = parse_durable_mutation(DurableMutationRequest {
917            operation: "finish".into(),
918            text: None,
919            item_id: Some("item-1".into()),
920            status: Some("queued".into()),
921            note: None,
922        })
923        .unwrap_err();
924        assert!(error.contains("invalid finish status"));
925    }
926
927    #[test]
928    fn optional_creation_bodies_reject_invalid_json_and_types() {
929        assert!(optional_json::<CreateSessionRequest>(b"").is_ok());
930        assert!(optional_json::<CreateSessionRequest>(br#"{"projectId":"project-a"}"#).is_ok());
931        for body in [b"{".as_slice(), b"null", br#"{"projectId":42}"#] {
932            assert_eq!(
933                optional_json::<CreateSessionRequest>(body).unwrap_err().0,
934                StatusCode::BAD_REQUEST
935            );
936        }
937        assert!(optional_json::<ForkRequest>(b"").is_ok());
938        assert!(optional_json::<ForkRequest>(br#"{"entryId":42}"#).is_err());
939    }
940
941    #[tokio::test]
942    async fn live_events_do_not_duplicate_replay_or_the_snapshot_cursor() {
943        for replay in [
944            vec![],
945            vec![BufferedEvent {
946                seq: 5,
947                value: json!({"type":"agent_start"}),
948            }],
949        ] {
950            let (sender, receiver) = broadcast::channel(8);
951            for seq in [4, 5, 6] {
952                sender
953                    .send(BufferedEvent {
954                        seq,
955                        value: json!({"type":"agent_start"}),
956                    })
957                    .unwrap();
958            }
959            drop(sender);
960            let has_replay = !replay.is_empty();
961            let stream = event_stream(json!({"type":"ready"}), replay, receiver, false, Some(5));
962            let body = axum::body::to_bytes(Sse::new(stream).into_response().into_body(), 4096)
963                .await
964                .unwrap();
965            let text = String::from_utf8(body.to_vec()).unwrap();
966            assert!(!text.contains("id: 4\n"));
967            assert_eq!(text.matches("id: 5\n").count(), usize::from(has_replay));
968            assert_eq!(text.matches("id: 6\n").count(), 1);
969        }
970    }
971
972    #[tokio::test]
973    async fn durable_events_emit_ready_changes_and_reconnect_replay() {
974        use rpi_extcore::durable::{DurableChange, DurableState, DurableSubscription};
975
976        let (sender, receiver) = broadcast::channel(8);
977        sender
978            .send(DurableChange {
979                revision: 1,
980                state: DurableState {
981                    revision: 1,
982                    ..DurableState::default()
983                },
984            })
985            .unwrap();
986        drop(sender);
987        let body = axum::body::to_bytes(
988            Sse::new(durable_event_stream(
989                "session-1".into(),
990                DurableSubscription {
991                    ready: DurableState::default(),
992                    replay: vec![],
993                    receiver,
994                    gap: false,
995                    after: None,
996                },
997            ))
998            .into_response()
999            .into_body(),
1000            4096,
1001        )
1002        .await
1003        .unwrap();
1004        let text = String::from_utf8(body.to_vec()).unwrap();
1005        assert!(text.contains("event: durable_ready\n"));
1006        assert!(text.contains("event: durable_change\n"));
1007        assert!(text.contains("id: 1\n"));
1008
1009        let (sender, receiver) = broadcast::channel(8);
1010        drop(sender);
1011        let body = axum::body::to_bytes(
1012            Sse::new(durable_event_stream(
1013                "session-1".into(),
1014                DurableSubscription {
1015                    ready: DurableState {
1016                        revision: 1,
1017                        ..DurableState::default()
1018                    },
1019                    replay: vec![DurableChange {
1020                        revision: 1,
1021                        state: DurableState {
1022                            revision: 1,
1023                            ..DurableState::default()
1024                        },
1025                    }],
1026                    receiver,
1027                    gap: false,
1028                    after: Some(0),
1029                },
1030            ))
1031            .into_response()
1032            .into_body(),
1033            4096,
1034        )
1035        .await
1036        .unwrap();
1037        let text = String::from_utf8(body.to_vec()).unwrap();
1038        assert!(!text.contains("event: durable_ready\n"));
1039        assert_eq!(text.matches("id: 1\n").count(), 1);
1040    }
1041
1042    #[tokio::test]
1043    async fn logout_revokes_an_already_open_event_stream() {
1044        let security = Arc::new(Security::new(["example.test".into()], Some("secret")));
1045        let token = security.login("secret").await.unwrap();
1046        let mut headers = axum::http::HeaderMap::new();
1047        headers.insert(
1048            axum::http::header::COOKIE,
1049            format!("rpi_web_session={token}").parse().unwrap(),
1050        );
1051        let (sender, receiver) = broadcast::channel(8);
1052        let stream = authorized_events(
1053            event_stream(json!({"type":"ready"}), vec![], receiver, false, None),
1054            security.clone(),
1055            headers.clone(),
1056        );
1057        futures_util::pin_mut!(stream);
1058        assert!(stream.next().await.is_some());
1059        security.logout(&headers).await;
1060        sender
1061            .send(BufferedEvent {
1062                seq: 1,
1063                value: json!({"type":"message_end","message":"private"}),
1064            })
1065            .unwrap();
1066        assert!(stream.next().await.is_none());
1067    }
1068}