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;
12use super::assistant_sessions::{
13    cancel_assistant_turn, create_assistant_session, current_assistant_session,
14    delete_assistant_session, list_assistant_sessions, push_assistant_context,
15    read_assistant_session, resume_assistant_session, set_assistant_config_option,
16    submit_assistant_turn,
17};
18use super::assistant_socket::assistant_session_socket;
19use super::authoring::compile_source;
20use super::awl::{
21    bind_run, check, create_document, deploy_authoring, doc as awl_doc, edit, emit, format,
22    get_document, get_layout, get_revision, get_run_status, list_documents, put_document,
23    put_layout, scaffold, worker_availability,
24};
25use super::awl_deployed::{get_deployed_doc, get_deployed_document, list_deployed};
26use super::awl_graph::{deployed_graph_svg, workspace_graph_svg};
27use super::build::build_identity;
28use super::changelog::changelog;
29use super::children::list_children;
30use super::cluster_command::cluster_command;
31use super::deploy::{list_versions, route_version, unload_version, upload_package};
32use super::describe_live::describe_live;
33use super::dev_ui::{dev_register_mock, dev_replay_run, dev_trigger_run};
34use super::events::subscribe_events_socket;
35use super::history::{fetch_event, fetch_history};
36use super::intervene::{intervene, list_attempts};
37use super::managed_workers::{
38    list_managed_workers, restart_managed_worker, start_managed_worker, stop_managed_worker,
39};
40use super::outbox::list_dead_letters;
41use super::queues::list_unserved_queues;
42use super::schedules::{
43    create_schedule, delete_schedule, describe_schedule, list_schedules, pause_schedule,
44    resume_schedule, update_schedule,
45};
46use super::transcripts::{fetch_transcript, list_transcript_streams};
47use super::unrecoverable::list_unrecoverable_runs;
48use super::update_status::update_status;
49use super::whoami::whoami;
50use super::worker_deployments::{
51    delete_worker_deployment, get_worker_deployment, list_worker_deployments,
52    put_worker_deployment, set_worker_deployment_desired_state,
53};
54use super::workers::{drain_worker, stop_worker};
55use super::workflow_document::get_run_document;
56use super::workflows::{
57    cancel_workflow, describe_workflow, list_namespace_records, list_namespaces,
58    post_list_workflows, post_namespace, query_workflow, rename_workflow, reopen_workflow,
59    retire_workloop, set_namespace_placement, signal_workflow, start_workflow,
60};
61use crate::assistant::mcp::{AssistantMcpRuntime, assistant_mcp_router};
62use crate::mcp::{McpRuntime, mcp_disabled_router, mcp_router};
63use crate::{ServerError, ServerState, observability, ops_console::assets};
64
65/// Build the public HTTP application: workflow-management routes first, then
66/// the ops-console static asset fallback. The ops console adds no data API.
67///
68/// # Errors
69///
70/// Returns [`ServerError::Config`] when ops-console assets are misconfigured, or
71/// when the MCP surface is enabled and its published tool catalog cannot be
72/// constructed.
73pub fn http_router(state: ServerState) -> Result<Router, ServerError> {
74    let ops_console = assets::ops_console_router(&state.runtime_config().ops_console)?;
75    let cors = cors_layer(&state.runtime_config().cors_allowed_origins)?;
76    let metrics = state.metrics().cloned();
77    let health = state.health().cloned();
78    let mut router = workflow_router(state.clone());
79    // The MCP endpoint is another route on THIS listener: one port, one
80    // process. It is merged here rather than inside `workflow_router` because
81    // it is a top-level protocol surface alongside `/metrics` and `/health/*`,
82    // not a workflow-management route.
83    // The assistant's OWN MCP route, beside the general one and never inside it.
84    // Merged before the general family so the two mounts read together: they
85    // share a listener and a protocol layer and share nothing else — not a
86    // catalogue, not a credential, and not the `[mcp] enabled` switch.
87    let assistant_mcp = assistant_mcp_family(&state)?;
88    router = router
89        .merge(assistant_mcp.with_state(state.clone()))
90        .merge(mcp_family(&state)?.with_state(state));
91    if let Some(metrics) = metrics {
92        router = router.merge(Router::new().route(
93            "/metrics",
94            get(observability::metrics::metrics_handler).with_state(metrics),
95        ));
96    }
97    if let Some(health) = health {
98        router = router.merge(
99            Router::new()
100                .route("/health/live", get(observability::health::live))
101                .route(
102                    "/health/ready",
103                    get(observability::health::ready).with_state(health),
104                ),
105        );
106    }
107    let router = router.merge(ops_console);
108    // CORS is applied last so it wraps every public route the browser ops console
109    // calls (the workflow API, /metrics, /health/*, the ops-console fallback).
110    // With no configured origins `cors_layer` returns None and the router is
111    // byte-identical to before — no cross-origin request is allowed (the secure
112    // default). With origins set the layer also answers OPTIONS preflight.
113    Ok(match cors {
114        Some(cors) => router.layer(cors),
115        None => router,
116    })
117}
118
119/// Build the CORS layer for the public HTTP router from the operator-configured
120/// allowed origins.
121///
122/// Returns `Ok(None)` when no origins are configured — the secure default:
123/// the layer is not installed and no cross-origin request is permitted. When
124/// origins are configured the layer is scoped to exactly those origins (never
125/// `Any`, so it is safe to pair with credentialed requests), permits the
126/// methods the ops console uses (GET, POST, PUT, DELETE, and OPTIONS preflight), and allows
127/// exactly the request headers the API consumes.
128///
129/// # Errors
130///
131/// Returns [`ServerError::Config`] when a configured origin is not a valid HTTP
132/// header value. Startup validation already rejects malformed origins, so this
133/// is defense in depth.
134fn cors_layer(allowed_origins: &[String]) -> Result<Option<CorsLayer>, ServerError> {
135    if allowed_origins.is_empty() {
136        return Ok(None);
137    }
138    let mut origins = Vec::with_capacity(allowed_origins.len());
139    for origin in allowed_origins {
140        let value = origin
141            .parse::<HeaderValue>()
142            .map_err(|source| ServerError::Config {
143                message: format!("invalid CORS origin `{origin}`: {source}"),
144            })?;
145        origins.push(value);
146    }
147    let layer = CorsLayer::new()
148        .allow_origin(origins)
149        .allow_methods([
150            Method::GET,
151            Method::POST,
152            Method::PUT,
153            Method::DELETE,
154            Method::OPTIONS,
155        ])
156        .allow_headers([
157            header::CONTENT_TYPE,
158            header::AUTHORIZATION,
159            HeaderName::from_static("x-aion-namespaces"),
160            HeaderName::from_static("x-aion-subject"),
161        ]);
162    Ok(Some(layer))
163}
164
165/// Disabled deploy surface: a plain 404 with no body, indistinguishable
166/// from an unmounted route family.
167async fn deploy_disabled() -> StatusCode {
168    StatusCode::NOT_FOUND
169}
170
171/// Disabled authoring surface: a plain 404 with no body, indistinguishable
172/// from an unmounted route family. When `[authoring].gleam_path` is absent the
173/// server compiles no Gleam and deploys pre-built `.aion` files only (CN7).
174async fn authoring_disabled() -> StatusCode {
175    StatusCode::NOT_FOUND
176}
177
178/// Disabled dev surface: a plain 404 with no body, indistinguishable from an
179/// unmounted route family. When `[dev].enabled` is false the server mounts no
180/// dev endpoints and installs no activity-mock decorator (CN4).
181async fn dev_disabled() -> StatusCode {
182    StatusCode::NOT_FOUND
183}
184
185/// Disabled deployment surfaces: a plain 404 with no body for the deployed-AWL
186/// reader, worker-deployment management, and managed-worker lifecycle
187/// families. All follow the deploy surface's switch and stay dark when the
188/// operator closes it.
189async fn deploy_surface_disabled() -> StatusCode {
190    StatusCode::NOT_FOUND
191}
192
193/// The read-only deployed-AWL route family.
194///
195/// GET only: both routes carry a single `get`, so every other method is a 405
196/// the router answers — there is no mutation handler on this family to reach,
197/// guarded or otherwise. With the deploy surface off the family is a plain 404,
198/// covering the bare path and everything under it (a `{*rest}` wildcard does
199/// not match the bare path).
200fn deployed_awl_router(deploy_enabled: bool) -> Router<ServerState> {
201    if deploy_enabled {
202        Router::new()
203            .route("/awl/deployed", get(list_deployed))
204            .route(
205                "/awl/deployed/{workflow_type}/{content_hash}",
206                get(get_deployed_document),
207            )
208            .route(
209                "/awl/deployed/{workflow_type}/{content_hash}/doc",
210                get(get_deployed_doc),
211            )
212            .route(
213                "/awl/deployed/{workflow_type}/{content_hash}/doc/graph.svg",
214                get(deployed_graph_svg),
215            )
216    } else {
217        Router::new()
218            .route("/awl/deployed", any(deploy_surface_disabled))
219            .route("/awl/deployed/{*rest}", any(deploy_surface_disabled))
220    }
221}
222
223fn worker_deployment_router(deploy_enabled: bool) -> Router<ServerState> {
224    if deploy_enabled {
225        Router::new()
226            .route("/worker-deployments", get(list_worker_deployments))
227            .route(
228                "/worker-deployments/{name}",
229                get(get_worker_deployment)
230                    .put(put_worker_deployment)
231                    .delete(delete_worker_deployment),
232            )
233            .route(
234                "/worker-deployments/{name}/desired-state",
235                post(set_worker_deployment_desired_state),
236            )
237    } else {
238        Router::new()
239            .route("/worker-deployments", any(deploy_surface_disabled))
240            .route("/worker-deployments/{*rest}", any(deploy_surface_disabled))
241    }
242}
243
244/// The managed-worker route family: the always-mounted status join, and the
245/// three lifecycle commands mounted only when `[deploy].enabled` is set.
246///
247/// The mount condition matches the surfaces these commands belong to: their
248/// gRPC counterparts live on `DeployService`, which joins the listener only
249/// under `[deploy].enabled` (`crate::run`), and the sibling
250/// `/worker-deployments/*` family follows the same switch. With deploy off,
251/// every path under `/workers/managed/` is a plain 404 — a server that is not
252/// a deploy target exposes no lifecycle mutation on any transport. Within a
253/// mounted route, authorization is the handlers' deploy-grant check (decided
254/// per caller; with auth disabled the single-tenant operator holds the grant
255/// server-side). The read stays mounted regardless, exactly as it always has
256/// been: reporting the fleet is not a deploy-surface mutation.
257fn managed_worker_router(deploy_enabled: bool) -> Router<ServerState> {
258    let read = Router::new().route("/workers/managed", get(list_managed_workers));
259    if deploy_enabled {
260        read.route("/workers/managed/{name}/start", post(start_managed_worker))
261            .route("/workers/managed/{name}/stop", post(stop_managed_worker))
262            .route(
263                "/workers/managed/{name}/restart",
264                post(restart_managed_worker),
265            )
266    } else {
267        read.route("/workers/managed/{*rest}", any(deploy_surface_disabled))
268    }
269}
270
271/// The MCP route family, mounted on the SERVER'S OWN listener.
272///
273/// One port, one process: the MCP endpoint is another route on the same HTTP
274/// surface the console and the workflow API are served from, not a second
275/// listener. It is dark unless `[mcp].enabled` is set, and a dark surface is a
276/// plain 404 — indistinguishable from a build that never had the route.
277///
278/// A construction failure (a published tool schema that will not compile) is
279/// NOT downgraded to a dark surface: it is a server defect, and an operator who
280/// asked for the endpoint would otherwise get a 404 and no idea why.
281///
282/// Each call builds one runtime, and a runtime owns one task store. That is
283/// correct because a task is reachable only through the identifier its creator
284/// was handed: two routers are two disjoint sets of handles, never a split view
285/// of one set. The serving path builds the router exactly once.
286fn mcp_family(state: &ServerState) -> Result<Router<ServerState>, ServerError> {
287    if !state.runtime_config().mcp.enabled {
288        return Ok(mcp_disabled_router());
289    }
290    let runtime = McpRuntime::build(state.clone(), &state.runtime_config().mcp)?;
291    Ok(mcp_router(std::sync::Arc::new(runtime)))
292}
293
294/// The assistant's own MCP route family.
295///
296/// ALWAYS mounted, unlike the general `/mcp`. `[mcp] enabled` governs whether
297/// this deployment exposes its WORKFLOW tools; it says nothing about whether a
298/// session's own agent may ask what is on the operator's screen, and taking that
299/// away with the same switch would leave an agent asking the operator which file
300/// they have open — the one behaviour this surface exists to remove.
301///
302/// Nothing is reachable on it without a session bearer, so mounting it costs no
303/// authority: an unauthenticated call is a `401` naming the credential.
304fn assistant_mcp_family(state: &ServerState) -> Result<Router<ServerState>, ServerError> {
305    let runtime = AssistantMcpRuntime::build(state.clone(), &state.runtime_config().mcp)?;
306    Ok(assistant_mcp_router(std::sync::Arc::new(runtime)))
307}
308
309/// The assistant-session route family: the caller's own conversations with an
310/// agent harness this server owns.
311///
312/// ALWAYS mounted, like the `/assistant` descriptor it sits beside. Whether
313/// this server can open a session is a `[assistant]` question these routes
314/// ANSWER — `503` carrying the reason and the remedy — not one the router hides
315/// behind a 404 an operator cannot tell from a binary that never had the
316/// surface.
317///
318/// No namespace appears in any of these paths. A session is one operator's
319/// conversation and lives in no namespace, so the caller's own subject is the
320/// whole scope and the deployment-wide `assistant.sessions` grant is what
321/// authorizes it.
322fn assistant_session_router() -> Router<ServerState> {
323    Router::new()
324        .route(
325            "/assistant/sessions",
326            get(list_assistant_sessions).post(create_assistant_session),
327        )
328        // BEFORE the `{id}` route it shares a prefix with. The router matches a
329        // literal segment ahead of a capture, so `current` can never arrive as
330        // a session id to parse — and reading the two in this order is how the
331        // next person sees that without having to know the matcher's rule.
332        .route(
333            "/assistant/sessions/current",
334            get(current_assistant_session),
335        )
336        .route(
337            "/assistant/sessions/{id}",
338            get(read_assistant_session).delete(delete_assistant_session),
339        )
340        .route(
341            "/assistant/sessions/{id}/turns",
342            post(submit_assistant_turn),
343        )
344        .route(
345            "/assistant/sessions/{id}/context",
346            axum::routing::put(push_assistant_context),
347        )
348        .route(
349            "/assistant/sessions/{id}/cancel",
350            post(cancel_assistant_turn),
351        )
352        .route(
353            "/assistant/sessions/{id}/config",
354            axum::routing::put(set_assistant_config_option),
355        )
356        .route(
357            "/assistant/sessions/{id}/resume",
358            post(resume_assistant_session),
359        )
360        .route(
361            "/assistant/sessions/{id}/events",
362            get(assistant_session_socket),
363        )
364}
365
366/// Mount the development workflow controls only when explicitly enabled.
367fn dev_router(enabled: bool) -> Router<ServerState> {
368    if enabled {
369        Router::new()
370            .route("/dev/runs", post(dev_trigger_run))
371            .route("/dev/mocks", post(dev_register_mock))
372            .route("/dev/replay", post(dev_replay_run))
373    } else {
374        Router::new().route("/dev/{*rest}", any(dev_disabled))
375    }
376}
377
378/// Build the public workflow-management HTTP router.
379/// The deploy sub-router: the four verbs when `[deploy].enabled`, or the
380/// explicit dark catch-all (a plain 404 that keeps the ops-console SPA
381/// fallback from answering for the deploy namespace). The archive upload
382/// route disables the default body limit because `read_archive_body`
383/// enforces the operator-configured `deploy.max_archive_bytes` ceiling
384/// while streaming.
385fn deploy_routes(state: &ServerState) -> Router<ServerState> {
386    if state.runtime_config().deploy.enabled {
387        Router::new()
388            .route(
389                "/deploy/packages",
390                post(upload_package).layer(DefaultBodyLimit::disable()),
391            )
392            .route("/deploy/versions", get(list_versions))
393            .route("/deploy/route", post(route_version))
394            .route("/deploy/unload", post(unload_version))
395    } else {
396        Router::new().route("/deploy/{*rest}", any(deploy_disabled))
397    }
398}
399
400/// The whole HTTP surface: workflow lifecycle, deploy (when enabled), AWL
401/// tooling, the assistant, and the ops-console fallback, over one state.
402pub fn workflow_router(state: ServerState) -> Router {
403    // The deploy surface is dark by default: when `[deploy].enabled` is
404    // false the routes are not mounted and every `/deploy/*` path is a
405    // plain 404 (the explicit catch-all keeps the ops-console SPA fallback
406    // from answering for the deploy namespace). The archive upload route
407    // disables the default body limit because `read_archive_body` enforces
408    // the operator-configured `deploy.max_archive_bytes` ceiling while
409    // streaming.
410    let deploy = deploy_routes(&state);
411    // The authoring surface is dark by default, gated on
412    // `[authoring].gleam_path`: when it is unset the routes are not mounted and
413    // every `/authoring/*` path is a plain 404 (the explicit catch-all keeps
414    // the ops-console SPA fallback from answering for the authoring namespace).
415    // With it absent the server compiles no Gleam and deploys pre-built `.aion`
416    // files only (CN7).
417    let authoring = if state.runtime_config().authoring.gleam_path.is_some() {
418        Router::new().route("/authoring/compile", post(compile_source))
419    } else {
420        Router::new().route("/authoring/{*rest}", any(authoring_disabled))
421    };
422    // The full AWL studio is always mounted. Stock config supplies the
423    // `aion-authoring` workspace; the typed unconfigured refusal remains for
424    // manually constructed runtime configs that explicitly omit it.
425    let awl_documents = Router::new()
426        .route("/awl/documents", get(list_documents).post(create_document))
427        .route(
428            "/awl/documents/{*path}",
429            get(get_document).put(put_document),
430        )
431        .route("/awl/layout/{*path}", get(get_layout).put(put_layout));
432    let awl_deployed = deployed_awl_router(state.runtime_config().deploy.enabled);
433    let awl = Router::new()
434        .route("/awl/check", post(check))
435        .route("/awl/doc", post(awl_doc))
436        .route("/awl/doc/graph.svg", post(workspace_graph_svg))
437        .route("/awl/emit", post(emit))
438        .route("/awl/deploy", post(deploy_authoring))
439        .route("/awl/revisions/{hash}", get(get_revision))
440        .route("/awl/workers/availability", post(worker_availability))
441        .route("/awl/runs/{deployment_id}", get(get_run_status))
442        .route("/awl/runs/{deployment_id}/binding", post(bind_run))
443        .route("/awl/edit", post(edit))
444        .route("/awl/fmt", post(format))
445        .route("/awl/scaffold", post(scaffold))
446        .merge(awl_documents)
447        .merge(awl_deployed);
448    // The dev surface is dark by default, gated on `[dev].enabled`: when off the
449    // routes are not mounted and every `/dev/*` path is a plain 404 (the
450    // explicit catch-all keeps the ops-console SPA fallback from answering for
451    // the dev namespace), and the engine runs the bare production dispatcher.
452    let dev = dev_router(state.runtime_config().dev.enabled);
453    deploy
454        .merge(authoring)
455        .merge(awl)
456        .merge(dev)
457        .merge(worker_deployment_router(
458            state.runtime_config().deploy.enabled,
459        ))
460        .route("/whoami", get(whoami))
461        .route("/build", get(build_identity))
462        // The installed version joined with the last completed manual update
463        // check — the ops console's update pill reads this. Serving it never
464        // touches the network.
465        .route("/update-status", get(update_status))
466        // What changed in the version this server runs — embedded in the
467        // binary, consumed by the console's "What's new" panel.
468        .route("/changelog", get(changelog))
469        // The built-in assistant, always mounted: it ships in the binary like
470        // the ops console, so there is no configuration under which this server
471        // has one and does not say so.
472        .route("/assistant", get(assistant_descriptor))
473        .merge(assistant_session_router())
474        .route("/namespaces", get(list_namespaces).post(post_namespace))
475        .route("/namespaces/records", get(list_namespace_records))
476        .route(
477            "/namespaces/{name}/placement",
478            axum::routing::put(set_namespace_placement),
479        )
480        .route("/workflows/start", post(start_workflow))
481        .route("/workflows/signal", post(signal_workflow))
482        .route("/workflows/query", post(query_workflow))
483        .route("/workflows/cancel", post(cancel_workflow))
484        .route("/workflows/retire", post(retire_workloop))
485        .route("/workflows/rename", post(rename_workflow))
486        .route("/workflows/reopen", post(reopen_workflow))
487        .route("/workflows/list", post(post_list_workflows))
488        .route("/workflows/describe", post(describe_workflow))
489        .route("/workflows/describe-live", post(describe_live))
490        .route("/workflows/children", post(list_children))
491        .route("/workflows/history", post(fetch_history))
492        .route("/workflows/event", post(fetch_event))
493        .route("/workflows/intervene", post(intervene))
494        .route("/workflows/attempts", post(list_attempts))
495        .route("/workflows/transcript", post(fetch_transcript))
496        .route("/workflows/transcripts", post(list_transcript_streams))
497        .route("/workflows/unrecoverable", get(list_unrecoverable_runs))
498        // The run's deployed document, under the run's own permission — NOT
499        // the deploy switch (see `workflow_document`).
500        .route(
501            "/workflows/{workflow_id}/document/{content_hash}",
502            get(get_run_document),
503        )
504        .route("/events/stream", get(subscribe_events_socket))
505        .route("/cluster/command", post(cluster_command))
506        .route("/outbox/dead-letters", post(list_dead_letters))
507        .route("/queues/unserved", get(list_unserved_queues))
508        .merge(managed_worker_router(state.runtime_config().deploy.enabled))
509        .route("/workers/{worker_id}/drain", post(drain_worker))
510        .route("/workers/{worker_id}/stop", post(stop_worker))
511        .route("/schedules", post(create_schedule).get(list_schedules))
512        .route(
513            "/schedules/{id}",
514            get(describe_schedule)
515                .put(update_schedule)
516                .delete(delete_schedule),
517        )
518        .route("/schedules/{id}/pause", post(pause_schedule))
519        .route("/schedules/{id}/resume", post(resume_schedule))
520        .with_state(state)
521}
522
523#[cfg(test)]
524#[path = "router_tests.rs"]
525mod tests;