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, CellState, 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        // CTL-002 (E2E report): name-addressed counterparts of the
69        // UUID routes above. The literal `/by-name/` segment is matched
70        // before `:id` because axum's matchit prefers literal segments
71        // over captures at the same depth, so `/v1/formations/by-name/foo`
72        // never tries to parse `by-name` as a UUID.
73        .route(
74            "/v1/formations/by-name/:name",
75            get(routes::formations::get_formation_by_name),
76        )
77        .route(
78            "/v1/formations/by-name/:name",
79            delete(routes::formations::delete_formation_by_name),
80        )
81        .route(
82            "/v1/formations/:id/status",
83            post(routes::formations::update_formation_status),
84        )
85        .route("/v1/cells", get(routes::cells::list_cells))
86        .route("/v1/cells/:id", get(routes::cells::get_cell))
87        // E2E report SRV-001: `cellctl version` is the day-1 reachability
88        // probe a fresh operator types first. Returns build metadata
89        // (crate version, optional short git SHA, build profile, API
90        // version) behind the same Bearer gate as every other route.
91        .route("/v1/version", get(routes::meta::get_version))
92        // EVT-001: one-shot snapshot of recent events. The `--follow`
93        // path stays on `/ws/events`; this exists for environments
94        // where WebSocket isn't viable (corporate proxies, kubectl-
95        // style scripted pulls). See `routes/events.rs` for the
96        // contract and the WS-envelope wire compatibility note.
97        .route("/v1/events", get(routes::events::list_events))
98        .route("/ws/events", get(ws::ws_events))
99        .layer(TraceLayer::new_for_http())
100        .layer(
101            CorsLayer::new()
102                // Origin is left open because the only legitimate
103                // browser client is the cellctl localhost proxy — the
104                // method restriction below is the structural gate, not
105                // the origin list.
106                .allow_origin(Any)
107                .allow_methods([Method::GET, Method::OPTIONS])
108                .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]),
109        )
110        .with_state(state)
111}
112
113#[cfg(test)]
114mod cors_tests {
115    use super::*;
116    use axum::body::Body;
117    use axum::http::{header, Method, Request, StatusCode};
118    use tower::ServiceExt;
119
120    /// ADR-0016 structural enforcement: a cross-origin browser MUST
121    /// NOT be able to mutate state. The CORS preflight for
122    /// `POST /v1/formations` is the gate — we assert the server's
123    /// `Access-Control-Allow-Methods` response *omits* `POST`. With
124    /// the strict layer in `router()`, the preflight advertises only
125    /// the safe methods; a compliant browser then refuses to send the
126    /// actual POST.
127    #[tokio::test]
128    async fn cors_preflight_for_post_does_not_allow_post() {
129        let state = AppState::new(None, "test-token");
130        let app = router(state);
131
132        let req = Request::builder()
133            .method(Method::OPTIONS)
134            .uri("/v1/formations")
135            .header(header::ORIGIN, "http://attacker.example")
136            .header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST")
137            .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
138            .body(Body::empty())
139            .unwrap();
140
141        let resp = app.oneshot(req).await.expect("router response");
142        // CORS preflight itself succeeds at the HTTP layer; the gate is
143        // in the Access-Control-Allow-Methods header.
144        assert_eq!(resp.status(), StatusCode::OK);
145
146        let allow_methods = resp
147            .headers()
148            .get(header::ACCESS_CONTROL_ALLOW_METHODS)
149            .and_then(|v| v.to_str().ok())
150            .unwrap_or_default()
151            .to_ascii_uppercase();
152
153        assert!(
154            !allow_methods.contains("POST"),
155            "POST must not appear in Access-Control-Allow-Methods (got {allow_methods:?})",
156        );
157        assert!(
158            allow_methods.contains("GET"),
159            "GET must appear in Access-Control-Allow-Methods (got {allow_methods:?})",
160        );
161    }
162
163    /// And the safe preflight (GET) is allowed — sanity check we
164    /// didn't accidentally lock the whole API down.
165    #[tokio::test]
166    async fn cors_preflight_for_get_is_allowed() {
167        let state = AppState::new(None, "test-token");
168        let app = router(state);
169
170        let req = Request::builder()
171            .method(Method::OPTIONS)
172            .uri("/v1/formations")
173            .header(header::ORIGIN, "http://localhost:9999")
174            .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
175            .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
176            .body(Body::empty())
177            .unwrap();
178
179        let resp = app.oneshot(req).await.expect("router response");
180        assert_eq!(resp.status(), StatusCode::OK);
181
182        let allow_methods = resp
183            .headers()
184            .get(header::ACCESS_CONTROL_ALLOW_METHODS)
185            .and_then(|v| v.to_str().ok())
186            .unwrap_or_default()
187            .to_ascii_uppercase();
188        assert!(
189            allow_methods.contains("GET"),
190            "GET must be in Access-Control-Allow-Methods (got {allow_methods:?})",
191        );
192    }
193}