Skip to main content

cellos_server/
lib.rs

1//! CellOS HTTP control plane API library surface.
2//!
3//! Exposes the router builder and core types so that integration tests
4//! (and future embedders) can drive the server without spawning a real
5//! TCP listener. See `src/main.rs` for the production wiring.
6
7pub mod auth;
8pub mod error;
9pub mod jetstream;
10pub mod routes;
11pub mod state;
12pub mod ws;
13
14use axum::extract::DefaultBodyLimit;
15use axum::http::{header, Method};
16use axum::{
17    middleware::map_response,
18    routing::{delete, get, post},
19    Router,
20};
21use tower_http::cors::{Any, CorsLayer};
22use tower_http::trace::TraceLayer;
23
24use crate::error::{normalize_problem_response, problem_response, AppErrorKind};
25
26pub use state::{AppState, CellRecord, CellState, FormationRecord, FormationStatus};
27
28/// Per-route ceiling on `POST /v1/formations` body bytes. axum
29/// defaults to 2 MiB; that is ~100x the size of any realistic formation
30/// document we have observed (~12 KiB for a 64-cell formation).
31/// Lowering this surfaces a 413 Payload Too Large rather than burning
32/// CPU on serde parsing a 2 MiB document at the admission gate.
33const FORMATIONS_POST_MAX_BYTES: usize = 64 * 1024;
34
35/// Build the full axum router with all routes mounted at their
36/// canonical paths. `AppState` is cloned per request via axum's
37/// `with_state`.
38///
39/// ADR-0017 §D2: `cellos-server` is API-only. The static bundle moved
40/// to `cellctl` and is served by `cellctl webui`. There is no
41/// `ServeDir` fallback here — unmatched paths return 404.
42///
43/// ADR-0016 (read-only browser boundary): CORS is restricted to
44/// `GET` + `OPTIONS` so a misbehaving browser context (XSS, malicious
45/// extension, or a hostile in-page script that slipped past the
46/// `cellctl webui` proxy) cannot mutate state via a cross-origin
47/// `POST /v1/formations`. The localhost proxy makes browser origins a
48/// non-issue in practice, but we enforce the read-only shape
49/// structurally so the boundary survives a proxy bug.
50pub fn router(state: AppState) -> Router {
51    Router::new()
52        .route(
53            "/v1/formations",
54            post(routes::formations::create_formation)
55                .layer(DefaultBodyLimit::max(FORMATIONS_POST_MAX_BYTES)),
56        )
57        .route("/v1/formations", get(routes::formations::list_formations))
58        // NOTE: axum 0.7 ships matchit 0.7 which uses the `:id`
59        // capture syntax. The `{id}` syntax (axum 0.8 / matchit 0.8)
60        // is treated as a LITERAL path segment in 0.7 — the route
61        // would only match a URL containing the four characters `{id}`,
62        // which no real client sends. Until the workspace bumps to
63        // axum 0.8 these routes MUST use `:id`. (Doc comments on the
64        // handlers still spell `{id}` because that is the URL shape
65        // a client sees; only the route registration is constrained.)
66        .route("/v1/formations/:id", get(routes::formations::get_formation))
67        .route(
68            "/v1/formations/:id",
69            delete(routes::formations::delete_formation),
70        )
71        // CTL-002 (E2E report): name-addressed counterparts of the
72        // UUID routes above. The literal `/by-name/` segment is matched
73        // before `:id` because axum's matchit prefers literal segments
74        // over captures at the same depth, so `/v1/formations/by-name/foo`
75        // never tries to parse `by-name` as a UUID.
76        .route(
77            "/v1/formations/by-name/:name",
78            get(routes::formations::get_formation_by_name),
79        )
80        .route(
81            "/v1/formations/by-name/:name",
82            delete(routes::formations::delete_formation_by_name),
83        )
84        .route(
85            "/v1/formations/:id/status",
86            post(routes::formations::update_formation_status),
87        )
88        .route("/v1/cells", get(routes::cells::list_cells))
89        .route("/v1/cells/:id", get(routes::cells::get_cell))
90        // E2E report SRV-001: `cellctl version` is the day-1 reachability
91        // probe a fresh operator types first. Returns build metadata
92        // (crate version, optional short git SHA, build profile, API
93        // version) behind the same Bearer gate as every other route.
94        .route("/v1/version", get(routes::meta::get_version))
95        // EVT-001: one-shot snapshot of recent events. The `--follow`
96        // path stays on `/ws/events`; this exists for environments
97        // where WebSocket isn't viable (corporate proxies, kubectl-
98        // style scripted pulls). See `routes/events.rs` for the
99        // contract and the WS-envelope wire compatibility note.
100        .route("/v1/events", get(routes::events::list_events))
101        .route("/ws/events", get(ws::ws_events))
102        // FUZZ-WAVE-1 MED-2: unmatched paths used to return 404 with an
103        // empty body and no Content-Type. RFC 9457 says every error
104        // response should carry problem+json; adopters parse `type` to
105        // distinguish a missing route from an authentication failure.
106        .fallback(not_found_handler)
107        // FUZZ-WAVE-1 MED-2: wrong-method-on-existing-path used to
108        // return 405 with empty body. axum's method router still sets
109        // the `Allow` header on its own response; the
110        // `normalize_problem_response` middleware below preserves Allow
111        // when it rewrites the body.
112        .method_not_allowed_fallback(method_not_allowed_handler)
113        .layer(TraceLayer::new_for_http())
114        .layer(
115            CorsLayer::new()
116                // Origin is left open because the only legitimate
117                // browser client is the cellctl localhost proxy — the
118                // method restriction below is the structural gate, not
119                // the origin list.
120                .allow_origin(Any)
121                .allow_methods([Method::GET, Method::OPTIONS])
122                .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]),
123        )
124        // FUZZ-WAVE-1 MED-1: outermost response-mapping layer normalises
125        // every 4xx that isn't already problem+json (axum's built-in
126        // JsonRejection / PathRejection / QueryRejection / BodyLimit
127        // rejections emit text/plain). Placed AFTER CorsLayer so CORS
128        // preflight 200s aren't touched, and outside TraceLayer so
129        // tracing sees the original status.
130        .layer(map_response(normalize_problem_response))
131        .with_state(state)
132}
133
134/// FUZZ-WAVE-1 MED-2: 404 fallback for unmatched paths. Returns
135/// `application/problem+json` so adopters can distinguish a missing
136/// route (`/problems/not-found`) from an authentication failure
137/// (`/problems/unauthorized`) without sniffing the body shape.
138async fn not_found_handler(req: axum::extract::Request) -> axum::response::Response {
139    let path = req.uri().path().to_string();
140    // Keep the detail terse — we don't want to echo a 4 KiB attacker
141    // URL verbatim into the error body. The leading slash plus first
142    // 200 bytes is enough for an operator to debug.
143    let truncated: String = path.chars().take(200).collect();
144    problem_response(
145        AppErrorKind::NotFound,
146        format!("no route matched '{truncated}'"),
147    )
148}
149
150/// FUZZ-WAVE-1 MED-2: 405 fallback for wrong-method-on-known-path.
151/// axum's method router still attaches the `Allow` header to the
152/// underlying response before this handler runs; the
153/// `normalize_problem_response` layer copies it through to the
154/// rewritten body. RFC 9110 §15.5.6 requires Allow on every 405.
155async fn method_not_allowed_handler(req: axum::extract::Request) -> axum::response::Response {
156    let method = req.method().as_str().to_string();
157    let path: String = req.uri().path().chars().take(200).collect();
158    problem_response(
159        AppErrorKind::MethodNotAllowed,
160        format!("method '{method}' not allowed for '{path}' — see Allow header"),
161    )
162}
163
164#[cfg(test)]
165mod cors_tests {
166    use super::*;
167    use axum::body::Body;
168    use axum::http::{header, Method, Request, StatusCode};
169    use tower::ServiceExt;
170
171    /// ADR-0016 structural enforcement: a cross-origin browser MUST
172    /// NOT be able to mutate state. The CORS preflight for
173    /// `POST /v1/formations` is the gate — we assert the server's
174    /// `Access-Control-Allow-Methods` response *omits* `POST`. With
175    /// the strict layer in `router()`, the preflight advertises only
176    /// the safe methods; a compliant browser then refuses to send the
177    /// actual POST.
178    #[tokio::test]
179    async fn cors_preflight_for_post_does_not_allow_post() {
180        let state = AppState::new(None, "test-token");
181        let app = router(state);
182
183        let req = Request::builder()
184            .method(Method::OPTIONS)
185            .uri("/v1/formations")
186            .header(header::ORIGIN, "http://attacker.example")
187            .header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST")
188            .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
189            .body(Body::empty())
190            .unwrap();
191
192        let resp = app.oneshot(req).await.expect("router response");
193        // CORS preflight itself succeeds at the HTTP layer; the gate is
194        // in the Access-Control-Allow-Methods header.
195        assert_eq!(resp.status(), StatusCode::OK);
196
197        let allow_methods = resp
198            .headers()
199            .get(header::ACCESS_CONTROL_ALLOW_METHODS)
200            .and_then(|v| v.to_str().ok())
201            .unwrap_or_default()
202            .to_ascii_uppercase();
203
204        assert!(
205            !allow_methods.contains("POST"),
206            "POST must not appear in Access-Control-Allow-Methods (got {allow_methods:?})",
207        );
208        assert!(
209            allow_methods.contains("GET"),
210            "GET must appear in Access-Control-Allow-Methods (got {allow_methods:?})",
211        );
212    }
213
214    /// And the safe preflight (GET) is allowed — sanity check we
215    /// didn't accidentally lock the whole API down.
216    #[tokio::test]
217    async fn cors_preflight_for_get_is_allowed() {
218        let state = AppState::new(None, "test-token");
219        let app = router(state);
220
221        let req = Request::builder()
222            .method(Method::OPTIONS)
223            .uri("/v1/formations")
224            .header(header::ORIGIN, "http://localhost:9999")
225            .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
226            .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
227            .body(Body::empty())
228            .unwrap();
229
230        let resp = app.oneshot(req).await.expect("router response");
231        assert_eq!(resp.status(), StatusCode::OK);
232
233        let allow_methods = resp
234            .headers()
235            .get(header::ACCESS_CONTROL_ALLOW_METHODS)
236            .and_then(|v| v.to_str().ok())
237            .unwrap_or_default()
238            .to_ascii_uppercase();
239        assert!(
240            allow_methods.contains("GET"),
241            "GET must be in Access-Control-Allow-Methods (got {allow_methods:?})",
242        );
243    }
244}