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
+64
@@ -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"
|
||||||
|
}
|
||||||
+58
@@ -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"
|
||||||
|
}
|
||||||
+52
@@ -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"
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user