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