Skip to main content

client_core/
http.rs

1//! REST client for the relay's legacy HTTP+JSON endpoints.
2//!
3//! Targets the same routes the Go CLI uses today: `POST /clips`,
4//! `POST /clips/binary`, `GET /clips/latest`, `GET /devices`,
5//! `POST /auth/device-code`, `GET /auth/device-code/poll`,
6//! `POST /auth/device/revoke`, `POST /auth/key-bundle/retry`.
7//! The legacy `/auth/pair` and `/auth/pair-token/new` routes were
8//! retired in the OAuth-only migration.
9//!
10//! Retry: 3 attempts with exponential backoff (1s, 2s) matching
11//! `cinch/cmd/push.go:188-203`.
12
13use std::time::Duration;
14
15use reqwest::{multipart, Client, StatusCode};
16
17use crate::protocol::{Clip, DeviceInfo};
18use crate::rest::{
19    DeviceCodeCompleteRequest, DeviceCodeDenyRequest, DeviceCodePollResponse, DeviceCodeRequest,
20    DeviceCodeResponse, DeviceRevokeRequest, ErrorResponse, KeyBundlePutRequest, KeyBundleResponse,
21    PullResponse, PushRequest, PushResponse, RegisterDevicePublicKeyRequest,
22};
23
24const MAX_ATTEMPTS: u32 = 3;
25const REQUEST_TIMEOUT_SECS: u64 = 30;
26
27/// A relay-recorded deletion event returned by `GET /tombstones`.
28#[derive(Debug, serde::Deserialize)]
29pub struct Tombstone {
30    pub clip_id: String,
31    pub deleted_at: String, // RFC3339
32}
33
34/// Filter shape for `RestClient::list_clips`. Mirrors the relay's `ListFilter`.
35#[derive(Debug, Default, Clone)]
36pub struct ListClipsFilter {
37    pub limit: u32,
38    pub source: Option<String>,
39    pub exclude_source: Option<String>,
40    pub exclude_image: bool,
41    pub exclude_text: bool,
42    pub clip_ids: Vec<String>,
43}
44
45#[derive(Debug, thiserror::Error)]
46pub enum HttpError {
47    #[error("network: {0}")]
48    Network(String),
49    #[error("auth required (401)")]
50    Unauthorized,
51    #[error("relay error ({status}): {message}")]
52    Relay {
53        status: u16,
54        message: String,
55        fix: String,
56    },
57    #[error("decode response: {0}")]
58    Decode(String),
59    #[error("build request: {0}")]
60    Build(String),
61}
62
63#[derive(Debug, Clone)]
64pub struct RestClient {
65    base_url: String,
66    token: String,
67    client: Client,
68}
69
70impl RestClient {
71    /// Construct a new client. `relay_url` is trimmed of any trailing slash.
72    pub fn new(relay_url: impl Into<String>, token: impl Into<String>) -> Result<Self, HttpError> {
73        let base = relay_url.into().trim_end_matches('/').to_string();
74        let client = Client::builder()
75            .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
76            .build()
77            .map_err(|e| HttpError::Build(e.to_string()))?;
78        Ok(Self {
79            base_url: base,
80            token: token.into(),
81            client,
82        })
83    }
84
85    /// `POST /clips` with JSON body — text and encrypted-binary path.
86    pub async fn push_clip_json(&self, req: &PushRequest) -> Result<PushResponse, HttpError> {
87        let url = format!("{}/clips", self.base_url);
88        let resp = self
89            .send_with_retry(|| {
90                self.client
91                    .post(&url)
92                    .bearer_auth(&self.token)
93                    .json(req)
94                    .build()
95            })
96            .await?;
97        decode_push_response(resp).await
98    }
99
100    /// `POST /clips/binary` — multipart form for unencrypted binary.
101    /// `data` is the raw file bytes; metadata fields are sent as form fields.
102    pub async fn push_clip_binary(
103        &self,
104        data: Vec<u8>,
105        content_type: &str,
106        source: &str,
107        label: Option<&str>,
108        target_device_id: Option<&str>,
109    ) -> Result<PushResponse, HttpError> {
110        let url = format!("{}/clips/binary", self.base_url);
111        let mut last_err: Option<HttpError> = None;
112        for attempt in 0..MAX_ATTEMPTS {
113            if attempt > 0 {
114                tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
115            }
116            // Multipart parts must be rebuilt per attempt because their bodies
117            // are consumed by `.send()`.
118            let mut form = multipart::Form::new()
119                .part(
120                    "file",
121                    multipart::Part::bytes(data.clone()).file_name("upload"),
122                )
123                .text("content_type", content_type.to_string())
124                .text("source", source.to_string());
125            if let Some(l) = label.filter(|s| !s.is_empty()) {
126                form = form.text("label", l.to_string());
127            }
128            if let Some(d) = target_device_id.filter(|s| !s.is_empty()) {
129                form = form.text("target_device_id", d.to_string());
130            }
131            let resp = self
132                .client
133                .post(&url)
134                .bearer_auth(&self.token)
135                .multipart(form)
136                .send()
137                .await;
138            match resp {
139                Ok(r) => return decode_push_response(r).await,
140                Err(e) => last_err = Some(HttpError::Network(e.to_string())),
141            }
142        }
143        Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
144    }
145
146    /// `POST /pull` — request the local Mac clipboard via WS round-trip.
147    pub async fn pull_clipboard(&self) -> Result<PullResponse, HttpError> {
148        let url = format!("{}/pull", self.base_url);
149        let resp = self
150            .send_with_retry(|| self.client.post(&url).bearer_auth(&self.token).build())
151            .await?;
152        decode_json_response::<PullResponse>(resp).await
153    }
154
155    /// `GET /clips/latest?source=...` — most recent clip matching `source`.
156    pub async fn get_latest_clip(&self, source: &str) -> Result<Clip, HttpError> {
157        let url = format!("{}/clips/latest", self.base_url);
158        let resp = self
159            .send_with_retry(|| {
160                self.client
161                    .get(&url)
162                    .bearer_auth(&self.token)
163                    .query(&[("source", source)])
164                    .build()
165            })
166            .await?;
167        decode_json_response::<Clip>(resp).await
168    }
169
170    /// `GET /clips/{id}/media` — raw image bytes for image clips.
171    pub async fn get_clip_media(&self, clip_id: &str) -> Result<Vec<u8>, HttpError> {
172        let url = format!("{}/clips/{}/media", self.base_url, clip_id);
173        let resp = self
174            .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
175            .await?;
176        let status = resp.status();
177        if status == StatusCode::UNAUTHORIZED {
178            return Err(HttpError::Unauthorized);
179        }
180        if !status.is_success() {
181            return Err(HttpError::Relay {
182                status: status.as_u16(),
183                message: format!("Image not found on relay (HTTP {}).", status.as_u16()),
184                fix: String::new(),
185            });
186        }
187        resp.bytes()
188            .await
189            .map(|b| b.to_vec())
190            .map_err(|e| HttpError::Decode(e.to_string()))
191    }
192
193    /// `POST /auth/device-code` — start the device-code flow. The relay
194    /// returns a `verification_uri` for the user to open in a browser.
195    /// `machine_id` is opaque (empty string disables relay-side dedup).
196    pub async fn start_device_code(
197        &self,
198        relay_url: &str,
199        hostname: &str,
200        machine_id: &str,
201        user_hint: Option<&str>,
202    ) -> Result<DeviceCodeResponse, HttpError> {
203        let url = format!("{}/auth/device-code", relay_url.trim_end_matches('/'));
204        let req = DeviceCodeRequest {
205            hostname: Some(hostname.to_string()),
206            machine_id: if machine_id.is_empty() {
207                None
208            } else {
209                Some(machine_id.to_string())
210            },
211            user_hint: user_hint.map(|s| s.to_string()),
212        };
213        let resp = self
214            .client
215            .post(&url)
216            .json(&req)
217            .send()
218            .await
219            .map_err(|e| HttpError::Network(e.to_string()))?;
220        decode_json_response::<DeviceCodeResponse>(resp).await
221    }
222
223    /// `GET /auth/device-code/poll?code=...` — single poll. Caller drives
224    /// the loop and respects `interval` from the start response.
225    pub async fn poll_device_code(
226        &self,
227        relay_url: &str,
228        device_code: &str,
229    ) -> Result<DeviceCodePollResponse, HttpError> {
230        let url = format!("{}/auth/device-code/poll", relay_url.trim_end_matches('/'));
231        let resp = self
232            .client
233            .get(&url)
234            .query(&[("code", device_code)])
235            .send()
236            .await
237            .map_err(|e| HttpError::Network(e.to_string()))?;
238        decode_json_response::<DeviceCodePollResponse>(resp).await
239    }
240
241    /// `POST /auth/device-code/complete` — approve a pending device-code
242    /// login from an already-authenticated local device.
243    pub async fn complete_device_code(&self, user_code: &str) -> Result<(), HttpError> {
244        let url = format!("{}/auth/device-code/complete", self.base_url);
245        let body = DeviceCodeCompleteRequest {
246            user_code: user_code.to_string(),
247            user_id: String::new(),
248            device_id: String::new(),
249            token: String::new(),
250        };
251        let resp = self
252            .client
253            .post(&url)
254            .bearer_auth(&self.token)
255            .json(&body)
256            .send()
257            .await
258            .map_err(|e| HttpError::Network(e.to_string()))?;
259        decode_json_response::<serde_json::Value>(resp)
260            .await
261            .map(|_| ())
262    }
263
264    /// `POST /cinch.v1.AuthService/DeviceCodeDeny` (Connect-RPC unary, JSON encoding)
265    /// — reject a pending device-code login from this already-signed-in device.
266    pub async fn deny_device_code(&self, user_code: &str) -> Result<(), HttpError> {
267        let url = format!("{}/cinch.v1.AuthService/DeviceCodeDeny", self.base_url);
268        let body = DeviceCodeDenyRequest {
269            user_code: user_code.to_string(),
270        };
271        let resp = self
272            .client
273            .post(&url)
274            .bearer_auth(&self.token)
275            .json(&body)
276            .send()
277            .await
278            .map_err(|e| HttpError::Network(e.to_string()))?;
279        decode_json_response::<serde_json::Value>(resp)
280            .await
281            .map(|_| ())
282    }
283
284    /// `GET /health` — liveness probe used by the wizard before issuing a
285    /// device code, so URL typos surface as a clean error before the user
286    /// is sent to a browser.
287    pub async fn probe_relay(&self, relay_url: &str) -> Result<(), HttpError> {
288        let url = format!("{}/health", relay_url.trim_end_matches('/'));
289        let resp = self
290            .client
291            .get(&url)
292            .send()
293            .await
294            .map_err(|e| HttpError::Network(e.to_string()))?;
295        if resp.status().is_success() {
296            Ok(())
297        } else {
298            Err(HttpError::Relay {
299                status: resp.status().as_u16(),
300                message: format!("health check failed: HTTP {}", resp.status().as_u16()),
301                fix: String::new(),
302            })
303        }
304    }
305
306    /// `POST /auth/key-bundle` — publish an encrypted user-key bundle
307    /// for `target_device_id`. Called by any device that holds the
308    /// user's master key when the relay broadcasts a
309    /// `key_exchange_requested` event for a freshly-paired peer.
310    /// `ephemeral_public_key` and `encrypted_bundle` are both
311    /// base64url-encoded. Bearer-authenticated.
312    pub async fn post_key_bundle(
313        &self,
314        target_device_id: &str,
315        ephemeral_public_key: &str,
316        encrypted_bundle: &str,
317    ) -> Result<(), HttpError> {
318        let url = format!("{}/auth/key-bundle", self.base_url);
319        let body = KeyBundlePutRequest {
320            device_id: target_device_id.to_string(),
321            ephemeral_public_key: ephemeral_public_key.to_string(),
322            encrypted_bundle: encrypted_bundle.to_string(),
323        };
324        let resp = self
325            .client
326            .post(&url)
327            .bearer_auth(&self.token)
328            .json(&body)
329            .send()
330            .await
331            .map_err(|e| HttpError::Network(e.to_string()))?;
332        let status = resp.status();
333        if status == StatusCode::UNAUTHORIZED {
334            return Err(HttpError::Unauthorized);
335        }
336        if !status.is_success() {
337            return Err(HttpError::Relay {
338                status: status.as_u16(),
339                message: format!("post key bundle failed: HTTP {}", status.as_u16()),
340                fix: String::new(),
341            });
342        }
343        Ok(())
344    }
345
346    /// `POST /auth/device/public-key` — register the X25519 public key
347    /// for the calling device so the relay can include it in
348    /// ListPendingKeyExchanges sweeps and broadcast
349    /// `key_exchange_requested` events for it. Called once after the
350    /// OAuth-only login flow finishes installing local credentials.
351    /// Bearer-authenticated.
352    pub async fn register_device_public_key(
353        &self,
354        public_key: &str,
355        fingerprint: &str,
356    ) -> Result<(), HttpError> {
357        let url = format!("{}/auth/device/public-key", self.base_url);
358        let body = RegisterDevicePublicKeyRequest {
359            public_key: public_key.to_string(),
360            fingerprint: fingerprint.to_string(),
361        };
362        let resp = self
363            .client
364            .post(&url)
365            .bearer_auth(&self.token)
366            .json(&body)
367            .send()
368            .await
369            .map_err(|e| HttpError::Network(e.to_string()))?;
370        let status = resp.status();
371        if status == StatusCode::UNAUTHORIZED {
372            return Err(HttpError::Unauthorized);
373        }
374        if !status.is_success() {
375            return Err(HttpError::Relay {
376                status: status.as_u16(),
377                message: format!("register public key failed: HTTP {}", status.as_u16()),
378                fix: String::new(),
379            });
380        }
381        Ok(())
382    }
383
384    /// `POST /auth/key-bundle/retry` — ask the relay to re-broadcast
385    /// `key_exchange_requested` for the calling device. Used when the
386    /// initial key handoff missed (no key-bearer was online at login
387    /// time). Bearer-authenticated.
388    pub async fn retry_key_bundle(&self) -> Result<(), HttpError> {
389        let url = format!("{}/auth/key-bundle/retry", self.base_url);
390        let resp = self
391            .client
392            .post(&url)
393            .bearer_auth(&self.token)
394            .send()
395            .await
396            .map_err(|e| HttpError::Network(e.to_string()))?;
397        let status = resp.status();
398        if status == StatusCode::UNAUTHORIZED {
399            return Err(HttpError::Unauthorized);
400        }
401        if !status.is_success() {
402            return Err(HttpError::Relay {
403                status: status.as_u16(),
404                message: format!("retry key bundle failed: HTTP {}", status.as_u16()),
405                fix: String::new(),
406            });
407        }
408        Ok(())
409    }
410
411    /// `POST /auth/device/revoke` — revoke the active device server-side.
412    /// Best-effort: callers should still wipe local credentials regardless
413    /// of relay reachability.
414    pub async fn revoke_device(&self, device_id: &str) -> Result<(), HttpError> {
415        let url = format!("{}/auth/device/revoke", self.base_url);
416        let body = DeviceRevokeRequest {
417            device_id: device_id.to_string(),
418        };
419        let resp = self
420            .client
421            .post(&url)
422            .bearer_auth(&self.token)
423            .json(&body)
424            .send()
425            .await
426            .map_err(|e| HttpError::Network(e.to_string()))?;
427        let status = resp.status();
428        if !status.is_success() {
429            return Err(HttpError::Relay {
430                status: status.as_u16(),
431                message: format!("revoke failed: HTTP {}", status.as_u16()),
432                fix: String::new(),
433            });
434        }
435        Ok(())
436    }
437
438    /// `PUT /devices/{device_id}/nickname` — set or clear a human-readable
439    /// nickname for a paired device. An empty string clears the nickname.
440    /// Task 5.9 uses this path; the desktop `set_device_nickname` command
441    /// delegates here rather than calling reqwest directly.
442    pub async fn set_device_nickname(
443        &self,
444        device_id: &str,
445        nickname: &str,
446    ) -> Result<(), HttpError> {
447        let url = format!("{}/devices/{}/nickname", self.base_url, device_id);
448        #[derive(serde::Serialize)]
449        struct NicknameBody<'a> {
450            nickname: &'a str,
451        }
452        let resp = self
453            .client
454            .put(&url)
455            .bearer_auth(&self.token)
456            .json(&NicknameBody { nickname })
457            .send()
458            .await
459            .map_err(|e| HttpError::Network(e.to_string()))?;
460        let status = resp.status();
461        if !status.is_success() {
462            let body = resp.text().await.unwrap_or_default();
463            return Err(HttpError::Relay {
464                status: status.as_u16(),
465                message: format!("set_device_nickname failed: {}", body),
466                fix: String::new(),
467            });
468        }
469        Ok(())
470    }
471
472    /// `PUT /devices/self/retention` — set this device's remote retention
473    /// (in days). The relay only exposes a self-targeted endpoint; per-device
474    /// retention writes are not supported over REST.
475    pub async fn set_remote_retention(&self, days: i32) -> Result<(), HttpError> {
476        let url = format!("{}/devices/self/retention", self.base_url);
477        #[derive(serde::Serialize)]
478        struct Body {
479            remote_retention_days: i32,
480        }
481        let resp = self
482            .client
483            .put(&url)
484            .bearer_auth(&self.token)
485            .json(&Body {
486                remote_retention_days: days,
487            })
488            .send()
489            .await
490            .map_err(|e| HttpError::Network(e.to_string()))?;
491        let status = resp.status();
492        if !status.is_success() {
493            let body = resp.text().await.unwrap_or_default();
494            return Err(HttpError::Relay {
495                status: status.as_u16(),
496                message: format!("set_remote_retention failed: {}", body),
497                fix: String::new(),
498            });
499        }
500        Ok(())
501    }
502
503    /// `GET /auth/key-bundle` — fetch the encrypted user-key bundle the
504    /// desktop publishes after a pair. Bearer-authenticated.
505    /// Always returns 200; an absent bundle is signalled by empty
506    /// `ephemeral_public_key`/`encrypted_bundle` plus a non-empty
507    /// `pending_since` RFC3339 timestamp, so callers can poll without
508    /// distinguishing "not yet" from "device unknown" via status code.
509    pub async fn get_key_bundle(&self) -> Result<KeyBundleResponse, HttpError> {
510        let url = format!("{}/auth/key-bundle", self.base_url);
511        let resp = self
512            .client
513            .get(&url)
514            .bearer_auth(&self.token)
515            .send()
516            .await
517            .map_err(|e| HttpError::Network(e.to_string()))?;
518        decode_json_response::<KeyBundleResponse>(resp).await
519    }
520
521    /// `GET /tombstones[?since=<rfc3339>]` — deleted clip IDs since a timestamp.
522    pub async fn list_tombstones(
523        &self,
524        since: Option<chrono::DateTime<chrono::Utc>>,
525    ) -> Result<Vec<Tombstone>, HttpError> {
526        let url = format!("{}/tombstones", self.base_url);
527        let resp = self
528            .send_with_retry(|| {
529                let mut req = self.client.get(&url).bearer_auth(&self.token);
530                if let Some(ts) = since {
531                    req = req.query(&[("since", ts.to_rfc3339())]);
532                }
533                req.build()
534            })
535            .await?;
536        decode_json_response::<Vec<Tombstone>>(resp).await
537    }
538
539    /// `GET /clips[?since=<rfc3339>][&limit=<n>]` — list clips, optionally filtered to those
540    /// newer than `since`. Returns oldest-first when `since` is provided.
541    /// `limit` caps the number of results (relay maximum is 100).
542    pub async fn list_clips_since(
543        &self,
544        since: Option<chrono::DateTime<chrono::Utc>>,
545        limit: u32,
546    ) -> Result<Vec<Clip>, HttpError> {
547        let url = format!("{}/clips", self.base_url);
548        let resp = self
549            .send_with_retry(|| {
550                let mut req = self.client.get(&url).bearer_auth(&self.token);
551                if let Some(ts) = since {
552                    req = req.query(&[("since", ts.to_rfc3339())]);
553                }
554                req = req.query(&[("limit", limit.to_string())]);
555                req.build()
556            })
557            .await?;
558        decode_json_response::<Vec<Clip>>(resp).await
559    }
560
561    /// `GET /clips?...` — list clips with the given filter, newest-first.
562    /// Limit is clamped server-side; the client clamps to 200 to match the relay cap.
563    pub async fn list_clips(&self, filter: ListClipsFilter) -> Result<Vec<Clip>, HttpError> {
564        let url = format!("{}/clips", self.base_url);
565        let resp = self
566            .send_with_retry(|| {
567                let mut req = self.client.get(&url).bearer_auth(&self.token);
568                let limit = if filter.limit == 0 {
569                    50
570                } else {
571                    filter.limit.min(200)
572                };
573                req = req.query(&[("limit", limit.to_string())]);
574                if let Some(s) = &filter.source {
575                    req = req.query(&[("source", s.as_str())]);
576                }
577                if let Some(s) = &filter.exclude_source {
578                    req = req.query(&[("exclude_source", s.as_str())]);
579                }
580                if filter.exclude_image {
581                    req = req.query(&[("exclude_image", "true")]);
582                }
583                if filter.exclude_text {
584                    req = req.query(&[("exclude_text", "true")]);
585                }
586                for id in &filter.clip_ids {
587                    req = req.query(&[("clip_id", id.as_str())]);
588                }
589                req.build()
590            })
591            .await?;
592        decode_json_response::<Vec<Clip>>(resp).await
593    }
594
595    /// `GET /clips?clip_id=<id>&limit=1` — fetch one clip by ID.
596    pub async fn get_clip_by_id(&self, clip_id: &str) -> Result<Clip, HttpError> {
597        let clips = self
598            .list_clips(ListClipsFilter {
599                limit: 1,
600                clip_ids: vec![clip_id.to_string()],
601                ..Default::default()
602            })
603            .await?;
604        clips.into_iter().next().ok_or_else(|| HttpError::Relay {
605            status: 404,
606            message: format!("Clip {} not found.", clip_id),
607            fix: String::new(),
608        })
609    }
610
611    /// `GET /clips/latest?exclude_source=<key>` — latest clip whose source != exclude_source.
612    pub async fn get_latest_clip_excluding(&self, exclude_source: &str) -> Result<Clip, HttpError> {
613        let url = format!("{}/clips/latest", self.base_url);
614        let resp = self
615            .send_with_retry(|| {
616                self.client
617                    .get(&url)
618                    .bearer_auth(&self.token)
619                    .query(&[("exclude_source", exclude_source)])
620                    .build()
621            })
622            .await?;
623        decode_json_response::<Clip>(resp).await
624    }
625
626    /// `DELETE /clips/{id}` — remove a clip. 404 is treated as success.
627    pub async fn delete_clip(&self, clip_id: &str) -> Result<(), HttpError> {
628        let url = format!("{}/clips/{}", self.base_url, clip_id);
629        let resp = self
630            .send_with_retry(|| self.client.delete(&url).bearer_auth(&self.token).build())
631            .await?;
632        let status = resp.status();
633        if status == StatusCode::NOT_FOUND || status.is_success() {
634            return Ok(());
635        }
636        if status == StatusCode::UNAUTHORIZED {
637            return Err(HttpError::Unauthorized);
638        }
639        Err(HttpError::Relay {
640            status: status.as_u16(),
641            message: format!("Delete clip failed (HTTP {}).", status.as_u16()),
642            fix: String::new(),
643        })
644    }
645
646    /// `POST /clips/{id}/pin` — set or clear pin state. Best-effort: 404 treated as success.
647    pub async fn set_clip_pin(
648        &self,
649        clip_id: &str,
650        is_pinned: bool,
651        pin_note: Option<&str>,
652    ) -> Result<(), HttpError> {
653        let url = format!("{}/clips/{}/pin", self.base_url, clip_id);
654        #[derive(serde::Serialize)]
655        struct PinBody<'a> {
656            is_pinned: bool,
657            #[serde(skip_serializing_if = "Option::is_none")]
658            pin_note: Option<&'a str>,
659        }
660        let body = PinBody {
661            is_pinned,
662            pin_note,
663        };
664        let resp = self
665            .send_with_retry(|| {
666                self.client
667                    .post(&url)
668                    .bearer_auth(&self.token)
669                    .json(&body)
670                    .build()
671            })
672            .await?;
673        let status = resp.status();
674        if status == StatusCode::NOT_FOUND || status.is_success() {
675            return Ok(());
676        }
677        if status == StatusCode::UNAUTHORIZED {
678            return Err(HttpError::Unauthorized);
679        }
680        Err(HttpError::Relay {
681            status: status.as_u16(),
682            message: format!("Set clip pin failed (HTTP {}).", status.as_u16()),
683            fix: String::new(),
684        })
685    }
686
687    /// `GET /devices` — list of paired devices for the current user.
688    pub async fn list_devices(&self) -> Result<Vec<DeviceInfo>, HttpError> {
689        let url = format!("{}/devices", self.base_url);
690        let resp = self
691            .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
692            .await?;
693        decode_json_response::<Vec<DeviceInfo>>(resp).await
694    }
695
696    async fn send_with_retry<F>(&self, build: F) -> Result<reqwest::Response, HttpError>
697    where
698        F: Fn() -> Result<reqwest::Request, reqwest::Error>,
699    {
700        let mut last_err: Option<HttpError> = None;
701        for attempt in 0..MAX_ATTEMPTS {
702            if attempt > 0 {
703                tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
704            }
705            let req = build().map_err(|e| HttpError::Build(e.to_string()))?;
706            match self.client.execute(req).await {
707                Ok(resp) => return Ok(resp),
708                Err(e) => last_err = Some(HttpError::Network(e.to_string())),
709            }
710        }
711        Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
712    }
713}
714
715async fn decode_push_response(resp: reqwest::Response) -> Result<PushResponse, HttpError> {
716    decode_json_response::<PushResponse>(resp).await
717}
718
719async fn decode_json_response<T: serde::de::DeserializeOwned>(
720    resp: reqwest::Response,
721) -> Result<T, HttpError> {
722    let status = resp.status();
723    if status == StatusCode::UNAUTHORIZED {
724        return Err(HttpError::Unauthorized);
725    }
726    if !status.is_success() {
727        let err: ErrorResponse = resp.json().await.unwrap_or_default();
728        let message = if !err.message.is_empty() {
729            err.message
730        } else {
731            err.error
732        };
733        return Err(HttpError::Relay {
734            status: status.as_u16(),
735            message,
736            fix: err.fix,
737        });
738    }
739    resp.json::<T>()
740        .await
741        .map_err(|e| HttpError::Decode(e.to_string()))
742}
743
744#[cfg(test)]
745mod tests {
746    use crate::proto::cinch::v1::DeviceCodeStartRequest;
747
748    #[test]
749    fn device_code_start_request_includes_user_hint_when_set() {
750        let req = DeviceCodeStartRequest {
751            hostname: Some("dev-box-3".into()),
752            machine_id: Some("m1".into()),
753            user_hint: Some("alice@example.com".into()),
754        };
755        let bytes = serde_json::to_vec(&req).unwrap();
756        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
757        assert_eq!(parsed["user_hint"], "alice@example.com");
758    }
759
760    #[test]
761    fn device_code_start_request_omits_user_hint_when_none() {
762        let req = DeviceCodeStartRequest {
763            hostname: Some("dev-box-3".into()),
764            machine_id: Some("m1".into()),
765            user_hint: None,
766        };
767        let bytes = serde_json::to_vec(&req).unwrap();
768        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
769        assert!(
770            parsed.get("user_hint").is_none(),
771            "user_hint must omit when None"
772        );
773    }
774}