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