Skip to main content

harn_vm/triggers/
webhook_intake.rs

1//! Forge-agnostic webhook intake substrate.
2//!
3//! This is the lowest-level layer connectors can wire into to absorb webhook
4//! deliveries. It is deliberately ignorant of any specific provider (GitHub,
5//! GitLab, Linear, Slack, Stripe, ...). A connector declares:
6//!
7//! * a path scope (e.g. `/hooks/github`)
8//! * a signature header + algorithm + format the substrate should verify
9//! * a delivery-id header the substrate should dedupe on
10//! * a topic to republish accepted deliveries onto
11//!
12//! and the substrate handles the rest: HMAC verification, delivery-id
13//! deduplication (durable across process restarts via the trigger inbox), and
14//! republishing to the chosen event-log topic. Per-forge event normalization
15//! lives in the connector that consumes the topic.
16//!
17//! The substrate is process-global, mirroring the rest of the trigger runtime.
18//! It is exposed to Harn scripts through the `webhook_intake_*` builtins.
19//
20// Design notes:
21// - A connector should be able to wire intake in <30 lines of Harn (issue
22//   #1011). The Harn-facing surface is therefore a single `register` call that
23//   takes a config dict, plus `feed` for hand-driving the substrate from a
24//   handler or a test fixture.
25// - Dedupe survives process restart because we delegate to the existing
26//   `InboxIndex`, which rehydrates from the durable claim topic on construction.
27// - Multiple connectors on different paths must not collide — each intake gets
28//   its own intake id, and the inbox dedupe is namespaced by intake id.
29
30use std::cell::RefCell;
31use std::collections::{BTreeMap, HashMap};
32use std::sync::Arc;
33use std::time::Duration as StdDuration;
34
35use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
36use base64::Engine;
37use serde::{Deserialize, Serialize};
38use subtle::ConstantTimeEq;
39use time::OffsetDateTime;
40use uuid::Uuid;
41
42use crate::connectors::MetricsRegistry;
43use crate::event_log::{
44    active_event_log, install_memory_for_current_thread, EventLog, LogEvent, Topic,
45};
46use crate::runtime_limits::RuntimeLimits;
47use crate::triggers::inbox::InboxIndex;
48
49/// Default delivery-id retention used when callers omit the field. Matches the
50/// 24-hour window most providers retry within.
51pub const DEFAULT_DEDUPE_TTL_SECS: u64 = 24 * 60 * 60;
52/// Topic the substrate appends `webhook_intake_rejected` audit events on.
53pub const REJECTION_TOPIC: &str = "triggers.webhook_intake.rejections";
54const INTAKE_EVENT_LOG_QUEUE_DEPTH: usize = RuntimeLimits::DEFAULT.default_event_log_queue_depth;
55
56/// Identifier of a registered intake. Stable for the lifetime of the process,
57/// or until `webhook_intake_deregister` is called.
58#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
59pub struct WebhookIntakeId(pub String);
60
61impl WebhookIntakeId {
62    pub fn new() -> Self {
63        Self(format!("intake_{}", Uuid::now_v7()))
64    }
65
66    pub fn as_str(&self) -> &str {
67        &self.0
68    }
69}
70
71impl Default for WebhookIntakeId {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77/// Hash algorithm used to recompute the HMAC over the request body.
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum HmacAlgorithm {
81    #[default]
82    Sha256,
83    Sha1,
84}
85
86impl HmacAlgorithm {
87    pub fn parse(raw: &str) -> Option<Self> {
88        Self::parse_with_legacy_sha1(raw, false)
89    }
90
91    pub fn parse_with_legacy_sha1(raw: &str, allow_legacy_sha1: bool) -> Option<Self> {
92        match raw.trim().to_ascii_lowercase().as_str() {
93            "" | "sha256" | "hmac-sha256" => Some(Self::Sha256),
94            "sha1" | "hmac-sha1" if allow_legacy_sha1 => Some(Self::Sha1),
95            _ => None,
96        }
97    }
98
99    pub fn is_legacy_sha1_alias(raw: &str) -> bool {
100        matches!(
101            raw.trim().to_ascii_lowercase().as_str(),
102            "sha1" | "hmac-sha1"
103        )
104    }
105
106    pub fn as_str(&self) -> &'static str {
107        match self {
108            Self::Sha256 => "sha256",
109            Self::Sha1 => "sha1",
110        }
111    }
112}
113
114/// Encoding the wire signature is presented in.
115#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117pub enum SignatureEncoding {
118    #[default]
119    Hex,
120    Base64,
121}
122
123impl SignatureEncoding {
124    pub fn parse(raw: &str) -> Option<Self> {
125        match raw.trim().to_ascii_lowercase().as_str() {
126            "" | "hex" | "base16" => Some(Self::Hex),
127            "base64" | "b64" => Some(Self::Base64),
128            _ => None,
129        }
130    }
131
132    pub fn as_str(&self) -> &'static str {
133        match self {
134            Self::Hex => "hex",
135            Self::Base64 => "base64",
136        }
137    }
138}
139
140/// Configuration for a single registered intake.
141#[derive(Clone, Debug)]
142pub struct WebhookIntakeConfig {
143    pub id: Option<String>,
144    pub path: Option<String>,
145    pub signature_header: String,
146    pub signature_prefix: Option<String>,
147    pub signature_encoding: SignatureEncoding,
148    pub algorithm: HmacAlgorithm,
149    pub allow_legacy_sha1: bool,
150    pub secret: Vec<u8>,
151    pub delivery_id_header: String,
152    pub topic: String,
153    pub dedupe_ttl: StdDuration,
154}
155
156impl WebhookIntakeConfig {
157    /// Common forges (GitHub, GitLab, Bitbucket) prefix the digest with the
158    /// algorithm name. Helper for callers that want the same default the
159    /// stdlib parser applies when `signature_prefix` is omitted.
160    pub fn default_prefix(algorithm: HmacAlgorithm) -> Option<String> {
161        Some(format!("{}=", algorithm.as_str()))
162    }
163}
164
165/// What the substrate did with a delivery.
166#[derive(Clone, Debug, PartialEq, Eq)]
167pub enum WebhookIntakeStatus {
168    Accepted,
169    Duplicate,
170    Rejected,
171}
172
173impl WebhookIntakeStatus {
174    pub fn as_str(&self) -> &'static str {
175        match self {
176            Self::Accepted => "accepted",
177            Self::Duplicate => "duplicate",
178            Self::Rejected => "rejected",
179        }
180    }
181}
182
183/// Result of feeding a delivery through the substrate.
184#[derive(Clone, Debug, Serialize, Deserialize)]
185pub struct WebhookIntakeOutcome {
186    pub status: String,
187    pub intake_id: String,
188    pub topic: String,
189    pub delivery_id: Option<String>,
190    pub topic_event_id: Option<u64>,
191    pub reason: Option<String>,
192    pub received_at: String,
193}
194
195/// Snapshot of an intake registration. Returned by `register` and `list`.
196#[derive(Clone, Debug, Serialize, Deserialize)]
197pub struct WebhookIntakeSnapshot {
198    pub id: String,
199    pub path: Option<String>,
200    pub topic: String,
201    pub signature_header: String,
202    pub signature_prefix: Option<String>,
203    pub signature_encoding: String,
204    pub algorithm: String,
205    pub allow_legacy_sha1: bool,
206    pub delivery_id_header: String,
207    pub dedupe_ttl_seconds: u64,
208}
209
210/// Raw inbound delivery as fed to the substrate.
211#[derive(Clone, Debug, Default)]
212pub struct WebhookIntakeRequest {
213    pub headers: BTreeMap<String, String>,
214    pub body: Vec<u8>,
215    pub path: Option<String>,
216    pub received_at: Option<OffsetDateTime>,
217}
218
219#[derive(Debug)]
220pub enum WebhookIntakeError {
221    Config(String),
222    UnknownIntake(String),
223    Internal(String),
224}
225
226impl std::fmt::Display for WebhookIntakeError {
227    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228        match self {
229            Self::Config(detail) => write!(f, "intake config: {detail}"),
230            Self::UnknownIntake(id) => write!(f, "intake `{id}` is not registered"),
231            Self::Internal(detail) => write!(f, "intake substrate: {detail}"),
232        }
233    }
234}
235
236impl std::error::Error for WebhookIntakeError {}
237
238#[derive(Clone)]
239struct RegisteredIntake {
240    snapshot: WebhookIntakeSnapshot,
241    config: WebhookIntakeConfig,
242}
243
244#[derive(Default)]
245struct WebhookIntakeRegistry {
246    by_id: HashMap<String, RegisteredIntake>,
247    by_path: HashMap<String, String>,
248}
249
250thread_local! {
251    static REGISTRY: RefCell<WebhookIntakeRegistry> =
252        RefCell::new(WebhookIntakeRegistry::default());
253}
254
255fn with_registry<R>(f: impl FnOnce(&WebhookIntakeRegistry) -> R) -> R {
256    REGISTRY.with(|slot| f(&slot.borrow()))
257}
258
259fn with_registry_mut<R>(f: impl FnOnce(&mut WebhookIntakeRegistry) -> R) -> R {
260    REGISTRY.with(|slot| f(&mut slot.borrow_mut()))
261}
262
263/// Reset all in-process intake state. Called between conformance tests.
264pub fn clear_webhook_intake_state() {
265    with_registry_mut(|state| {
266        state.by_id.clear();
267        state.by_path.clear();
268    });
269    reset_cached_inbox();
270}
271
272/// Snapshot of all currently-registered intakes.
273pub fn snapshot_webhook_intakes() -> Vec<WebhookIntakeSnapshot> {
274    with_registry(|state| {
275        let mut out: Vec<_> = state
276            .by_id
277            .values()
278            .map(|entry| entry.snapshot.clone())
279            .collect();
280        out.sort_by(|left, right| left.id.cmp(&right.id));
281        out
282    })
283}
284
285/// Resolve the intake registered against `path`, if any.
286pub fn intake_for_path(path: &str) -> Option<String> {
287    with_registry(|state| state.by_path.get(path).cloned())
288}
289
290/// Register a new intake. Returns its assigned id.
291pub fn register_webhook_intake(
292    config: WebhookIntakeConfig,
293) -> Result<WebhookIntakeSnapshot, WebhookIntakeError> {
294    if config.signature_header.trim().is_empty() {
295        return Err(WebhookIntakeError::Config(
296            "signature_header cannot be empty".to_string(),
297        ));
298    }
299    if config.delivery_id_header.trim().is_empty() {
300        return Err(WebhookIntakeError::Config(
301            "delivery_id_header cannot be empty".to_string(),
302        ));
303    }
304    if config.topic.trim().is_empty() {
305        return Err(WebhookIntakeError::Config(
306            "topic cannot be empty".to_string(),
307        ));
308    }
309    if config.secret.is_empty() {
310        return Err(WebhookIntakeError::Config(
311            "secret cannot be empty".to_string(),
312        ));
313    }
314    if config.algorithm == HmacAlgorithm::Sha1 && !config.allow_legacy_sha1 {
315        return Err(WebhookIntakeError::Config(
316            "algorithm `sha1` is legacy; set `allow_legacy_sha1: true` to verify SHA-1 HMAC signatures for an existing provider".to_string(),
317        ));
318    }
319    Topic::new(config.topic.clone())
320        .map_err(|error| WebhookIntakeError::Config(format!("invalid topic: {error}")))?;
321
322    let id = config
323        .id
324        .clone()
325        .filter(|raw| !raw.trim().is_empty())
326        .unwrap_or_else(|| WebhookIntakeId::new().0);
327
328    with_registry_mut(|state| {
329        if state.by_id.contains_key(&id) {
330            return Err(WebhookIntakeError::Config(format!(
331                "intake `{id}` is already registered"
332            )));
333        }
334        if let Some(path) = config.path.as_ref() {
335            if state.by_path.contains_key(path) {
336                return Err(WebhookIntakeError::Config(format!(
337                    "path `{path}` is already bound to intake `{}`",
338                    state.by_path[path]
339                )));
340            }
341        }
342
343        let snapshot = WebhookIntakeSnapshot {
344            id: id.clone(),
345            path: config.path.clone(),
346            topic: config.topic.clone(),
347            signature_header: normalize_header(&config.signature_header),
348            signature_prefix: config.signature_prefix.clone(),
349            signature_encoding: config.signature_encoding.as_str().to_string(),
350            algorithm: config.algorithm.as_str().to_string(),
351            allow_legacy_sha1: config.allow_legacy_sha1,
352            delivery_id_header: normalize_header(&config.delivery_id_header),
353            dedupe_ttl_seconds: config.dedupe_ttl.as_secs(),
354        };
355
356        let mut stored = config;
357        stored.id = Some(id.clone());
358        stored.signature_header = normalize_header(&stored.signature_header);
359        stored.delivery_id_header = normalize_header(&stored.delivery_id_header);
360
361        if let Some(path) = stored.path.as_ref() {
362            state.by_path.insert(path.clone(), id.clone());
363        }
364        state.by_id.insert(
365            id,
366            RegisteredIntake {
367                snapshot: snapshot.clone(),
368                config: stored,
369            },
370        );
371        Ok(snapshot)
372    })
373}
374
375/// Remove an intake. Returns `true` if it existed.
376pub fn deregister_webhook_intake(intake_id: &str) -> bool {
377    with_registry_mut(|state| {
378        let Some(entry) = state.by_id.remove(intake_id) else {
379            return false;
380        };
381        if let Some(path) = entry.snapshot.path {
382            state.by_path.remove(&path);
383        }
384        true
385    })
386}
387
388/// Feed a delivery through the substrate.
389///
390/// On `Accepted`, the substrate has appended a `webhook_delivery` event to the
391/// configured topic and claimed the delivery id in the inbox. Subsequent calls
392/// with the same delivery id within the dedupe TTL return `Duplicate`. Any
393/// signature failure (missing/invalid header, bad HMAC) returns `Rejected`.
394pub async fn feed_webhook_intake(
395    intake_id: &str,
396    request: WebhookIntakeRequest,
397) -> Result<WebhookIntakeOutcome, WebhookIntakeError> {
398    let entry = with_registry(|state| state.by_id.get(intake_id).cloned())
399        .ok_or_else(|| WebhookIntakeError::UnknownIntake(intake_id.to_string()))?;
400    let received_at = request.received_at.unwrap_or_else(OffsetDateTime::now_utc);
401    let received_at_str = format_rfc3339(received_at);
402    let log = ensure_event_log();
403    let topic = Topic::new(entry.snapshot.topic.clone())
404        .map_err(|error| WebhookIntakeError::Internal(format!("invalid topic: {error}")))?;
405
406    if let Some(expected_path) = entry.config.path.as_deref() {
407        if let Some(actual_path) = request.path.as_deref() {
408            if actual_path != expected_path {
409                let reason =
410                    format!("path mismatch: expected `{expected_path}`, got `{actual_path}`");
411                emit_rejection(&log, &entry.snapshot, &reason, &received_at_str).await;
412                return Ok(WebhookIntakeOutcome {
413                    status: WebhookIntakeStatus::Rejected.as_str().to_string(),
414                    intake_id: entry.snapshot.id.clone(),
415                    topic: entry.snapshot.topic.clone(),
416                    delivery_id: None,
417                    topic_event_id: None,
418                    reason: Some(reason),
419                    received_at: received_at_str,
420                });
421            }
422        }
423    }
424
425    let signature = match find_header(&request.headers, &entry.config.signature_header) {
426        Some(value) => value,
427        None => {
428            let reason = format!(
429                "missing signature header `{}`",
430                entry.config.signature_header
431            );
432            emit_rejection(&log, &entry.snapshot, &reason, &received_at_str).await;
433            return Ok(WebhookIntakeOutcome {
434                status: WebhookIntakeStatus::Rejected.as_str().to_string(),
435                intake_id: entry.snapshot.id.clone(),
436                topic: entry.snapshot.topic.clone(),
437                delivery_id: None,
438                topic_event_id: None,
439                reason: Some(reason),
440                received_at: received_at_str,
441            });
442        }
443    };
444
445    if let Err(reason) = verify_signature(&entry.config, signature.as_str(), &request.body) {
446        emit_rejection(&log, &entry.snapshot, &reason, &received_at_str).await;
447        return Ok(WebhookIntakeOutcome {
448            status: WebhookIntakeStatus::Rejected.as_str().to_string(),
449            intake_id: entry.snapshot.id.clone(),
450            topic: entry.snapshot.topic.clone(),
451            delivery_id: None,
452            topic_event_id: None,
453            reason: Some(reason),
454            received_at: received_at_str,
455        });
456    }
457
458    let delivery_id = match find_header(&request.headers, &entry.config.delivery_id_header) {
459        Some(value) if !value.trim().is_empty() => value,
460        _ => {
461            let reason = format!(
462                "missing delivery id header `{}`",
463                entry.config.delivery_id_header
464            );
465            emit_rejection(&log, &entry.snapshot, &reason, &received_at_str).await;
466            return Ok(WebhookIntakeOutcome {
467                status: WebhookIntakeStatus::Rejected.as_str().to_string(),
468                intake_id: entry.snapshot.id.clone(),
469                topic: entry.snapshot.topic.clone(),
470                delivery_id: None,
471                topic_event_id: None,
472                reason: Some(reason),
473                received_at: received_at_str,
474            });
475        }
476    };
477
478    let inbox = ensure_inbox().await?;
479    let claim_key = format!("intake:{}", entry.snapshot.id);
480    let claimed = inbox
481        .insert_if_new(&claim_key, &delivery_id, entry.config.dedupe_ttl)
482        .await
483        .map_err(|error| WebhookIntakeError::Internal(format!("inbox claim: {error}")))?;
484    if !claimed {
485        return Ok(WebhookIntakeOutcome {
486            status: WebhookIntakeStatus::Duplicate.as_str().to_string(),
487            intake_id: entry.snapshot.id.clone(),
488            topic: entry.snapshot.topic.clone(),
489            delivery_id: Some(delivery_id),
490            topic_event_id: None,
491            reason: None,
492            received_at: received_at_str,
493        });
494    }
495
496    let payload = serde_json::json!({
497        "intake_id": entry.snapshot.id,
498        "delivery_id": delivery_id,
499        "received_at": received_at_str,
500        "headers": request.headers,
501        "body_b64": BASE64_STANDARD.encode(&request.body),
502        "body_text": std::str::from_utf8(&request.body).ok().map(str::to_string),
503        "path": entry.config.path,
504        "signature_header": entry.config.signature_header,
505        "delivery_id_header": entry.config.delivery_id_header,
506        "algorithm": entry.config.algorithm.as_str(),
507    });
508    let mut headers_meta = BTreeMap::new();
509    headers_meta.insert("intake_id".to_string(), entry.snapshot.id.clone());
510    headers_meta.insert("delivery_id".to_string(), delivery_id.clone());
511    let event_id = log
512        .append(
513            &topic,
514            LogEvent::new("webhook_delivery", payload).with_headers(headers_meta),
515        )
516        .await
517        .map_err(|error| WebhookIntakeError::Internal(format!("topic append: {error}")))?;
518
519    Ok(WebhookIntakeOutcome {
520        status: WebhookIntakeStatus::Accepted.as_str().to_string(),
521        intake_id: entry.snapshot.id.clone(),
522        topic: entry.snapshot.topic.clone(),
523        delivery_id: Some(delivery_id),
524        topic_event_id: Some(event_id),
525        reason: None,
526        received_at: received_at_str,
527    })
528}
529
530/// Read the most recent `limit` accepted deliveries on an intake's topic.
531/// Provides the bounded replay buffer surface called out in #1011.
532pub async fn recent_webhook_deliveries(
533    intake_id: &str,
534    limit: usize,
535) -> Result<Vec<serde_json::Value>, WebhookIntakeError> {
536    let topic_name = with_registry(|state| {
537        state
538            .by_id
539            .get(intake_id)
540            .map(|entry| entry.snapshot.topic.clone())
541    })
542    .ok_or_else(|| WebhookIntakeError::UnknownIntake(intake_id.to_string()))?;
543    let log = ensure_event_log();
544    let topic = Topic::new(topic_name)
545        .map_err(|error| WebhookIntakeError::Internal(format!("invalid topic: {error}")))?;
546    let events = log
547        .read_range(&topic, None, usize::MAX)
548        .await
549        .map_err(|error| WebhookIntakeError::Internal(format!("topic read: {error}")))?;
550    let mut payloads: Vec<serde_json::Value> = events
551        .into_iter()
552        .filter(|(_, event)| {
553            event.kind == "webhook_delivery"
554                && event
555                    .headers
556                    .get("intake_id")
557                    .map(|value| value == intake_id)
558                    .unwrap_or(true)
559        })
560        .map(|(_, event)| event.payload)
561        .collect();
562    if payloads.len() > limit {
563        let drop = payloads.len() - limit;
564        payloads.drain(0..drop);
565    }
566    Ok(payloads)
567}
568
569fn verify_signature(
570    config: &WebhookIntakeConfig,
571    raw_signature: &str,
572    body: &[u8],
573) -> Result<(), String> {
574    let stripped = strip_prefix(raw_signature, config.signature_prefix.as_deref())?;
575    let provided = decode_signature(stripped, config.signature_encoding)?;
576    let expected = compute_hmac(config.algorithm, &config.secret, body);
577    if provided.len() != expected.len() {
578        return Err(format!(
579            "signature length mismatch (expected {} bytes, got {})",
580            expected.len(),
581            provided.len()
582        ));
583    }
584    if expected.ct_eq(&provided).into() {
585        Ok(())
586    } else {
587        Err("signature mismatch".to_string())
588    }
589}
590
591fn strip_prefix<'a>(raw: &'a str, prefix: Option<&str>) -> Result<&'a str, String> {
592    let trimmed = raw.trim();
593    match prefix {
594        Some(expected) if !expected.is_empty() => trimmed
595            .strip_prefix(expected)
596            .ok_or_else(|| format!("signature missing expected prefix `{expected}`")),
597        _ => Ok(trimmed),
598    }
599}
600
601fn decode_signature(raw: &str, encoding: SignatureEncoding) -> Result<Vec<u8>, String> {
602    match encoding {
603        SignatureEncoding::Hex => hex::decode(raw).map_err(|error| format!("invalid hex: {error}")),
604        SignatureEncoding::Base64 => BASE64_STANDARD
605            .decode(raw)
606            .map_err(|error| format!("invalid base64: {error}")),
607    }
608}
609
610fn compute_hmac(algorithm: HmacAlgorithm, secret: &[u8], data: &[u8]) -> Vec<u8> {
611    match algorithm {
612        HmacAlgorithm::Sha256 => crate::connectors::hmac::hmac_sha256(secret, data),
613        HmacAlgorithm::Sha1 => crate::connectors::hmac::hmac_sha1(secret, data),
614    }
615}
616
617fn find_header(headers: &BTreeMap<String, String>, name: &str) -> Option<String> {
618    let lower = name.to_ascii_lowercase();
619    headers
620        .iter()
621        .find(|(key, _)| key.to_ascii_lowercase() == lower)
622        .map(|(_, value)| value.clone())
623}
624
625fn normalize_header(name: &str) -> String {
626    name.trim().to_ascii_lowercase()
627}
628
629fn format_rfc3339(value: OffsetDateTime) -> String {
630    harn_clock::format_rfc3339(value)
631}
632
633fn ensure_event_log() -> Arc<crate::event_log::AnyEventLog> {
634    active_event_log()
635        .unwrap_or_else(|| install_memory_for_current_thread(INTAKE_EVENT_LOG_QUEUE_DEPTH))
636}
637
638thread_local! {
639    static CACHED_INBOX: RefCell<Option<(Arc<crate::event_log::AnyEventLog>, Arc<InboxIndex>)>> =
640        const { RefCell::new(None) };
641}
642
643async fn ensure_inbox() -> Result<Arc<InboxIndex>, WebhookIntakeError> {
644    let log = ensure_event_log();
645    if let Some(existing) = CACHED_INBOX.with(|slot| {
646        slot.borrow()
647            .as_ref()
648            .filter(|(cached_log, _)| Arc::ptr_eq(cached_log, &log))
649            .map(|(_, inbox)| inbox.clone())
650    }) {
651        return Ok(existing);
652    }
653    let metrics = Arc::new(MetricsRegistry::default());
654    let inbox = Arc::new(
655        InboxIndex::new(log.clone(), metrics)
656            .await
657            .map_err(|error| WebhookIntakeError::Internal(format!("inbox init: {error}")))?,
658    );
659    CACHED_INBOX.with(|slot| {
660        *slot.borrow_mut() = Some((log, inbox.clone()));
661    });
662    Ok(inbox)
663}
664
665fn reset_cached_inbox() {
666    CACHED_INBOX.with(|slot| *slot.borrow_mut() = None);
667}
668
669async fn emit_rejection(
670    log: &Arc<crate::event_log::AnyEventLog>,
671    snapshot: &WebhookIntakeSnapshot,
672    reason: &str,
673    received_at: &str,
674) {
675    let topic = match Topic::new(REJECTION_TOPIC) {
676        Ok(topic) => topic,
677        Err(_) => return,
678    };
679    let mut headers = BTreeMap::new();
680    headers.insert("intake_id".to_string(), snapshot.id.clone());
681    let payload = serde_json::json!({
682        "intake_id": snapshot.id,
683        "topic": snapshot.topic,
684        "path": snapshot.path,
685        "reason": reason,
686        "received_at": received_at,
687    });
688    let _ = log
689        .append(
690            &topic,
691            LogEvent::new("webhook_intake_rejected", payload).with_headers(headers),
692        )
693        .await;
694}
695
696/// Helper: build a `WebhookIntakeRequest` from a `headers` dict, body, and
697/// optional fields. Used by the stdlib bindings to keep the parsing logic out
698/// of the substrate proper.
699pub fn build_request(
700    headers: BTreeMap<String, String>,
701    body: Vec<u8>,
702    path: Option<String>,
703    received_at: Option<OffsetDateTime>,
704) -> WebhookIntakeRequest {
705    WebhookIntakeRequest {
706        headers,
707        body,
708        path,
709        received_at,
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716    use crate::event_log::{install_default_for_base_dir, AnyEventLog, FileEventLog};
717
718    fn make_config(secret: &[u8]) -> WebhookIntakeConfig {
719        WebhookIntakeConfig {
720            id: None,
721            path: None,
722            signature_header: "x-test-signature".to_string(),
723            signature_prefix: Some("sha256=".to_string()),
724            signature_encoding: SignatureEncoding::Hex,
725            algorithm: HmacAlgorithm::Sha256,
726            allow_legacy_sha1: false,
727            secret: secret.to_vec(),
728            delivery_id_header: "x-test-delivery".to_string(),
729            topic: "tests.webhook_intake".to_string(),
730            dedupe_ttl: StdDuration::from_mins(1),
731        }
732    }
733
734    fn signed_headers(
735        config: &WebhookIntakeConfig,
736        body: &[u8],
737        delivery_id: &str,
738    ) -> BTreeMap<String, String> {
739        let digest = compute_hmac(config.algorithm, &config.secret, body);
740        let signature = match config.signature_encoding {
741            SignatureEncoding::Hex => hex::encode(&digest),
742            SignatureEncoding::Base64 => BASE64_STANDARD.encode(&digest),
743        };
744        let prefix = config.signature_prefix.clone().unwrap_or_default();
745        let mut headers = BTreeMap::new();
746        headers.insert(
747            config.signature_header.clone(),
748            format!("{prefix}{signature}"),
749        );
750        headers.insert(config.delivery_id_header.clone(), delivery_id.to_string());
751        headers
752    }
753
754    async fn reset() {
755        clear_webhook_intake_state();
756        crate::event_log::reset_active_event_log();
757    }
758
759    #[tokio::test(flavor = "current_thread")]
760    async fn round_trip_accepts_then_dedupes() {
761        reset().await;
762        let body = br#"{"hello":"world"}"#.to_vec();
763        let config = make_config(b"super-secret");
764        let snapshot = register_webhook_intake(config.clone()).expect("register");
765
766        let headers = signed_headers(&config, &body, "delivery-1");
767        let outcome = feed_webhook_intake(
768            snapshot.id.as_str(),
769            build_request(headers.clone(), body.clone(), None, None),
770        )
771        .await
772        .expect("feed");
773        assert_eq!(outcome.status, "accepted");
774        assert_eq!(outcome.delivery_id.as_deref(), Some("delivery-1"));
775        assert!(outcome.topic_event_id.is_some());
776
777        let dup = feed_webhook_intake(
778            snapshot.id.as_str(),
779            build_request(headers, body, None, None),
780        )
781        .await
782        .expect("feed dup");
783        assert_eq!(dup.status, "duplicate");
784    }
785
786    #[tokio::test(flavor = "current_thread")]
787    async fn rejects_bad_signature() {
788        reset().await;
789        let body = b"payload".to_vec();
790        let config = make_config(b"correct-secret");
791        let snapshot = register_webhook_intake(config.clone()).expect("register");
792
793        let mut headers = signed_headers(&config, &body, "delivery-1");
794        headers.insert(
795            config.signature_header.clone(),
796            "sha256=deadbeef".to_string(),
797        );
798        let outcome = feed_webhook_intake(
799            snapshot.id.as_str(),
800            build_request(headers, body, None, None),
801        )
802        .await
803        .expect("feed");
804        assert_eq!(outcome.status, "rejected");
805        assert!(outcome
806            .reason
807            .as_deref()
808            .unwrap_or("")
809            .contains("signature"));
810    }
811
812    #[tokio::test(flavor = "current_thread")]
813    async fn isolates_two_intakes_on_different_paths() {
814        reset().await;
815        let body = b"shared-body".to_vec();
816        let mut config_a = make_config(b"secret-a");
817        config_a.path = Some("/hooks/a".to_string());
818        config_a.topic = "tests.intake_a".to_string();
819        config_a.delivery_id_header = "x-a-delivery".to_string();
820
821        let mut config_b = make_config(b"secret-b");
822        config_b.path = Some("/hooks/b".to_string());
823        config_b.topic = "tests.intake_b".to_string();
824        config_b.delivery_id_header = "x-b-delivery".to_string();
825
826        let snap_a = register_webhook_intake(config_a.clone()).expect("register a");
827        let snap_b = register_webhook_intake(config_b.clone()).expect("register b");
828
829        // Same delivery id on both intakes must NOT collide.
830        let headers_a = signed_headers(&config_a, &body, "shared-delivery");
831        let outcome_a = feed_webhook_intake(
832            snap_a.id.as_str(),
833            build_request(headers_a, body.clone(), Some("/hooks/a".to_string()), None),
834        )
835        .await
836        .expect("feed a");
837        assert_eq!(outcome_a.status, "accepted");
838
839        let headers_b = signed_headers(&config_b, &body, "shared-delivery");
840        let outcome_b = feed_webhook_intake(
841            snap_b.id.as_str(),
842            build_request(headers_b, body, Some("/hooks/b".to_string()), None),
843        )
844        .await
845        .expect("feed b");
846        assert_eq!(outcome_b.status, "accepted");
847
848        // And path mismatch is rejected.
849        let body = b"more".to_vec();
850        let headers = signed_headers(&config_a, &body, "another-delivery");
851        let outcome = feed_webhook_intake(
852            snap_a.id.as_str(),
853            build_request(headers, body, Some("/hooks/wrong".to_string()), None),
854        )
855        .await
856        .expect("feed mismatch");
857        assert_eq!(outcome.status, "rejected");
858        assert!(outcome.reason.unwrap().contains("path mismatch"));
859    }
860
861    #[tokio::test(flavor = "current_thread")]
862    async fn supports_sha1_legacy() {
863        reset().await;
864        let body = b"legacy".to_vec();
865        let mut config = make_config(b"legacy-secret");
866        config.algorithm = HmacAlgorithm::Sha1;
867        config.allow_legacy_sha1 = true;
868        config.signature_prefix = Some("sha1=".to_string());
869        let snapshot = register_webhook_intake(config.clone()).expect("register");
870
871        let headers = signed_headers(&config, &body, "legacy-delivery");
872        let outcome = feed_webhook_intake(
873            snapshot.id.as_str(),
874            build_request(headers, body, None, None),
875        )
876        .await
877        .expect("feed");
878        assert_eq!(outcome.status, "accepted");
879    }
880
881    #[test]
882    fn hmac_algorithm_parser_gates_sha1() {
883        assert_eq!(HmacAlgorithm::parse("sha256"), Some(HmacAlgorithm::Sha256));
884        assert_eq!(HmacAlgorithm::parse("sha1"), None);
885        assert_eq!(
886            HmacAlgorithm::parse_with_legacy_sha1("sha1", true),
887            Some(HmacAlgorithm::Sha1)
888        );
889    }
890
891    #[tokio::test(flavor = "current_thread")]
892    async fn rejects_sha1_without_legacy_opt_in() {
893        reset().await;
894        let mut config = make_config(b"legacy-secret");
895        config.algorithm = HmacAlgorithm::Sha1;
896        config.signature_prefix = Some("sha1=".to_string());
897
898        let error = register_webhook_intake(config).expect_err("sha1 requires opt-in");
899        assert!(error.to_string().contains("set `allow_legacy_sha1: true`"));
900    }
901
902    #[tokio::test(flavor = "current_thread")]
903    async fn dedupe_survives_process_restart() {
904        clear_webhook_intake_state();
905        crate::event_log::reset_active_event_log();
906        let tmp = tempfile::tempdir().expect("tempdir");
907        // Install a file-backed event log so the inbox can rehydrate from disk.
908        let log = Arc::new(AnyEventLog::File(
909            FileEventLog::open(tmp.path().to_path_buf(), 32).expect("file log"),
910        ));
911        crate::event_log::install_active_event_log(log.clone());
912
913        let body = br#"{"hello":"durable"}"#.to_vec();
914        let mut config = make_config(b"durable-secret");
915        // Pin the id so the post-restart re-register uses the same dedupe
916        // namespace; without this each register call would mint a fresh id.
917        config.id = Some("durable-intake".to_string());
918        let snapshot = register_webhook_intake(config.clone()).expect("register");
919        let headers = signed_headers(&config, &body, "durable-1");
920
921        let first = feed_webhook_intake(
922            snapshot.id.as_str(),
923            build_request(headers.clone(), body.clone(), None, None),
924        )
925        .await
926        .expect("first feed");
927        assert_eq!(first.status, "accepted");
928
929        // Simulate a process restart by clearing in-memory intake state and the
930        // active event log handle, then reinstalling a fresh file-backed log on
931        // the same directory.
932        clear_webhook_intake_state();
933        crate::event_log::reset_active_event_log();
934        let log = Arc::new(AnyEventLog::File(
935            FileEventLog::open(tmp.path().to_path_buf(), 32).expect("file log"),
936        ));
937        crate::event_log::install_active_event_log(log);
938        let snapshot = register_webhook_intake(config.clone()).expect("re-register");
939
940        let second = feed_webhook_intake(
941            snapshot.id.as_str(),
942            build_request(headers, body, None, None),
943        )
944        .await
945        .expect("second feed");
946        assert_eq!(second.status, "duplicate");
947
948        // Use install_default_for_base_dir to keep parity with stdlib path
949        // (no assertion — just ensures the helper still works).
950        let _log = install_default_for_base_dir(tmp.path()).expect("install default");
951    }
952}