Skip to main content

ryu_webhook_ingress/
dispatch.rs

1//! Path-aware inbound webhook dispatch — the "route ANY inbound path" seam
2//! (webhook-unify).
3//!
4//! # Why this exists
5//!
6//! Before this module the managed [`super::RyuRelaySource`] fanned out **only**
7//! `composio.webhook` frames and dispatched them straight to the composio
8//! triggers store. A per-workflow webhook trigger
9//! (`POST /api/workflows/<id>/webhook`) was therefore *unreachable* on a default
10//! laptop node: the tunnel/relay knew a single hardcoded path
11//! ([`super::WEBHOOK_PATH`]) and no other. The tunnel backends
12//! (Cloudflared/Funnel/OwnRelay) forward every path to Core's real router, so
13//! they already worked — the gap was RyuRelay's in-process dispatch.
14//!
15//! This module closes it by making dispatch **path-routed**: given
16//! `(path, raw_body, signature)` it delivers to the correct handler — the composio
17//! webhook receiver, a per-workflow webhook trigger, or (future) a registered
18//! channel — instead of composio-only. The concrete handlers are kernel; they are
19//! reached through [`super::WebhookIngressHost`].
20//!
21//! # Auth is re-verified here (the correctness crux)
22//!
23//! A per-workflow webhook's HMAC secret lives **only** in Core (the workflow's
24//! `Webhook` trigger); `apps/server` cannot know it. So a workflow frame that
25//! arrives over the relay MUST be re-verified here with the *same*
26//! `verify_workflow_webhook_signature` the HTTP handler uses (both go through the
27//! host) — dispatching it unverified would be an auth bypass that fires real side
28//! effects. Composio, by contrast, is a trust-relay: the server verifies the
29//! global secret before fan-out, so the legacy `composio.webhook` frame path stays
30//! as-is (see [`super::ryu_relay`]). The fail-closed ladder below stays in this
31//! crate; the host only performs the leaf secret lookup + crypto + run.
32//!
33//! Placement (CLAUDE.md §1): choosing which handler runs for an inbound event is
34//! *what runs* → Core. No policy here; the outbound governance the sibling
35//! `channel_send` node performs is the Gateway's job and lives there.
36
37use std::collections::BTreeMap;
38use std::sync::{Mutex, OnceLock, RwLock};
39
40use serde_json::Value;
41
42use super::host::{host, WorkflowWebhookSecret};
43use super::ryu_relay::SeenDeliveries;
44use super::WEBHOOK_PATH;
45
46/// The URL-path prefix/suffix bracketing a per-workflow webhook trigger route
47/// (`/api/workflows/<id>/webhook`). Kept in lockstep with the axum route in
48/// `server/mod.rs` so [`workflow_webhook_path`] and [`parse_workflow_webhook_path`]
49/// round-trip.
50const WORKFLOW_WEBHOOK_PREFIX: &str = "/api/workflows/";
51const WORKFLOW_WEBHOOK_SUFFIX: &str = "/webhook";
52
53/// Replay-staleness window for inbound webhooks that carry a timestamp header
54/// (seconds). A delivery whose declared timestamp is more than this far from
55/// "now" (either direction — clock skew or a replayed capture) is rejected.
56const REPLAY_WINDOW_SECS: i64 = 300;
57
58/// Build the canonical per-workflow webhook path for `id`. The registry
59/// (`GET /api/webhooks`) and the delivery recorder use this so the stored
60/// last-delivery key and the advertised URL never drift.
61pub fn workflow_webhook_path(id: &str) -> String {
62    format!("{WORKFLOW_WEBHOOK_PREFIX}{id}{WORKFLOW_WEBHOOK_SUFFIX}")
63}
64
65/// Parse a `/api/workflows/<id>/webhook` path back to its `<id>`, or `None` when
66/// the path is not a workflow-webhook route. Rejects an empty id and a nested
67/// path (an `id` may not itself contain a `/`).
68fn parse_workflow_webhook_path(path: &str) -> Option<String> {
69    let inner = path
70        .strip_prefix(WORKFLOW_WEBHOOK_PREFIX)?
71        .strip_suffix(WORKFLOW_WEBHOOK_SUFFIX)?;
72    if inner.is_empty() || inner.contains('/') {
73        return None;
74    }
75    Some(inner.to_owned())
76}
77
78// ── Last-delivery tracking (the registry's per-endpoint metadata) ─────────────
79
80/// Process-global map of `webhook path → last-delivery unix seconds`. Populated
81/// on every successful dispatch (relay *and* direct-HTTP), read by the
82/// `GET /api/webhooks` registry so each endpoint can show when it last fired.
83static LAST_DELIVERY: RwLock<BTreeMap<String, i64>> = RwLock::new(BTreeMap::new());
84
85fn now_unix() -> i64 {
86    std::time::SystemTime::now()
87        .duration_since(std::time::UNIX_EPOCH)
88        .map(|d| d.as_secs() as i64)
89        .unwrap_or(0)
90}
91
92/// Record that `path` just received (and accepted) a delivery, stamping "now".
93/// Called from both the relay dispatcher and the direct HTTP handlers so the
94/// registry reflects every source.
95pub fn record_delivery(path: &str) {
96    if let Ok(mut guard) = LAST_DELIVERY.write() {
97        guard.insert(path.to_owned(), now_unix());
98    }
99}
100
101/// The unix-seconds timestamp of the last accepted delivery for `path`, if any.
102pub fn last_delivery(path: &str) -> Option<i64> {
103    LAST_DELIVERY.read().ok().and_then(|g| g.get(path).copied())
104}
105
106// ── Direct-HTTP delivery dedup (relay parity) ─────────────────────────────────
107
108/// Process-global dedup set for DIRECT-HTTP deliveries. The relay transport
109/// keeps its own per-subscription set (ryu_relay.rs); this one covers the
110/// public HTTP handlers, which face the same at-least-once retry semantics.
111/// Returns true when `id` is new (dispatch) — false when already seen (skip).
112/// An empty id is always "new": deliveries without a delivery-id header are
113/// not dedupable and pass through unchanged.
114pub fn first_http_delivery(id: &str) -> bool {
115    static SEEN: OnceLock<Mutex<SeenDeliveries>> = OnceLock::new();
116    let lock = SEEN.get_or_init(|| Mutex::new(SeenDeliveries::default()));
117    let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner());
118    guard.insert(id)
119}
120
121// ── Replay window (acceptance #5) ─────────────────────────────────────────────
122
123/// Whether an inbound delivery is fresh enough to accept, given the value of a
124/// timestamp header (e.g. Svix/Composio `webhook-timestamp`) if present.
125///
126/// **Back-compat, low-risk posture**: a delivery with *no* timestamp header, or
127/// an unparseable one, is treated as fresh (returns `true`) — many callers
128/// (including Core's own tests and simple integrations) do not sign a timestamp,
129/// and rejecting them would break existing flows. Only a *present, parseable*
130/// timestamp that is outside [`REPLAY_WINDOW_SECS`] of `now_unix` is rejected.
131/// This adds replay protection for callers that opt in without a hard cutover.
132pub fn timestamp_fresh(ts_header: Option<&str>, now: i64) -> bool {
133    let Some(raw) = ts_header.map(str::trim).filter(|s| !s.is_empty()) else {
134        return true;
135    };
136    // Accept a bare unix-seconds value or an `t=<secs>` (Stripe-style) token.
137    let parsed = raw
138        .split(',')
139        .find_map(|tok| tok.trim().strip_prefix("t=").or(Some(tok.trim())))
140        .and_then(|v| v.parse::<i64>().ok());
141    match parsed {
142        Some(ts) => (now - ts).abs() <= REPLAY_WINDOW_SECS,
143        None => true,
144    }
145}
146
147// ── Shared per-workflow webhook delivery (reused by HTTP + relay) ─────────────
148
149/// The outcome of delivering a per-workflow webhook. Rich enough that both the
150/// axum `workflow_webhook` handler (→ HTTP status) and the relay dispatcher
151/// (→ log line) map from the *same* decision, so their auth can never drift.
152#[derive(Debug)]
153pub enum WorkflowWebhookOutcome {
154    /// The signature verified and the workflow run started; carries its run id.
155    Ran(String),
156    /// No workflow with this id exists.
157    NotFound,
158    /// The workflow exists but declares no `Webhook` trigger.
159    NoWebhookTrigger,
160    /// The webhook trigger exists but has no (non-empty) secret configured —
161    /// fail-closed: an unauthenticated public trigger is a forgery vector.
162    NoSecret,
163    /// The signature was missing or did not match.
164    BadSignature,
165    /// The body was not valid UTF-8 / JSON. Carries a human-readable reason.
166    BadBody(String),
167    /// The signature verified but starting the run failed. Carries the error.
168    RunError(String),
169    /// The signature verified but `delivery_id` was already seen (at-least-once
170    /// retry) — the run already fired on the first delivery, so this one is a
171    /// no-op. Relay parity: mirrors `ryu_relay.rs`'s `SeenDeliveries` dedup for
172    /// the direct-HTTP path.
173    Duplicate,
174}
175
176/// Verify and (on success) fire a per-workflow webhook trigger. This is the
177/// single source of truth for the workflow-webhook auth + run path — the HTTP
178/// handler and the relay dispatcher both call it, guaranteeing identical
179/// fail-closed semantics.
180///
181/// `delivery_id` is checked against the process-global HTTP seen-set
182/// ([`first_http_delivery`]) immediately AFTER the signature verifies (never
183/// before — an unauthenticated caller must not be able to poison the seen-set
184/// with a forged id and suppress a later legitimate delivery) and BEFORE the
185/// run fires. An empty `delivery_id` (no id header, or a caller — such as the
186/// relay — that already deduped upstream) is always treated as new, so passing
187/// `""` is a safe no-op.
188///
189/// On success it records the delivery against [`workflow_webhook_path`] so the
190/// registry reflects relay-delivered firings too.
191pub async fn deliver_workflow_webhook(
192    id: &str,
193    raw_body: &[u8],
194    signature: Option<&str>,
195    delivery_id: &str,
196) -> WorkflowWebhookOutcome {
197    let Ok(host) = host() else {
198        // No host installed → treat as unresolvable (fail-closed, never fires).
199        return WorkflowWebhookOutcome::NotFound;
200    };
201    // The host does the raw lookup; the empty-secret → NoSecret decision stays
202    // here (the crate owns the fail-closed ladder, not the host).
203    let secret = match host.workflow_webhook_secret(id) {
204        WorkflowWebhookSecret::NotFound => return WorkflowWebhookOutcome::NotFound,
205        WorkflowWebhookSecret::NoTrigger => return WorkflowWebhookOutcome::NoWebhookTrigger,
206        WorkflowWebhookSecret::Secret(s) => match s.filter(|s| !s.trim().is_empty()) {
207            Some(secret) => secret,
208            None => return WorkflowWebhookOutcome::NoSecret,
209        },
210    };
211    if !host.verify_workflow_webhook_signature(&secret, raw_body, signature) {
212        return WorkflowWebhookOutcome::BadSignature;
213    }
214    // Dedup AFTER auth so an unauthenticated caller cannot poison the seen-set
215    // with a forged id and suppress a legitimate delivery.
216    if !first_http_delivery(delivery_id) {
217        return WorkflowWebhookOutcome::Duplicate;
218    }
219    // The raw JSON body becomes the run's trigger payload; validate it parses so
220    // a malformed body fails fast rather than seeding unusable trigger state.
221    let Ok(body_str) = std::str::from_utf8(raw_body) else {
222        return WorkflowWebhookOutcome::BadBody("body is not valid UTF-8".to_owned());
223    };
224    if serde_json::from_str::<Value>(body_str).is_err() {
225        return WorkflowWebhookOutcome::BadBody("body must be valid JSON".to_owned());
226    }
227    match host.run_workflow_for_trigger(id, body_str).await {
228        Ok(run_id) => {
229            record_delivery(&workflow_webhook_path(id));
230            WorkflowWebhookOutcome::Ran(run_id)
231        }
232        Err(e) => WorkflowWebhookOutcome::RunError(e.to_string()),
233    }
234}
235
236// ── The path router (any inbound path) ────────────────────────────────────────
237
238/// The outcome of routing one inbound webhook by path.
239#[derive(Debug)]
240pub enum InboundOutcome {
241    /// Delivered to a handler; `detail` is a short human summary for logs.
242    Delivered { detail: String },
243    /// Recognised the path but refused the delivery (bad signature, no secret,
244    /// store unavailable, …). Carries the reason.
245    Rejected(String),
246    /// No handler is registered for this path.
247    Unhandled,
248}
249
250/// Route an inbound webhook to the correct in-process handler by `path`.
251///
252/// This is the unified replacement for the composio-only relay dispatch: it
253/// matches the composio path and every per-workflow webhook path (and is the one
254/// place a future registered-channel path would gain an arm). It re-verifies the
255/// signature for the workflow arm (the secret lives only in Core) and for the
256/// composio arm (defense-in-depth even though the relay server also verifies).
257///
258/// `signature` is the pre-extracted signature-header value (the relay frame /
259/// HTTP handler picks the right header spelling). `raw_body` is the exact bytes
260/// the signature was computed over.
261pub async fn deliver_inbound(
262    path: &str,
263    raw_body: &[u8],
264    signature: Option<&str>,
265) -> InboundOutcome {
266    if path == WEBHOOK_PATH {
267        let Ok(host) = host() else {
268            return InboundOutcome::Rejected("webhook-ingress host unavailable".to_owned());
269        };
270        // Composio: verify the global secret, then hand to the composio store.
271        if !host.verify_webhook_signature(raw_body, signature) {
272            return InboundOutcome::Rejected(
273                "composio webhook: invalid or missing signature".to_owned(),
274            );
275        }
276        let Ok(payload) = serde_json::from_slice::<Value>(raw_body) else {
277            return InboundOutcome::Rejected("composio webhook: invalid JSON body".to_owned());
278        };
279        return match host.composio_handle_webhook(&payload).await {
280            Some(fired) => {
281                record_delivery(path);
282                InboundOutcome::Delivered {
283                    detail: format!("composio webhook fired {fired} run(s)"),
284                }
285            }
286            None => InboundOutcome::Rejected("composio triggers store unavailable".to_owned()),
287        };
288    }
289
290    if let Some(id) = parse_workflow_webhook_path(path) {
291        // "" for `delivery_id`: every `deliver_inbound` caller (the relay's
292        // `Inbound` arm, and Core's real-wiring test) already deduped by the
293        // frame/delivery id upstream — see `ryu_relay.rs`'s `dispatch_frame` —
294        // so a second dedup here would be redundant, and "" keeps it a no-op.
295        return match deliver_workflow_webhook(&id, raw_body, signature, "").await {
296            WorkflowWebhookOutcome::Ran(run_id) => InboundOutcome::Delivered {
297                detail: format!("workflow '{id}' run {run_id}"),
298            },
299            WorkflowWebhookOutcome::NotFound => {
300                InboundOutcome::Rejected(format!("workflow '{id}' not found"))
301            }
302            WorkflowWebhookOutcome::NoWebhookTrigger => {
303                InboundOutcome::Rejected(format!("workflow '{id}' has no webhook trigger"))
304            }
305            WorkflowWebhookOutcome::NoSecret => InboundOutcome::Rejected(format!(
306                "workflow '{id}' webhook has no secret configured"
307            )),
308            WorkflowWebhookOutcome::BadSignature => {
309                InboundOutcome::Rejected(format!("workflow '{id}': invalid or missing signature"))
310            }
311            WorkflowWebhookOutcome::BadBody(e) => {
312                InboundOutcome::Rejected(format!("workflow '{id}': {e}"))
313            }
314            WorkflowWebhookOutcome::RunError(e) => {
315                InboundOutcome::Rejected(format!("workflow '{id}' run failed: {e}"))
316            }
317            // "" is always treated as new by `first_http_delivery`, so this arm
318            // is unreachable from this call site — kept exhaustive for the enum.
319            WorkflowWebhookOutcome::Duplicate => {
320                InboundOutcome::Rejected(format!("workflow '{id}': duplicate delivery"))
321            }
322        };
323    }
324
325    InboundOutcome::Unhandled
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::host::{set_global_host, WebhookIngressHost};
332    use std::sync::Arc;
333
334    /// A single deterministic mock host installed for the crate test process
335    /// (`set_global_host` is a `OnceLock` → one host per process, so behaviour is
336    /// keyed purely on inputs). Drives the router's branches without any kernel:
337    /// - `workflow_webhook_secret`: keyed on an `-notfound` / `-notrigger` /
338    ///   `-nosecret` marker in the id, else a real secret.
339    /// - `verify_workflow_webhook_signature`: true iff the signature is `"good"`.
340    /// - `run_workflow_for_trigger`: succeeds with a synthetic run id.
341    struct MockHost;
342
343    #[async_trait::async_trait]
344    impl WebhookIngressHost for MockHost {
345        fn composio_is_configured(&self) -> bool {
346            true
347        }
348        fn has_webhook_trigger(&self) -> bool {
349            false
350        }
351        fn verify_webhook_signature(&self, _raw_body: &[u8], signature: Option<&str>) -> bool {
352            signature == Some("good")
353        }
354        fn verify_workflow_webhook_signature(
355            &self,
356            _secret: &str,
357            _raw_body: &[u8],
358            signature: Option<&str>,
359        ) -> bool {
360            signature == Some("good")
361        }
362        async fn composio_handle_webhook(&self, _payload: &Value) -> Option<usize> {
363            Some(1)
364        }
365        async fn run_workflow_for_trigger(
366            &self,
367            workflow_id: &str,
368            _payload_json: &str,
369        ) -> anyhow::Result<String> {
370            Ok(format!("trigrun_{workflow_id}"))
371        }
372        fn workflow_webhook_secret(&self, workflow_id: &str) -> WorkflowWebhookSecret {
373            if workflow_id.contains("-notfound") {
374                WorkflowWebhookSecret::NotFound
375            } else if workflow_id.contains("-notrigger") {
376                WorkflowWebhookSecret::NoTrigger
377            } else if workflow_id.contains("-nosecret") {
378                WorkflowWebhookSecret::Secret(None)
379            } else {
380                WorkflowWebhookSecret::Secret(Some("s3cr3t".to_owned()))
381            }
382        }
383        fn auth_token(&self) -> Option<String> {
384            None
385        }
386        fn data_dir(&self) -> std::path::PathBuf {
387            std::env::temp_dir()
388        }
389        async fn ensure_funnel(&self, _port: u16) -> anyhow::Result<String> {
390            anyhow::bail!("mock: no funnel")
391        }
392        async fn funnel_url(&self, _port: u16) -> Option<String> {
393            None
394        }
395    }
396
397    /// Install the shared mock host (idempotent). Any host-driven test calls this
398    /// first, so install order across the parallel test threads is irrelevant.
399    fn ensure_mock_host() {
400        set_global_host(Arc::new(MockHost));
401    }
402
403    #[test]
404    fn workflow_path_round_trips() {
405        let p = workflow_webhook_path("wf-123");
406        assert_eq!(p, "/api/workflows/wf-123/webhook");
407        assert_eq!(parse_workflow_webhook_path(&p).as_deref(), Some("wf-123"));
408    }
409
410    #[test]
411    fn parse_rejects_non_workflow_and_nested_paths() {
412        assert_eq!(parse_workflow_webhook_path("/api/composio/webhook"), None);
413        assert_eq!(parse_workflow_webhook_path("/api/workflows//webhook"), None);
414        assert_eq!(
415            parse_workflow_webhook_path("/api/workflows/a/b/webhook"),
416            None
417        );
418        assert_eq!(parse_workflow_webhook_path("/nope"), None);
419    }
420
421    #[test]
422    fn timestamp_fresh_accepts_absent_and_recent_rejects_stale() {
423        let now = 1_000_000i64;
424        // Absent / unparseable → fresh (back-compat).
425        assert!(timestamp_fresh(None, now));
426        assert!(timestamp_fresh(Some("   "), now));
427        assert!(timestamp_fresh(Some("not-a-number"), now));
428        // Within the window → fresh; the `t=` form is accepted too.
429        assert!(timestamp_fresh(Some("1000000"), now));
430        assert!(timestamp_fresh(Some(&format!("{}", now - 299)), now));
431        assert!(timestamp_fresh(Some("t=1000000"), now));
432        // Outside the window (either direction) → stale.
433        assert!(!timestamp_fresh(Some(&format!("{}", now - 301)), now));
434        assert!(!timestamp_fresh(Some(&format!("{}", now + 301)), now));
435    }
436
437    #[tokio::test]
438    async fn last_delivery_round_trips() {
439        let path = format!(
440            "/api/workflows/ld-{}/webhook",
441            uuid::Uuid::new_v4().simple()
442        );
443        assert!(last_delivery(&path).is_none());
444        record_delivery(&path);
445        assert!(last_delivery(&path).is_some());
446    }
447
448    #[tokio::test]
449    async fn unknown_path_is_unhandled() {
450        let outcome = deliver_inbound("/api/does/not/exist", b"{}", None).await;
451        assert!(matches!(outcome, InboundOutcome::Unhandled));
452    }
453
454    #[tokio::test]
455    async fn workflow_path_with_bad_signature_is_rejected_not_composio() {
456        // A workflow path routes to the WORKFLOW arm, not composio: a bad/missing
457        // signature yields a workflow-scoped rejection (or NotFound), never the
458        // composio "invalid signature" message. This proves the router split.
459        ensure_mock_host();
460        let id = format!("wf-{}", uuid::Uuid::new_v4().simple());
461        let path = workflow_webhook_path(&id);
462        let outcome = deliver_inbound(&path, b"{}", Some("deadbeef")).await;
463        match outcome {
464            InboundOutcome::Rejected(msg) => {
465                assert!(
466                    msg.contains(&id),
467                    "expected a workflow-scoped rejection, got: {msg}"
468                );
469                assert!(
470                    !msg.contains("composio"),
471                    "workflow path must not route to composio: {msg}"
472                );
473            }
474            other => panic!("expected Rejected, got {other:?}"),
475        }
476    }
477
478    /// The router acceptance test (crate-side, mock host): a **workflow** webhook
479    /// delivered through the unified ingress (`deliver_inbound`, the same entry the
480    /// relay uses) routes to the WORKFLOW arm, re-verifies the trigger secret via
481    /// the host, and on success reaches `run_workflow_for_trigger` — producing a
482    /// run id and recording the delivery. A tampered/bad signature is rejected
483    /// fail-closed. The *real-wiring* variant (real `save_workflow` + run against
484    /// `CoreWebhookIngressHost`) lives in core (`apps/core/src/webhook_ingress.rs`).
485    #[tokio::test]
486    async fn workflow_webhook_reaches_run_through_unified_ingress() {
487        ensure_mock_host();
488
489        let id = format!("wf-unify-{}", uuid::Uuid::new_v4().simple());
490        let body = br#"{"event":"unify","value":42}"#;
491        let path = workflow_webhook_path(&id);
492
493        // A valid signature ("good" per the mock) reaches the run.
494        let outcome = deliver_inbound(&path, body, Some("good")).await;
495        match &outcome {
496            InboundOutcome::Delivered { detail } => {
497                assert!(
498                    detail.contains(&id) && detail.contains("run"),
499                    "expected a workflow run delivery, got: {detail}"
500                );
501            }
502            other => panic!("expected Delivered (reaching the workflow run), got {other:?}"),
503        }
504        // And it is recorded for the registry.
505        assert!(
506            last_delivery(&path).is_some(),
507            "delivery should be recorded for the registry"
508        );
509
510        // A bad signature is rejected fail-closed (never fires the run).
511        let rejected = deliver_inbound(&path, br#"{"event":"tampered"}"#, Some("bad")).await;
512        assert!(matches!(rejected, InboundOutcome::Rejected(_)));
513    }
514
515    // ── first_http_delivery (Plan 013) ─────────────────────────────────────────
516    //
517    // `first_http_delivery` backs a process-global `OnceLock<Mutex<SeenDeliveries>>`
518    // shared by every test in this (and any other) process. cargo runs tests as
519    // parallel threads in one process, so ids are prefixed per-test to avoid
520    // cross-test interference — mirrors `last_delivery_round_trips`'s uuid-suffix
521    // pattern above.
522
523    #[test]
524    fn first_http_delivery_dedups_repeats() {
525        let id = format!("http-dlv-{}", uuid::Uuid::new_v4().simple());
526        assert!(first_http_delivery(&id), "first sight is new");
527        assert!(!first_http_delivery(&id), "second sight is a duplicate");
528    }
529
530    #[test]
531    fn first_http_delivery_empty_id_always_new() {
532        // No id to dedup on → never suppress dispatch (matches SeenDeliveries).
533        assert!(first_http_delivery(""));
534        assert!(first_http_delivery(""));
535    }
536
537    #[test]
538    fn first_http_delivery_distinct_ids_do_not_interfere() {
539        let a = format!("http-dlv-a-{}", uuid::Uuid::new_v4().simple());
540        let b = format!("http-dlv-b-{}", uuid::Uuid::new_v4().simple());
541        assert!(first_http_delivery(&a));
542        assert!(
543            first_http_delivery(&b),
544            "a different id is unaffected by a's insert"
545        );
546        assert!(!first_http_delivery(&a), "a is still deduped");
547        assert!(!first_http_delivery(&b), "b is still deduped");
548    }
549
550    /// `deliver_workflow_webhook` acceptance (Plan 013): a valid signature with a
551    /// repeated `delivery_id` yields `Ran` on the first call and `Duplicate` on
552    /// the second — the dedup sits after auth (a bad-signature call never
553    /// consumes the seen-set, proven by the second assertion below) and before
554    /// the run.
555    #[tokio::test]
556    async fn deliver_workflow_webhook_dedups_by_delivery_id() {
557        ensure_mock_host();
558
559        let id = format!("wf-dedup-{}", uuid::Uuid::new_v4().simple());
560        let delivery_id = format!("dlv-{}", uuid::Uuid::new_v4().simple());
561        let body = br#"{"event":"first"}"#;
562
563        // First delivery: valid signature, fresh id → runs.
564        let first = deliver_workflow_webhook(&id, body, Some("good"), &delivery_id).await;
565        assert!(matches!(first, WorkflowWebhookOutcome::Ran(_)));
566
567        // Retried delivery: same id → duplicate, no second run.
568        let retried = deliver_workflow_webhook(&id, body, Some("good"), &delivery_id).await;
569        assert!(matches!(retried, WorkflowWebhookOutcome::Duplicate));
570
571        // An unauthenticated forged id never reaches (and so never poisons) the
572        // seen-set: a fresh id with a bad signature is rejected, not deduped —
573        // and that same id is still usable afterwards for a real delivery.
574        let forged_id = format!("dlv-forged-{}", uuid::Uuid::new_v4().simple());
575        let bad = deliver_workflow_webhook(&id, body, Some("bad"), &forged_id).await;
576        assert!(matches!(bad, WorkflowWebhookOutcome::BadSignature));
577        let now_valid = deliver_workflow_webhook(&id, body, Some("good"), &forged_id).await;
578        assert!(
579            matches!(now_valid, WorkflowWebhookOutcome::Ran(_)),
580            "a forged-signature attempt must not poison the seen-set for the real id"
581        );
582    }
583}