Skip to main content

rpi_http/
server.rs

1use crate::security::{self, Security};
2use axum::{
3    extract::{DefaultBodyLimit, Multipart, 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, RenameProjectRequest,
25        RenameRequest, Service, 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}/rename", post(rename_project))
217        .route("/api/v1/projects/{id}/files", get(project_files))
218        .route(
219            "/api/v1/projects/{id}/upload",
220            post(upload_project_file).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
221        )
222        .route("/api/v1/projects/{id}/file", get(project_file))
223        .route("/api/v1/projects/{id}/git/status", get(project_status))
224        .route("/api/v1/projects/{id}/git/diff", get(project_diff))
225        .route(
226            "/api/v1/auth",
227            get(auth_status).post(auth_login).delete(auth_logout),
228        )
229        .route("/api/v1/auth/refresh", post(refresh_auth))
230        .route("/api/v1/sessions", post(create_session).get(list_sessions))
231        .route(
232            "/api/v1/sessions/{id}",
233            get(get_session).delete(delete_session),
234        )
235        .route("/api/v1/sessions/{id}/open", post(open_session))
236        .route("/api/v1/sessions/{id}/fork", post(fork_session))
237        .route("/api/v1/sessions/{id}/clone", post(clone_session))
238        .route("/api/v1/sessions/{id}/rename", post(rename_session))
239        .route("/api/v1/sessions/{id}/metadata", post(session_metadata))
240        .route("/api/v1/sessions/{id}/commands", post(command))
241        .route("/api/v1/sessions/{id}/snapshot", get(snapshot))
242        .route(
243            "/api/v1/sessions/{id}/durable",
244            get(durable_state).post(durable_mutation),
245        )
246        .route("/api/v1/sessions/{id}/durable/events", get(durable_events))
247        .route("/api/v1/sessions/{id}/events", get(events))
248        .with_state(state)
249}
250
251async fn upload_project_file(
252    State(state): State<AppState>,
253    Path(id): Path<String>,
254    mut multipart: Multipart,
255) -> HttpReply {
256    let Some(field) = (match multipart.next_field().await {
257        Ok(field) => field,
258        Err(error) => {
259            return HttpReply(
260                (
261                    ReplyKind::InvalidInput,
262                    json!({"error": format!("invalid upload: {error}")}),
263                )
264                    .into_reply(),
265            );
266        }
267    }) else {
268        return HttpReply(
269            (ReplyKind::InvalidInput, json!({"error":"file is required"})).into_reply(),
270        );
271    };
272    let filename = field.file_name().unwrap_or("upload").to_string();
273    let bytes = match field.bytes().await {
274        Ok(bytes) => bytes.to_vec(),
275        Err(error) => {
276            return HttpReply(
277                (
278                    ReplyKind::InvalidInput,
279                    json!({"error": format!("cannot read upload: {error}")}),
280                )
281                    .into_reply(),
282            );
283        }
284    };
285    HttpReply(
286        service::upload_project_file(&state.backend, id, filename, bytes)
287            .await
288            .into_reply(),
289    )
290}
291fn configured_password(config: &AppConfig) -> Option<String> {
292    let (primary, legacy) = if config.service_name == "rpi-web" {
293        ("RPI_WEB_PASSWORD", "RPI_HTTP_PASSWORD")
294    } else {
295        ("RPI_HTTP_PASSWORD", "RPI_WEB_PASSWORD")
296    };
297    std::env::var(primary)
298        .or_else(|_| std::env::var(legacy))
299        .ok()
300        .map(|value| value.trim().to_string())
301        .filter(|value| !value.is_empty())
302}
303
304fn allowed_hosts(config: &AppConfig) -> Vec<String> {
305    let (primary, legacy) = if config.service_name == "rpi-web" {
306        ("RPI_WEB_ALLOWED_HOSTS", "RPI_HTTP_ALLOWED_HOSTS")
307    } else {
308        ("RPI_HTTP_ALLOWED_HOSTS", "RPI_WEB_ALLOWED_HOSTS")
309    };
310    std::env::var(primary)
311        .or_else(|_| std::env::var(legacy))
312        .ok()
313        .map(|value| {
314            value
315                .split(',')
316                .map(str::trim)
317                .filter(|host| !host.is_empty())
318                .map(str::to_string)
319                .collect()
320        })
321        .filter(|hosts: &Vec<String>| !hosts.is_empty())
322        .or_else(|| config.allowed_hosts.clone())
323        .unwrap_or_else(|| {
324            let mut hosts = vec![
325                format!("127.0.0.1:{}", config.port),
326                format!("localhost:{}", config.port),
327            ];
328            if let Ok(ip) = config.bind.parse::<std::net::IpAddr>() {
329                if ip.is_ipv6() {
330                    hosts.push(format!("[::1]:{}", config.port));
331                }
332                if !ip.is_unspecified() {
333                    let authority = SocketAddr::new(ip, config.port).to_string();
334                    if !hosts.contains(&authority) {
335                        hosts.push(authority);
336                    }
337                }
338            }
339            hosts
340        })
341}
342
343async fn auth_status(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Json<Value> {
344    security::status_for(&state.security, &headers).await
345}
346
347async fn auth_login(
348    State(state): State<AppState>,
349    headers: axum::http::HeaderMap,
350    Json(request): Json<security::LoginRequest>,
351) -> impl IntoResponse {
352    security::login_for(&state.security, &headers, request).await
353}
354
355async fn auth_logout(
356    State(state): State<AppState>,
357    headers: axum::http::HeaderMap,
358) -> axum::response::Response {
359    security::logout_for(&state.security, &headers).await
360}
361
362async fn health(State(state): State<AppState>) -> Json<Value> {
363    Json(json!({"ok":true,"service":state.service_name,"protocol":"rpi"}))
364}
365async fn capabilities() -> Json<Value> {
366    Json(protocol::capabilities())
367}
368async fn list_projects(State(state): State<AppState>) -> axum::response::Response {
369    HttpReply(service::list_projects(&state.backend).await.into_reply()).into_response()
370}
371
372async fn list_project_folders(
373    State(state): State<AppState>,
374    Query(query): Query<ProjectFoldersQuery>,
375) -> axum::response::Response {
376    HttpReply(
377        service::list_project_folders(&state.backend, query)
378            .await
379            .into_reply(),
380    )
381    .into_response()
382}
383
384async fn create_project(
385    State(state): State<AppState>,
386    Json(request): Json<CreateProjectRequest>,
387) -> axum::response::Response {
388    HttpReply(
389        service::create_project(&state.backend, request)
390            .await
391            .into_reply(),
392    )
393    .into_response()
394}
395
396async fn rename_project(
397    State(state): State<AppState>,
398    Path(id): Path<String>,
399    Json(request): Json<RenameProjectRequest>,
400) -> axum::response::Response {
401    HttpReply(
402        service::rename_project(&state.backend, id, request)
403            .await
404            .into_reply(),
405    )
406    .into_response()
407}
408
409async fn project_files(
410    State(state): State<AppState>,
411    Path(id): Path<String>,
412    Query(query): Query<FileQuery>,
413) -> axum::response::Response {
414    HttpReply(
415        service::project_files(&state.backend, id, query)
416            .await
417            .into_reply(),
418    )
419    .into_response()
420}
421
422async fn project_file(
423    State(state): State<AppState>,
424    Path(id): Path<String>,
425    Query(query): Query<FileQuery>,
426) -> axum::response::Response {
427    HttpReply(
428        service::project_file(&state.backend, id, query)
429            .await
430            .into_reply(),
431    )
432    .into_response()
433}
434
435async fn project_status(
436    State(state): State<AppState>,
437    Path(id): Path<String>,
438) -> axum::response::Response {
439    HttpReply(
440        service::project_status(&state.backend, id)
441            .await
442            .into_reply(),
443    )
444    .into_response()
445}
446
447async fn project_diff(
448    State(state): State<AppState>,
449    Path(id): Path<String>,
450    Query(query): Query<FileQuery>,
451) -> axum::response::Response {
452    HttpReply(
453        service::project_diff(&state.backend, id, query)
454            .await
455            .into_reply(),
456    )
457    .into_response()
458}
459
460async fn refresh_auth(
461    State(state): State<AppState>,
462    Json(request): Json<AuthRefreshRequest>,
463) -> axum::response::Response {
464    HttpReply(
465        service::refresh_auth(&state.backend, request)
466            .await
467            .into_reply(),
468    )
469    .into_response()
470}
471
472async fn create_session(
473    State(state): State<AppState>,
474    body: axum::body::Bytes,
475) -> axum::response::Response {
476    let request: CreateSessionRequest = match optional_json(&body) {
477        Ok(request) => request,
478        Err(error) => return error.into_response(),
479    };
480    HttpReply(
481        service::create_session(&state.backend, request)
482            .await
483            .into_reply(),
484    )
485    .into_response()
486}
487
488async fn rename_session(
489    State(state): State<AppState>,
490    Path(id): Path<String>,
491    Json(request): Json<RenameRequest>,
492) -> axum::response::Response {
493    HttpReply(
494        service::rename_session(&state.backend, id, request)
495            .await
496            .into_reply(),
497    )
498    .into_response()
499}
500
501async fn session_metadata(
502    State(state): State<AppState>,
503    Path(id): Path<String>,
504    Json(request): Json<MetadataRequest>,
505) -> axum::response::Response {
506    HttpReply(
507        service::session_metadata(&state.backend, id, request)
508            .await
509            .into_reply(),
510    )
511    .into_response()
512}
513
514async fn fork_session(
515    State(state): State<AppState>,
516    Path(id): Path<String>,
517    Json(request): Json<ForkRequest>,
518) -> axum::response::Response {
519    HttpReply(
520        service::fork_session(&state.backend, id, request)
521            .await
522            .into_reply(),
523    )
524    .into_response()
525}
526
527async fn clone_session(
528    State(state): State<AppState>,
529    Path(id): Path<String>,
530    body: axum::body::Bytes,
531) -> axum::response::Response {
532    let request: ForkRequest = match optional_json(&body) {
533        Ok(request) => request,
534        Err(error) => return error.into_response(),
535    };
536    HttpReply(
537        service::clone_session(&state.backend, id, request)
538            .await
539            .into_reply(),
540    )
541    .into_response()
542}
543
544async fn open_session(
545    State(state): State<AppState>,
546    Path(id): Path<String>,
547) -> axum::response::Response {
548    HttpReply(service::open_session(&state.backend, id).await.into_reply()).into_response()
549}
550
551async fn list_sessions(
552    State(state): State<AppState>,
553    Query(query): Query<SessionListQuery>,
554) -> axum::response::Response {
555    HttpReply(
556        service::list_sessions(&state.backend, query)
557            .await
558            .into_reply(),
559    )
560    .into_response()
561}
562
563async fn get_session(
564    State(state): State<AppState>,
565    Path(id): Path<String>,
566) -> axum::response::Response {
567    HttpReply(service::get_session(&state.backend, id).await.into_reply()).into_response()
568}
569
570async fn delete_session(
571    State(state): State<AppState>,
572    Path(id): Path<String>,
573) -> axum::response::Response {
574    HttpReply(
575        service::delete_session(&state.backend, id)
576            .await
577            .into_reply(),
578    )
579    .into_response()
580}
581
582async fn command(
583    State(state): State<AppState>,
584    Path(id): Path<String>,
585    Json(command): Json<ApiCommand>,
586) -> axum::response::Response {
587    HttpReply(
588        service::command(&state.backend, id, command)
589            .await
590            .into_reply(),
591    )
592    .into_response()
593}
594
595#[derive(Debug, Deserialize)]
596#[serde(rename_all = "camelCase", deny_unknown_fields)]
597struct DurableMutationRequest {
598    operation: String,
599    #[serde(default)]
600    text: Option<String>,
601    #[serde(default)]
602    item_id: Option<String>,
603    #[serde(default)]
604    status: Option<String>,
605    #[serde(default)]
606    note: Option<String>,
607}
608
609fn parse_durable_mutation(request: DurableMutationRequest) -> Result<DurableMutation, String> {
610    let item_id = || {
611        request
612            .item_id
613            .clone()
614            .filter(|value| !value.trim().is_empty())
615            .ok_or_else(|| "itemId is required".to_string())
616    };
617    match request.operation.trim() {
618        "start_run" => Ok(DurableMutation::StartRun),
619        "settle_run" => Ok(DurableMutation::SettleRun),
620        "recover_run" => Ok(DurableMutation::RecoverRun),
621        "enqueue" => Ok(DurableMutation::Enqueue {
622            text: request
623                .text
624                .filter(|value| !value.trim().is_empty())
625                .ok_or_else(|| "text is required".to_string())?,
626        }),
627        "claim" => Ok(DurableMutation::Claim { id: item_id()? }),
628        "finish" => {
629            let status = match request.status.as_deref() {
630                Some("completed") => QueueStatus::Completed,
631                Some("failed") => QueueStatus::Failed,
632                Some(other) => return Err(format!("invalid finish status: {other}")),
633                None => return Err("status is required for finish".to_string()),
634            };
635            Ok(DurableMutation::Finish {
636                id: item_id()?,
637                status,
638                note: request.note.unwrap_or_default(),
639            })
640        }
641        "cancel" => Ok(DurableMutation::Cancel {
642            id: item_id()?,
643            note: request.note.unwrap_or_default(),
644        }),
645        other => Err(format!("unsupported durable operation: {other}")),
646    }
647}
648
649async fn durable_state(
650    State(state): State<AppState>,
651    Path(id): Path<String>,
652) -> axum::response::Response {
653    HttpReply(
654        service::durable_state(&state.backend, id)
655            .await
656            .into_reply(),
657    )
658    .into_response()
659}
660
661async fn durable_mutation(
662    State(state): State<AppState>,
663    Path(id): Path<String>,
664    Json(request): Json<DurableMutationRequest>,
665) -> axum::response::Response {
666    let mutation = match parse_durable_mutation(request) {
667        Ok(mutation) => mutation,
668        Err(error) => {
669            return (StatusCode::BAD_REQUEST, Json(json!({"error": error}))).into_response()
670        }
671    };
672    HttpReply(
673        service::mutate_durable(&state.backend, id, mutation)
674            .await
675            .into_reply(),
676    )
677    .into_response()
678}
679
680async fn snapshot(
681    State(state): State<AppState>,
682    Path(id): Path<String>,
683    Query(options): Query<service::SnapshotOptions>,
684) -> axum::response::Response {
685    HttpReply(
686        service::snapshot_with_options(&state.backend, id, options)
687            .await
688            .into_reply(),
689    )
690    .into_response()
691}
692
693// An absent body keeps the legacy default, but a malformed supplied body must
694// never turn into a successful creation with silently discarded parameters.
695fn optional_json<T: DeserializeOwned + Default>(
696    body: &[u8],
697) -> Result<T, (StatusCode, Json<Value>)> {
698    if body.is_empty() {
699        return Ok(T::default());
700    }
701    serde_json::from_slice(body).map_err(|_| {
702        (
703            StatusCode::BAD_REQUEST,
704            Json(json!({"error":"invalid JSON request body"})),
705        )
706    })
707}
708
709#[derive(Debug, serde::Deserialize)]
710struct EventQuery {
711    after: Option<u64>,
712}
713
714async fn durable_events(
715    State(state): State<AppState>,
716    Path(id): Path<String>,
717    headers: axum::http::HeaderMap,
718    Query(query): Query<EventQuery>,
719) -> axum::response::Response {
720    let after = headers
721        .get("last-event-id")
722        .and_then(|value| value.to_str().ok())
723        .and_then(|value| value.parse::<u64>().ok())
724        .or(query.after);
725    let Some(subscription) = state.backend.durable_subscription(&id, after).await else {
726        let status = if state.backend.live_session(&id).await.is_some() {
727            StatusCode::SERVICE_UNAVAILABLE
728        } else {
729            StatusCode::NOT_FOUND
730        };
731        return (
732            status,
733            Json(json!({
734                "error": if status == StatusCode::NOT_FOUND {
735                    "session not found"
736                } else {
737                    "durable unavailable"
738                },
739                "code": if status == StatusCode::NOT_FOUND {
740                    "session_not_found"
741                } else {
742                    "durable_unavailable"
743                }
744            })),
745        )
746            .into_response();
747    };
748    let mut shutdown = state.shutdown;
749    Sse::new(
750        authorized_events(
751            durable_event_stream(id, subscription),
752            state.security,
753            headers,
754        )
755        .take_until(async move {
756            if !*shutdown.borrow_and_update() {
757                let _ = shutdown.changed().await;
758            }
759        }),
760    )
761    .keep_alive(axum::response::sse::KeepAlive::default())
762    .into_response()
763}
764
765fn durable_event_stream(
766    session_id: String,
767    subscription: rpi_extcore::durable::DurableSubscription,
768) -> impl Stream<Item = Result<Event, Infallible>> {
769    let rpi_extcore::durable::DurableSubscription {
770        ready,
771        replay,
772        receiver,
773        gap,
774        after,
775    } = subscription;
776    let mut head = Vec::new();
777    if after.is_none() {
778        head.push(Ok(Event::default()
779            .event("durable_ready")
780            .id(ready.revision.to_string())
781            .data(
782                json!({
783                    "sessionId": session_id,
784                    "revision": ready.revision,
785                    "state": ready,
786                })
787                .to_string(),
788            )));
789    }
790    if gap {
791        head.push(Ok(Event::default().event("resync_required").data(
792            json!({
793                "type": "resync_required",
794                "code": "durable_event_gap"
795            })
796            .to_string(),
797        )));
798    }
799    stream::iter(head).chain(
800        rpi_extcore::durable::replay_changes(replay, receiver, after.or(Some(ready.revision))).map(
801            move |item| {
802                Ok(match item {
803                    rpi_extcore::durable::DurableStreamItem::Change(change) => Event::default()
804                        .event("durable_change")
805                        .id(change.revision.to_string())
806                        .data(
807                            json!({
808                                "sessionId": session_id,
809                                "revision": change.revision,
810                                "state": change.state,
811                            })
812                            .to_string(),
813                        ),
814                    rpi_extcore::durable::DurableStreamItem::ResyncRequired { dropped } => {
815                        Event::default().event("resync_required").data(
816                            json!({
817                                "type": "resync_required",
818                                "code": "durable_event_stream_lagged",
819                                "dropped": dropped
820                            })
821                            .to_string(),
822                        )
823                    }
824                })
825            },
826        ),
827    )
828}
829
830async fn events(
831    State(state): State<AppState>,
832    Path(id): Path<String>,
833    headers: axum::http::HeaderMap,
834    Query(query): Query<EventQuery>,
835) -> axum::response::Response {
836    let after = headers
837        .get("last-event-id")
838        .and_then(|value| value.to_str().ok())
839        .and_then(|value| value.parse::<u64>().ok())
840        .or(query.after);
841    let Some(subscription) = state.backend.subscribe_events(&id, after).await else {
842        return (
843            StatusCode::NOT_FOUND,
844            Json(json!({"error":"session not found"})),
845        )
846            .into_response();
847    };
848    let mut shutdown = state.shutdown;
849    Sse::new(
850        authorized_events(
851            event_stream(
852                subscription.ready,
853                subscription.replay,
854                subscription.receiver,
855                subscription.gap,
856                subscription.after,
857            ),
858            state.security,
859            headers,
860        )
861        .take_until(async move {
862            if !*shutdown.borrow_and_update() {
863                let _ = shutdown.changed().await;
864            }
865        }),
866    )
867    .keep_alive(axum::response::sse::KeepAlive::default())
868    .into_response()
869}
870
871fn event_stream(
872    ready: Value,
873    replay: Vec<BufferedEvent>,
874    receiver: broadcast::Receiver<BufferedEvent>,
875    gap: bool,
876    after: Option<u64>,
877) -> impl Stream<Item = Result<Event, Infallible>> {
878    let head: Vec<Result<Event, Infallible>> =
879        std::iter::once(Ok(Event::default().event("ready").data(ready.to_string())))
880            .chain(gap.then(|| {
881                Ok(Event::default()
882                    .event("resync_required")
883                    .data("{\"type\":\"resync_required\"}"))
884            }))
885            .collect();
886    stream::iter(head).chain(rpi_extcore::events::replay_stream(replay,receiver,after).map(|item| {
887        Ok(match item {
888            rpi_extcore::events::StreamItem::Event(event)=>to_sse(event),
889            rpi_extcore::events::StreamItem::ResyncRequired{dropped}=>Event::default().event("resync_required").data(json!({"type":"resync_required","code":"event_stream_lagged","dropped":dropped}).to_string()),
890        })
891    }))
892}
893
894// The middleware checks only the initial HTTP request. A long-lived stream must
895// also stop after another tab logs out or the authentication session expires.
896fn authorized_events(
897    events: impl Stream<Item = Result<Event, Infallible>> + Send + 'static,
898    security: Arc<Security>,
899    headers: axum::http::HeaderMap,
900) -> impl Stream<Item = Result<Event, Infallible>> {
901    let expiry_check = tokio::time::interval(std::time::Duration::from_secs(10));
902    stream::unfold(
903        (Box::pin(events), expiry_check),
904        move |(mut events, mut expiry_check)| {
905            let security = security.clone();
906            let headers = headers.clone();
907            async move {
908                loop {
909                    tokio::select! {
910                        event = events.next() => {
911                            let event = event?;
912                            if !security.authorized(&headers).await { return None; }
913                            return Some((event, (events, expiry_check)));
914                        }
915                        _ = expiry_check.tick() => {
916                            if !security.authorized(&headers).await { return None; }
917                        }
918                    }
919                }
920            }
921        },
922    )
923}
924
925fn to_sse(mut event: BufferedEvent) -> Event {
926    rpi_extcore::timing::refresh_event(&mut event.value);
927    let event_type = event
928        .value
929        .get("type")
930        .and_then(Value::as_str)
931        .unwrap_or("message");
932    Event::default()
933        .event(event_type)
934        .id(event.seq.to_string())
935        .data(event.value.to_string())
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941
942    #[test]
943    fn durable_requests_map_to_protocol_neutral_mutations() {
944        let mutation = parse_durable_mutation(DurableMutationRequest {
945            operation: "enqueue".into(),
946            text: Some("run tests".into()),
947            item_id: None,
948            status: None,
949            note: None,
950        })
951        .unwrap();
952        assert!(matches!(mutation, DurableMutation::Enqueue { text } if text == "run tests"));
953
954        let mutation = parse_durable_mutation(DurableMutationRequest {
955            operation: "finish".into(),
956            text: None,
957            item_id: Some("item-1".into()),
958            status: Some("completed".into()),
959            note: Some("passed".into()),
960        })
961        .unwrap();
962        assert!(matches!(
963            mutation,
964            DurableMutation::Finish {
965                id,
966                status: QueueStatus::Completed,
967                note
968            } if id == "item-1" && note == "passed"
969        ));
970    }
971
972    #[test]
973    fn durable_requests_reject_invalid_operations_and_statuses() {
974        let error = parse_durable_mutation(DurableMutationRequest {
975            operation: "finish".into(),
976            text: None,
977            item_id: Some("item-1".into()),
978            status: Some("queued".into()),
979            note: None,
980        })
981        .unwrap_err();
982        assert!(error.contains("invalid finish status"));
983    }
984
985    #[test]
986    fn optional_creation_bodies_reject_invalid_json_and_types() {
987        assert!(optional_json::<CreateSessionRequest>(b"").is_ok());
988        assert!(optional_json::<CreateSessionRequest>(br#"{"projectId":"project-a"}"#).is_ok());
989        for body in [b"{".as_slice(), b"null", br#"{"projectId":42}"#] {
990            assert_eq!(
991                optional_json::<CreateSessionRequest>(body).unwrap_err().0,
992                StatusCode::BAD_REQUEST
993            );
994        }
995        assert!(optional_json::<ForkRequest>(b"").is_ok());
996        assert!(optional_json::<ForkRequest>(br#"{"entryId":42}"#).is_err());
997    }
998
999    #[tokio::test]
1000    async fn live_events_do_not_duplicate_replay_or_the_snapshot_cursor() {
1001        for replay in [
1002            vec![],
1003            vec![BufferedEvent {
1004                seq: 5,
1005                value: json!({"type":"agent_start"}),
1006            }],
1007        ] {
1008            let (sender, receiver) = broadcast::channel(8);
1009            for seq in [4, 5, 6] {
1010                sender
1011                    .send(BufferedEvent {
1012                        seq,
1013                        value: json!({"type":"agent_start"}),
1014                    })
1015                    .unwrap();
1016            }
1017            drop(sender);
1018            let has_replay = !replay.is_empty();
1019            let stream = event_stream(json!({"type":"ready"}), replay, receiver, false, Some(5));
1020            let body = axum::body::to_bytes(Sse::new(stream).into_response().into_body(), 4096)
1021                .await
1022                .unwrap();
1023            let text = String::from_utf8(body.to_vec()).unwrap();
1024            assert!(!text.contains("id: 4\n"));
1025            assert_eq!(text.matches("id: 5\n").count(), usize::from(has_replay));
1026            assert_eq!(text.matches("id: 6\n").count(), 1);
1027        }
1028    }
1029
1030    #[tokio::test]
1031    async fn durable_events_emit_ready_changes_and_reconnect_replay() {
1032        use rpi_extcore::durable::{DurableChange, DurableState, DurableSubscription};
1033
1034        let (sender, receiver) = broadcast::channel(8);
1035        sender
1036            .send(DurableChange {
1037                revision: 1,
1038                state: DurableState {
1039                    revision: 1,
1040                    ..DurableState::default()
1041                },
1042            })
1043            .unwrap();
1044        drop(sender);
1045        let body = axum::body::to_bytes(
1046            Sse::new(durable_event_stream(
1047                "session-1".into(),
1048                DurableSubscription {
1049                    ready: DurableState::default(),
1050                    replay: vec![],
1051                    receiver,
1052                    gap: false,
1053                    after: None,
1054                },
1055            ))
1056            .into_response()
1057            .into_body(),
1058            4096,
1059        )
1060        .await
1061        .unwrap();
1062        let text = String::from_utf8(body.to_vec()).unwrap();
1063        assert!(text.contains("event: durable_ready\n"));
1064        assert!(text.contains("event: durable_change\n"));
1065        assert!(text.contains("id: 1\n"));
1066
1067        let (sender, receiver) = broadcast::channel(8);
1068        drop(sender);
1069        let body = axum::body::to_bytes(
1070            Sse::new(durable_event_stream(
1071                "session-1".into(),
1072                DurableSubscription {
1073                    ready: DurableState {
1074                        revision: 1,
1075                        ..DurableState::default()
1076                    },
1077                    replay: vec![DurableChange {
1078                        revision: 1,
1079                        state: DurableState {
1080                            revision: 1,
1081                            ..DurableState::default()
1082                        },
1083                    }],
1084                    receiver,
1085                    gap: false,
1086                    after: Some(0),
1087                },
1088            ))
1089            .into_response()
1090            .into_body(),
1091            4096,
1092        )
1093        .await
1094        .unwrap();
1095        let text = String::from_utf8(body.to_vec()).unwrap();
1096        assert!(!text.contains("event: durable_ready\n"));
1097        assert_eq!(text.matches("id: 1\n").count(), 1);
1098    }
1099
1100    #[tokio::test]
1101    async fn logout_revokes_an_already_open_event_stream() {
1102        let security = Arc::new(Security::new(["example.test".into()], Some("secret")));
1103        let token = security.login("secret").await.unwrap();
1104        let mut headers = axum::http::HeaderMap::new();
1105        headers.insert(
1106            axum::http::header::COOKIE,
1107            format!("rpi_web_session={token}").parse().unwrap(),
1108        );
1109        let (sender, receiver) = broadcast::channel(8);
1110        let stream = authorized_events(
1111            event_stream(json!({"type":"ready"}), vec![], receiver, false, None),
1112            security.clone(),
1113            headers.clone(),
1114        );
1115        futures_util::pin_mut!(stream);
1116        assert!(stream.next().await.is_some());
1117        security.logout(&headers).await;
1118        sender
1119            .send(BufferedEvent {
1120                seq: 1,
1121                value: json!({"type":"message_end","message":"private"}),
1122            })
1123            .unwrap();
1124        assert!(stream.next().await.is_none());
1125    }
1126}