bctx-cloud-core 0.1.4

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::{request::Parts, StatusCode},
    middleware::{self, Next},
    response::{IntoResponse, Response},
    routing::{get, post},
    Router,
};
use db::CloudDb;
use std::net::SocketAddr;
use std::path::PathBuf;

#[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())
            .unwrap_or("dev-secret");

        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
}

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) -> Self {
        let jwt_secret = std::env::var("BCTX_JWT_SECRET")
            .unwrap_or_else(|_| "dev-secret-change-in-prod".to_string());
        let base_url = std::env::var("BCTX_BASE_URL").unwrap_or_else(|_| format!("http://{bind}"));
        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),
            )
            // 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))
            // 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))
            // 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/success", get(routes::billing::checkout_success))
            .route("/billing/cancel", get(routes::billing::checkout_cancel))
            // health
            .route("/health", get(health))
            .layer(middleware::from_fn_with_state(
                state.clone(),
                inject_jwt_secret,
            ))
            .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()
}