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    /// Navigation timeout.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub timeout: Option<u32>,
174    /// Browser session timeout.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub browser_timeout: Option<u32>,
177    /// Enable MCP support in the browser.
178    #[serde(skip_serializing_if = "is_false")]
179    pub enable_mcp: bool,
180    /// Record the session for replay via [`Client::cloud_browser_playback`] /
181    /// [`Client::cloud_browser_video`].
182    #[serde(skip_serializing_if = "is_false")]
183    pub debug: bool,
184}
185
186/// Response from `POST /unblock`.
187#[derive(Debug, Clone, Deserialize, Default)]
188pub struct UnblockResult {
189    /// WebSocket URL to connect to.
190    #[serde(default)]
191    pub ws_url: String,
192    /// Session id.
193    #[serde(default)]
194    pub session_id: String,
195    /// Run id.
196    #[serde(default)]
197    pub run_id: String,
198    /// MCP endpoint (only when enable_mcp was set).
199    #[serde(default)]
200    pub mcp_endpoint: String,
201}
202
203impl Client {
204    /// Build the WebSocket URL for a new Cloud Browser session.
205    ///
206    /// On rejection the server sends a JSON error frame then a close frame
207    /// with code 1008/1011/1013 and a "ERR::BROWSER::CODE: reason" string.
208    /// See <https://scrapfly.io/docs/cloud-browser-api/errors#websocket-close-frame>
209    pub fn cloud_browser_url(&self, config: &BrowserConfig) -> String {
210        let ws_host = ws_base(self.cloud_browser_host());
211        let mut pairs: Vec<(String, String)> = vec![("api_key".into(), self.api_key().into())];
212        if let Some(v) = &config.proxy_pool {
213            pairs.push(("proxy_pool".into(), v.clone()));
214        }
215        if let Some(v) = &config.os {
216            pairs.push(("os".into(), v.clone()));
217        }
218        if let Some(v) = &config.country {
219            pairs.push(("country".into(), v.clone()));
220        }
221        if let Some(v) = &config.session {
222            pairs.push(("session".into(), v.clone()));
223        }
224        if let Some(v) = config.timeout {
225            pairs.push(("timeout".into(), v.to_string()));
226        }
227        if config.block_images {
228            pairs.push(("block_images".into(), "true".into()));
229        }
230        if config.block_styles {
231            pairs.push(("block_styles".into(), "true".into()));
232        }
233        if config.block_fonts {
234            pairs.push(("block_fonts".into(), "true".into()));
235        }
236        if config.block_media {
237            pairs.push(("block_media".into(), "true".into()));
238        }
239        if config.screenshot {
240            pairs.push(("screenshot".into(), "true".into()));
241        }
242        if config.cache {
243            pairs.push(("cache".into(), "true".into()));
244        }
245        if config.blacklist {
246            pairs.push(("blacklist".into(), "true".into()));
247        }
248        if config.debug {
249            pairs.push(("debug".into(), "true".into()));
250        }
251        if let Some(v) = &config.resolution {
252            pairs.push(("resolution".into(), v.clone()));
253        }
254        if let Some(v) = &config.browser_brand {
255            pairs.push(("browser_brand".into(), v.clone()));
256        }
257        if let Some(v) = &config.byop_proxy {
258            pairs.push(("byop_proxy".into(), v.clone()));
259        }
260        if config.enable_mcp {
261            pairs.push(("enable_mcp".into(), "true".into()));
262        }
263        if config.solve_captcha {
264            pairs.push(("solve_captcha".into(), "true".into()));
265        }
266        if let Some(v) = &config.vault {
267            pairs.push(("vault".into(), v.clone()));
268        }
269        if let Some(v) = &config.vault_key {
270            pairs.push(("vault_key".into(), v.clone()));
271        }
272        if config.enable_vnc {
273            pairs.push(("enable_vnc".into(), "true".into()));
274        }
275        if let Some(v) = &config.vnc_password {
276            pairs.push(("vnc_password".into(), v.clone()));
277        }
278        if config.enable_rtc {
279            pairs.push(("enable_rtc".into(), "true".into()));
280        }
281        if let Some(v) = &config.rtc_username {
282            pairs.push(("rtc_username".into(), v.clone()));
283        }
284        if let Some(v) = &config.rtc_password {
285            pairs.push(("rtc_password".into(), v.clone()));
286        }
287        if !config.hitl_allowed_networks.is_empty() {
288            pairs.push((
289                "hitl_allowed_networks".into(),
290                config.hitl_allowed_networks.join(","),
291            ));
292        }
293        let qs = serde_urlencoded::to_string(&pairs).unwrap_or_default();
294        format!("{}?{}", ws_host, qs)
295    }
296
297    /// Return the deterministic project salt for this client's api key.
298    /// Matches the `X-Browser-Project-Salt` response header.
299    pub fn cloud_browser_project_salt(&self) -> String {
300        project_salt(self.api_key())
301    }
302
303    /// Call `POST /unblock` to bypass anti-bot protection.
304    pub async fn cloud_browser_unblock(
305        &self,
306        config: &UnblockConfig,
307    ) -> Result<UnblockResult, ScrapflyError> {
308        let url = format!(
309            "{}/unblock?key={}",
310            rest_base(self.cloud_browser_host()),
311            self.api_key()
312        );
313        let url = Url::parse(&url)
314            .map_err(|e| ScrapflyError::Config(format!("invalid unblock url: {}", e)))?;
315        let body = serde_json::to_vec(config)?;
316        let mut headers = HeaderMap::new();
317        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
318        let resp = self
319            .send_with_retry(Method::POST, url, Some(headers), Some(body))
320            .await?;
321        let status = resp.status().as_u16();
322        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
323        if status != 200 {
324            return Err(from_response(status, &body, 0, false));
325        }
326        Ok(serde_json::from_slice(&body)?)
327    }
328
329    /// List browser extensions for the account.
330    pub async fn cloud_browser_extension_list(&self) -> Result<serde_json::Value, ScrapflyError> {
331        let url = format!(
332            "{}/extension?key={}",
333            rest_base(self.cloud_browser_host()),
334            self.api_key()
335        );
336        let url = Url::parse(&url)
337            .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
338        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
339        let status = resp.status().as_u16();
340        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
341        if status != 200 {
342            return Err(from_response(status, &body, 0, false));
343        }
344        Ok(serde_json::from_slice(&body)?)
345    }
346
347    /// Get details of a specific browser extension.
348    pub async fn cloud_browser_extension_get(
349        &self,
350        extension_id: &str,
351    ) -> Result<serde_json::Value, ScrapflyError> {
352        let url = format!(
353            "{}/extension/{}?key={}",
354            rest_base(self.cloud_browser_host()),
355            extension_id,
356            self.api_key()
357        );
358        let url = Url::parse(&url)
359            .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
360        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
361        let status = resp.status().as_u16();
362        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
363        if status != 200 {
364            return Err(from_response(status, &body, 0, false));
365        }
366        Ok(serde_json::from_slice(&body)?)
367    }
368
369    /// Upload a browser extension from a local file (.zip or .crx).
370    pub async fn cloud_browser_extension_upload(
371        &self,
372        file_path: &std::path::Path,
373    ) -> Result<serde_json::Value, ScrapflyError> {
374        let url = format!(
375            "{}/extension?key={}",
376            rest_base(self.cloud_browser_host()),
377            self.api_key()
378        );
379        let url = Url::parse(&url)
380            .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
381        let file_bytes = std::fs::read(file_path)
382            .map_err(|e| ScrapflyError::Config(format!("failed to read extension file: {}", e)))?;
383        let file_name = file_path
384            .file_name()
385            .and_then(|n| n.to_str())
386            .unwrap_or("extension.zip")
387            .to_string();
388        // Build multipart body manually (reqwest multipart feature not enabled)
389        let boundary = format!(
390            "----ScrapflyBoundary{}",
391            std::time::SystemTime::now()
392                .duration_since(std::time::UNIX_EPOCH)
393                .unwrap_or_default()
394                .as_millis()
395        );
396        let mut body = Vec::new();
397        body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
398        body.extend_from_slice(
399            format!(
400                "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n\
401                 Content-Type: application/octet-stream\r\n\r\n",
402                file_name
403            )
404            .as_bytes(),
405        );
406        body.extend_from_slice(&file_bytes);
407        body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
408        let mut headers = HeaderMap::new();
409        headers.insert(
410            CONTENT_TYPE,
411            HeaderValue::from_str(&format!("multipart/form-data; boundary={}", boundary))
412                .map_err(|e| ScrapflyError::Config(format!("invalid content-type: {}", e)))?,
413        );
414        let resp = self
415            .send_with_retry(Method::POST, url, Some(headers), Some(body))
416            .await?;
417        let status = resp.status().as_u16();
418        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
419        if status != 200 && status != 201 {
420            return Err(from_response(status, &body, 0, false));
421        }
422        Ok(serde_json::from_slice(&body)?)
423    }
424
425    /// Delete a browser extension by ID.
426    pub async fn cloud_browser_extension_delete(
427        &self,
428        extension_id: &str,
429    ) -> Result<serde_json::Value, ScrapflyError> {
430        let url = format!(
431            "{}/extension/{}?key={}",
432            rest_base(self.cloud_browser_host()),
433            extension_id,
434            self.api_key()
435        );
436        let url = Url::parse(&url)
437            .map_err(|e| ScrapflyError::Config(format!("invalid extension url: {}", e)))?;
438        let resp = self
439            .send_with_retry(Method::DELETE, url, None, None)
440            .await?;
441        let status = resp.status().as_u16();
442        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
443        if status != 200 {
444            return Err(from_response(status, &body, 0, false));
445        }
446        Ok(serde_json::from_slice(&body)?)
447    }
448
449    // ------------------------------------------------------------------
450    // Cloud Browser credential vault — E2EE customer-held keys.
451    //
452    // The vault key returned by `cloud_browser_vault_create` /
453    // `cloud_browser_vault_rotate` is the ONLY copy. The SDK forwards it
454    // transiently in the `X-Vault-Key` header on the few endpoints that
455    // need it (item create/update with secret rotation, vault rotate)
456    // and never logs, prints, formats, or otherwise persists it. Loud
457    // rule documented at:
458    //   /root/.claude/projects/-root-scrapfly-apps/memory/agent_secret_tokenization_boundary.md
459    // ------------------------------------------------------------------
460
461    /// Create a new credential vault. The server returns a freshly
462    /// generated vault key under the `key` field of the response — this
463    /// is the only time the key is shown. Save it locally; the server
464    /// cannot recover it.
465    pub async fn cloud_browser_vault_create(
466        &self,
467        name: &str,
468        description: Option<&str>,
469    ) -> Result<serde_json::Value, ScrapflyError> {
470        let url = format!(
471            "{}/vault?key={}",
472            rest_base(self.cloud_browser_host()),
473            self.api_key()
474        );
475        let url = Url::parse(&url)
476            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
477        let body = serde_json::json!({
478            "name": name,
479            "description": description.unwrap_or(""),
480        });
481        let body_bytes = serde_json::to_vec(&body)?;
482        let mut headers = HeaderMap::new();
483        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
484        let resp = self
485            .send_with_retry(Method::POST, url, Some(headers), Some(body_bytes))
486            .await?;
487        let status = resp.status().as_u16();
488        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
489        if status != 200 && status != 201 {
490            return Err(from_response(status, &body, 0, false));
491        }
492        Ok(serde_json::from_slice(&body)?)
493    }
494
495    /// List all credential vaults for the account.
496    pub async fn cloud_browser_vault_list(&self) -> Result<serde_json::Value, ScrapflyError> {
497        let url = format!(
498            "{}/vault?key={}",
499            rest_base(self.cloud_browser_host()),
500            self.api_key()
501        );
502        let url = Url::parse(&url)
503            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
504        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
505        let status = resp.status().as_u16();
506        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
507        if status != 200 {
508            return Err(from_response(status, &body, 0, false));
509        }
510        Ok(serde_json::from_slice(&body)?)
511    }
512
513    /// Get vault metadata. Does NOT include any secret material.
514    pub async fn cloud_browser_vault_get(
515        &self,
516        vault_id: &str,
517    ) -> Result<serde_json::Value, ScrapflyError> {
518        let url = format!(
519            "{}/vault/{}?key={}",
520            rest_base(self.cloud_browser_host()),
521            vault_id,
522            self.api_key()
523        );
524        let url = Url::parse(&url)
525            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
526        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
527        let status = resp.status().as_u16();
528        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
529        if status != 200 {
530            return Err(from_response(status, &body, 0, false));
531        }
532        Ok(serde_json::from_slice(&body)?)
533    }
534
535    /// Rename a vault (and/or update its description). Only the
536    /// non-`None` fields are sent.
537    pub async fn cloud_browser_vault_update(
538        &self,
539        vault_id: &str,
540        name: Option<&str>,
541        description: Option<&str>,
542    ) -> Result<serde_json::Value, ScrapflyError> {
543        let url = format!(
544            "{}/vault/{}?key={}",
545            rest_base(self.cloud_browser_host()),
546            vault_id,
547            self.api_key()
548        );
549        let url = Url::parse(&url)
550            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
551        let mut body_map = serde_json::Map::new();
552        if let Some(v) = name {
553            body_map.insert("name".into(), serde_json::Value::String(v.into()));
554        }
555        if let Some(v) = description {
556            body_map.insert("description".into(), serde_json::Value::String(v.into()));
557        }
558        let body_bytes = serde_json::to_vec(&serde_json::Value::Object(body_map))?;
559        let mut headers = HeaderMap::new();
560        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
561        let resp = self
562            .send_with_retry(Method::PATCH, url, Some(headers), Some(body_bytes))
563            .await?;
564        let status = resp.status().as_u16();
565        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
566        if status != 200 {
567            return Err(from_response(status, &body, 0, false));
568        }
569        Ok(serde_json::from_slice(&body)?)
570    }
571
572    /// Delete a vault by id (cascades to all items).
573    pub async fn cloud_browser_vault_delete(
574        &self,
575        vault_id: &str,
576    ) -> Result<serde_json::Value, ScrapflyError> {
577        let url = format!(
578            "{}/vault/{}?key={}",
579            rest_base(self.cloud_browser_host()),
580            vault_id,
581            self.api_key()
582        );
583        let url = Url::parse(&url)
584            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
585        let resp = self
586            .send_with_retry(Method::DELETE, url, None, None)
587            .await?;
588        let status = resp.status().as_u16();
589        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
590        if status != 200 {
591            return Err(from_response(status, &body, 0, false));
592        }
593        Ok(serde_json::from_slice(&body)?)
594    }
595
596    /// Rotate a vault's key. Requires the CURRENT key in the
597    /// `X-Vault-Key` header; the server returns a fresh key in the
598    /// response body. After this call the old key is permanently
599    /// invalid for this vault. The current key is forwarded to the
600    /// server transiently and never logged by the SDK.
601    pub async fn cloud_browser_vault_rotate(
602        &self,
603        vault_id: &str,
604        current_vault_key: &str,
605    ) -> Result<serde_json::Value, ScrapflyError> {
606        let url = format!(
607            "{}/vault/{}/rotate?key={}",
608            rest_base(self.cloud_browser_host()),
609            vault_id,
610            self.api_key()
611        );
612        let url = Url::parse(&url)
613            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
614        let mut headers = HeaderMap::new();
615        headers.insert(
616            "X-Vault-Key",
617            HeaderValue::from_str(current_vault_key)
618                // Generic message — never echo the key value back.
619                .map_err(|_| ScrapflyError::Config("X-Vault-Key contained invalid bytes".into()))?,
620        );
621        let resp = self
622            .send_with_retry(Method::POST, url, Some(headers), None)
623            .await?;
624        let status = resp.status().as_u16();
625        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
626        if status != 200 {
627            return Err(from_response(status, &body, 0, false));
628        }
629        Ok(serde_json::from_slice(&body)?)
630    }
631
632    /// List items in a vault. Items include the encrypted `secret_blob`
633    /// but not plaintext — the blob is meaningless without the
634    /// customer-held key.
635    pub async fn cloud_browser_vault_item_list(
636        &self,
637        vault_id: &str,
638    ) -> Result<serde_json::Value, ScrapflyError> {
639        let url = format!(
640            "{}/vault/{}/item?key={}",
641            rest_base(self.cloud_browser_host()),
642            vault_id,
643            self.api_key()
644        );
645        let url = Url::parse(&url)
646            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
647        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
648        let status = resp.status().as_u16();
649        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
650        if status != 200 {
651            return Err(from_response(status, &body, 0, false));
652        }
653        Ok(serde_json::from_slice(&body)?)
654    }
655
656    /// Create a new item in a vault. The vault key is required to
657    /// envelope-encrypt the per-item DEK; it is forwarded transiently
658    /// in the `X-Vault-Key` header. Caller-supplied `item` JSON shape
659    /// follows the documented contract (see itemCreateRequest in
660    /// pkg/vault/controller.go) — typically:
661    ///
662    /// ```json
663    /// {
664    ///   "type": "password",
665    ///   "label": "...",
666    ///   "origin": "https://example.com",
667    ///   "username": "user",
668    ///   "secret": {"password": "hunter2"}
669    /// }
670    /// ```
671    pub async fn cloud_browser_vault_item_create(
672        &self,
673        vault_id: &str,
674        vault_key: &str,
675        item: serde_json::Value,
676    ) -> Result<serde_json::Value, ScrapflyError> {
677        let url = format!(
678            "{}/vault/{}/item?key={}",
679            rest_base(self.cloud_browser_host()),
680            vault_id,
681            self.api_key()
682        );
683        let url = Url::parse(&url)
684            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
685        let body_bytes = serde_json::to_vec(&item)?;
686        let mut headers = HeaderMap::new();
687        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
688        headers.insert(
689            "X-Vault-Key",
690            HeaderValue::from_str(vault_key)
691                .map_err(|_| ScrapflyError::Config("X-Vault-Key contained invalid bytes".into()))?,
692        );
693        let resp = self
694            .send_with_retry(Method::POST, url, Some(headers), Some(body_bytes))
695            .await?;
696        let status = resp.status().as_u16();
697        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
698        if status != 200 && status != 201 {
699            return Err(from_response(status, &body, 0, false));
700        }
701        Ok(serde_json::from_slice(&body)?)
702    }
703
704    /// Patch an existing item. `vault_key` is REQUIRED iff the patch
705    /// rotates the secret payload (i.e. `secret` is present in
706    /// `patch`); for pure metadata edits (label, origin, username,
707    /// metadata) the server accepts the request without an
708    /// `X-Vault-Key` header. The SDK forwards the key only when
709    /// supplied — it does not auto-detect the patch shape.
710    pub async fn cloud_browser_vault_item_update(
711        &self,
712        vault_id: &str,
713        item_id: &str,
714        vault_key: Option<&str>,
715        patch: serde_json::Value,
716    ) -> Result<serde_json::Value, ScrapflyError> {
717        let url = format!(
718            "{}/vault/{}/item/{}?key={}",
719            rest_base(self.cloud_browser_host()),
720            vault_id,
721            item_id,
722            self.api_key()
723        );
724        let url = Url::parse(&url)
725            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
726        let body_bytes = serde_json::to_vec(&patch)?;
727        let mut headers = HeaderMap::new();
728        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
729        if let Some(k) = vault_key {
730            headers.insert(
731                "X-Vault-Key",
732                HeaderValue::from_str(k).map_err(|_| {
733                    ScrapflyError::Config("X-Vault-Key contained invalid bytes".into())
734                })?,
735            );
736        }
737        let resp = self
738            .send_with_retry(Method::PATCH, url, Some(headers), Some(body_bytes))
739            .await?;
740        let status = resp.status().as_u16();
741        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
742        if status != 200 {
743            return Err(from_response(status, &body, 0, false));
744        }
745        Ok(serde_json::from_slice(&body)?)
746    }
747
748    /// Delete a single vault item.
749    pub async fn cloud_browser_vault_item_delete(
750        &self,
751        vault_id: &str,
752        item_id: &str,
753    ) -> Result<serde_json::Value, ScrapflyError> {
754        let url = format!(
755            "{}/vault/{}/item/{}?key={}",
756            rest_base(self.cloud_browser_host()),
757            vault_id,
758            item_id,
759            self.api_key()
760        );
761        let url = Url::parse(&url)
762            .map_err(|e| ScrapflyError::Config(format!("invalid vault url: {}", e)))?;
763        let resp = self
764            .send_with_retry(Method::DELETE, url, None, None)
765            .await?;
766        let status = resp.status().as_u16();
767        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
768        if status != 200 {
769            return Err(from_response(status, &body, 0, false));
770        }
771        Ok(serde_json::from_slice(&body)?)
772    }
773
774    /// Get debug recording playback metadata for a run. The response carries
775    /// `available`, `status` (one of `ready`, `uploading`, `unavailable`,
776    /// `disabled`), `metadata`, `video_url`, and `retry_after_ms`.
777    pub async fn cloud_browser_playback(
778        &self,
779        run_id: &str,
780    ) -> Result<serde_json::Value, ScrapflyError> {
781        let url = format!(
782            "{}/run/{}/playback?key={}",
783            rest_base(self.cloud_browser_host()),
784            run_id,
785            self.api_key()
786        );
787        let url = Url::parse(&url)
788            .map_err(|e| ScrapflyError::Config(format!("invalid playback url: {}", e)))?;
789        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
790        let status = resp.status().as_u16();
791        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
792        if status != 200 {
793            return Err(from_response(status, &body, 0, false));
794        }
795        Ok(serde_json::from_slice(&body)?)
796    }
797
798    /// Poll the playback endpoint until the recording resolves to a terminal
799    /// state (`ready` or `unavailable`) or the timeout elapses. Honours the
800    /// server's `retry_after_ms` hint when present.
801    pub async fn cloud_browser_wait_for_playback(
802        &self,
803        run_id: &str,
804        timeout: std::time::Duration,
805        fallback_interval: std::time::Duration,
806    ) -> Result<serde_json::Value, ScrapflyError> {
807        let deadline = std::time::Instant::now() + timeout;
808        loop {
809            let playback = self.cloud_browser_playback(run_id).await?;
810            let status = playback
811                .get("status")
812                .and_then(|v| v.as_str())
813                .unwrap_or("");
814            if status != "uploading" {
815                return Ok(playback);
816            }
817            let remaining = deadline.saturating_duration_since(std::time::Instant::now());
818            if remaining.is_zero() {
819                return Ok(playback);
820            }
821            let retry_after_ms = playback
822                .get("retry_after_ms")
823                .and_then(|v| v.as_u64())
824                .unwrap_or(0);
825            let next = if retry_after_ms > 0 {
826                std::time::Duration::from_millis(retry_after_ms)
827            } else {
828                fallback_interval
829            };
830            tokio::time::sleep(next.min(remaining)).await;
831        }
832    }
833
834    /// Terminate a Cloud Browser session.
835    pub async fn cloud_browser_session_stop(&self, session_id: &str) -> Result<(), ScrapflyError> {
836        if session_id.is_empty() {
837            return Err(ScrapflyError::Config("session_id is required".into()));
838        }
839        let url = format!(
840            "{}/session/{}/stop?key={}",
841            rest_base(self.cloud_browser_host()),
842            session_id,
843            self.api_key()
844        );
845        let url = Url::parse(&url)
846            .map_err(|e| ScrapflyError::Config(format!("invalid session url: {}", e)))?;
847        let resp = self.send_with_retry(Method::POST, url, None, None).await?;
848        let status = resp.status().as_u16();
849        if status != 200 {
850            let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
851            return Err(from_response(status, &body, 0, false));
852        }
853        Ok(())
854    }
855
856    /// List all running Cloud Browser sessions.
857    pub async fn cloud_browser_sessions(&self) -> Result<serde_json::Value, ScrapflyError> {
858        let url = format!(
859            "{}/sessions?key={}",
860            rest_base(self.cloud_browser_host()),
861            self.api_key()
862        );
863        let url = Url::parse(&url)
864            .map_err(|e| ScrapflyError::Config(format!("invalid sessions url: {}", e)))?;
865        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
866        let status = resp.status().as_u16();
867        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
868        if status != 200 {
869            return Err(from_response(status, &body, 0, false));
870        }
871        Ok(serde_json::from_slice(&body)?)
872    }
873
874    /// Download a debug session recording video (raw bytes).
875    pub async fn cloud_browser_video(&self, run_id: &str) -> Result<Vec<u8>, ScrapflyError> {
876        let url = format!(
877            "{}/run/{}/video?key={}",
878            rest_base(self.cloud_browser_host()),
879            run_id,
880            self.api_key()
881        );
882        let url = Url::parse(&url)
883            .map_err(|e| ScrapflyError::Config(format!("invalid video url: {}", e)))?;
884        let resp = self.send_with_retry(Method::GET, url, None, None).await?;
885        let status = resp.status().as_u16();
886        let body = resp.bytes().await.map_err(ScrapflyError::Transport)?;
887        if status != 200 {
888            return Err(from_response(status, &body, 0, false));
889        }
890        Ok(body.to_vec())
891    }
892}