1use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
2use serde_json::Value;
3
4use crate::config::{CairnConfig, Credentials};
5use crate::errors::CairnError;
6
7#[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
19pub 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 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 pub fn claims(&self) -> Option<Value> {
88 let jwt = self.jwt.as_ref()?;
89 Self::decode_jwt_claims::<Value>(jwt)
90 }
91
92 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 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 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 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 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 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 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 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 async fn handle_response(&self, resp: reqwest::Response) -> Result<Value, CairnError> {
226 let status = resp.status();
227
228 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 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 let is_auto_pay = std::env::var("CAIRN_AUTO_PAY").map(|v| v == "1").unwrap_or(false);
276
277 if is_auto_pay {
278 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}