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::children::list_children;
22use super::cluster_command::cluster_command;
23use super::deploy::{list_versions, route_version, unload_version, upload_package};
24use super::describe_live::describe_live;
25use super::dev_ui::{dev_register_mock, dev_replay_run, dev_trigger_run};
26use super::events::subscribe_events_socket;
27use super::history::{fetch_event, fetch_history};
28use super::intervene::{intervene, list_attempts};
29use super::managed_workers::{
30    list_managed_workers, restart_managed_worker, start_managed_worker, stop_managed_worker,
31};
32use super::outbox::list_dead_letters;
33use super::queues::list_unserved_queues;
34use super::schedules::{
35    create_schedule, delete_schedule, describe_schedule, list_schedules, pause_schedule,
36    resume_schedule, update_schedule,
37};
38use super::transcripts::{fetch_transcript, list_transcript_streams};
39use super::unrecoverable::list_unrecoverable_runs;
40use super::update_status::update_status;
41use super::whoami::whoami;
42use super::worker_deployments::{
43    delete_worker_deployment, get_worker_deployment, list_worker_deployments,
44    put_worker_deployment, set_worker_deployment_desired_state,
45};
46use super::workers::{drain_worker, stop_worker};
47use super::workflows::{
48    cancel_workflow, count_workflows, describe_workflow, get_workflows, list_namespace_records,
49    list_namespaces, post_list_workflows, post_namespace, query_workflow, rename_workflow,
50    reopen_workflow, set_namespace_placement, signal_workflow, start_workflow,
51};
52use crate::mcp::{McpRuntime, mcp_disabled_router, mcp_router};
53use crate::{ServerError, ServerState, observability, ops_console::assets};
54
55/// Build the public HTTP application: workflow-management routes first, then
56/// the ops-console static asset fallback. The ops console adds no data API.
57///
58/// # Errors
59///
60/// Returns [`ServerError::Config`] when ops-console assets are misconfigured, or
61/// when the MCP surface is enabled and its published tool catalog cannot be
62/// constructed.
63pub fn http_router(state: ServerState) -> Result<Router, ServerError> {
64    let ops_console = assets::ops_console_router(&state.runtime_config().ops_console)?;
65    let cors = cors_layer(&state.runtime_config().cors_allowed_origins)?;
66    let metrics = state.metrics().cloned();
67    let health = state.health().cloned();
68    let mut router = workflow_router(state.clone());
69    // The MCP endpoint is another route on THIS listener: one port, one
70    // process. It is merged here rather than inside `workflow_router` because
71    // it is a top-level protocol surface alongside `/metrics` and `/health/*`,
72    // not a workflow-management route.
73    router = router.merge(mcp_family(&state)?.with_state(state));
74    if let Some(metrics) = metrics {
75        router = router.merge(Router::new().route(
76            "/metrics",
77            get(observability::metrics::metrics_handler).with_state(metrics),
78        ));
79    }
80    if let Some(health) = health {
81        router = router.merge(
82            Router::new()
83                .route("/health/live", get(observability::health::live))
84                .route(
85                    "/health/ready",
86                    get(observability::health::ready).with_state(health),
87                ),
88        );
89    }
90    let router = router.merge(ops_console);
91    // CORS is applied last so it wraps every public route the browser ops console
92    // calls (the workflow API, /metrics, /health/*, the ops-console fallback).
93    // With no configured origins `cors_layer` returns None and the router is
94    // byte-identical to before — no cross-origin request is allowed (the secure
95    // default). With origins set the layer also answers OPTIONS preflight.
96    Ok(match cors {
97        Some(cors) => router.layer(cors),
98        None => router,
99    })
100}
101
102/// Build the CORS layer for the public HTTP router from the operator-configured
103/// allowed origins.
104///
105/// Returns `Ok(None)` when no origins are configured — the secure default:
106/// the layer is not installed and no cross-origin request is permitted. When
107/// origins are configured the layer is scoped to exactly those origins (never
108/// `Any`, so it is safe to pair with credentialed requests), permits the
109/// methods the ops console uses (GET, POST, PUT, DELETE, and OPTIONS preflight), and allows
110/// exactly the request headers the API consumes.
111///
112/// # Errors
113///
114/// Returns [`ServerError::Config`] when a configured origin is not a valid HTTP
115/// header value. Startup validation already rejects malformed origins, so this
116/// is defense in depth.
117fn cors_layer(allowed_origins: &[String]) -> Result<Option<CorsLayer>, ServerError> {
118    if allowed_origins.is_empty() {
119        return Ok(None);
120    }
121    let mut origins = Vec::with_capacity(allowed_origins.len());
122    for origin in allowed_origins {
123        let value = origin
124            .parse::<HeaderValue>()
125            .map_err(|source| ServerError::Config {
126                message: format!("invalid CORS origin `{origin}`: {source}"),
127            })?;
128        origins.push(value);
129    }
130    let layer = CorsLayer::new()
131        .allow_origin(origins)
132        .allow_methods([
133            Method::GET,
134            Method::POST,
135            Method::PUT,
136            Method::DELETE,
137            Method::OPTIONS,
138        ])
139        .allow_headers([
140            header::CONTENT_TYPE,
141            header::AUTHORIZATION,
142            HeaderName::from_static("x-aion-namespaces"),
143            HeaderName::from_static("x-aion-subject"),
144        ]);
145    Ok(Some(layer))
146}
147
148/// Disabled deploy surface: a plain 404 with no body, indistinguishable
149/// from an unmounted route family.
150async fn deploy_disabled() -> StatusCode {
151    StatusCode::NOT_FOUND
152}
153
154/// Disabled authoring surface: a plain 404 with no body, indistinguishable
155/// from an unmounted route family. When `[authoring].gleam_path` is absent the
156/// server compiles no Gleam and deploys pre-built `.aion` files only (CN7).
157async fn authoring_disabled() -> StatusCode {
158    StatusCode::NOT_FOUND
159}
160
161/// Disabled dev surface: a plain 404 with no body, indistinguishable from an
162/// unmounted route family. When `[dev].enabled` is false the server mounts no
163/// dev endpoints and installs no activity-mock decorator (CN4).
164async fn dev_disabled() -> StatusCode {
165    StatusCode::NOT_FOUND
166}
167
168/// Disabled deployment surfaces: a plain 404 with no body for the deployed-AWL
169/// reader, worker-deployment management, and managed-worker lifecycle
170/// families. All follow the deploy surface's switch and stay dark when the
171/// operator closes it.
172async fn deploy_surface_disabled() -> StatusCode {
173    StatusCode::NOT_FOUND
174}
175
176/// The read-only deployed-AWL route family.
177///
178/// GET only: both routes carry a single `get`, so every other method is a 405
179/// the router answers — there is no mutation handler on this family to reach,
180/// guarded or otherwise. With the deploy surface off the family is a plain 404,
181/// covering the bare path and everything under it (a `{*rest}` wildcard does
182/// not match the bare path).
183fn deployed_awl_router(deploy_enabled: bool) -> Router<ServerState> {
184    if deploy_enabled {
185        Router::new()
186            .route("/awl/deployed", get(list_deployed))
187            .route(
188                "/awl/deployed/{workflow_type}/{content_hash}",
189                get(get_deployed_document),
190            )
191    } else {
192        Router::new()
193            .route("/awl/deployed", any(deploy_surface_disabled))
194            .route("/awl/deployed/{*rest}", any(deploy_surface_disabled))
195    }
196}
197
198fn worker_deployment_router(deploy_enabled: bool) -> Router<ServerState> {
199    if deploy_enabled {
200        Router::new()
201            .route("/worker-deployments", get(list_worker_deployments))
202            .route(
203                "/worker-deployments/{name}",
204                get(get_worker_deployment)
205                    .put(put_worker_deployment)
206                    .delete(delete_worker_deployment),
207            )
208            .route(
209                "/worker-deployments/{name}/desired-state",
210                post(set_worker_deployment_desired_state),
211            )
212    } else {
213        Router::new()
214            .route("/worker-deployments", any(deploy_surface_disabled))
215            .route("/worker-deployments/{*rest}", any(deploy_surface_disabled))
216    }
217}
218
219/// The managed-worker route family: the always-mounted status join, and the
220/// three lifecycle commands mounted only when `[deploy].enabled` is set.
221///
222/// The mount condition matches the surfaces these commands belong to: their
223/// gRPC counterparts live on `DeployService`, which joins the listener only
224/// under `[deploy].enabled` (`crate::run`), and the sibling
225/// `/worker-deployments/*` family follows the same switch. With deploy off,
226/// every path under `/workers/managed/` is a plain 404 — a server that is not
227/// a deploy target exposes no lifecycle mutation on any transport. Within a
228/// mounted route, authorization is the handlers' deploy-grant check (decided
229/// per caller; with auth disabled the single-tenant operator holds the grant
230/// server-side). The read stays mounted regardless, exactly as it always has
231/// been: reporting the fleet is not a deploy-surface mutation.
232fn managed_worker_router(deploy_enabled: bool) -> Router<ServerState> {
233    let read = Router::new().route("/workers/managed", get(list_managed_workers));
234    if deploy_enabled {
235        read.route("/workers/managed/{name}/start", post(start_managed_worker))
236            .route("/workers/managed/{name}/stop", post(stop_managed_worker))
237            .route(
238                "/workers/managed/{name}/restart",
239                post(restart_managed_worker),
240            )
241    } else {
242        read.route("/workers/managed/{*rest}", any(deploy_surface_disabled))
243    }
244}
245
246/// The MCP route family, mounted on the SERVER'S OWN listener.
247///
248/// One port, one process: the MCP endpoint is another route on the same HTTP
249/// surface the console and the workflow API are served from, not a second
250/// listener. It is dark unless `[mcp].enabled` is set, and a dark surface is a
251/// plain 404 — indistinguishable from a build that never had the route.
252///
253/// A construction failure (a published tool schema that will not compile) is
254/// NOT downgraded to a dark surface: it is a server defect, and an operator who
255/// asked for the endpoint would otherwise get a 404 and no idea why.
256///
257/// Each call builds one runtime, and a runtime owns one task store. That is
258/// correct because a task is reachable only through the identifier its creator
259/// was handed: two routers are two disjoint sets of handles, never a split view
260/// of one set. The serving path builds the router exactly once.
261fn mcp_family(state: &ServerState) -> Result<Router<ServerState>, ServerError> {
262    if !state.runtime_config().mcp.enabled {
263        return Ok(mcp_disabled_router());
264    }
265    let runtime = McpRuntime::build(state.clone(), &state.runtime_config().mcp)?;
266    Ok(mcp_router(std::sync::Arc::new(runtime)))
267}
268
269/// Mount the development workflow controls only when explicitly enabled.
270fn dev_router(enabled: bool) -> Router<ServerState> {
271    if enabled {
272        Router::new()
273            .route("/dev/runs", post(dev_trigger_run))
274            .route("/dev/mocks", post(dev_register_mock))
275            .route("/dev/replay", post(dev_replay_run))
276    } else {
277        Router::new().route("/dev/{*rest}", any(dev_disabled))
278    }
279}
280
281/// Build the public workflow-management HTTP router.
282pub fn workflow_router(state: ServerState) -> Router {
283    // The deploy surface is dark by default: when `[deploy].enabled` is
284    // false the routes are not mounted and every `/deploy/*` path is a
285    // plain 404 (the explicit catch-all keeps the ops-console SPA fallback
286    // from answering for the deploy namespace). The archive upload route
287    // disables the default body limit because `read_archive_body` enforces
288    // the operator-configured `deploy.max_archive_bytes` ceiling while
289    // streaming.
290    let deploy = if state.runtime_config().deploy.enabled {
291        Router::new()
292            .route(
293                "/deploy/packages",
294                post(upload_package).layer(DefaultBodyLimit::disable()),
295            )
296            .route("/deploy/versions", get(list_versions))
297            .route("/deploy/route", post(route_version))
298            .route("/deploy/unload", post(unload_version))
299    } else {
300        Router::new().route("/deploy/{*rest}", any(deploy_disabled))
301    };
302    // The authoring surface is dark by default, gated on
303    // `[authoring].gleam_path`: when it is unset the routes are not mounted and
304    // every `/authoring/*` path is a plain 404 (the explicit catch-all keeps
305    // the ops-console SPA fallback from answering for the authoring namespace).
306    // With it absent the server compiles no Gleam and deploys pre-built `.aion`
307    // files only (CN7).
308    let authoring = if state.runtime_config().authoring.gleam_path.is_some() {
309        Router::new().route("/authoring/compile", post(compile_source))
310    } else {
311        Router::new().route("/authoring/{*rest}", any(authoring_disabled))
312    };
313    // The full AWL studio is always mounted. Stock config supplies the
314    // `aion-authoring` workspace; the typed unconfigured refusal remains for
315    // manually constructed runtime configs that explicitly omit it.
316    let awl_documents = Router::new()
317        .route("/awl/documents", get(list_documents).post(create_document))
318        .route(
319            "/awl/documents/{*path}",
320            get(get_document).put(put_document),
321        )
322        .route("/awl/layout/{*path}", get(get_layout).put(put_layout));
323    let awl_deployed = deployed_awl_router(state.runtime_config().deploy.enabled);
324    let awl = Router::new()
325        .route("/awl/check", post(check))
326        .route("/awl/emit", post(emit))
327        .route("/awl/deploy", post(deploy_authoring))
328        .route("/awl/revisions/{hash}", get(get_revision))
329        .route("/awl/workers/availability", post(worker_availability))
330        .route("/awl/runs/{deployment_id}", get(get_run_status))
331        .route("/awl/runs/{deployment_id}/binding", post(bind_run))
332        .route("/awl/edit", post(edit))
333        .route("/awl/fmt", post(format))
334        .route("/awl/scaffold", post(scaffold))
335        .merge(awl_documents)
336        .merge(awl_deployed);
337    // The dev surface is dark by default, gated on `[dev].enabled`: when off the
338    // routes are not mounted and every `/dev/*` path is a plain 404 (the
339    // explicit catch-all keeps the ops-console SPA fallback from answering for
340    // the dev namespace), and the engine runs the bare production dispatcher.
341    let dev = dev_router(state.runtime_config().dev.enabled);
342    deploy
343        .merge(authoring)
344        .merge(awl)
345        .merge(dev)
346        .merge(worker_deployment_router(
347            state.runtime_config().deploy.enabled,
348        ))
349        .route("/whoami", get(whoami))
350        .route("/build", get(build_identity))
351        // The installed version joined with the last completed manual update
352        // check — the ops console's update pill reads this. Serving it never
353        // touches the network.
354        .route("/update-status", get(update_status))
355        // What changed in the version this server runs — embedded in the
356        // binary, consumed by the console's "What's new" panel.
357        .route("/changelog", get(changelog))
358        // The built-in assistant, always mounted: it ships in the binary like
359        // the ops console, so there is no configuration under which this server
360        // has one and does not say so.
361        .route("/assistant", get(assistant_descriptor))
362        .route("/assistant/document", get(assistant_document))
363        .route("/namespaces", get(list_namespaces).post(post_namespace))
364        .route("/namespaces/records", get(list_namespace_records))
365        .route(
366            "/namespaces/{name}/placement",
367            axum::routing::put(set_namespace_placement),
368        )
369        .route("/workflows", get(get_workflows))
370        .route("/workflows/count", get(count_workflows))
371        .route("/workflows/start", post(start_workflow))
372        .route("/workflows/signal", post(signal_workflow))
373        .route("/workflows/query", post(query_workflow))
374        .route("/workflows/cancel", post(cancel_workflow))
375        .route("/workflows/rename", post(rename_workflow))
376        .route("/workflows/reopen", post(reopen_workflow))
377        .route("/workflows/list", post(post_list_workflows))
378        .route("/workflows/describe", post(describe_workflow))
379        .route("/workflows/describe-live", post(describe_live))
380        .route("/workflows/children", post(list_children))
381        .route("/workflows/history", post(fetch_history))
382        .route("/workflows/event", post(fetch_event))
383        .route("/workflows/intervene", post(intervene))
384        .route("/workflows/attempts", post(list_attempts))
385        .route("/workflows/transcript", post(fetch_transcript))
386        .route("/workflows/transcripts", post(list_transcript_streams))
387        .route("/workflows/unrecoverable", get(list_unrecoverable_runs))
388        .route("/events/stream", get(subscribe_events_socket))
389        .route("/cluster/command", post(cluster_command))
390        .route("/outbox/dead-letters", post(list_dead_letters))
391        .route("/queues/unserved", get(list_unserved_queues))
392        .merge(managed_worker_router(state.runtime_config().deploy.enabled))
393        .route("/workers/{worker_id}/drain", post(drain_worker))
394        .route("/workers/{worker_id}/stop", post(stop_worker))
395        .route("/schedules", post(create_schedule).get(list_schedules))
396        .route(
397            "/schedules/{id}",
398            get(describe_schedule)
399                .put(update_schedule)
400                .delete(delete_schedule),
401        )
402        .route("/schedules/{id}/pause", post(pause_schedule))
403        .route("/schedules/{id}/resume", post(resume_schedule))
404        .with_state(state)
405}
406
407#[cfg(test)]
408#[path = "router_tests.rs"]
409mod tests;