Skip to main content

r402_http/server/
middleware.rs

1//! Axum middleware for enforcing [x402](https://www.x402.org) payments on protected routes.
2//!
3//! This middleware validates incoming payment headers using a configured x402 facilitator,
4//! verifies the payment, executes the request, and settles valid payments after successful
5//! execution. If the handler returns an error (4xx/5xx), settlement is skipped.
6//!
7//! Returns a `402 Payment Required` response if the request lacks a valid payment.
8//!
9//! ## Settlement Modes
10//!
11//! - **[`SettlementMode::Sequential`]** (default): verify → execute → settle.
12//!   Safer — settlement only runs after the handler succeeds.
13//! - **[`SettlementMode::Concurrent`]**: verify → (settle ∥ execute) → await settle.
14//!   Lower latency — overlaps settlement with handler execution.
15//! - **[`SettlementMode::Background`]**: verify → spawn settle → execute → return.
16//!   Fire-and-forget — ideal for streaming responses.
17//!
18//! ## Configuration Notes
19//!
20//! - **[`X402Middleware::with_price_tag`]** sets the assets and amounts accepted for payment (static pricing).
21//! - **[`X402Middleware::with_dynamic_price`]** sets a callback for dynamic pricing based on request context.
22//! - **[`X402Middleware::with_base_url`]** sets the base URL for computing full resource URLs.
23//!   If not set, defaults to `http://localhost/` (avoid in production).
24//! - **[`X402Layer::with_settlement_mode`]** selects sequential or concurrent settlement.
25//! - **[`X402Layer::with_description`]** is optional but helps the payer understand what is being paid for.
26//! - **[`X402Layer::with_mime_type`]** sets the MIME type of the protected resource (default: `application/json`).
27//! - **[`X402Layer::with_resource`]** explicitly sets the full URI of the protected resource.
28//!
29
30use std::convert::Infallible;
31use std::future::Future;
32use std::pin::Pin;
33use std::sync::Arc;
34use std::task::{Context, Poll};
35use std::time::Duration;
36
37use axum_core::extract::Request;
38use axum_core::response::Response;
39use http::{HeaderMap, Uri};
40use r402_core::facilitator::Facilitator;
41use r402_core::wire;
42use tower::util::BoxCloneSyncService;
43use tower::{Layer, Service};
44use url::Url;
45
46use super::facilitator::{FacilitatorClient, FacilitatorClientError};
47use super::hooks::{DynPaygateHooks, PaygateHooks, ProtectedRequestOutcome};
48use super::paygate::{Paygate, ResourceTemplate};
49use super::pricing::{DynamicPriceTags, PriceTagSource, StaticPriceTags};
50
51/// Controls when on-chain settlement executes relative to the inner service.
52///
53/// # Variants
54///
55/// - **Sequential** (default): verify → execute → settle.  Settlement only
56///   runs after the handler returns a successful response.  This is the
57///   safest option — no settlement occurs on handler errors.
58///
59/// - **Concurrent**: verify → (settle ∥ execute) → await settle.  Settlement
60///   is spawned immediately after verification and runs in parallel with the
61///   handler, reducing total request latency by one facilitator RTT.
62///   On handler error the settlement task is detached (fire-and-forget).
63///
64/// - **Background**: verify → spawn settle (fire-and-forget) → execute → return.
65///   Settlement runs entirely in the background — the response is returned to
66///   the client immediately after the handler completes, without waiting for
67///   settlement.  Ideal for **streaming** responses (e.g. SSE / LLM token
68///   streams) where the client should start receiving data as soon as possible.
69///   **Trade-off:** the `Payment-Response` header is not attached since settlement
70///   may still be in progress when the response is sent.
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
72pub enum SettlementMode {
73    /// Settlement runs **after** the handler completes.
74    #[default]
75    Sequential,
76    /// Settlement runs **concurrently** with the handler; response waits for settlement.
77    Concurrent,
78    /// Settlement is fire-and-forget; response is returned immediately.
79    Background,
80}
81
82/// The main X402 middleware instance for enforcing x402 payments on routes.
83///
84/// Create a single instance per application and use it to build payment layers
85/// for protected routes.
86pub struct X402Middleware<F> {
87    facilitator: F,
88    base_url: Option<Url>,
89}
90
91impl<F: Clone> Clone for X402Middleware<F> {
92    fn clone(&self) -> Self {
93        Self {
94            facilitator: self.facilitator.clone(),
95            base_url: self.base_url.clone(),
96        }
97    }
98}
99
100impl<F: std::fmt::Debug> std::fmt::Debug for X402Middleware<F> {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("X402Middleware")
103            .field("facilitator", &self.facilitator)
104            .field("base_url", &self.base_url)
105            .finish()
106    }
107}
108
109impl<F> X402Middleware<F> {
110    /// Creates a middleware instance from any facilitator implementation.
111    ///
112    /// Use this when you already have a configured facilitator (e.g. one
113    /// with custom timeouts, caching, or a non-default HTTP client).
114    #[must_use]
115    pub const fn from_facilitator(facilitator: F) -> Self {
116        Self {
117            facilitator,
118            base_url: None,
119        }
120    }
121
122    /// Returns a reference to the underlying facilitator.
123    pub const fn facilitator(&self) -> &F {
124        &self.facilitator
125    }
126}
127
128impl X402Middleware<Arc<FacilitatorClient>> {
129    /// Creates a new middleware instance with a facilitator URL.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`FacilitatorClientError`] if the URL cannot be parsed or the
134    /// derived `/verify`, `/settle`, and `/supported` endpoints cannot be
135    /// constructed.
136    pub fn try_new(url: &str) -> Result<Self, FacilitatorClientError> {
137        let facilitator = FacilitatorClient::try_from(url)?;
138        Ok(Self {
139            facilitator: Arc::new(facilitator),
140            base_url: None,
141        })
142    }
143
144    /// Returns the configured facilitator URL.
145    #[must_use]
146    pub fn facilitator_url(&self) -> &Url {
147        self.facilitator.base_url()
148    }
149
150    /// Sets the TTL for caching the facilitator's supported response.
151    ///
152    /// Default is 10 minutes. Use [`FacilitatorClient::without_supported_cache()`]
153    /// to disable caching entirely.
154    #[must_use]
155    pub fn with_supported_cache_ttl(&self, ttl: Duration) -> Self {
156        let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
157        let facilitator = Arc::new(inner.with_supported_cache_ttl(ttl));
158        Self {
159            facilitator,
160            base_url: self.base_url.clone(),
161        }
162    }
163
164    /// Sets a per-request timeout for all facilitator HTTP calls (verify, settle, supported).
165    ///
166    /// Without this, the underlying `reqwest::Client` uses no timeout by default,
167    /// which can cause requests to hang indefinitely if the facilitator is slow
168    /// or unreachable, eventually triggering OS-level TCP timeouts (typically 2–5 minutes).
169    ///
170    /// A reasonable production value is 30 seconds.
171    #[must_use]
172    pub fn with_facilitator_timeout(&self, timeout: Duration) -> Self {
173        let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
174        let facilitator = Arc::new(inner.with_timeout(timeout));
175        Self {
176            facilitator,
177            base_url: self.base_url.clone(),
178        }
179    }
180}
181
182impl TryFrom<&str> for X402Middleware<Arc<FacilitatorClient>> {
183    type Error = FacilitatorClientError;
184
185    fn try_from(value: &str) -> Result<Self, Self::Error> {
186        Self::try_new(value)
187    }
188}
189
190impl TryFrom<String> for X402Middleware<Arc<FacilitatorClient>> {
191    type Error = FacilitatorClientError;
192
193    fn try_from(value: String) -> Result<Self, Self::Error> {
194        Self::try_new(&value)
195    }
196}
197
198impl<F> X402Middleware<F>
199where
200    F: Clone,
201{
202    /// Sets the base URL used to construct resource URLs dynamically.
203    ///
204    /// If [`X402Layer::with_resource`] is not called, this base URL is combined with
205    /// each request's path/query to compute the resource. If not set, defaults to `http://localhost/`.
206    ///
207    /// In production, prefer calling `with_resource` or setting a precise `base_url`.
208    #[must_use]
209    pub fn with_base_url(&self, base_url: Url) -> Self {
210        let mut this = self.clone();
211        this.base_url = Some(base_url);
212        this
213    }
214}
215
216impl<TFacilitator> X402Middleware<TFacilitator>
217where
218    TFacilitator: Clone,
219{
220    /// Sets the price tag for the protected route.
221    ///
222    /// Creates a layer builder that can be further configured with additional
223    /// price tags and resource information.
224    #[must_use]
225    pub fn with_price_tag(
226        &self,
227        price_tag: wire::PriceTag,
228    ) -> X402Layer<StaticPriceTags, TFacilitator> {
229        X402Layer {
230            facilitator: self.facilitator.clone(),
231            price_source: StaticPriceTags::new(vec![price_tag]),
232            base_url: self.base_url.clone().map(Arc::new),
233            resource: Arc::new(ResourceTemplate::default()),
234            settlement_mode: SettlementMode::default(),
235            hooks: None,
236        }
237    }
238
239    /// Sets multiple price tags for the protected route.
240    ///
241    /// Convenience method for services that accept several payment options
242    /// (e.g. multiple tokens / networks).  Returns an empty-bypass builder
243    /// when the list is empty — the middleware will pass requests through
244    /// without payment enforcement.
245    #[must_use]
246    pub fn with_price_tags(
247        &self,
248        price_tags: Vec<wire::PriceTag>,
249    ) -> X402Layer<StaticPriceTags, TFacilitator> {
250        X402Layer {
251            facilitator: self.facilitator.clone(),
252            price_source: StaticPriceTags::new(price_tags),
253            base_url: self.base_url.clone().map(Arc::new),
254            resource: Arc::new(ResourceTemplate::default()),
255            settlement_mode: SettlementMode::default(),
256            hooks: None,
257        }
258    }
259
260    /// Sets a dynamic price source for the protected route.
261    ///
262    /// The `callback` receives request headers, URI, and base URL, and returns
263    /// a vector of V2 price tags.
264    #[must_use]
265    pub fn with_dynamic_price<F, Fut>(
266        &self,
267        callback: F,
268    ) -> X402Layer<DynamicPriceTags, TFacilitator>
269    where
270        F: Fn(&HeaderMap, &Uri, Option<&Url>) -> Fut + Send + Sync + 'static,
271        Fut: Future<Output = Vec<wire::PriceTag>> + Send + 'static,
272    {
273        X402Layer {
274            facilitator: self.facilitator.clone(),
275            price_source: DynamicPriceTags::new(callback),
276            base_url: self.base_url.clone().map(Arc::new),
277            resource: Arc::new(ResourceTemplate::default()),
278            settlement_mode: SettlementMode::default(),
279            hooks: None,
280        }
281    }
282}
283
284/// Builder for configuring the X402 middleware layer.
285///
286/// Generic over `TSource` which implements [`PriceTagSource`] to support
287/// both static and dynamic pricing strategies.
288#[derive(Clone)]
289#[allow(
290    missing_debug_implementations,
291    reason = "generic types may not impl Debug"
292)]
293pub struct X402Layer<TSource, TFacilitator> {
294    facilitator: TFacilitator,
295    base_url: Option<Arc<Url>>,
296    price_source: TSource,
297    resource: Arc<ResourceTemplate>,
298    settlement_mode: SettlementMode,
299    hooks: Option<Arc<dyn DynPaygateHooks>>,
300}
301
302impl<TFacilitator> X402Layer<StaticPriceTags, TFacilitator> {
303    /// Adds another payment option.
304    ///
305    /// Allows specifying multiple accepted payment methods (e.g., different networks).
306    ///
307    /// Note: This method is only available for static price tag sources.
308    #[must_use]
309    pub fn with_price_tag(mut self, price_tag: wire::PriceTag) -> Self {
310        self.price_source = self.price_source.with_price_tag(price_tag);
311        self
312    }
313}
314
315#[allow(
316    missing_debug_implementations,
317    reason = "generic types may not impl Debug"
318)]
319impl<TSource, TFacilitator> X402Layer<TSource, TFacilitator> {
320    /// Sets a description of what the payment grants access to.
321    ///
322    /// This is included in 402 responses to inform clients what they're paying for.
323    #[must_use]
324    pub fn with_description(mut self, description: String) -> Self {
325        let mut new_resource = (*self.resource).clone();
326        new_resource.description = description;
327        self.resource = Arc::new(new_resource);
328        self
329    }
330
331    /// Sets the MIME type of the protected resource.
332    ///
333    /// Defaults to `application/json` if not specified.
334    #[must_use]
335    pub fn with_mime_type(mut self, mime: String) -> Self {
336        let mut new_resource = (*self.resource).clone();
337        new_resource.mime_type = mime;
338        self.resource = Arc::new(new_resource);
339        self
340    }
341
342    /// Sets the full URL of the protected resource.
343    ///
344    /// When set, this URL is used directly instead of constructing it from the base URL
345    /// and request URI. This is the preferred approach in production.
346    #[must_use]
347    #[allow(
348        clippy::needless_pass_by_value,
349        reason = "Url consumed via to_string()"
350    )]
351    pub fn with_resource(mut self, resource: Url) -> Self {
352        let mut new_resource = (*self.resource).clone();
353        new_resource.url = Some(resource.to_string());
354        self.resource = Arc::new(new_resource);
355        self
356    }
357
358    /// Sets the settlement mode.
359    ///
360    /// - [`SettlementMode::Sequential`] (default): verify → execute → settle.
361    /// - [`SettlementMode::Concurrent`]: verify → (settle ∥ execute) → await settle.
362    /// - [`SettlementMode::Background`]: verify → spawn settle → execute → return.
363    ///
364    /// Concurrent mode reduces total latency by overlapping settlement with
365    /// handler execution. Background mode is ideal for streaming responses
366    /// where the client should receive data immediately (settlement errors
367    /// are logged but do not propagate).
368    #[must_use]
369    pub const fn with_settlement_mode(mut self, mode: SettlementMode) -> Self {
370        self.settlement_mode = mode;
371        self
372    }
373
374    /// Attaches [`PaygateHooks`] for pre- and post-payment extensibility.
375    ///
376    /// Hooks fire at the HTTP layer (before and after the x402 payment check)
377    /// and let integrators bypass payment for API-key holders, enforce
378    /// IP allow-lists, or short-circuit with a custom response.
379    ///
380    /// For transport-agnostic verify/settle lifecycle (including
381    /// `on_verified_payment_canceled`), register hooks on a
382    /// [`r402_core::ResourceServer`] and build the paygate with
383    /// [`super::paygate::Paygate::builder_from_server`], or use
384    /// [`super::paygate::PaygateBuilder::with_resource_hook`] on a manual
385    /// [`Paygate`].
386    #[must_use]
387    pub fn with_hooks<H>(mut self, hooks: H) -> Self
388    where
389        H: PaygateHooks + 'static,
390    {
391        self.hooks = Some(Arc::new(hooks));
392        self
393    }
394}
395
396impl<S, TSource, TFacilitator> Layer<S> for X402Layer<TSource, TFacilitator>
397where
398    S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + Sync + 'static,
399    S::Future: Send + 'static,
400    TFacilitator: Facilitator + Clone,
401    TSource: PriceTagSource,
402{
403    type Service = X402MiddlewareService<TSource, TFacilitator>;
404
405    fn layer(&self, inner: S) -> Self::Service {
406        X402MiddlewareService {
407            facilitator: self.facilitator.clone(),
408            base_url: self.base_url.clone(),
409            price_source: self.price_source.clone(),
410            resource: Arc::clone(&self.resource),
411            settlement_mode: self.settlement_mode,
412            hooks: self.hooks.clone(),
413            inner: BoxCloneSyncService::new(inner),
414        }
415    }
416}
417
418/// Axum service that enforces x402 payments on incoming requests.
419///
420/// Generic over `TSource` which implements [`PriceTagSource`] to support
421/// both static and dynamic pricing strategies.
422#[derive(Clone)]
423#[allow(
424    missing_debug_implementations,
425    reason = "BoxCloneSyncService does not impl Debug"
426)]
427pub struct X402MiddlewareService<TSource, TFacilitator> {
428    /// Payment facilitator (local or remote)
429    facilitator: TFacilitator,
430    /// Base URL for constructing resource URLs
431    base_url: Option<Arc<Url>>,
432    /// Price tag source - can be static or dynamic
433    price_source: TSource,
434    /// Resource information
435    resource: Arc<ResourceTemplate>,
436    /// Settlement strategy (sequential, concurrent, or background)
437    settlement_mode: SettlementMode,
438    /// Optional paygate lifecycle hooks (Fix-8)
439    hooks: Option<Arc<dyn DynPaygateHooks>>,
440    /// The inner Axum service being wrapped
441    inner: BoxCloneSyncService<Request, Response, Infallible>,
442}
443
444impl<TSource, TFacilitator> Service<Request> for X402MiddlewareService<TSource, TFacilitator>
445where
446    TSource: PriceTagSource,
447    TFacilitator: Facilitator + Clone + Send + Sync + 'static,
448{
449    type Response = Response;
450    type Error = Infallible;
451    type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
452
453    /// Delegates readiness polling to the wrapped inner service.
454    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
455        self.inner.poll_ready(cx)
456    }
457
458    /// Intercepts the request, injects payment enforcement logic, and forwards to the wrapped service.
459    #[allow(
460        clippy::excessive_nesting,
461        reason = "async move + match inside Box::pin is the idiomatic Service::call shape"
462    )]
463    fn call(&mut self, req: Request) -> Self::Future {
464        let price_source = self.price_source.clone();
465        let facilitator = self.facilitator.clone();
466        let base_url = self.base_url.clone();
467        let resource_builder = Arc::clone(&self.resource);
468        let settlement_mode = self.settlement_mode;
469        let hooks = self.hooks.clone();
470        let mut inner = self.inner.clone();
471
472        Box::pin(async move {
473            // Fix-8: dispatch on_protected_request before the payment check.
474            // Hooks may grant access, short-circuit with an error, or let
475            // the standard flow continue.
476            let mut req = req;
477            if let Some(h) = hooks.as_ref() {
478                match h.on_protected_request(&req).await {
479                    ProtectedRequestOutcome::Continue => {}
480                    ProtectedRequestOutcome::GrantAccess => return inner.call(req).await,
481                    ProtectedRequestOutcome::Abort { status, body } => {
482                        return Ok(build_abort_response(status, body));
483                    }
484                }
485            }
486
487            // Resolve price tags from the source
488            let accepts = price_source
489                .resolve(req.headers(), req.uri(), base_url.as_deref())
490                .await;
491
492            // If no price tags are configured, bypass payment enforcement
493            if accepts.is_empty() {
494                return inner.call(req).await;
495            }
496
497            let resource = resource_builder.resolve(base_url.as_deref(), &req);
498
499            let mut gate_builder = Paygate::builder(facilitator)
500                .accepts(accepts)
501                .resource(resource);
502            if let Some(h) = hooks.as_ref() {
503                gate_builder = gate_builder.hooks_dyn(Arc::clone(h));
504            }
505            let mut gate = gate_builder.build();
506            gate.enrich_accepts().await;
507
508            // Fix-8: after the paygate verifies the payment, fire
509            // on_payment_verified so hooks can stamp request extensions
510            // (e.g. payer address) for downstream handlers.
511            if let Some(h) = hooks.as_ref() {
512                h.on_payment_verified(&mut req).await;
513            }
514
515            let result = match settlement_mode {
516                SettlementMode::Sequential => gate.handle_request(inner, req).await,
517                SettlementMode::Concurrent => gate.handle_request_concurrent(inner, req).await,
518                SettlementMode::Background => gate.handle_request_background(inner, req).await,
519            };
520            Ok(result.unwrap_or_else(|err| gate.error_response(err)))
521        })
522    }
523}
524
525/// Constructs an abort response from a [`PaygateHooks`] short-circuit.
526fn build_abort_response(status: http::StatusCode, body: Option<String>) -> Response {
527    let mut response = Response::new(axum_core::body::Body::from(body.unwrap_or_default()));
528    *response.status_mut() = status;
529    if let Ok(ct) = http::HeaderValue::from_str("text/plain; charset=utf-8") {
530        let _ = response
531            .headers_mut()
532            .insert(http::header::CONTENT_TYPE, ct);
533    }
534    super::paygate::ensure_expose_headers(response.headers_mut());
535    response
536}
537
538#[cfg(test)]
539#[allow(
540    clippy::expect_used,
541    clippy::unwrap_used,
542    clippy::panic,
543    reason = "test assertions on known-valid fixtures"
544)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn try_new_stores_parsed_facilitator_url() {
550        let input = "https://facilitator.example.com";
551        let middleware = X402Middleware::try_new(input).expect("valid facilitator URL");
552        let expected = Url::parse("https://facilitator.example.com/").expect("fixture URL");
553        assert_eq!(
554            middleware.facilitator_url(),
555            &expected,
556            "stored base URL must equal the parsed, slash-normalized input"
557        );
558        assert_eq!(
559            middleware.facilitator_url().as_str(),
560            "https://facilitator.example.com/"
561        );
562
563        let slashed = X402Middleware::try_new("https://facilitator.example.com/")
564            .expect("valid facilitator URL with trailing slash");
565        assert_eq!(slashed.facilitator_url(), middleware.facilitator_url());
566    }
567
568    #[test]
569    fn try_new_rejects_invalid_url() {
570        let err = X402Middleware::try_new("not a url");
571        assert!(
572            err.is_err(),
573            "invalid facilitator URL must return Err, not panic"
574        );
575        match err {
576            Err(FacilitatorClientError::UrlParse { context, .. }) => {
577                assert_eq!(context, "Failed to parse base url");
578            }
579            other => panic!("expected UrlParse, got {other:?}"),
580        }
581    }
582
583    #[test]
584    fn try_from_str_matches_try_new() {
585        let via_try_new =
586            X402Middleware::try_new("https://facilitator.example.com").expect("try_new");
587        let via_try_from =
588            X402Middleware::try_from("https://facilitator.example.com").expect("TryFrom<&str>");
589        assert_eq!(
590            via_try_new.facilitator_url(),
591            via_try_from.facilitator_url()
592        );
593    }
594}