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}