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