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