use std::collections::BTreeMap;
use std::sync::RwLock;
use serde_json::Value;
use super::host::{host, WorkflowWebhookSecret};
use super::WEBHOOK_PATH;
const WORKFLOW_WEBHOOK_PREFIX: &str = "/api/workflows/";
const WORKFLOW_WEBHOOK_SUFFIX: &str = "/webhook";
const REPLAY_WINDOW_SECS: i64 = 300;
pub fn workflow_webhook_path(id: &str) -> String {
format!("{WORKFLOW_WEBHOOK_PREFIX}{id}{WORKFLOW_WEBHOOK_SUFFIX}")
}
fn parse_workflow_webhook_path(path: &str) -> Option<String> {
let inner = path
.strip_prefix(WORKFLOW_WEBHOOK_PREFIX)?
.strip_suffix(WORKFLOW_WEBHOOK_SUFFIX)?;
if inner.is_empty() || inner.contains('/') {
return None;
}
Some(inner.to_owned())
}
static LAST_DELIVERY: RwLock<BTreeMap<String, i64>> = RwLock::new(BTreeMap::new());
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn record_delivery(path: &str) {
if let Ok(mut guard) = LAST_DELIVERY.write() {
guard.insert(path.to_owned(), now_unix());
}
}
pub fn last_delivery(path: &str) -> Option<i64> {
LAST_DELIVERY.read().ok().and_then(|g| g.get(path).copied())
}
pub fn timestamp_fresh(ts_header: Option<&str>, now: i64) -> bool {
let Some(raw) = ts_header.map(str::trim).filter(|s| !s.is_empty()) else {
return true;
};
let parsed = raw
.split(',')
.find_map(|tok| tok.trim().strip_prefix("t=").or(Some(tok.trim())))
.and_then(|v| v.parse::<i64>().ok());
match parsed {
Some(ts) => (now - ts).abs() <= REPLAY_WINDOW_SECS,
None => true,
}
}
#[derive(Debug)]
pub enum WorkflowWebhookOutcome {
Ran(String),
NotFound,
NoWebhookTrigger,
NoSecret,
BadSignature,
BadBody(String),
RunError(String),
}
pub async fn deliver_workflow_webhook(
id: &str,
raw_body: &[u8],
signature: Option<&str>,
) -> WorkflowWebhookOutcome {
let Ok(host) = host() else {
return WorkflowWebhookOutcome::NotFound;
};
let secret = match host.workflow_webhook_secret(id) {
WorkflowWebhookSecret::NotFound => return WorkflowWebhookOutcome::NotFound,
WorkflowWebhookSecret::NoTrigger => return WorkflowWebhookOutcome::NoWebhookTrigger,
WorkflowWebhookSecret::Secret(s) => match s.filter(|s| !s.trim().is_empty()) {
Some(secret) => secret,
None => return WorkflowWebhookOutcome::NoSecret,
},
};
if !host.verify_workflow_webhook_signature(&secret, raw_body, signature) {
return WorkflowWebhookOutcome::BadSignature;
}
let Ok(body_str) = std::str::from_utf8(raw_body) else {
return WorkflowWebhookOutcome::BadBody("body is not valid UTF-8".to_owned());
};
if serde_json::from_str::<Value>(body_str).is_err() {
return WorkflowWebhookOutcome::BadBody("body must be valid JSON".to_owned());
}
match host.run_workflow_for_trigger(id, body_str).await {
Ok(run_id) => {
record_delivery(&workflow_webhook_path(id));
WorkflowWebhookOutcome::Ran(run_id)
}
Err(e) => WorkflowWebhookOutcome::RunError(e.to_string()),
}
}
#[derive(Debug)]
pub enum InboundOutcome {
Delivered { detail: String },
Rejected(String),
Unhandled,
}
pub async fn deliver_inbound(path: &str, raw_body: &[u8], signature: Option<&str>) -> InboundOutcome {
if path == WEBHOOK_PATH {
let Ok(host) = host() else {
return InboundOutcome::Rejected("webhook-ingress host unavailable".to_owned());
};
if !host.verify_webhook_signature(raw_body, signature) {
return InboundOutcome::Rejected(
"composio webhook: invalid or missing signature".to_owned(),
);
}
let Ok(payload) = serde_json::from_slice::<Value>(raw_body) else {
return InboundOutcome::Rejected("composio webhook: invalid JSON body".to_owned());
};
return match host.composio_handle_webhook(&payload).await {
Some(fired) => {
record_delivery(path);
InboundOutcome::Delivered {
detail: format!("composio webhook fired {fired} run(s)"),
}
}
None => InboundOutcome::Rejected("composio triggers store unavailable".to_owned()),
};
}
if let Some(id) = parse_workflow_webhook_path(path) {
return match deliver_workflow_webhook(&id, raw_body, signature).await {
WorkflowWebhookOutcome::Ran(run_id) => InboundOutcome::Delivered {
detail: format!("workflow '{id}' run {run_id}"),
},
WorkflowWebhookOutcome::NotFound => {
InboundOutcome::Rejected(format!("workflow '{id}' not found"))
}
WorkflowWebhookOutcome::NoWebhookTrigger => {
InboundOutcome::Rejected(format!("workflow '{id}' has no webhook trigger"))
}
WorkflowWebhookOutcome::NoSecret => {
InboundOutcome::Rejected(format!("workflow '{id}' webhook has no secret configured"))
}
WorkflowWebhookOutcome::BadSignature => {
InboundOutcome::Rejected(format!("workflow '{id}': invalid or missing signature"))
}
WorkflowWebhookOutcome::BadBody(e) => {
InboundOutcome::Rejected(format!("workflow '{id}': {e}"))
}
WorkflowWebhookOutcome::RunError(e) => {
InboundOutcome::Rejected(format!("workflow '{id}' run failed: {e}"))
}
};
}
InboundOutcome::Unhandled
}
#[cfg(test)]
mod tests {
use super::*;
use crate::host::{set_global_host, WebhookIngressHost};
use std::sync::Arc;
struct MockHost;
#[async_trait::async_trait]
impl WebhookIngressHost for MockHost {
fn composio_is_configured(&self) -> bool {
true
}
fn verify_webhook_signature(&self, _raw_body: &[u8], signature: Option<&str>) -> bool {
signature == Some("good")
}
fn verify_workflow_webhook_signature(
&self,
_secret: &str,
_raw_body: &[u8],
signature: Option<&str>,
) -> bool {
signature == Some("good")
}
async fn composio_handle_webhook(&self, _payload: &Value) -> Option<usize> {
Some(1)
}
async fn run_workflow_for_trigger(
&self,
workflow_id: &str,
_payload_json: &str,
) -> anyhow::Result<String> {
Ok(format!("trigrun_{workflow_id}"))
}
fn workflow_webhook_secret(&self, workflow_id: &str) -> WorkflowWebhookSecret {
if workflow_id.contains("-notfound") {
WorkflowWebhookSecret::NotFound
} else if workflow_id.contains("-notrigger") {
WorkflowWebhookSecret::NoTrigger
} else if workflow_id.contains("-nosecret") {
WorkflowWebhookSecret::Secret(None)
} else {
WorkflowWebhookSecret::Secret(Some("s3cr3t".to_owned()))
}
}
fn auth_token(&self) -> Option<String> {
None
}
fn data_dir(&self) -> std::path::PathBuf {
std::env::temp_dir()
}
async fn ensure_funnel(&self, _port: u16) -> anyhow::Result<String> {
anyhow::bail!("mock: no funnel")
}
async fn funnel_url(&self, _port: u16) -> Option<String> {
None
}
}
fn ensure_mock_host() {
set_global_host(Arc::new(MockHost));
}
#[test]
fn workflow_path_round_trips() {
let p = workflow_webhook_path("wf-123");
assert_eq!(p, "/api/workflows/wf-123/webhook");
assert_eq!(parse_workflow_webhook_path(&p).as_deref(), Some("wf-123"));
}
#[test]
fn parse_rejects_non_workflow_and_nested_paths() {
assert_eq!(parse_workflow_webhook_path("/api/composio/webhook"), None);
assert_eq!(parse_workflow_webhook_path("/api/workflows//webhook"), None);
assert_eq!(
parse_workflow_webhook_path("/api/workflows/a/b/webhook"),
None
);
assert_eq!(parse_workflow_webhook_path("/nope"), None);
}
#[test]
fn timestamp_fresh_accepts_absent_and_recent_rejects_stale() {
let now = 1_000_000i64;
assert!(timestamp_fresh(None, now));
assert!(timestamp_fresh(Some(" "), now));
assert!(timestamp_fresh(Some("not-a-number"), now));
assert!(timestamp_fresh(Some("1000000"), now));
assert!(timestamp_fresh(Some(&format!("{}", now - 299)), now));
assert!(timestamp_fresh(Some("t=1000000"), now));
assert!(!timestamp_fresh(Some(&format!("{}", now - 301)), now));
assert!(!timestamp_fresh(Some(&format!("{}", now + 301)), now));
}
#[tokio::test]
async fn last_delivery_round_trips() {
let path = format!("/api/workflows/ld-{}/webhook", uuid::Uuid::new_v4().simple());
assert!(last_delivery(&path).is_none());
record_delivery(&path);
assert!(last_delivery(&path).is_some());
}
#[tokio::test]
async fn unknown_path_is_unhandled() {
let outcome = deliver_inbound("/api/does/not/exist", b"{}", None).await;
assert!(matches!(outcome, InboundOutcome::Unhandled));
}
#[tokio::test]
async fn workflow_path_with_bad_signature_is_rejected_not_composio() {
ensure_mock_host();
let id = format!("wf-{}", uuid::Uuid::new_v4().simple());
let path = workflow_webhook_path(&id);
let outcome = deliver_inbound(&path, b"{}", Some("deadbeef")).await;
match outcome {
InboundOutcome::Rejected(msg) => {
assert!(
msg.contains(&id),
"expected a workflow-scoped rejection, got: {msg}"
);
assert!(
!msg.contains("composio"),
"workflow path must not route to composio: {msg}"
);
}
other => panic!("expected Rejected, got {other:?}"),
}
}
#[tokio::test]
async fn workflow_webhook_reaches_run_through_unified_ingress() {
ensure_mock_host();
let id = format!("wf-unify-{}", uuid::Uuid::new_v4().simple());
let body = br#"{"event":"unify","value":42}"#;
let path = workflow_webhook_path(&id);
let outcome = deliver_inbound(&path, body, Some("good")).await;
match &outcome {
InboundOutcome::Delivered { detail } => {
assert!(
detail.contains(&id) && detail.contains("run"),
"expected a workflow run delivery, got: {detail}"
);
}
other => panic!("expected Delivered (reaching the workflow run), got {other:?}"),
}
assert!(
last_delivery(&path).is_some(),
"delivery should be recorded for the registry"
);
let rejected = deliver_inbound(&path, br#"{"event":"tampered"}"#, Some("bad")).await;
assert!(matches!(rejected, InboundOutcome::Rejected(_)));
}
}