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