Skip to main content

autumn_harvest/
webhook_trigger.rs

1//! Inbound HTTP webhook trigger descriptors (issue #344).
2//!
3//! # Who owns what
4//!
5//! Harvest does **not** ship its own signature-verification, replay-protection,
6//! or secret-rotation code. autumn-web 0.5 already has first-class signed
7//! webhook intake (`autumn_web::webhook::SignedWebhook`, configured via
8//! `[security.webhooks.endpoints]`): it verifies the sender's signature and
9//! timestamp, rejects stale/duplicate deliveries, and rotates secrets — all
10//! **before** any handler code runs. Harvest's slice sits entirely downstream
11//! of that verification and answers a narrower question: *which workflow
12//! should this already-verified delivery start or signal, and how do we
13//! dispatch that idempotently?*
14//!
15//! This mirrors the existing outbound-webhook extension slice
16//! (`autumn-harvest-plugin`'s `webhooks` feature, which durably delivers
17//! outbound webhooks through autumn-web's `webhook_outbound` module) and the
18//! `#[workflow(mcp)]` MCP tool slice (issue #597): Autumn owns the primitive,
19//! Harvest ships a thin binding layer.
20//!
21//! # The pieces
22//!
23//! - [`WebhookCtx`] — verified request metadata handed to a `#[webhook]`
24//!   mapping function (never the *unverified* request).
25//! - [`WebhookTarget`] — whether a verified delivery starts a fresh workflow
26//!   or signals (with start-or-attach) an existing one.
27//! - [`WebhookTriggerInfo`] — the registration record produced by a
28//!   `#[webhook]` companion function and collected by `webhooks![]`.
29//! - [`validate_webhook_triggers`] — pure fail-fast validation run at
30//!   `HarvestPlugin::build` time, before any route is mounted.
31//!
32//! See `docs/getting-started/12-webhooks.md` for an end-to-end walkthrough.
33
34use crate::types::WorkflowId;
35
36/// Verified webhook request metadata handed to a `#[webhook]` mapping
37/// function.
38///
39/// Built by the plugin from an already-verified
40/// `autumn_web::webhook::SignedWebhook` — by the time a mapping function
41/// receives a `WebhookCtx`, signature, timestamp, and (if configured) replay
42/// verification have already succeeded. A mapping function never sees an
43/// unverified request.
44#[derive(Debug, Clone)]
45pub struct WebhookCtx {
46    /// The exact HTTP path this webhook is bound to (the `#[webhook(path =
47    /// ...)]` value), useful for logging when one mapping function is shared
48    /// across bindings.
49    pub path: &'static str,
50    /// The `security.webhooks.endpoints` configuration entry name that
51    /// verified this request.
52    pub endpoint: String,
53    /// The provider preset that verified this request (`"stripe"`,
54    /// `"github"`, `"slack"`, or `"generic"`).
55    pub provider: String,
56    /// The provider-supplied delivery ID, when the endpoint's
57    /// `delivery_id_header` (or a top-level JSON `"id"` field) resolved one.
58    ///
59    /// Required for [`WebhookTarget::SignalsWithStart`] targets — it becomes
60    /// the signal's idempotency key. Not required for
61    /// [`WebhookTarget::Starts`] targets, whose idempotency comes from the
62    /// mapping function's deterministic [`WorkflowId`].
63    pub delivery_id: Option<String>,
64    /// The provider-supplied event type, when the endpoint's
65    /// `event_type_header` resolved one.
66    pub event_type: Option<String>,
67    /// The exact verified request body bytes.
68    pub raw_body: Vec<u8>,
69}
70
71impl WebhookCtx {
72    /// Construct a `WebhookCtx` from already-verified request metadata.
73    #[must_use]
74    pub fn new(
75        path: &'static str,
76        endpoint: impl Into<String>,
77        provider: impl Into<String>,
78        delivery_id: Option<String>,
79        event_type: Option<String>,
80        raw_body: Vec<u8>,
81    ) -> Self {
82        Self {
83            path,
84            endpoint: endpoint.into(),
85            provider: provider.into(),
86            delivery_id,
87            event_type,
88            raw_body,
89        }
90    }
91}
92
93/// Why a `#[webhook]` mapping function's dispatch shim failed to produce a
94/// workflow trigger.
95///
96/// Both variants are surfaced by the plugin as `400 Bad Request` with a
97/// distinguishable `error_code` (`"parse_failed"` for [`Self::Deserialize`],
98/// `"mapping_rejected"` for [`Self::Rejected`]) — never a `500`, since the
99/// request itself has already passed signature verification by this point.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum WebhookHandlerError {
102    /// The verified body could not be deserialized into the mapping
103    /// function's typed payload parameter.
104    Deserialize(String),
105    /// The mapping function itself returned `Err`.
106    Rejected(String),
107}
108
109impl std::fmt::Display for WebhookHandlerError {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            Self::Deserialize(msg) => write!(f, "webhook payload deserialize failed: {msg}"),
113            Self::Rejected(msg) => write!(f, "webhook mapping function rejected payload: {msg}"),
114        }
115    }
116}
117
118impl std::error::Error for WebhookHandlerError {}
119
120/// Type-erased `#[webhook]` mapping shim.
121///
122/// A plain `fn` pointer — mirrors [`crate::info::WorkflowHandlerFn`]'s design
123/// (design decision #6: fn pointers keep registration `Sync` without needing
124/// `Arc`). The macro emits a monomorphized closure, cast to this type, that
125/// deserializes `payload` into the user's typed parameter and invokes the
126/// (synchronous, by design — see the module docs on why webhook mapping
127/// functions never do I/O) mapping function.
128///
129/// `payload` is borrowed rather than owned: the plugin's dispatch path needs
130/// the same verified body both for this shim's deserialization and, on
131/// dispatch, as the workflow input/signal payload it hands to the delegate
132/// start call -- taking `&Value` here lets the caller keep its own owned
133/// copy without an extra clone per dispatch.
134pub type WebhookHandlerFn =
135    fn(&WebhookCtx, &serde_json::Value) -> Result<WorkflowId, WebhookHandlerError>;
136
137/// Where a verified webhook delivery is routed.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum WebhookTarget {
140    /// Start a fresh workflow execution (`reuse_policy: AllowDuplicate`). The
141    /// mapping function's returned [`WorkflowId`] is the idempotency
142    /// mechanism: redelivering the same logical event should map to the same
143    /// `WorkflowId`.
144    Starts {
145        /// The target workflow's registered name.
146        workflow: &'static str,
147    },
148    /// Atomically start-or-attach and signal an existing (or fresh) workflow
149    /// execution, keyed by the verified delivery ID (issue #244's
150    /// `signal_with_start`).
151    SignalsWithStart {
152        /// The target workflow's registered name.
153        workflow: &'static str,
154        /// The signal name delivered to the target workflow.
155        signal_name: &'static str,
156    },
157}
158
159impl WebhookTarget {
160    /// The target workflow's registered name, regardless of variant.
161    #[must_use]
162    pub const fn workflow(&self) -> &'static str {
163        match self {
164            Self::Starts { workflow } | Self::SignalsWithStart { workflow, .. } => workflow,
165        }
166    }
167}
168
169/// Registration produced by a `#[webhook]` companion function, collected by
170/// `webhooks![]` and consumed by `HarvestPlugin::webhooks(...)`.
171#[derive(Debug, Clone)]
172pub struct WebhookTriggerInfo {
173    /// The annotated function's name.
174    pub name: &'static str,
175    /// The annotated function's module path (`module_path!()`), for
176    /// diagnostics when two mapping functions share a name.
177    pub module: &'static str,
178    /// The exact HTTP path this webhook binds to. Must start with `/` and
179    /// must not be `/`. Must match a `security.webhooks.endpoints[].path` in
180    /// the embedding app's configuration exactly — that is the endpoint
181    /// whose verification governs this binding.
182    pub path: &'static str,
183    /// Where a verified delivery is routed.
184    pub target: WebhookTarget,
185    /// The type-erased mapping shim.
186    pub handler: WebhookHandlerFn,
187    /// Optional task-queue override for the dispatched workflow start. `None`
188    /// defers to the target workflow's own registered default queue.
189    pub queue: Option<&'static str>,
190}
191
192/// Pure validation for a set of registered webhook triggers.
193///
194/// Rejects duplicate binding paths (which would silently shadow each other
195/// at the HTTP router level) and malformed paths. Run at
196/// `HarvestPlugin::build` time so a mount conflict fails fast — at process
197/// startup — rather than manifesting as a confusing 404/wrong-handler on the
198/// first live request.
199///
200/// # Errors
201///
202/// Returns a human-readable message naming the offending path and the
203/// trigger name(s) involved.
204pub fn validate_webhook_triggers(triggers: &[WebhookTriggerInfo]) -> Result<(), String> {
205    let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
206    for trigger in triggers {
207        if !trigger.path.starts_with('/') || trigger.path == "/" {
208            return Err(format!(
209                "webhook trigger '{}' has invalid path '{}': path must start with '/' and must \
210                 not be the root path",
211                trigger.name, trigger.path
212            ));
213        }
214        if let Some(first_name) = seen.insert(trigger.path, trigger.name) {
215            return Err(format!(
216                "duplicate webhook binding path '{}': triggers '{first_name}' and '{}' would \
217                 shadow each other -- each #[webhook] path must be unique",
218                trigger.path, trigger.name
219            ));
220        }
221    }
222    Ok(())
223}