Skip to main content

bext_plugin_api/
webhook.rs

1//! Webhook capability trait. See `plan/ecosystem/02-capabilities.md` (Webhook section).
2//!
3//! A `WebhookPlugin` verifies that an inbound HTTP request really was
4//! sent by a given third party. The shape is deliberately vendor-neutral:
5//! Stripe's `Stripe-Signature`, GitHub's `X-Hub-Signature-256`, Shopify's
6//! `X-Shopify-Hmac-Sha256`, Slack's `v0:{ts}:{body}` scheme and Twilio's
7//! URL + sorted-params scheme all satisfy the same surface, and a project
8//! can swap implementations by editing `bext.config.toml` without touching
9//! code.
10//!
11//! # Design notes
12//!
13//! - **Verify-only.** The trait does *not* carry a `handle(event)` method.
14//!   In bext, webhook handlers are normal routes — the value of the
15//!   capability is the per-vendor signature check that must run before
16//!   the route body touches the payload. Each project's business logic
17//!   for "what happens when Stripe reports a charge" lives in the route,
18//!   not the plugin, and the plan's original `handle(event)` sketch
19//!   would have forced every verifier crate to carry a decoder for every
20//!   vendor's event shape — an open-ended commitment that violates
21//!   architecture principle 6 (no vendor-specific fields on the trait).
22//!   Leaving the plugin as a pure verifier also matches the existing
23//!   `AuthPlugin::resolve` pattern: a trait that answers one well-posed
24//!   question and hands the result back to middleware.
25//!
26//! - **Sync, not async.** Every other trait in this crate is sync
27//!   (`middleware.rs`, `auth.rs`, `session.rs`, `mailer.rs`, `tracer.rs`,
28//!   `scheduled.rs`). All five of the in-scope verifiers are pure
29//!   in-memory HMAC — no network I/O, nothing to await. Matching the
30//!   existing convention keeps the WASM/QuickJS/nsjail ABI consistent
31//!   and avoids dragging `async-trait` into a dependency-minimal leaf
32//!   crate. Future verifiers that need network I/O (OAuth-style
33//!   signature checks with a JWKS fetch) bridge with `block_on` the
34//!   same way `SesMailerPlugin` does today.
35//!
36//! - **Errors classify, don't decorate.** Callers need the variant to
37//!   pick an HTTP status: `MissingHeader`/`MalformedPayload` → 400,
38//!   `InvalidSignature`/`ReplayDetected` → 401, `Backend` → 500. The
39//!   inner `String` is vendor-provided detail for logs. Do not parse
40//!   it. This mirrors the `MailerError` shape from E1.
41//!
42//! - **Raw bytes, not a structured event.** `WebhookRequest::body` is a
43//!   `Vec<u8>` because *every* vendor's signature covers the exact
44//!   byte stream on the wire — JSON reserialisation would mutate
45//!   whitespace and break the HMAC. Headers are `Vec<(String, String)>`
46//!   rather than a map because multiple headers with the same name are
47//!   legal in HTTP and preserving order aids reproducibility in logs.
48
49use serde::{Deserialize, Serialize};
50
51/// What kind of signature scheme a provider implements. Runtime uses
52/// this to surface shape in admin UI and to help CLI wizards generate
53/// the right secret-configuration stanza. It is *not* an exhaustive
54/// taxonomy — new schemes land as new variants.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum WebhookSchemeKind {
58    /// HMAC over the raw body with a shared secret.
59    /// Used by GitHub (`X-Hub-Signature-256`) and Shopify
60    /// (`X-Shopify-Hmac-Sha256`).
61    HmacBody,
62    /// HMAC over `{timestamp}.{body}` or `v0:{ts}:{body}` with replay
63    /// protection via a freshness window.
64    /// Used by Stripe and Slack.
65    HmacTimestampedBody,
66    /// HMAC over the request URL concatenated with sorted form-field
67    /// pairs. Used by Twilio.
68    HmacUrlFormFields,
69}
70
71/// A minimal, ABI-flat snapshot of the inbound HTTP request that a
72/// `WebhookPlugin` needs in order to verify the signature.
73///
74/// Kept free of framework-specific types (`http::Request`, `hyper::Body`)
75/// so that guests compiled to WASM can construct it directly from the
76/// host-function marshalling layer.
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct WebhookRequest {
79    /// Fully-qualified request URL including scheme, host, path and
80    /// query. Twilio's signature covers this exact string, so
81    /// middleware MUST preserve the bytes the client sent; do not
82    /// re-format via a URL parser before passing to `verify`.
83    pub url: String,
84
85    /// HTTP method (`POST`, `GET`, ...). Some schemes are
86    /// method-sensitive (Twilio uses `POST` vs `GET` to decide
87    /// whether form fields participate in the signature).
88    pub method: String,
89
90    /// All request headers, in arrival order, keys preserved with
91    /// their original casing. Matching is performed case-insensitively
92    /// by the verifier; preserving original case is purely for logs.
93    pub headers: Vec<(String, String)>,
94
95    /// Raw body bytes exactly as received. Every in-scope verifier
96    /// signs this byte stream; deserialising and re-serialising
97    /// would mutate whitespace / field order and break the HMAC.
98    pub body: Vec<u8>,
99
100    /// Unix-epoch milliseconds at which the host received the
101    /// request. Verifiers that enforce a freshness window
102    /// (Stripe, Slack) use this as the "now" reference so that
103    /// tests can pass a deterministic value instead of wall-clock
104    /// time. Default: `0`, which verifiers interpret as "use the
105    /// plugin's configured clock".
106    #[serde(default)]
107    pub received_at_ms: u64,
108}
109
110impl WebhookRequest {
111    /// Case-insensitive header lookup. Returns the *first* matching
112    /// header value; callers that need duplicates walk `headers`
113    /// directly. Returns `None` if no header with `name` exists.
114    pub fn header(&self, name: &str) -> Option<&str> {
115        self.headers
116            .iter()
117            .find(|(k, _)| k.eq_ignore_ascii_case(name))
118            .map(|(_, v)| v.as_str())
119    }
120}
121
122/// Why a webhook verification failed, classified so callers can map
123/// the variant onto an HTTP response code.
124///
125/// The inner `String` carries vendor-provided detail (`"timestamp
126/// 123 is 412s old, max 300s"`, `"signature byte mismatch at index
127/// 7"`, ...) suitable for logs and error pages. Do not parse it; use
128/// the variant for branching.
129///
130/// HTTP mapping (enforced at the middleware layer, not here):
131///
132/// | Variant             | HTTP status | Safe to retry?           |
133/// | ------------------- | ----------- | ------------------------ |
134/// | `MissingHeader`     | 400         | No — fix the sender.     |
135/// | `MalformedPayload`  | 400         | No — fix the sender.     |
136/// | `InvalidSignature`  | 401         | No — wrong secret.       |
137/// | `ReplayDetected`    | 401         | No — resend with new ts. |
138/// | `Backend`           | 500         | Yes, once.               |
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub enum WebhookError {
141    /// A header the scheme requires is missing from the request.
142    /// The inner string is the header name the plugin expected.
143    MissingHeader(String),
144    /// A required header or body section exists but is syntactically
145    /// malformed (e.g. Stripe's `t=...,v1=...` comma-list fails to
146    /// parse, Twilio's URL can't be reconstructed from the scheme +
147    /// host + path triple).
148    MalformedPayload(String),
149    /// The HMAC comparison failed — either the body was mutated in
150    /// flight, the signing secret on this side is wrong, or the
151    /// request is an outright forgery. Indistinguishable from the
152    /// plugin's point of view.
153    InvalidSignature(String),
154    /// A valid-looking request is too old to accept (Stripe default:
155    /// 5 minutes, Slack default: 5 minutes). Resending with a fresh
156    /// timestamp would succeed, so this is distinct from
157    /// `InvalidSignature`.
158    ReplayDetected(String),
159    /// The plugin itself could not complete verification because of
160    /// a host-side fault (secret missing from config, clock skew
161    /// probe failed, underlying crypto library returned an error
162    /// the plugin couldn't classify). Middleware MUST NOT leak this
163    /// back to the sender as a signature failure.
164    Backend(String),
165}
166
167impl WebhookError {
168    pub fn missing_header(name: impl Into<String>) -> Self {
169        WebhookError::MissingHeader(name.into())
170    }
171
172    pub fn malformed(detail: impl Into<String>) -> Self {
173        WebhookError::MalformedPayload(detail.into())
174    }
175
176    pub fn invalid_signature(detail: impl Into<String>) -> Self {
177        WebhookError::InvalidSignature(detail.into())
178    }
179
180    pub fn replay(detail: impl Into<String>) -> Self {
181        WebhookError::ReplayDetected(detail.into())
182    }
183
184    pub fn backend(detail: impl Into<String>) -> Self {
185        WebhookError::Backend(detail.into())
186    }
187
188    /// Borrow the inner detail string, regardless of variant.
189    pub fn message(&self) -> &str {
190        match self {
191            WebhookError::MissingHeader(m)
192            | WebhookError::MalformedPayload(m)
193            | WebhookError::InvalidSignature(m)
194            | WebhookError::ReplayDetected(m)
195            | WebhookError::Backend(m) => m,
196        }
197    }
198
199    /// Recommended HTTP status code for this error. Middleware
200    /// returning the response body MAY override — this is a hint,
201    /// not a hard binding.
202    pub fn http_status(&self) -> u16 {
203        match self {
204            WebhookError::MissingHeader(_) | WebhookError::MalformedPayload(_) => 400,
205            WebhookError::InvalidSignature(_) | WebhookError::ReplayDetected(_) => 401,
206            WebhookError::Backend(_) => 500,
207        }
208    }
209}
210
211impl std::fmt::Display for WebhookError {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        let tag = match self {
214            WebhookError::MissingHeader(_) => "missing-header",
215            WebhookError::MalformedPayload(_) => "malformed-payload",
216            WebhookError::InvalidSignature(_) => "invalid-signature",
217            WebhookError::ReplayDetected(_) => "replay-detected",
218            WebhookError::Backend(_) => "backend",
219        };
220        write!(f, "webhook {}: {}", tag, self.message())
221    }
222}
223
224impl std::error::Error for WebhookError {}
225
226/// A plugin that verifies webhook requests from one third-party
227/// provider.
228///
229/// All methods are synchronous and return `Result` values to match
230/// the rest of `bext-plugin-api` (WASM guests cannot express async
231/// traits today). Verifiers that need network I/O bridge with a
232/// `block_on` the same way `SesMailerPlugin` does.
233///
234/// Host functions `webhook.verify` call through to the currently
235/// registered implementation keyed on `provider()`.
236pub trait WebhookPlugin: Send + Sync {
237    /// Vendor name used for plugin registration, e.g. `"stripe"`,
238    /// `"github"`, `"shopify"`, `"slack"`, `"twilio"`. Lower-case,
239    /// no whitespace.
240    fn provider(&self) -> &str;
241
242    /// Which signature scheme this plugin implements. Surfaced in
243    /// admin UI; verifiers that compose two schemes pick the
244    /// dominant one.
245    fn scheme(&self) -> WebhookSchemeKind;
246
247    /// Verify that `req` really came from the configured third
248    /// party. Returns `Ok(())` on success; the caller then proceeds
249    /// to its own handler. Middleware MUST treat any `Err` as a
250    /// hard stop and use [`WebhookError::http_status`] (or its own
251    /// override) to build the response.
252    fn verify(&self, req: &WebhookRequest) -> Result<(), WebhookError>;
253
254    /// Optional health check. Default: always healthy. Plugins that
255    /// cache signing secrets from an external secret store override
256    /// this to probe the cache.
257    fn is_healthy(&self) -> bool {
258        true
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn header_lookup_is_case_insensitive() {
268        let req = WebhookRequest {
269            headers: vec![
270                ("Content-Type".into(), "application/json".into()),
271                ("X-Hub-Signature-256".into(), "sha256=abc".into()),
272            ],
273            ..Default::default()
274        };
275        assert_eq!(req.header("content-type"), Some("application/json"));
276        assert_eq!(req.header("X-HUB-SIGNATURE-256"), Some("sha256=abc"));
277        assert_eq!(req.header("missing"), None);
278    }
279
280    #[test]
281    fn header_lookup_returns_first_duplicate() {
282        let req = WebhookRequest {
283            headers: vec![
284                ("X-Forwarded-For".into(), "a".into()),
285                ("X-Forwarded-For".into(), "b".into()),
286            ],
287            ..Default::default()
288        };
289        assert_eq!(req.header("x-forwarded-for"), Some("a"));
290    }
291
292    #[test]
293    fn webhook_error_helpers_and_http_status() {
294        let e = WebhookError::missing_header("Stripe-Signature");
295        assert!(matches!(e, WebhookError::MissingHeader(_)));
296        assert_eq!(e.http_status(), 400);
297        assert_eq!(e.message(), "Stripe-Signature");
298        assert!(e.to_string().contains("missing-header"));
299
300        assert_eq!(WebhookError::malformed("x").http_status(), 400);
301        assert_eq!(WebhookError::invalid_signature("x").http_status(), 401);
302        assert_eq!(WebhookError::replay("x").http_status(), 401);
303        assert_eq!(WebhookError::backend("x").http_status(), 500);
304    }
305
306    struct AlwaysOkPlugin;
307    impl WebhookPlugin for AlwaysOkPlugin {
308        fn provider(&self) -> &str { "test" }
309        fn scheme(&self) -> WebhookSchemeKind { WebhookSchemeKind::HmacBody }
310        fn verify(&self, _req: &WebhookRequest) -> Result<(), WebhookError> { Ok(()) }
311    }
312
313    #[test]
314    fn trait_is_object_safe_and_default_health_works() {
315        let p: Box<dyn WebhookPlugin> = Box::new(AlwaysOkPlugin);
316        assert_eq!(p.provider(), "test");
317        assert!(p.is_healthy());
318        assert!(p.verify(&WebhookRequest::default()).is_ok());
319    }
320}