pointlock_human_cli/
webhook.rs1use hmac::{Hmac, Mac};
13use pointlock_store::projection::HumanInboxEntry;
14use sha2::Sha256;
15
16pub const SIGNATURE_HEADER: &str = "X-Pointlock-Signature";
18
19#[derive(Debug, Clone)]
21pub struct WebhookNotification {
22 pub body: String,
24 pub signature: Option<String>,
26}
27
28pub fn build_notification(
35 entries: &[HumanInboxEntry],
36 store_dir: &str,
37 run_id: &str,
38 secret: Option<&str>,
39) -> WebhookNotification {
40 let envelope = serde_json::json!({
41 "pointlockWebhook": 1,
42 "runId": run_id,
43 "storeDir": store_dir,
44 "respondHint": format!(
45 "pointlock resume {run_id} --store {store_dir} (responses go through \
46 pointlock-human-cli; this webhook never collects)"
47 ),
48 "entries": entries,
49 });
50 let body = serde_json::to_string(&envelope).expect("the envelope always serializes");
51 let signature = secret.map(|secret| signature_for(&body, secret));
52 WebhookNotification { body, signature }
53}
54
55pub fn signature_for(body: &str, secret: &str) -> String {
60 let mut mac =
61 Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
62 mac.update(body.as_bytes());
63 let digest = mac.finalize().into_bytes();
64 let hex: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
65 format!("sha256={hex}")
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71
72 #[test]
73 fn signature_matches_the_rfc_4231_vector() {
74 assert_eq!(
77 signature_for("what do ya want for nothing?", "Jefe"),
78 "sha256=5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
79 );
80 assert_ne!(
82 signature_for("what do ya want for nothing!", "Jefe"),
83 signature_for("what do ya want for nothing?", "Jefe")
84 );
85 }
86
87 #[test]
88 fn notification_envelope_is_versioned_and_signable() {
89 let notification = build_notification(&[], "/tmp/store", "run-1", None);
90 let envelope: serde_json::Value =
91 serde_json::from_str(¬ification.body).expect("valid JSON");
92 assert_eq!(envelope["pointlockWebhook"], 1);
93 assert_eq!(envelope["runId"], "run-1");
94 assert!(
95 envelope["respondHint"]
96 .as_str()
97 .is_some_and(|hint| hint.contains("pointlock resume")),
98 "the recovery hint rides the envelope (06 §4.2)"
99 );
100 assert!(envelope["entries"].as_array().is_some_and(Vec::is_empty));
101 assert_eq!(notification.signature, None);
103 let signed = build_notification(&[], "/tmp/store", "run-1", Some("s3cret"));
104 assert_eq!(
105 signed.signature.as_deref(),
106 Some(signature_for(&signed.body, "s3cret").as_str())
107 );
108 }
109}