Skip to main content

subcog/webhooks/
payload.rs

1//! Webhook payload types and HMAC signing.
2//!
3//! This module defines the JSON payload structure for webhook notifications
4//! and provides HMAC-SHA256 signing for payload authentication.
5//!
6//! # Payload Format
7//!
8//! ```json
9//! {
10//!   "event_id": "550e8400-e29b-41d4-a716-446655440000",
11//!   "event_type": "captured",
12//!   "timestamp": "2024-01-15T10:30:00Z",
13//!   "domain": "project",
14//!   "data": {
15//!     "memory_id": "abc123",
16//!     "namespace": "decisions",
17//!     "content_length": 256
18//!   }
19//! }
20//! ```
21//!
22//! # HMAC Signing
23//!
24//! When HMAC authentication is configured, the payload is signed using
25//! HMAC-SHA256 and the signature is added to the `X-Subcog-Signature` header
26//! in the format `sha256=<hex-encoded-signature>`.
27
28use crate::models::{MemoryEvent, MemoryId};
29use hmac::{Hmac, Mac};
30use secrecy::{ExposeSecret, SecretString};
31use serde::{Deserialize, Serialize};
32use sha2::Sha256;
33
34/// Webhook payload sent to webhook endpoints.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct WebhookPayload {
37    /// Unique event ID for idempotency.
38    pub event_id: String,
39
40    /// Event type (e.g., "captured", "deleted", "consolidated").
41    pub event_type: String,
42
43    /// ISO 8601 timestamp.
44    pub timestamp: String,
45
46    /// Domain scope (e.g., "project", "user", "org").
47    pub domain: String,
48
49    /// Event-specific data.
50    pub data: serde_json::Value,
51}
52
53impl WebhookPayload {
54    /// Creates a new webhook payload from a memory event.
55    #[must_use]
56    pub fn from_event(event: &MemoryEvent, domain: &str) -> Self {
57        let meta = event.meta();
58        let event_type = event.event_type();
59
60        Self {
61            event_id: meta.event_id.clone(),
62            event_type: event_type.to_string(),
63            timestamp: chrono::Utc::now().to_rfc3339(),
64            domain: domain.to_string(),
65            data: Self::event_to_data(event),
66        }
67    }
68
69    /// Creates a test event payload for webhook testing.
70    #[must_use]
71    pub fn test_event() -> Self {
72        Self {
73            event_id: uuid::Uuid::new_v4().to_string(),
74            event_type: "test".to_string(),
75            timestamp: chrono::Utc::now().to_rfc3339(),
76            domain: "test".to_string(),
77            data: serde_json::json!({
78                "message": "This is a test webhook event",
79                "source": "subcog"
80            }),
81        }
82    }
83
84    /// Converts the payload to a JSON string (default format).
85    ///
86    /// # Panics
87    ///
88    /// This function will not panic as the payload structure is always
89    /// serializable. Any serialization error would indicate a bug.
90    #[must_use]
91    pub fn to_json(&self) -> String {
92        serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
93    }
94
95    /// Converts the payload to format-specific JSON.
96    ///
97    /// # Arguments
98    ///
99    /// * `format` - The payload format to use
100    #[must_use]
101    pub fn to_format_json(&self, format: super::config::PayloadFormat) -> String {
102        match format {
103            super::config::PayloadFormat::Default => self.to_json(),
104            super::config::PayloadFormat::Slack => self.to_slack_json(),
105            super::config::PayloadFormat::Discord => self.to_discord_json(),
106        }
107    }
108
109    /// Converts the payload to Slack-compatible JSON format.
110    ///
111    /// Slack expects `{"text": "message"}` or Block Kit format.
112    #[must_use]
113    pub fn to_slack_json(&self) -> String {
114        let text = self.format_message();
115        serde_json::json!({
116            "text": text,
117            "blocks": [
118                {
119                    "type": "header",
120                    "text": {
121                        "type": "plain_text",
122                        "text": format!("Subcog: {}", self.event_type),
123                        "emoji": true
124                    }
125                },
126                {
127                    "type": "section",
128                    "fields": [
129                        {
130                            "type": "mrkdwn",
131                            "text": format!("*Event:*\n{}", self.event_type)
132                        },
133                        {
134                            "type": "mrkdwn",
135                            "text": format!("*Domain:*\n{}", self.domain)
136                        }
137                    ]
138                },
139                {
140                    "type": "section",
141                    "text": {
142                        "type": "mrkdwn",
143                        "text": format!("*Details:*\n```{}```", self.data)
144                    }
145                },
146                {
147                    "type": "context",
148                    "elements": [
149                        {
150                            "type": "mrkdwn",
151                            "text": format!("Event ID: {} | {}", self.event_id, self.timestamp)
152                        }
153                    ]
154                }
155            ]
156        })
157        .to_string()
158    }
159
160    /// Converts the payload to Discord-compatible JSON format.
161    ///
162    /// Discord expects `{"content": "message"}` or embed format.
163    #[must_use]
164    pub fn to_discord_json(&self) -> String {
165        let text = self.format_message();
166        serde_json::json!({
167            "content": text,
168            "embeds": [
169                {
170                    "title": format!("Subcog: {}", self.event_type),
171                    "color": 5_814_783,
172                    "fields": [
173                        {
174                            "name": "Event",
175                            "value": self.event_type,
176                            "inline": true
177                        },
178                        {
179                            "name": "Domain",
180                            "value": self.domain,
181                            "inline": true
182                        },
183                        {
184                            "name": "Details",
185                            "value": format!("```json\n{}\n```", self.data)
186                        }
187                    ],
188                    "footer": {
189                        "text": format!("Event ID: {}", self.event_id)
190                    },
191                    "timestamp": self.timestamp
192                }
193            ]
194        })
195        .to_string()
196    }
197
198    /// Formats a human-readable message for the event.
199    fn format_message(&self) -> String {
200        match self.event_type.as_str() {
201            "captured" => format!("Memory captured in {} domain", self.domain),
202            "deleted" => format!("Memory deleted in {} domain", self.domain),
203            "updated" => format!("Memory updated in {} domain", self.domain),
204            "consolidated" => format!("Memories consolidated in {} domain", self.domain),
205            "test" => "Subcog webhook test event".to_string(),
206            _ => format!("{} event in {} domain", self.event_type, self.domain),
207        }
208    }
209
210    /// Computes HMAC-SHA256 signature for the payload.
211    ///
212    /// # Arguments
213    ///
214    /// * `secret` - The shared secret for signing
215    ///
216    /// # Returns
217    ///
218    /// The signature in format `sha256=<hex-encoded-signature>`.
219    #[must_use]
220    pub fn compute_signature(&self, secret: &SecretString) -> String {
221        let payload_json = self.to_json();
222        compute_hmac_signature(secret.expose_secret(), &payload_json)
223    }
224
225    /// Converts a memory event to event-specific data.
226    #[allow(clippy::too_many_lines)]
227    fn event_to_data(event: &MemoryEvent) -> serde_json::Value {
228        match event {
229            MemoryEvent::Captured {
230                memory_id,
231                namespace,
232                domain,
233                content_length,
234                ..
235            } => serde_json::json!({
236                "memory_id": memory_id.as_str(),
237                "namespace": namespace.as_str(),
238                "domain": domain.to_string(),
239                "content_length": content_length
240            }),
241
242            MemoryEvent::Updated {
243                memory_id,
244                modified_fields,
245                ..
246            } => serde_json::json!({
247                "memory_id": memory_id.as_str(),
248                "modified_fields": modified_fields
249            }),
250
251            // Deleted and Archived have identical payload structure
252            MemoryEvent::Deleted {
253                memory_id, reason, ..
254            }
255            | MemoryEvent::Archived {
256                memory_id, reason, ..
257            } => serde_json::json!({
258                "memory_id": memory_id.as_str(),
259                "reason": reason
260            }),
261
262            MemoryEvent::Retrieved {
263                memory_id,
264                query,
265                score,
266                ..
267            } => serde_json::json!({
268                "memory_id": memory_id.as_str(),
269                "query": query.as_ref(),
270                "score": score
271            }),
272
273            MemoryEvent::Redacted {
274                memory_id,
275                redaction_type,
276                ..
277            } => serde_json::json!({
278                "memory_id": memory_id.as_str(),
279                "redaction_type": redaction_type
280            }),
281
282            MemoryEvent::Synced {
283                pushed,
284                pulled,
285                conflicts,
286                ..
287            } => serde_json::json!({
288                "pushed": pushed,
289                "pulled": pulled,
290                "conflicts": conflicts
291            }),
292
293            MemoryEvent::Consolidated {
294                processed,
295                archived,
296                merged,
297                ..
298            } => serde_json::json!({
299                "processed": processed,
300                "archived": archived,
301                "merged": merged
302            }),
303
304            MemoryEvent::McpStarted {
305                transport, port, ..
306            } => serde_json::json!({
307                "transport": transport,
308                "port": port
309            }),
310
311            MemoryEvent::McpAuthFailed {
312                client_id, reason, ..
313            } => serde_json::json!({
314                "client_id": client_id,
315                "reason": reason
316            }),
317
318            MemoryEvent::McpToolExecuted {
319                tool_name,
320                status,
321                duration_ms,
322                error,
323                ..
324            } => serde_json::json!({
325                "tool_name": tool_name,
326                "status": status,
327                "duration_ms": duration_ms,
328                "error": error
329            }),
330
331            MemoryEvent::McpRequestError {
332                operation, error, ..
333            } => serde_json::json!({
334                "operation": operation,
335                "error": error
336            }),
337
338            MemoryEvent::HookInvoked { hook, .. } => serde_json::json!({
339                "hook": hook
340            }),
341
342            MemoryEvent::HookClassified {
343                hook,
344                classification,
345                classifier,
346                confidence,
347                ..
348            } => serde_json::json!({
349                "hook": hook,
350                "classification": classification,
351                "classifier": classifier,
352                "confidence": confidence
353            }),
354
355            MemoryEvent::HookCaptureDecision {
356                hook,
357                decision,
358                namespace,
359                memory_id,
360                ..
361            } => serde_json::json!({
362                "hook": hook,
363                "decision": decision,
364                "namespace": namespace,
365                "memory_id": memory_id.as_ref().map(MemoryId::as_str)
366            }),
367
368            MemoryEvent::HookFailed { hook, error, .. } => serde_json::json!({
369                "hook": hook,
370                "error": error
371            }),
372        }
373    }
374}
375
376/// Computes HMAC-SHA256 signature for a payload string.
377///
378/// # Arguments
379///
380/// * `secret` - The shared secret
381/// * `payload` - The payload string to sign
382///
383/// # Returns
384///
385/// The signature in format `sha256=<hex-encoded-signature>`.
386///
387/// # Panics
388///
389/// This function will not panic. HMAC-SHA256 accepts keys of any length.
390#[must_use]
391#[allow(clippy::expect_used)] // HMAC-SHA256 accepts any key size, cannot fail
392pub fn compute_hmac_signature(secret: &str, payload: &str) -> String {
393    type HmacSha256 = Hmac<Sha256>;
394
395    // SAFETY: HMAC-SHA256 accepts keys of any length, new_from_slice cannot fail
396    let mut mac =
397        HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC-SHA256 accepts any key size");
398    mac.update(payload.as_bytes());
399
400    let result = mac.finalize();
401    let signature = hex::encode(result.into_bytes());
402
403    format!("sha256={signature}")
404}
405
406/// Verifies an HMAC-SHA256 signature.
407///
408/// This function is provided for webhook receivers to verify incoming
409/// webhook signatures. It uses constant-time comparison to prevent
410/// timing attacks.
411///
412/// # Arguments
413///
414/// * `secret` - The shared secret
415/// * `payload` - The payload string that was signed
416/// * `signature` - The signature to verify (with or without `sha256=` prefix)
417///
418/// # Returns
419///
420/// `true` if the signature is valid, `false` otherwise.
421///
422/// # Example
423///
424/// ```rust,ignore
425/// use subcog::webhooks::payload::verify_hmac_signature;
426///
427/// let is_valid = verify_hmac_signature("my-secret", r#"{"event":"test"}"#, "sha256=...");
428/// ```
429#[must_use]
430#[allow(dead_code)] // Provided for external webhook receivers
431pub fn verify_hmac_signature(secret: &str, payload: &str, signature: &str) -> bool {
432    let expected = compute_hmac_signature(secret, payload);
433
434    // Handle both with and without prefix
435    let signature = if signature.starts_with("sha256=") {
436        signature.to_string()
437    } else {
438        format!("sha256={signature}")
439    };
440
441    // Use constant-time comparison to prevent timing attacks
442    constant_time_eq(expected.as_bytes(), signature.as_bytes())
443}
444
445/// Constant-time comparison to prevent timing attacks.
446#[allow(dead_code)] // Used by verify_hmac_signature
447fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
448    if a.len() != b.len() {
449        return false;
450    }
451
452    let mut result = 0u8;
453    for (x, y) in a.iter().zip(b.iter()) {
454        result |= x ^ y;
455    }
456    result == 0
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::models::{Domain, EventMeta};
463
464    #[test]
465    fn test_payload_to_json() {
466        let payload = WebhookPayload::test_event();
467        let json = payload.to_json();
468
469        assert!(json.contains("event_id"));
470        assert!(json.contains("event_type"));
471        assert!(json.contains("test"));
472    }
473
474    #[test]
475    fn test_hmac_signature_computation() {
476        let secret = "my-secret-key";
477        let payload = r#"{"event":"test"}"#;
478
479        let signature = compute_hmac_signature(secret, payload);
480
481        assert!(signature.starts_with("sha256="));
482        assert_eq!(signature.len(), 7 + 64); // "sha256=" + 64 hex chars
483    }
484
485    #[test]
486    fn test_hmac_signature_verification() {
487        let secret = "my-secret-key";
488        let payload = r#"{"event":"test"}"#;
489
490        let signature = compute_hmac_signature(secret, payload);
491
492        assert!(verify_hmac_signature(secret, payload, &signature));
493        assert!(!verify_hmac_signature("wrong-secret", payload, &signature));
494        assert!(!verify_hmac_signature(secret, "wrong-payload", &signature));
495    }
496
497    #[test]
498    fn test_hmac_verification_handles_prefix() {
499        let secret = "my-secret-key";
500        let payload = r#"{"event":"test"}"#;
501
502        let signature = compute_hmac_signature(secret, payload);
503        let without_prefix = signature.strip_prefix("sha256=").expect("prefix");
504
505        // Both should verify correctly
506        assert!(verify_hmac_signature(secret, payload, &signature));
507        assert!(verify_hmac_signature(secret, payload, without_prefix));
508    }
509
510    #[test]
511    fn test_payload_from_captured_event() {
512        let event = MemoryEvent::Captured {
513            meta: EventMeta::new("test", None),
514            memory_id: MemoryId::new("test-123"),
515            namespace: crate::Namespace::Decisions,
516            domain: Domain::new(),
517            content_length: 100,
518        };
519
520        let payload = WebhookPayload::from_event(&event, "project");
521
522        assert_eq!(payload.event_type, "captured");
523        assert_eq!(payload.domain, "project");
524        assert!(payload.data.get("memory_id").is_some());
525        assert!(payload.data.get("namespace").is_some());
526    }
527
528    #[test]
529    fn test_constant_time_eq() {
530        assert!(constant_time_eq(b"hello", b"hello"));
531        assert!(!constant_time_eq(b"hello", b"world"));
532        assert!(!constant_time_eq(b"hello", b"hell"));
533    }
534}