Skip to main content

nexo_tool_meta/
webhook.rs

1//! [`WebhookEnvelope`] — the JSON payload nexo publishes to NATS
2//! after a webhook source verifies and parses an inbound HTTP
3//! request.
4//!
5//! Microapps subscribe to the broker subject (typically
6//! `webhook.<source_id>.<event_kind>`) and deserialise this
7//! envelope to react to provider events.
8
9use std::collections::BTreeMap;
10use std::net::IpAddr;
11
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15/// Current `WebhookEnvelope.schema` version. The daemon stamps
16/// every envelope with this constant; consumers read it to gate
17/// behaviour against a known shape.
18pub const ENVELOPE_SCHEMA_VERSION: u8 = 1;
19
20/// Typed JSON envelope nexo publishes after every accepted
21/// webhook request.
22///
23/// Subscribers correlate events via `envelope_id` (deterministic
24/// dedup) and `received_at_ms` (late-binding analytics).
25/// `headers_subset` is a defensive allowlist — secrets like
26/// `Authorization` / `Cookie` / signature headers are stripped
27/// before publish, so a NATS subscriber sees only non-secret
28/// correlation IDs.
29///
30/// Unlike [`crate::BindingContext`], this struct is intentionally
31/// *not* `#[non_exhaustive]`: it represents a wire-shape value
32/// constructed on both sides (the daemon writes it; tests +
33/// mocks build it via struct-literal). Field additions are
34/// semver-major because the JSON wire shape changes regardless.
35///
36/// # Example
37///
38/// Microapps typically deserialise the envelope from a NATS
39/// payload:
40///
41/// ```
42/// use nexo_tool_meta::WebhookEnvelope;
43///
44/// let payload = serde_json::json!({
45///     "schema": 1,
46///     "source_id": "github_main",
47///     "event_kind": "pull_request",
48///     "body_json": {"action": "opened"},
49///     "headers_subset": {},
50///     "received_at_ms": 0,
51///     "envelope_id": "00000000-0000-0000-0000-000000000000",
52///     "client_ip": null
53/// });
54/// let env: WebhookEnvelope = serde_json::from_value(payload).unwrap();
55/// assert_eq!(env.schema, 1);
56/// assert_eq!(env.source_id, "github_main");
57/// ```
58#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
60pub struct WebhookEnvelope {
61    /// Wire-shape version. Always [`ENVELOPE_SCHEMA_VERSION`].
62    pub schema: u8,
63    /// Operator-assigned source identifier — matches the
64    /// `webhook_receiver.sources[].id` YAML field.
65    pub source_id: String,
66    /// Event kind extracted from the inbound request (header or
67    /// JSON body path, per source config).
68    pub event_kind: String,
69    /// Inbound body, parsed as JSON. Non-JSON bodies are wrapped
70    /// as `{ "raw_base64": "..." }` upstream.
71    pub body_json: serde_json::Value,
72    /// Allowlisted headers forwarded for downstream correlation.
73    /// Authorization / Cookie / signature headers are stripped.
74    pub headers_subset: BTreeMap<String, String>,
75    /// Server-side receipt timestamp in milliseconds since epoch.
76    pub received_at_ms: i64,
77    /// Random per-envelope identifier — useful for dedup.
78    pub envelope_id: Uuid,
79    /// Resolved client IP (after trusted-proxy logic). `None`
80    /// for envelopes built outside an HTTP request context.
81    pub client_ip: Option<IpAddr>,
82}
83
84/// Turn-log marker.
85///
86/// Returns `"webhook:<source_id>"` so a downstream audit row can
87/// distinguish webhook-originated turns from native-channel
88/// inbounds. Mirrors the `"channel:<server>"` convention used
89/// for MCP channels.
90///
91/// # Example
92///
93/// ```
94/// use nexo_tool_meta::format_webhook_source;
95/// assert_eq!(format_webhook_source("github_main"), "webhook:github_main");
96/// ```
97pub fn format_webhook_source(source_id: &str) -> String {
98    format!("webhook:{source_id}")
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn sample() -> WebhookEnvelope {
106        WebhookEnvelope {
107            schema: ENVELOPE_SCHEMA_VERSION,
108            source_id: "github_main".into(),
109            event_kind: "pull_request".into(),
110            body_json: serde_json::json!({"action": "opened"}),
111            headers_subset: BTreeMap::new(),
112            received_at_ms: 1_700_000_000,
113            envelope_id: Uuid::nil(),
114            client_ip: None,
115        }
116    }
117
118    #[test]
119    fn schema_constant_locked_at_1() {
120        assert_eq!(ENVELOPE_SCHEMA_VERSION, 1);
121        assert_eq!(sample().schema, 1);
122    }
123
124    #[test]
125    fn round_trip_through_serde() {
126        let original = sample();
127        let json = serde_json::to_string(&original).unwrap();
128        let back: WebhookEnvelope = serde_json::from_str(&json).unwrap();
129        assert_eq!(original, back);
130    }
131
132    #[test]
133    fn wire_shape_lock_down() {
134        let env = sample();
135        let v = serde_json::to_value(&env).unwrap();
136        for key in [
137            "schema",
138            "source_id",
139            "event_kind",
140            "body_json",
141            "headers_subset",
142            "received_at_ms",
143            "envelope_id",
144            "client_ip",
145        ] {
146            assert!(v.get(key).is_some(), "missing key `{key}` in envelope");
147        }
148    }
149
150    #[test]
151    fn format_webhook_source_prefixes() {
152        assert_eq!(format_webhook_source("github_main"), "webhook:github_main");
153        assert_eq!(format_webhook_source(""), "webhook:");
154    }
155}