use http::header::{ACCESS_CONTROL_EXPOSE_HEADERS, CACHE_CONTROL};
use http::{HeaderMap, HeaderValue};
pub const PAYMENT_SIGNATURE: &str = "Payment-Signature";
pub const PAYMENT_REQUIRED: &str = "Payment-Required";
pub const PAYMENT_RESPONSE: &str = "Payment-Response";
pub const SIGN_IN_WITH_X: &str = "SIGN-IN-WITH-X";
pub const EXTENSION_RESPONSES: &str = "EXTENSION-RESPONSES";
pub const X402_EXPOSED_HEADERS: &str = "Payment-Required, Payment-Response";
pub const X402_ALLOW_HEADERS: &str = "Payment-Signature, SIGN-IN-WITH-X";
pub fn ensure_expose_headers(headers: &mut HeaderMap) {
let x402 = HeaderValue::from_static(X402_EXPOSED_HEADERS);
match headers.get(ACCESS_CONTROL_EXPOSE_HEADERS) {
None => {
let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, x402);
}
Some(existing) => {
let Ok(existing_str) = existing.to_str() else {
let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, x402);
return;
};
if existing_str.contains("Payment-Required")
&& existing_str.contains("Payment-Response")
{
return;
}
let merged = format!("{existing_str}, {X402_EXPOSED_HEADERS}");
if let Ok(value) = HeaderValue::from_str(&merged) {
let _ = headers.insert(ACCESS_CONTROL_EXPOSE_HEADERS, value);
}
}
}
}
pub fn set_no_store(headers: &mut HeaderMap) {
let _ = headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
pub fn merge_private(headers: &mut HeaderMap) {
match headers
.get(CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.map(str::trim)
{
None | Some("") => {
let _ = headers.insert(CACHE_CONTROL, HeaderValue::from_static("private"));
}
Some(existing) if cache_directive_present(existing, "private") => {}
Some(existing) => {
let merged = format!("{existing}, private");
if let Ok(value) = HeaderValue::from_str(&merged) {
let _ = headers.insert(CACHE_CONTROL, value);
}
}
}
}
fn cache_directive_present(header: &str, token: &str) -> bool {
header
.split(',')
.any(|part| part.trim().eq_ignore_ascii_case(token))
}
#[cfg(test)]
mod tests {
use http::header::{ACCESS_CONTROL_EXPOSE_HEADERS, CACHE_CONTROL};
use super::*;
#[test]
fn header_names_match_contract() {
assert_eq!(PAYMENT_SIGNATURE, "Payment-Signature");
assert_eq!(PAYMENT_REQUIRED, "Payment-Required");
assert_eq!(PAYMENT_RESPONSE, "Payment-Response");
assert_eq!(SIGN_IN_WITH_X, "SIGN-IN-WITH-X");
assert_eq!(EXTENSION_RESPONSES, "EXTENSION-RESPONSES");
assert_eq!(X402_EXPOSED_HEADERS, "Payment-Required, Payment-Response");
assert_eq!(X402_ALLOW_HEADERS, "Payment-Signature, SIGN-IN-WITH-X");
}
#[test]
fn expose_headers_adds_when_absent() {
let mut headers = HeaderMap::new();
ensure_expose_headers(&mut headers);
assert_eq!(
headers
.get(ACCESS_CONTROL_EXPOSE_HEADERS)
.map(HeaderValue::as_bytes),
Some(X402_EXPOSED_HEADERS.as_bytes()),
"expose headers must be inserted when missing"
);
}
#[test]
fn expose_headers_merges_existing() {
let mut headers = HeaderMap::new();
let _ = headers.insert(
ACCESS_CONTROL_EXPOSE_HEADERS,
HeaderValue::from_static("X-Foo"),
);
ensure_expose_headers(&mut headers);
let value = headers
.get(ACCESS_CONTROL_EXPOSE_HEADERS)
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
assert!(
value.contains("X-Foo"),
"existing CORS expose value must be kept"
);
assert!(
value.contains("Payment-Required"),
"Payment-Required must be exposed"
);
assert!(
value.contains("Payment-Response"),
"Payment-Response must be exposed"
);
}
#[test]
fn expose_headers_is_idempotent() {
let mut headers = HeaderMap::new();
ensure_expose_headers(&mut headers);
ensure_expose_headers(&mut headers);
assert_eq!(
headers
.get(ACCESS_CONTROL_EXPOSE_HEADERS)
.map(HeaderValue::as_bytes),
Some(X402_EXPOSED_HEADERS.as_bytes()),
"second call must not duplicate expose headers"
);
}
#[test]
fn set_no_store_overwrites() {
let mut headers = HeaderMap::new();
let _ = headers.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=60"));
set_no_store(&mut headers);
assert_eq!(
headers.get(CACHE_CONTROL).map(HeaderValue::as_bytes),
Some(b"no-store".as_slice()),
"no-store must replace any prior Cache-Control"
);
}
#[test]
fn merge_private_when_absent() {
let mut headers = HeaderMap::new();
merge_private(&mut headers);
assert_eq!(
headers.get(CACHE_CONTROL).map(HeaderValue::as_bytes),
Some(b"private".as_slice()),
"missing Cache-Control must become private"
);
}
#[test]
fn merge_private_appends_existing() {
let mut headers = HeaderMap::new();
let _ = headers.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=0"));
merge_private(&mut headers);
assert_eq!(
headers.get(CACHE_CONTROL).and_then(|v| v.to_str().ok()),
Some("max-age=0, private"),
"existing Cache-Control must be suffixed with private"
);
}
#[test]
fn merge_private_is_idempotent() {
let mut headers = HeaderMap::new();
let _ = headers.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=0"));
merge_private(&mut headers);
merge_private(&mut headers);
assert_eq!(
headers.get(CACHE_CONTROL).and_then(|v| v.to_str().ok()),
Some("max-age=0, private"),
"private must not be appended twice"
);
}
}