bctx-cloud-core 0.1.24

bctx-cloud-core — cloud client and server for Vault sync, dashboard API, billing
Documentation
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

const TOKEN_FILE: &str = ".bctx/cloud_token.json";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenStore {
    pub access_token: String,
    pub refresh_token: Option<String>,
    pub expires_at: Option<String>,
    pub endpoint: String,
    pub user_id: String,
    pub email: Option<String>,
    pub tier: String,
}

#[derive(Debug, Deserialize)]
pub struct DeviceCodeResponse {
    pub device_code: String,
    pub user_code: String,
    pub verification_uri: String,
    pub expires_in: u64,
    pub interval: u64,
}

#[derive(Debug, Deserialize)]
pub struct TokenResponse {
    pub access_token: String,
    pub refresh_token: Option<String>,
    pub expires_in: Option<u64>,
    pub user_id: String,
    pub email: Option<String>,
    pub tier: String,
}

/// Begin the OAuth2 device flow. Returns the URL and user code to display.
#[cfg(feature = "cloud-server")]
pub async fn device_flow_start(endpoint: &str, client_id: &str) -> Result<DeviceCodeResponse> {
    let url = format!("{endpoint}/auth/device");
    let resp = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(10))
        .connect_timeout(std::time::Duration::from_secs(8))
        .build()?
        .post(&url)
        .json(&serde_json::json!({ "client_id": client_id }))
        .send()
        .await?
        .error_for_status()?
        .json::<DeviceCodeResponse>()
        .await?;
    Ok(resp)
}

#[cfg(not(feature = "cloud-server"))]
pub async fn device_flow_start(_endpoint: &str, _client_id: &str) -> Result<DeviceCodeResponse> {
    bail!("cloud-server feature not enabled")
}

/// Poll for the token after the user authorises on the verification URI.
#[cfg(feature = "cloud-server")]
pub async fn device_flow_poll(
    endpoint: &str,
    client_id: &str,
    device_code: &str,
    interval_secs: u64,
) -> Result<TokenResponse> {
    let url = format!("{endpoint}/auth/token");
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(15))
        .build()?;
    let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(300);
    loop {
        if tokio::time::Instant::now() >= deadline {
            bail!("authorization timed out — run `bctx login` to try again");
        }
        tokio::time::sleep(tokio::time::Duration::from_secs(interval_secs)).await;
        let resp = match client
            .post(&url)
            .json(&serde_json::json!({
                "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
                "device_code": device_code,
                "client_id": client_id,
            }))
            .send()
            .await
        {
            Ok(r) => r,
            Err(e) if e.is_timeout() => {
                eprintln!("\n  (network timeout — retrying...)");
                continue;
            }
            Err(e) => return Err(e.into()),
        };
        if resp.status().is_success() {
            return Ok(resp.json::<TokenResponse>().await?);
        }
        let body: serde_json::Value = resp.json().await.unwrap_or_default();
        match body["error"].as_str() {
            Some("authorization_pending") => {
                print!(".");
                let _ = std::io::Write::flush(&mut std::io::stdout());
                continue;
            }
            Some("slow_down") => tokio::time::sleep(tokio::time::Duration::from_secs(5)).await,
            Some("expired_token") => bail!("device code expired — run `bctx login` to try again"),
            Some(e) => bail!("auth error: {e}"),
            None => bail!("unexpected auth response"),
        }
    }
}

#[cfg(not(feature = "cloud-server"))]
pub async fn device_flow_poll(
    _endpoint: &str,
    _client_id: &str,
    _device_code: &str,
    _interval_secs: u64,
) -> Result<TokenResponse> {
    bail!("cloud-server feature not enabled")
}

pub fn token_path() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
    PathBuf::from(home).join(TOKEN_FILE)
}

pub fn save_token(store: &TokenStore) -> Result<()> {
    let path = token_path();
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(&path, serde_json::to_string_pretty(store)?)?;
    Ok(())
}

pub fn load_token() -> Option<TokenStore> {
    let path = token_path();
    let data = std::fs::read_to_string(path).ok()?;
    serde_json::from_str(&data).ok()
}

pub fn clear_token() -> Result<()> {
    let path = token_path();
    if path.exists() {
        std::fs::remove_file(path)?;
    }
    Ok(())
}

#[cfg(feature = "cloud-server")]
pub async fn fetch_account(endpoint: &str, token: &str) -> Result<serde_json::Value> {
    let url = format!("{endpoint}/account/me");
    let resp = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(10))
        .connect_timeout(std::time::Duration::from_secs(8))
        .build()?
        .get(&url)
        .bearer_auth(token)
        .send()
        .await?
        .error_for_status()?
        .json::<serde_json::Value>()
        .await?;
    Ok(resp)
}

#[cfg(not(feature = "cloud-server"))]
pub async fn fetch_account(_endpoint: &str, _token: &str) -> Result<serde_json::Value> {
    bail!("cloud-server feature not enabled")
}