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
+13 -3
View File
@@ -129,6 +129,8 @@ fn catalog() -> Vec<DirectoryApp> {
pub struct DirectoryQuery {
#[serde(rename = "clawId")]
claw_id: Option<AgentId>,
/// When true (and no clawId), report workspace-wide connections.
workspace: Option<bool>,
}
/// GET /api/apps[?clawId=] — the connect directory (§8.2), with live
@@ -144,6 +146,9 @@ pub async fn directory(
cm_db::repo::connections::list_for_agent(&state.pool, user.workspace_id, agent.id)
.await?
}
None if query.workspace.unwrap_or(false) => {
cm_db::repo::connections::list_for_workspace(&state.pool, user.workspace_id).await?
}
None => Vec::new(),
};
let merged = catalog()
@@ -166,7 +171,7 @@ pub async fn directory(
#[derive(Deserialize)]
pub struct ConnectRequest {
#[serde(rename = "clawId")]
claw_id: AgentId,
claw_id: Option<AgentId>,
provider: String,
#[serde(rename = "authType")]
auth_type: String,
@@ -185,7 +190,12 @@ pub async fn connect(
if !matches!(body.auth_type.as_str(), "keys" | "basic") {
return Err(ApiError::Conflict);
}
let agent = workspace_agent(&state, &user, body.claw_id).await?;
// clawId present => per-claw connection; absent => workspace-wide
// (agent_id NULL), the /apps global page path.
let agent_id = match body.claw_id {
Some(claw_id) => Some(workspace_agent(&state, &user, claw_id).await?.id),
None => None,
};
let socket = state.broker_socket.as_ref().ok_or(ApiError::Internal)?;
let mut broker = cm_secrets::BrokerClient::connect(socket)
.await
@@ -201,7 +211,7 @@ pub async fn connect(
let connection = cm_db::repo::connections::insert(
&state.pool,
user.workspace_id,
Some(agent.id),
agent_id,
&body.provider,
&body.auth_type,
secret_ref,