Skip to main content

r402_http/
headers.rs

1//! x402 HTTP header names and Cache-Control helpers.
2//!
3//! Request: [`PAYMENT_SIGNATURE`], [`SIGN_IN_WITH_X`] (never emit as response).
4//! Response: [`PAYMENT_REQUIRED`], [`PAYMENT_RESPONSE`].
5//! Facilitator-only: [`EXTENSION_RESPONSES`] (not buyer CORS).
6
7use http::header::{ACCESS_CONTROL_EXPOSE_HEADERS, CACHE_CONTROL};
8use http::{HeaderMap, HeaderValue};
9
10/// Buyer retry header carrying a base64 `PaymentPayload`.
11pub const PAYMENT_SIGNATURE: &str = "Payment-Signature";
12
13/// 402 challenge header carrying a base64 `PaymentRequired`.
14pub const PAYMENT_REQUIRED: &str = "Payment-Required";
15
16/// Settlement receipt header carrying a base64 `SettleResponse`.
17pub const PAYMENT_RESPONSE: &str = "Payment-Response";
18
19/// SIWX proof header (base64 JSON). Never emit as a response header.
20pub const SIGN_IN_WITH_X: &str = "SIGN-IN-WITH-X";
21
22/// Facilitator-only sidechannel. Not a buyer CORS expose/allow header.
23pub const EXTENSION_RESPONSES: &str = "EXTENSION-RESPONSES";
24
25/// CORS `Access-Control-Expose-Headers` value for x402 response headers.
26pub const X402_EXPOSED_HEADERS: &str = "Payment-Required, Payment-Response";
27
28/// CORS `Access-Control-Allow-Headers` value for the app `CorsLayer`.
29pub const X402_ALLOW_HEADERS: &str = "Payment-Signature, SIGN-IN-WITH-X";
30
31/// Ensures `Access-Control-Expose-Headers` advertises the x402 response headers.
32pub fn ensure_expose_headers(headers: &mut HeaderMap) {
33    let x402 = HeaderValue::from_static(X402_EXPOSED_HEADERS);
34    match headers.get(ACCESS_CONTROL_EXPOSE_HEADERS) {
35        None => {
36            let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, x402);
37        }
38        Some(existing) => {
39            let Ok(existing_str) = existing.to_str() else {
40                let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, x402);
41                return;
42            };
43            if existing_str.contains("Payment-Required")
44                && existing_str.contains("Payment-Response")
45            {
46                return;
47            }
48            let merged = format!("{existing_str}, {X402_EXPOSED_HEADERS}");
49            if let Ok(value) = HeaderValue::from_str(&merged) {
50                let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, value);
51            }
52        }
53    }
54}
55
56/// Writes `Cache-Control: no-store` (402 / 412 / settle-fail).
57pub fn set_no_store(headers: &mut HeaderMap) {
58    let _ = headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
59}
60
61/// Merges `private` into `Cache-Control` (200 + `Payment-Response`).
62///
63/// Missing or empty `Cache-Control` becomes `private`. An existing value that
64/// does not already contain `private` becomes `existing, private`.
65pub fn merge_private(headers: &mut HeaderMap) {
66    match headers
67        .get(CACHE_CONTROL)
68        .and_then(|value| value.to_str().ok())
69        .map(str::trim)
70    {
71        None | Some("") => {
72            let _ = headers.insert(CACHE_CONTROL, HeaderValue::from_static("private"));
73        }
74        Some(existing) if cache_directive_present(existing, "private") => {}
75        Some(existing) => {
76            let merged = format!("{existing}, private");
77            if let Ok(value) = HeaderValue::from_str(&merged) {
78                let _ = headers.insert(CACHE_CONTROL, value);
79            }
80        }
81    }
82}
83
84fn cache_directive_present(header: &str, token: &str) -> bool {
85    header
86        .split(',')
87        .any(|part| part.trim().eq_ignore_ascii_case(token))
88}
89
90#[cfg(test)]
91mod tests {
92    use http::header::{ACCESS_CONTROL_EXPOSE_HEADERS, CACHE_CONTROL};
93
94    use super::*;
95
96    #[test]
97    fn header_names_match_contract() {
98        assert_eq!(PAYMENT_SIGNATURE, "Payment-Signature");
99        assert_eq!(PAYMENT_REQUIRED, "Payment-Required");
100        assert_eq!(PAYMENT_RESPONSE, "Payment-Response");
101        assert_eq!(SIGN_IN_WITH_X, "SIGN-IN-WITH-X");
102        assert_eq!(EXTENSION_RESPONSES, "EXTENSION-RESPONSES");
103        assert_eq!(X402_EXPOSED_HEADERS, "Payment-Required, Payment-Response");
104        assert_eq!(X402_ALLOW_HEADERS, "Payment-Signature, SIGN-IN-WITH-X");
105    }
106
107    #[test]
108    fn expose_headers_adds_when_absent() {
109        let mut headers = HeaderMap::new();
110        ensure_expose_headers(&mut headers);
111        assert_eq!(
112            headers
113                .get(ACCESS_CONTROL_EXPOSE_HEADERS)
114                .map(HeaderValue::as_bytes),
115            Some(X402_EXPOSED_HEADERS.as_bytes()),
116            "expose headers must be inserted when missing"
117        );
118    }
119
120    #[test]
121    fn expose_headers_merges_existing() {
122        let mut headers = HeaderMap::new();
123        let _ = headers.insert(
124            ACCESS_CONTROL_EXPOSE_HEADERS,
125            HeaderValue::from_static("X-Foo"),
126        );
127        ensure_expose_headers(&mut headers);
128        let value = headers
129            .get(ACCESS_CONTROL_EXPOSE_HEADERS)
130            .and_then(|v| v.to_str().ok())
131            .unwrap_or_default();
132        assert!(
133            value.contains("X-Foo"),
134            "existing CORS expose value must be kept"
135        );
136        assert!(
137            value.contains("Payment-Required"),
138            "Payment-Required must be exposed"
139        );
140        assert!(
141            value.contains("Payment-Response"),
142            "Payment-Response must be exposed"
143        );
144    }
145
146    #[test]
147    fn expose_headers_is_idempotent() {
148        let mut headers = HeaderMap::new();
149        ensure_expose_headers(&mut headers);
150        ensure_expose_headers(&mut headers);
151        assert_eq!(
152            headers
153                .get(ACCESS_CONTROL_EXPOSE_HEADERS)
154                .map(HeaderValue::as_bytes),
155            Some(X402_EXPOSED_HEADERS.as_bytes()),
156            "second call must not duplicate expose headers"
157        );
158    }
159
160    #[test]
161    fn set_no_store_overwrites() {
162        let mut headers = HeaderMap::new();
163        let _ = headers.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=60"));
164        set_no_store(&mut headers);
165        assert_eq!(
166            headers.get(CACHE_CONTROL).map(HeaderValue::as_bytes),
167            Some(b"no-store".as_slice()),
168            "no-store must replace any prior Cache-Control"
169        );
170    }
171
172    #[test]
173    fn merge_private_when_absent() {
174        let mut headers = HeaderMap::new();
175        merge_private(&mut headers);
176        assert_eq!(
177            headers.get(CACHE_CONTROL).map(HeaderValue::as_bytes),
178            Some(b"private".as_slice()),
179            "missing Cache-Control must become private"
180        );
181    }
182
183    #[test]
184    fn merge_private_appends_existing() {
185        let mut headers = HeaderMap::new();
186        let _ = headers.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=0"));
187        merge_private(&mut headers);
188        assert_eq!(
189            headers.get(CACHE_CONTROL).and_then(|v| v.to_str().ok()),
190            Some("max-age=0, private"),
191            "existing Cache-Control must be suffixed with private"
192        );
193    }
194
195    #[test]
196    fn merge_private_is_idempotent() {
197        let mut headers = HeaderMap::new();
198        let _ = headers.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=0"));
199        merge_private(&mut headers);
200        merge_private(&mut headers);
201        assert_eq!(
202            headers.get(CACHE_CONTROL).and_then(|v| v.to_str().ok()),
203            Some("max-age=0, private"),
204            "private must not be appended twice"
205        );
206    }
207}