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    routing::{delete, get, post},
18    Router,
19};
20use tower_http::cors::{Any, CorsLayer};
21use tower_http::trace::TraceLayer;
22
23pub use state::{AppState, CellRecord, FormationRecord, FormationStatus};
24
25/// Per-route ceiling on `POST /v1/formations` body bytes. axum
26/// defaults to 2 MiB; that is ~100x the size of any realistic formation
27/// document we have observed (~12 KiB for a 64-cell formation).
28/// Lowering this surfaces a 413 Payload Too Large rather than burning
29/// CPU on serde parsing a 2 MiB document at the admission gate.
30const FORMATIONS_POST_MAX_BYTES: usize = 64 * 1024;
31
32/// Build the full axum router with all routes mounted at their
33/// canonical paths. `AppState` is cloned per request via axum's
34/// `with_state`.
35///
36/// ADR-0017 §D2: `cellos-server` is API-only. The static bundle moved
37/// to `cellctl` and is served by `cellctl webui`. There is no
38/// `ServeDir` fallback here — unmatched paths return 404.
39///
40/// ADR-0016 (read-only browser boundary): CORS is restricted to
41/// `GET` + `OPTIONS` so a misbehaving browser context (XSS, malicious
42/// extension, or a hostile in-page script that slipped past the
43/// `cellctl webui` proxy) cannot mutate state via a cross-origin
44/// `POST /v1/formations`. The localhost proxy makes browser origins a
45/// non-issue in practice, but we enforce the read-only shape
46/// structurally so the boundary survives a proxy bug.
47pub fn router(state: AppState) -> Router {
48    Router::new()
49        .route(
50            "/v1/formations",
51            post(routes::formations::create_formation)
52                .layer(DefaultBodyLimit::max(FORMATIONS_POST_MAX_BYTES)),
53        )
54        .route("/v1/formations", get(routes::formations::list_formations))
55        // NOTE: axum 0.7 ships matchit 0.7 which uses the `:id`
56        // capture syntax. The `{id}` syntax (axum 0.8 / matchit 0.8)
57        // is treated as a LITERAL path segment in 0.7 — the route
58        // would only match a URL containing the four characters `{id}`,
59        // which no real client sends. Until the workspace bumps to
60        // axum 0.8 these routes MUST use `:id`. (Doc comments on the
61        // handlers still spell `{id}` because that is the URL shape
62        // a client sees; only the route registration is constrained.)
63        .route("/v1/formations/:id", get(routes::formations::get_formation))
64        .route(
65            "/v1/formations/:id",
66            delete(routes::formations::delete_formation),
67        )
68        .route(
69            "/v1/formations/:id/status",
70            post(routes::formations::update_formation_status),
71        )
72        .route("/v1/cells", get(routes::cells::list_cells))
73        .route("/v1/cells/:id", get(routes::cells::get_cell))
74        .route("/ws/events", get(ws::ws_events))
75        .layer(TraceLayer::new_for_http())
76        .layer(
77            CorsLayer::new()
78                // Origin is left open because the only legitimate
79                // browser client is the cellctl localhost proxy — the
80                // method restriction below is the structural gate, not
81                // the origin list.
82                .allow_origin(Any)
83                .allow_methods([Method::GET, Method::OPTIONS])
84                .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]),
85        )
86        .with_state(state)
87}
88
89#[cfg(test)]
90mod cors_tests {
91    use super::*;
92    use axum::body::Body;
93    use axum::http::{header, Method, Request, StatusCode};
94    use tower::ServiceExt;
95
96    /// ADR-0016 structural enforcement: a cross-origin browser MUST
97    /// NOT be able to mutate state. The CORS preflight for
98    /// `POST /v1/formations` is the gate — we assert the server's
99    /// `Access-Control-Allow-Methods` response *omits* `POST`. With
100    /// the strict layer in `router()`, the preflight advertises only
101    /// the safe methods; a compliant browser then refuses to send the
102    /// actual POST.
103    #[tokio::test]
104    async fn cors_preflight_for_post_does_not_allow_post() {
105        let state = AppState::new(None, "test-token");
106        let app = router(state);
107
108        let req = Request::builder()
109            .method(Method::OPTIONS)
110            .uri("/v1/formations")
111            .header(header::ORIGIN, "http://attacker.example")
112            .header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST")
113            .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
114            .body(Body::empty())
115            .unwrap();
116
117        let resp = app.oneshot(req).await.expect("router response");
118        // CORS preflight itself succeeds at the HTTP layer; the gate is
119        // in the Access-Control-Allow-Methods header.
120        assert_eq!(resp.status(), StatusCode::OK);
121
122        let allow_methods = resp
123            .headers()
124            .get(header::ACCESS_CONTROL_ALLOW_METHODS)
125            .and_then(|v| v.to_str().ok())
126            .unwrap_or_default()
127            .to_ascii_uppercase();
128
129        assert!(
130            !allow_methods.contains("POST"),
131            "POST must not appear in Access-Control-Allow-Methods (got {allow_methods:?})",
132        );
133        assert!(
134            allow_methods.contains("GET"),
135            "GET must appear in Access-Control-Allow-Methods (got {allow_methods:?})",
136        );
137    }
138
139    /// And the safe preflight (GET) is allowed — sanity check we
140    /// didn't accidentally lock the whole API down.
141    #[tokio::test]
142    async fn cors_preflight_for_get_is_allowed() {
143        let state = AppState::new(None, "test-token");
144        let app = router(state);
145
146        let req = Request::builder()
147            .method(Method::OPTIONS)
148            .uri("/v1/formations")
149            .header(header::ORIGIN, "http://localhost:9999")
150            .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
151            .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
152            .body(Body::empty())
153            .unwrap();
154
155        let resp = app.oneshot(req).await.expect("router response");
156        assert_eq!(resp.status(), StatusCode::OK);
157
158        let allow_methods = resp
159            .headers()
160            .get(header::ACCESS_CONTROL_ALLOW_METHODS)
161            .and_then(|v| v.to_str().ok())
162            .unwrap_or_default()
163            .to_ascii_uppercase();
164        assert!(
165            allow_methods.contains("GET"),
166            "GET must be in Access-Control-Allow-Methods (got {allow_methods:?})",
167        );
168    }
169}