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}