bctx-cloud-core 0.1.38

bctx-cloud-core — cloud client and server for Vault sync, dashboard API, billing
Documentation
pub mod billing;
pub mod db;
pub mod routes;

use anyhow::Result;
use axum::{
    extract::{FromRequestParts, Request},
    http::{header, request::Parts, Method, StatusCode},
    middleware::{self, Next},
    response::{IntoResponse, Response},
    routing::{get, post},
    Router,
};
use db::CloudDb;
use std::net::SocketAddr;
use std::path::PathBuf;
use tower_http::cors::{Any, CorsLayer};

#[derive(Clone)]
pub struct AppState {
    pub db: CloudDb,
    pub jwt_secret: String,
    pub base_url: String,
}

pub struct AppError(pub anyhow::Error);

impl From<anyhow::Error> for AppError {
    fn from(e: anyhow::Error) -> Self {
        AppError(e)
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            serde_json::json!({"error": self.0.to_string()}).to_string(),
        )
            .into_response()
    }
}

/// Extractor that validates the Bearer JWT and returns the user_id.
pub struct AuthUser(pub String);

#[axum::async_trait]
impl<S: Send + Sync> FromRequestParts<S> for AuthUser {
    type Rejection = (StatusCode, String);

    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
        // State is accessed via parts extensions — we need AppState injected before this.
        // For now extract the Authorization header and verify with the secret from the extension.
        let auth = parts
            .headers
            .get("Authorization")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "))
            .map(String::from)
            .ok_or((StatusCode::UNAUTHORIZED, "missing token".to_string()))?;

        // JWT secret is stored in request extensions by the auth middleware
        let secret = parts
            .extensions
            .get::<JwtSecret>()
            .map(|s| s.0.as_str())
            .ok_or((
                StatusCode::INTERNAL_SERVER_ERROR,
                "server misconfigured".to_string(),
            ))?;

        let user_id = routes::auth::verify_jwt(&auth, secret)
            .ok_or((StatusCode::UNAUTHORIZED, "invalid token".to_string()))?;

        Ok(AuthUser(user_id))
    }
}

#[derive(Clone)]
struct JwtSecret(String);

async fn inject_jwt_secret(
    axum::extract::State(state): axum::extract::State<AppState>,
    mut req: Request,
    next: Next,
) -> Response {
    req.extensions_mut()
        .insert(JwtSecret(state.jwt_secret.clone()));
    next.run(req).await
}

/// Shortest accepted BCTX_JWT_SECRET; anything shorter is brute-forceable.
pub const MIN_JWT_SECRET_LEN: usize = 32;

/// The signing secret. There is deliberately no default: this source is public, so a
/// built-in secret would let anyone mint a token for any user.
pub fn choose_jwt_secret(configured: Option<String>, dev: bool) -> anyhow::Result<String> {
    match configured {
        Some(s) if s.len() >= MIN_JWT_SECRET_LEN => Ok(s),
        Some(_) => {
            anyhow::bail!("BCTX_JWT_SECRET must be at least {MIN_JWT_SECRET_LEN} characters")
        }
        None if dev => Ok(format!(
            "dev-{}-{}",
            uuid::Uuid::new_v4().simple(),
            uuid::Uuid::new_v4().simple()
        )),
        None => anyhow::bail!(
            "BCTX_JWT_SECRET is not set; refusing to start. For local development set BCTX_DEV=1."
        ),
    }
}

pub struct CloudServer {
    pub bind: SocketAddr,
    pub db_path: Option<PathBuf>,
    pub jwt_secret: String,
    pub base_url: String,
}

impl CloudServer {
    pub fn new(bind: SocketAddr) -> anyhow::Result<Self> {
        let jwt_secret = choose_jwt_secret(
            std::env::var("BCTX_JWT_SECRET").ok(),
            std::env::var("BCTX_DEV").as_deref() == Ok("1"),
        )?;
        let base_url = std::env::var("BCTX_BASE_URL").unwrap_or_else(|_| format!("http://{bind}"));
        Ok(Self {
            bind,
            db_path: None,
            jwt_secret,
            base_url,
        })
    }

    pub fn with_db_path(mut self, path: PathBuf) -> Self {
        self.db_path = Some(path);
        self
    }

    pub async fn run(self) -> Result<()> {
        let db = match self.db_path {
            Some(ref p) => {
                if let Some(parent) = p.parent() {
                    std::fs::create_dir_all(parent)?;
                }
                CloudDb::open(p)?
            }
            None => CloudDb::open_in_memory()?,
        };

        let state = AppState {
            db,
            jwt_secret: self.jwt_secret,
            base_url: self.base_url,
        };

        let app = Router::new()
            // web dashboard (unauthenticated — JS handles auth)
            .route("/", get(serve_dashboard))
            .route("/dashboard.html", get(serve_dashboard))
            // auth
            .route("/auth/device", post(routes::auth::device_start))
            .route("/auth/token", post(routes::auth::token_poll))
            .route(
                "/auth/activate",
                get(routes::auth::activate_page).post(routes::auth::activate),
            )
            .route("/auth/web", post(routes::auth::web_login))
            .route("/auth/web-code", post(routes::auth::web_code))
            .route(
                "/auth/web-code/exchange",
                post(routes::auth::web_code_exchange),
            )
            // vault sync
            .route("/sync/vault", post(routes::sync::push_vault))
            .route("/sync/vault/:project_hash", get(routes::sync::pull_vault))
            .route("/sync/signals", post(routes::sync::push_signals))
            .route("/sync/usage", post(routes::sync::push_usage))
            // dashboard
            .route("/dashboard/summary", get(routes::dashboard::summary))
            .route("/dashboard/gauge", get(routes::dashboard::gauge))
            .route("/dashboard/history", get(routes::dashboard::history))
            .route("/dashboard/commands", get(routes::dashboard::commands))
            // admin
            .route("/admin/stats", get(routes::admin::stats))
            .route("/admin/conversion", get(routes::admin::conversion))
            .route("/admin/export", get(routes::admin::export))
            .route("/admin/export.csv", get(routes::admin::export_csv))
            .route("/admin/promote", post(routes::admin::promote))
            .route("/admin/demote", post(routes::admin::demote))
            // account
            .route("/account/me", get(routes::account::me))
            // team (Studio+)
            .route("/team", post(routes::team::create_team))
            .route("/team/:team_id", get(routes::team::get_team))
            .route("/team/:team_id/invite", post(routes::team::invite_member))
            .route("/team/:team_id/vault", post(routes::team::push_team_vault))
            .route(
                "/team/:team_id/vault/:project_hash",
                get(routes::team::pull_team_vault),
            )
            // billing
            .route("/billing/webhook", post(billing::webhook::handle))
            .route("/billing/checkout", post(routes::billing::create_checkout))
            .route("/billing/portal", post(routes::billing::create_portal))
            .route("/billing/status", get(routes::billing::billing_status))
            .route("/billing/resume", post(routes::billing::billing_resume))
            .route("/billing/success", get(routes::billing::checkout_success))
            .route("/billing/cancel", get(routes::billing::checkout_cancel))
            // compress (public, cross-origin — used by Chrome extension)
            .route("/compress", post(routes::compress::compress))
            // health
            .route("/health", get(health))
            .layer(middleware::from_fn_with_state(
                state.clone(),
                inject_jwt_secret,
            ))
            .layer(
                CorsLayer::new()
                    .allow_origin(Any)
                    .allow_methods([Method::GET, Method::POST, Method::OPTIONS])
                    .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]),
            )
            .with_state(state);

        let listener = tokio::net::TcpListener::bind(self.bind).await?;
        tracing::info!("bctx-cloud listening on {}", self.bind);
        axum::serve(listener, app).await?;
        Ok(())
    }
}

async fn health() -> impl IntoResponse {
    serde_json::json!({"status": "ok", "version": env!("CARGO_PKG_VERSION")}).to_string()
}

static DASHBOARD_HTML: &str = include_str!("dashboard.html");

async fn serve_dashboard() -> impl IntoResponse {
    axum::response::Response::builder()
        .header("Content-Type", "text/html; charset=utf-8")
        .header("Cache-Control", "no-cache")
        .body(axum::body::Body::from(DASHBOARD_HTML))
        .unwrap()
}

#[cfg(test)]
mod secret_tests {
    use super::*;

    #[test]
    fn missing_secret_is_refused_outside_dev() {
        assert!(choose_jwt_secret(None, false).is_err());
    }

    #[test]
    fn short_secret_is_refused() {
        assert!(choose_jwt_secret(Some("short".into()), false).is_err());
    }

    #[test]
    fn strong_secret_is_used_as_is() {
        let s = "a".repeat(MIN_JWT_SECRET_LEN);
        assert_eq!(choose_jwt_secret(Some(s.clone()), false).unwrap(), s);
    }

    #[test]
    fn dev_mode_gets_a_fresh_random_secret() {
        let a = choose_jwt_secret(None, true).unwrap();
        let b = choose_jwt_secret(None, true).unwrap();
        assert_ne!(a, b);
        assert!(a.len() >= MIN_JWT_SECRET_LEN);
    }

    #[test]
    fn no_default_secret_literal_remains_in_source() {
        let src = include_str!("mod.rs");
        assert!(!src.contains(concat!("dev-sec", "ret-change")));
        assert!(!src.contains(concat!("\"dev", "-secret\"")));
    }
}

#[cfg(test)]
mod dashboard_html_tests {
    use super::DASHBOARD_HTML;

    /// Each `${...}` inside an `innerHTML` template, with the line it starts on.
    fn inner_html_interpolations() -> Vec<(usize, String)> {
        let mut found = Vec::new();
        let mut rest = DASHBOARD_HTML;
        while let Some(at) = rest.find(".innerHTML =") {
            let line = DASHBOARD_HTML[..DASHBOARD_HTML.len() - rest.len() + at]
                .lines()
                .count();
            rest = &rest[at..];
            let end = rest.find(";\n").unwrap_or(rest.len());
            let stmt = &rest[..end];
            let mut s = stmt;
            while let Some(open) = s.find("${") {
                let mut depth = 0;
                let mut close = open + 2;
                for (i, c) in s[open + 2..].char_indices() {
                    match c {
                        '{' => depth += 1,
                        '}' if depth == 0 => {
                            close = open + 2 + i;
                            break;
                        }
                        '}' => depth -= 1,
                        _ => {}
                    }
                }
                found.push((line, s[open + 2..close].trim().to_string()));
                s = &s[close..];
            }
            rest = &rest[end..];
        }
        found
    }

    #[test]
    fn inner_html_never_interpolates_unescaped_text() {
        let numeric =
            |e: &str| e.starts_with("fmt(") || e.ends_with(".toFixed(0)") || e == "s.run_count";
        let constant_choice = |e: &str| {
            e.contains(" ? '") && e.contains(" : '") && e.ends_with('\'') && !e.contains('+')
        };
        let bad: Vec<_> = inner_html_interpolations()
            .into_iter()
            .filter(|(_, e)| !(e.starts_with("esc(") || numeric(e) || constant_choice(e)))
            .collect();
        assert!(bad.is_empty(), "wrap these in esc(): {bad:?}");
    }

    #[test]
    fn the_scan_sees_the_command_table() {
        assert!(inner_html_interpolations()
            .iter()
            .any(|(_, e)| e.contains("s.program")));
    }
}