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}