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}