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:
co-authored by
Claude Fable 5
parent
67b80da695
commit
f3f08a8edd
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user