Skip to main content

pointlock_human_cli/
webhook.rs

1//! The webhook notify-only channel (06 §4.2): the POST payload and its
2//! optional HMAC signature. Notify-only by ruling — v0.1's single-process
3//! local architecture has no authenticable inbound HTTP face, so
4//! responses NEVER come back this way; collection stays with the `cli`
5//! channel (store-arbitrated). Transport (the actual POST) lives with the
6//! assembly layer; this module is the pure, testable half.
7//!
8//! Evidence BYTES are never embedded (06 §4.2): the inbox entries carry
9//! values and references only, and the envelope names the local store as
10//! the forensics path plus the recovery hint for responding.
11
12use hmac::{Hmac, Mac};
13use pointlock_store::projection::HumanInboxEntry;
14use sha2::Sha256;
15
16/// The HTTP header carrying the body signature.
17pub const SIGNATURE_HEADER: &str = "X-Pointlock-Signature";
18
19/// One ready-to-send webhook notification.
20#[derive(Debug, Clone)]
21pub struct WebhookNotification {
22    /// The JSON body (the `pointlockWebhook: 1` envelope).
23    pub body: String,
24    /// The [`SIGNATURE_HEADER`] value, when a secret is configured.
25    pub signature: Option<String>,
26}
27
28/// Builds the notification for one run's pending inbox entries: a
29/// CLI-owned envelope (`pointlockWebhook: 1`, precedent: `pointlockReport`)
30/// around the R14 inbox DTO projections, with the local forensics path
31/// and the recovery hint (06 §4.2). Receivers deduplicate by each
32/// entry's `requestId` — re-notification after another suspension is
33/// legal and expected (notify is idempotent).
34pub 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
55/// The `sha256=<hex>` HMAC-SHA256 signature of `body` under `secret`
56/// (06 §4.2's `X-Pointlock-Signature`). Computed over the exact body
57/// bytes — any reformatting on the receiving side must verify against
58/// the raw payload.
59pub 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        // RFC 4231 test case 2 — an external truth source, not an echo
75        // of our own implementation.
76        assert_eq!(
77            signature_for("what do ya want for nothing?", "Jefe"),
78            "sha256=5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
79        );
80        // Negative control: a tampered body must not verify.
81        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(&notification.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        // No secret → no signature; with secret → signature over THIS body.
102        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}