1use crate::models::{MemoryEvent, MemoryId};
29use hmac::{Hmac, Mac};
30use secrecy::{ExposeSecret, SecretString};
31use serde::{Deserialize, Serialize};
32use sha2::Sha256;
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct WebhookPayload {
37 pub event_id: String,
39
40 pub event_type: String,
42
43 pub timestamp: String,
45
46 pub domain: String,
48
49 pub data: serde_json::Value,
51}
52
53impl WebhookPayload {
54 #[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 #[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 #[must_use]
91 pub fn to_json(&self) -> String {
92 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
93 }
94
95 #[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 #[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 #[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 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 #[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 #[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 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#[must_use]
391#[allow(clippy::expect_used)] pub fn compute_hmac_signature(secret: &str, payload: &str) -> String {
393 type HmacSha256 = Hmac<Sha256>;
394
395 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#[must_use]
430#[allow(dead_code)] pub fn verify_hmac_signature(secret: &str, payload: &str, signature: &str) -> bool {
432 let expected = compute_hmac_signature(secret, payload);
433
434 let signature = if signature.starts_with("sha256=") {
436 signature.to_string()
437 } else {
438 format!("sha256={signature}")
439 };
440
441 constant_time_eq(expected.as_bytes(), signature.as_bytes())
443}
444
445#[allow(dead_code)] fn 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); }
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 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}