use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, PoisonError};
use std::time::{Duration, Instant};
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use serde_json::{Value, json};
use uuid::Uuid;
use crate::core::billing::Plan;
use super::auth::{AppState, auth_user};
use super::config::Config;
pub(super) async fn resolve_plan(cfg: &Config, user_id: Uuid) -> Plan {
resolve_entitlements_raw(cfg, user_id)
.await
.and_then(|v| v.get("plan").and_then(Value::as_str).map(Plan::parse))
.unwrap_or(Plan::Free)
}
const ENTITLEMENTS_CACHE_TTL: Duration = Duration::from_mins(1);
const ENTITLEMENTS_CACHE_MAX: usize = 50_000;
const ENTITLEMENTS_STALE_RETAIN: Duration = Duration::from_hours(1);
struct CachedEntitlements {
at: Instant,
value: Value,
}
type EntitlementsCacheSlot = Mutex<HashMap<Uuid, CachedEntitlements>>;
static ENTITLEMENTS_CACHE: LazyLock<EntitlementsCacheSlot> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn entitlements_cache_fresh(
slot: &EntitlementsCacheSlot,
user_id: Uuid,
now: Instant,
) -> Option<Value> {
let guard = slot.lock().unwrap_or_else(PoisonError::into_inner);
guard
.get(&user_id)
.filter(|e| now.duration_since(e.at) < ENTITLEMENTS_CACHE_TTL)
.map(|e| e.value.clone())
}
fn entitlements_cache_any(slot: &EntitlementsCacheSlot, user_id: Uuid) -> Option<Value> {
let guard = slot.lock().unwrap_or_else(PoisonError::into_inner);
guard.get(&user_id).map(|e| e.value.clone())
}
fn entitlements_cache_store(
slot: &EntitlementsCacheSlot,
user_id: Uuid,
now: Instant,
value: &Value,
) {
let mut guard = slot.lock().unwrap_or_else(PoisonError::into_inner);
guard.insert(
user_id,
CachedEntitlements {
at: now,
value: value.clone(),
},
);
if guard.len() > ENTITLEMENTS_CACHE_MAX {
prune_entitlements_cache(&mut guard, now);
}
}
fn prune_entitlements_cache(map: &mut HashMap<Uuid, CachedEntitlements>, now: Instant) {
map.retain(|_, e| now.duration_since(e.at) < ENTITLEMENTS_STALE_RETAIN);
}
async fn resolve_entitlements_raw(cfg: &Config, user_id: Uuid) -> Option<Value> {
let (Some(base), Some(key)) = (
cfg.billing_base_url.clone(),
cfg.billing_internal_key.clone(),
) else {
return None;
};
let now = Instant::now();
if let Some(cached) = entitlements_cache_fresh(&ENTITLEMENTS_CACHE, user_id, now) {
return Some(cached);
}
let url = format!("{base}/api/billing/entitlements/{user_id}");
let fetched = tokio::task::spawn_blocking(move || {
ureq::get(&url)
.header("X-Internal-Key", &key)
.call()
.ok()?
.into_body()
.read_to_string()
.ok()
})
.await
.ok()
.flatten()
.and_then(|body| serde_json::from_str::<Value>(&body).ok());
match fetched {
Some(value) => {
entitlements_cache_store(&ENTITLEMENTS_CACHE, user_id, now, &value);
Some(value)
}
None => entitlements_cache_any(&ENTITLEMENTS_CACHE, user_id),
}
}
pub(super) async fn billing_delete_account(
cfg: &Config,
user_id: Uuid,
) -> Result<Option<Value>, (StatusCode, String)> {
let (Some(base), Some(key)) = (
cfg.billing_base_url.clone(),
cfg.billing_internal_key.clone(),
) else {
return Ok(None);
};
let url = format!("{base}/api/billing/account/{user_id}");
let body = tokio::task::spawn_blocking(move || {
ureq::delete(&url)
.header("X-Internal-Key", &key)
.call()
.map_err(|e| e.to_string())?
.into_body()
.read_to_string()
.map_err(|e| e.to_string())
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("join: {e}")))?
.map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("billing deletion failed — account NOT deleted, please retry: {e}"),
)
})?;
Ok(Some(serde_json::from_str(&body).unwrap_or(Value::Null)))
}
fn sync_is_open(cfg: &Config) -> bool {
cfg.sync_open || cfg.billing_base_url.is_none()
}
pub(super) fn cloud_sync_allowed(cfg: &Config, plan: Plan) -> bool {
sync_is_open(cfg) || plan.entitlements().cloud_sync
}
pub(super) async fn require_cloud_sync(
state: &AppState,
headers: &HeaderMap,
) -> Result<(Uuid, String), (StatusCode, String)> {
let (user_id, email) = auth_user(state, headers).await?;
let plan = if sync_is_open(&state.cfg) {
Plan::Free
} else {
resolve_plan(&state.cfg, user_id).await
};
if cloud_sync_allowed(&state.cfg, plan) {
return Ok((user_id, email));
}
Err((
StatusCode::PAYMENT_REQUIRED,
format!(
"cloud sync requires lean-ctx Pro (current plan: {}). \
Run `lean-ctx upgrade` to enable hosted cross-device sync.",
plan.as_str()
),
))
}
pub(super) async fn hosted_index_quota_mb(state: &AppState, user_id: Uuid) -> u32 {
if !sync_is_open(&state.cfg) {
let quota = resolve_plan(&state.cfg, user_id)
.await
.entitlements()
.hosted_index_mb;
if quota > 0 {
return quota;
}
}
1_000
}
pub(super) async fn get_account_entitlements(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let raw = resolve_entitlements_raw(&state.cfg, user_id).await;
let plan = raw
.as_ref()
.and_then(|v| v.get("plan").and_then(Value::as_str).map(Plan::parse))
.unwrap_or(Plan::Free);
let org = raw
.as_ref()
.and_then(|v| v.get("org").cloned())
.unwrap_or(Value::Null);
let subscription = raw
.as_ref()
.and_then(|v| v.get("subscription").cloned())
.unwrap_or(Value::Null);
Ok(Json(json!({
"plan": plan.as_str(),
"entitlements": plan.entitlements(),
"org": org,
"subscription": subscription,
})))
}
async fn billing_post(
cfg: &Config,
path: &str,
payload: Value,
) -> Result<Value, (StatusCode, String)> {
let (Some(base), Some(key)) = (
cfg.billing_base_url.clone(),
cfg.billing_internal_key.clone(),
) else {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"billing is not enabled on this deployment".to_string(),
));
};
let url = format!("{base}{path}");
let bytes = serde_json::to_vec(&payload)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("encode: {e}")))?;
let text = tokio::task::spawn_blocking(move || {
ureq::post(&url)
.header("X-Internal-Key", &key)
.header("Content-Type", "application/json")
.send(&bytes)
.map_err(|e| e.to_string())?
.into_body()
.read_to_string()
.map_err(|e| e.to_string())
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("join: {e}")))?
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("billing upstream: {e}")))?;
serde_json::from_str::<Value>(&text).map_err(|e| {
(
StatusCode::BAD_GATEWAY,
format!("billing returned non-JSON: {e}"),
)
})
}
#[derive(Deserialize)]
pub(super) struct CheckoutBody {
plan: String,
#[serde(default)]
interval: Option<String>,
}
pub(super) async fn post_account_checkout(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<CheckoutBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, email) = auth_user(&state, &headers).await?;
let payload = json!({
"user_id": user_id,
"email": email,
"plan": body.plan,
"interval": body.interval,
});
Ok(Json(
billing_post(&state.cfg, "/api/billing/checkout", payload).await?,
))
}
pub(super) async fn post_account_portal(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let payload = json!({ "user_id": user_id });
Ok(Json(
billing_post(&state.cfg, "/api/billing/portal", payload).await?,
))
}
async fn billing_forward(
cfg: &Config,
method: &'static str,
path: String,
payload: Option<Value>,
) -> Result<(StatusCode, Value), (StatusCode, String)> {
let (Some(base), Some(key)) = (
cfg.billing_base_url.clone(),
cfg.billing_internal_key.clone(),
) else {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"billing is not enabled on this deployment".to_string(),
));
};
let url = format!("{base}{path}");
let (code, text) = tokio::task::spawn_blocking(move || -> Result<(u16, String), String> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.build()
.into();
let resp = match method {
"GET" => agent.get(&url).header("X-Internal-Key", &key).call(),
"DELETE" => agent.delete(&url).header("X-Internal-Key", &key).call(),
_ => {
let bytes = serde_json::to_vec(&payload.unwrap_or_else(|| json!({})))
.map_err(|e| e.to_string())?;
let builder = match method {
"PATCH" => agent.patch(&url),
"PUT" => agent.put(&url),
_ => agent.post(&url),
};
builder
.header("X-Internal-Key", &key)
.header("Content-Type", "application/json")
.send(&bytes)
}
}
.map_err(|e| e.to_string())?;
let code = resp.status().as_u16();
let body = resp
.into_body()
.read_to_string()
.map_err(|e| e.to_string())?;
Ok((code, body))
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("join: {e}")))?
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("billing upstream: {e}")))?;
let json = serde_json::from_str::<Value>(&text).unwrap_or(Value::Null);
let status = StatusCode::from_u16(code).unwrap_or(StatusCode::BAD_GATEWAY);
Ok((status, json))
}
fn finish(status: StatusCode, json: Value) -> Result<Json<Value>, (StatusCode, String)> {
if status.is_success() {
return Ok(Json(json));
}
let msg = json
.get("error")
.and_then(Value::as_str)
.or_else(|| json.get("message").and_then(Value::as_str))
.unwrap_or("team request failed")
.to_string();
Err((status, msg))
}
#[derive(Deserialize)]
pub(super) struct MemberBody {
#[serde(default)]
role: Option<String>,
#[serde(default)]
label: Option<String>,
}
pub(super) async fn get_account_team(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_team_savings(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}/savings"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_team_savings_member(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::Path(signer): axum::extract::Path<String>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
if signer.is_empty()
|| signer.len() > 64
|| !signer
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return Err((StatusCode::BAD_REQUEST, "invalid signer id".into()));
}
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}/savings/member/{signer}"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn forward_for_digest(cfg: &Config, path: String) -> Option<(u16, Value)> {
match billing_forward(cfg, "GET", path, None).await {
Ok((status, json)) => Some((status.as_u16(), json)),
Err(_) => None,
}
}
#[derive(Deserialize)]
pub(super) struct TeamSettingsBody {
#[serde(default, rename = "roiWebhookUrl")]
roi_webhook_url: Option<String>,
}
pub(super) async fn put_account_team_settings(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<TeamSettingsBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PUT",
format!("/api/billing/team/{user_id}/settings"),
Some(json!({ "roiWebhookUrl": body.roi_webhook_url })),
)
.await?;
finish(status, json)
}
pub(super) async fn post_account_team_owner_token(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/team/{user_id}/owner-token"),
Some(json!({})),
)
.await?;
finish(status, json)
}
pub(super) async fn post_account_team_member(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<MemberBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/team/{user_id}/tokens"),
Some(json!({ "role": body.role, "label": body.label })),
)
.await?;
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct InviteBody {
#[serde(default)]
label: Option<String>,
#[serde(default)]
role: Option<String>,
}
pub(super) async fn post_account_team_invite(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<InviteBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/team/{user_id}/invites"),
Some(json!({ "label": body.label, "role": body.role })),
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_team_invites(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}/invites"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn delete_account_team_invite(
State(state): State<AppState>,
headers: HeaderMap,
Path(invite_id): Path<Uuid>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/team/{user_id}/invites/{invite_id}"),
None,
)
.await?;
if status == StatusCode::NO_CONTENT {
return Ok(Json(json!({ "revoked": true })));
}
finish(status, json)
}
pub(super) async fn forward_invite_redeem(
cfg: &Config,
code: &str,
) -> Result<(StatusCode, Value), (StatusCode, String)> {
billing_forward(
cfg,
"POST",
"/api/billing/invites/redeem".to_string(),
Some(json!({ "code": code })),
)
.await
}
#[derive(Deserialize)]
pub(super) struct OrgSsoBody {
email_domain: String,
issuer: String,
client_id: String,
#[serde(default)]
client_secret: Option<String>,
}
pub(super) async fn get_account_org_sso(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/org/{user_id}/sso"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn put_account_org_sso(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<OrgSsoBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PUT",
format!("/api/billing/org/{user_id}/sso"),
Some(json!({
"email_domain": body.email_domain,
"issuer": body.issuer,
"client_id": body.client_id,
"client_secret": body.client_secret,
})),
)
.await?;
finish(status, json)
}
pub(super) async fn post_account_org_sso_verify(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/org/{user_id}/sso/verify"),
Some(json!({})),
)
.await?;
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct OrgSsoRequiredBody {
sso_required: bool,
}
pub(super) async fn put_account_org_sso_required(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<OrgSsoRequiredBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PUT",
format!("/api/billing/org/{user_id}/sso/required"),
Some(json!({ "sso_required": body.sso_required })),
)
.await?;
finish(status, json)
}
pub(super) async fn delete_account_org_sso(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/org/{user_id}/sso"),
None,
)
.await?;
if status == StatusCode::NO_CONTENT {
return Ok(Json(json!({ "removed": true })));
}
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct AuditQuery {
#[serde(default)]
before: Option<i64>,
#[serde(default)]
limit: Option<i64>,
#[serde(default)]
event: Option<String>,
}
fn build_audit_query(q: &AuditQuery) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(b) = q.before
&& b > 0
{
parts.push(format!("before={b}"));
}
if let Some(l) = q.limit {
parts.push(format!("limit={}", l.clamp(1, 200)));
}
if let Some(ev) = q.event.as_deref() {
let ev = ev.trim();
if !ev.is_empty()
&& ev.len() <= 48
&& ev.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')
{
parts.push(format!("event={ev}"));
}
}
if parts.is_empty() {
String::new()
} else {
format!("?{}", parts.join("&"))
}
}
pub(super) async fn get_account_org_audit(
State(state): State<AppState>,
headers: HeaderMap,
Query(q): Query<AuditQuery>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let qs = build_audit_query(&q);
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/org/{user_id}/audit{qs}"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_org_audit_export(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Response, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, body) = billing_forward_text(
&state.cfg,
format!("/api/billing/org/{user_id}/audit/export.csv"),
)
.await?;
if !status.is_success() {
return Err((status, "audit export failed".to_string()));
}
Ok((
[
(header::CONTENT_TYPE, "text/csv; charset=utf-8"),
(
header::CONTENT_DISPOSITION,
"attachment; filename=\"leanctx-audit-log.csv\"",
),
],
body,
)
.into_response())
}
#[derive(Deserialize)]
pub(super) struct RegistryNamespaceBody {
namespace: String,
#[serde(default)]
org_id: Option<String>,
}
pub(super) async fn put_account_registry_namespace(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RegistryNamespaceBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PUT",
format!("/api/billing/registry/{user_id}/namespace"),
Some(json!({ "namespace": body.namespace, "org_id": body.org_id })),
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_registry(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/registry/{user_id}"),
None,
)
.await?;
finish(status, json)
}
#[derive(Deserialize, Default)]
pub(super) struct RegistryTokenBody {
#[serde(default)]
label: Option<String>,
#[serde(default)]
scope: Option<String>,
}
pub(super) async fn post_account_registry_token(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RegistryTokenBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/registry/{user_id}/tokens"),
Some(json!({ "label": body.label, "scope": body.scope })),
)
.await?;
finish(status, json)
}
pub(super) async fn delete_account_registry_token(
State(state): State<AppState>,
headers: HeaderMap,
Path(token_id): Path<i64>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/registry/{user_id}/tokens/{token_id}"),
None,
)
.await?;
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct RegistryPriceBody {
name: String,
#[serde(default)]
price_cents: Option<i32>,
}
pub(super) async fn put_account_registry_price(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RegistryPriceBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PUT",
format!("/api/billing/registry/{user_id}/price"),
Some(json!({ "name": body.name, "price_cents": body.price_cents })),
)
.await?;
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct RegistryBuyBody {
namespace: String,
name: String,
}
pub(super) async fn post_account_registry_buy(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RegistryBuyBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/registry/{user_id}/buy"),
Some(json!({
"namespace": body.namespace,
"name": body.name,
"email": email,
})),
)
.await?;
finish(status, json)
}
#[derive(Deserialize)]
pub(super) struct RegistryDomainBody {
domain: String,
}
pub(super) async fn post_account_registry_domain(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<RegistryDomainBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/registry/{user_id}/domains"),
Some(json!({ "domain": body.domain })),
)
.await?;
finish(status, json)
}
pub(super) async fn post_account_registry_domain_verify(
State(state): State<AppState>,
headers: HeaderMap,
Path(domain_id): Path<i64>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/registry/{user_id}/domains/{domain_id}/verify"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn delete_account_registry_domain(
State(state): State<AppState>,
headers: HeaderMap,
Path(domain_id): Path<i64>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/registry/{user_id}/domains/{domain_id}"),
None,
)
.await?;
finish(status, json)
}
async fn billing_forward_text(
cfg: &Config,
path: String,
) -> Result<(StatusCode, String), (StatusCode, String)> {
let (Some(base), Some(key)) = (
cfg.billing_base_url.clone(),
cfg.billing_internal_key.clone(),
) else {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"billing is not enabled on this deployment".to_string(),
));
};
let url = format!("{base}{path}");
let (code, text) = tokio::task::spawn_blocking(move || -> Result<(u16, String), String> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.build()
.into();
let resp = agent
.get(&url)
.header("X-Internal-Key", &key)
.call()
.map_err(|e| e.to_string())?;
let code = resp.status().as_u16();
let body = resp
.into_body()
.read_to_string()
.map_err(|e| e.to_string())?;
Ok((code, body))
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("join: {e}")))?
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("billing upstream: {e}")))?;
let status = StatusCode::from_u16(code).unwrap_or(StatusCode::BAD_GATEWAY);
Ok((status, text))
}
const SUPPORTERS_CACHE_TTL: Duration = Duration::from_mins(5);
const SUPPORTER_NAME_MAX: usize = 80;
const SUPPORTER_MESSAGE_MAX: usize = 140;
const SUPPORTER_META_MAX: usize = 40;
type SupportersCacheSlot = Mutex<Option<(Instant, Value)>>;
static SUPPORTERS_CACHE: SupportersCacheSlot = Mutex::new(None);
fn supporters_cache_fresh(slot: &SupportersCacheSlot, now: Instant) -> Option<Value> {
let guard = slot.lock().unwrap_or_else(PoisonError::into_inner);
guard
.as_ref()
.filter(|(at, _)| now.duration_since(*at) < SUPPORTERS_CACHE_TTL)
.map(|(_, v)| v.clone())
}
fn supporters_cache_store(slot: &SupportersCacheSlot, now: Instant, value: &Value) {
*slot.lock().unwrap_or_else(PoisonError::into_inner) = Some((now, value.clone()));
}
fn supporters_cache_last(slot: &SupportersCacheSlot) -> Option<Value> {
slot.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
.map(|(_, v)| v.clone())
}
fn sanitize_supporter_text(raw: &str, max: usize) -> String {
let mut plain = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
if c == '<'
&& matches!(chars.peek(), Some(n) if n.is_ascii_alphabetic() || *n == '/' || *n == '!')
{
for tag_char in chars.by_ref() {
if tag_char == '>' {
break;
}
}
plain.push(' ');
continue;
}
plain.push(if c.is_control() { ' ' } else { c });
}
let mut out = String::with_capacity(plain.len().min(max * 4));
let mut count = 0usize;
let mut last_was_space = true; for c in plain.chars() {
let c = if c.is_whitespace() { ' ' } else { c };
if c == ' ' && last_was_space {
continue;
}
last_was_space = c == ' ';
out.push(c);
count += 1;
if count == max {
break;
}
}
while out.ends_with(' ') {
out.pop();
}
out
}
fn sanitize_supporters_payload(raw: &Value) -> Value {
let supporters: Vec<Value> = raw
.get("supporters")
.and_then(Value::as_array)
.map(|list| {
list.iter()
.filter_map(|entry| {
if !entry.is_object() {
return None;
}
let text = |field: &str, max: usize| {
sanitize_supporter_text(
entry.get(field).and_then(Value::as_str).unwrap_or(""),
max,
)
};
let message = text("message", SUPPORTER_MESSAGE_MAX);
Some(json!({
"name": text("name", SUPPORTER_NAME_MAX),
"message": if message.is_empty() { Value::Null } else { Value::String(message) },
"tier": text("tier", SUPPORTER_META_MAX),
"amount_cents": entry.get("amount_cents").and_then(Value::as_i64).unwrap_or(0).max(0),
"currency": text("currency", SUPPORTER_META_MAX),
"created_at": text("created_at", SUPPORTER_META_MAX),
}))
})
.collect()
})
.unwrap_or_default();
json!({ "count": supporters.len(), "supporters": supporters })
}
pub(super) async fn get_supporters(State(state): State<AppState>) -> Response {
let (Some(base), Some(key)) = (
state.cfg.billing_base_url.clone(),
state.cfg.billing_internal_key.clone(),
) else {
return Json(json!({ "supporters": [], "count": 0 })).into_response();
};
let now = Instant::now();
if let Some(fresh) = supporters_cache_fresh(&SUPPORTERS_CACHE, now) {
return Json(fresh).into_response();
}
let url = format!("{base}/api/billing/supporters");
let body = tokio::task::spawn_blocking(move || {
ureq::get(&url)
.header("X-Internal-Key", &key)
.call()
.ok()?
.into_body()
.read_to_string()
.ok()
})
.await
.ok()
.flatten();
match body.and_then(|b| serde_json::from_str::<Value>(&b).ok()) {
Some(raw) => {
let clean = sanitize_supporters_payload(&raw);
supporters_cache_store(&SUPPORTERS_CACHE, now, &clean);
Json(clean).into_response()
}
None => match supporters_cache_last(&SUPPORTERS_CACHE) {
Some(stale) => Json(stale).into_response(),
None => (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "error": "supporters_unavailable" })),
)
.into_response(),
},
}
}
#[derive(Deserialize)]
pub(super) struct SupporterCheckoutBody {
#[serde(default)]
amount_cents: i64,
}
pub(super) async fn post_supporter_checkout(
State(state): State<AppState>,
Json(body): Json<SupporterCheckoutBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
let amount = body.amount_cents.clamp(100, 100_000);
Ok(Json(
billing_post(
&state.cfg,
"/api/billing/supporters/checkout",
json!({ "amount_cents": amount }),
)
.await?,
))
}
pub(super) async fn delete_account_team_member(
State(state): State<AppState>,
headers: HeaderMap,
Path(token_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/team/{user_id}/tokens/{token_id}"),
None,
)
.await?;
if status.is_success() {
return Ok(Json(json!({ "revoked": true })));
}
finish(status, json)
}
pub(super) async fn post_account_team_seats(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/team/{user_id}/seats"),
Some(body),
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_team_storage(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}/storage"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn get_account_team_connectors(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"GET",
format!("/api/billing/team/{user_id}/connectors"),
None,
)
.await?;
finish(status, json)
}
pub(super) async fn post_account_team_connector(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"POST",
format!("/api/billing/team/{user_id}/connectors"),
Some(body),
)
.await?;
finish(status, json)
}
pub(super) async fn patch_account_team_connector(
State(state): State<AppState>,
headers: HeaderMap,
Path(connector_id): Path<String>,
Json(body): Json<Value>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"PATCH",
format!("/api/billing/team/{user_id}/connectors/{connector_id}"),
Some(body),
)
.await?;
if status.is_success() {
return Ok(Json(json!({ "updated": true })));
}
finish(status, json)
}
pub(super) async fn delete_account_team_connector(
State(state): State<AppState>,
headers: HeaderMap,
Path(connector_id): Path<String>,
) -> Result<Json<Value>, (StatusCode, String)> {
let (user_id, _email) = auth_user(&state, &headers).await?;
let (status, json) = billing_forward(
&state.cfg,
"DELETE",
format!("/api/billing/team/{user_id}/connectors/{connector_id}"),
None,
)
.await?;
if status.is_success() {
return Ok(Json(json!({ "deleted": true })));
}
finish(status, json)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(billing: bool, sync_open: bool) -> Config {
Config {
bind_host: "127.0.0.1".into(),
bind_port: 8088,
public_base_url: String::new(),
api_base_url: String::new(),
database_url: String::new(),
ip_hash_salt: String::new(),
smtp_host: None,
smtp_port: None,
smtp_username: None,
smtp_password: None,
smtp_from: None,
billing_base_url: billing.then(|| "https://billing.example".to_string()),
billing_internal_key: billing.then(|| "internal-key".to_string()),
sync_open,
}
}
#[test]
fn gated_deployment_blocks_free_and_supporter_only() {
let gated = cfg(true, false);
assert!(!cloud_sync_allowed(&gated, Plan::Free));
assert!(!cloud_sync_allowed(&gated, Plan::Supporter));
assert!(cloud_sync_allowed(&gated, Plan::Pro));
assert!(cloud_sync_allowed(&gated, Plan::Team));
assert!(cloud_sync_allowed(&gated, Plan::Enterprise));
}
#[test]
fn no_billing_plane_never_gates_sync() {
let open = cfg(false, false);
assert!(sync_is_open(&open));
assert!(cloud_sync_allowed(&open, Plan::Free));
}
#[test]
fn operator_opt_out_opens_sync_even_with_billing() {
let opt_out = cfg(true, true);
assert!(sync_is_open(&opt_out));
assert!(cloud_sync_allowed(&opt_out, Plan::Free));
}
#[test]
fn supporter_text_strips_html_and_control_chars() {
let dirty = "Eve <script>alert('x')</script>\u{0007}\n<b>!</b>";
assert_eq!(
sanitize_supporter_text(dirty, SUPPORTER_NAME_MAX),
"Eve alert('x') !"
);
assert_eq!(
sanitize_supporter_text("i <3 rust & you", SUPPORTER_MESSAGE_MAX),
"i <3 rust & you"
);
}
#[test]
fn supporter_text_clamps_length_on_char_boundaries() {
let long_name = "ä".repeat(90);
let clamped = sanitize_supporter_text(&long_name, SUPPORTER_NAME_MAX);
assert_eq!(clamped.chars().count(), SUPPORTER_NAME_MAX);
let long_message = "m".repeat(500);
assert_eq!(
sanitize_supporter_text(&long_message, SUPPORTER_MESSAGE_MAX).len(),
SUPPORTER_MESSAGE_MAX
);
assert_eq!(
sanitize_supporter_text(" a \t\t b\n\nc ", SUPPORTER_NAME_MAX),
"a b c"
);
}
#[test]
fn supporters_payload_is_whitelisted_and_recounted() {
let raw = json!({
"supporters": [
{
"name": "<b>Ada</b>",
"message": "",
"tier": "Sponsor",
"amount_cents": 2500,
"currency": "usd",
"created_at": "2026-05-01T10:00:00Z",
"email": "leak@example.com"
},
"not-an-object"
],
"count": 99
});
let clean = sanitize_supporters_payload(&raw);
assert_eq!(clean["count"], 1);
assert_eq!(clean["supporters"].as_array().map(Vec::len), Some(1));
let s = &clean["supporters"][0];
assert_eq!(s["name"], "Ada");
assert!(s["message"].is_null());
assert_eq!(s["tier"], "Sponsor");
assert_eq!(s["amount_cents"], 2500);
assert_eq!(s["currency"], "usd");
assert_eq!(s["created_at"], "2026-05-01T10:00:00Z");
assert!(s.get("email").is_none());
}
#[test]
fn supporters_payload_handles_malformed_upstream_shapes() {
let clean = sanitize_supporters_payload(&json!({ "unexpected": true }));
assert_eq!(clean["count"], 0);
assert_eq!(clean["supporters"].as_array().map(Vec::len), Some(0));
}
#[test]
fn supporters_cache_hit_expiry_and_stale_fallback() {
let slot: SupportersCacheSlot = Mutex::new(None);
let t0 = Instant::now();
assert!(supporters_cache_fresh(&slot, t0).is_none());
assert!(supporters_cache_last(&slot).is_none());
let wall = json!({ "count": 1, "supporters": [{ "name": "Ada" }] });
supporters_cache_store(&slot, t0, &wall);
let just_before = (t0 + SUPPORTERS_CACHE_TTL)
.checked_sub(Duration::from_secs(1))
.unwrap();
assert_eq!(
supporters_cache_fresh(&slot, just_before),
Some(wall.clone())
);
assert!(supporters_cache_fresh(&slot, t0 + SUPPORTERS_CACHE_TTL).is_none());
assert_eq!(supporters_cache_last(&slot), Some(wall.clone()));
let t1 = t0 + SUPPORTERS_CACHE_TTL + Duration::from_secs(10);
supporters_cache_store(&slot, t1, &wall);
assert_eq!(supporters_cache_fresh(&slot, t1), Some(wall));
}
#[test]
fn entitlements_cache_fresh_then_expiry_then_stale_fallback() {
let slot: EntitlementsCacheSlot = Mutex::new(HashMap::new());
let uid = Uuid::new_v4();
let t0 = Instant::now();
assert!(entitlements_cache_fresh(&slot, uid, t0).is_none());
assert!(entitlements_cache_any(&slot, uid).is_none());
let pro = json!({ "plan": "pro", "entitlements": { "cloud_sync": true } });
entitlements_cache_store(&slot, uid, t0, &pro);
let just_before = (t0 + ENTITLEMENTS_CACHE_TTL)
.checked_sub(Duration::from_secs(1))
.unwrap();
assert_eq!(
entitlements_cache_fresh(&slot, uid, just_before),
Some(pro.clone())
);
assert!(entitlements_cache_fresh(&slot, uid, t0 + ENTITLEMENTS_CACHE_TTL).is_none());
assert_eq!(entitlements_cache_any(&slot, uid), Some(pro));
}
#[test]
fn entitlements_cache_stale_fallback_is_per_user() {
let slot: EntitlementsCacheSlot = Mutex::new(HashMap::new());
let seen = Uuid::new_v4();
let never_seen = Uuid::new_v4();
let t0 = Instant::now();
entitlements_cache_store(&slot, seen, t0, &json!({ "plan": "pro" }));
assert_eq!(
entitlements_cache_any(&slot, seen),
Some(json!({ "plan": "pro" }))
);
assert!(entitlements_cache_any(&slot, never_seen).is_none());
}
#[test]
fn prune_entitlements_cache_evicts_only_very_old_entries() {
let mut map: HashMap<Uuid, CachedEntitlements> = HashMap::new();
let t0 = Instant::now();
let later = t0 + ENTITLEMENTS_STALE_RETAIN + Duration::from_secs(1);
let old = Uuid::new_v4();
let recent = Uuid::new_v4();
map.insert(
old,
CachedEntitlements {
at: t0,
value: json!({ "plan": "team" }),
},
);
map.insert(
recent,
CachedEntitlements {
at: later,
value: json!({ "plan": "pro" }),
},
);
prune_entitlements_cache(&mut map, later);
assert!(
!map.contains_key(&old),
"entries past the stale-retain window are dropped"
);
assert!(map.contains_key(&recent), "recent entries are kept");
}
}