bctx-cloud-core 0.1.6

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 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 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::new()
        .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::new();
    loop {
        tokio::time::sleep(tokio::time::Duration::from_secs(interval_secs)).await;
        let resp = 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?;
        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(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::new()
        .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")
}