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::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
54/// Build the public HTTP application: workflow-management routes first, then
55/// the ops-console static asset fallback. The ops console adds no data API.
56///
57/// # Errors
58///
59/// Returns [`ServerError::Config`] when ops-console assets are misconfigured, or
60/// when the MCP surface is enabled and its published tool catalog cannot be
61/// constructed.
62pub 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    // The MCP endpoint is another route on THIS listener: one port, one
69    // process. It is merged here rather than inside `workflow_router` because
70    // it is a top-level protocol surface alongside `/metrics` and `/health/*`,
71    // not a workflow-management route.
72    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    // CORS is applied last so it wraps every public route the browser ops console
91    // calls (the workflow API, /metrics, /health/*, the ops-console fallback).
92    // With no configured origins `cors_layer` returns None and the router is
93    // byte-identical to before — no cross-origin request is allowed (the secure
94    // default). With origins set the layer also answers OPTIONS preflight.
95    Ok(match cors {
96        Some(cors) => router.layer(cors),
97        None => router,
98    })
99}
100
101/// Build the CORS layer for the public HTTP router from the operator-configured
102/// allowed origins.
103///
104/// Returns `Ok(None)` when no origins are configured — the secure default:
105/// the layer is not installed and no cross-origin request is permitted. When
106/// origins are configured the layer is scoped to exactly those origins (never
107/// `Any`, so it is safe to pair with credentialed requests), permits the
108/// methods the ops console uses (GET, POST, PUT, DELETE, and OPTIONS preflight), and allows
109/// exactly the request headers the API consumes.
110///
111/// # Errors
112///
113/// Returns [`ServerError::Config`] when a configured origin is not a valid HTTP
114/// header value. Startup validation already rejects malformed origins, so this
115/// is defense in depth.
116fn 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
147/// Disabled deploy surface: a plain 404 with no body, indistinguishable
148/// from an unmounted route family.
149async fn deploy_disabled() -> StatusCode {
150    StatusCode::NOT_FOUND
151}
152
153/// Disabled authoring surface: a plain 404 with no body, indistinguishable
154/// from an unmounted route family. When `[authoring].gleam_path` is absent the
155/// server compiles no Gleam and deploys pre-built `.aion` files only (CN7).
156async fn authoring_disabled() -> StatusCode {
157    StatusCode::NOT_FOUND
158}
159
160/// Disabled dev surface: a plain 404 with no body, indistinguishable from an
161/// unmounted route family. When `[dev].enabled` is false the server mounts no
162/// dev endpoints and installs no activity-mock decorator (CN4).
163async fn dev_disabled() -> StatusCode {
164    StatusCode::NOT_FOUND
165}
166
167/// Disabled deployment surfaces: a plain 404 with no body for the deployed-AWL
168/// reader, worker-deployment management, and managed-worker lifecycle
169/// families. All follow the deploy surface's switch and stay dark when the
170/// operator closes it.
171async fn deploy_surface_disabled() -> StatusCode {
172    StatusCode::NOT_FOUND
173}
174
175/// The read-only deployed-AWL route family.
176///
177/// GET only: both routes carry a single `get`, so every other method is a 405
178/// the router answers — there is no mutation handler on this family to reach,
179/// guarded or otherwise. With the deploy surface off the family is a plain 404,
180/// covering the bare path and everything under it (a `{*rest}` wildcard does
181/// not match the bare path).
182fn 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
218/// The managed-worker route family: the always-mounted status join, and the
219/// three lifecycle commands mounted only when `[deploy].enabled` is set.
220///
221/// The mount condition matches the surfaces these commands belong to: their
222/// gRPC counterparts live on `DeployService`, which joins the listener only
223/// under `[deploy].enabled` (`crate::run`), and the sibling
224/// `/worker-deployments/*` family follows the same switch. With deploy off,
225/// every path under `/workers/managed/` is a plain 404 — a server that is not
226/// a deploy target exposes no lifecycle mutation on any transport. Within a
227/// mounted route, authorization is the handlers' deploy-grant check (decided
228/// per caller; with auth disabled the single-tenant operator holds the grant
229/// server-side). The read stays mounted regardless, exactly as it always has
230/// been: reporting the fleet is not a deploy-surface mutation.
231fn 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
245/// The MCP route family, mounted on the SERVER'S OWN listener.
246///
247/// One port, one process: the MCP endpoint is another route on the same HTTP
248/// surface the console and the workflow API are served from, not a second
249/// listener. It is dark unless `[mcp].enabled` is set, and a dark surface is a
250/// plain 404 — indistinguishable from a build that never had the route.
251///
252/// A construction failure (a published tool schema that will not compile) is
253/// NOT downgraded to a dark surface: it is a server defect, and an operator who
254/// asked for the endpoint would otherwise get a 404 and no idea why.
255///
256/// Each call builds one runtime, and a runtime owns one task store. That is
257/// correct because a task is reachable only through the identifier its creator
258/// was handed: two routers are two disjoint sets of handles, never a split view
259/// of one set. The serving path builds the router exactly once.
260fn 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
268/// Build the public workflow-management HTTP router.
269pub fn workflow_router(state: ServerState) -> Router {
270    // The deploy surface is dark by default: when `[deploy].enabled` is
271    // false the routes are not mounted and every `/deploy/*` path is a
272    // plain 404 (the explicit catch-all keeps the ops-console SPA fallback
273    // from answering for the deploy namespace). The archive upload route
274    // disables the default body limit because `read_archive_body` enforces
275    // the operator-configured `deploy.max_archive_bytes` ceiling while
276    // streaming.
277    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    // The authoring surface is dark by default, gated on
290    // `[authoring].gleam_path`: when it is unset the routes are not mounted and
291    // every `/authoring/*` path is a plain 404 (the explicit catch-all keeps
292    // the ops-console SPA fallback from answering for the authoring namespace).
293    // With it absent the server compiles no Gleam and deploys pre-built `.aion`
294    // files only (CN7).
295    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    // The full AWL studio is always mounted. Stock config supplies the
301    // `aion-authoring` workspace; the typed unconfigured refusal remains for
302    // manually constructed runtime configs that explicitly omit it.
303    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    // The dev surface is dark by default, gated on `[dev].enabled`: when off the
325    // routes are not mounted and every `/dev/*` path is a plain 404 (the
326    // explicit catch-all keeps the ops-console SPA fallback from answering for
327    // the dev namespace), and the engine runs the bare production dispatcher.
328    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        // The installed version joined with the last completed manual update
346        // check — the ops console's update pill reads this. Serving it never
347        // touches the network.
348        .route("/update-status", get(update_status))
349        // What changed in the version this server runs — embedded in the
350        // binary, consumed by the console's "What's new" panel.
351        .route("/changelog", get(changelog))
352        // The built-in assistant, always mounted: it ships in the binary like
353        // the ops console, so there is no configuration under which this server
354        // has one and does not say so.
355        .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        // Preflight: the browser sends OPTIONS with the requested method/header;
521        // the layer must answer with the matching allow-origin and allow-methods.
522        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        // Actual request from the allowed origin echoes the allow-origin header.
543        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        // runtime_config() leaves cors_allowed_origins empty (the secure default).
580        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        // This test rides the production `build_with_store` startup path, so
675        // under `feature = "auth"` the configured jwks_url must be a live
676        // endpoint for the initial JWKS fetch.
677        #[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}