1use http::header::{ACCESS_CONTROL_EXPOSE_HEADERS, CACHE_CONTROL};
8use http::{HeaderMap, HeaderValue};
9
10pub const PAYMENT_SIGNATURE: &str = "Payment-Signature";
12
13pub const PAYMENT_REQUIRED: &str = "Payment-Required";
15
16pub const PAYMENT_RESPONSE: &str = "Payment-Response";
18
19pub const SIGN_IN_WITH_X: &str = "SIGN-IN-WITH-X";
21
22pub const EXTENSION_RESPONSES: &str = "EXTENSION-RESPONSES";
24
25pub const X402_EXPOSED_HEADERS: &str = "Payment-Required, Payment-Response";
27
28pub const X402_ALLOW_HEADERS: &str = "Payment-Signature, SIGN-IN-WITH-X";
30
31pub 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
56pub fn set_no_store(headers: &mut HeaderMap) {
58 let _ = headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
59}
60
61pub 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}