Skip to main content

aion_server/api/http/
router.rs

1//! Public HTTP router construction.
2
3use axum::{
4    Router,
5    extract::DefaultBodyLimit,
6    http::{HeaderName, HeaderValue, Method, StatusCode, header},
7    routing::{any, get, post},
8};
9use tower_http::cors::CorsLayer;
10
11use super::authoring::compile_source;
12use super::deploy::{list_versions, route_version, unload_version, upload_package};
13use super::dev_ui::{dev_register_mock, dev_replay_run, dev_trigger_run};
14use super::events::subscribe_events_socket;
15use super::schedules::{
16    create_schedule, delete_schedule, describe_schedule, list_schedules, pause_schedule,
17    resume_schedule, update_schedule,
18};
19use super::workflows::{
20    cancel_workflow, count_workflows, describe_workflow, get_workflows, list_namespaces,
21    post_list_workflows, query_workflow, signal_workflow, start_workflow,
22};
23use crate::{ServerError, ServerState, dashboard::assets, observability};
24
25/// Build the public HTTP application: workflow-management routes first, then
26/// the dashboard static asset fallback. The dashboard adds no data API.
27///
28/// # Errors
29///
30/// Returns [`ServerError::Config`] when dashboard assets are misconfigured.
31pub fn http_router(state: ServerState) -> Result<Router, ServerError> {
32    let dashboard = assets::dashboard_router(&state.runtime_config().dashboard)?;
33    let cors = cors_layer(&state.runtime_config().cors_allowed_origins)?;
34    let metrics = state.metrics().cloned();
35    let health = state.health().cloned();
36    let mut router = workflow_router(state);
37    if let Some(metrics) = metrics {
38        router = router.merge(Router::new().route(
39            "/metrics",
40            get(observability::metrics::metrics_handler).with_state(metrics),
41        ));
42    }
43    if let Some(health) = health {
44        router = router.merge(
45            Router::new()
46                .route("/health/live", get(observability::health::live))
47                .route(
48                    "/health/ready",
49                    get(observability::health::ready).with_state(health),
50                ),
51        );
52    }
53    let router = router.merge(dashboard);
54    // CORS is applied last so it wraps every public route the browser dashboard
55    // calls (the workflow API, /metrics, /health/*, the dashboard fallback).
56    // With no configured origins `cors_layer` returns None and the router is
57    // byte-identical to before — no cross-origin request is allowed (the secure
58    // default). With origins set the layer also answers OPTIONS preflight.
59    Ok(match cors {
60        Some(cors) => router.layer(cors),
61        None => router,
62    })
63}
64
65/// Build the CORS layer for the public HTTP router from the operator-configured
66/// allowed origins.
67///
68/// Returns `Ok(None)` when no origins are configured — the secure default:
69/// the layer is not installed and no cross-origin request is permitted. When
70/// origins are configured the layer is scoped to exactly those origins (never
71/// `Any`, so it is safe to pair with credentialed requests), permits the
72/// methods the dashboard uses (GET, POST, and OPTIONS preflight), and allows
73/// exactly the request headers the API consumes.
74///
75/// # Errors
76///
77/// Returns [`ServerError::Config`] when a configured origin is not a valid HTTP
78/// header value. Startup validation already rejects malformed origins, so this
79/// is defense in depth.
80fn cors_layer(allowed_origins: &[String]) -> Result<Option<CorsLayer>, ServerError> {
81    if allowed_origins.is_empty() {
82        return Ok(None);
83    }
84    let mut origins = Vec::with_capacity(allowed_origins.len());
85    for origin in allowed_origins {
86        let value = origin
87            .parse::<HeaderValue>()
88            .map_err(|source| ServerError::Config {
89                message: format!("invalid CORS origin `{origin}`: {source}"),
90            })?;
91        origins.push(value);
92    }
93    let layer = CorsLayer::new()
94        .allow_origin(origins)
95        .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
96        .allow_headers([
97            header::CONTENT_TYPE,
98            header::AUTHORIZATION,
99            HeaderName::from_static("x-aion-namespaces"),
100            HeaderName::from_static("x-aion-subject"),
101        ]);
102    Ok(Some(layer))
103}
104
105/// Disabled deploy surface: a plain 404 with no body, indistinguishable
106/// from an unmounted route family.
107async fn deploy_disabled() -> StatusCode {
108    StatusCode::NOT_FOUND
109}
110
111/// Disabled authoring surface: a plain 404 with no body, indistinguishable
112/// from an unmounted route family. When `[authoring].gleam_path` is absent the
113/// server compiles no Gleam and deploys pre-built `.aion` files only (CN7).
114async fn authoring_disabled() -> StatusCode {
115    StatusCode::NOT_FOUND
116}
117
118/// Disabled dev surface: a plain 404 with no body, indistinguishable from an
119/// unmounted route family. When `[dev].enabled` is false the server mounts no
120/// dev endpoints and installs no activity-mock decorator (CN4).
121async fn dev_disabled() -> StatusCode {
122    StatusCode::NOT_FOUND
123}
124
125/// Build the public workflow-management HTTP router.
126pub fn workflow_router(state: ServerState) -> Router {
127    // The deploy surface is dark by default: when `[deploy].enabled` is
128    // false the routes are not mounted and every `/deploy/*` path is a
129    // plain 404 (the explicit catch-all keeps the dashboard SPA fallback
130    // from answering for the deploy namespace). The archive upload route
131    // disables the default body limit because `read_archive_body` enforces
132    // the operator-configured `deploy.max_archive_bytes` ceiling while
133    // streaming.
134    let deploy = if state.runtime_config().deploy.enabled {
135        Router::new()
136            .route(
137                "/deploy/packages",
138                post(upload_package).layer(DefaultBodyLimit::disable()),
139            )
140            .route("/deploy/versions", get(list_versions))
141            .route("/deploy/route", post(route_version))
142            .route("/deploy/unload", post(unload_version))
143    } else {
144        Router::new().route("/deploy/{*rest}", any(deploy_disabled))
145    };
146    // The authoring surface is dark by default, gated on
147    // `[authoring].gleam_path`: when it is unset the routes are not mounted and
148    // every `/authoring/*` path is a plain 404 (the explicit catch-all keeps
149    // the dashboard SPA fallback from answering for the authoring namespace).
150    // With it absent the server compiles no Gleam and deploys pre-built `.aion`
151    // files only (CN7).
152    let authoring = if state.runtime_config().authoring.gleam_path.is_some() {
153        Router::new().route("/authoring/compile", post(compile_source))
154    } else {
155        Router::new().route("/authoring/{*rest}", any(authoring_disabled))
156    };
157    // The dev surface is dark by default, gated on `[dev].enabled`: when off the
158    // routes are not mounted and every `/dev/*` path is a plain 404 (the
159    // explicit catch-all keeps the dashboard SPA fallback from answering for
160    // the dev namespace), and the engine runs the bare production dispatcher.
161    let dev = if state.runtime_config().dev.enabled {
162        Router::new()
163            .route("/dev/runs", post(dev_trigger_run))
164            .route("/dev/mocks", post(dev_register_mock))
165            .route("/dev/replay", post(dev_replay_run))
166    } else {
167        Router::new().route("/dev/{*rest}", any(dev_disabled))
168    };
169    deploy
170        .merge(authoring)
171        .merge(dev)
172        .route("/namespaces", get(list_namespaces))
173        .route("/workflows", get(get_workflows))
174        .route("/workflows/count", get(count_workflows))
175        .route("/workflows/start", post(start_workflow))
176        .route("/workflows/signal", post(signal_workflow))
177        .route("/workflows/query", post(query_workflow))
178        .route("/workflows/cancel", post(cancel_workflow))
179        .route("/workflows/list", post(post_list_workflows))
180        .route("/workflows/describe", post(describe_workflow))
181        .route("/events/stream", get(subscribe_events_socket))
182        .route("/schedules", post(create_schedule).get(list_schedules))
183        .route(
184            "/schedules/{id}",
185            get(describe_schedule)
186                .put(update_schedule)
187                .delete(delete_schedule),
188        )
189        .route("/schedules/{id}/pause", post(pause_schedule))
190        .route("/schedules/{id}/resume", post(resume_schedule))
191        .with_state(state)
192}
193
194#[cfg(test)]
195mod tests {
196    use std::{fs, sync::Arc};
197
198    use aion::EngineBuilder;
199    use aion_store::{EventStore, InMemoryStore};
200    use axum::{body, http::Request, http::StatusCode};
201    use tower::ServiceExt;
202
203    use super::super::test_support::{
204        NAMESPACE, json_request, read_json, read_text, runtime_config, server_state,
205    };
206    use super::*;
207    use crate::{
208        NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces,
209        config::{DashboardAssetSource, DashboardConfig, NamespaceMode},
210    };
211
212    #[tokio::test]
213    async fn dashboard_assets_serve_index_asset_and_do_not_shadow_public_api()
214    -> Result<(), Box<dyn std::error::Error>> {
215        let bundle = tempfile::tempdir()?;
216        fs::write(
217            bundle.path().join("index.html"),
218            "<!doctype html><title>Aion</title><script src=\"/app.js\"></script>",
219        )?;
220        fs::write(bundle.path().join("app.js"), "window.AION = true;")?;
221
222        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
223        let engine = Arc::new(
224            EngineBuilder::new()
225                .store_arc(Arc::clone(&store))
226                .in_memory_visibility()
227                .scheduler_threads(1)
228                .build()
229                .await?,
230        );
231        let resolver = NamespaceResolver::from_parts(
232            NamespaceMode::SharedEngine,
233            Some(engine),
234            Arc::new(StaticWorkflowNamespaces::default()),
235            Arc::new(StaticScheduleNamespaces::default()),
236        );
237        let mut config = runtime_config();
238        config.dashboard = DashboardConfig {
239            source: DashboardAssetSource::FileSystem {
240                asset_path: bundle.path().to_path_buf(),
241            },
242        };
243        let router = http_router(server_state(resolver, config).await?)?;
244
245        let root = router
246            .clone()
247            .oneshot(Request::builder().uri("/").body(body::Body::empty())?)
248            .await?;
249        assert_eq!(root.status(), StatusCode::OK);
250        assert!(read_text(root).await?.contains("<title>Aion</title>"));
251
252        let asset = router
253            .clone()
254            .oneshot(
255                Request::builder()
256                    .uri("/app.js")
257                    .body(body::Body::empty())?,
258            )
259            .await?;
260        assert_eq!(asset.status(), StatusCode::OK);
261        assert_eq!(read_text(asset).await?, "window.AION = true;");
262
263        let spa = router
264            .clone()
265            .oneshot(
266                Request::builder()
267                    .uri("/dashboard/workflows/demo")
268                    .body(body::Body::empty())?,
269            )
270            .await?;
271        assert_eq!(spa.status(), StatusCode::OK);
272        assert!(read_text(spa).await?.contains("<title>Aion</title>"));
273
274        let list = serde_json::json!({
275            "namespace": NAMESPACE,
276            "filter": { "workflow_type": "nonexistent" },
277        });
278        let list_response = router
279            .oneshot(json_request("/workflows/list", &list)?)
280            .await?;
281        assert_eq!(list_response.status(), StatusCode::OK);
282        let list_body: serde_json::Value = read_json(list_response).await?;
283        assert!(
284            list_body["summaries"]
285                .as_array()
286                .ok_or("summaries missing")?
287                .is_empty()
288        );
289        Ok(())
290    }
291
292    #[tokio::test]
293    async fn cors_preflight_and_actual_request_carry_allow_headers_for_configured_origin()
294    -> Result<(), Box<dyn std::error::Error>> {
295        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
296        let engine = Arc::new(
297            EngineBuilder::new()
298                .store_arc(Arc::clone(&store))
299                .in_memory_visibility()
300                .scheduler_threads(1)
301                .build()
302                .await?,
303        );
304        let resolver = NamespaceResolver::from_parts(
305            NamespaceMode::SharedEngine,
306            Some(engine),
307            Arc::new(StaticWorkflowNamespaces::default()),
308            Arc::new(StaticScheduleNamespaces::default()),
309        );
310        let mut config = runtime_config();
311        config.cors_allowed_origins = vec!["http://localhost:5173".to_owned()];
312        let router = http_router(server_state(resolver, config).await?)?;
313
314        // Preflight: the browser sends OPTIONS with the requested method/header;
315        // the layer must answer with the matching allow-origin and allow-methods.
316        let preflight = router
317            .clone()
318            .oneshot(
319                Request::builder()
320                    .method("OPTIONS")
321                    .uri("/workflows/list")
322                    .header("origin", "http://localhost:5173")
323                    .header("access-control-request-method", "POST")
324                    .header("access-control-request-headers", "x-aion-namespaces")
325                    .body(body::Body::empty())?,
326            )
327            .await?;
328        assert_eq!(
329            preflight
330                .headers()
331                .get("access-control-allow-origin")
332                .and_then(|value| value.to_str().ok()),
333            Some("http://localhost:5173")
334        );
335
336        // Actual request from the allowed origin echoes the allow-origin header.
337        let actual = router
338            .oneshot(
339                Request::builder()
340                    .uri("/health/live")
341                    .header("origin", "http://localhost:5173")
342                    .body(body::Body::empty())?,
343            )
344            .await?;
345        assert_eq!(actual.status(), StatusCode::OK);
346        assert_eq!(
347            actual
348                .headers()
349                .get("access-control-allow-origin")
350                .and_then(|value| value.to_str().ok()),
351            Some("http://localhost:5173")
352        );
353        Ok(())
354    }
355
356    #[tokio::test]
357    async fn cors_absent_origins_install_no_layer() -> Result<(), Box<dyn std::error::Error>> {
358        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
359        let engine = Arc::new(
360            EngineBuilder::new()
361                .store_arc(Arc::clone(&store))
362                .in_memory_visibility()
363                .scheduler_threads(1)
364                .build()
365                .await?,
366        );
367        let resolver = NamespaceResolver::from_parts(
368            NamespaceMode::SharedEngine,
369            Some(engine),
370            Arc::new(StaticWorkflowNamespaces::default()),
371            Arc::new(StaticScheduleNamespaces::default()),
372        );
373        // runtime_config() leaves cors_allowed_origins empty (the secure default).
374        let router = http_router(server_state(resolver, runtime_config()).await?)?;
375
376        let response = router
377            .oneshot(
378                Request::builder()
379                    .uri("/health/live")
380                    .header("origin", "http://localhost:5173")
381                    .body(body::Body::empty())?,
382            )
383            .await?;
384        assert_eq!(response.status(), StatusCode::OK);
385        assert!(
386            response
387                .headers()
388                .get("access-control-allow-origin")
389                .is_none(),
390            "no CorsLayer must be installed when no origins are configured"
391        );
392        Ok(())
393    }
394
395    #[tokio::test]
396    async fn observability_routes_are_public_and_expose_expected_payloads()
397    -> Result<(), Box<dyn std::error::Error>> {
398        // This test rides the production `build_with_store` startup path, so
399        // under `feature = "auth"` the configured jwks_url must be a live
400        // endpoint for the initial JWKS fetch.
401        #[cfg(feature = "auth")]
402        let config = {
403            let mut config = runtime_config();
404            config.auth.jwks_url = Some(crate::auth::test_support::serve_jwks()?);
405            config
406        };
407        #[cfg(not(feature = "auth"))]
408        let config = runtime_config();
409        let router = http_router(
410            crate::ServerState::build_with_store(InMemoryStore::default(), config).await?,
411        )?;
412
413        let metrics_response = router
414            .clone()
415            .oneshot(
416                Request::builder()
417                    .uri("/metrics")
418                    .body(body::Body::empty())?,
419            )
420            .await?;
421        assert_eq!(metrics_response.status(), StatusCode::OK);
422        assert_eq!(
423            metrics_response
424                .headers()
425                .get(axum::http::header::CONTENT_TYPE)
426                .and_then(|value| value.to_str().ok()),
427            Some("text/plain; version=0.0.4; charset=utf-8")
428        );
429        let metrics_body = read_text(metrics_response).await?;
430        assert!(metrics_body.contains("# HELP aion_workflows_started_total"));
431        assert!(metrics_body.contains("# TYPE aion_workflows_started_total counter"));
432        assert!(metrics_body.contains("# HELP aion_activity_duration_seconds"));
433        assert!(metrics_body.contains("# TYPE aion_activity_duration_seconds histogram"));
434        assert!(metrics_body.contains("aion_activity_duration_seconds_bucket"));
435        assert!(metrics_body.contains("aion_store_operation_duration_seconds_bucket"));
436
437        let live_response = router
438            .clone()
439            .oneshot(
440                Request::builder()
441                    .uri("/health/live")
442                    .body(body::Body::empty())?,
443            )
444            .await?;
445        assert_eq!(live_response.status(), StatusCode::OK);
446
447        let ready_response = router
448            .oneshot(
449                Request::builder()
450                    .uri("/health/ready")
451                    .body(body::Body::empty())?,
452            )
453            .await?;
454        assert_eq!(ready_response.status(), StatusCode::OK);
455        Ok(())
456    }
457}