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}