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    network: String,
16    api_root: String,
17}
18
19/// HTTP client wrapper with automatic JWT injection and x402 payment fallback.
20pub struct BackpacClient {
21    inner: reqwest::Client,
22    api_url: String,
23    worker_url: String,
24    jwt: Option<String>,
25    chain: String,
26    network: String,
27    worker_domain: String,
28}
29
30impl BackpacClient {
31    /// Create a new client. Reads JWT from env, flag, or saved credentials.
32    pub fn new(jwt_override: Option<&str>, api_url: Option<&str>, worker_url: Option<&str>) -> Self {
33        let config = CairnConfig::load();
34
35        let chain = config.chain.clone();
36        let network = config.network.clone();
37        let api_domain = config.api_domain.clone();
38        let worker_domain = config.worker_domain.clone();
39
40        let resolved_api_url = api_url
41            .map(|s| s.to_string())
42            .or(config.api_url.clone())
43            .or_else(|| std::env::var("BACKPAC_API_URL").ok())
44            .unwrap_or_else(|| {
45                let protocol = if api_domain.starts_with("localhost") || api_domain.starts_with("127.0.0.1") {
46                    "http"
47                } else {
48                    "https"
49                };
50                format!("{}://{}", protocol, api_domain)
51            });
52
53        let resolved_worker_url = worker_url
54            .map(|s| s.to_string())
55            .or(config.worker_url.clone())
56            .or_else(|| std::env::var("BACKPAC_WORKER_URL").ok())
57            .unwrap_or_else(|| {
58                if worker_domain.starts_with("localhost") || worker_domain.starts_with("127.0.0.1") {
59                    format!("http://{}", worker_domain)
60                } else {
61                    format!("https://{}-{}.{}", chain, network, worker_domain)
62                }
63            });
64
65        let jwt = jwt_override
66            .map(|s| s.to_string())
67            .or_else(|| std::env::var("BACKPAC_JWT").ok())
68            .or_else(|| Credentials::load().map(|c| c.jwt));
69
70        let inner = reqwest::Client::builder()
71            .timeout(std::time::Duration::from_secs(30))
72            .build()
73            .expect("Failed to create HTTP client");
74
75        Self {
76            inner,
77            api_url: resolved_api_url,
78            worker_url: resolved_worker_url,
79            jwt,
80            chain,
81            network,
82            worker_domain,
83        }
84    }
85
86    /// Get the JWT claims as a JSON Value.
87    pub fn claims(&self) -> Option<Value> {
88        let jwt = self.jwt.as_ref()?;
89        Self::decode_jwt_claims::<Value>(jwt)
90    }
91
92    /// Helper to decode JWT claims.
93    fn decode_jwt_claims<T: serde::de::DeserializeOwned>(jwt: &str) -> Option<T> {
94        let parts: Vec<&str> = jwt.split('.').collect();
95        if parts.len() != 3 {
96            return None;
97        }
98
99        let payload_b64 = parts[1];
100        let decoded = base64::Engine::decode(
101            &base64::engine::general_purpose::URL_SAFE_NO_PAD,
102            payload_b64,
103        ).ok()?;
104
105        serde_json::from_slice(&decoded).ok()
106    }
107
108    /// Build headers with JWT Bearer auth.
109    fn auth_headers(&self) -> Result<HeaderMap, CairnError> {
110        let mut headers = HeaderMap::new();
111        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
112
113        if let Some(ref jwt) = self.jwt {
114            let val = format!("Bearer {}", jwt);
115            headers.insert(
116                AUTHORIZATION,
117                HeaderValue::from_str(&val).map_err(|e| CairnError::Auth(e.to_string()))?,
118            );
119        }
120        Ok(headers)
121    }
122
123    /// GET request with auth.
124    pub async fn get(&self, path: &str) -> Result<Value, CairnError> {
125        let url = format!("{}{}", self.api_url, path);
126        let resp = self
127            .inner
128            .get(&url)
129            .headers(self.auth_headers()?)
130            .send()
131            .await?;
132
133        self.handle_response(resp).await
134    }
135
136    /// Create an EventSource for Server-Sent Events (SSE).
137    pub fn stream(&self, path: &str) -> Result<reqwest_eventsource::EventSource, CairnError> {
138        let url = format!("{}{}", self.api_url, path);
139        let req = self.inner.get(&url).headers(self.auth_headers()?);
140        reqwest_eventsource::EventSource::new(req).map_err(|e| CairnError::General(e.to_string()))
141    }
142
143    /// POST request with auth and JSON body.
144    pub async fn post(&self, path: &str, body: &Value) -> Result<Value, CairnError> {
145        let url = format!("{}{}", self.api_url, path);
146        let resp = self
147            .inner
148            .post(&url)
149            .headers(self.auth_headers()?)
150            .json(body)
151            .send()
152            .await?;
153
154        self.handle_response(resp).await
155    }
156
157    /// PUT request with auth and JSON body.
158    pub async fn put(&self, path: &str, body: &Value) -> Result<Value, CairnError> {
159        let url = format!("{}{}", self.api_url, path);
160        let resp = self
161            .inner
162            .put(&url)
163            .headers(self.auth_headers()?)
164            .json(body)
165            .send()
166            .await?;
167
168        self.handle_response(resp).await
169    }
170
171    /// POST for RPC route (root path /) with custom headers for PoI binding.
172    pub async fn rpc_post(
173        &self,
174        body: &Value,
175        poi_id: Option<&str>,
176        confidence: Option<f64>,
177        hostname: Option<&str>,
178    ) -> Result<Value, CairnError> {
179        let url = format!("{}/", self.worker_url);
180        let mut headers = self.auth_headers()?;
181
182        let hostname_header = if let Some(h) = hostname {
183            h.to_string()
184        } else {
185            // Header always uses chain-network format (e.g. ethereum-mainnet.backpac.xyz)
186            // If worker_domain is localhost, we fallback to production domain for the header.
187            let base_domain = if self.worker_domain.starts_with("localhost") || self.worker_domain.starts_with("127.0.0.1") {
188                "backpac.xyz"
189            } else {
190                &self.worker_domain
191            };
192            format!("{}-{}.{}", self.chain, self.network, base_domain)
193        };
194
195        headers.insert(
196            "x-backpac-hostname",
197            HeaderValue::from_str(&hostname_header).map_err(|e| CairnError::InvalidInput(e.to_string()))?,
198        );
199
200        if let Some(poi) = poi_id {
201            headers.insert(
202                "X-Backpac-Poi-Id",
203                HeaderValue::from_str(poi).map_err(|e| CairnError::InvalidInput(e.to_string()))?,
204            );
205        }
206        if let Some(conf) = confidence {
207            headers.insert(
208                "X-Backpac-Confidence",
209                HeaderValue::from_str(&conf.to_string())
210                    .map_err(|e| CairnError::InvalidInput(e.to_string()))?,
211            );
212        }
213
214        if std::env::var("CAIRN_DEBUG").map(|v| v == "1").unwrap_or(false) {
215            eprintln!("[DEBUG] RPC POST URL: {}", url);
216            eprintln!("[DEBUG] RPC Headers: {:?}", headers);
217            eprintln!("[DEBUG] RPC Body: {:?}", body);
218        }
219
220        let resp = self.inner.post(&url).headers(headers).json(body).send().await?;
221        self.handle_response(resp).await
222    }
223
224    /// Handle HTTP response, mapping status codes to CairnErrors.
225    async fn handle_response(&self, resp: reqwest::Response) -> Result<Value, CairnError> {
226        let status = resp.status();
227
228        // Extract headers before taking ownership of resp body
229        let mut l402_challenge = String::new();
230        if let Some(header) = resp.headers().get("WWW-Authenticate") {
231            if let Ok(val) = header.to_str() {
232                l402_challenge = val.to_string();
233            }
234        } else if let Some(header) = resp.headers().get("L402") {
235            if let Ok(val) = header.to_str() {
236                l402_challenge = val.to_string();
237            }
238        }
239        let mut intent_id = None;
240        if let Some(header) = resp.headers().get("x-backpac-intent-id") {
241            if let Ok(val) = header.to_str() {
242                intent_id = Some(val.to_string());
243            }
244        }
245
246        if status.is_success() {
247            let mut body: Value = resp.json().await?;
248            if let Some(id) = intent_id {
249                if let Some(obj) = body.as_object_mut() {
250                    obj.insert("intent_id".to_string(), Value::String(id));
251                }
252            }
253            return Ok(body);
254        }
255
256        // Try to parse error body
257        let body_text = resp.text().await.unwrap_or_default();
258        let error_msg = serde_json::from_str::<Value>(&body_text)
259            .ok()
260            .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from))
261            .unwrap_or_else(|| body_text.clone());
262
263        match status.as_u16() {
264            400 => Err(CairnError::InvalidInput(error_msg)),
265            401 => {
266                if error_msg.contains("expired") {
267                    Err(CairnError::TokenExpired)
268                } else {
269                    Err(CairnError::Auth(error_msg))
270                }
271            }
272            402 => {
273                if !l402_challenge.is_empty() {
274                    // x402 Payment Required — parse macaroon and invoice
275                    let is_auto_pay = std::env::var("CAIRN_AUTO_PAY").map(|v| v == "1").unwrap_or(false);
276                    
277                    if is_auto_pay {
278                        // TODO: Parse L402 macaroon & invoice, sign with wallet via ethers,
279                        // and re-submit the RPC request with Authorization: L402 <macaroon> <sig>
280                        return Err(CairnError::InsufficientFunds(
281                            format!("Credits exhausted. Auto-payment attempted for L402 challenge but signing logic is pending implementation. Challenge: {}", l402_challenge),
282                        ));
283                    } else {
284                        return Err(CairnError::InsufficientFunds(
285                            format!("Credits exhausted. Payment required: {}\nHint: Set CAIRN_AUTO_PAY=1 to enable wallet auto-payment.", l402_challenge),
286                        ));
287                    }
288                }
289
290                Err(CairnError::InsufficientFunds(
291                    "Credits exhausted. x402 auto-payment not available (missing L402 headers).".to_string(),
292                ))
293            }
294            403 => Err(CairnError::Forbidden(error_msg)),
295            404 => Err(CairnError::NotFound(error_msg)),
296            409 => Err(CairnError::Conflict(error_msg)),
297            410 => {
298                if error_msg.contains("expired") {
299                    Err(CairnError::IntentExpired(error_msg))
300                } else {
301                    Err(CairnError::IntentAborted(error_msg))
302                }
303            }
304            _ => Err(CairnError::General(format!("HTTP {}: {}", status, error_msg))),
305        }
306    }
307}