Skip to main content

cairn_cli/
client.rs

1use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
2use serde_json::Value;
3
4use crate::config::{CairnConfig, Credentials};
5use crate::errors::CairnError;
6
7/// JWT Claims structure for internal use
8#[allow(dead_code)]
9#[derive(serde::Deserialize, Debug)]
10pub struct AgentClaims {
11    pub sub: String,
12    pub account_id: String,
13    pub did: String,
14    pub chain: String,
15}
16
17/// HTTP client wrapper with automatic JWT injection and x402 payment fallback.
18pub struct BackpacClient {
19    inner: reqwest::Client,
20    base_url: String,
21    jwt: Option<String>,
22}
23
24impl BackpacClient {
25    /// Create a new client. Reads JWT from env, flag, or saved credentials.
26    pub fn new(jwt_override: Option<&str>, api_url: Option<&str>) -> Self {
27        let config = CairnConfig::load();
28
29        let base_url = api_url
30            .map(|s| s.to_string())
31            .or(config.api_url.clone())
32            .or_else(|| std::env::var("BACKPAC_API_URL").ok())
33            .unwrap_or_else(|| "https://api.backpac.xyz".to_string());
34
35        let jwt = jwt_override
36            .map(|s| s.to_string())
37            .or_else(|| std::env::var("BACKPAC_JWT").ok())
38            .or_else(|| Credentials::load().map(|c| c.jwt));
39
40        let inner = reqwest::Client::builder()
41            .timeout(std::time::Duration::from_secs(30))
42            .build()
43            .expect("Failed to create HTTP client");
44
45        Self { inner, base_url, jwt }
46    }
47
48    /// Get the JWT claims as a JSON Value.
49    pub fn claims(&self) -> Option<Value> {
50        let jwt = self.jwt.as_ref()?;
51        let parts: Vec<&str> = jwt.split('.').collect();
52        if parts.len() != 3 {
53            return None;
54        }
55
56        let payload_b64 = parts[1];
57        let decoded = base64::Engine::decode(
58            &base64::engine::general_purpose::URL_SAFE_NO_PAD,
59            payload_b64,
60        ).ok()?;
61
62        serde_json::from_slice(&decoded).ok()
63    }
64
65    /// Build headers with JWT Bearer auth.
66    fn auth_headers(&self) -> Result<HeaderMap, CairnError> {
67        let mut headers = HeaderMap::new();
68        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
69
70        if let Some(ref jwt) = self.jwt {
71            let val = format!("Bearer {}", jwt);
72            headers.insert(
73                AUTHORIZATION,
74                HeaderValue::from_str(&val).map_err(|e| CairnError::Auth(e.to_string()))?,
75            );
76        }
77        Ok(headers)
78    }
79
80    /// GET request with auth.
81    pub async fn get(&self, path: &str) -> Result<Value, CairnError> {
82        let url = format!("{}{}", self.base_url, path);
83        let resp = self
84            .inner
85            .get(&url)
86            .headers(self.auth_headers()?)
87            .send()
88            .await?;
89
90        self.handle_response(resp).await
91    }
92
93    /// Create an EventSource for Server-Sent Events (SSE).
94    pub fn stream(&self, path: &str) -> Result<reqwest_eventsource::EventSource, CairnError> {
95        let url = format!("{}{}", self.base_url, path);
96        let req = self.inner.get(&url).headers(self.auth_headers()?);
97        reqwest_eventsource::EventSource::new(req).map_err(|e| CairnError::General(e.to_string()))
98    }
99
100    /// POST request with auth and JSON body.
101    pub async fn post(&self, path: &str, body: &Value) -> Result<Value, CairnError> {
102        let url = format!("{}{}", self.base_url, path);
103        let resp = self
104            .inner
105            .post(&url)
106            .headers(self.auth_headers()?)
107            .json(body)
108            .send()
109            .await?;
110
111        self.handle_response(resp).await
112    }
113
114    /// PUT request with auth and JSON body.
115    pub async fn put(&self, path: &str, body: &Value) -> Result<Value, CairnError> {
116        let url = format!("{}{}", self.base_url, path);
117        let resp = self
118            .inner
119            .put(&url)
120            .headers(self.auth_headers()?)
121            .json(body)
122            .send()
123            .await?;
124
125        self.handle_response(resp).await
126    }
127
128    /// POST for RPC route (root path /) with custom headers for PoI binding.
129    pub async fn rpc_post(
130        &self,
131        body: &Value,
132        poi_id: Option<&str>,
133        confidence: Option<f64>,
134        hostname: Option<&str>,
135    ) -> Result<Value, CairnError> {
136        let url = format!("{}/", self.base_url);
137        let mut headers = self.auth_headers()?;
138
139        if let Some(h) = hostname {
140            headers.insert(
141                "x-backpac-hostname",
142                HeaderValue::from_str(h).map_err(|e| CairnError::InvalidInput(e.to_string()))?,
143            );
144        }
145
146        if let Some(poi) = poi_id {
147            headers.insert(
148                "X-Backpac-Poi-Id",
149                HeaderValue::from_str(poi).map_err(|e| CairnError::InvalidInput(e.to_string()))?,
150            );
151        }
152        if let Some(conf) = confidence {
153            headers.insert(
154                "X-Backpac-Confidence",
155                HeaderValue::from_str(&conf.to_string())
156                    .map_err(|e| CairnError::InvalidInput(e.to_string()))?,
157            );
158        }
159
160        let resp = self.inner.post(&url).headers(headers).json(body).send().await?;
161        self.handle_response(resp).await
162    }
163
164    /// Handle HTTP response, mapping status codes to CairnErrors.
165    async fn handle_response(&self, resp: reqwest::Response) -> Result<Value, CairnError> {
166        let status = resp.status();
167
168        // Extract headers before taking ownership of resp body
169        let mut l402_challenge = String::new();
170        if let Some(header) = resp.headers().get("WWW-Authenticate") {
171            if let Ok(val) = header.to_str() {
172                l402_challenge = val.to_string();
173            }
174        } else if let Some(header) = resp.headers().get("L402") {
175            if let Ok(val) = header.to_str() {
176                l402_challenge = val.to_string();
177            }
178        }
179
180        if status.is_success() {
181            let body: Value = resp.json().await?;
182            return Ok(body);
183        }
184
185        // Try to parse error body
186        let body_text = resp.text().await.unwrap_or_default();
187        let error_msg = serde_json::from_str::<Value>(&body_text)
188            .ok()
189            .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
190            .unwrap_or_else(|| body_text.clone());
191
192        match status.as_u16() {
193            400 => Err(CairnError::InvalidInput(error_msg)),
194            401 => {
195                if error_msg.contains("expired") {
196                    Err(CairnError::TokenExpired)
197                } else {
198                    Err(CairnError::Auth(error_msg))
199                }
200            }
201            402 => {
202                if !l402_challenge.is_empty() {
203                    // x402 Payment Required — parse macaroon and invoice
204                    let is_auto_pay = std::env::var("CAIRN_AUTO_PAY").map(|v| v == "1").unwrap_or(false);
205                    
206                    if is_auto_pay {
207                        // TODO: Parse L402 macaroon & invoice, sign with wallet via ethers,
208                        // and re-submit the RPC request with Authorization: L402 <macaroon> <sig>
209                        return Err(CairnError::InsufficientFunds(
210                            format!("Credits exhausted. Auto-payment attempted for L402 challenge but signing logic is pending implementation. Challenge: {}", l402_challenge),
211                        ));
212                    } else {
213                        return Err(CairnError::InsufficientFunds(
214                            format!("Credits exhausted. Payment required: {}\nHint: Set CAIRN_AUTO_PAY=1 to enable wallet auto-payment.", l402_challenge),
215                        ));
216                    }
217                }
218
219                Err(CairnError::InsufficientFunds(
220                    "Credits exhausted. x402 auto-payment not available (missing L402 headers).".to_string(),
221                ))
222            }
223            403 => Err(CairnError::Forbidden(error_msg)),
224            404 => Err(CairnError::NotFound(error_msg)),
225            409 => Err(CairnError::Conflict(error_msg)),
226            410 => {
227                if error_msg.contains("expired") {
228                    Err(CairnError::IntentExpired(error_msg))
229                } else {
230                    Err(CairnError::IntentAborted(error_msg))
231                }
232            }
233            _ => Err(CairnError::General(format!("HTTP {}: {}", status, error_msg))),
234        }
235    }
236}