fraiseql_webhooks/lib.rs
1//! # fraiseql-webhooks
2//!
3//! **Inbound** webhook receiver for FraiseQL — receives, verifies, and routes HTTP
4//! callbacks from third-party services into your database.
5//!
6//! ## Inbound vs. Outbound
7//!
8//! FraiseQL has two webhook-related crates with complementary roles:
9//!
10//! | Crate | Direction | Purpose |
11//! |-------|-----------|---------|
12//! | `fraiseql-webhooks` | **Inbound** | Receive callbacks from Stripe, GitHub, Shopify, … |
13//! | `fraiseql-observers` | **Outbound** | Emit notifications when your data changes |
14//!
15//! Use `fraiseql-webhooks` when you want to react to events from external providers.
16//! Use `fraiseql-observers` when you want to push events to subscribers.
17//!
18//! ## Supported Providers
19//!
20//! Built-in signature verification for:
21//! - **Stripe** — HMAC-SHA256 on `Stripe-Signature` header with replay protection
22//! - **GitHub** — HMAC-SHA256 on `X-Hub-Signature-256` header
23//! - **Shopify** — HMAC-SHA256 on `X-Shopify-Hmac-Sha256` header
24//! - **SendGrid** — ECDSA on `X-Twilio-Email-Event-Webhook-Signature`
25//! - **Paddle** — RSA-SHA256 on `Paddle-Signature` header
26//! - Custom providers via the [`traits::SignatureVerifier`] trait
27//!
28//! ## Security Properties
29//!
30//! - **Constant-time comparison** — prevents timing attacks on HMAC tokens
31//! - **Replay protection** — Stripe/Paddle timestamps validated within a 5-minute window
32//! - **Idempotency** — duplicate delivery is detected and silently discarded
33//! - **Transaction boundaries** — each webhook is processed inside a database transaction
34//!
35//! ## Quick Start
36//!
37//! ```text
38//! See docs/architecture/webhooks.md for the full integration guide.
39//! ```
40//!
41//! ## See Also
42//!
43//! - [`docs/architecture/webhooks.md`](../../../../docs/architecture/webhooks.md) — full design doc
44//! - `fraiseql-observers` — outbound change notifications
45//!
46//! ## Features
47
48#![forbid(unsafe_code)]
49// module_name_repetitions, must_use_candidate, uninlined_format_args:
50// allowed at workspace level (Cargo.toml [workspace.lints.clippy]).
51#![allow(clippy::doc_markdown)] // Reason: technical terms don't need backtick wrapping
52#![allow(clippy::struct_field_names)] // Reason: field prefixes match domain terminology
53#![allow(clippy::wildcard_imports)] // Reason: test modules use wildcard imports
54#![allow(clippy::items_after_statements)] // Reason: helper structs near point of use in tests
55#![allow(clippy::missing_const_for_fn)] // Reason: const fn not stable for all patterns used
56#![allow(clippy::cast_possible_wrap)] // Reason: values are within i64 range by design
57#![allow(clippy::redundant_clone)] // Reason: explicit clone at API boundaries for clarity
58//! - **15+ provider support**: Stripe, GitHub, Shopify, and more
59//! - **Signature verification**: Constant-time comparison for security
60//! - **Idempotency**: Prevent duplicate event processing
61//! - **Event routing**: Map webhook events to database functions
62//! - **Transaction boundaries**: Correct isolation levels for data consistency
63
64pub mod config;
65pub mod signature;
66pub mod testing;
67pub mod traits;
68pub mod transaction;
69
70// Re-exports
71pub use config::{WebhookConfig, WebhookEventConfig};
72pub use signature::SignatureError;
73// Re-export testing mocks for unit tests and integration tests with `testing` feature
74#[cfg(any(test, feature = "testing"))]
75pub use testing::mocks;
76pub use traits::{Clock, EventHandler, IdempotencyStore, SecretProvider, SignatureVerifier};
77pub use transaction::{WebhookIsolation, execute_in_transaction};
78
79/// Errors that can occur during webhook request processing.
80#[derive(Debug, thiserror::Error)]
81#[non_exhaustive]
82pub enum WebhookError {
83 /// The incoming request did not include the expected signature header for the provider.
84 #[error("Missing signature header")]
85 MissingSignature,
86
87 /// The signature header was present but could not be parsed according to the provider's format.
88 /// The inner string contains a description of the parse failure.
89 #[error("Invalid signature format: {0}")]
90 InvalidSignature(String),
91
92 /// The computed HMAC or asymmetric signature did not match the value in the request header.
93 #[error("Signature verification failed")]
94 SignatureVerificationFailed,
95
96 /// The webhook request timestamp is outside the configured replay-protection tolerance window.
97 #[error("Timestamp expired (received: {received}, now: {now}, tolerance: {tolerance}s)")]
98 TimestampExpired {
99 /// Unix timestamp (seconds) extracted from the request.
100 received: i64,
101 /// Unix timestamp (seconds) at the time of verification.
102 now: i64,
103 /// Maximum allowed age of a request in seconds before it is rejected.
104 tolerance: u64,
105 },
106
107 /// The provider requires a timestamp header for replay protection, but none was present.
108 #[error("Missing timestamp header")]
109 MissingTimestamp,
110
111 /// The secret named in the configuration could not be retrieved from the secret provider.
112 /// The inner string is the secret name that was not found.
113 #[error("Missing webhook secret: {0}")]
114 MissingSecret(String),
115
116 /// The request arrived for a provider name that is not registered in the `ProviderRegistry`.
117 /// The inner string is the unrecognised provider name.
118 #[error("Unknown webhook provider: {0}")]
119 UnknownProvider(String),
120
121 /// The event type extracted from the payload has no corresponding handler in the configuration.
122 /// The inner string is the unrecognised event type.
123 #[error("Unknown event type: {0}")]
124 UnknownEvent(String),
125
126 /// The request body could not be deserialised as a valid JSON payload.
127 /// The inner string is the serde_json error message.
128 #[error("Invalid payload: {0}")]
129 InvalidPayload(String),
130
131 /// The database function called by the event handler returned an error or panicked.
132 /// The inner string contains the handler's error message.
133 #[error("Handler execution failed: {0}")]
134 HandlerFailed(String),
135
136 /// A sqlx database operation failed during transaction management or idempotency checking.
137 /// The inner string is the sqlx error message.
138 #[error("Database error: {0}")]
139 Database(String),
140
141 /// Evaluation of a configured conditional expression failed.
142 /// The inner string describes the evaluation error.
143 #[error("Condition evaluation error: {0}")]
144 Condition(String),
145
146 /// A field mapping from the webhook payload to a function parameter failed.
147 /// The inner string describes which mapping could not be applied.
148 #[error("Mapping error: {0}")]
149 Mapping(String),
150
151 /// A webhook was received for a provider that has no entry in the active configuration.
152 /// The inner string is the provider name.
153 #[error("Provider not configured: {0}")]
154 ProviderNotConfigured(String),
155
156 /// A configuration field contains an invalid value.
157 /// The inner string describes the validation failure.
158 #[error("Configuration error: {0}")]
159 Configuration(String),
160}
161
162impl From<sqlx::Error> for WebhookError {
163 fn from(err: sqlx::Error) -> Self {
164 Self::Database(err.to_string())
165 }
166}
167
168impl From<serde_json::Error> for WebhookError {
169 fn from(err: serde_json::Error) -> Self {
170 Self::InvalidPayload(err.to_string())
171 }
172}
173
174/// Result type for webhook operations
175pub type Result<T> = std::result::Result<T, WebhookError>;