Skip to main content

solari_browser/
types.rs

1//! Wire types for the Solari Browser control plane. serde field names match the
2//! JSON wire EXACTLY (camelCase where the API is camelCase).
3
4use serde::{Deserialize, Deserializer, Serialize};
5use serde_json::{Map, Value};
6
7// ---------------------------------------------------------------------------
8// Regions
9// ---------------------------------------------------------------------------
10
11/// Supported Solari regions. More coming soon.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum SolariRegion {
14    /// `https://api.getsolari.com`
15    #[default]
16    UsWest,
17}
18
19impl SolariRegion {
20    /// The API base URL this region resolves to.
21    pub fn base_url(&self) -> &'static str {
22        match self {
23            SolariRegion::UsWest => "https://api.getsolari.com",
24        }
25    }
26}
27
28// ---------------------------------------------------------------------------
29// Storage state (cookies + localStorage)
30// ---------------------------------------------------------------------------
31
32/// Cookie `sameSite` policy, as Playwright spells it.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34pub enum SameSite {
35    Strict,
36    Lax,
37    None,
38}
39
40/// One cookie in a [`StorageState`].
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct Cookie {
44    pub name: String,
45    pub value: String,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub domain: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub path: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub expires: Option<f64>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub http_only: Option<bool>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub secure: Option<bool>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub same_site: Option<SameSite>,
58}
59
60/// One `localStorage` key/value pair.
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62pub struct LocalStorageEntry {
63    pub name: String,
64    pub value: String,
65}
66
67/// One origin's `localStorage` in a [`StorageState`].
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct StorageOrigin {
71    pub origin: String,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub local_storage: Option<Vec<LocalStorageEntry>>,
74}
75
76/// A browser profile's persisted state — Playwright's `storageState` shape.
77/// Unknown keys round-trip through [`StorageState::extra`].
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct StorageState {
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub cookies: Option<Vec<Cookie>>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub origins: Option<Vec<StorageOrigin>>,
85    /// Any additional keys the wire carried (TS index signature).
86    #[serde(flatten)]
87    pub extra: Map<String, Value>,
88}
89
90// ---------------------------------------------------------------------------
91// Managed proxy
92// ---------------------------------------------------------------------------
93
94/// Managed proxy tier.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "lowercase")]
97pub enum ProxyTier {
98    /// Rotating residential IPs (default).
99    Residential,
100    /// Fixed ISP IP.
101    Static,
102    /// Carrier / mobile IPs.
103    Mobile,
104}
105
106/// Managed proxy egress request.
107#[derive(Debug, Clone, Default, Serialize)]
108#[serde(rename_all = "camelCase")]
109pub struct ProxyRequest {
110    /// ISO-3166-1 alpha-2, lowercase. Defaults to `"us"` server-side.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub country: Option<String>,
113    /// Proxy tier. Defaults to [`ProxyTier::Residential`] server-side.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub tier: Option<ProxyTier>,
116    /// Pin egress to a specific ASN (e.g. `"20057"` for AT&T Mobility).
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub asn: Option<String>,
119    /// Sticky-session label: any 1-128 characters (the gateway turns it into a stable id for the proxy vendor). The same label
120    /// gets the same egress IP for `session_duration` minutes.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub session: Option<String>,
123    /// Sticky lifetime in minutes (1-30, default 10). Only with `session`.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub session_duration: Option<u32>,
126    /// US-only geo narrowing (e.g. `"california"`).
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub state: Option<String>,
129    /// US-only city pin (e.g. `"los_angeles"`).
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub city: Option<String>,
132}
133
134impl ProxyRequest {
135    /// A request pinned to a country code.
136    pub fn country(code: impl Into<String>) -> Self {
137        ProxyRequest {
138            country: Some(code.into()),
139            ..Default::default()
140        }
141    }
142
143    pub fn tier(mut self, tier: ProxyTier) -> Self {
144        self.tier = Some(tier);
145        self
146    }
147
148    pub fn asn(mut self, asn: impl Into<String>) -> Self {
149        self.asn = Some(asn.into());
150        self
151    }
152
153    /// Pin the egress IP under a sticky-session id.
154    pub fn session(mut self, id: impl Into<String>) -> Self {
155        self.session = Some(id.into());
156        self
157    }
158
159    pub fn session_duration(mut self, minutes: u32) -> Self {
160        self.session_duration = Some(minutes);
161        self
162    }
163
164    pub fn state(mut self, state: impl Into<String>) -> Self {
165        self.state = Some(state.into());
166        self
167    }
168
169    pub fn city(mut self, city: impl Into<String>) -> Self {
170        self.city = Some(city.into());
171        self
172    }
173}
174
175/// What to send as the `proxy` field of `POST /sessions`.
176///
177/// Mirrors the TS union `string | ProxyRequest | "off" | "smart"`:
178///
179/// ```
180/// # use solari_browser::{ProxySpec, ProxyRequest, ProxyTier};
181/// ProxySpec::country("us");                                    // proxy: "us"
182/// ProxySpec::off();                                            // proxy: "off"
183/// ProxySpec::smart();                                          // proxy: "smart"
184/// ProxySpec::from(ProxyRequest::country("us").tier(ProxyTier::Static));
185/// ```
186#[derive(Debug, Clone, Serialize)]
187#[serde(untagged)]
188pub enum ProxySpec {
189    /// A bare string: a country code, or the literals `"off"` / `"smart"`.
190    Shorthand(String),
191    /// A full egress request object.
192    Request(ProxyRequest),
193}
194
195impl ProxySpec {
196    /// Shorthand country pin, e.g. `ProxySpec::country("gb")`.
197    pub fn country(code: impl Into<String>) -> Self {
198        ProxySpec::Shorthand(code.into())
199    }
200
201    /// Explicitly disable managed proxy egress (`proxy: "off"`).
202    pub fn off() -> Self {
203        ProxySpec::Shorthand("off".to_string())
204    }
205
206    /// Let the gateway sweep proxy strategies on block (`proxy: "smart"`).
207    pub fn smart() -> Self {
208        ProxySpec::Shorthand("smart".to_string())
209    }
210}
211
212impl From<ProxyRequest> for ProxySpec {
213    fn from(r: ProxyRequest) -> Self {
214        ProxySpec::Request(r)
215    }
216}
217
218/// Coarse confirmation of the proxy the gateway resolved for a session.
219///
220/// Carries NO credentials by design: egress is applied server-side (the pool
221/// attaches the upstream to your session), so a caller never dials the proxy
222/// and never needs its address or account.
223#[derive(Debug, Clone, Deserialize)]
224#[serde(rename_all = "camelCase")]
225pub struct ResolvedProxyConfig {
226    pub timezone_id: String,
227    pub country: String,
228    #[serde(default)]
229    pub tier: Option<ProxyTier>,
230}
231
232/// `GET /proxy/countries` — supported egress countries, plus whether the
233/// gateway actually has residential credentials configured.
234#[derive(Debug, Clone, Default, Deserialize)]
235pub struct ProxyCountries {
236    /// False when this gateway has no residential proxy credentials — sending a
237    /// `proxy` spec will fail.
238    #[serde(default)]
239    pub enabled: bool,
240    /// Supported ISO-3166-1 alpha-2 country codes, lowercase.
241    #[serde(default)]
242    pub countries: Vec<String>,
243}
244
245// ---------------------------------------------------------------------------
246// Sessions
247// ---------------------------------------------------------------------------
248
249/// A live browser session.
250///
251/// `Debug` hides the path and query of the endpoints: they carry the session's credential.
252#[derive(Clone)]
253pub struct Session {
254    pub id: String,
255    /// Playwright wire-protocol endpoint (upstream, as returned by the API).
256    pub ws_endpoint: String,
257    /// Raw CDP endpoint — for `chromiumoxide` / Puppeteer. Derived from
258    /// `ws_endpoint` when the API does not return one.
259    pub cdp_endpoint: String,
260    /// Plan-tier deadline (ISO 8601 UTC); the session auto-releases at this point.
261    pub expires_at: String,
262    /// Three-state, mirroring the TS `storageState?: StorageState | null`:
263    /// - `None` — no profile attached to this session.
264    /// - `Some(None)` — a profile is attached but has no saved state yet.
265    /// - `Some(Some(state))` — the profile's state.
266    pub storage_state: Option<Option<StorageState>>,
267    /// Present only when a managed proxy was requested.
268    pub proxy: Option<ResolvedProxyConfig>,
269    /// Echo of the `user_metadata` sent at create; `None` when none was sent.
270    pub user_metadata: Option<Map<String, Value>>,
271}
272
273impl std::fmt::Debug for Session {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        f.debug_struct("Session")
276            .field("id", &"<redacted>")
277            .field("ws_endpoint", &crate::http::redact_url(&self.ws_endpoint))
278            .field("cdp_endpoint", &crate::http::redact_url(&self.cdp_endpoint))
279            .field("expires_at", &self.expires_at)
280            .field("storage_state", &self.storage_state)
281            .field("proxy", &self.proxy)
282            .field("user_metadata", &self.user_metadata)
283            .finish()
284    }
285}
286
287/// `GET /sessions/:id`. Fields the gateway adds later land in [`SessionView::extra`].
288#[derive(Debug, Clone, Default, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct SessionView {
291    /// The session id (the same composite id `Sessions::create` returned).
292    #[serde(default)]
293    pub id: Option<String>,
294    /// Older pool-proxied payloads used this key instead of `id`.
295    #[serde(default)]
296    pub session_id: Option<String>,
297    #[serde(default)]
298    pub status: Option<String>,
299    /// Pool flavour that served the session (`"fast"` or `"stealth"`).
300    #[serde(default)]
301    pub kind: Option<String>,
302    /// Owning organisation id.
303    #[serde(default)]
304    pub org: Option<String>,
305    #[serde(default)]
306    pub created_at: Option<String>,
307    #[serde(default)]
308    pub expires_at: Option<String>,
309    /// What was sent as `user_metadata` at create; `None` when none was.
310    #[serde(default)]
311    pub user_metadata: Option<Map<String, Value>>,
312    /// Present while the session is active.
313    #[serde(default)]
314    pub ws_endpoint: Option<String>,
315    #[serde(default)]
316    pub cdp_endpoint: Option<String>,
317    #[serde(flatten)]
318    pub extra: Map<String, Value>,
319}
320
321/// One row of [`Sessions::list`](crate::Sessions::list). It never carries the
322/// websocket endpoints (they are bearer capabilities): use `Sessions::get`.
323#[derive(Debug, Clone, Default, Deserialize)]
324#[serde(rename_all = "camelCase")]
325pub struct SessionSummary {
326    pub id: String,
327    #[serde(default)]
328    pub status: String,
329    #[serde(default)]
330    pub kind: Option<String>,
331    #[serde(default)]
332    pub created_at: Option<String>,
333    #[serde(default)]
334    pub expires_at: Option<String>,
335    #[serde(default)]
336    pub user_metadata: Option<Map<String, Value>>,
337}
338
339/// One page of [`Sessions::list`](crate::Sessions::list).
340#[derive(Debug, Clone, Default, Deserialize)]
341#[serde(rename_all = "camelCase")]
342pub struct SessionList {
343    #[serde(default)]
344    pub sessions: Vec<SessionSummary>,
345    /// `None` on the last page; pass it as `ListSessionsOptions::cursor`.
346    #[serde(default)]
347    pub next_cursor: Option<String>,
348}
349
350/// Presigned URL for a session's replay.
351#[derive(Debug, Clone)]
352pub struct ReplayUrl {
353    pub url: String,
354    /// Lifetime of the presigned URL. `0` when the API omits it.
355    pub expires_in_seconds: u64,
356    /// Content encoding of the object behind `url`. `"gzip"` when omitted.
357    pub content_encoding: String,
358}
359
360// ---------------------------------------------------------------------------
361// Profiles
362// ---------------------------------------------------------------------------
363
364/// A stored browser profile (cookies + localStorage).
365#[derive(Debug, Clone, Deserialize)]
366#[serde(rename_all = "camelCase")]
367pub struct Profile {
368    pub id: String,
369    #[serde(default)]
370    pub name: String,
371    /// Increases on every save. 0 when the gateway omits it.
372    #[serde(default)]
373    pub version: u64,
374    /// Size of the saved storage state in bytes (a JSON number on the wire). `None` for a profile that has not been saved yet.
375    /// The TypeScript `Profile.sizeBytes`; it used to be reachable only through `extra`.
376    #[serde(default)]
377    pub size_bytes: Option<u64>,
378    /// Any additional keys the wire carried (TS index signature).
379    #[serde(flatten)]
380    pub extra: Map<String, Value>,
381}
382
383/// Result of `POST /profiles/:id/save`.
384#[derive(Debug, Clone, Default)]
385pub struct ProfileSaveResult {
386    /// Monotonic version of the saved state. `0` when the API omits it.
387    pub version: u64,
388    /// Size of the saved state in bytes. `0` when the API omits it.
389    pub size_bytes: u64,
390}
391
392// ---------------------------------------------------------------------------
393// Internal wire shapes
394// ---------------------------------------------------------------------------
395
396/// Wire body for `POST /sessions`. Falsy/unset fields are omitted; when every
397/// field is omitted the request is sent with no body at all.
398#[derive(Debug, Clone, Default, Serialize)]
399#[serde(rename_all = "camelCase")]
400pub(crate) struct CreateSessionRequest {
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub profile_id: Option<String>,
403    #[serde(skip_serializing_if = "is_false")]
404    pub recording: bool,
405    #[serde(skip_serializing_if = "is_false")]
406    pub stealth: bool,
407    #[serde(skip_serializing_if = "is_false")]
408    pub captcha: bool,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub proxy: Option<ProxySpec>,
411    #[serde(skip_serializing_if = "Option::is_none")]
412    pub timeout_seconds: Option<u32>,
413    #[serde(skip_serializing_if = "is_false")]
414    pub keep_alive: bool,
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub user_metadata: Option<Map<String, Value>>,
417}
418
419fn is_false(b: &bool) -> bool {
420    !*b
421}
422
423/// Presigned pointer to a profile's storage state, returned on session create.
424#[derive(Debug, Clone, Deserialize)]
425#[serde(rename_all = "camelCase")]
426pub(crate) struct StorageStateUrl {
427    #[serde(default)]
428    pub url: Option<String>,
429    #[allow(dead_code)]
430    #[serde(default)]
431    pub expires_in_seconds: Option<u64>,
432}
433
434/// `201` response from `POST /sessions`.
435#[derive(Debug, Clone, Deserialize)]
436#[serde(rename_all = "camelCase")]
437pub(crate) struct CreateSessionResponse {
438    #[serde(default)]
439    pub session_id: Option<String>,
440    #[serde(default)]
441    pub ws_endpoint: Option<String>,
442    #[serde(default)]
443    pub cdp_endpoint: Option<String>,
444    #[serde(default)]
445    pub expires_at: Option<String>,
446    /// Absent vs explicit `null` are distinguished.
447    #[serde(default, deserialize_with = "double_option")]
448    pub storage_state_url: Option<Option<StorageStateUrl>>,
449    /// Legacy inline state, for gateways that predate `storageStateUrl`.
450    #[serde(default, deserialize_with = "double_option")]
451    pub storage_state: Option<Option<StorageState>>,
452    #[serde(default)]
453    pub proxy: Option<ResolvedProxyConfig>,
454    #[serde(default)]
455    pub user_metadata: Option<Map<String, Value>>,
456}
457
458/// `GET /sessions/:id/replay-url`.
459#[derive(Debug, Clone, Deserialize)]
460#[serde(rename_all = "camelCase")]
461pub(crate) struct ReplayUrlResponse {
462    #[serde(default)]
463    pub url: Option<String>,
464    #[serde(default)]
465    pub expires_in_seconds: Option<u64>,
466    #[serde(default)]
467    pub content_encoding: Option<String>,
468}
469
470/// `POST /profiles/:id/save`.
471#[derive(Debug, Clone, Deserialize)]
472#[serde(rename_all = "camelCase")]
473pub(crate) struct ProfileSaveResponse {
474    #[serde(default)]
475    pub version: Option<u64>,
476    #[serde(default)]
477    pub size_bytes: Option<u64>,
478}
479
480/// Distinguish "key absent" (`None`) from "key present and null" (`Some(None)`).
481/// Requires `#[serde(default)]` alongside it.
482fn double_option<'de, T, D>(de: D) -> Result<Option<Option<T>>, D::Error>
483where
484    T: serde::Deserialize<'de>,
485    D: Deserializer<'de>,
486{
487    serde::Deserialize::deserialize(de).map(Some)
488}
489
490// ---------------------------------------------------------------------------
491
492#[cfg(test)]
493mod resolved_proxy_tests {
494    use super::*;
495
496    /// REGRESSION GUARD for the shipped fix: the resolved-proxy type carries NO
497    /// credentials.
498    ///
499    /// `ResolvedProxyConfig` used to carry `server`/`username`/`password`. The
500    /// gateway stopped sending them (they disclose the proxy vendor's account),
501    /// so this binding deserialized empty strings forever — a contract that was
502    /// not merely dead but misleading: a caller could read `proxy.server`, get
503    /// `""`, and conclude no proxy was applied.
504    ///
505    /// Rust has no reflection, so the field set is read back out of the derived
506    /// `Debug` output. That is exactly the surface that matters anyway: whatever
507    /// `{:?}` prints is what ends up in somebody's log.
508    fn debug_field_names(dbg: &str) -> Vec<String> {
509        let inner = dbg
510            .split_once('{')
511            .and_then(|(_, rest)| rest.rsplit_once('}'))
512            .map(|(inner, _)| inner)
513            .unwrap_or("");
514        let mut names = Vec::new();
515        let mut depth = 0usize;
516        for part in inner.split(',') {
517            // Only split on top-level commas (Some(..) / nested braces).
518            if depth == 0 {
519                if let Some((name, _)) = part.split_once(':') {
520                    let name = name.trim();
521                    if !name.is_empty() {
522                        names.push(name.to_string());
523                    }
524                }
525            }
526            depth += part.matches(['(', '{', '[']).count();
527            depth = depth.saturating_sub(part.matches([')', '}', ']']).count());
528        }
529        names
530    }
531
532    #[test]
533    fn resolved_proxy_config_has_exactly_the_confirmation_fields() {
534        let wire = r#"{"timezoneId":"America/Los_Angeles","country":"us","tier":"mobile"}"#;
535        let rp: ResolvedProxyConfig = serde_json::from_str(wire).unwrap();
536
537        let names = debug_field_names(&format!("{rp:?}"));
538        assert_eq!(
539            names,
540            vec!["timezone_id", "country", "tier"],
541            "ResolvedProxyConfig must stay a COARSE CONFIRMATION: egress is applied \
542             server-side, so a caller never dials the proxy and must never receive \
543             its address or account. Got: {names:?}"
544        );
545    }
546
547    #[test]
548    fn legacy_credential_keys_are_dropped_not_resurrected() {
549        let wire = r#"{
550            "timezoneId":"America/Los_Angeles",
551            "country":"us",
552            "tier":"mobile",
553            "server":"http://resi.vendor.example:8000",
554            "username":"acct-12345",
555            "password":"hunter2"
556        }"#;
557        let rp: ResolvedProxyConfig = serde_json::from_str(wire).unwrap();
558
559        assert_eq!(rp.timezone_id, "America/Los_Angeles");
560        assert_eq!(rp.country, "us");
561
562        let rendered = format!("{rp:?}");
563        for secret in ["resi.vendor.example", "acct-12345", "hunter2", "server", "username", "password"] {
564            assert!(
565                !rendered.contains(secret),
566                "resolved proxy leaks {secret:?}: {rendered}"
567            );
568        }
569    }
570}