Skip to main content

cratefield_module_webhooks/
lib.rs

1//! `cratefield-module-webhooks`: outbound webhook delivery.
2//!
3//! ```no_run
4//! use cratefield_module_webhooks::Webhooks;
5//!
6//! let module = Webhooks::new().max_attempts(5);
7//! ```
8//!
9//! Two things, said once here and once in the README's first section:
10//! this module ships **no HTTP routes of its own** — the Rust API is the
11//! surface — and [`Webhooks::create_endpoint`] hands back the endpoint's
12//! signing secret **exactly once**. The wire format, the delivery
13//! policy, the configuration and the worked examples are the README
14//! below.
15
16#![doc = include_str!("../README.md")]
17#![forbid(unsafe_code)]
18
19mod clock;
20mod deliver;
21mod store;
22
23pub use crate::deliver::{
24    DeliveryError, DrainReport, EVENT_ID_HEADER, EVENT_TYPE_HEADER, SIGNATURE_HEADER,
25};
26pub use crate::store::{DeadLetter, Delivery, Endpoint, TOPIC_DELIVER};
27use cratefield_core::{
28    AnyError, BoxFuture, Config, ConfigError, Database, DbError, IdGen, Migrations, Module,
29    ModuleConfig, ModuleContext, PersonalDataSet, Port, SqlMigration, UlidIdGen,
30};
31use http::header::CONTENT_TYPE;
32
33/// The name the module mounts under: `/v1/webhooks`.
34pub const MODULE_NAME: &str = "webhooks";
35
36/// The one migration: the endpoints, outbox, deliveries and dead-letter
37/// tables, in the portable SQL subset (ADR 0004).
38const MIGRATION_INIT: SqlMigration = SqlMigration::new(
39    "0001",
40    "init",
41    include_str!("../migrations/sqlite/0001_init.sql"),
42);
43
44/// The bounds the builder's clamp and `WEBHOOKS_MAX_ATTEMPTS` validation
45/// share, so the two can never drift apart.
46const MAX_ATTEMPTS_RANGE: std::ops::RangeInclusive<u32> = 1..=1_000;
47
48/// A module that delivers signed outbound webhooks.
49#[derive(Debug, Clone)]
50pub struct Webhooks {
51    /// Dead-letter a delivery after this many failed attempts. Overridden
52    /// per deployment by `WEBHOOKS_MAX_ATTEMPTS`.
53    max_attempts: u32,
54    /// The second signature header, `WEBHOOKS_SIGNATURE_ALIAS`, as
55    /// [`Webhooks::from_config`] validated it.
56    signature_alias: Option<String>,
57}
58
59impl Default for Webhooks {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl Webhooks {
66    /// Defaults: dead-letter after five failed attempts, no signature alias.
67    #[must_use]
68    pub fn new() -> Self {
69        Self {
70            max_attempts: 5,
71            signature_alias: None,
72        }
73    }
74
75    /// The module as the deployment configures it. `WEBHOOKS_SIGNATURE_ALIAS`
76    /// names a second header that carries the same signature, for receivers
77    /// that verify under their product's own header name; unset is
78    /// [`Webhooks::new`]. `WEBHOOKS_MAX_ATTEMPTS` is still read by the drain.
79    ///
80    /// # Errors
81    ///
82    /// [`ConfigError`] when the alias is not an HTTP header token, or names a
83    /// header the module or the transport already sends. Build the module
84    /// with this at boot to refuse a bad alias then. The drain reads the
85    /// alias from its own config on every pass, so it refuses one then too.
86    pub fn from_config(cfg: &dyn Config) -> Result<Self, ConfigError> {
87        match signature_alias(cfg) {
88            Ok(signature_alias) => Ok(Self {
89                signature_alias,
90                ..Self::new()
91            }),
92            Err(problem) => {
93                let mut errors = ConfigError::default();
94                errors.push(problem);
95                Err(errors)
96            }
97        }
98    }
99
100    /// The signature header names [`Webhooks::from_config`] stored:
101    /// [`SIGNATURE_HEADER`], then the alias if there is one. All of them carry
102    /// the same value. The drain takes its names from the config it is given.
103    #[must_use]
104    pub fn signature_headers(&self) -> Vec<&str> {
105        let mut names = vec![SIGNATURE_HEADER];
106        names.extend(self.signature_alias.as_deref());
107        names
108    }
109
110    /// Dead-letter a delivery after this many failed attempts instead of
111    /// five. Values outside `1..=1_000` clamp to the nearest bound — the
112    /// same range `WEBHOOKS_MAX_ATTEMPTS` must pass validation with.
113    /// `WEBHOOKS_MAX_ATTEMPTS` overrides this when set.
114    #[must_use]
115    pub fn max_attempts(mut self, max_attempts: u32) -> Self {
116        self.max_attempts =
117            max_attempts.clamp(*MAX_ATTEMPTS_RANGE.start(), *MAX_ATTEMPTS_RANGE.end());
118        self
119    }
120
121    /// Registers one delivery endpoint for `subject` and returns its
122    /// signing secret — **the only time the secret is ever handed back**.
123    /// List the endpoints later and the secret is not in the answer.
124    ///
125    /// `event_types` is a filter; an empty slice means "everything"
126    /// (`*`). A URL that is not http(s), or a filter naming an event type
127    /// that could never ride a header, is refused here rather than failed
128    /// at every delivery.
129    ///
130    /// # Errors
131    ///
132    /// [`EndpointError`] for a malformed URL or subject, or a filter
133    /// naming an unusable event type, and [`DbError`] when the insert
134    /// fails.
135    pub async fn create_endpoint(
136        &self,
137        db: &dyn Database,
138        subject: &str,
139        url: &str,
140        event_types: &[&str],
141        now: &str,
142    ) -> Result<CreatedEndpoint, EndpointError> {
143        if subject.trim().is_empty() || subject.len() > 255 {
144            return Err(EndpointError::InvalidSubject);
145        }
146        if !is_http_url(url) {
147            return Err(EndpointError::InvalidUrl(url.to_owned()));
148        }
149        for candidate in event_types {
150            let candidate = candidate.trim();
151            if !candidate.is_empty() && !is_valid_event_type(candidate) {
152                return Err(EndpointError::InvalidEventType(candidate.to_owned()));
153            }
154        }
155        let secret = generate_secret()?;
156        let id = UlidIdGen.ulid();
157        db.execute(&store::insert_endpoint_statement(
158            &id,
159            subject,
160            url,
161            &secret,
162            &normalize_event_types(event_types),
163            now,
164        ))
165        .await?;
166        Ok(CreatedEndpoint {
167            endpoint_id: id,
168            secret,
169        })
170    }
171
172    /// The endpoints registered for `subject`, creation order — without
173    /// their secrets.
174    ///
175    /// # Errors
176    ///
177    /// [`DbError`] when the read fails.
178    pub async fn endpoints(
179        &self,
180        db: &dyn Database,
181        subject: &str,
182    ) -> Result<Vec<Endpoint>, DbError> {
183        store::endpoints_for_subject(db, subject).await
184    }
185
186    /// Deletes one endpoint of one subject; `false` when no such endpoint
187    /// belongs to that subject. Events already queued for it are dropped
188    /// by the next drain — there is nobody left to deliver them to.
189    ///
190    /// # Errors
191    ///
192    /// [`DbError`] when the delete fails.
193    pub async fn delete_endpoint(
194        &self,
195        db: &dyn Database,
196        subject: &str,
197        endpoint_id: &str,
198    ) -> Result<bool, DbError> {
199        store::delete_endpoint(db, subject, endpoint_id).await
200    }
201
202    /// Fans one event out to every endpoint of `subject` whose filter
203    /// matches `event_type`, and returns the outbox `INSERT`s — append
204    /// `Published::into_statements` to the caller's **own**
205    /// `db.batch_atomic(..)`, so the event is durable exactly when the
206    /// state change that caused it is. Nothing is written until that
207    /// batch runs.
208    ///
209    /// An event no endpoint matches is `Published::endpoints == 0` and
210    /// nothing else — not an error: "nobody subscribed to that" is an
211    /// ordinary answer.
212    ///
213    /// # Errors
214    ///
215    /// [`PublishError::InvalidEventType`] for an event type that could
216    /// never ride the `Cratefield-Event-Type` header (refused before any
217    /// read, so it cannot burn attempts later), and [`DbError`] when the
218    /// endpoint read fails.
219    pub async fn publish(
220        &self,
221        db: &dyn Database,
222        subject: &str,
223        event_type: &str,
224        data: &serde_json::Value,
225        now: &str,
226    ) -> Result<Published, PublishError> {
227        if !is_valid_event_type(event_type) {
228            return Err(PublishError::InvalidEventType(event_type.to_owned()));
229        }
230        let endpoints = store::endpoints_for_subject(db, subject).await?;
231        let event_id = UlidIdGen.ulid();
232        let mut statements = Vec::new();
233        for endpoint in &endpoints {
234            if !matches_filter(&endpoint.event_types, event_type) {
235                continue;
236            }
237            statements.push(store::enqueue_statement(
238                &store::DeliveryJob {
239                    endpoint_id: endpoint.id.clone(),
240                    event_id: event_id.clone(),
241                    event_type: event_type.to_owned(),
242                    subject: subject.to_owned(),
243                    created_at: now.to_owned(),
244                    data: data.clone(),
245                },
246                subject,
247                now,
248            ));
249        }
250        Ok(Published {
251            event_id,
252            endpoints: statements.len(),
253            statements,
254        })
255    }
256
257    /// Delivers due outbox rows through the ports of `ctx`, spending no
258    /// more than what the invocation's `ctx.scheduled` budget allows (ADR
259    /// 0023). Call it from the venture's **scheduled** entry point — a cron
260    /// invocation builds no router, so the context must be the one the
261    /// runtime hands in, not one parked at router-build time.
262    ///
263    /// Each row is signed and delivered once per pass: 2xx completes it,
264    /// anything else retries with exponential backoff (410 Gone and
265    /// SSRF-blocked destinations dead-letter immediately), and
266    /// `MAX_ATTEMPTS` failures dead-letter it for
267    /// [`Webhooks::replay`]. One delivery attempt spends one subrequest;
268    /// when the budget runs out the rows it never reached stay queued, due
269    /// now, for the next tick. A per-row failure never aborts the pass.
270    ///
271    /// The signature names come from `ctx`'s `WEBHOOKS_SIGNATURE_ALIAS`, on
272    /// every pass, whichever constructor built this module.
273    ///
274    /// # Errors
275    ///
276    /// [`DeliveryError`] when a port is missing, the claim fails, the outbox
277    /// refuses a completion write, or the alias is not usable. An unusable
278    /// alias is refused before any row is claimed.
279    pub async fn drain_with(&self, ctx: &ModuleContext) -> Result<DrainReport, DeliveryError> {
280        deliver::drain(ctx, self.max_attempts).await
281    }
282
283    /// Re-enqueues one dead letter of `subject` — attempts reset to zero —
284    /// and removes it, in one batch. The delivery log keeps the history.
285    /// `false` — the letter left exactly where it was — when the id names
286    /// no dead letter of this subject, **or** when the letter's endpoint
287    /// is gone: a replay into a deleted endpoint would consume the letter
288    /// and then be dropped by the drain, destroying the event for good.
289    ///
290    /// # Errors
291    ///
292    /// [`DbError`] when the read or the batch fails.
293    pub async fn replay(
294        &self,
295        db: &dyn Database,
296        subject: &str,
297        dead_letter_id: &str,
298        now: &str,
299    ) -> Result<bool, DbError> {
300        store::replay(db, subject, dead_letter_id, now).await
301    }
302
303    /// A subject's delivery log, newest attempt first. `status_code` is
304    /// `None` for an attempt that never got an answer.
305    ///
306    /// # Errors
307    ///
308    /// [`DbError`] when the read fails.
309    pub async fn deliveries(
310        &self,
311        db: &dyn Database,
312        subject: &str,
313        limit: u64,
314    ) -> Result<Vec<Delivery>, DbError> {
315        store::deliveries(db, subject, limit).await
316    }
317
318    /// A subject's dead letters, oldest first — the ids
319    /// [`Webhooks::replay`] takes.
320    ///
321    /// # Errors
322    ///
323    /// [`DbError`] when the read fails.
324    pub async fn dead_letters(
325        &self,
326        db: &dyn Database,
327        subject: &str,
328        limit: u64,
329    ) -> Result<Vec<DeadLetter>, DbError> {
330        store::dead_letters(db, subject, limit).await
331    }
332}
333
334impl Module for Webhooks {
335    fn name(&self) -> &'static str {
336        MODULE_NAME
337    }
338
339    fn version(&self) -> &'static str {
340        env!("CARGO_PKG_VERSION")
341    }
342
343    fn requires(&self) -> &'static [Port] {
344        // `Clock` stamps both the stored timestamps and the signature; a
345        // webhook that cannot be delivered is not a delivery pipeline.
346        &[Port::Db, Port::HttpClient, Port::Clock]
347    }
348
349    fn tables(&self) -> &'static [&'static str] {
350        &[
351            store::ENDPOINTS,
352            store::OUTBOX,
353            store::DELIVERIES,
354            store::DEAD_LETTERS,
355        ]
356    }
357
358    /// Every table keys its rows on the subject the event (or endpoint)
359    /// belongs to, so erasure reaches all four; the endpoint `secret` is
360    /// credential material and is exported redacted.
361    fn personal_data(&self) -> &'static [PersonalDataSet] {
362        const SETS: &[PersonalDataSet] = &[
363            PersonalDataSet {
364                table: store::ENDPOINTS,
365                subject: "subject",
366                kind: cratefield_core::DataKind::Identifier,
367                disposition: cratefield_core::Disposition::Erase,
368                description: "The webhook URLs this venture delivers your events to, one row \
369                              each. Each row holds a signing secret, which is why it is \
370                              redacted here; deleting your account deletes the endpoints and \
371                              their secrets.",
372                redacted: &["secret"],
373                subject_via: None,
374            },
375            PersonalDataSet {
376                table: store::OUTBOX,
377                subject: "subject",
378                kind: cratefield_core::DataKind::Content,
379                disposition: cratefield_core::Disposition::Erase,
380                description: "Events queued for delivery to the endpoints above, with the \
381                              account they are about. A delivery is erased with the account \
382                              even if it has not been sent yet.",
383                redacted: &[],
384                subject_via: None,
385            },
386            PersonalDataSet {
387                table: store::DELIVERIES,
388                subject: "subject",
389                kind: cratefield_core::DataKind::Usage,
390                disposition: cratefield_core::Disposition::Erase,
391                description: "The log of delivery attempts: which endpoint, which event, what \
392                              status code came back, and when. It shows which of your events \
393                              left the venture, and nothing else.",
394                redacted: &[],
395                subject_via: None,
396            },
397            PersonalDataSet {
398                table: store::DEAD_LETTERS,
399                subject: "subject",
400                kind: cratefield_core::DataKind::Content,
401                disposition: cratefield_core::Disposition::Erase,
402                description: "Events that gave up after repeated failed deliveries, kept so \
403                              they can be replayed. They carry the same data as the queued \
404                              events above, and go when your account is deleted.",
405                redacted: &[],
406                subject_via: None,
407            },
408        ];
409        SETS
410    }
411
412    fn migrations(&self) -> Migrations {
413        const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
414        // The array is the apply order; this refuses a gap, a duplicate
415        // or an entry out of order at build time (issue #27).
416        const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
417        Migrations {
418            sqlite: &MIGRATIONS,
419            postgres: &[],
420        }
421    }
422
423    fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
424        let module = ModuleConfig::new(MODULE_NAME, cfg);
425        let mut errors = ConfigError::default();
426        if let Some(raw) = cfg
427            .get(&module.key("MAX_ATTEMPTS"))
428            .map(|raw| raw.parse::<u32>())
429            && !raw.is_ok_and(|value| MAX_ATTEMPTS_RANGE.contains(&value))
430        {
431            errors.push(format!(
432                "webhooks: {} must be a whole number between 1 and 1000",
433                module.key("MAX_ATTEMPTS")
434            ));
435        }
436        if let Err(problem) = signature_alias(cfg) {
437            errors.push(problem);
438        }
439        errors.into_result()
440    }
441
442    /// Deliberately empty: see the crate documentation — the Rust API is
443    /// this module's surface, and a management route would need an auth
444    /// decision this crate does not make.
445    fn router(&self, _ctx: ModuleContext) -> axum::Router {
446        axum::Router::new()
447    }
448
449    /// The recovery half of the outbox contract: whatever a caller's
450    /// immediate drain never got to is delivered on the next tick — as far
451    /// as the invocation's budget reaches (ADR 0023), with the rest left
452    /// due for the ticks after — and the dead letters pile up here for
453    /// [`Webhooks::replay`].
454    fn scheduled<'a>(
455        &'a self,
456        ctx: &'a ModuleContext,
457        cron: &'a str,
458    ) -> BoxFuture<'a, Result<(), AnyError>> {
459        Box::pin(async move {
460            let report = self
461                .drain_with(ctx)
462                .await
463                .map_err(|err| Box::new(err) as AnyError)?;
464            // `deferred` alone is worth a line: the budget stopped a pass
465            // that had work left, and the next tick must pick it up.
466            if report.claimed > 0 || report.deferred > 0 {
467                tracing::info!(?report, cron, "drained the webhooks outbox");
468            }
469            Ok(())
470        })
471    }
472}
473
474/// What [`Webhooks::create_endpoint`] returns: the endpoint's id, and the
475/// signing secret — which is returned **once**. Store it now; it is never
476/// listed again.
477#[derive(Clone, PartialEq, Eq)]
478pub struct CreatedEndpoint {
479    pub endpoint_id: String,
480    pub secret: String,
481}
482
483// The `Debug` is manual because the struct carries a signing secret: a
484// `{:?}` in a log, a trace or a test failure must not print it (the
485// pattern `cratefield-secrets` uses for its own bytes).
486impl std::fmt::Debug for CreatedEndpoint {
487    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
488        f.debug_struct("CreatedEndpoint")
489            .field("endpoint_id", &self.endpoint_id)
490            .field("secret", &"[redacted]")
491            .finish()
492    }
493}
494
495/// Why an endpoint was refused before the database was touched.
496#[derive(Debug, Clone, PartialEq, Eq)]
497pub enum EndpointError {
498    /// The URL is not an http(s) URL, carries whitespace, or is longer
499    /// than 2048 bytes.
500    InvalidUrl(String),
501    /// The subject is empty or longer than 255 bytes.
502    InvalidSubject,
503    /// A filter named an event type no HTTP header could carry. Refused
504    /// here, because it would filter out every event: publishing such a
505    /// type fails too, so no match could ever happen.
506    InvalidEventType(String),
507    /// The operating system refused to give this process random bytes,
508    /// so no secret could be generated.
509    Entropy(String),
510    /// The insert failed.
511    Database(DbError),
512}
513
514impl std::fmt::Display for EndpointError {
515    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516        match self {
517            EndpointError::InvalidUrl(url) => {
518                write!(f, "webhooks: not an http(s) URL: {url:?}")
519            }
520            EndpointError::InvalidSubject => {
521                f.write_str("webhooks: the subject must be 1..=255 bytes")
522            }
523            EndpointError::InvalidEventType(event_type) => {
524                write!(f, "webhooks: not a usable event type: {event_type:?}")
525            }
526            EndpointError::Entropy(error) => write!(f, "webhooks: {error}"),
527            EndpointError::Database(error) => write!(f, "webhooks: {error}"),
528        }
529    }
530}
531
532impl std::error::Error for EndpointError {}
533
534impl From<DbError> for EndpointError {
535    fn from(error: DbError) -> Self {
536        EndpointError::Database(error)
537    }
538}
539
540/// Why [`Webhooks::publish`] refused before writing anything.
541#[derive(Debug, Clone, PartialEq, Eq)]
542pub enum PublishError {
543    /// The event type is not a header-safe token: empty, whitespace, or
544    /// anything outside visible ASCII. Refused here because such a type
545    /// would otherwise become an undeliverable `Cratefield-Event-Type`
546    /// header value that burns every retry.
547    InvalidEventType(String),
548    /// The endpoint read failed.
549    Database(DbError),
550}
551
552impl std::fmt::Display for PublishError {
553    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554        match self {
555            PublishError::InvalidEventType(event_type) => {
556                write!(f, "webhooks: not a usable event type: {event_type:?}")
557            }
558            PublishError::Database(error) => write!(f, "webhooks: {error}"),
559        }
560    }
561}
562
563impl std::error::Error for PublishError {}
564
565impl From<DbError> for PublishError {
566    fn from(error: DbError) -> Self {
567        PublishError::Database(error)
568    }
569}
570
571/// What [`Webhooks::publish`] produced. Append
572/// `Published::into_statements` to the caller's own `db.batch_atomic(..)`;
573/// nothing is written until that batch runs.
574#[derive(Debug, Clone)]
575pub struct Published {
576    /// The event's identity, shared by every fan-out row of this one
577    /// event and sent as the `Cratefield-Event-Id` header, so a receiver
578    /// can dedupe the redeliveries at-least-once delivery promises.
579    pub event_id: String,
580    /// How many endpoints matched — how many statements there are, and
581    /// how many POSTs the drain will eventually make.
582    pub endpoints: usize,
583    statements: Vec<cratefield_core::Statement>,
584}
585
586impl Published {
587    /// The outbox `INSERT`s, for the caller's own
588    /// `db.batch_atomic(..)`.
589    #[must_use]
590    pub fn into_statements(self) -> Vec<cratefield_core::Statement> {
591        self.statements
592    }
593}
594
595/// Whether an event type can be both a filter entry and a header value:
596/// 1..=128 bytes of visible ASCII, no whitespace — the characters an
597/// HTTP header value survives verbatim. Enforced at `publish` and at
598/// `create_endpoint`, so no filter can name a type no event could ever
599/// carry.
600fn is_valid_event_type(event_type: &str) -> bool {
601    (1..=128).contains(&event_type.len()) && event_type.chars().all(|c| c.is_ascii_graphic())
602}
603
604/// `WEBHOOKS_SIGNATURE_ALIAS`: `None` when unset or empty, otherwise an HTTP
605/// header token the module and the transport do not already own, compared
606/// case-insensitively. The problem names the key, never the value.
607pub(crate) fn signature_alias(cfg: &dyn Config) -> Result<Option<String>, String> {
608    let key = ModuleConfig::new(MODULE_NAME, cfg).key("SIGNATURE_ALIAS");
609    let Some(raw) = cfg.get(&key).filter(|raw| !raw.is_empty()) else {
610        return Ok(None);
611    };
612    let reserved = [
613        CONTENT_TYPE.as_str(),
614        SIGNATURE_HEADER,
615        EVENT_ID_HEADER,
616        EVENT_TYPE_HEADER,
617        "host",
618        "content-length",
619        "transfer-encoding",
620        "connection",
621        "te",
622        "trailer",
623        "upgrade",
624        "keep-alive",
625        "proxy-authorization",
626        "proxy-connection",
627    ];
628    let usable = http::HeaderName::from_bytes(raw.as_bytes()).is_ok_and(|name| {
629        !reserved
630            .iter()
631            .any(|sent| sent.eq_ignore_ascii_case(name.as_str()))
632    });
633    if usable {
634        Ok(Some(raw))
635    } else {
636        Err(format!(
637            "webhooks: {key} must be an HTTP header name the module does not already send"
638        ))
639    }
640}
641
642/// Whether an endpoint's stored `event_types` filter matches an event.
643///
644/// `*` (what an empty filter normalises to) matches everything; anything
645/// else is a comma-separated list of exact event types, so `order.paid`
646/// does not match `order.paid.v2`.
647#[must_use]
648pub fn matches_filter(filter: &str, event_type: &str) -> bool {
649    let filter = filter.trim();
650    filter == "*"
651        || filter
652            .split(',')
653            .map(str::trim)
654            .any(|candidate| candidate == event_type)
655}
656
657/// Normalises the `create_endpoint` filter: trimmed, empty parts dropped,
658/// duplicates collapsed, and an empty result meaning "everything".
659#[must_use]
660pub fn normalize_event_types(event_types: &[&str]) -> String {
661    let mut seen = Vec::new();
662    for candidate in event_types {
663        let candidate = candidate.trim();
664        if !candidate.is_empty() && !seen.contains(&candidate) {
665            seen.push(candidate);
666        }
667    }
668    if seen.is_empty() {
669        "*".to_owned()
670    } else {
671        seen.join(",")
672    }
673}
674
675/// An http(s) URL with nowhere for whitespace to hide. The deep checks —
676/// loopback, private and metadata destinations — are the `HttpClient`
677/// port's contract, re-vetted per delivery and per redirect hop; this is
678/// the fast, obvious refusal a typo earns at registration.
679fn is_http_url(url: &str) -> bool {
680    let max = 2048;
681    (url.starts_with("https://") || url.starts_with("http://"))
682        && url.len() <= max
683        && !url.contains(char::is_whitespace)
684        && match url.split_once("://") {
685            // The authority must be non-empty: `http:///path` names no
686            // host at all.
687            Some((_, rest)) => {
688                let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
689                !authority.is_empty()
690            }
691            None => false,
692        }
693}
694
695/// 32 OS-random bytes, hex, with the `whsec_` prefix the in-repo fixtures
696/// use. A signing key, generated where it is stored: it crosses one
697/// response boundary (creation) and is then never handed back, never
698/// logged, and exported only redacted.
699fn generate_secret() -> Result<String, EndpointError> {
700    let mut bytes = [0_u8; 32];
701    getrandom::fill(&mut bytes).map_err(|error| EndpointError::Entropy(error.to_string()))?;
702    let mut hex = String::with_capacity(2 * bytes.len());
703    for byte in bytes {
704        // Writing to a String cannot fail.
705        use std::fmt::Write as _;
706        let _ = write!(hex, "{byte:02x}");
707    }
708    Ok(format!("whsec_{hex}"))
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn defaults_are_the_documented_ones() {
717        let module = Webhooks::new();
718        assert_eq!(module.name(), "webhooks");
719        assert_eq!(module.version(), env!("CARGO_PKG_VERSION"));
720        assert_eq!(module.requires(), [Port::Db, Port::HttpClient, Port::Clock]);
721        assert_eq!(
722            module.tables(),
723            [
724                "webhooks_endpoints",
725                "webhooks_outbox",
726                "webhooks_deliveries",
727                "webhooks_dead_letters"
728            ]
729        );
730        assert_eq!(module.max_attempts, 5);
731    }
732
733    #[test]
734    fn max_attempts_stays_within_the_range_the_config_must_pass() {
735        assert_eq!(Webhooks::new().max_attempts(0).max_attempts, 1);
736        assert_eq!(Webhooks::new().max_attempts(2).max_attempts, 2);
737        assert_eq!(Webhooks::new().max_attempts(1_000).max_attempts, 1_000);
738        assert_eq!(Webhooks::new().max_attempts(5_000).max_attempts, 1_000);
739    }
740
741    #[test]
742    fn a_created_endpoints_debug_never_carries_the_secret() {
743        let created = CreatedEndpoint {
744            endpoint_id: "ep_01HXYZ".to_owned(),
745            secret: "whsec_hunter2".to_owned(),
746        };
747        let rendered = format!("{created:?}");
748        assert!(rendered.contains("ep_01HXYZ"), "{rendered}");
749        assert!(rendered.contains("[redacted]"), "{rendered}");
750        assert!(!rendered.contains("hunter2"), "{rendered}");
751    }
752
753    #[test]
754    fn event_types_must_be_header_safe_tokens() {
755        assert!(is_valid_event_type("order.paid"));
756        assert!(is_valid_event_type("order-paid_v2"));
757        assert!(!is_valid_event_type(""), "empty");
758        assert!(!is_valid_event_type(" order.paid"), "leading space");
759        assert!(!is_valid_event_type("order paid"), "inner space");
760        assert!(!is_valid_event_type("command\ninjection"), "control char");
761        assert!(!is_valid_event_type("héllo"), "non-ASCII");
762        assert!(is_valid_event_type(&"x".repeat(128)), "the max is allowed");
763        assert!(!is_valid_event_type(&"x".repeat(129)), "over the max");
764    }
765
766    #[test]
767    fn a_star_filter_matches_everything_and_a_list_matches_exactly() {
768        assert!(matches_filter("*", "order.paid"));
769        assert!(matches_filter("*", ""));
770        assert!(matches_filter("order.paid", "order.paid"));
771        assert!(matches_filter(
772            " order.paid , order.refunded ",
773            "order.refunded"
774        ));
775        assert!(!matches_filter("order.paid", "order.paid.v2"));
776        assert!(!matches_filter("order.paid", "order.refunded"));
777        assert!(
778            !matches_filter("", "order.paid"),
779            "empty stores as *, never as nothing"
780        );
781    }
782
783    #[test]
784    fn filters_normalise_to_a_stored_list_or_a_star() {
785        assert_eq!(
786            normalize_event_types(&["order.paid", "order.refunded"]),
787            "order.paid,order.refunded"
788        );
789        assert_eq!(normalize_event_types(&[" order.paid ", ""]), "order.paid");
790        assert_eq!(normalize_event_types(&["a", "a", "b"]), "a,b");
791        assert_eq!(normalize_event_types(&[]), "*");
792        assert_eq!(normalize_event_types(&["", "  "]), "*");
793    }
794
795    #[test]
796    fn urls_must_be_http_s_with_nowhere_for_whitespace_to_hide() {
797        assert!(is_http_url("https://example.com/hook"));
798        assert!(is_http_url("http://example.com"));
799        assert!(!is_http_url("example.com/hook"));
800        assert!(!is_http_url("ftp://example.com"));
801        assert!(!is_http_url("https://"));
802        assert!(!is_http_url("http:///path"), "no host at all");
803        assert!(!is_http_url("https:///q?a=1"), "no host, only a query");
804        assert!(is_http_url("http://example.com?a=1"), "authority, no path");
805        assert!(!is_http_url("https://example.com/a b"));
806        assert!(!is_http_url(""));
807        assert!(!is_http_url("https://example.com/\n"));
808    }
809
810    #[test]
811    fn secrets_are_prefixed_hex_and_never_repeat() {
812        let a = generate_secret().expect("os randomness");
813        let b = generate_secret().expect("os randomness");
814        assert!(a.starts_with("whsec_"), "{a}");
815        let hex = a.strip_prefix("whsec_").expect("prefix");
816        assert_eq!(hex.len(), 64, "32 bytes in hex");
817        assert!(
818            hex.chars()
819                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
820        );
821        assert_ne!(a, b, "two secrets are two draws");
822    }
823
824    #[test]
825    fn a_bad_config_is_collected_not_thrown_one_at_a_time() {
826        let ok = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "3")]);
827        assert!(Webhooks::new().validate_config(&ok).is_ok());
828
829        let empty = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "")]);
830        let error = Webhooks::new()
831            .validate_config(&empty)
832            .expect_err("an unparsable attempt count is refused");
833        assert!(
834            error.to_string().contains("WEBHOOKS_MAX_ATTEMPTS"),
835            "{error}"
836        );
837
838        let zero = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "0")]);
839        assert!(Webhooks::new().validate_config(&zero).is_err());
840
841        // The range the builder clamps to is the range this accepts.
842        let ceiling = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "1000")]);
843        assert!(Webhooks::new().validate_config(&ceiling).is_ok());
844        let past = MapConfig::from_pairs([("WEBHOOKS_MAX_ATTEMPTS", "1001")]);
845        assert!(Webhooks::new().validate_config(&past).is_err());
846    }
847
848    use cratefield_core::MapConfig;
849}