cellos-server 0.5.1

HTTP control plane for CellOS — admission, projection over JetStream, WebSocket fan-out of CloudEvents. Pure event-sourced architecture.
Documentation
//! CellOS HTTP control plane API library surface.
//!
//! Exposes the router builder and core types so that integration tests
//! (and future embedders) can drive the server without spawning a real
//! TCP listener. See `src/main.rs` for the production wiring.

pub mod auth;
pub mod error;
pub mod jetstream;
pub mod routes;
pub mod state;
pub mod ws;

use axum::extract::DefaultBodyLimit;
use axum::http::{header, Method};
use axum::{
    routing::{delete, get, post},
    Router,
};
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;

pub use state::{AppState, CellRecord, CellState, FormationRecord, FormationStatus};

/// Per-route ceiling on `POST /v1/formations` body bytes. axum
/// defaults to 2 MiB; that is ~100x the size of any realistic formation
/// document we have observed (~12 KiB for a 64-cell formation).
/// Lowering this surfaces a 413 Payload Too Large rather than burning
/// CPU on serde parsing a 2 MiB document at the admission gate.
const FORMATIONS_POST_MAX_BYTES: usize = 64 * 1024;

/// Build the full axum router with all routes mounted at their
/// canonical paths. `AppState` is cloned per request via axum's
/// `with_state`.
///
/// ADR-0017 §D2: `cellos-server` is API-only. The static bundle moved
/// to `cellctl` and is served by `cellctl webui`. There is no
/// `ServeDir` fallback here — unmatched paths return 404.
///
/// ADR-0016 (read-only browser boundary): CORS is restricted to
/// `GET` + `OPTIONS` so a misbehaving browser context (XSS, malicious
/// extension, or a hostile in-page script that slipped past the
/// `cellctl webui` proxy) cannot mutate state via a cross-origin
/// `POST /v1/formations`. The localhost proxy makes browser origins a
/// non-issue in practice, but we enforce the read-only shape
/// structurally so the boundary survives a proxy bug.
pub fn router(state: AppState) -> Router {
    Router::new()
        .route(
            "/v1/formations",
            post(routes::formations::create_formation)
                .layer(DefaultBodyLimit::max(FORMATIONS_POST_MAX_BYTES)),
        )
        .route("/v1/formations", get(routes::formations::list_formations))
        // NOTE: axum 0.7 ships matchit 0.7 which uses the `:id`
        // capture syntax. The `{id}` syntax (axum 0.8 / matchit 0.8)
        // is treated as a LITERAL path segment in 0.7 — the route
        // would only match a URL containing the four characters `{id}`,
        // which no real client sends. Until the workspace bumps to
        // axum 0.8 these routes MUST use `:id`. (Doc comments on the
        // handlers still spell `{id}` because that is the URL shape
        // a client sees; only the route registration is constrained.)
        .route("/v1/formations/:id", get(routes::formations::get_formation))
        .route(
            "/v1/formations/:id",
            delete(routes::formations::delete_formation),
        )
        // CTL-002 (E2E report): name-addressed counterparts of the
        // UUID routes above. The literal `/by-name/` segment is matched
        // before `:id` because axum's matchit prefers literal segments
        // over captures at the same depth, so `/v1/formations/by-name/foo`
        // never tries to parse `by-name` as a UUID.
        .route(
            "/v1/formations/by-name/:name",
            get(routes::formations::get_formation_by_name),
        )
        .route(
            "/v1/formations/by-name/:name",
            delete(routes::formations::delete_formation_by_name),
        )
        .route(
            "/v1/formations/:id/status",
            post(routes::formations::update_formation_status),
        )
        .route("/v1/cells", get(routes::cells::list_cells))
        .route("/v1/cells/:id", get(routes::cells::get_cell))
        // E2E report SRV-001: `cellctl version` is the day-1 reachability
        // probe a fresh operator types first. Returns build metadata
        // (crate version, optional short git SHA, build profile, API
        // version) behind the same Bearer gate as every other route.
        .route("/v1/version", get(routes::meta::get_version))
        // EVT-001: one-shot snapshot of recent events. The `--follow`
        // path stays on `/ws/events`; this exists for environments
        // where WebSocket isn't viable (corporate proxies, kubectl-
        // style scripted pulls). See `routes/events.rs` for the
        // contract and the WS-envelope wire compatibility note.
        .route("/v1/events", get(routes::events::list_events))
        .route("/ws/events", get(ws::ws_events))
        .layer(TraceLayer::new_for_http())
        .layer(
            CorsLayer::new()
                // Origin is left open because the only legitimate
                // browser client is the cellctl localhost proxy — the
                // method restriction below is the structural gate, not
                // the origin list.
                .allow_origin(Any)
                .allow_methods([Method::GET, Method::OPTIONS])
                .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]),
        )
        .with_state(state)
}

#[cfg(test)]
mod cors_tests {
    use super::*;
    use axum::body::Body;
    use axum::http::{header, Method, Request, StatusCode};
    use tower::ServiceExt;

    /// ADR-0016 structural enforcement: a cross-origin browser MUST
    /// NOT be able to mutate state. The CORS preflight for
    /// `POST /v1/formations` is the gate — we assert the server's
    /// `Access-Control-Allow-Methods` response *omits* `POST`. With
    /// the strict layer in `router()`, the preflight advertises only
    /// the safe methods; a compliant browser then refuses to send the
    /// actual POST.
    #[tokio::test]
    async fn cors_preflight_for_post_does_not_allow_post() {
        let state = AppState::new(None, "test-token");
        let app = router(state);

        let req = Request::builder()
            .method(Method::OPTIONS)
            .uri("/v1/formations")
            .header(header::ORIGIN, "http://attacker.example")
            .header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST")
            .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
            .body(Body::empty())
            .unwrap();

        let resp = app.oneshot(req).await.expect("router response");
        // CORS preflight itself succeeds at the HTTP layer; the gate is
        // in the Access-Control-Allow-Methods header.
        assert_eq!(resp.status(), StatusCode::OK);

        let allow_methods = resp
            .headers()
            .get(header::ACCESS_CONTROL_ALLOW_METHODS)
            .and_then(|v| v.to_str().ok())
            .unwrap_or_default()
            .to_ascii_uppercase();

        assert!(
            !allow_methods.contains("POST"),
            "POST must not appear in Access-Control-Allow-Methods (got {allow_methods:?})",
        );
        assert!(
            allow_methods.contains("GET"),
            "GET must appear in Access-Control-Allow-Methods (got {allow_methods:?})",
        );
    }

    /// And the safe preflight (GET) is allowed — sanity check we
    /// didn't accidentally lock the whole API down.
    #[tokio::test]
    async fn cors_preflight_for_get_is_allowed() {
        let state = AppState::new(None, "test-token");
        let app = router(state);

        let req = Request::builder()
            .method(Method::OPTIONS)
            .uri("/v1/formations")
            .header(header::ORIGIN, "http://localhost:9999")
            .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
            .header(header::ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
            .body(Body::empty())
            .unwrap();

        let resp = app.oneshot(req).await.expect("router response");
        assert_eq!(resp.status(), StatusCode::OK);

        let allow_methods = resp
            .headers()
            .get(header::ACCESS_CONTROL_ALLOW_METHODS)
            .and_then(|v| v.to_str().ok())
            .unwrap_or_default()
            .to_ascii_uppercase();
        assert!(
            allow_methods.contains("GET"),
            "GET must be in Access-Control-Allow-Methods (got {allow_methods:?})",
        );
    }
}