Skip to main content

r402_http/server/
layer.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//! - **[`X402LayerBuilder::with_settlement_mode`]** selects sequential or concurrent settlement.
25//! - **[`X402LayerBuilder::with_description`]** is optional but helps the payer understand what is being paid for.
26//! - **[`X402LayerBuilder::with_mime_type`]** sets the MIME type of the protected resource (default: `application/json`).
27//! - **[`X402LayerBuilder::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;
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 default facilitator URL.
130    ///
131    /// # Panics
132    ///
133    /// Panics if the facilitator URL is invalid.
134    #[must_use]
135    #[allow(
136        clippy::expect_used,
137        reason = "constructor panics on invalid URL by design"
138    )]
139    pub fn new(url: &str) -> Self {
140        let facilitator = FacilitatorClient::try_from(url).expect("Invalid facilitator URL");
141        Self {
142            facilitator: Arc::new(facilitator),
143            base_url: None,
144        }
145    }
146
147    /// Creates a new middleware instance with a facilitator URL.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if the URL is invalid.
152    pub fn try_new(url: &str) -> Result<Self, Box<dyn std::error::Error>> {
153        let facilitator = FacilitatorClient::try_from(url)?;
154        Ok(Self {
155            facilitator: Arc::new(facilitator),
156            base_url: None,
157        })
158    }
159
160    /// Returns the configured facilitator URL.
161    #[must_use]
162    pub fn facilitator_url(&self) -> &Url {
163        self.facilitator.base_url()
164    }
165
166    /// Sets the TTL for caching the facilitator's supported response.
167    ///
168    /// Default is 10 minutes. Use [`FacilitatorClient::without_supported_cache()`]
169    /// to disable caching entirely.
170    #[must_use]
171    pub fn with_supported_cache_ttl(&self, ttl: Duration) -> Self {
172        let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
173        let facilitator = Arc::new(inner.with_supported_cache_ttl(ttl));
174        Self {
175            facilitator,
176            base_url: self.base_url.clone(),
177        }
178    }
179
180    /// Sets a per-request timeout for all facilitator HTTP calls (verify, settle, supported).
181    ///
182    /// Without this, the underlying `reqwest::Client` uses no timeout by default,
183    /// which can cause requests to hang indefinitely if the facilitator is slow
184    /// or unreachable, eventually triggering OS-level TCP timeouts (typically 2–5 minutes).
185    ///
186    /// A reasonable production value is 30 seconds.
187    #[must_use]
188    pub fn with_facilitator_timeout(&self, timeout: Duration) -> Self {
189        let inner = Arc::unwrap_or_clone(Arc::clone(&self.facilitator));
190        let facilitator = Arc::new(inner.with_timeout(timeout));
191        Self {
192            facilitator,
193            base_url: self.base_url.clone(),
194        }
195    }
196}
197
198impl TryFrom<&str> for X402Middleware<Arc<FacilitatorClient>> {
199    type Error = Box<dyn std::error::Error>;
200
201    fn try_from(value: &str) -> Result<Self, Self::Error> {
202        Self::try_new(value)
203    }
204}
205
206impl TryFrom<String> for X402Middleware<Arc<FacilitatorClient>> {
207    type Error = Box<dyn std::error::Error>;
208
209    fn try_from(value: String) -> Result<Self, Self::Error> {
210        Self::try_new(&value)
211    }
212}
213
214impl<F> X402Middleware<F>
215where
216    F: Clone,
217{
218    /// Sets the base URL used to construct resource URLs dynamically.
219    ///
220    /// If [`X402LayerBuilder::with_resource`] is not called, this base URL is combined with
221    /// each request's path/query to compute the resource. If not set, defaults to `http://localhost/`.
222    ///
223    /// In production, prefer calling `with_resource` or setting a precise `base_url`.
224    #[must_use]
225    pub fn with_base_url(&self, base_url: Url) -> Self {
226        let mut this = self.clone();
227        this.base_url = Some(base_url);
228        this
229    }
230}
231
232impl<TFacilitator> X402Middleware<TFacilitator>
233where
234    TFacilitator: Clone,
235{
236    /// Sets the price tag for the protected route.
237    ///
238    /// Creates a layer builder that can be further configured with additional
239    /// price tags and resource information.
240    #[must_use]
241    pub fn with_price_tag(
242        &self,
243        price_tag: wire::PriceTag,
244    ) -> X402LayerBuilder<StaticPriceTags, TFacilitator> {
245        X402LayerBuilder {
246            facilitator: self.facilitator.clone(),
247            price_source: StaticPriceTags::new(vec![price_tag]),
248            base_url: self.base_url.clone().map(Arc::new),
249            resource: Arc::new(ResourceTemplate::default()),
250            settlement_mode: SettlementMode::default(),
251            hooks: None,
252        }
253    }
254
255    /// Sets multiple price tags for the protected route.
256    ///
257    /// Convenience method for services that accept several payment options
258    /// (e.g. multiple tokens / networks).  Returns an empty-bypass builder
259    /// when the list is empty — the middleware will pass requests through
260    /// without payment enforcement.
261    #[must_use]
262    pub fn with_price_tags(
263        &self,
264        price_tags: Vec<wire::PriceTag>,
265    ) -> X402LayerBuilder<StaticPriceTags, TFacilitator> {
266        X402LayerBuilder {
267            facilitator: self.facilitator.clone(),
268            price_source: StaticPriceTags::new(price_tags),
269            base_url: self.base_url.clone().map(Arc::new),
270            resource: Arc::new(ResourceTemplate::default()),
271            settlement_mode: SettlementMode::default(),
272            hooks: None,
273        }
274    }
275
276    /// Sets a dynamic price source for the protected route.
277    ///
278    /// The `callback` receives request headers, URI, and base URL, and returns
279    /// a vector of V2 price tags.
280    #[must_use]
281    pub fn with_dynamic_price<F, Fut>(
282        &self,
283        callback: F,
284    ) -> X402LayerBuilder<DynamicPriceTags, TFacilitator>
285    where
286        F: Fn(&HeaderMap, &Uri, Option<&Url>) -> Fut + Send + Sync + 'static,
287        Fut: Future<Output = Vec<wire::PriceTag>> + Send + 'static,
288    {
289        X402LayerBuilder {
290            facilitator: self.facilitator.clone(),
291            price_source: DynamicPriceTags::new(callback),
292            base_url: self.base_url.clone().map(Arc::new),
293            resource: Arc::new(ResourceTemplate::default()),
294            settlement_mode: SettlementMode::default(),
295            hooks: None,
296        }
297    }
298}
299
300/// Builder for configuring the X402 middleware layer.
301///
302/// Generic over `TSource` which implements [`PriceTagSource`] to support
303/// both static and dynamic pricing strategies.
304#[derive(Clone)]
305#[allow(
306    missing_debug_implementations,
307    reason = "generic types may not impl Debug"
308)]
309pub struct X402LayerBuilder<TSource, TFacilitator> {
310    facilitator: TFacilitator,
311    base_url: Option<Arc<Url>>,
312    price_source: TSource,
313    resource: Arc<ResourceTemplate>,
314    settlement_mode: SettlementMode,
315    hooks: Option<Arc<dyn DynPaygateHooks>>,
316}
317
318impl<TFacilitator> X402LayerBuilder<StaticPriceTags, TFacilitator> {
319    /// Adds another payment option.
320    ///
321    /// Allows specifying multiple accepted payment methods (e.g., different networks).
322    ///
323    /// Note: This method is only available for static price tag sources.
324    #[must_use]
325    pub fn with_price_tag(mut self, price_tag: wire::PriceTag) -> Self {
326        self.price_source = self.price_source.with_price_tag(price_tag);
327        self
328    }
329}
330
331#[allow(
332    missing_debug_implementations,
333    reason = "generic types may not impl Debug"
334)]
335impl<TSource, TFacilitator> X402LayerBuilder<TSource, TFacilitator> {
336    /// Sets a description of what the payment grants access to.
337    ///
338    /// This is included in 402 responses to inform clients what they're paying for.
339    #[must_use]
340    pub fn with_description(mut self, description: String) -> Self {
341        let mut new_resource = (*self.resource).clone();
342        new_resource.description = description;
343        self.resource = Arc::new(new_resource);
344        self
345    }
346
347    /// Sets the MIME type of the protected resource.
348    ///
349    /// Defaults to `application/json` if not specified.
350    #[must_use]
351    pub fn with_mime_type(mut self, mime: String) -> Self {
352        let mut new_resource = (*self.resource).clone();
353        new_resource.mime_type = mime;
354        self.resource = Arc::new(new_resource);
355        self
356    }
357
358    /// Sets the full URL of the protected resource.
359    ///
360    /// When set, this URL is used directly instead of constructing it from the base URL
361    /// and request URI. This is the preferred approach in production.
362    #[must_use]
363    #[allow(
364        clippy::needless_pass_by_value,
365        reason = "Url consumed via to_string()"
366    )]
367    pub fn with_resource(mut self, resource: Url) -> Self {
368        let mut new_resource = (*self.resource).clone();
369        new_resource.url = Some(resource.to_string());
370        self.resource = Arc::new(new_resource);
371        self
372    }
373
374    /// Sets the settlement mode.
375    ///
376    /// - [`SettlementMode::Sequential`] (default): verify → execute → settle.
377    /// - [`SettlementMode::Concurrent`]: verify → (settle ∥ execute) → await settle.
378    /// - [`SettlementMode::Background`]: verify → spawn settle → execute → return.
379    ///
380    /// Concurrent mode reduces total latency by overlapping settlement with
381    /// handler execution. Background mode is ideal for streaming responses
382    /// where the client should receive data immediately (settlement errors
383    /// are logged but do not propagate).
384    #[must_use]
385    pub const fn with_settlement_mode(mut self, mode: SettlementMode) -> Self {
386        self.settlement_mode = mode;
387        self
388    }
389
390    /// Attaches [`PaygateHooks`] for pre- and post-payment extensibility.
391    ///
392    /// Hooks fire at the HTTP layer (before and after the x402 payment check)
393    /// and let integrators bypass payment for API-key holders, enforce
394    /// IP allow-lists, or short-circuit with a custom response.
395    #[must_use]
396    pub fn with_hooks<H>(mut self, hooks: H) -> Self
397    where
398        H: PaygateHooks + 'static,
399    {
400        self.hooks = Some(Arc::new(hooks));
401        self
402    }
403}
404
405impl<S, TSource, TFacilitator> Layer<S> for X402LayerBuilder<TSource, TFacilitator>
406where
407    S: Service<Request, Response = Response, Error = Infallible> + Clone + Send + Sync + 'static,
408    S::Future: Send + 'static,
409    TFacilitator: Facilitator + Clone,
410    TSource: PriceTagSource,
411{
412    type Service = X402MiddlewareService<TSource, TFacilitator>;
413
414    fn layer(&self, inner: S) -> Self::Service {
415        X402MiddlewareService {
416            facilitator: self.facilitator.clone(),
417            base_url: self.base_url.clone(),
418            price_source: self.price_source.clone(),
419            resource: Arc::clone(&self.resource),
420            settlement_mode: self.settlement_mode,
421            hooks: self.hooks.clone(),
422            inner: BoxCloneSyncService::new(inner),
423        }
424    }
425}
426
427/// Axum service that enforces x402 payments on incoming requests.
428///
429/// Generic over `TSource` which implements [`PriceTagSource`] to support
430/// both static and dynamic pricing strategies.
431#[derive(Clone)]
432#[allow(
433    missing_debug_implementations,
434    reason = "BoxCloneSyncService does not impl Debug"
435)]
436pub struct X402MiddlewareService<TSource, TFacilitator> {
437    /// Payment facilitator (local or remote)
438    facilitator: TFacilitator,
439    /// Base URL for constructing resource URLs
440    base_url: Option<Arc<Url>>,
441    /// Price tag source - can be static or dynamic
442    price_source: TSource,
443    /// Resource information
444    resource: Arc<ResourceTemplate>,
445    /// Settlement strategy (sequential, concurrent, or background)
446    settlement_mode: SettlementMode,
447    /// Optional paygate lifecycle hooks (Fix-8)
448    hooks: Option<Arc<dyn DynPaygateHooks>>,
449    /// The inner Axum service being wrapped
450    inner: BoxCloneSyncService<Request, Response, Infallible>,
451}
452
453impl<TSource, TFacilitator> Service<Request> for X402MiddlewareService<TSource, TFacilitator>
454where
455    TSource: PriceTagSource,
456    TFacilitator: Facilitator + Clone + Send + Sync + 'static,
457{
458    type Response = Response;
459    type Error = Infallible;
460    type Future = Pin<Box<dyn Future<Output = Result<Response, Infallible>> + Send>>;
461
462    /// Delegates readiness polling to the wrapped inner service.
463    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
464        self.inner.poll_ready(cx)
465    }
466
467    /// Intercepts the request, injects payment enforcement logic, and forwards to the wrapped service.
468    #[allow(
469        clippy::excessive_nesting,
470        reason = "async move + match inside Box::pin is the idiomatic Service::call shape"
471    )]
472    fn call(&mut self, req: Request) -> Self::Future {
473        let price_source = self.price_source.clone();
474        let facilitator = self.facilitator.clone();
475        let base_url = self.base_url.clone();
476        let resource_builder = Arc::clone(&self.resource);
477        let settlement_mode = self.settlement_mode;
478        let hooks = self.hooks.clone();
479        let mut inner = self.inner.clone();
480
481        Box::pin(async move {
482            // Fix-8: dispatch on_protected_request before the payment check.
483            // Hooks may grant access, short-circuit with an error, or let
484            // the standard flow continue.
485            let mut req = req;
486            if let Some(h) = hooks.as_ref() {
487                match h.on_protected_request(&req).await {
488                    ProtectedRequestOutcome::Continue => {}
489                    ProtectedRequestOutcome::GrantAccess => return inner.call(req).await,
490                    ProtectedRequestOutcome::Abort { status, body } => {
491                        return Ok(build_abort_response(status, body));
492                    }
493                }
494            }
495
496            // Resolve price tags from the source
497            let accepts = price_source
498                .resolve(req.headers(), req.uri(), base_url.as_deref())
499                .await;
500
501            // If no price tags are configured, bypass payment enforcement
502            if accepts.is_empty() {
503                return inner.call(req).await;
504            }
505
506            let resource = resource_builder.resolve(base_url.as_deref(), &req);
507
508            let mut gate_builder = Paygate::builder(facilitator)
509                .accepts(accepts)
510                .resource(resource);
511            if let Some(h) = hooks.as_ref() {
512                gate_builder = gate_builder.hooks_dyn(Arc::clone(h));
513            }
514            let mut gate = gate_builder.build();
515            gate.enrich_accepts().await;
516
517            // Fix-8: after the paygate verifies the payment, fire
518            // on_payment_verified so hooks can stamp request extensions
519            // (e.g. payer address) for downstream handlers.
520            if let Some(h) = hooks.as_ref() {
521                h.on_payment_verified(&mut req).await;
522            }
523
524            let result = match settlement_mode {
525                SettlementMode::Sequential => gate.handle_request(inner, req).await,
526                SettlementMode::Concurrent => gate.handle_request_concurrent(inner, req).await,
527                SettlementMode::Background => gate.handle_request_background(inner, req).await,
528            };
529            Ok(result.unwrap_or_else(|err| gate.error_response(err)))
530        })
531    }
532}
533
534/// Constructs an abort response from a [`PaygateHooks`] short-circuit.
535fn build_abort_response(status: http::StatusCode, body: Option<String>) -> Response {
536    let mut response = Response::new(axum_core::body::Body::from(body.unwrap_or_default()));
537    *response.status_mut() = status;
538    if let Ok(ct) = http::HeaderValue::from_str("text/plain; charset=utf-8") {
539        let _ = response
540            .headers_mut()
541            .insert(http::header::CONTENT_TYPE, ct);
542    }
543    super::cors::ensure_expose_headers(response.headers_mut());
544    response
545}