bctx-cloud-core 0.1.24

bctx-cloud-core — cloud client and server for Vault sync, dashboard API, billing
Documentation
use axum::{
    extract::State,
    http::{HeaderMap, StatusCode},
    response::IntoResponse,
    Json,
};
use serde::{Deserialize, Serialize};
use weave::lenses::{apply_stack, clarity::ClarityLens, narrow::NarrowLens, Lens, LensContext};

use crate::server::{routes::auth, AppState};

// Daily request limits per caller class
const LIMIT_ANON: i64 = 100;
const LIMIT_FREE: i64 = 500;
const LIMIT_BEACON: i64 = 2_000;
const LIMIT_STUDIO: i64 = 10_000;
// enterprise = i64::MAX (unlimited)

const MAX_CONTENT_BYTES: usize = 2 * 1024 * 1024; // 2 MB

#[derive(Debug, Deserialize)]
pub struct CompressRequest {
    pub content: String,
    /// Target token budget for the output (default: 2000).
    pub budget_tokens: Option<usize>,
}

#[derive(Debug, Serialize)]
pub struct CompressResponse {
    pub compressed: String,
    pub tokens_before: usize,
    pub tokens_after: usize,
    pub savings_pct: f64,
}

/// Extract the real client IP, preferring the X-Forwarded-For header set by
/// Railway's load balancer over the socket address we can't access here.
fn client_ip(headers: &HeaderMap) -> String {
    headers
        .get("x-forwarded-for")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.split(',').next())
        .map(|s| s.trim().to_string())
        .unwrap_or_else(|| "unknown".to_string())
}

/// POST /compress
///
/// Compresses arbitrary text through the Weave Clarity→Narrow lens pipeline.
/// No auth required — anonymous callers are rate-limited by IP.
/// Authenticated callers (Bearer JWT) get tier-based higher limits and have
/// their usage recorded in the dashboard.
///
/// Rate limits (requests / day):
///   anonymous  → 100
///   free       → 500
///   beacon+    → 2 000
///   studio     → 10 000
///   enterprise → unlimited
pub async fn compress(
    State(state): State<AppState>,
    headers: HeaderMap,
    Json(req): Json<CompressRequest>,
) -> impl IntoResponse {
    // Hard size cap — reject before doing any work
    if req.content.len() > MAX_CONTENT_BYTES {
        return (
            StatusCode::PAYLOAD_TOO_LARGE,
            Json(serde_json::json!({
                "error": "content exceeds 2 MB limit — install bctx CLI for local compression"
            })),
        )
            .into_response();
    }

    // Resolve caller identity and daily limit
    let token = headers
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .map(String::from);

    let (rate_key, daily_limit, authenticated_uid) = match token {
        Some(ref t) => match auth::verify_jwt(t, &state.jwt_secret) {
            Some(uid) => {
                let tier = state
                    .db
                    .get_user(&uid)
                    .map(|u| u.tier)
                    .unwrap_or_else(|| "free".to_string());
                let limit = match tier.as_str() {
                    "enterprise" => i64::MAX,
                    "studio" => LIMIT_STUDIO,
                    "beacon" => LIMIT_BEACON,
                    _ => LIMIT_FREE,
                };
                (uid.clone(), limit, Some(uid))
            }
            None => {
                let ip = format!("ip:{}", client_ip(&headers));
                (ip, LIMIT_ANON, None)
            }
        },
        None => {
            let ip = format!("ip:{}", client_ip(&headers));
            (ip, LIMIT_ANON, None)
        }
    };

    // Check rate limit
    if !state.db.check_compress_rate_limit(&rate_key, daily_limit) {
        return (
            StatusCode::TOO_MANY_REQUESTS,
            Json(serde_json::json!({
                "error": "daily limit reached",
                "hint": "Sign in at betterctx.com for higher limits, or install the bctx CLI for unlimited local compression"
            })),
        )
            .into_response();
    }

    // Run Clarity → Narrow lens stack
    let budget = req.budget_tokens.unwrap_or(2000);
    let ctx = LensContext::new(budget);
    let lenses: Vec<Box<dyn Lens>> = vec![Box::new(ClarityLens), Box::new(NarrowLens)];
    let output = apply_stack(&lenses, &req.content, &ctx);

    let tokens_saved = output.tokens_before.saturating_sub(output.tokens_after) as i64;

    // Record usage — for authenticated users this feeds into the dashboard;
    // for anonymous we record with the ip: key purely for rate limiting.
    let record_key = authenticated_uid.as_deref().unwrap_or(&rate_key);
    let _ = state.db.record_usage(
        record_key,
        output.tokens_before as i64,
        tokens_saved,
        "web_compress",
    );

    let savings_pct = output.savings_pct();
    (
        StatusCode::OK,
        [("access-control-allow-origin", "*")],
        Json(CompressResponse {
            compressed: output.content,
            tokens_before: output.tokens_before,
            tokens_after: output.tokens_after,
            savings_pct,
        }),
    )
        .into_response()
}