Skip to main content

mj_controller/server/api/
routes.rs

1use super::*;
2
3pub(in crate::server) fn router(state: ServerState) -> Router<ServerState> {
4    Router::new()
5        .route(
6            "/sessions/{session_id}/native-agents/{child_id}/history",
7            get(native_agent_history),
8        )
9        .route("/events", get(events::events))
10        .route("/profiles/{profile_id}/config", get(profile_config))
11        .route(
12            "/profiles/{profile_id}/subagent-options",
13            get(subagent_options),
14        )
15        .route("/options", get(options))
16        .route(
17            "/sessions/{session_id}/config",
18            axum::routing::patch(set_config),
19        )
20        .route("/workspaces", get(list_workspaces).post(create_workspace))
21        .route("/sessions", get(list_sessions).post(start_session))
22        .route("/sessions/{session_id}", get(get_session))
23        .route(
24            "/sessions/{session_id}/subagents",
25            get(list_subagents).post(spawn_subagent),
26        )
27        .route("/sessions/{session_id}/prompt", post(prompt))
28        .route("/sessions/{session_id}/transcript", get(transcript))
29        .route("/sessions/{session_id}/history", get(transcript_history))
30        .route("/sessions/{session_id}/usage", get(usage))
31        .route("/sessions/{session_id}/usage/tree", get(usage_tree))
32        .route("/sessions/{session_id}/wait", post(wait))
33        .route("/sessions/{session_id}/suspend", post(suspend))
34        .route("/sessions/{session_id}/destroy", post(destroy))
35        .route("/sessions/{session_id}/resume", post(resume))
36        .route(
37            "/sessions/{session_id}/background-tasks/stop",
38            post(stop_background_task),
39        )
40        .route(
41            "/sessions/{session_id}/interrupt-turn",
42            post(interrupt_turn),
43        )
44        .route(
45            "/sessions/{session_id}/review",
46            get(review_status).post(start_review),
47        )
48        .route(
49            "/sessions/{session_id}/review/{resolution}",
50            post(resolve_review),
51        )
52        .route("/sessions/{session_id}/diff", get(diff))
53        .route(
54            "/sessions/{session_id}/files",
55            get(read_file)
56                .put(write_file)
57                .layer(axum::extract::DefaultBodyLimit::max(
58                    mj_checkpoint::archive::MAX_SESSION_FILE_BYTES as usize,
59                )),
60        )
61        .route("/sessions/{session_id}/elicitations", get(elicitations))
62        .route(
63            "/sessions/{session_id}/elicitations/{elicitation_id}",
64            post(respond_elicitation),
65        )
66        .route("/sessions/{session_id}/export", post(export))
67        .route("/wiki/search", get(wiki_search))
68        .route("/wiki/sessions/{wiki_id}", get(wiki_session))
69        .route("/wiki/sessions/{wiki_id}/brief", get(wiki_brief))
70        .route("/wiki/sessions/{wiki_id}/hits", get(wiki_hits))
71        .route("/wiki/sessions/{wiki_id}/restore", post(wiki_restore))
72        .route_layer(axum::middleware::from_fn_with_state(
73            state,
74            require_api_auth,
75        ))
76        // Outside the auth layer so a 401 carries the version header too: a
77        // client must be able to tell "wrong token" from "wrong server".
78        .layer(axum::middleware::from_fn(api_response_headers))
79}
80
81/// Accept either the bearer token or the viewer's own session cookie.
82///
83/// The cookie is accepted because a browser already signed in to the viewer is
84/// the same user, and it makes the API reachable from the viewer page without
85/// handing the page a second secret.
86pub(super) async fn require_api_auth(
87    State(state): State<ServerState>,
88    request: HttpRequest<axum::body::Body>,
89    next: Next,
90) -> Result<Response, ApiFailure> {
91    let bearer = request
92        .headers()
93        .get(AUTHORIZATION)
94        .and_then(|value| value.to_str().ok())
95        .and_then(|value| value.strip_prefix("Bearer "))
96        .map(str::trim);
97    if bearer.is_some_and(|token| {
98        constant_time_eq(state.api_token.as_bytes(), token.as_bytes()) && !token.is_empty()
99    }) {
100        return Ok(next.run(request).await);
101    }
102    let cookie =
103        super::super::authenticated_viewer(&state, request.headers()).map_err(|error| {
104            if error.status == StatusCode::UNAUTHORIZED {
105                ApiFailure::new(
106                    StatusCode::UNAUTHORIZED,
107                    "supply the API token from the api-token file as a bearer token",
108                )
109            } else {
110                ApiFailure::from(error)
111            }
112        })?;
113    let mut response = next.run(request).await;
114    super::super::renew_viewer_response(&state, &cookie, &mut response)?;
115    Ok(response)
116}
117
118/// Stamp the contract version and forbid caching on every API response,
119/// including failures.
120pub(super) async fn api_response_headers(
121    request: HttpRequest<axum::body::Body>,
122    next: Next,
123) -> Response {
124    let mut response = next.run(request).await;
125    let headers = response.headers_mut();
126    headers.insert(API_VERSION_HEADER, HeaderValue::from_static(API_VERSION));
127    headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
128    response
129}
130
131// ---------------------------------------------------------------------------
132// Handlers
133// ---------------------------------------------------------------------------
134
135#[derive(Debug, Default, Clone, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct SessionListQuery {
138    pub workspace_id: Option<String>,
139}
140
141#[derive(Debug, Default, Deserialize)]
142#[serde(deny_unknown_fields)]
143pub(super) struct ProfileConfigQuery {
144    pub(super) model: Option<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct SetConfigRequest {
150    pub key: String,
151    pub value: String,
152}