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