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