Skip to main content

r402_http/server/
cors.rs

1//! CORS helpers for exposing x402 response headers to browser clients.
2//!
3//! A browser-based buyer needs to read the `Payment-Required` and
4//! `Payment-Response` headers from cross-origin responses. That read is
5//! blocked by default unless the server explicitly advertises the headers
6//! via `Access-Control-Expose-Headers`. [`ensure_expose_headers`] merges the
7//! x402 entries into any existing value (idempotent).
8
9use http::HeaderMap;
10use http::header::{ACCESS_CONTROL_EXPOSE_HEADERS, HeaderValue};
11
12/// The canonical list of x402 response headers clients need to see.
13pub const X402_EXPOSED_HEADERS: &str = "Payment-Required, Payment-Response";
14
15/// Ensures `Access-Control-Expose-Headers` on `headers` advertises the x402
16/// response headers. Idempotent: calling more than once is a no-op.
17pub fn ensure_expose_headers(headers: &mut HeaderMap) {
18    let x402 = HeaderValue::from_static(X402_EXPOSED_HEADERS);
19    match headers.get(ACCESS_CONTROL_EXPOSE_HEADERS) {
20        None => {
21            let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, x402);
22        }
23        Some(existing) => {
24            let Ok(existing_str) = existing.to_str() else {
25                let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, x402);
26                return;
27            };
28            if existing_str.contains("Payment-Required")
29                && existing_str.contains("Payment-Response")
30            {
31                return;
32            }
33            let merged = format!("{existing_str}, {X402_EXPOSED_HEADERS}");
34            if let Ok(value) = HeaderValue::from_str(&merged) {
35                let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, value);
36            }
37        }
38    }
39}
40
41#[cfg(test)]
42mod tests {
43    use super::*;
44
45    #[test]
46    fn adds_header_when_absent() {
47        let mut headers = HeaderMap::new();
48        ensure_expose_headers(&mut headers);
49        assert_eq!(
50            headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).unwrap(),
51            X402_EXPOSED_HEADERS,
52        );
53    }
54
55    #[test]
56    fn merges_existing_header() {
57        let mut headers = HeaderMap::new();
58        let _ = headers.insert(
59            ACCESS_CONTROL_EXPOSE_HEADERS,
60            HeaderValue::from_static("X-Foo"),
61        );
62        ensure_expose_headers(&mut headers);
63        let value = headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).unwrap();
64        let value = value.to_str().unwrap();
65        assert!(value.contains("X-Foo"));
66        assert!(value.contains("Payment-Required"));
67        assert!(value.contains("Payment-Response"));
68    }
69
70    #[test]
71    fn idempotent_on_repeated_calls() {
72        let mut headers = HeaderMap::new();
73        ensure_expose_headers(&mut headers);
74        ensure_expose_headers(&mut headers);
75        let value = headers.get(ACCESS_CONTROL_EXPOSE_HEADERS).unwrap();
76        assert_eq!(value, X402_EXPOSED_HEADERS);
77    }
78}