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