1use 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::assistant::{assistant_descriptor, assistant_document};
12use super::authoring::compile_source;
13use super::awl::{
14 bind_run, check, create_document, deploy_authoring, edit, emit, format, get_document,
15 get_layout, get_revision, get_run_status, list_documents, put_document, put_layout, scaffold,
16 worker_availability,
17};
18use super::awl_deployed::{get_deployed_document, list_deployed};
19use super::build::build_identity;
20use super::changelog::changelog;
21use super::cluster_command::cluster_command;
22use super::deploy::{list_versions, route_version, unload_version, upload_package};
23use super::describe_live::describe_live;
24use super::dev_ui::{dev_register_mock, dev_replay_run, dev_trigger_run};
25use super::events::subscribe_events_socket;
26use super::history::{fetch_event, fetch_history};
27use super::intervene::{intervene, list_attempts};
28use super::managed_workers::{
29 list_managed_workers, restart_managed_worker, start_managed_worker, stop_managed_worker,
30};
31use super::outbox::list_dead_letters;
32use super::queues::list_unserved_queues;
33use super::schedules::{
34 create_schedule, delete_schedule, describe_schedule, list_schedules, pause_schedule,
35 resume_schedule, update_schedule,
36};
37use super::transcripts::{fetch_transcript, list_transcript_streams};
38use super::unrecoverable::list_unrecoverable_runs;
39use super::whoami::whoami;
40use super::worker_deployments::{
41 delete_worker_deployment, get_worker_deployment, list_worker_deployments,
42 put_worker_deployment, set_worker_deployment_desired_state,
43};
44use super::workers::{drain_worker, stop_worker};
45use super::workflows::{
46 cancel_workflow, count_workflows, describe_workflow, get_workflows, list_namespace_records,
47 list_namespaces, post_list_workflows, post_namespace, query_workflow, reopen_workflow,
48 set_namespace_placement, signal_workflow, start_workflow,
49};
50use crate::mcp::{McpRuntime, mcp_disabled_router, mcp_router};
51use crate::{ServerError, ServerState, observability, ops_console::assets};
52
53pub fn http_router(state: ServerState) -> Result<Router, ServerError> {
62 let ops_console = assets::ops_console_router(&state.runtime_config().ops_console)?;
63 let cors = cors_layer(&state.runtime_config().cors_allowed_origins)?;
64 let metrics = state.metrics().cloned();
65 let health = state.health().cloned();
66 let mut router = workflow_router(state.clone());
67 router = router.merge(mcp_family(&state)?.with_state(state));
72 if let Some(metrics) = metrics {
73 router = router.merge(Router::new().route(
74 "/metrics",
75 get(observability::metrics::metrics_handler).with_state(metrics),
76 ));
77 }
78 if let Some(health) = health {
79 router = router.merge(
80 Router::new()
81 .route("/health/live", get(observability::health::live))
82 .route(
83 "/health/ready",
84 get(observability::health::ready).with_state(health),
85 ),
86 );
87 }
88 let router = router.merge(ops_console);
89 Ok(match cors {
95 Some(cors) => router.layer(cors),
96 None => router,
97 })
98}
99
100fn cors_layer(allowed_origins: &[String]) -> Result<Option<CorsLayer>, ServerError> {
116 if allowed_origins.is_empty() {
117 return Ok(None);
118 }
119 let mut origins = Vec::with_capacity(allowed_origins.len());
120 for origin in allowed_origins {
121 let value = origin
122 .parse::<HeaderValue>()
123 .map_err(|source| ServerError::Config {
124 message: format!("invalid CORS origin `{origin}`: {source}"),
125 })?;
126 origins.push(value);
127 }
128 let layer = CorsLayer::new()
129 .allow_origin(origins)
130 .allow_methods([
131 Method::GET,
132 Method::POST,
133 Method::PUT,
134 Method::DELETE,
135 Method::OPTIONS,
136 ])
137 .allow_headers([
138 header::CONTENT_TYPE,
139 header::AUTHORIZATION,
140 HeaderName::from_static("x-aion-namespaces"),
141 HeaderName::from_static("x-aion-subject"),
142 ]);
143 Ok(Some(layer))
144}
145
146async fn deploy_disabled() -> StatusCode {
149 StatusCode::NOT_FOUND
150}
151
152async fn authoring_disabled() -> StatusCode {
156 StatusCode::NOT_FOUND
157}
158
159async fn dev_disabled() -> StatusCode {
163 StatusCode::NOT_FOUND
164}
165
166async fn deploy_surface_disabled() -> StatusCode {
171 StatusCode::NOT_FOUND
172}
173
174fn deployed_awl_router(deploy_enabled: bool) -> Router<ServerState> {
182 if deploy_enabled {
183 Router::new()
184 .route("/awl/deployed", get(list_deployed))
185 .route(
186 "/awl/deployed/{workflow_type}/{content_hash}",
187 get(get_deployed_document),
188 )
189 } else {
190 Router::new()
191 .route("/awl/deployed", any(deploy_surface_disabled))
192 .route("/awl/deployed/{*rest}", any(deploy_surface_disabled))
193 }
194}
195
196fn worker_deployment_router(deploy_enabled: bool) -> Router<ServerState> {
197 if deploy_enabled {
198 Router::new()
199 .route("/worker-deployments", get(list_worker_deployments))
200 .route(
201 "/worker-deployments/{name}",
202 get(get_worker_deployment)
203 .put(put_worker_deployment)
204 .delete(delete_worker_deployment),
205 )
206 .route(
207 "/worker-deployments/{name}/desired-state",
208 post(set_worker_deployment_desired_state),
209 )
210 } else {
211 Router::new()
212 .route("/worker-deployments", any(deploy_surface_disabled))
213 .route("/worker-deployments/{*rest}", any(deploy_surface_disabled))
214 }
215}
216
217fn managed_worker_router(deploy_enabled: bool) -> Router<ServerState> {
231 let read = Router::new().route("/workers/managed", get(list_managed_workers));
232 if deploy_enabled {
233 read.route("/workers/managed/{name}/start", post(start_managed_worker))
234 .route("/workers/managed/{name}/stop", post(stop_managed_worker))
235 .route(
236 "/workers/managed/{name}/restart",
237 post(restart_managed_worker),
238 )
239 } else {
240 read.route("/workers/managed/{*rest}", any(deploy_surface_disabled))
241 }
242}
243
244fn mcp_family(state: &ServerState) -> Result<Router<ServerState>, ServerError> {
260 if !state.runtime_config().mcp.enabled {
261 return Ok(mcp_disabled_router());
262 }
263 let runtime = McpRuntime::build(state.clone(), &state.runtime_config().mcp)?;
264 Ok(mcp_router(std::sync::Arc::new(runtime)))
265}
266
267pub fn workflow_router(state: ServerState) -> Router {
269 let deploy = if state.runtime_config().deploy.enabled {
277 Router::new()
278 .route(
279 "/deploy/packages",
280 post(upload_package).layer(DefaultBodyLimit::disable()),
281 )
282 .route("/deploy/versions", get(list_versions))
283 .route("/deploy/route", post(route_version))
284 .route("/deploy/unload", post(unload_version))
285 } else {
286 Router::new().route("/deploy/{*rest}", any(deploy_disabled))
287 };
288 let authoring = if state.runtime_config().authoring.gleam_path.is_some() {
295 Router::new().route("/authoring/compile", post(compile_source))
296 } else {
297 Router::new().route("/authoring/{*rest}", any(authoring_disabled))
298 };
299 let awl_documents = Router::new()
303 .route("/awl/documents", get(list_documents).post(create_document))
304 .route(
305 "/awl/documents/{*path}",
306 get(get_document).put(put_document),
307 )
308 .route("/awl/layout/{*path}", get(get_layout).put(put_layout));
309 let awl_deployed = deployed_awl_router(state.runtime_config().deploy.enabled);
310 let awl = Router::new()
311 .route("/awl/check", post(check))
312 .route("/awl/emit", post(emit))
313 .route("/awl/deploy", post(deploy_authoring))
314 .route("/awl/revisions/{hash}", get(get_revision))
315 .route("/awl/workers/availability", post(worker_availability))
316 .route("/awl/runs/{deployment_id}", get(get_run_status))
317 .route("/awl/runs/{deployment_id}/binding", post(bind_run))
318 .route("/awl/edit", post(edit))
319 .route("/awl/fmt", post(format))
320 .route("/awl/scaffold", post(scaffold))
321 .merge(awl_documents)
322 .merge(awl_deployed);
323 let dev = if state.runtime_config().dev.enabled {
328 Router::new()
329 .route("/dev/runs", post(dev_trigger_run))
330 .route("/dev/mocks", post(dev_register_mock))
331 .route("/dev/replay", post(dev_replay_run))
332 } else {
333 Router::new().route("/dev/{*rest}", any(dev_disabled))
334 };
335 deploy
336 .merge(authoring)
337 .merge(awl)
338 .merge(dev)
339 .merge(worker_deployment_router(
340 state.runtime_config().deploy.enabled,
341 ))
342 .route("/whoami", get(whoami))
343 .route("/build", get(build_identity))
344 .route("/changelog", get(changelog))
347 .route("/assistant", get(assistant_descriptor))
351 .route("/assistant/document", get(assistant_document))
352 .route("/namespaces", get(list_namespaces).post(post_namespace))
353 .route("/namespaces/records", get(list_namespace_records))
354 .route(
355 "/namespaces/{name}/placement",
356 axum::routing::put(set_namespace_placement),
357 )
358 .route("/workflows", get(get_workflows))
359 .route("/workflows/count", get(count_workflows))
360 .route("/workflows/start", post(start_workflow))
361 .route("/workflows/signal", post(signal_workflow))
362 .route("/workflows/query", post(query_workflow))
363 .route("/workflows/cancel", post(cancel_workflow))
364 .route("/workflows/reopen", post(reopen_workflow))
365 .route("/workflows/list", post(post_list_workflows))
366 .route("/workflows/describe", post(describe_workflow))
367 .route("/workflows/describe-live", post(describe_live))
368 .route("/workflows/history", post(fetch_history))
369 .route("/workflows/event", post(fetch_event))
370 .route("/workflows/intervene", post(intervene))
371 .route("/workflows/attempts", post(list_attempts))
372 .route("/workflows/transcript", post(fetch_transcript))
373 .route("/workflows/transcripts", post(list_transcript_streams))
374 .route("/workflows/unrecoverable", get(list_unrecoverable_runs))
375 .route("/events/stream", get(subscribe_events_socket))
376 .route("/cluster/command", post(cluster_command))
377 .route("/outbox/dead-letters", post(list_dead_letters))
378 .route("/queues/unserved", get(list_unserved_queues))
379 .merge(managed_worker_router(state.runtime_config().deploy.enabled))
380 .route("/workers/{worker_id}/drain", post(drain_worker))
381 .route("/workers/{worker_id}/stop", post(stop_worker))
382 .route("/schedules", post(create_schedule).get(list_schedules))
383 .route(
384 "/schedules/{id}",
385 get(describe_schedule)
386 .put(update_schedule)
387 .delete(delete_schedule),
388 )
389 .route("/schedules/{id}/pause", post(pause_schedule))
390 .route("/schedules/{id}/resume", post(resume_schedule))
391 .with_state(state)
392}
393
394#[cfg(test)]
395mod tests {
396 use std::{fs, sync::Arc};
397
398 use aion::EngineBuilder;
399 use aion_store::{EventStore, InMemoryStore};
400 use axum::{body, http::Request, http::StatusCode};
401 use tower::ServiceExt;
402
403 use super::super::test_support::{
404 NAMESPACE, json_request, read_json, read_text, runtime_config, server_state,
405 };
406 use super::*;
407 use crate::{
408 NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces,
409 config::{NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig},
410 };
411
412 #[tokio::test]
413 async fn ops_console_assets_serve_index_asset_and_do_not_shadow_public_api()
414 -> Result<(), Box<dyn std::error::Error>> {
415 let bundle = crate::test_support::private_tempdir()?;
416 fs::write(
417 bundle.path().join("index.html"),
418 "<!doctype html><title>Aion</title><script src=\"/app.js\"></script>",
419 )?;
420 fs::write(bundle.path().join("app.js"), "window.AION = true;")?;
421
422 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
423 let engine = Arc::new(
424 EngineBuilder::new()
425 .store_arc(Arc::clone(&store))
426 .in_memory_visibility()
427 .scheduler_threads(1)
428 .build()
429 .await?,
430 );
431 let resolver = NamespaceResolver::from_parts(
432 NamespaceMode::SharedEngine,
433 Some(engine),
434 Arc::new(StaticWorkflowNamespaces::default()),
435 Arc::new(StaticScheduleNamespaces::default()),
436 );
437 let mut config = runtime_config();
438 config.ops_console = OpsConsoleConfig {
439 source: OpsConsoleAssetSource::FileSystem {
440 asset_path: bundle.path().to_path_buf(),
441 },
442 };
443 let router = http_router(server_state(resolver, config).await?)?;
444
445 let root = router
446 .clone()
447 .oneshot(Request::builder().uri("/").body(body::Body::empty())?)
448 .await?;
449 assert_eq!(root.status(), StatusCode::OK);
450 assert!(read_text(root).await?.contains("<title>Aion</title>"));
451
452 let asset = router
453 .clone()
454 .oneshot(
455 Request::builder()
456 .uri("/app.js")
457 .body(body::Body::empty())?,
458 )
459 .await?;
460 assert_eq!(asset.status(), StatusCode::OK);
461 assert_eq!(read_text(asset).await?, "window.AION = true;");
462
463 let spa = router
464 .clone()
465 .oneshot(
466 Request::builder()
467 .uri("/ops-console/workflows/demo")
468 .body(body::Body::empty())?,
469 )
470 .await?;
471 assert_eq!(spa.status(), StatusCode::OK);
472 assert!(read_text(spa).await?.contains("<title>Aion</title>"));
473
474 let list = serde_json::json!({
475 "namespace": NAMESPACE,
476 "filter": { "workflow_type": "nonexistent" },
477 });
478 let list_response = router
479 .oneshot(json_request("/workflows/list", &list)?)
480 .await?;
481 assert_eq!(list_response.status(), StatusCode::OK);
482 let list_body: serde_json::Value = read_json(list_response).await?;
483 assert!(
484 list_body["summaries"]
485 .as_array()
486 .ok_or("summaries missing")?
487 .is_empty()
488 );
489 Ok(())
490 }
491
492 #[tokio::test]
493 async fn cors_preflight_and_actual_request_carry_allow_headers_for_configured_origin()
494 -> Result<(), Box<dyn std::error::Error>> {
495 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
496 let engine = Arc::new(
497 EngineBuilder::new()
498 .store_arc(Arc::clone(&store))
499 .in_memory_visibility()
500 .scheduler_threads(1)
501 .build()
502 .await?,
503 );
504 let resolver = NamespaceResolver::from_parts(
505 NamespaceMode::SharedEngine,
506 Some(engine),
507 Arc::new(StaticWorkflowNamespaces::default()),
508 Arc::new(StaticScheduleNamespaces::default()),
509 );
510 let mut config = runtime_config();
511 config.cors_allowed_origins = vec!["http://localhost:5173".to_owned()];
512 let router = http_router(server_state(resolver, config).await?)?;
513
514 let preflight = router
517 .clone()
518 .oneshot(
519 Request::builder()
520 .method("OPTIONS")
521 .uri("/workflows/list")
522 .header("origin", "http://localhost:5173")
523 .header("access-control-request-method", "POST")
524 .header("access-control-request-headers", "x-aion-namespaces")
525 .body(body::Body::empty())?,
526 )
527 .await?;
528 assert_eq!(
529 preflight
530 .headers()
531 .get("access-control-allow-origin")
532 .and_then(|value| value.to_str().ok()),
533 Some("http://localhost:5173")
534 );
535
536 let actual = router
538 .oneshot(
539 Request::builder()
540 .uri("/health/live")
541 .header("origin", "http://localhost:5173")
542 .body(body::Body::empty())?,
543 )
544 .await?;
545 assert_eq!(actual.status(), StatusCode::OK);
546 assert_eq!(
547 actual
548 .headers()
549 .get("access-control-allow-origin")
550 .and_then(|value| value.to_str().ok()),
551 Some("http://localhost:5173")
552 );
553 Ok(())
554 }
555
556 #[tokio::test]
557 async fn cors_absent_origins_install_no_layer() -> Result<(), Box<dyn std::error::Error>> {
558 let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
559 let engine = Arc::new(
560 EngineBuilder::new()
561 .store_arc(Arc::clone(&store))
562 .in_memory_visibility()
563 .scheduler_threads(1)
564 .build()
565 .await?,
566 );
567 let resolver = NamespaceResolver::from_parts(
568 NamespaceMode::SharedEngine,
569 Some(engine),
570 Arc::new(StaticWorkflowNamespaces::default()),
571 Arc::new(StaticScheduleNamespaces::default()),
572 );
573 let router = http_router(server_state(resolver, runtime_config()).await?)?;
575
576 let response = router
577 .oneshot(
578 Request::builder()
579 .uri("/health/live")
580 .header("origin", "http://localhost:5173")
581 .body(body::Body::empty())?,
582 )
583 .await?;
584 assert_eq!(response.status(), StatusCode::OK);
585 assert!(
586 response
587 .headers()
588 .get("access-control-allow-origin")
589 .is_none(),
590 "no CorsLayer must be installed when no origins are configured"
591 );
592 Ok(())
593 }
594
595 #[cfg(feature = "auth")]
596 #[tokio::test]
597 async fn worker_availability_allows_granted_jwt_namespace_and_denies_foreign_namespace()
598 -> Result<(), Box<dyn std::error::Error>> {
599 let (engine, _, _) = super::super::test_support::shared_engine().await?;
600 let resolver = NamespaceResolver::from_config(
601 NamespaceConfig {
602 mode: NamespaceMode::SharedEngine,
603 },
604 engine,
605 );
606 let router = http_router(server_state(resolver, runtime_config()).await?)?;
607
608 let allowed = router
609 .clone()
610 .oneshot(json_request(
611 "/awl/workers/availability",
612 &serde_json::json!({
613 "namespace": NAMESPACE,
614 "task_queue": "orders",
615 }),
616 )?)
617 .await?;
618 assert_eq!(allowed.status(), StatusCode::OK);
619
620 let foreign = router
621 .oneshot(json_request(
622 "/awl/workers/availability",
623 &serde_json::json!({
624 "namespace": "tenant-b",
625 "task_queue": "orders",
626 }),
627 )?)
628 .await?;
629 assert_eq!(foreign.status(), StatusCode::FORBIDDEN);
630 let body: serde_json::Value = read_json(foreign).await?;
631 assert_eq!(body["code"], "namespace_denied");
632 Ok(())
633 }
634
635 #[tokio::test]
636 async fn worker_availability_keeps_auth_off_operator_access()
637 -> Result<(), Box<dyn std::error::Error>> {
638 let (engine, _, _) = super::super::test_support::shared_engine().await?;
639 let resolver = NamespaceResolver::from_config(
640 NamespaceConfig {
641 mode: NamespaceMode::SharedEngine,
642 },
643 engine,
644 );
645 let mut config = runtime_config();
646 config.auth.enabled = false;
647 config.auth.jwks_url = None;
648 let router = http_router(crate::ServerState::from_parts(resolver, config))?;
649 let request = Request::builder()
650 .method("POST")
651 .uri("/awl/workers/availability")
652 .header("content-type", "application/json")
653 .body(body::Body::from(serde_json::to_vec(&serde_json::json!({
654 "namespace": "operator-selected-namespace",
655 "task_queue": "orders",
656 }))?))?;
657
658 let response = router.oneshot(request).await?;
659 assert_eq!(response.status(), StatusCode::OK);
660 let body: serde_json::Value = read_json(response).await?;
661 assert_eq!(body["connected_workers"], 0);
662 Ok(())
663 }
664
665 #[tokio::test]
666 async fn observability_routes_are_public_and_expose_expected_payloads()
667 -> Result<(), Box<dyn std::error::Error>> {
668 #[cfg(feature = "auth")]
672 let config = {
673 let mut config = runtime_config();
674 config.auth.jwks_url = Some(crate::auth::test_support::serve_jwks()?);
675 config
676 };
677 #[cfg(not(feature = "auth"))]
678 let config = runtime_config();
679 let router = http_router(
680 crate::ServerState::build_with_store(InMemoryStore::default(), config).await?,
681 )?;
682
683 let metrics_response = router
684 .clone()
685 .oneshot(
686 Request::builder()
687 .uri("/metrics")
688 .body(body::Body::empty())?,
689 )
690 .await?;
691 assert_eq!(metrics_response.status(), StatusCode::OK);
692 assert_eq!(
693 metrics_response
694 .headers()
695 .get(axum::http::header::CONTENT_TYPE)
696 .and_then(|value| value.to_str().ok()),
697 Some("text/plain; version=0.0.4; charset=utf-8")
698 );
699 let metrics_body = read_text(metrics_response).await?;
700 assert!(metrics_body.contains("# HELP aion_workflows_started_total"));
701 assert!(metrics_body.contains("# TYPE aion_workflows_started_total counter"));
702 assert!(metrics_body.contains("# HELP aion_activity_duration_seconds"));
703 assert!(metrics_body.contains("# TYPE aion_activity_duration_seconds histogram"));
704 assert!(metrics_body.contains("aion_activity_duration_seconds_bucket"));
705 assert!(metrics_body.contains("aion_store_operation_duration_seconds_bucket"));
706
707 let live_response = router
708 .clone()
709 .oneshot(
710 Request::builder()
711 .uri("/health/live")
712 .body(body::Body::empty())?,
713 )
714 .await?;
715 assert_eq!(live_response.status(), StatusCode::OK);
716
717 let ready_response = router
718 .oneshot(
719 Request::builder()
720 .uri("/health/ready")
721 .body(body::Body::empty())?,
722 )
723 .await?;
724 assert_eq!(ready_response.status(), StatusCode::OK);
725 Ok(())
726 }
727}