bext-plugin-api 0.2.0

Plugin trait definitions and shared types for bext — the public ABI for plugin authors
Documentation
//! Webhook capability trait. See `plan/ecosystem/02-capabilities.md` (Webhook section).
//!
//! A `WebhookPlugin` verifies that an inbound HTTP request really was
//! sent by a given third party. The shape is deliberately vendor-neutral:
//! Stripe's `Stripe-Signature`, GitHub's `X-Hub-Signature-256`, Shopify's
//! `X-Shopify-Hmac-Sha256`, Slack's `v0:{ts}:{body}` scheme and Twilio's
//! URL + sorted-params scheme all satisfy the same surface, and a project
//! can swap implementations by editing `bext.config.toml` without touching
//! code.
//!
//! # Design notes
//!
//! - **Verify-only.** The trait does *not* carry a `handle(event)` method.
//!   In bext, webhook handlers are normal routes — the value of the
//!   capability is the per-vendor signature check that must run before
//!   the route body touches the payload. Each project's business logic
//!   for "what happens when Stripe reports a charge" lives in the route,
//!   not the plugin, and the plan's original `handle(event)` sketch
//!   would have forced every verifier crate to carry a decoder for every
//!   vendor's event shape — an open-ended commitment that violates
//!   architecture principle 6 (no vendor-specific fields on the trait).
//!   Leaving the plugin as a pure verifier also matches the existing
//!   `AuthPlugin::resolve` pattern: a trait that answers one well-posed
//!   question and hands the result back to middleware.
//!
//! - **Sync, not async.** Every other trait in this crate is sync
//!   (`middleware.rs`, `auth.rs`, `session.rs`, `mailer.rs`, `tracer.rs`,
//!   `scheduled.rs`). All five of the in-scope verifiers are pure
//!   in-memory HMAC — no network I/O, nothing to await. Matching the
//!   existing convention keeps the WASM/QuickJS/nsjail ABI consistent
//!   and avoids dragging `async-trait` into a dependency-minimal leaf
//!   crate. Future verifiers that need network I/O (OAuth-style
//!   signature checks with a JWKS fetch) bridge with `block_on` the
//!   same way `SesMailerPlugin` does today.
//!
//! - **Errors classify, don't decorate.** Callers need the variant to
//!   pick an HTTP status: `MissingHeader`/`MalformedPayload` → 400,
//!   `InvalidSignature`/`ReplayDetected` → 401, `Backend` → 500. The
//!   inner `String` is vendor-provided detail for logs. Do not parse
//!   it. This mirrors the `MailerError` shape from E1.
//!
//! - **Raw bytes, not a structured event.** `WebhookRequest::body` is a
//!   `Vec<u8>` because *every* vendor's signature covers the exact
//!   byte stream on the wire — JSON reserialisation would mutate
//!   whitespace and break the HMAC. Headers are `Vec<(String, String)>`
//!   rather than a map because multiple headers with the same name are
//!   legal in HTTP and preserving order aids reproducibility in logs.

use serde::{Deserialize, Serialize};

/// What kind of signature scheme a provider implements. Runtime uses
/// this to surface shape in admin UI and to help CLI wizards generate
/// the right secret-configuration stanza. It is *not* an exhaustive
/// taxonomy — new schemes land as new variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookSchemeKind {
    /// HMAC over the raw body with a shared secret.
    /// Used by GitHub (`X-Hub-Signature-256`) and Shopify
    /// (`X-Shopify-Hmac-Sha256`).
    HmacBody,
    /// HMAC over `{timestamp}.{body}` or `v0:{ts}:{body}` with replay
    /// protection via a freshness window.
    /// Used by Stripe and Slack.
    HmacTimestampedBody,
    /// HMAC over the request URL concatenated with sorted form-field
    /// pairs. Used by Twilio.
    HmacUrlFormFields,
}

/// A minimal, ABI-flat snapshot of the inbound HTTP request that a
/// `WebhookPlugin` needs in order to verify the signature.
///
/// Kept free of framework-specific types (`http::Request`, `hyper::Body`)
/// so that guests compiled to WASM can construct it directly from the
/// host-function marshalling layer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebhookRequest {
    /// Fully-qualified request URL including scheme, host, path and
    /// query. Twilio's signature covers this exact string, so
    /// middleware MUST preserve the bytes the client sent; do not
    /// re-format via a URL parser before passing to `verify`.
    pub url: String,

    /// HTTP method (`POST`, `GET`, ...). Some schemes are
    /// method-sensitive (Twilio uses `POST` vs `GET` to decide
    /// whether form fields participate in the signature).
    pub method: String,

    /// All request headers, in arrival order, keys preserved with
    /// their original casing. Matching is performed case-insensitively
    /// by the verifier; preserving original case is purely for logs.
    pub headers: Vec<(String, String)>,

    /// Raw body bytes exactly as received. Every in-scope verifier
    /// signs this byte stream; deserialising and re-serialising
    /// would mutate whitespace / field order and break the HMAC.
    pub body: Vec<u8>,

    /// Unix-epoch milliseconds at which the host received the
    /// request. Verifiers that enforce a freshness window
    /// (Stripe, Slack) use this as the "now" reference so that
    /// tests can pass a deterministic value instead of wall-clock
    /// time. Default: `0`, which verifiers interpret as "use the
    /// plugin's configured clock".
    #[serde(default)]
    pub received_at_ms: u64,
}

impl WebhookRequest {
    /// Case-insensitive header lookup. Returns the *first* matching
    /// header value; callers that need duplicates walk `headers`
    /// directly. Returns `None` if no header with `name` exists.
    pub fn header(&self, name: &str) -> Option<&str> {
        self.headers
            .iter()
            .find(|(k, _)| k.eq_ignore_ascii_case(name))
            .map(|(_, v)| v.as_str())
    }
}

/// Why a webhook verification failed, classified so callers can map
/// the variant onto an HTTP response code.
///
/// The inner `String` carries vendor-provided detail (`"timestamp
/// 123 is 412s old, max 300s"`, `"signature byte mismatch at index
/// 7"`, ...) suitable for logs and error pages. Do not parse it; use
/// the variant for branching.
///
/// HTTP mapping (enforced at the middleware layer, not here):
///
/// | Variant             | HTTP status | Safe to retry?           |
/// | ------------------- | ----------- | ------------------------ |
/// | `MissingHeader`     | 400         | No — fix the sender.     |
/// | `MalformedPayload`  | 400         | No — fix the sender.     |
/// | `InvalidSignature`  | 401         | No — wrong secret.       |
/// | `ReplayDetected`    | 401         | No — resend with new ts. |
/// | `Backend`           | 500         | Yes, once.               |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum WebhookError {
    /// A header the scheme requires is missing from the request.
    /// The inner string is the header name the plugin expected.
    MissingHeader(String),
    /// A required header or body section exists but is syntactically
    /// malformed (e.g. Stripe's `t=...,v1=...` comma-list fails to
    /// parse, Twilio's URL can't be reconstructed from the scheme +
    /// host + path triple).
    MalformedPayload(String),
    /// The HMAC comparison failed — either the body was mutated in
    /// flight, the signing secret on this side is wrong, or the
    /// request is an outright forgery. Indistinguishable from the
    /// plugin's point of view.
    InvalidSignature(String),
    /// A valid-looking request is too old to accept (Stripe default:
    /// 5 minutes, Slack default: 5 minutes). Resending with a fresh
    /// timestamp would succeed, so this is distinct from
    /// `InvalidSignature`.
    ReplayDetected(String),
    /// The plugin itself could not complete verification because of
    /// a host-side fault (secret missing from config, clock skew
    /// probe failed, underlying crypto library returned an error
    /// the plugin couldn't classify). Middleware MUST NOT leak this
    /// back to the sender as a signature failure.
    Backend(String),
}

impl WebhookError {
    pub fn missing_header(name: impl Into<String>) -> Self {
        WebhookError::MissingHeader(name.into())
    }

    pub fn malformed(detail: impl Into<String>) -> Self {
        WebhookError::MalformedPayload(detail.into())
    }

    pub fn invalid_signature(detail: impl Into<String>) -> Self {
        WebhookError::InvalidSignature(detail.into())
    }

    pub fn replay(detail: impl Into<String>) -> Self {
        WebhookError::ReplayDetected(detail.into())
    }

    pub fn backend(detail: impl Into<String>) -> Self {
        WebhookError::Backend(detail.into())
    }

    /// Borrow the inner detail string, regardless of variant.
    pub fn message(&self) -> &str {
        match self {
            WebhookError::MissingHeader(m)
            | WebhookError::MalformedPayload(m)
            | WebhookError::InvalidSignature(m)
            | WebhookError::ReplayDetected(m)
            | WebhookError::Backend(m) => m,
        }
    }

    /// Recommended HTTP status code for this error. Middleware
    /// returning the response body MAY override — this is a hint,
    /// not a hard binding.
    pub fn http_status(&self) -> u16 {
        match self {
            WebhookError::MissingHeader(_) | WebhookError::MalformedPayload(_) => 400,
            WebhookError::InvalidSignature(_) | WebhookError::ReplayDetected(_) => 401,
            WebhookError::Backend(_) => 500,
        }
    }
}

impl std::fmt::Display for WebhookError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let tag = match self {
            WebhookError::MissingHeader(_) => "missing-header",
            WebhookError::MalformedPayload(_) => "malformed-payload",
            WebhookError::InvalidSignature(_) => "invalid-signature",
            WebhookError::ReplayDetected(_) => "replay-detected",
            WebhookError::Backend(_) => "backend",
        };
        write!(f, "webhook {}: {}", tag, self.message())
    }
}

impl std::error::Error for WebhookError {}

/// A plugin that verifies webhook requests from one third-party
/// provider.
///
/// All methods are synchronous and return `Result` values to match
/// the rest of `bext-plugin-api` (WASM guests cannot express async
/// traits today). Verifiers that need network I/O bridge with a
/// `block_on` the same way `SesMailerPlugin` does.
///
/// Host functions `webhook.verify` call through to the currently
/// registered implementation keyed on `provider()`.
pub trait WebhookPlugin: Send + Sync {
    /// Vendor name used for plugin registration, e.g. `"stripe"`,
    /// `"github"`, `"shopify"`, `"slack"`, `"twilio"`. Lower-case,
    /// no whitespace.
    fn provider(&self) -> &str;

    /// Which signature scheme this plugin implements. Surfaced in
    /// admin UI; verifiers that compose two schemes pick the
    /// dominant one.
    fn scheme(&self) -> WebhookSchemeKind;

    /// Verify that `req` really came from the configured third
    /// party. Returns `Ok(())` on success; the caller then proceeds
    /// to its own handler. Middleware MUST treat any `Err` as a
    /// hard stop and use [`WebhookError::http_status`] (or its own
    /// override) to build the response.
    fn verify(&self, req: &WebhookRequest) -> Result<(), WebhookError>;

    /// Optional health check. Default: always healthy. Plugins that
    /// cache signing secrets from an external secret store override
    /// this to probe the cache.
    fn is_healthy(&self) -> bool {
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn header_lookup_is_case_insensitive() {
        let req = WebhookRequest {
            headers: vec![
                ("Content-Type".into(), "application/json".into()),
                ("X-Hub-Signature-256".into(), "sha256=abc".into()),
            ],
            ..Default::default()
        };
        assert_eq!(req.header("content-type"), Some("application/json"));
        assert_eq!(req.header("X-HUB-SIGNATURE-256"), Some("sha256=abc"));
        assert_eq!(req.header("missing"), None);
    }

    #[test]
    fn header_lookup_returns_first_duplicate() {
        let req = WebhookRequest {
            headers: vec![
                ("X-Forwarded-For".into(), "a".into()),
                ("X-Forwarded-For".into(), "b".into()),
            ],
            ..Default::default()
        };
        assert_eq!(req.header("x-forwarded-for"), Some("a"));
    }

    #[test]
    fn webhook_error_helpers_and_http_status() {
        let e = WebhookError::missing_header("Stripe-Signature");
        assert!(matches!(e, WebhookError::MissingHeader(_)));
        assert_eq!(e.http_status(), 400);
        assert_eq!(e.message(), "Stripe-Signature");
        assert!(e.to_string().contains("missing-header"));

        assert_eq!(WebhookError::malformed("x").http_status(), 400);
        assert_eq!(WebhookError::invalid_signature("x").http_status(), 401);
        assert_eq!(WebhookError::replay("x").http_status(), 401);
        assert_eq!(WebhookError::backend("x").http_status(), 500);
    }

    struct AlwaysOkPlugin;
    impl WebhookPlugin for AlwaysOkPlugin {
        fn provider(&self) -> &str { "test" }
        fn scheme(&self) -> WebhookSchemeKind { WebhookSchemeKind::HmacBody }
        fn verify(&self, _req: &WebhookRequest) -> Result<(), WebhookError> { Ok(()) }
    }

    #[test]
    fn trait_is_object_safe_and_default_health_works() {
        let p: Box<dyn WebhookPlugin> = Box::new(AlwaysOkPlugin);
        assert_eq!(p.provider(), "test");
        assert!(p.is_healthy());
        assert!(p.verify(&WebhookRequest::default()).is_ok());
    }
}