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::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
42pub 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 Ok(match cors {
77 Some(cors) => router.layer(cors),
78 None => router,
79 })
80}
81
82fn 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
122async fn deploy_disabled() -> StatusCode {
125 StatusCode::NOT_FOUND
126}
127
128async fn authoring_disabled() -> StatusCode {
132 StatusCode::NOT_FOUND
133}
134
135async fn dev_disabled() -> StatusCode {
139 StatusCode::NOT_FOUND
140}
141
142async fn deployed_awl_disabled() -> StatusCode {
147 StatusCode::NOT_FOUND
148}
149
150fn 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
172pub fn workflow_router(state: ServerState) -> Router {
174 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 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 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 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 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 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 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 #[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}