use hmac::{Hmac, Mac};
use pointlock_store::projection::HumanInboxEntry;
use sha2::Sha256;
pub const SIGNATURE_HEADER: &str = "X-Pointlock-Signature";
#[derive(Debug, Clone)]
pub struct WebhookNotification {
pub body: String,
pub signature: Option<String>,
}
pub fn build_notification(
entries: &[HumanInboxEntry],
store_dir: &str,
run_id: &str,
secret: Option<&str>,
) -> WebhookNotification {
let envelope = serde_json::json!({
"pointlockWebhook": 1,
"runId": run_id,
"storeDir": store_dir,
"respondHint": format!(
"pointlock resume {run_id} --store {store_dir} (responses go through \
pointlock-human-cli; this webhook never collects)"
),
"entries": entries,
});
let body = serde_json::to_string(&envelope).expect("the envelope always serializes");
let signature = secret.map(|secret| signature_for(&body, secret));
WebhookNotification { body, signature }
}
pub fn signature_for(body: &str, secret: &str) -> String {
let mut mac =
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
mac.update(body.as_bytes());
let digest = mac.finalize().into_bytes();
let hex: String = digest.iter().map(|byte| format!("{byte:02x}")).collect();
format!("sha256={hex}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn signature_matches_the_rfc_4231_vector() {
assert_eq!(
signature_for("what do ya want for nothing?", "Jefe"),
"sha256=5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
assert_ne!(
signature_for("what do ya want for nothing!", "Jefe"),
signature_for("what do ya want for nothing?", "Jefe")
);
}
#[test]
fn notification_envelope_is_versioned_and_signable() {
let notification = build_notification(&[], "/tmp/store", "run-1", None);
let envelope: serde_json::Value =
serde_json::from_str(¬ification.body).expect("valid JSON");
assert_eq!(envelope["pointlockWebhook"], 1);
assert_eq!(envelope["runId"], "run-1");
assert!(
envelope["respondHint"]
.as_str()
.is_some_and(|hint| hint.contains("pointlock resume")),
"the recovery hint rides the envelope (06 §4.2)"
);
assert!(envelope["entries"].as_array().is_some_and(Vec::is_empty));
assert_eq!(notification.signature, None);
let signed = build_notification(&[], "/tmp/store", "run-1", Some("s3cret"));
assert_eq!(
signed.signature.as_deref(),
Some(signature_for(&signed.body, "s3cret").as_str())
);
}
}