Files
clawmates/crates/cm-api/src/routes/webhooks.rs
T
Omar Sobh d2b1f0569e cm-api: quota — enforce max_active_runs on run enqueue
Adds a Quota.max_active_runs ceiling (queued + running topology runs at
once, per workspace) to stop a single workspace flooding the shared queue.
Free tier: 10, Pro: 25, Team: 100. Enforced at every /run enqueue site:
run_org, run_company, run_team, and the webhook trigger. Webhooks return
429 rather than 402 so external callers can back off — the guard is what
stops a leaked webhook token from being weaponized into a queue flood.

A single team run also spawns a tier-tree of children, so the practical
cap grows with the topology — this counts the outer runs, not every step.
2026-07-05 18:49:03 -07:00

131 lines
4.4 KiB
Rust

//! Inbound webhook triggers (Triggered deploy mode). A team gets a token whose
//! public URL, when POSTed, enqueues the team's topology run — reusing the same
//! durable-run path as the scheduler and the UI "Run team" button.
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
/// `POST /api/teams/{id}/webhooks` — mint a webhook token for the team.
pub async fn create_webhook(
State(state): State<AppState>,
Authed(user): Authed,
Path(team_id): Path<Uuid>,
body: Option<Json<CreateBody>>,
) -> Result<Json<Value>, ApiError> {
// Scope check: the team must belong to the caller's workspace.
cm_db::repo::teams::get_team(&state.pool, team_id, user.workspace_id).await?;
let default_task = body.map(|b| b.0.task).unwrap_or_default();
let id = Uuid::now_v7();
let token = Uuid::now_v7();
sqlx::query("INSERT INTO webhook_tokens (id, workspace_id, team_id, token, task) VALUES ($1, $2, $3, $4, $5)")
.bind(id)
.bind(user.workspace_id.as_uuid())
.bind(team_id)
.bind(token)
.bind(&default_task)
.execute(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
Ok(Json(
json!({ "token": token.to_string(), "url": format!("/api/hooks/{token}") }),
))
}
#[derive(Deserialize, Default)]
pub struct CreateBody {
#[serde(default)]
pub task: String,
}
/// `GET /api/teams/{id}/webhooks` — list the team's webhook tokens.
pub async fn list_webhooks(
State(state): State<AppState>,
Authed(user): Authed,
Path(team_id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
cm_db::repo::teams::get_team(&state.pool, team_id, user.workspace_id).await?;
let rows = sqlx::query_as::<_, (Uuid,)>(
"SELECT token FROM webhook_tokens WHERE team_id = $1 ORDER BY created_at DESC",
)
.bind(team_id)
.fetch_all(&state.pool)
.await
.map_err(|_| ApiError::Internal)?;
let hooks: Vec<Value> = rows
.into_iter()
.map(|(t,)| json!({ "token": t.to_string(), "url": format!("/api/hooks/{t}") }))
.collect();
Ok(Json(json!({ "webhooks": hooks })))
}
#[derive(Deserialize, Default)]
struct HookBody {
#[serde(default)]
task: String,
}
/// `POST /api/hooks/{token}` — PUBLIC. Fire the bound team's topology run. Body
/// `{task?}` overrides the team's default task. Mirrors the Slack/Stripe inbound
/// pattern (no session auth; the unguessable token is the credential).
pub async fn trigger_hook(
State(state): State<AppState>,
Path(token): Path<Uuid>,
body: axum::body::Bytes,
) -> StatusCode {
let row = sqlx::query_as::<_, (Uuid, Uuid, String)>(
"SELECT workspace_id, team_id, task FROM webhook_tokens WHERE token = $1",
)
.bind(token)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let Some((ws_id, team_id, default_task)) = row else {
return StatusCode::NOT_FOUND;
};
let ws = cm_domain::WorkspaceId::from(ws_id);
let Ok(team) = cm_db::repo::teams::get_team(&state.pool, team_id, ws).await else {
return StatusCode::NOT_FOUND;
};
let task = serde_json::from_slice::<HookBody>(&body)
.ok()
.map(|b| b.task)
.filter(|t| !t.trim().is_empty())
.or_else(|| (!default_task.trim().is_empty()).then_some(default_task))
.unwrap_or_else(|| "webhook trigger".to_string());
// Webhooks are unauthenticated public endpoints — the enforce_new_run
// guard is what stops a leaked token from being weaponized into a queue
// flood. 429 (not 402) so external callers can back off.
if crate::quota::enforce_new_run(&state, ws).await.is_err() {
return StatusCode::TOO_MANY_REQUESTS;
}
let run_id = Uuid::now_v7();
if cm_db::repo::topology_runs::enqueue_run(&state.pool, run_id, ws, &task, &team.graph)
.await
.is_err()
{
return StatusCode::INTERNAL_SERVER_ERROR;
}
let _ = sqlx::query("UPDATE webhook_tokens SET last_fired_at = now() WHERE token = $1")
.bind(token)
.execute(&state.pool)
.await;
let _ = cm_db::repo::audit::append(
&state.pool,
ws,
cm_db::repo::audit::Actor::System,
"topology.webhook_triggered",
"webhook",
&token.to_string(),
json!({ "team_id": team_id, "run_id": run_id }),
)
.await;
StatusCode::ACCEPTED
}