//! 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, Authed(user): Authed, Path(team_id): Path, body: Option>, ) -> Result, 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, Authed(user): Authed, Path(team_id): Path, ) -> Result, 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 = 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, Path(token): Path, 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::(&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 }