agentic_connect/engine/
webhook.rs1use ring::hmac;
4use std::collections::HashMap;
5
6use crate::types::{ConnectError, ConnectResult};
7
8#[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#[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
32pub 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
39pub fn verify_hmac_sha256(secret: &str, payload: &str, signature: &str) -> bool {
41 let expected = compute_hmac_sha256(secret, payload);
42 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 expected == signature.trim_start_matches("sha256=")
50 }
51 }
52}
53
54#[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}