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