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