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}