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
@@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "SELECT u.id AS user_id, u.display_name, u.role, u.email,\n a.id AS claw_id, a.name AS claw_name, a.job_title, a.accent\n FROM users u\n JOIN agents a ON a.managed_by = u.id\n WHERE u.workspace_id = $1\n ORDER BY u.display_name, a.name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "display_name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "role",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "email",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "claw_id",
"type_info": "Uuid"
},
{
"ordinal": 5,
"name": "claw_name",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "job_title",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "accent",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "92a267bd4b7396576c1ef86ec90cb04fcc25a653a03d87fb43f139f1995464d5"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref\n FROM app_connections\n WHERE workspace_id = $1 AND agent_id IS NULL AND status = 'connected'\n ORDER BY created_at",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "agent_id",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "provider",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "auth_type",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "status",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "secret_ref",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
true,
false,
false,
false,
true
]
},
"hash": "c827f3d2765e60fa8b839bb49521cea0ebc0e21322591f98cd81c36712d70253"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT a.id, a.name, a.accent,\n COALESCE(SUM(u.credits), 0)::BIGINT AS \"credits!\",\n COALESCE(SUM(u.tokens_in + u.tokens_out), 0)::BIGINT AS \"tokens!\",\n COUNT(u.id)::BIGINT AS \"runs!\"\n FROM agents a\n LEFT JOIN usage_events u ON u.agent_id = a.id\n WHERE a.workspace_id = $1\n GROUP BY a.id, a.name, a.accent\n ORDER BY \"credits!\" DESC, \"tokens!\" DESC, a.name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "accent",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "credits!",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "tokens!",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "runs!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
null,
null,
null
]
},
"hash": "d4ef449c48b15519b7195be637dca3d456140ce477993d25a87e209174f79aba"
}
+1
View File
@@ -185,6 +185,7 @@ async fn run() -> Result<(), String> {
cm_api::AppState::new(pool, runtime) cm_api::AppState::new(pool, runtime)
.with_broker(PathBuf::from(&config.broker.socket_path)) .with_broker(PathBuf::from(&config.broker.socket_path))
.with_oauth(config.oauth.clone()) .with_oauth(config.oauth.clone())
.with_billing(config.billing.clone())
.pipe_auth_verifier(auth_verifier), .pipe_auth_verifier(auth_verifier),
); );
if e2e::enabled() { if e2e::enabled() {
+3
View File
@@ -7,6 +7,9 @@ license.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
hex = "0.4"
hmac = "0.12"
sha2 = "0.10"
async-stream = "0.3" async-stream = "0.3"
axum = "0.8" axum = "0.8"
futures = "0.3" futures = "0.3"
+6
View File
@@ -28,6 +28,12 @@ impl From<cm_db::DbError> for ApiError {
} }
} }
impl From<sqlx::Error> for ApiError {
fn from(err: sqlx::Error) -> Self {
ApiError::from(cm_db::DbError::from(err))
}
}
impl From<cm_auth::AuthError> for ApiError { impl From<cm_auth::AuthError> for ApiError {
fn from(err: cm_auth::AuthError) -> Self { fn from(err: cm_auth::AuthError) -> Self {
match err { match err {
+12
View File
@@ -21,6 +21,7 @@ pub struct AppState {
/// Secret broker socket; connect flows refuse without it. /// Secret broker socket; connect flows refuse without it.
pub broker_socket: Option<std::path::PathBuf>, pub broker_socket: Option<std::path::PathBuf>,
pub oauth: cm_config::OAuthConfig, pub oauth: cm_config::OAuthConfig,
pub billing: cm_config::BillingConfig,
} }
impl AppState { impl AppState {
@@ -32,6 +33,7 @@ impl AppState {
runtime, runtime,
broker_socket: None, broker_socket: None,
oauth: cm_config::OAuthConfig::default(), oauth: cm_config::OAuthConfig::default(),
billing: cm_config::BillingConfig::default(),
} }
} }
@@ -40,6 +42,11 @@ impl AppState {
self self
} }
pub fn with_billing(mut self, billing: cm_config::BillingConfig) -> AppState {
self.billing = billing;
self
}
/// Optional form of [`AppState::with_auth_verifier`] for call chains. /// Optional form of [`AppState::with_auth_verifier`] for call chains.
pub fn pipe_auth_verifier( pub fn pipe_auth_verifier(
self, self,
@@ -119,9 +126,14 @@ pub fn router(state: AppState) -> Router {
) )
.route("/api/team/claws", get(routes::team::claws)) .route("/api/team/claws", get(routes::team::claws))
.route("/api/team/members", get(routes::team::members)) .route("/api/team/members", get(routes::team::members))
.route("/api/team/orgchart", get(routes::team::orgchart))
.route("/api/team/leaderboard", get(routes::team::leaderboard))
.route("/api/team/credits", get(routes::team::credits)) .route("/api/team/credits", get(routes::team::credits))
.route("/api/team/usage", get(routes::billing::usage)) .route("/api/team/usage", get(routes::billing::usage))
.route("/api/credits/redeem", post(routes::billing::redeem)) .route("/api/credits/redeem", post(routes::billing::redeem))
.route("/api/credits/checkout", post(routes::billing::checkout))
.route("/api/billing/config", get(routes::billing::billing_config))
.route("/api/billing/stripe", post(routes::billing::stripe_webhook))
.route("/api/team/permissions", get(routes::team::permissions)) .route("/api/team/permissions", get(routes::team::permissions))
.layer(tower_http::trace::TraceLayer::new_for_http()) .layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state) .with_state(state)
+13 -3
View File
@@ -129,6 +129,8 @@ fn catalog() -> Vec<DirectoryApp> {
pub struct DirectoryQuery { pub struct DirectoryQuery {
#[serde(rename = "clawId")] #[serde(rename = "clawId")]
claw_id: Option<AgentId>, 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 /// 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) cm_db::repo::connections::list_for_agent(&state.pool, user.workspace_id, agent.id)
.await? .await?
} }
None if query.workspace.unwrap_or(false) => {
cm_db::repo::connections::list_for_workspace(&state.pool, user.workspace_id).await?
}
None => Vec::new(), None => Vec::new(),
}; };
let merged = catalog() let merged = catalog()
@@ -166,7 +171,7 @@ pub async fn directory(
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ConnectRequest { pub struct ConnectRequest {
#[serde(rename = "clawId")] #[serde(rename = "clawId")]
claw_id: AgentId, claw_id: Option<AgentId>,
provider: String, provider: String,
#[serde(rename = "authType")] #[serde(rename = "authType")]
auth_type: String, auth_type: String,
@@ -185,7 +190,12 @@ pub async fn connect(
if !matches!(body.auth_type.as_str(), "keys" | "basic") { if !matches!(body.auth_type.as_str(), "keys" | "basic") {
return Err(ApiError::Conflict); 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 socket = state.broker_socket.as_ref().ok_or(ApiError::Internal)?;
let mut broker = cm_secrets::BrokerClient::connect(socket) let mut broker = cm_secrets::BrokerClient::connect(socket)
.await .await
@@ -201,7 +211,7 @@ pub async fn connect(
let connection = cm_db::repo::connections::insert( let connection = cm_db::repo::connections::insert(
&state.pool, &state.pool,
user.workspace_id, user.workspace_id,
Some(agent.id), agent_id,
&body.provider, &body.provider,
&body.auth_type, &body.auth_type,
secret_ref, secret_ref,
+154
View File
@@ -1,7 +1,10 @@
use axum::body::Bytes;
use axum::extract::State; use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::Json; use axum::Json;
use cm_billing::BillingError; use cm_billing::BillingError;
use cm_db::repo::audit::Actor; use cm_db::repo::audit::Actor;
use hmac::{Hmac, Mac};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -52,3 +55,154 @@ pub async fn usage(
"credits": credits, "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)
}
+81
View File
@@ -41,3 +41,84 @@ pub async fn permissions(Authed(user): Authed) -> Json<Value> {
"can_manage_billing": owner, "can_manage_billing": owner,
})) }))
} }
/// Org chart for the Team page (R4): each member with the claws they
/// manage (`agents.managed_by`). Only members who manage ≥1 claw appear.
pub async fn orgchart(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let rows = sqlx::query!(
r#"SELECT u.id AS user_id, u.display_name, u.role, u.email,
a.id AS claw_id, a.name AS claw_name, a.job_title, a.accent
FROM users u
JOIN agents a ON a.managed_by = u.id
WHERE u.workspace_id = $1
ORDER BY u.display_name, a.name"#,
user.workspace_id.as_uuid(),
)
.fetch_all(&state.pool)
.await?;
let mut chart: Vec<Value> = Vec::new();
for row in rows {
let claw = json!({
"id": row.claw_id,
"name": row.claw_name,
"job_title": row.job_title,
"accent": row.accent,
});
match chart
.iter_mut()
.find(|node| node["user"]["id"] == json!(row.user_id))
{
Some(node) => node["claws"].as_array_mut().unwrap().push(claw),
None => chart.push(json!({
"user": {
"id": row.user_id,
"display_name": row.display_name,
"role": row.role,
"email": row.email,
},
"claws": [claw],
})),
}
}
Ok(Json(Value::Array(chart)))
}
/// Leaderboard for the Team page (R4): every claw ranked by its rolled-up
/// `usage_events` (credits desc), zeros included.
pub async fn leaderboard(
State(state): State<AppState>,
Authed(user): Authed,
) -> Result<Json<Value>, ApiError> {
let rows = sqlx::query!(
r#"SELECT a.id, a.name, a.accent,
COALESCE(SUM(u.credits), 0)::BIGINT AS "credits!",
COALESCE(SUM(u.tokens_in + u.tokens_out), 0)::BIGINT AS "tokens!",
COUNT(u.id)::BIGINT AS "runs!"
FROM agents a
LEFT JOIN usage_events u ON u.agent_id = a.id
WHERE a.workspace_id = $1
GROUP BY a.id, a.name, a.accent
ORDER BY "credits!" DESC, "tokens!" DESC, a.name"#,
user.workspace_id.as_uuid(),
)
.fetch_all(&state.pool)
.await?;
let board: Vec<Value> = rows
.into_iter()
.map(|r| {
json!({
"id": r.id,
"name": r.name,
"accent": r.accent,
"credits": r.credits,
"tokens": r.tokens,
"runs": r.runs,
})
})
.collect();
Ok(Json(Value::Array(board)))
}
+148
View File
@@ -0,0 +1,148 @@
//! Stripe Buy-credits: the signed webhook grants a credit lot exactly once
//! (real HMAC over the Stripe payload, the same constant-time pattern as
//! Slack). Live checkout-session creation is covered by a separate
//! CM_LIVE_STRIPE test; here we exercise the verification + grant path
//! offline with self-signed payloads.
use std::sync::Arc;
use cm_api::AppState;
use cm_auth::AuthService;
use cm_config::BillingConfig;
use cm_domain::{Role, User, UserId, Workspace, WorkspaceId};
use cm_llm::ScriptedProvider;
use cm_runtime::{Runtime, RuntimeConfig};
use hmac::{Hmac, Mac};
use serde_json::{json, Value};
const WEBHOOK_SECRET: &str = "whsec_test_secret";
async fn serve(pool: sqlx::PgPool) -> (String, reqwest::Client, WorkspaceId) {
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let owner = User {
id: UserId::new(),
workspace_id: ws.id,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: "Owner".into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
cm_db::repo::users::insert(&pool, &owner).await.unwrap();
AuthService::new(pool.clone())
.set_password(owner.id, "pw")
.await
.unwrap();
let runtime = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml("").unwrap()),
RuntimeConfig::basic("scripted", 1024),
);
let billing = BillingConfig {
stripe_secret_key: Some("sk_test_x".into()),
stripe_price_id: Some("price_x".into()),
stripe_webhook_secret: Some(WEBHOOK_SECRET.into()),
credits_per_pack: 1000,
return_base: Some("http://localhost:3000".into()),
};
let app = cm_api::router(AppState::new(pool, runtime).with_billing(billing));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{addr}"), reqwest::Client::new(), ws.id)
}
/// Stripe's scheme: `t=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<body>"))`.
fn stripe_signature(timestamp: i64, body: &str) -> String {
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(WEBHOOK_SECRET.as_bytes()).unwrap();
mac.update(format!("{timestamp}.{body}").as_bytes());
format!(
"t={timestamp},v1={}",
hex::encode(mac.finalize().into_bytes())
)
}
fn event(session_id: &str, workspace_id: WorkspaceId) -> String {
json!({
"id": "evt_1",
"type": "checkout.session.completed",
"data": { "object": {
"id": session_id,
"metadata": { "workspace_id": workspace_id.as_uuid().to_string() }
}}
})
.to_string()
}
#[tokio::test]
async fn a_signed_checkout_completion_grants_credits_exactly_once() {
let pool = cm_testkit::test_pool().await;
let (base, client, ws) = serve(pool.clone()).await;
let body = event("cs_test_1", ws);
let ts = 1_700_000_000;
let sig = stripe_signature(ts, &body);
let post = || {
client
.post(format!("{base}/api/billing/stripe"))
.header("stripe-signature", &sig)
.header("content-type", "application/json")
.body(body.clone())
.send()
};
assert_eq!(post().await.unwrap().status(), 200);
assert_eq!(
cm_db::repo::credits::balance(&pool, ws).await.unwrap(),
1000,
"one pack granted"
);
// Replayed event (same session id) is idempotent — no double grant.
assert_eq!(post().await.unwrap().status(), 200);
assert_eq!(
cm_db::repo::credits::balance(&pool, ws).await.unwrap(),
1000,
"replay must not double-grant"
);
}
#[tokio::test]
async fn a_forged_signature_is_refused_and_grants_nothing() {
let pool = cm_testkit::test_pool().await;
let (base, client, ws) = serve(pool.clone()).await;
let body = event("cs_test_2", ws);
let res = client
.post(format!("{base}/api/billing/stripe"))
.header("stripe-signature", "t=1700000000,v1=deadbeef")
.header("content-type", "application/json")
.body(body)
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
assert_eq!(cm_db::repo::credits::balance(&pool, ws).await.unwrap(), 0);
}
#[tokio::test]
async fn the_config_endpoint_reports_buy_credits_enabled() {
let pool = cm_testkit::test_pool().await;
let (base, client, _ws) = serve(pool.clone()).await;
let cfg: Value = client
.get(format!("{base}/api/billing/config"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert_eq!(cfg["buy_credits_enabled"], true);
}
+182
View File
@@ -0,0 +1,182 @@
//! The Team page's new tabs (R4): the org chart (members → the claws they
//! manage) and the leaderboard (agents ranked by real usage_events).
use std::sync::Arc;
use cm_api::AppState;
use cm_auth::AuthService;
use cm_domain::{
AccessPolicy, Agent, AgentId, AgentStatus, Role, User, UserId, Workspace, WorkspaceId,
};
use cm_llm::ScriptedProvider;
use cm_runtime::{Runtime, RuntimeConfig};
use serde_json::{json, Value};
async fn serve(pool: sqlx::PgPool) -> (String, reqwest::Client) {
let runtime = Runtime::new(
pool.clone(),
Arc::new(ScriptedProvider::from_toml("").unwrap()),
RuntimeConfig::basic("scripted", 1024),
);
let app = cm_api::router(AppState::new(pool, runtime));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
(format!("http://{addr}"), reqwest::Client::new())
}
async fn owner(pool: &sqlx::PgPool, ws: WorkspaceId, name: &str) -> User {
let user = User {
id: UserId::new(),
workspace_id: ws,
email: format!("{}@acme.test", UserId::new()),
role: Role::Owner,
display_name: name.into(),
created_at: time::OffsetDateTime::UNIX_EPOCH,
};
cm_db::repo::users::insert(pool, &user).await.unwrap();
user
}
async fn claw(pool: &sqlx::PgPool, ws: WorkspaceId, manager: UserId, name: &str) -> AgentId {
let agent = Agent {
id: AgentId::new(),
workspace_id: ws,
name: name.into(),
job_title: "Analyst".into(),
system_prompt: String::new(),
avatar: String::new(),
accent: "#f96565".into(),
wallpaper: String::new(),
managed_by: manager,
status: AgentStatus::Online,
};
cm_db::repo::agents::insert(pool, &agent, &AccessPolicy::default())
.await
.unwrap();
agent.id
}
async fn login(base: &str, client: &reqwest::Client, email: &str) -> String {
client
.post(format!("{base}/api/auth/login"))
.json(&json!({"email": email, "password": "pw"}))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap()["token"]
.as_str()
.unwrap()
.to_owned()
}
#[tokio::test]
async fn orgchart_groups_each_member_with_the_claws_they_manage() {
let pool = cm_testkit::test_pool().await;
let (base, client) = serve(pool.clone()).await;
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let ada = owner(&pool, ws.id, "Ada").await;
let ben = owner(&pool, ws.id, "Ben").await;
AuthService::new(pool.clone())
.set_password(ada.id, "pw")
.await
.unwrap();
let scout = claw(&pool, ws.id, ada.id, "Scout").await;
let quill = claw(&pool, ws.id, ada.id, "Quill").await;
let _solo = claw(&pool, ws.id, ben.id, "Atlas").await;
let token = login(&base, &client, &ada.email).await;
let chart: Value = client
.get(format!("{base}/api/team/orgchart"))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let nodes = chart.as_array().unwrap();
assert_eq!(nodes.len(), 2, "two managers");
let ada_node = nodes
.iter()
.find(|n| n["user"]["display_name"] == "Ada")
.unwrap();
let managed: Vec<&str> = ada_node["claws"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(managed.contains(&"Scout") && managed.contains(&"Quill"));
assert_eq!(managed.len(), 2);
// The claw ids are real (usable to deep-link into a chat).
assert!(ada_node["claws"][0]["id"].as_str().is_some());
let _ = (scout, quill);
}
#[tokio::test]
async fn leaderboard_ranks_claws_by_real_usage() {
let pool = cm_testkit::test_pool().await;
let (base, client) = serve(pool.clone()).await;
let ws = Workspace {
id: WorkspaceId::new(),
name: "Acme".into(),
plan: "team".into(),
};
cm_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
let ada = owner(&pool, ws.id, "Ada").await;
AuthService::new(pool.clone())
.set_password(ada.id, "pw")
.await
.unwrap();
let busy = claw(&pool, ws.id, ada.id, "Busy").await;
let idle = claw(&pool, ws.id, ada.id, "Idle").await;
// Two real usage events for Busy, none for Idle.
for (tin, tout, credits) in [(1000i64, 500i64, 2i64), (3000, 1000, 4)] {
sqlx::query(
"INSERT INTO usage_events (workspace_id, agent_id, kind, tokens_in, tokens_out, credits)
VALUES ($1, $2, 'llm_tokens', $3, $4, $5)",
)
.bind(ws.id.as_uuid())
.bind(busy.as_uuid())
.bind(tin)
.bind(tout)
.bind(sqlx::types::BigDecimal::from(credits))
.execute(&pool)
.await
.unwrap();
}
let token = login(&base, &client, &ada.email).await;
let board: Value = client
.get(format!("{base}/api/team/leaderboard"))
.bearer_auth(&token)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let rows = board.as_array().unwrap();
assert_eq!(rows.len(), 2);
// Busy ranks first with the summed usage; Idle is present at zero.
assert_eq!(rows[0]["name"], "Busy");
assert_eq!(rows[0]["credits"], 6);
assert_eq!(rows[0]["tokens"], 5500);
assert_eq!(rows[0]["runs"], 2);
assert_eq!(rows[1]["name"], "Idle");
assert_eq!(rows[1]["credits"], 0);
assert_eq!(rows[1]["runs"], 0);
let _ = idle;
}
+21
View File
@@ -159,6 +159,25 @@ impl Default for SandboxConfig {
} }
} }
#[derive(Debug, Clone, Default, Deserialize)]
pub struct BillingConfig {
/// Stripe secret key (sk_*); enables Buy-credits when set.
pub stripe_secret_key: Option<String>,
/// Price id (price_*) of the credit pack sold at checkout.
pub stripe_price_id: Option<String>,
/// Webhook signing secret (whsec_*) for verifying Stripe callbacks.
pub stripe_webhook_secret: Option<String>,
/// Credits granted per completed checkout (one pack).
#[serde(default = "default_pack_credits")]
pub credits_per_pack: i64,
/// Public base URL for the checkout success/cancel return.
pub return_base: Option<String>,
}
fn default_pack_credits() -> i64 {
1000
}
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
pub struct TelemetryConfig { pub struct TelemetryConfig {
/// OTLP/HTTP collector base (e.g. http://otel-collector:4318). /// OTLP/HTTP collector base (e.g. http://otel-collector:4318).
@@ -195,6 +214,8 @@ pub struct AppConfig {
pub sandbox: SandboxConfig, pub sandbox: SandboxConfig,
#[serde(default)] #[serde(default)]
pub telemetry: TelemetryConfig, pub telemetry: TelemetryConfig,
#[serde(default)]
pub billing: BillingConfig,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
+18
View File
@@ -71,6 +71,24 @@ pub async fn list_for_agent(
Ok(rows) Ok(rows)
} }
/// Workspace-wide connections (agent_id IS NULL) — the global /apps page.
pub async fn list_for_workspace(
pool: &PgPool,
workspace_id: WorkspaceId,
) -> Result<Vec<AppConnection>, DbError> {
let rows = sqlx::query_as!(
AppConnection,
r#"SELECT id, workspace_id, agent_id, provider, auth_type, status, secret_ref
FROM app_connections
WHERE workspace_id = $1 AND agent_id IS NULL AND status = 'connected'
ORDER BY created_at"#,
workspace_id.as_uuid(),
)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// The agent's live connection for one provider, if any. /// The agent's live connection for one provider, if any.
pub async fn find_provider( pub async fn find_provider(
pool: &PgPool, pool: &PgPool,