Skip to main content

scrapfly_sdk/
cloud_browser.rs

1//! Cloud Browser API — port of `sdk/go/cloud_browser.go`.
2
3use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
4use reqwest::{Method, Url};
5use serde::{Deserialize, Serialize};
6
7use crate::client::Client;
8use crate::error::{from_response, ScrapflyError};
9
10/// Configuration for a Cloud Browser WebSocket session (passed to
11/// [`cloud_browser_url`]).
12#[derive(Debug, Clone, Default, Serialize)]
13pub struct BrowserConfig {
14    /// Proxy pool.
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub proxy_pool: Option<String>,
17    /// OS fingerprint.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub os: Option<String>,
20    /// Proxy country.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub country: Option<String>,
23    /// Session name.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub session: Option<String>,
26    /// Session timeout (seconds).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub timeout: Option<u32>,
29    /// Block images.
30    #[serde(skip_serializing_if = "is_false")]
31    pub block_images: bool,
32    /// Block stylesheets.
33    #[serde(skip_serializing_if = "is_false")]
34    pub block_styles: bool,
35    /// Block fonts.
36    #[serde(skip_serializing_if = "is_false")]
37    pub block_fonts: bool,
38    /// Block media.
39    #[serde(skip_serializing_if = "is_false")]
40    pub block_media: bool,
41    /// Enable screenshot capability.
42    #[serde(skip_serializing_if = "is_false")]
43    pub screenshot: bool,
44    /// Enable cache.
45    #[serde(skip_serializing_if = "is_false")]
46    pub cache: bool,
47    /// Enable blacklist.
48    #[serde(skip_serializing_if = "is_false")]
49    pub blacklist: bool,
50    /// Debug.
51    #[serde(skip_serializing_if = "is_false")]
52    pub debug: bool,
53    /// Resolution.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub resolution: Option<String>,
56    /// Browser brand.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub browser_brand: Option<String>,
59    /// BYOP proxy URL.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub byop_proxy: Option<String>,
62    /// Enable MCP (Model Context Protocol) support.
63    #[serde(skip_serializing_if = "is_false")]
64    pub enable_mcp: bool,
65    /// Arm Scrapium's built-in captcha detector + solver on the first page attach.
66    /// Turnstile, DataDome slider, reCAPTCHA, GeeTest, PerimeterX hold, and
67    /// puzzle captchas are handled automatically. Billed per solve; failures
68    /// cost nothing. See <https://scrapfly.io/docs/cloud-browser-api/captcha-solver>.
69    #[serde(skip_serializing_if = "is_false")]
70    pub solve_captcha: bool,
71    /// Cloud Browser credential vault NAME to attach to the session — the
72    /// alphanumeric name given at create time. The server resolves the name
73    /// to the vault scoped to the api-key's project and environment, decrypts
74    /// its items with the caller-supplied [`BrowserConfig::vault_key`], and
75    /// pushes them via CDP before the customer takes over. Pair with
76    /// `vault_key`. See <https://scrapfly.io/docs/cloud-browser-api/vault>.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub vault: Option<String>,
79    /// Customer-held base64-encoded 32-byte vault key. Forwarded transiently
80    /// on the WebSocket query string and zeroed by the server after items
81    /// are decrypted. The SDK never logs or echoes this value — see the
82    /// E2EE boundary documentation in the project's HIPAA evidence pack.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub vault_key: Option<String>,
85
86    /// Enable the human-in-the-loop VNC channel.
87    #[serde(skip_serializing_if = "is_false")]
88    pub enable_vnc: bool,
89    /// Customer-chosen VNC password. Required when `enable_vnc` is true and
90    /// `hitl_allowed_networks` is empty.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub vnc_password: Option<String>,
93    /// Enable the human-in-the-loop WebRTC channel.
94    #[serde(skip_serializing_if = "is_false")]
95    pub enable_rtc: bool,
96    /// Optional WebRTC username (defaults to "scrapfly" server-side).
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub rtc_username: Option<String>,
99    /// Customer-chosen WebRTC password. Required when `enable_rtc` is true
100    /// and `hitl_allowed_networks` is empty.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub rtc_password: Option<String>,
103    /// Source IPs / CIDRs trusted to attach to HITL channels (VNC + WebRTC +
104    /// downloads) without credentials. Sent on the wire as a comma-separated
105    /// string.
106    #[serde(skip_serializing_if = "Vec::is_empty")]
107    pub hitl_allowed_networks: Vec<String>,
108}
109
110/// Return the deterministic project salt for an api key (`sha256(api_key)[:8]`).
111/// Matches the `X-Browser-Project-Salt` response header returned on a
112/// successful Cloud Browser WebSocket upgrade.
113pub fn project_salt(api_key: &str) -> String {
114    use sha2::{Digest, Sha256};
115    let digest = Sha256::digest(api_key.as_bytes());
116    hex::encode(&digest[..4])
117}
118
119fn is_false(v: &bool) -> bool {
120    !*v
121}
122
123/// Normalize an arbitrary Cloud Browser host to a `wss://` URL, regardless of
124/// the scheme the caller configured. Accepted input schemes: `https://`
125/// (default), `wss://`, `ws://`, `http://`, and bare `host[:port]`. Mirrors
126/// `sdk/go/cloud_browser.go::wsBase`.
127fn ws_base(host: &str) -> String {
128    if let Some(rest) = host.strip_prefix("wss://") {
129        format!("wss://{}", rest)
130    } else if let Some(rest) = host.strip_prefix("ws://") {
131        format!("ws://{}", rest)
132    } else if let Some(rest) = host.strip_prefix("https://") {
133        format!("wss://{}", rest)
134    } else if let Some(rest) = host.strip_prefix("http://") {
135        format!("ws://{}", rest)
136    } else {
137        format!("wss://{}", host)
138    }
139}
140
141/// Normalize an arbitrary Cloud Browser host to its REST form (`https://` or
142/// `http://`). Callers typically configure a `wss://` / `ws://` host (the CDP
143/// entry point); the REST endpoints (`/unblock`, `/session/.../stop`) live on
144/// the HTTP-equivalent origin. Mirrors `sdk/go/cloud_browser.go::restBase`.
145fn rest_base(host: &str) -> String {
146    if let Some(rest) = host.strip_prefix("wss://") {
147        format!("https://{}", rest)
148    } else if let Some(rest) = host.strip_prefix("ws://") {
149        format!("http://{}", rest)
150    } else if host.starts_with("https://") || host.starts_with("http://") {
151        host.to_string()
152    } else {
153        format!("https://{}", host)
154    }
155}
156
157/// Unblock request body.
158#[derive(Debug, Clone, Default, Serialize)]
159pub struct UnblockConfig {
160    /// Target URL.
161    pub url: String,
162    /// Proxy country.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub country: Option<String>,
165    /// Fingerprint OS: `linux`, `windows`, `macos`.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub os: Option<String>,
168    /// Fingerprint browser brand: `chrome`, `edge`, `brave`, `opera`.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub browser_brand: Option<String>,
171    /// Named session for reconnection — reuses an existing ASP session and
172    /// disables auto-close on disconnect.
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub session: Option<String>,
175    /// Navigation timeout.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub timeout: Option<u32>,
178    /// Browser session timeout.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub browser_timeout: Option<u32>,
181    /// Enable MCP support in the browser.
182    #[serde(skip_serializing_if = "is_false")]
183    pub enable_mcp: bool,
184    /// Record the session for replay via [`Client::cloud_browser_playback`] /
185    /// [`Client::cloud_browser_video`].
186    #[serde(skip_serializing_if = "is_false")]
187    pub debug: bool,
188}
189
190/// Response from `POST /unblock`.
191#[derive(Debug, Clone, Deserialize, Default)]
192pub struct UnblockResult {
193    /// WebSocket URL to connect to.
194    #[serde(default)]
195    pub ws_url: String,
196    /// Session id.
197    #[serde(default)]
198    pub session_id: String,
199    /// Run id.
200    #[serde(default)]
201    pub run_id: String,
202    /// MCP endpoint (only when enable_mcp was set).
203    #[serde(default)]
204    pub mcp_endpoint: String,
205}
206
207impl Client {
208    /// Build the WebSocket URL for a new Cloud Browser session.
209    ///
210    /// On rejection the server sends a JSON error frame then a close frame
211    /// with code 1008/1011/1013 and a "ERR::BROWSER::CODE: reason" string.
212    /// See <https://scrapfly.io/docs/cloud-browser-api/errors#websocket-close-frame>
213    pub fn cloud_browser_url(&self, config: &BrowserConfig) -> String {
214        let ws_host = ws_base(self.cloud_browser_host());
215        let mut pairs: Vec<(String, String)> = vec![("api_key".into(), self.api_key().into())];
216        if let Some(v) = &config.proxy_pool {
217            pairs.push(("proxy_pool".into(), v.clone()));
218        }
219        if let Some(v) = &config.os {
220            pairs.push(("os".into(), v.clone()));
221        }
222        if let Some(v) = &config.country {
223            pairs.push(("country".into(), v.clone()));
224        }
225        if let Some(v) = &config.session {
226            pairs.push(("session".into(), v.clone()));
227        }
228        if let Some(v) = config.timeout {
229            pairs.push(("timeout".into(), v.to_string()));
230        }
231        if config.block_images {
232            pairs.push(("block_images".into(), "true".into()));
233        }
234        if config.block_styles {
235            pairs.push(("block_styles".into(), "true".into()));
236        }
237        if config.block_fonts {
238            pairs.push(("block_fonts".into(), "true".into()));
239        }
240        if config.block_media {
241            pairs.push(("block_media".into(), "true".into()));
242        }
243        if config.screenshot {
244            pairs.push(("screenshot".into(), "true".into()));
245        }
246        if config.cache {
247            pairs.push(("cache".into(), "true".into()));
248        }
249        if config.blacklist {
250            pairs.push(("blacklist".into(), "true".into()));
251        }
252        if config.debug {
253            pairs.push(("debug".into(), "true".into()));
254        }
255        if let Some(v) = &config.resolution {
256            pairs.push(("resolution".into(), v.clone()));
257        }
258        if let Some(v) = &config.browser_brand {
259            pairs.push(("browser_brand".into(), v.clone()));
260        }
261        if let Some(v) = &config.byop_proxy {
262            pairs.push(("byop_proxy".into(), v.clone()));
263        }
264        if config.enable_mcp {
265            pairs.push(("enable_mcp".into(), "true".into()));
266        }
267        if config.solve_captcha {
268            pairs.push(("solve_captcha".into(), "true".into()));
269        }
270        if let Some(v) = &config.vault {
271            pairs.push(("vault".into(), v.clone()));
272        }
273        if let Some(v) = &config.vault_key {
274            pairs.push(("vault_key".into(), v.clone()));
275        }
276        if config.enable_vnc {
277            pairs.push(("enable_vnc".into(), "true".into()));
278        }
279        if let Some(v) = &config.vnc_password {
280            pairs.push(("vnc_password".into(), v.clone()));
281        }
282        if config.enable_rtc {
283            pairs.push(("enable_rtc".into(), "true".into()));
284        }
285        if let Some(v) = &config.rtc_username {
286            pairs.push(("rtc_username".into(), v.clone()));
287        }
288        if let Some(v) = &config.rtc_password {
289            pairs.push(("rtc_password".into(), v.clone()));
290        }
291        if !config.hitl_allowed_networks.is_empty() {
292            pairs.push((
293                "hitl_allowed_networks".into(),
294                config.hitl_allowed_networks.join(","),
295            ));
296        }
297        let qs = serde_urlencoded::to_string(&pairs).unwrap_or_default();
298        format!("{}?{}", ws_host, qs)
299    }
300
301    /// Return the deterministic project salt for this client's api key.
302    /// Matches the `X-Browser-Project-Salt` response header.
303    pub fn cloud_browser_project_salt(&self) -> String {
304        project_salt(self.api_key())
305    }
306
307    /// Call `POST /unblock` to bypass anti-bot protection.
308    pub async fn cloud_browser_unblock(
309        &self,
310        config: &UnblockConfig,
311    ) -> Result<UnblockResult, ScrapflyError> {
312        let url = format!(
313            "{}/unblock?key={}",
314            rest_base(self.cloud_browser_host()),
315            self.api_key()
316        );
317        let url = Url::parse(&url)
318            .map_err(|e| ScrapflyError::Config(format!("invalid unblock url: {}", e)))?;
319        let body = serde_json::to_vec(config)?;
320        let mut headers = HeaderMap::new();
321        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
322        let resp = self
323            .send_with_retry(Method::POST, url, Some(headers), Some(body))
324            .await?;
325        let status = resp.status().as_u16();
326        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
327        if status != 200 {
328            return Err(from_response(status, &body, 0, false));
329        }
330        Ok(serde_json::from_slice(&body)?)
331    }
332
333    /// List browser extensions for the account.
334    pub async fn cloud_browser_extension_list(&self) -> Result<serde_json::Value, ScrapflyError> {
335        let url = format!(
336            "{}/extension?key={}",
337            rest_base(self.cloud_browser_host()),
338            self.api_key()
339        );
340        let url = Url::parse(&url)
341            .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
342        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
343        let status = resp.status().as_u16();
344        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
345        if status != 200 {
346            return Err(from_response(status, &body, 0, false));
347        }
348        Ok(serde_json::from_slice(&body)?)
349    }
350
351    /// Get details of a specific browser extension.
352    pub async fn cloud_browser_extension_get(
353        &self,
354        extension_id: &str,
355    ) -> Result<serde_json::Value, ScrapflyError> {
356        let url = format!(
357            "{}/extension/{}?key={}",
358            rest_base(self.cloud_browser_host()),
359            extension_id,
360            self.api_key()
361        );
362        let url = Url::parse(&url)
363            .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
364        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
365        let status = resp.status().as_u16();
366        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
367        if status != 200 {
368            return Err(from_response(status, &body, 0, false));
369        }
370        Ok(serde_json::from_slice(&body)?)
371    }
372
373    /// Upload a browser extension from a local file (.zip or .crx).
374    pub async fn cloud_browser_extension_upload(
375        &self,
376        file_path: &std::path::Path,
377    ) -> Result<serde_json::Value, ScrapflyError> {
378        let url = format!(
379            "{}/extension?key={}",
380            rest_base(self.cloud_browser_host()),
381            self.api_key()
382        );
383        let url = Url::parse(&url)
384            .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
385        let file_bytes = std::fs::read(file_path)
386            .map_err(|e| ScrapflyError::Config(format!("failed to read extension file: {}", e)))?;
387        let file_name = file_path
388            .file_name()
389            .and_then(|n| n.to_str())
390            .unwrap_or("extension.zip")
391            .to_string();
392        // Build multipart body manually (reqwest multipart feature not enabled)
393        let boundary = format!(
394            "----ScrapflyBoundary{}",
395            std::time::SystemTime::now()
396                .duration_since(std::time::UNIX_EPOCH)
397                .unwrap_or_default()
398                .as_millis()
399        );
400        let mut body = Vec::new();
401        body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
402        body.extend_from_slice(
403            format!(
404                "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n\
405                 Content-Type: application/octet-stream\r\n\r\n",
406                file_name
407            )
408            .as_bytes(),
409        );
410        body.extend_from_slice(&file_bytes);
411        body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
412        let mut headers = HeaderMap::new();
413        headers.insert(
414            CONTENT_TYPE,
415            HeaderValue::from_str(&format!("multipart/form-data; boundary={}", boundary))
416                .map_err(|e| ScrapflyError::Config(format!("invalid content-type: {}", e)))?,
417        );
418        let resp = self
419            .send_with_retry(Method::POST, url, Some(headers), Some(body))
420            .await?;
421        let status = resp.status().as_u16();
422        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
423        if status != 200 && status != 201 {
424            return Err(from_response(status, &body, 0, false));
425        }
426        Ok(serde_json::from_slice(&body)?)
427    }
428
429    /// Delete a browser extension by ID.
430    pub async fn cloud_browser_extension_delete(
431        &self,
432        extension_id: &str,
433    ) -> Result<serde_json::Value, ScrapflyError> {
434        let url = format!(
435            "{}/extension/{}?key={}",
436            rest_base(self.cloud_browser_host()),
437            extension_id,
438            self.api_key()
439        );
440        let url = Url::parse(&url)
441            .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
442        let resp = self
443            .send_with_retry(Method::DELETE, url, None, None)
444            .await?;
445        let status = resp.status().as_u16();
446        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
447        if status != 200 {
448            return Err(from_response(status, &body, 0, false));
449        }
450        Ok(serde_json::from_slice(&body)?)
451    }
452
453    // ------------------------------------------------------------------
454    // Cloud Browser credential vault — E2EE customer-held keys.
455    //
456    // The vault key returned by `cloud_browser_vault_create` /
457    // `cloud_browser_vault_rotate` is the ONLY copy. The SDK forwards it
458    // transiently in the `X-Vault-Key` header on the few endpoints that
459    // need it (item create/update with secret rotation, vault rotate)
460    // and never logs, prints, formats, or otherwise persists it. Loud
461    // rule documented at:
462    //   /root/.claude/projects/-root-scrapfly-apps/memory/agent_secret_tokenization_boundary.md
463    // ------------------------------------------------------------------
464
465    /// Create a new credential vault. The server returns a freshly
466    /// generated vault key under the `key` field of the response — this
467    /// is the only time the key is shown. Save it locally; the server
468    /// cannot recover it.
469    pub async fn cloud_browser_vault_create(
470        &self,
471        name: &str,
472        description: Option<&str>,
473    ) -> Result<serde_json::Value, ScrapflyError> {
474        let url = format!(
475            "{}/vault?key={}",
476            rest_base(self.cloud_browser_host()),
477            self.api_key()
478        );
479        let url = Url::parse(&url)
480            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
481        let body = serde_json::json!({
482            "name": name,
483            "description": description.unwrap_or(""),
484        });
485        let body_bytes = serde_json::to_vec(&body)?;
486        let mut headers = HeaderMap::new();
487        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
488        let resp = self
489            .send_with_retry(Method::POST, url, Some(headers), Some(body_bytes))
490            .await?;
491        let status = resp.status().as_u16();
492        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
493        if status != 200 && status != 201 {
494            return Err(from_response(status, &body, 0, false));
495        }
496        Ok(serde_json::from_slice(&body)?)
497    }
498
499    /// List all credential vaults for the account.
500    pub async fn cloud_browser_vault_list(&self) -> Result<serde_json::Value, ScrapflyError> {
501        let url = format!(
502            "{}/vault?key={}",
503            rest_base(self.cloud_browser_host()),
504            self.api_key()
505        );
506        let url = Url::parse(&url)
507            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
508        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
509        let status = resp.status().as_u16();
510        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
511        if status != 200 {
512            return Err(from_response(status, &body, 0, false));
513        }
514        Ok(serde_json::from_slice(&body)?)
515    }
516
517    /// Get vault metadata. Does NOT include any secret material.
518    pub async fn cloud_browser_vault_get(
519        &self,
520        vault_id: &str,
521    ) -> Result<serde_json::Value, ScrapflyError> {
522        let url = format!(
523            "{}/vault/{}?key={}",
524            rest_base(self.cloud_browser_host()),
525            vault_id,
526            self.api_key()
527        );
528        let url = Url::parse(&url)
529            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
530        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
531        let status = resp.status().as_u16();
532        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
533        if status != 200 {
534            return Err(from_response(status, &body, 0, false));
535        }
536        Ok(serde_json::from_slice(&body)?)
537    }
538
539    /// Rename a vault (and/or update its description). Only the
540    /// non-`None` fields are sent.
541    pub async fn cloud_browser_vault_update(
542        &self,
543        vault_id: &str,
544        name: Option<&str>,
545        description: Option<&str>,
546    ) -> Result<serde_json::Value, ScrapflyError> {
547        let url = format!(
548            "{}/vault/{}?key={}",
549            rest_base(self.cloud_browser_host()),
550            vault_id,
551            self.api_key()
552        );
553        let url = Url::parse(&url)
554            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
555        let mut body_map = serde_json::Map::new();
556        if let Some(v) = name {
557            body_map.insert("name".into(), serde_json::Value::String(v.into()));
558        }
559        if let Some(v) = description {
560            body_map.insert("description".into(), serde_json::Value::String(v.into()));
561        }
562        let body_bytes = serde_json::to_vec(&serde_json::Value::Object(body_map))?;
563        let mut headers = HeaderMap::new();
564        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
565        let resp = self
566            .send_with_retry(Method::PATCH, url, Some(headers), Some(body_bytes))
567            .await?;
568        let status = resp.status().as_u16();
569        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
570        if status != 200 {
571            return Err(from_response(status, &body, 0, false));
572        }
573        Ok(serde_json::from_slice(&body)?)
574    }
575
576    /// Delete a vault by id (cascades to all items).
577    pub async fn cloud_browser_vault_delete(
578        &self,
579        vault_id: &str,
580    ) -> Result<serde_json::Value, ScrapflyError> {
581        let url = format!(
582            "{}/vault/{}?key={}",
583            rest_base(self.cloud_browser_host()),
584            vault_id,
585            self.api_key()
586        );
587        let url = Url::parse(&url)
588            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
589        let resp = self
590            .send_with_retry(Method::DELETE, url, None, None)
591            .await?;
592        let status = resp.status().as_u16();
593        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
594        if status != 200 {
595            return Err(from_response(status, &body, 0, false));
596        }
597        Ok(serde_json::from_slice(&body)?)
598    }
599
600    /// Rotate a vault's key. Requires the CURRENT key in the
601    /// `X-Vault-Key` header; the server returns a fresh key in the
602    /// response body. After this call the old key is permanently
603    /// invalid for this vault. The current key is forwarded to the
604    /// server transiently and never logged by the SDK.
605    pub async fn cloud_browser_vault_rotate(
606        &self,
607        vault_id: &str,
608        current_vault_key: &str,
609    ) -> Result<serde_json::Value, ScrapflyError> {
610        let url = format!(
611            "{}/vault/{}/rotate?key={}",
612            rest_base(self.cloud_browser_host()),
613            vault_id,
614            self.api_key()
615        );
616        let url = Url::parse(&url)
617            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
618        let mut headers = HeaderMap::new();
619        headers.insert(
620            "X-Vault-Key",
621            HeaderValue::from_str(current_vault_key)
622                // Generic message — never echo the key value back.
623                .map_err(|_| ScrapflyError::Config("X-Vault-Key contained invalid bytes".into()))?,
624        );
625        let resp = self
626            .send_with_retry(Method::POST, url, Some(headers), None)
627            .await?;
628        let status = resp.status().as_u16();
629        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
630        if status != 200 {
631            return Err(from_response(status, &body, 0, false));
632        }
633        Ok(serde_json::from_slice(&body)?)
634    }
635
636    /// List items in a vault. Items include the encrypted `secret_blob`
637    /// but not plaintext — the blob is meaningless without the
638    /// customer-held key.
639    pub async fn cloud_browser_vault_item_list(
640        &self,
641        vault_id: &str,
642    ) -> Result<serde_json::Value, ScrapflyError> {
643        let url = format!(
644            "{}/vault/{}/item?key={}",
645            rest_base(self.cloud_browser_host()),
646            vault_id,
647            self.api_key()
648        );
649        let url = Url::parse(&url)
650            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
651        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
652        let status = resp.status().as_u16();
653        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
654        if status != 200 {
655            return Err(from_response(status, &body, 0, false));
656        }
657        Ok(serde_json::from_slice(&body)?)
658    }
659
660    /// Create a new item in a vault. The vault key is required to
661    /// envelope-encrypt the per-item DEK; it is forwarded transiently
662    /// in the `X-Vault-Key` header. Caller-supplied `item` JSON shape
663    /// follows the documented contract (see itemCreateRequest in
664    /// pkg/vault/controller.go) — typically:
665    ///
666    /// ```json
667    /// {
668    ///   "type": "password",
669    ///   "label": "...",
670    ///   "origin": "https://example.com",
671    ///   "username": "user",
672    ///   "secret": {"password": "hunter2"}
673    /// }
674    /// ```
675    pub async fn cloud_browser_vault_item_create(
676        &self,
677        vault_id: &str,
678        vault_key: &str,
679        item: serde_json::Value,
680    ) -> Result<serde_json::Value, ScrapflyError> {
681        let url = format!(
682            "{}/vault/{}/item?key={}",
683            rest_base(self.cloud_browser_host()),
684            vault_id,
685            self.api_key()
686        );
687        let url = Url::parse(&url)
688            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
689        let body_bytes = serde_json::to_vec(&item)?;
690        let mut headers = HeaderMap::new();
691        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
692        headers.insert(
693            "X-Vault-Key",
694            HeaderValue::from_str(vault_key)
695                .map_err(|_| ScrapflyError::Config("X-Vault-Key contained invalid bytes".into()))?,
696        );
697        let resp = self
698            .send_with_retry(Method::POST, url, Some(headers), Some(body_bytes))
699            .await?;
700        let status = resp.status().as_u16();
701        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
702        if status != 200 && status != 201 {
703            return Err(from_response(status, &body, 0, false));
704        }
705        Ok(serde_json::from_slice(&body)?)
706    }
707
708    /// Patch an existing item. `vault_key` is REQUIRED iff the patch
709    /// rotates the secret payload (i.e. `secret` is present in
710    /// `patch`); for pure metadata edits (label, origin, username,
711    /// metadata) the server accepts the request without an
712    /// `X-Vault-Key` header. The SDK forwards the key only when
713    /// supplied — it does not auto-detect the patch shape.
714    pub async fn cloud_browser_vault_item_update(
715        &self,
716        vault_id: &str,
717        item_id: &str,
718        vault_key: Option<&str>,
719        patch: serde_json::Value,
720    ) -> Result<serde_json::Value, ScrapflyError> {
721        let url = format!(
722            "{}/vault/{}/item/{}?key={}",
723            rest_base(self.cloud_browser_host()),
724            vault_id,
725            item_id,
726            self.api_key()
727        );
728        let url = Url::parse(&url)
729            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
730        let body_bytes = serde_json::to_vec(&patch)?;
731        let mut headers = HeaderMap::new();
732        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
733        if let Some(k) = vault_key {
734            headers.insert(
735                "X-Vault-Key",
736                HeaderValue::from_str(k).map_err(|_| {
737                    ScrapflyError::Config("X-Vault-Key contained invalid bytes".into())
738                })?,
739            );
740        }
741        let resp = self
742            .send_with_retry(Method::PATCH, url, Some(headers), Some(body_bytes))
743            .await?;
744        let status = resp.status().as_u16();
745        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
746        if status != 200 {
747            return Err(from_response(status, &body, 0, false));
748        }
749        Ok(serde_json::from_slice(&body)?)
750    }
751
752    /// Delete a single vault item.
753    pub async fn cloud_browser_vault_item_delete(
754        &self,
755        vault_id: &str,
756        item_id: &str,
757    ) -> Result<serde_json::Value, ScrapflyError> {
758        let url = format!(
759            "{}/vault/{}/item/{}?key={}",
760            rest_base(self.cloud_browser_host()),
761            vault_id,
762            item_id,
763            self.api_key()
764        );
765        let url = Url::parse(&url)
766            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
767        let resp = self
768            .send_with_retry(Method::DELETE, url, None, None)
769            .await?;
770        let status = resp.status().as_u16();
771        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
772        if status != 200 {
773            return Err(from_response(status, &body, 0, false));
774        }
775        Ok(serde_json::from_slice(&body)?)
776    }
777
778    /// Get debug recording playback metadata for a run. The response carries
779    /// `available`, `status` (one of `ready`, `uploading`, `unavailable`,
780    /// `disabled`), `metadata`, `video_url`, and `retry_after_ms`.
781    pub async fn cloud_browser_playback(
782        &self,
783        run_id: &str,
784    ) -> Result<serde_json::Value, ScrapflyError> {
785        let url = format!(
786            "{}/run/{}/playback?key={}",
787            rest_base(self.cloud_browser_host()),
788            run_id,
789            self.api_key()
790        );
791        let url = Url::parse(&url)
792            .map_err(|e| ScrapflyError::Config(format!("invalid playback url: {}", e)))?;
793        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
794        let status = resp.status().as_u16();
795        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
796        if status != 200 {
797            return Err(from_response(status, &body, 0, false));
798        }
799        Ok(serde_json::from_slice(&body)?)
800    }
801
802    /// Poll the playback endpoint until the recording resolves to a terminal
803    /// state (`ready` or `unavailable`) or the timeout elapses. Honours the
804    /// server's `retry_after_ms` hint when present.
805    pub async fn cloud_browser_wait_for_playback(
806        &self,
807        run_id: &str,
808        timeout: std::time::Duration,
809        fallback_interval: std::time::Duration,
810    ) -> Result<serde_json::Value, ScrapflyError> {
811        let deadline = std::time::Instant::now() + timeout;
812        loop {
813            let playback = self.cloud_browser_playback(run_id).await?;
814            let status = playback
815                .get("status")
816                .and_then(|v| v.as_str())
817                .unwrap_or("");
818            if status != "uploading" {
819                return Ok(playback);
820            }
821            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
822            if remaining.is_zero() {
823                return Ok(playback);
824            }
825            let retry_after_ms = playback
826                .get("retry_after_ms")
827                .and_then(|v| v.as_u64())
828                .unwrap_or(0);
829            let next = if retry_after_ms > 0 {
830                std::time::Duration::from_millis(retry_after_ms)
831            } else {
832                fallback_interval
833            };
834            tokio::time::sleep(next.min(remaining)).await;
835        }
836    }
837
838    /// Terminate a Cloud Browser session.
839    pub async fn cloud_browser_session_stop(&self, session_id: &str) -> Result<(), ScrapflyError> {
840        if session_id.is_empty() {
841            return Err(ScrapflyError::Config("session_id is required".into()));
842        }
843        let url = format!(
844            "{}/session/{}/stop?key={}",
845            rest_base(self.cloud_browser_host()),
846            session_id,
847            self.api_key()
848        );
849        let url = Url::parse(&url)
850            .map_err(|e| ScrapflyError::Config(format!("invalid session url: {}", e)))?;
851        let resp = self.send_with_retry(Method::POST, url, None, None).await?;
852        let status = resp.status().as_u16();
853        if status != 200 {
854            let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
855            return Err(from_response(status, &body, 0, false));
856        }
857        Ok(())
858    }
859
860    /// List all running Cloud Browser sessions.
861    pub async fn cloud_browser_sessions(&self) -> Result<serde_json::Value, ScrapflyError> {
862        let url = format!(
863            "{}/sessions?key={}",
864            rest_base(self.cloud_browser_host()),
865            self.api_key()
866        );
867        let url = Url::parse(&url)
868            .map_err(|e| ScrapflyError::Config(format!("invalid sessions url: {}", e)))?;
869        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
870        let status = resp.status().as_u16();
871        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
872        if status != 200 {
873            return Err(from_response(status, &body, 0, false));
874        }
875        Ok(serde_json::from_slice(&body)?)
876    }
877
878    /// Download a debug session recording video (raw bytes).
879    pub async fn cloud_browser_video(&self, run_id: &str) -> Result<Vec<u8>, ScrapflyError> {
880        let url = format!(
881            "{}/run/{}/video?key={}",
882            rest_base(self.cloud_browser_host()),
883            run_id,
884            self.api_key()
885        );
886        let url = Url::parse(&url)
887            .map_err(|e| ScrapflyError::Config(format!("invalid video url: {}", e)))?;
888        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
889        let status = resp.status().as_u16();
890        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
891        if status != 200 {
892            return Err(from_response(status, &body, 0, false));
893        }
894        Ok(body.to_vec())
895    }
896}