R4 backend: team org-chart + leaderboard, Stripe credits, workspace apps

- GET /api/team/orgchart — each member grouped with the claws they manage
  (agents.managed_by); GET /api/team/leaderboard — every claw ranked by
  its real usage_events rollup (credits/tokens/runs, zeros included).
  Both tested against real Postgres.
- Stripe Buy-credits (the Slack/Clerk integration pattern): [billing]
  config (stripe keys + price + webhook secret + credits_per_pack);
  POST /api/credits/checkout opens a real Checkout Session; POST
  /api/billing/stripe verifies Stripe's t=,v1= HMAC (constant-time) and
  grants one credit lot, idempotent on the session id; GET
  /api/billing/config gates the button (honest degradation when unset).
  Offline tests: signed grant + replay no-double-grant + forged-sig 400 +
  config flag. Live checkout creation deferred to a CM_LIVE_STRIPE test.
- /apps global page support: clawId now optional on connect + directory;
  absent => workspace-wide connection (app_connections.agent_id NULL) via
  new connections::list_for_workspace.
- ApiError gains a From<sqlx::Error> so inline queries use ? cleanly.

cm-api 10 test files incl. team_tabs (2) + stripe_billing (3); clippy clean.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-10 19:58:09 -05:00
co-authored by Claude Fable 5
parent 67b80da695
commit f3f08a8edd
14 changed files with 813 additions and 3 deletions
+154
View File
@@ -1,7 +1,10 @@
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use cm_billing::BillingError;
use cm_db::repo::audit::Actor;
use hmac::{Hmac, Mac};
use serde::Deserialize;
use serde_json::{json, Value};
@@ -52,3 +55,154 @@ pub async fn usage(
"credits": credits,
})))
}
/// GET /api/billing/config — whether Buy-credits is available (the button
/// is hidden when Stripe is unconfigured; honest degradation).
pub async fn billing_config(State(state): State<AppState>) -> Json<Value> {
let enabled = state.billing.stripe_secret_key.is_some()
&& state.billing.stripe_price_id.is_some()
&& state.billing.stripe_webhook_secret.is_some();
Json(json!({
"buy_credits_enabled": enabled,
"credits_per_pack": state.billing.credits_per_pack,
}))
}
/// POST /api/credits/checkout — opens a real Stripe Checkout Session for
/// one credit pack and returns its URL. The completed payment arrives
/// asynchronously at the webhook below (the credit grant lives there, not
/// here — the session URL alone grants nothing).
pub async fn checkout(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let (Some(secret), Some(price), Some(base)) = (
state.billing.stripe_secret_key.as_deref(),
state.billing.stripe_price_id.as_deref(),
state.billing.return_base.as_deref(),
) else {
return Err(ApiError::Conflict); // Buy-credits not configured.
};
let form = [
("mode", "payment".to_owned()),
("line_items[0][price]", price.to_owned()),
("line_items[0][quantity]", "1".to_owned()),
(
"metadata[workspace_id]",
user.workspace_id.as_uuid().to_string(),
),
("success_url", format!("{base}/credits?purchase=success")),
("cancel_url", format!("{base}/credits")),
];
let response: Value = reqwest::Client::new()
.post("https://api.stripe.com/v1/checkout/sessions")
.basic_auth(secret, Some(""))
.form(&form)
.send()
.await
.map_err(|_| ApiError::Internal)?
.json()
.await
.map_err(|_| ApiError::Internal)?;
let url = response["url"].as_str().ok_or(ApiError::Internal)?;
Ok(Json(json!({ "url": url })))
}
/// Verifies Stripe's `t=…,v1=…` signature (HMAC-SHA256 over `<ts>.<body>`,
/// constant-time) against the webhook secret.
fn stripe_signature_valid(secret: &str, header: &str, body: &[u8]) -> bool {
let mut timestamp = "";
let mut provided = "";
for part in header.split(',') {
match part.split_once('=') {
Some(("t", v)) => timestamp = v,
Some(("v1", v)) => provided = v,
_ => {}
}
}
if timestamp.is_empty() || provided.is_empty() {
return false;
}
let Ok(mut mac) = Hmac::<sha2::Sha256>::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
let expected = hex::encode(mac.finalize().into_bytes());
expected.len() == provided.len()
&& expected
.bytes()
.zip(provided.bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
== 0
}
/// POST /api/billing/stripe — the Stripe webhook. Public by design;
/// authenticity is the signature. A verified `checkout.session.completed`
/// grants one credit pack, idempotent on the session id.
pub async fn stripe_webhook(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<StatusCode, StatusCode> {
let secret = state
.billing
.stripe_webhook_secret
.as_deref()
.ok_or(StatusCode::NOT_FOUND)?;
let signature = headers
.get("stripe-signature")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
if !stripe_signature_valid(secret, signature, &body) {
return Err(StatusCode::BAD_REQUEST);
}
let event: Value = serde_json::from_slice(&body).map_err(|_| StatusCode::BAD_REQUEST)?;
if event["type"] != "checkout.session.completed" {
return Ok(StatusCode::OK); // Acknowledge unrelated events.
}
let object = &event["data"]["object"];
let session_id = object["id"].as_str().ok_or(StatusCode::BAD_REQUEST)?;
let workspace_id = object["metadata"]["workspace_id"]
.as_str()
.and_then(|s| s.parse::<uuid::Uuid>().ok())
.ok_or(StatusCode::BAD_REQUEST)?;
// Idempotent on the session id (Stripe retries; replays must not
// double-grant). The unique source string is the dedupe key.
let source = format!("stripe:{session_id}");
let already: i64 = sqlx::query_scalar(
"SELECT count(*) FROM credit_lots WHERE workspace_id = $1 AND source = $2",
)
.bind(workspace_id)
.bind(&source)
.fetch_one(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if already > 0 {
return Ok(StatusCode::OK);
}
let workspace = cm_domain::WorkspaceId::from(workspace_id);
cm_db::repo::credits::add_lot(
&state.pool,
workspace,
state.billing.credits_per_pack,
&source,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
cm_db::repo::audit::append(
&state.pool,
workspace,
Actor::System,
"credits.stripe_purchase",
"credit_lot",
session_id,
json!({"credits": state.billing.credits_per_pack}),
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::OK)
}