Skip to main content

agentic_connect/engine/
webhook.rs

1//! Webhook engine — send, receive, verify signatures.
2
3use ring::hmac;
4use std::collections::HashMap;
5
6use crate::types::{ConnectError, ConnectResult};
7
8/// Webhook delivery record.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct WebhookDelivery {
11    pub id: uuid::Uuid,
12    pub url: String,
13    pub payload: serde_json::Value,
14    pub status: u16,
15    pub latency_ms: u64,
16    pub timestamp: chrono::DateTime<chrono::Utc>,
17    pub success: bool,
18}
19
20/// Registered webhook endpoint.
21#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
22pub struct WebhookEndpoint {
23    pub name: String,
24    pub url: String,
25    pub events: Vec<String>,
26    pub secret: Option<String>,
27    pub active: bool,
28    pub created_at: chrono::DateTime<chrono::Utc>,
29    pub delivery_count: u64,
30}
31
32/// Compute HMAC-SHA256 signature for webhook payload.
33pub fn compute_hmac_sha256(secret: &str, payload: &str) -> String {
34    let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_bytes());
35    let tag = hmac::sign(&key, payload.as_bytes());
36    hex_encode(tag.as_ref())
37}
38
39/// Verify HMAC-SHA256 signature.
40pub fn verify_hmac_sha256(secret: &str, payload: &str, signature: &str) -> bool {
41    let expected = compute_hmac_sha256(secret, payload);
42    // Constant-time comparison via ring
43    let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_bytes());
44    let sig_bytes = hex_decode(signature);
45    match sig_bytes {
46        Some(bytes) => hmac::verify(&key, payload.as_bytes(), &bytes).is_ok(),
47        None => {
48            // Try comparing hex strings as fallback
49            expected == signature.trim_start_matches("sha256=")
50        }
51    }
52}
53
54/// Send a webhook with optional signature.
55#[cfg(feature = "http")]
56pub async fn send_webhook(
57    url: &str,
58    payload: &serde_json::Value,
59    secret: Option<&str>,
60) -> ConnectResult<WebhookDelivery> {
61    let body = serde_json::to_string(payload)
62        .map_err(|e| ConnectError::Serialization(e.to_string()))?;
63
64    let mut headers = HashMap::new();
65    headers.insert("content-type".to_string(), "application/json".to_string());
66
67    if let Some(s) = secret {
68        let sig = compute_hmac_sha256(s, &body);
69        headers.insert("x-webhook-signature".to_string(), format!("sha256={}", sig));
70    }
71
72    let resp = crate::engine::http_client::http_request(
73        url, "POST", Some(&headers), Some(&body), 30_000,
74    ).await?;
75
76    Ok(WebhookDelivery {
77        id: uuid::Uuid::new_v4(),
78        url: url.to_string(),
79        payload: payload.clone(),
80        status: resp.status,
81        latency_ms: resp.latency_ms,
82        timestamp: chrono::Utc::now(),
83        success: resp.status < 400,
84    })
85}
86
87fn hex_encode(bytes: &[u8]) -> String {
88    bytes.iter().map(|b| format!("{:02x}", b)).collect()
89}
90
91fn hex_decode(hex: &str) -> Option<Vec<u8>> {
92    let hex = hex.trim_start_matches("sha256=");
93    if hex.len() % 2 != 0 { return None; }
94    (0..hex.len())
95        .step_by(2)
96        .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
97        .collect()
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn test_hmac_sha256_sign_verify() {
106        let secret = "my-webhook-secret";
107        let payload = r#"{"event":"push","ref":"refs/heads/main"}"#;
108        let sig = compute_hmac_sha256(secret, payload);
109        assert!(!sig.is_empty());
110        assert!(verify_hmac_sha256(secret, payload, &sig));
111    }
112
113    #[test]
114    fn test_hmac_wrong_secret() {
115        let payload = r#"{"test": true}"#;
116        let sig = compute_hmac_sha256("correct-secret", payload);
117        assert!(!verify_hmac_sha256("wrong-secret", payload, &sig));
118    }
119
120    #[test]
121    fn test_hmac_with_sha256_prefix() {
122        let secret = "sec";
123        let payload = "data";
124        let sig = compute_hmac_sha256(secret, payload);
125        let prefixed = format!("sha256={}", sig);
126        assert!(verify_hmac_sha256(secret, payload, &prefixed));
127    }
128
129    #[test]
130    fn test_hex_roundtrip() {
131        let bytes = b"hello";
132        let hex = hex_encode(bytes);
133        let decoded = hex_decode(&hex).unwrap();
134        assert_eq!(decoded, bytes);
135    }
136}