Skip to main content

walletkit_core/issuers/
pop_backend_client.rs

1use crate::error::WalletKitError;
2use crate::http_request::Request;
3use reqwest::Method;
4use serde::{Deserialize, Serialize};
5
6/// Request payload for registering or unregistering a recovery binding.
7///
8/// Serialized as JSON with `leafIndex` (camelCase) to match the `PoP` backend API.
9#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, uniffi::Record)]
10pub struct ManageRecoveryBindingRequest {
11    /// Hex-encoded subject identifier of the recovery binding.
12    pub sub: String,
13    /// The authenticator's leaf index in the World ID Merkle tree.
14    #[serde(rename = "leafIndex")]
15    pub leaf_index: u64,
16    /// The signature of the recovery agent update.
17    pub signature: String,
18    /// The nonce of the recovery agent update.
19    pub nonce: String,
20    /// The checksummed hex address of the recovery agent (e.g. `"0x1234…"`).
21    #[serde(rename = "recoveryAgent")]
22    pub recovery_agent: String,
23}
24
25#[derive(Deserialize)]
26struct ChallengeResponse {
27    challenge: String,
28}
29
30/// Error body returned by the recovery binding endpoints, carrying the sentinel error name.
31#[derive(Deserialize)]
32struct RecoveryBindingErrorResponse {
33    error: String,
34}
35
36#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, uniffi::Record)]
37pub struct RecoveryBindingResponse {
38    #[serde(rename = "recoveryAgent")]
39    pub recovery_agent: Option<String>,
40    #[serde(rename = "pendingRecoveryAgent")]
41    pub pending_recovery_agent: Option<String>,
42    #[serde(rename = "executeAfter")]
43    pub execute_after: Option<String>,
44}
45
46/// Low-level HTTP client for the Proof-of-Personhood (`PoP`) backend API.
47///
48/// Handles the REST calls for recovery binding management and challenge retrieval.
49/// All requests that mutate state require a security token (signature) and a
50/// challenge, passed via `X-Auth-Signature` and `X-Auth-Challenge` headers.
51///
52/// This client is not meant to be used directly — prefer [`super::RecoveryBindingManager`],
53/// which orchestrates the full challenge-response signing flow.
54pub struct PopBackendClient {
55    request: Request,
56    base_url: String,
57}
58
59impl PopBackendClient {
60    /// Creates a new client targeting the given base URL.
61    #[must_use]
62    pub fn new(base_url: String, user_agent: String) -> Self {
63        let request = Request::new(user_agent);
64        Self { request, base_url }
65    }
66}
67
68impl PopBackendClient {
69    /// Registers a new recovery binding via `POST /api/v1/recovery-binding`.
70    ///
71    /// # Errors
72    ///
73    /// * [`WalletKitError::IdentityNotFound`], [`WalletKitError::NoSuccessfulCaptureFound`],
74    ///   [`WalletKitError::DebugReportNotFound`]: HTTP 404 Not Found, split by the `error`
75    ///   field of the response body. An unrecognized body maps to `DebugReportNotFound`.
76    /// * [`WalletKitError::NotEligibleForRecovery`] — HTTP 412 Precondition Failed.
77    /// * [`WalletKitError::NetworkError`] — any other non-success status; the response body is in
78    ///   `error` and the HTTP status in `status`. This includes conflicts (e.g. HTTP 409) and
79    ///   server errors.
80    pub async fn bind_recovery_agent(
81        &self,
82        request: ManageRecoveryBindingRequest,
83        security_token: String,
84        challenge: String,
85    ) -> Result<(), WalletKitError> {
86        let url: String = format!("{}/api/v1/recovery-binding", self.base_url);
87        let response = self
88            .request
89            .post(&url)
90            .json(&request)
91            .header("X-Auth-Signature", security_token)
92            .header("X-Auth-Challenge", challenge)
93            .send()
94            .await?;
95
96        let response_status = response.status();
97        match response_status {
98            reqwest::StatusCode::CREATED | reqwest::StatusCode::OK => Ok(()),
99            reqwest::StatusCode::NOT_FOUND => Err(eligibility_error(response).await),
100            reqwest::StatusCode::PRECONDITION_FAILED => {
101                Err(WalletKitError::NotEligibleForRecovery)
102            }
103            _ => {
104                let error_message = response
105                    .text()
106                    .await
107                    .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
108                Err(WalletKitError::NetworkError {
109                    url,
110                    error: error_message,
111                    status: Some(response_status.as_u16()),
112                })
113            }
114        }
115    }
116
117    /// Removes a recovery binding via `DELETE /api/v1/recovery-binding`.
118    ///
119    /// # Errors
120    ///
121    /// * [`WalletKitError::AccountDoesNotExist`] — HTTP 404 (no binding found).
122    /// * [`WalletKitError::NetworkError`] — any other non-success status.
123    pub async fn unbind_recovery_agent(
124        &self,
125        request: ManageRecoveryBindingRequest,
126        security_token: String,
127        challenge: String,
128    ) -> Result<(), WalletKitError> {
129        let url: String = format!("{}/api/v1/recovery-binding", self.base_url);
130        let response = self
131            .request
132            .req(Method::DELETE, url.as_str())
133            .json(&request)
134            .header("X-Auth-Signature", security_token)
135            .header("X-Auth-Challenge", challenge)
136            .send()
137            .await?;
138        let response_status = response.status();
139        match response_status {
140            reqwest::StatusCode::OK => Ok(()),
141            reqwest::StatusCode::NOT_FOUND => {
142                Err(WalletKitError::RecoveryBindingDoesNotExist)
143            }
144            _ => {
145                let error_message = response
146                    .text()
147                    .await
148                    .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
149                Err(WalletKitError::NetworkError {
150                    url,
151                    error: error_message,
152                    status: Some(response_status.as_u16()),
153                })
154            }
155        }
156    }
157
158    /// Fetches a one-time challenge string via `GET /api/v1/challenge`.
159    ///
160    /// The returned challenge is used as input to the commitment signature
161    /// required by the register/unregister endpoints.
162    ///
163    /// # Errors
164    ///
165    /// * [`WalletKitError::NetworkError`] — non-success HTTP status.
166    /// * [`WalletKitError::SerializationError`] — response body is not valid JSON.
167    pub async fn get_challenge(&self) -> Result<String, WalletKitError> {
168        let url = format!("{}/api/v1/challenge", self.base_url);
169        let response = self.request.get(url.as_str()).send().await?;
170
171        let response_status = response.status();
172        if !response_status.is_success() {
173            let error_message = response
174                .text()
175                .await
176                .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
177            return Err(WalletKitError::NetworkError {
178                url,
179                status: Some(response_status.as_u16()),
180                error: error_message,
181            });
182        }
183
184        let challenge_response: ChallengeResponse =
185            response
186                .json()
187                .await
188                .map_err(|e| WalletKitError::SerializationError {
189                    error: format!("Failed to parse challenge response: {e}"),
190                })?;
191
192        Ok(challenge_response.challenge)
193    }
194
195    /// Fetches a recovery binding via `GET /api/v1/recovery-binding`.
196    ///
197    /// # Errors
198    ///
199    /// * [`WalletKitError::NetworkError`] — non-success HTTP status.
200    /// * [`WalletKitError::SerializationError`] — response body is not valid JSON.
201    /// * [`WalletKitError::RecoveryBindingDoesNotExist`] — HTTP 404 (no binding found).
202    pub async fn get_recovery_binding(
203        &self,
204        leaf_index: u64,
205    ) -> Result<RecoveryBindingResponse, WalletKitError> {
206        let url = format!(
207            "{}/api/v1/recovery-binding?leafIndex={leaf_index}",
208            self.base_url
209        );
210        let response = self.request.get(url.as_str()).send().await?;
211
212        let status = response.status();
213        if status.is_success() {
214            let recovery_binding: RecoveryBindingResponse = response.json().await?;
215            return Ok(recovery_binding);
216        }
217        if status == reqwest::StatusCode::NOT_FOUND {
218            return Err(WalletKitError::RecoveryBindingDoesNotExist);
219        }
220        let error_message = response
221            .text()
222            .await
223            .unwrap_or_else(|e| format!("Unknown error: {e:?}"));
224        Err(WalletKitError::NetworkError {
225            url,
226            error: error_message,
227            status: Some(status.as_u16()),
228        })
229    }
230}
231
232/// Maps a 404 from `POST /api/v1/recovery-binding` to the eligibility failure named in the body.
233///
234/// An unrecognized body keeps the historical [`WalletKitError::DebugReportNotFound`] mapping.
235async fn eligibility_error(response: reqwest::Response) -> WalletKitError {
236    match response
237        .json::<RecoveryBindingErrorResponse>()
238        .await
239        .map(|body| body.error)
240        .as_deref()
241    {
242        Ok("IdentityNotFound") => WalletKitError::IdentityNotFound,
243        Ok("NoSuccessfulCaptureFound") => WalletKitError::NoSuccessfulCaptureFound,
244        _ => WalletKitError::DebugReportNotFound,
245    }
246}
247
248#[cfg(test)]
249mod tests {
250
251    use super::*;
252    use test_case::test_case;
253
254    #[tokio::test]
255    async fn test_register_recovery_agent_success() {
256        let mut server = mockito::Server::new_async().await;
257        let url = server.url();
258
259        let recovery_agent = "0x1234567890abcdef".to_string();
260        let request = ManageRecoveryBindingRequest {
261            sub: "test-sub-123".to_string(),
262            leaf_index: 42,
263            signature: "0x1234567890abcdef".to_string(),
264            nonce: "0x1234567890abcdef1".to_string(),
265            recovery_agent: recovery_agent.clone(),
266        };
267
268        let mock = server
269            .mock("POST", "/api/v1/recovery-binding")
270            .match_body(mockito::Matcher::Json(serde_json::json!({
271                "sub": "test-sub-123",
272                "leafIndex": 42,
273                "signature": "0x1234567890abcdef",
274                "nonce": "0x1234567890abcdef1",
275                "recoveryAgent": "0x1234567890abcdef",
276            })))
277            .match_header("X-Auth-Signature", "security_token")
278            .match_header("X-Auth-Challenge", "challenge")
279            .with_status(201)
280            .with_body("{}")
281            .create_async()
282            .await;
283
284        let pop_api_client =
285            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
286
287        let result = pop_api_client
288            .bind_recovery_agent(
289                request,
290                "security_token".to_string(),
291                "challenge".to_string(),
292            )
293            .await;
294
295        assert!(result.is_ok(), "Expected success but got error: {result:?}");
296        mock.assert_async().await;
297        drop(server);
298    }
299
300    #[test_case(r#"{"error":"IdentityNotFound"}"#, "identity_not_found")]
301    #[test_case(
302        r#"{"error":"NoSuccessfulCaptureFound"}"#,
303        "no_successful_capture_found"
304    )]
305    #[test_case(r#"{"error":"DebugReportNotFound"}"#, "debug_report_not_found")]
306    #[test_case(r#"{"error":"SomethingNewFromTheBackend"}"#, "debug_report_not_found")]
307    #[test_case("not json at all", "debug_report_not_found")]
308    #[tokio::test]
309    async fn test_register_recovery_agent_404_maps_to_eligibility_error(
310        response_body: &str,
311        expected_error: &str,
312    ) {
313        let mut server = mockito::Server::new_async().await;
314        let url = server.url();
315
316        let request = ManageRecoveryBindingRequest {
317            sub: "test-sub-123".to_string(),
318            leaf_index: 42,
319            signature: "0x1234567890abcdef".to_string(),
320            nonce: "0x1234567890abcdef1".to_string(),
321            recovery_agent: "0x1234567890abcdef".to_string(),
322        };
323
324        let mock = server
325            .mock("POST", "/api/v1/recovery-binding")
326            .match_body(mockito::Matcher::Json(serde_json::json!({
327                "sub": "test-sub-123",
328                "leafIndex": 42,
329                "signature": "0x1234567890abcdef",
330                "nonce": "0x1234567890abcdef1",
331                "recoveryAgent": "0x1234567890abcdef",
332            })))
333            .match_header("X-Auth-Signature", "security_token")
334            .match_header("X-Auth-Challenge", "challenge")
335            .with_status(404)
336            .with_body(response_body)
337            .create_async()
338            .await;
339
340        let pop_api_client =
341            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
342
343        let result = pop_api_client
344            .bind_recovery_agent(
345                request,
346                "security_token".to_string(),
347                "challenge".to_string(),
348            )
349            .await;
350
351        let err = result.expect_err("Expected error but got success");
352        assert_eq!(err.to_string(), expected_error);
353        mock.assert_async().await;
354        drop(server);
355    }
356
357    #[tokio::test]
358    async fn test_unregister_recovery_agent_success() {
359        let mut server = mockito::Server::new_async().await;
360        let url = server.url();
361
362        let request = ManageRecoveryBindingRequest {
363            sub: "test-sub-123".to_string(),
364            leaf_index: 42,
365            signature: "0x1234567890abcdef".to_string(),
366            nonce: "0x1234567890abcdef1".to_string(),
367            recovery_agent: "0x0000000000000000000000000000000000000000".to_string(),
368        };
369
370        let mock = server
371            .mock("DELETE", "/api/v1/recovery-binding")
372            .match_body(mockito::Matcher::Json(serde_json::json!({
373                "sub": "test-sub-123",
374                "leafIndex": 42,
375                "signature": "0x1234567890abcdef",
376                "nonce": "0x1234567890abcdef1",
377                "recoveryAgent": "0x0000000000000000000000000000000000000000",
378            })))
379            .match_header("X-Auth-Signature", "security_token")
380            .match_header("X-Auth-Challenge", "challenge")
381            .with_status(200)
382            .with_body("{}")
383            .create_async()
384            .await;
385
386        let pop_api_client =
387            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
388
389        let result = pop_api_client
390            .unbind_recovery_agent(
391                request,
392                "security_token".to_string(),
393                "challenge".to_string(),
394            )
395            .await;
396
397        assert!(result.is_ok(), "Expected success but got error: {result:?}");
398        mock.assert_async().await;
399        drop(server);
400    }
401
402    #[tokio::test]
403    async fn test_unregister_recovery_agent_not_found() {
404        let mut server = mockito::Server::new_async().await;
405        let url = server.url();
406
407        let recovery_agent = "0x0000000000000000000000000000000000000000".to_string();
408        let request = ManageRecoveryBindingRequest {
409            sub: "test-sub-123".to_string(),
410            leaf_index: 42,
411            signature: "0x1234567890abcdef".to_string(),
412            nonce: "0x1234567890abcdef1".to_string(),
413            recovery_agent: recovery_agent.clone(),
414        };
415
416        let mock = server
417            .mock("DELETE", "/api/v1/recovery-binding")
418            .match_body(mockito::Matcher::Json(serde_json::json!({
419                "sub": "test-sub-123",
420                "leafIndex": 42,
421                "signature": "0x1234567890abcdef",
422                "nonce": "0x1234567890abcdef1",
423                "recoveryAgent": "0x0000000000000000000000000000000000000000",
424            })))
425            .match_header("X-Auth-Signature", "security_token")
426            .match_header("X-Auth-Challenge", "challenge")
427            .with_status(404)
428            .with_body("Recovery agent not found")
429            .create_async()
430            .await;
431
432        let pop_api_client =
433            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
434
435        let result = pop_api_client
436            .unbind_recovery_agent(
437                request,
438                "security_token".to_string(),
439                "challenge".to_string(),
440            )
441            .await;
442
443        assert!(result.is_err(), "Expected error but got success");
444        let err = result.unwrap_err();
445        assert!(
446            matches!(err, WalletKitError::RecoveryBindingDoesNotExist),
447            "Expected RecoveryBindingDoesNotExist error, got: {err:?}"
448        );
449        mock.assert_async().await;
450        drop(server);
451    }
452
453    #[tokio::test]
454    async fn test_get_recovery_binding_success() {
455        let mut server = mockito::Server::new_async().await;
456        let url = server.url();
457
458        let mock = server
459            .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
460            .with_status(200)
461            .with_body("{\"recoveryAgent\": \"0x1234567890abcdef\"}")
462            .create_async()
463            .await;
464
465        let pop_api_client =
466            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
467
468        let result = pop_api_client.get_recovery_binding(42).await;
469        assert!(result.is_ok(), "Expected success but got error: {result:?}");
470        mock.assert_async().await;
471        assert_eq!(
472            result.unwrap().recovery_agent,
473            Some("0x1234567890abcdef".to_string())
474        );
475        drop(server);
476    }
477
478    #[tokio::test]
479    async fn test_get_recovery_binding_not_found() {
480        let mut server = mockito::Server::new_async().await;
481        let url = server.url();
482
483        let mock = server
484            .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
485            .with_status(404)
486            .with_body("Recovery agent not found")
487            .create_async()
488            .await;
489
490        let pop_api_client =
491            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
492
493        let result = pop_api_client.get_recovery_binding(42).await;
494        assert!(result.is_err(), "Expected error but got success");
495        let err = result.unwrap_err();
496        assert!(
497            matches!(err, WalletKitError::RecoveryBindingDoesNotExist),
498            "Expected RecoveryBindingDoesNotExist error, got: {err:?}"
499        );
500        mock.assert_async().await;
501        drop(server);
502    }
503
504    #[tokio::test]
505    async fn test_get_recovery_binding_no_pending_recovery_agent() {
506        let mut server = mockito::Server::new_async().await;
507        let url = server.url();
508
509        let mock = server
510            .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
511            .with_status(200)
512            .with_body("{\"recoveryAgent\": \"0x1234567890abcdef\"}")
513            .create_async()
514            .await;
515
516        let pop_api_client =
517            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
518
519        let result = pop_api_client.get_recovery_binding(42).await;
520        assert!(result.is_ok(), "Expected success but got error: {result:?}");
521        mock.assert_async().await;
522        let recovery_binding = result.unwrap();
523        assert_eq!(recovery_binding.pending_recovery_agent, None);
524        assert_eq!(recovery_binding.execute_after, None);
525        assert_eq!(
526            recovery_binding.recovery_agent,
527            Some("0x1234567890abcdef".to_string())
528        );
529        drop(server);
530    }
531
532    #[tokio::test]
533    async fn test_get_recovery_binding_with_pending_recovery_agent() {
534        let mut server = mockito::Server::new_async().await;
535        let url = server.url();
536
537        let mock = server
538            .mock("GET", "/api/v1/recovery-binding?leafIndex=42")
539            .with_status(200)
540            .with_body("{\"recoveryAgent\": \"0x0000000000000000000000000000000000000001\", \"pendingRecoveryAgent\": \"0x0000000000000000000000000000000000000000\", \"executeAfter\": \"0x01\"}")
541            .create_async()
542            .await;
543
544        let pop_api_client =
545            PopBackendClient::new(url.clone(), "test-user-agent".to_string());
546
547        let result = pop_api_client.get_recovery_binding(42).await;
548        assert!(result.is_ok(), "Expected success but got error: {result:?}");
549        mock.assert_async().await;
550        let recovery_binding = result.unwrap();
551        assert_eq!(
552            recovery_binding.pending_recovery_agent,
553            Some("0x0000000000000000000000000000000000000000".to_string())
554        );
555        assert_eq!(recovery_binding.execute_after, Some("0x01".to_string()));
556        assert_eq!(
557            recovery_binding.recovery_agent,
558            Some("0x0000000000000000000000000000000000000001".to_string())
559        );
560        drop(server);
561    }
562}