Skip to main content

trusty_console/webhook/
mod.rs

1//! `POST /api/webhooks/{source}` — the console's webhook ingress (#5089 step 3).
2//!
3//! Why: ADR-0032 removed every sibling daemon's HTTP listener, and ADR-0034
4//! rules that console terminates the GitHub request and relays inward over UDS.
5//! The two handlers this path replaced both acknowledged GitHub *before* the
6//! work ran and downgraded every later failure to a log line. GitHub never
7//! retries an acknowledged delivery, so each of those failures was permanent,
8//! silent loss with every health signal still green. #5181 deleted them —
9//! `trusty-review`'s `POST /pr/github/webhook` and `trusty-analyze`'s
10//! `POST /webhooks/github` now 404 — so this route is the only HTTP webhook
11//! surface in the workspace, and the only holder of the shared secret.
12//!
13//! What: one route, multiplexed by `{source}` over both targets. The order of
14//! operations is the fix and is not negotiable:
15//!
16//! 1. unknown `{source}` → `404`, before any secret handling;
17//! 2. HMAC verified once, over the exact received bytes; unset secret and bad
18//!    signature both → `401` (ADR-0034 §2 unifies the policy to fail-closed);
19//! 3. the delivery is written and fsync'd to the spool — **on failure `500`,
20//!    and no `202` is ever sent**, so GitHub keeps the delivery redeliverable;
21//! 4. the relay runs, and its outcome is recorded durably: an explicit ack
22//!    deletes the entry, anything else leaves it `pending` with an incremented
23//!    attempt count;
24//! 5. `202`, because step 3 succeeded — not because step 4 did.
25//!
26//! 🔴 Explicitly absent, per ADR-0034 §2: `let _ = relay(...)`, a bare
27//! `tracing::warn!` as the sole record of a failed relay, and any `202` issued
28//! before the spool write returns.
29//!
30//! Spawn-on-demand — console starting a target that is not resident — landed in
31//! #5182 alongside the targets' listeners: [`spawn::TargetSupervisor`] runs
32//! `ensure_running` before each relay. A target that will not start is still
33//! [`relay::RelayOutcome::Unreachable`], which is a durable pending state, not a
34//! dropped delivery.
35//!
36//! Test: `tests.rs`.
37
38pub mod health;
39pub mod relay;
40pub mod schedule;
41pub mod spawn;
42pub mod spool;
43
44#[cfg(test)]
45#[path = "tests.rs"]
46mod tests;
47
48use std::collections::BTreeMap;
49use std::path::PathBuf;
50use std::sync::Arc;
51use std::time::{Duration, SystemTime, UNIX_EPOCH};
52
53use axum::body::Bytes;
54use axum::extract::{Path, State};
55use axum::http::{HeaderMap, StatusCode};
56use axum::response::IntoResponse;
57use base64::Engine as _;
58use base64::engine::general_purpose::STANDARD as BASE64;
59use serde_json::json;
60use trusty_common::webhook_hmac::{HMAC_ALGORITHM, SIGNATURE_HEADER, SignatureVerdict};
61
62use health::{DEFAULT_RED_AFTER, SpoolHealth};
63use relay::{RelayOutcome, UdsRelay};
64use schedule::ClaimSet;
65use spool::{Provenance, SPOOL_SCHEMA_VERSION, Spool, SpoolEntry, SpoolError};
66
67pub use schedule::BackoffPolicy;
68
69/// Most entries one sweep pass will relay.
70///
71/// Why: the sweep is serial and each relay can burn its full timeout, so an
72/// unbounded pass can outlast its own tick interval. Backoff already keeps the
73/// due set small; this bounds the pathological case where it is not. Entries
74/// left over are simply relayed on the next tick — they stay pending and
75/// durable in the meantime.
76const SWEEP_BUDGET: usize = 32;
77
78/// Largest webhook body the ingress route accepts.
79///
80/// Why: axum's `DefaultBodyLimit` is 2 MiB, and a rejection there happens
81/// *before* this module's handler runs — no spool entry, no metric, no log,
82/// just a 413 GitHub records and nobody reads. That is the invisible drop the
83/// whole step exists to remove, arriving through the framework instead of the
84/// code. GitHub payloads are legal to 25 MB and `push` / `pull_request` bodies
85/// routinely exceed 2 MiB, so the default silently refuses real deliveries.
86/// What: 25 MiB, matching GitHub's documented ceiling. Applied only to the
87/// webhook sub-router (`server::build_router_with_webhooks`), so the proxy and
88/// SPA routes keep the framework default.
89/// Test: `route_accepts_a_body_larger_than_the_axum_default_limit`.
90pub const MAX_WEBHOOK_BODY_BYTES: usize = 25 * 1024 * 1024;
91
92/// Environment variable holding the shared webhook secret.
93pub const SECRET_ENV: &str = "GITHUB_WEBHOOK_SECRET";
94
95/// Headers never written to the spool.
96///
97/// GitHub sends none of these; storing one would put a caller-supplied
98/// credential on disk for no benefit.
99const HEADER_DENYLIST: [&str; 3] = ["authorization", "cookie", "proxy-authorization"];
100
101/// Result of one ingress attempt, before it becomes an HTTP response.
102///
103/// Why: keeping the decision separate from axum lets every arm — including the
104/// two that must never produce a `202` — be asserted directly, without a router.
105/// What: one variant per outcome the ADR distinguishes.
106/// Test: the `ingest_*` cases in `tests.rs`.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum IngestOutcome {
109    /// `{source}` names no configured target.
110    UnknownSource {
111        /// The path segment that did not resolve.
112        source: String,
113    },
114    /// No secret is configured. Fail closed.
115    SecretMissing,
116    /// A secret is configured and the signature did not verify.
117    InvalidSignature,
118    /// The durable write failed. The caller MUST return 5xx and MUST NOT ack.
119    SpoolFailed {
120        /// Why the write failed.
121        reason: String,
122    },
123    /// The delivery is durably recorded. Safe to acknowledge GitHub.
124    Accepted {
125        /// The delivery id the entry is filed under.
126        delivery_id: String,
127        /// What the relay attempt established. Not a precondition of the ack.
128        relay: RelayOutcome,
129        /// Set when the post-relay bookkeeping write failed. The delivery is
130        /// still durable — this only means the attempt count or the deletion
131        /// did not land, both of which are recoverable by the retry sweep.
132        bookkeeping_error: Option<String>,
133    },
134}
135
136/// One relay target reachable through `/api/webhooks/{source}`.
137#[derive(Debug, Clone)]
138pub struct Target {
139    /// Route segment that selects it.
140    pub source: String,
141    /// UDS client for it.
142    pub relay: UdsRelay,
143}
144
145/// The webhook ingress: spool, secret, and one relay per target.
146///
147/// Why: assembled once at startup and shared by the route handler, the metrics
148/// handler, and the background retry sweep, so all three see the same spool.
149/// What: cheap to clone (everything behind `Arc`).
150/// Test: constructed directly in `tests.rs` with a temp-dir spool, so no test
151/// mutates a process-global env var.
152#[derive(Debug, Clone)]
153pub struct WebhookIngress {
154    spool: Arc<Spool>,
155    secret: Arc<String>,
156    key_id: Arc<String>,
157    targets: Arc<BTreeMap<String, UdsRelay>>,
158    red_after: Duration,
159    /// Which entries are being relayed right now, so the sweep and the request
160    /// path cannot both relay one delivery. See [`schedule::ClaimSet`].
161    claims: ClaimSet,
162    /// When a pending entry becomes eligible for another attempt.
163    backoff: BackoffPolicy,
164    /// Each target's inbox, so an acknowledged-but-undrained delivery is
165    /// metered rather than disappearing from the signal. See
166    /// [`health::SpoolHealth::undrained`] and #5192.
167    inbox_roots: Arc<Vec<(String, PathBuf)>>,
168}
169
170impl WebhookIngress {
171    /// Assemble an ingress from explicit parts.
172    ///
173    /// Test: used by every `tests.rs` case.
174    pub fn new(spool: Spool, secret: String, key_id: String, targets: Vec<Target>) -> Self {
175        Self {
176            spool: Arc::new(spool),
177            secret: Arc::new(secret),
178            key_id: Arc::new(key_id),
179            targets: Arc::new(
180                targets
181                    .into_iter()
182                    .map(|t| (t.source, t.relay))
183                    .collect::<BTreeMap<_, _>>(),
184            ),
185            red_after: DEFAULT_RED_AFTER,
186            claims: ClaimSet::new(),
187            backoff: BackoffPolicy::default(),
188            // Deliberately empty rather than derived: deriving would make every
189            // unit test read the developer's real `~/…/webhook-inbox`. Production
190            // wiring is `from_env`, and `from_env_meters_every_targets_inbox`
191            // pins that it populates this.
192            inbox_roots: Arc::new(Vec::new()),
193        }
194    }
195
196    /// Meter these inboxes when reporting health.
197    ///
198    /// Why: an acknowledged delivery leaves the spool, so without this the
199    /// status goes green while the work sits unprocessed in a target's inbox.
200    /// Test: `health_is_degraded_while_a_delivery_sits_undrained`.
201    pub fn with_inbox_roots(mut self, roots: Vec<(String, PathBuf)>) -> Self {
202        self.inbox_roots = Arc::new(roots);
203        self
204    }
205
206    /// Override the red-health threshold.
207    pub fn with_red_after(mut self, red_after: Duration) -> Self {
208        self.red_after = red_after;
209        self
210    }
211
212    /// Override the retry schedule.
213    ///
214    /// Test: the `sweep_*` and `backoff_*` cases use a zeroed grace so a sweep
215    /// runs without waiting out the real 5 s hold-off.
216    pub fn with_backoff(mut self, backoff: BackoffPolicy) -> Self {
217        self.backoff = backoff;
218        self
219    }
220
221    /// The retry schedule in force.
222    pub fn backoff(&self) -> BackoffPolicy {
223        self.backoff
224    }
225
226    /// Production wiring: spool under the console data dir, secret from
227    /// [`SECRET_ENV`], and one target per relay-capable service.
228    ///
229    /// Why: the socket paths come from `trusty_common::uds::scratch_socket_dir`,
230    /// the shared entry point #5099 built. That is `$TMPDIR/trusty-<uid>` with a
231    /// `/tmp` fallback — the *base* ADR-0034 §3 names, but not the exposure it
232    /// objects to: the uid-keyed subdirectory is created at `0700` and owned by
233    /// this process, and `connect_hardened` re-verifies owner and mode before
234    /// dialling. #5099 supersedes §3's "use the service state directory instead"
235    /// path rule by making the scratch path satisfy the property §3 wanted.
236    /// Nothing binds these sockets until step 4; dialling an absent one is a
237    /// clean `Unreachable`.
238    /// What: creates the spool directory eagerly so a misconfigured data dir
239    /// fails at startup rather than on the first delivery.
240    ///
241    /// # Errors
242    ///
243    /// When the data directory cannot be resolved or the spool directory cannot
244    /// be created.
245    ///
246    /// Test: `default_spool_root_lives_under_the_console_data_dir`, plus the
247    /// `#[ignore]`d `integration_from_env_*` cases, which point
248    /// `TRUSTY_DATA_DIR_OVERRIDE` at a temp dir under a lock.
249    pub fn from_env() -> anyhow::Result<Self> {
250        let spool = Spool::open(Spool::default_root()?)?;
251        let secret = std::env::var(SECRET_ENV).unwrap_or_default();
252        // #5182: the paths come from the shared contract rather than a literal
253        // here, so the sender and the two receivers cannot disagree about them.
254        let supervisor: spawn::SharedSupervisor = Arc::new(spawn::TargetSupervisor::new());
255        let mut targets = Vec::new();
256        for source in [
257            trusty_common::webhook_relay::REVIEW_SOURCE,
258            trusty_common::webhook_relay::ANALYZE_SOURCE,
259        ] {
260            let socket = trusty_common::webhook_relay::socket_path_for(source)
261                .ok_or_else(|| anyhow::anyhow!("no socket is defined for source {source}"))?;
262            targets.push(Target {
263                source: source.to_string(),
264                relay: UdsRelay::new(socket).with_supervisor(source, Arc::clone(&supervisor)),
265            });
266        }
267        // #5182 review: meter each target's inbox. Without it an acknowledged
268        // delivery leaves the spool and the signal goes green while the work
269        // sits unprocessed — see `health::SpoolHealth::undrained` and #5192.
270        let mut inbox_roots = Vec::new();
271        for source in [
272            trusty_common::webhook_relay::REVIEW_SOURCE,
273            trusty_common::webhook_relay::ANALYZE_SOURCE,
274        ] {
275            let root = trusty_common::webhook_relay::inbox_root_for(source)
276                .ok_or_else(|| anyhow::anyhow!("no inbox is defined for source {source}"))??;
277            inbox_roots.push((source.to_string(), root));
278        }
279        Ok(Self::new(spool, secret, SECRET_ENV.to_string(), targets).with_inbox_roots(inbox_roots))
280    }
281
282    /// The spool this ingress writes to.
283    pub fn spool(&self) -> &Spool {
284        &self.spool
285    }
286
287    /// Run one blocking spool operation off the async runtime.
288    ///
289    /// Why: every spool call does real filesystem work — `persist_new` alone
290    /// fsyncs a file and a directory, and a census `read_dir`s two of them.
291    /// Doing that inline stalls a runtime worker thread, and the ingest path
292    /// runs it twice per delivery while the metrics route runs it per request.
293    /// What: `spawn_blocking` over a cloned [`Spool`] (a `PathBuf` and a flag,
294    /// so cloning is free). A join failure — the blocking pool shutting down
295    /// mid-operation — is surfaced as an error rather than silently swallowed.
296    /// Test: exercised by every async case; the correctness of each operation
297    /// is covered by its own `spool_*` case.
298    async fn blocking<T, F>(&self, op: F) -> Result<T, SpoolError>
299    where
300        F: FnOnce(Spool) -> Result<T, SpoolError> + Send + 'static,
301        T: Send + 'static,
302    {
303        let spool = (*self.spool).clone();
304        match tokio::task::spawn_blocking(move || op(spool)).await {
305            Ok(result) => result,
306            Err(join) => Err(SpoolError::PrepareDir {
307                path: self.spool.root().to_path_buf(),
308                source: std::io::Error::other(format!("spool task did not complete: {join}")),
309            }),
310        }
311    }
312
313    /// Scan the spool and classify its health, now.
314    ///
315    /// Deliberately not cached — see [`health`]'s module docs. The scan is
316    /// filesystem work, so it runs off the async runtime.
317    pub async fn health(&self) -> SpoolHealth {
318        let red_after = self.red_after;
319        let now = now_unix_ms();
320        let spool = (*self.spool).clone();
321        let roots = Arc::clone(&self.inbox_roots);
322        match tokio::task::spawn_blocking(move || {
323            health::scan_health(&spool, now, red_after, &roots)
324        })
325        .await
326        {
327            Ok(health) => health,
328            // A scan that could not run is not a healthy spool.
329            Err(join) => health::scan_failed(
330                red_after,
331                format!("health scan task did not complete: {join}"),
332            ),
333        }
334    }
335
336    /// Verify, spool, relay — in that order.
337    ///
338    /// Why: the ordering IS the fix; see the module docs. In particular the
339    /// spool write happens before this function can return anything a caller
340    /// would turn into a `202`, and a relay failure never propagates as a
341    /// reason to drop the delivery.
342    ///
343    /// What: returns an [`IngestOutcome`]; performs no HTTP.
344    ///
345    /// Test: `ingest_rejects_an_unknown_source`,
346    /// `ingest_fails_closed_when_no_secret_is_configured`,
347    /// `ingest_rejects_a_forged_signature`,
348    /// `ingest_returns_spool_failed_and_never_accepts_when_the_write_fails`,
349    /// `ingest_accepts_and_deletes_on_an_explicit_ack`,
350    /// `relay_failure_leaves_a_pending_entry_with_an_incremented_attempt_count`.
351    pub async fn ingest(&self, source: &str, headers: &HeaderMap, body: &[u8]) -> IngestOutcome {
352        let Some(relay) = self.targets.get(source) else {
353            return IngestOutcome::UnknownSource {
354                source: source.to_string(),
355            };
356        };
357
358        // Step 2 — one verification, over the exact received bytes. Anything
359        // that re-frames the body first destroys the ability to check it.
360        let signature = header_str(headers, SIGNATURE_HEADER).unwrap_or_default();
361        match trusty_common::webhook_hmac::verify_github_signature(&self.secret, body, &signature) {
362            SignatureVerdict::Valid => {}
363            SignatureVerdict::SecretMissing => {
364                tracing::warn!(
365                    source,
366                    "{SECRET_ENV} is not set — refusing the delivery (fail-closed, ADR-0034 §2)"
367                );
368                return IngestOutcome::SecretMissing;
369            }
370            SignatureVerdict::Invalid => {
371                tracing::warn!(source, "webhook HMAC verification failed");
372                return IngestOutcome::InvalidSignature;
373            }
374        }
375
376        let received_at_unix_ms = now_unix_ms();
377        let delivery_id = header_str(headers, "x-github-delivery")
378            .filter(|v| !v.trim().is_empty())
379            .unwrap_or_else(|| format!("no-delivery-header-{received_at_unix_ms}"));
380
381        let mut entry = SpoolEntry {
382            schema_version: SPOOL_SCHEMA_VERSION,
383            delivery_id: delivery_id.clone(),
384            source: source.to_string(),
385            event: header_str(headers, "x-github-event").unwrap_or_default(),
386            headers: collect_headers(headers),
387            body_b64: BASE64.encode(body),
388            provenance: Provenance {
389                algorithm: HMAC_ALGORITHM.to_string(),
390                key_id: self.key_id.as_str().to_string(),
391                verified: true,
392            },
393            received_at_unix_ms,
394            attempts: 0,
395            last_error: None,
396            last_attempt_at_unix_ms: None,
397        };
398
399        // 🔴 Claim BEFORE the write, not after it. `entry_path` is a pure
400        // function of the receipt time and delivery id, both already fixed, so
401        // the path is known before the entry exists. Claiming afterwards left a
402        // window in which the entry was on disk and unclaimed — and the write
403        // now runs on the blocking pool, which widens that window to however
404        // long a worker thread takes to be scheduled. A sweep landing there
405        // relayed a delivery this request was about to relay itself. Measured,
406        // not theorised: with the claim taken after the write, a sweep 20 ms
407        // into `ingest` reports `acked: 1` for an entry the request path then
408        // relays again.
409        //
410        // Backoff's first-attempt grace also covers this window in production,
411        // but two guards that each fully close it is the point — a deployment
412        // that tunes the grace to zero must not reopen a double-relay.
413        //
414        // 🔴 No regression test pins this ordering. The window is one scheduler
415        // poll wide, and every deterministic probe tried against it — a
416        // rendezvous, a select! loop, a parallel hammer on a 4-worker runtime —
417        // passed against a claim-after-write build as readily as against this
418        // one. A test that cannot tell the two apart is worse than none, so
419        // none was kept. Do not reorder these two statements on the strength of
420        // a green suite.
421        let claim = self.claims.claim(&self.spool.entry_path(&entry));
422
423        // Step 3 — durable BEFORE the ack. A failure here is a 5xx, never a
424        // logged-and-accepted delivery. `persist_new` refuses to overwrite: the
425        // entry already at that path may be one console has acknowledged, and
426        // GitHub will never re-send it.
427        let to_write = entry.clone();
428        let path = match self
429            .blocking(move |spool| spool.persist_new(&to_write))
430            .await
431        {
432            Ok(path) => path,
433            Err(e) => {
434                let already = matches!(e, SpoolError::AlreadyExists { .. });
435                tracing::error!(
436                    source,
437                    delivery_id = %delivery_id,
438                    error = %e,
439                    already_spooled = already,
440                    "spool write failed — refusing the delivery so GitHub keeps it redeliverable"
441                );
442                return IngestOutcome::SpoolFailed {
443                    reason: format!("{e}"),
444                };
445            }
446        };
447
448        // Step 4 — relay, and record what happened durably either way, holding
449        // the claim taken before the write. It is released on drop, so a
450        // panicking relay cannot wedge the entry.
451        let outcome = match claim {
452            Some(_claim) => {
453                let outcome = relay.deliver(&entry).await;
454                let bookkeeping_error = self.settle(&path, &mut entry, &outcome).await;
455                return IngestOutcome::Accepted {
456                    delivery_id,
457                    relay: outcome,
458                    bookkeeping_error,
459                };
460            }
461            // Someone else is already relaying this exact path. The delivery is
462            // durable, so acknowledging is still correct; the in-flight relay
463            // (or the next sweep) settles it.
464            None => RelayOutcome::Unreachable {
465                reason: "another relay for this entry is already in flight".to_string(),
466            },
467        };
468
469        IngestOutcome::Accepted {
470            delivery_id,
471            relay: outcome,
472            bookkeeping_error: None,
473        }
474    }
475
476    /// Apply a relay outcome to the spool: delete on an explicit ack, otherwise
477    /// bump the attempt count.
478    ///
479    /// Why: the single place a spool entry can be removed, so "connection
480    /// succeeded" can never be mistaken for "work acknowledged".
481    /// What: returns `Some(reason)` when the bookkeeping write itself failed.
482    /// The delivery stays durable in that case — a failed delete leaves an entry
483    /// the sweep will retry (a duplicate delivery, which is recoverable), and a
484    /// failed attempt-bump leaves the count stale (visible as a growing age).
485    /// Both are the safe direction.
486    /// Test: `ingest_accepts_and_deletes_on_an_explicit_ack`,
487    /// `relay_failure_leaves_a_pending_entry_with_an_incremented_attempt_count`.
488    async fn settle(
489        &self,
490        path: &std::path::Path,
491        entry: &mut SpoolEntry,
492        outcome: &RelayOutcome,
493    ) -> Option<String> {
494        if outcome.is_acked() {
495            let acked_path = path.to_path_buf();
496            return match self
497                .blocking(move |spool| spool.remove_acked(&acked_path))
498                .await
499            {
500                Ok(()) => None,
501                Err(e) => {
502                    tracing::error!(
503                        delivery_id = %entry.delivery_id,
504                        error = %e,
505                        "target acknowledged but the spool entry could not be removed; \
506                         the retry sweep will redeliver it"
507                    );
508                    Some(format!("{e}"))
509                }
510            };
511        }
512
513        let mut updated = entry.clone();
514        let reason = outcome.reason().to_string();
515        let now = now_unix_ms();
516        let written = self
517            .blocking(move |spool| {
518                spool
519                    .record_attempt(&mut updated, reason, now)
520                    .map(|_| updated)
521            })
522            .await;
523        match written {
524            Ok(after) => {
525                *entry = after;
526                tracing::warn!(
527                    delivery_id = %entry.delivery_id,
528                    attempts = entry.attempts,
529                    reason = outcome.reason(),
530                    "relay did not acknowledge; entry stays pending"
531                );
532                None
533            }
534            Err(e) => {
535                tracing::error!(
536                    delivery_id = %entry.delivery_id,
537                    error = %e,
538                    "relay failed AND the attempt count could not be recorded; \
539                     the entry is still on disk and still pending"
540                );
541                Some(format!("{e}"))
542            }
543        }
544    }
545
546    /// Re-attempt every pending delivery that is due, once.
547    ///
548    /// Why: ADR-0034 §2 — "Console retries with backoff." Three guards, each
549    /// closing a different failure:
550    ///
551    /// - **Backoff** ([`BackoffPolicy::is_due`]) — without it every pending
552    ///   entry is re-relayed on every tick and each non-ack rewrites the whole
553    ///   base64 body plus two `fsync`s. Until step 4 binds a listener that is
554    ///   every delivery, forever.
555    /// - **Claims** ([`schedule::ClaimSet`]) — without them a tick landing
556    ///   inside the ≤5 s relay window sends a delivery the request path is
557    ///   still sending. One delivery, two relays.
558    /// - **[`SWEEP_BUDGET`]** — the pass is serial and each relay can burn its
559    ///   full timeout, so an unbounded pass can outlast its own tick interval.
560    ///
561    /// Nothing any guard skips is dropped: it stays pending, durable, and
562    /// visible to [`WebhookIngress::health`], which scans on the request rather
563    /// than trusting this loop to still be alive.
564    ///
565    /// What: relays each due entry, deleting only on an explicit ack. Returns
566    /// per-sweep counts.
567    ///
568    /// Test: `retry_sweep_acks_and_clears_a_pending_entry`,
569    /// `retry_sweep_leaves_an_unrelayable_entry_pending_with_more_attempts`,
570    /// `sweep_does_not_relay_an_entry_the_request_path_is_still_relaying`,
571    /// `sweep_honours_backoff_between_ticks`,
572    /// `sweep_stops_relaying_an_exhausted_entry`.
573    pub async fn retry_pending_once(&self) -> SweepReport {
574        let listing = match self.blocking(|spool| spool.list_pending()).await {
575            Ok(listing) => listing,
576            Err(e) => {
577                tracing::error!(error = %e, "webhook retry sweep could not read the spool");
578                return SweepReport {
579                    scan_error: Some(format!("{e}")),
580                    ..SweepReport::default()
581                };
582            }
583        };
584
585        let mut report = SweepReport {
586            undecodable: listing.undecodable.len(),
587            ..SweepReport::default()
588        };
589        let now = now_unix_ms();
590        for pending in listing.pending {
591            if report.acked + report.still_pending >= SWEEP_BUDGET {
592                report.deferred += 1;
593                continue;
594            }
595            let mut entry = pending.entry;
596            let Some(relay) = self.targets.get(&entry.source) else {
597                // A target removed from the config leaves its deliveries on
598                // disk rather than dropping them; the age turns the health
599                // state red, which is the correct operator signal.
600                report.orphaned += 1;
601                continue;
602            };
603            if self.backoff.is_exhausted(&entry) {
604                // Move it out of the live set. Leaving it here would make every
605                // later sweep and every metrics request read and decode it
606                // forever, and would pin the oldest-pending diagnostics to it so
607                // a genuinely new failure changed nothing an operator reads.
608                // It is kept, not deleted — it is still an unacknowledged
609                // webhook, and it keeps the health signal red.
610                report.exhausted += 1;
611                let quarantine_path = pending.path.clone();
612                if let Err(e) = self
613                    .blocking(move |spool| spool.quarantine(&quarantine_path))
614                    .await
615                {
616                    tracing::error!(
617                        delivery_id = %entry.delivery_id,
618                        error = %e,
619                        "could not move an exhausted entry aside; it stays in the live set"
620                    );
621                    report.bookkeeping_failures += 1;
622                }
623                continue;
624            }
625            if !self.backoff.is_due(&entry, now) {
626                report.not_due += 1;
627                continue;
628            }
629            let Some(_claim) = self.claims.claim(&pending.path) else {
630                report.in_flight += 1;
631                continue;
632            };
633
634            let outcome = relay.deliver(&entry).await;
635            if outcome.is_acked() {
636                report.acked += 1;
637            } else {
638                report.still_pending += 1;
639            }
640            if self
641                .settle(&pending.path, &mut entry, &outcome)
642                .await
643                .is_some()
644            {
645                report.bookkeeping_failures += 1;
646            }
647        }
648        report
649    }
650}
651
652/// Counts from one [`WebhookIngress::retry_pending_once`] pass.
653///
654/// Every pending entry lands in exactly one bucket, so the counts account for
655/// the whole spool — a delivery that vanishes from all of them is a bug the
656/// tests can see.
657#[derive(Debug, Clone, Default, PartialEq, Eq)]
658pub struct SweepReport {
659    /// Entries a target acknowledged and that were removed.
660    pub acked: usize,
661    /// Entries relayed this pass that stayed pending.
662    pub still_pending: usize,
663    /// Entries not yet eligible under the backoff schedule.
664    pub not_due: usize,
665    /// Entries past `max_attempts` — never retried again, never deleted, and
666    /// holding the health signal red until an operator intervenes.
667    pub exhausted: usize,
668    /// Entries another relay was already handling.
669    pub in_flight: usize,
670    /// Entries left for the next tick by [`SWEEP_BUDGET`].
671    pub deferred: usize,
672    /// Entries whose `source` no longer maps to a configured target.
673    pub orphaned: usize,
674    /// Entries on disk that could not be decoded.
675    pub undecodable: usize,
676    /// Entries whose post-relay spool write failed.
677    pub bookkeeping_failures: usize,
678    /// Set when the spool itself could not be listed.
679    pub scan_error: Option<String>,
680}
681
682/// `POST /api/webhooks/{source}` — the axum front door.
683///
684/// Why: a thin mapping from [`IngestOutcome`] to a status code, so the ordering
685/// guarantee lives in [`WebhookIngress::ingest`] and cannot be broken by an
686/// edit to the HTTP layer.
687/// What: `404` unknown source, `401` both refusal arms, `500` spool failure,
688/// `202` once the delivery is durable. The body reports the relay state so an
689/// operator can see a pending delivery without opening the dashboard.
690/// Test: `route_returns_500_and_no_ack_when_the_spool_write_fails`,
691/// `route_returns_401_for_an_unset_secret`, `route_returns_202_after_a_durable_write`.
692pub async fn webhook_handler(
693    State(ingress): State<WebhookIngress>,
694    Path(source): Path<String>,
695    headers: HeaderMap,
696    body: Bytes,
697) -> axum::response::Response {
698    match ingress.ingest(&source, &headers, &body).await {
699        IngestOutcome::UnknownSource { source } => (
700            StatusCode::NOT_FOUND,
701            axum::Json(json!({"error": "unknown webhook source", "source": source})),
702        )
703            .into_response(),
704        // One uniform 401 for both refusals: the response must not tell an
705        // unauthenticated caller whether a secret is configured.
706        IngestOutcome::SecretMissing | IngestOutcome::InvalidSignature => (
707            StatusCode::UNAUTHORIZED,
708            axum::Json(json!({"error": "signature verification failed"})),
709        )
710            .into_response(),
711        IngestOutcome::SpoolFailed { reason } => (
712            StatusCode::INTERNAL_SERVER_ERROR,
713            axum::Json(json!({
714                "error": "could not durably record the delivery; not acknowledged",
715                "detail": reason,
716            })),
717        )
718            .into_response(),
719        IngestOutcome::Accepted {
720            delivery_id,
721            relay,
722            bookkeeping_error,
723        } => (
724            StatusCode::ACCEPTED,
725            axum::Json(json!({
726                "status": "accepted",
727                "delivery_id": delivery_id,
728                "relay": if relay.is_acked() { "acknowledged" } else { "pending" },
729                "detail": relay.reason(),
730                "bookkeeping_error": bookkeeping_error,
731            })),
732        )
733            .into_response(),
734    }
735}
736
737/// `GET /api/console/metrics/webhooks` — oldest-pending-age as a health state.
738///
739/// Why: ADR-0034 §2 requires the signal on console's existing metrics surface,
740/// red once a delivery has been pending too long.
741/// What: scans the spool on this request and returns a `ConsoleMetricsReport`.
742/// Always `200`, never `503`: a red report is information, and a `503` would be
743/// indistinguishable from "no data yet" — which is the fail-quiet reading this
744/// signal exists to prevent.
745/// Test: `metrics_route_reports_red_for_an_aged_pending_entry`,
746/// `metrics_route_reports_ok_on_an_empty_spool`.
747pub async fn metrics_webhooks_handler(
748    State(ingress): State<WebhookIngress>,
749) -> axum::response::Response {
750    axum::Json(health::to_report(&ingress.health().await)).into_response()
751}
752
753/// Milliseconds since the Unix epoch, saturating at 0 before it.
754fn now_unix_ms() -> u64 {
755    SystemTime::now()
756        .duration_since(UNIX_EPOCH)
757        .map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64)
758        .unwrap_or(0)
759}
760
761/// One header value as a `String`, lowercased name, `None` when absent or not
762/// valid UTF-8.
763fn header_str(headers: &HeaderMap, name: &str) -> Option<String> {
764    headers
765        .get(name)
766        .and_then(|v| v.to_str().ok())
767        .map(str::to_string)
768}
769
770/// Every header worth spooling, lowercased, minus [`HEADER_DENYLIST`].
771fn collect_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
772    headers
773        .iter()
774        .filter_map(|(name, value)| {
775            let name = name.as_str().to_ascii_lowercase();
776            if HEADER_DENYLIST.contains(&name.as_str()) {
777                return None;
778            }
779            value.to_str().ok().map(|v| (name, v.to_string()))
780        })
781        .collect()
782}
783
784/// The spool root, for callers that need it before an ingress exists.
785pub fn default_spool_root() -> anyhow::Result<PathBuf> {
786    Spool::default_root()
787}
788
789/// Run [`WebhookIngress::retry_pending_once`] on an interval, forever.
790///
791/// Why: recovery for entries whose first relay failed — expected for every
792/// delivery until #5089 step 4 binds the targets' listeners.
793/// What: spawns a detached task. Deliberately NOT the detection path: if this
794/// task dies, `GET /api/console/metrics/webhooks` still turns red, because it
795/// scans the spool on the request rather than reading anything this loop wrote.
796/// Test: the sweep body is tested directly via `retry_sweep_*`; the timer
797/// wrapper carries no logic to test.
798pub fn start_retry_sweep(ingress: WebhookIngress, interval: Duration) {
799    tokio::spawn(async move {
800        let mut ticker = tokio::time::interval(interval);
801        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
802        loop {
803            ticker.tick().await;
804            let report = ingress.retry_pending_once().await;
805            if report.acked > 0
806                || report.still_pending > 0
807                || report.exhausted > 0
808                || report.scan_error.is_some()
809            {
810                tracing::info!(
811                    acked = report.acked,
812                    still_pending = report.still_pending,
813                    not_due = report.not_due,
814                    exhausted = report.exhausted,
815                    in_flight = report.in_flight,
816                    deferred = report.deferred,
817                    orphaned = report.orphaned,
818                    undecodable = report.undecodable,
819                    "webhook retry sweep completed"
820                );
821            }
822        }
823    });
824}