Skip to main content

silicon_browser_shared/
model.rs

1use std::fmt;
2use std::str::FromStr;
3
4use chrono::{DateTime, NaiveDate, Utc};
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8use crate::validation::{bounded, collection_len, identifier, purpose, required};
9use crate::{AccessList, ApiError, IdentityId, OrgId, ProfileId, SessionId, Validate, ValidationError};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum IdentityKind {
14    Carbon,
15    Silicon,
16}
17
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct Identity {
20    pub id: IdentityId,
21    pub name: String,
22    pub kind: IdentityKind,
23    #[serde(default, skip_serializing_if = "Vec::is_empty")]
24    pub tags: Vec<String>,
25    /// Additional identifiers verified by the identity authority for this same
26    /// principal. They are authorization context, never part of the public wire
27    /// representation.
28    #[doc(hidden)]
29    #[serde(skip)]
30    pub verified_aliases: Vec<IdentityId>,
31}
32
33impl Identity {
34    pub fn matches_principal(&self, principal_id: &str) -> bool {
35        same_principal(&self.id, principal_id)
36            || self.verified_aliases.iter().any(|alias| same_principal(alias, principal_id))
37    }
38
39    pub fn principal_ids(&self) -> impl Iterator<Item = &str> {
40        std::iter::once(self.id.as_str()).chain(self.verified_aliases.iter().map(String::as_str))
41    }
42}
43
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Org {
46    pub id: OrgId,
47    pub name: String,
48}
49
50/// Exchanges an IAM short-lived token (SLT) for browser OAuth-style tokens.
51#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct AuthExchangeRequest {
54    #[serde(alias = "token")]
55    pub short_lived_token: String,
56    /// IAM exchanges a short-lived token only in an explicitly selected
57    /// organization; there is no unscoped exchange or pre-exchange directory
58    /// lookup.
59    pub org_id: OrgId,
60}
61
62impl fmt::Debug for AuthExchangeRequest {
63    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
64        formatter
65            .debug_struct("AuthExchangeRequest")
66            .field("short_lived_token", &"[REDACTED]")
67            .field("org_id", &self.org_id)
68            .finish()
69    }
70}
71
72impl Validate for AuthExchangeRequest {
73    fn validate(&self) -> Result<(), ValidationError> {
74        opaque_auth_token(&self.short_lived_token, "oac_", "short_lived_token")?;
75        identifier(&self.org_id, "org_id")?;
76        Ok(())
77    }
78}
79
80#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct AuthRefreshRequest {
83    pub refresh_token: String,
84    /// Refresh tokens are opaque; the selected organization must remain explicit.
85    pub org_id: OrgId,
86}
87
88impl fmt::Debug for AuthRefreshRequest {
89    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
90        formatter
91            .debug_struct("AuthRefreshRequest")
92            .field("refresh_token", &"[REDACTED]")
93            .field("org_id", &self.org_id)
94            .finish()
95    }
96}
97
98impl Validate for AuthRefreshRequest {
99    fn validate(&self) -> Result<(), ValidationError> {
100        opaque_auth_token(&self.refresh_token, "ort_", "refresh_token")?;
101        identifier(&self.org_id, "org_id")?;
102        Ok(())
103    }
104}
105
106/// Tokens returned by IAM. Callers must persist these owner-only and atomically.
107#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct AuthSession {
109    pub access_token: String,
110    pub refresh_token: String,
111    pub expires_at: DateTime<Utc>,
112    pub identity: Identity,
113    pub org: Org,
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub services: Vec<String>,
116}
117
118pub type AuthRefreshResponse = AuthSession;
119
120impl fmt::Debug for AuthSession {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        formatter
123            .debug_struct("AuthSession")
124            .field("access_token", &"[REDACTED]")
125            .field("refresh_token", &"[REDACTED]")
126            .field("expires_at", &self.expires_at)
127            .field("identity", &self.identity)
128            .field("org", &self.org)
129            .field("services", &self.services)
130            .finish()
131    }
132}
133
134impl Validate for AuthSession {
135    fn validate(&self) -> Result<(), ValidationError> {
136        opaque_auth_token(&self.access_token, "oat_", "access_token")?;
137        opaque_auth_token(&self.refresh_token, "ort_", "refresh_token")?;
138        identifier(&self.identity.id, "identity.id")?;
139        identifier(&self.org.id, "org.id")?;
140        for service in &self.services {
141            identifier(service, "services")?;
142        }
143        Ok(())
144    }
145}
146
147fn opaque_auth_token(value: &str, prefix: &'static str, field: &'static str) -> Result<(), ValidationError> {
148    if !value.starts_with(prefix)
149        || value.len() == prefix.len()
150        || value.len() > 16 * 1024
151        || value.chars().any(|character| character.is_whitespace() || character.is_control())
152    {
153        return Err(ValidationError::Invalid {
154            field,
155            reason: format!("expected one bounded {prefix} token without whitespace or controls"),
156        });
157    }
158    Ok(())
159}
160
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub struct ProxyLocation {
163    pub code: String,
164    pub name: String,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub country: Option<String>,
167}
168
169impl Validate for ProxyLocation {
170    fn validate(&self) -> Result<(), ValidationError> {
171        required(&self.code, "location.code")?;
172        required(&self.name, "location.name")
173    }
174}
175
176#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum ProfileStatus {
179    Active,
180    Retired,
181}
182
183#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
184pub struct Profile {
185    pub id: ProfileId,
186    pub name: String,
187    pub fingerprint: String,
188    pub location: ProxyLocation,
189    pub access: AccessList,
190    pub owner_id: IdentityId,
191    pub sessions_run: u64,
192    pub status: ProfileStatus,
193    pub created_at: DateTime<Utc>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub ended_at: Option<DateTime<Utc>>,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub end_note: Option<String>,
198}
199
200#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(deny_unknown_fields)]
202pub struct ProfileCreate {
203    pub name: String,
204    pub location: String,
205    #[serde(default)]
206    pub access: AccessList,
207}
208
209impl Validate for ProfileCreate {
210    fn validate(&self) -> Result<(), ValidationError> {
211        bounded(&self.name, "name", 100)?;
212        if self.location.len() != 2 || !self.location.bytes().all(|byte| byte.is_ascii_alphabetic()) {
213            return Err(ValidationError::Invalid {
214                field: "location",
215                reason: "expected a two-letter provider location code".into(),
216            });
217        }
218        self.access.validate()
219    }
220}
221
222#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(deny_unknown_fields)]
224pub struct ProfileUpdate {
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub name: Option<String>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub access: Option<AccessList>,
229}
230
231impl Validate for ProfileUpdate {
232    fn validate(&self) -> Result<(), ValidationError> {
233        if self.name.is_none() && self.access.is_none() {
234            return Err(ValidationError::Required { field: "name or access" });
235        }
236        if let Some(name) = &self.name {
237            bounded(name, "name", 100)?;
238        }
239        if let Some(access) = &self.access {
240            access.validate()?;
241        }
242        Ok(())
243    }
244}
245
246#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248pub struct ProfileEnd {
249    pub note: String,
250}
251
252impl Validate for ProfileEnd {
253    fn validate(&self) -> Result<(), ValidationError> {
254        bounded(&self.note, "note", 4_000)
255    }
256}
257
258/// The only session TTLs accepted by Silicon Browser.
259#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
260pub enum SessionTtl {
261    #[serde(rename = "15m")]
262    Minutes15,
263    #[serde(rename = "30m")]
264    Minutes30,
265    #[serde(rename = "45m")]
266    Minutes45,
267    #[serde(rename = "60m")]
268    Minutes60,
269    #[serde(rename = "120m")]
270    Minutes120,
271    #[serde(rename = "240m")]
272    Minutes240,
273}
274
275impl SessionTtl {
276    pub const ALL: [Self; 6] =
277        [Self::Minutes15, Self::Minutes30, Self::Minutes45, Self::Minutes60, Self::Minutes120, Self::Minutes240];
278
279    pub const fn minutes(self) -> u16 {
280        match self {
281            Self::Minutes15 => 15,
282            Self::Minutes30 => 30,
283            Self::Minutes45 => 45,
284            Self::Minutes60 => 60,
285            Self::Minutes120 => 120,
286            Self::Minutes240 => 240,
287        }
288    }
289
290    pub const fn seconds(self) -> i64 {
291        self.minutes() as i64 * 60
292    }
293}
294
295impl fmt::Display for SessionTtl {
296    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
297        write!(formatter, "{}m", self.minutes())
298    }
299}
300
301impl FromStr for SessionTtl {
302    type Err = ValidationError;
303
304    fn from_str(value: &str) -> Result<Self, Self::Err> {
305        match value {
306            "15m" => Ok(Self::Minutes15),
307            "30m" => Ok(Self::Minutes30),
308            "45m" => Ok(Self::Minutes45),
309            "60m" => Ok(Self::Minutes60),
310            "120m" => Ok(Self::Minutes120),
311            "240m" => Ok(Self::Minutes240),
312            _ => Err(ValidationError::Invalid {
313                field: "ttl",
314                reason: "expected 15m, 30m, 45m, 60m, 120m, or 240m".into(),
315            }),
316        }
317    }
318}
319
320#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "snake_case")]
322pub enum SessionStatus {
323    Active,
324    Ended,
325    Expired,
326}
327
328#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
329pub struct Session {
330    pub id: SessionId,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub profile_id: Option<ProfileId>,
333    pub incognito: bool,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub location: Option<ProxyLocation>,
336    pub name: String,
337    pub description: String,
338    pub status: SessionStatus,
339    pub initiator_id: IdentityId,
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub participant_ids: Vec<IdentityId>,
342    pub ttl: SessionTtl,
343    pub started_at: DateTime<Utc>,
344    pub expires_at: DateTime<Utc>,
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub ended_at: Option<DateTime<Utc>>,
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub end_note: Option<String>,
349    pub usage: UsageTotal,
350}
351
352impl Session {
353    pub fn is_participant(&self, identity_id: &str) -> bool {
354        same_principal(&self.initiator_id, identity_id)
355            || self.participant_ids.iter().any(|participant| same_principal(participant, identity_id))
356    }
357
358    pub fn ttl_left_seconds_at(&self, now: DateTime<Utc>) -> u64 {
359        if self.status == SessionStatus::Active {
360            self.expires_at.signed_duration_since(now).num_seconds().max(0) as u64
361        } else {
362            0
363        }
364    }
365}
366
367#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
368#[serde(deny_unknown_fields)]
369pub struct SessionCreate {
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub profile_id: Option<ProfileId>,
372    #[serde(default)]
373    pub incognito: bool,
374    pub name: String,
375    pub description: String,
376    pub ttl: SessionTtl,
377}
378
379impl SessionCreate {
380    pub fn with_profile(
381        profile_id: impl Into<ProfileId>,
382        name: impl Into<String>,
383        description: impl Into<String>,
384        ttl: SessionTtl,
385    ) -> Self {
386        Self {
387            profile_id: Some(profile_id.into()),
388            incognito: false,
389            name: name.into(),
390            description: description.into(),
391            ttl,
392        }
393    }
394
395    pub fn incognito(name: impl Into<String>, description: impl Into<String>, ttl: SessionTtl) -> Self {
396        Self { profile_id: None, incognito: true, name: name.into(), description: description.into(), ttl }
397    }
398}
399
400impl Validate for SessionCreate {
401    fn validate(&self) -> Result<(), ValidationError> {
402        match (&self.profile_id, self.incognito) {
403            (Some(_), true) => {
404                return Err(ValidationError::Conflict { left: "profile_id", right: "incognito" });
405            }
406            (None, false) => {
407                return Err(ValidationError::Required { field: "profile_id or incognito" });
408            }
409            (Some(profile_id), false) => identifier(profile_id, "profile_id")?,
410            (None, true) => {}
411        }
412        bounded(&self.name, "name", 120)?;
413        bounded(&self.description, "description", 2_000)
414    }
415}
416
417#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(deny_unknown_fields)]
419pub struct SessionEnd {
420    pub note: String,
421}
422
423impl Validate for SessionEnd {
424    fn validate(&self) -> Result<(), ValidationError> {
425        bounded(&self.note, "note", 4_000)
426    }
427}
428
429#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
430pub struct SessionLog {
431    pub sequence: u64,
432    pub at: DateTime<Utc>,
433    pub actor_id: IdentityId,
434    pub command: String,
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub exit_code: Option<i32>,
437}
438
439#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
440pub struct SessionLogs {
441    pub session_id: SessionId,
442    pub date: NaiveDate,
443    pub entries: Vec<SessionLog>,
444}
445
446#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
447pub struct LiveLink {
448    pub session_id: SessionId,
449    pub url: String,
450    pub expires_at: DateTime<Utc>,
451}
452
453impl Validate for LiveLink {
454    fn validate(&self) -> Result<(), ValidationError> {
455        identifier(&self.session_id, "session_id")?;
456        validate_http_url(&self.url, "url")
457    }
458}
459
460/// Redeem the opaque grant carried in a Silicon Browser live-link fragment.
461///
462/// The grant remains explicit so opening a link never places it in an HTTP URL,
463/// browser history, or intermediary access log.
464#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
465#[serde(deny_unknown_fields)]
466pub struct LiveRedeemRequest {
467    pub grant: String,
468}
469
470impl Validate for LiveRedeemRequest {
471    fn validate(&self) -> Result<(), ValidationError> {
472        bounded(&self.grant, "grant", 64 * 1024)?;
473        if self.grant.chars().any(|character| character.is_whitespace() || character.is_control()) {
474            return Err(ValidationError::Invalid {
475                field: "grant",
476                reason: "expected one opaque value without whitespace or controls".into(),
477            });
478        }
479        Ok(())
480    }
481}
482
483#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
484#[serde(rename_all = "snake_case")]
485pub enum RecordingStatus {
486    Pending,
487    Available,
488    Trashed,
489    Failed,
490}
491
492#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
493pub struct Recording {
494    pub session_id: SessionId,
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub profile_id: Option<ProfileId>,
497    pub incognito: bool,
498    pub session_name: String,
499    pub session_description: String,
500    pub owner_id: IdentityId,
501    /// Identities that participated in the source session.
502    #[serde(default, skip_serializing_if = "Vec::is_empty")]
503    pub participant_ids: Vec<IdentityId>,
504    /// Actual path returned by Briefcase; empty until a verified video receipt exists.
505    pub briefcase_path: String,
506    #[serde(default, skip_serializing_if = "Option::is_none")]
507    pub briefcase_link: Option<String>,
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub command_log_link: Option<String>,
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub command_log_path: Option<String>,
512    /// Stable non-secret delivery diagnostic, when an artifact is waiting or failed.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub delivery_error: Option<String>,
515    pub duration_seconds: u64,
516    pub size_bytes: u64,
517    pub status: RecordingStatus,
518    pub created_at: DateTime<Utc>,
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub trashed_at: Option<DateTime<Utc>>,
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub purge_at: Option<DateTime<Utc>>,
523}
524
525impl Recording {
526    /// Legacy logical path helper. It does not create or predict a Briefcase directory;
527    /// actual storage paths come only from upload receipts.
528    pub fn private_path(owner_id: &str, session_id: &str) -> Result<String, ValidationError> {
529        identifier(owner_id, "owner_id")?;
530        identifier(session_id, "session_id")?;
531        Ok(format!("private/{owner_id}/sb/{session_id}"))
532    }
533
534    pub fn is_participant(&self, identity_id: &str) -> bool {
535        same_principal(&self.owner_id, identity_id)
536            || self.participant_ids.iter().any(|participant| same_principal(participant, identity_id))
537    }
538}
539
540#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
541pub struct Money {
542    /// ISO currency code. Empty is permitted only for a zero, not-yet-priced value.
543    pub currency: String,
544    /// Millionths of one currency unit, avoiding floating-point billing errors.
545    pub micros: u64,
546}
547
548#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
549pub struct UsageCost {
550    pub browser: Money,
551    pub proxy_in: Money,
552    pub proxy_out: Money,
553    /// Provider-reported proxy cost without a trustworthy directional split.
554    #[serde(default)]
555    pub proxy_unclassified: Money,
556    pub total: Money,
557}
558
559/// Current shared browser-account capacity. This is not an organization's
560/// allocation, remaining capacity, or a count of anyone's active sessions.
561#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
562pub struct UsageLimits {
563    pub concurrent_browser_limit: u64,
564    /// Account-reported rate limit; no interval is implied by this value.
565    #[serde(default)]
566    pub rate_limit: Option<u64>,
567    /// When the account was successfully checked, preserved on cache hits.
568    pub checked_at: DateTime<Utc>,
569}
570
571#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
572pub struct UsageTotal {
573    pub sessions: u64,
574    pub browser_seconds: u64,
575    pub proxy_bytes_in: u64,
576    pub proxy_bytes_out: u64,
577    /// Provider-reported proxy traffic without a trustworthy directional split.
578    #[serde(default)]
579    pub proxy_bytes_unclassified: u64,
580    pub cost: UsageCost,
581}
582
583impl UsageTotal {
584    pub fn browser_minutes(&self) -> f64 {
585        self.browser_seconds as f64 / 60.0
586    }
587
588    pub fn proxy_gb_in(&self) -> f64 {
589        self.proxy_bytes_in as f64 / 1_000_000_000.0
590    }
591
592    pub fn proxy_gb_out(&self) -> f64 {
593        self.proxy_bytes_out as f64 / 1_000_000_000.0
594    }
595
596    pub fn proxy_gb_unclassified(&self) -> f64 {
597        self.proxy_bytes_unclassified as f64 / 1_000_000_000.0
598    }
599
600    pub fn proxy_gb_total(&self) -> f64 {
601        (self.proxy_bytes_in as f64 + self.proxy_bytes_out as f64 + self.proxy_bytes_unclassified as f64)
602            / 1_000_000_000.0
603    }
604}
605
606#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
607pub struct Usage {
608    pub session_id: SessionId,
609    pub started_at: DateTime<Utc>,
610    #[serde(default, skip_serializing_if = "Vec::is_empty")]
611    pub principal_ids: Vec<IdentityId>,
612    pub browser_seconds: u64,
613    pub proxy_bytes_in: u64,
614    pub proxy_bytes_out: u64,
615    /// Provider-reported proxy traffic without a trustworthy directional split.
616    #[serde(default)]
617    pub proxy_bytes_unclassified: u64,
618    pub cost: UsageCost,
619}
620
621impl Usage {
622    pub fn is_for(&self, identity_id: &str) -> bool {
623        self.principal_ids.iter().any(|principal| same_principal(principal, identity_id))
624    }
625}
626
627#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
628#[serde(rename_all = "snake_case")]
629pub enum SearchType {
630    #[default]
631    Web,
632    News,
633    Research,
634}
635
636#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
637#[serde(deny_unknown_fields)]
638pub struct SearchRequest {
639    pub query: String,
640    pub purpose: String,
641    #[serde(default, rename = "type")]
642    pub search_type: SearchType,
643    #[serde(default, skip_serializing_if = "Vec::is_empty")]
644    pub include_domains: Vec<String>,
645    #[serde(default, skip_serializing_if = "Vec::is_empty")]
646    pub exclude_domains: Vec<String>,
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub location: Option<String>,
649    #[serde(default, skip_serializing_if = "Option::is_none")]
650    pub language: Option<String>,
651    #[serde(default, skip_serializing_if = "Option::is_none")]
652    pub recency_minutes: Option<u64>,
653    #[serde(default, skip_serializing_if = "Option::is_none")]
654    pub after: Option<NaiveDate>,
655    #[serde(default, skip_serializing_if = "Option::is_none")]
656    pub before: Option<NaiveDate>,
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub pub_year_min: Option<i32>,
659    #[serde(default, skip_serializing_if = "Option::is_none")]
660    pub pub_year_max: Option<i32>,
661    #[serde(default)]
662    pub page: u8,
663}
664
665impl Validate for SearchRequest {
666    fn validate(&self) -> Result<(), ValidationError> {
667        bounded(&self.query, "query", 16_384)?;
668        purpose(&self.purpose)?;
669        collection_len(self.include_domains.len(), "include_domains", 100)?;
670        collection_len(self.exclude_domains.len(), "exclude_domains", 100)?;
671        if self.page > 10 {
672            return Err(ValidationError::OutOfRange { field: "page", min: 0, max: 10 });
673        }
674        if self.recency_minutes == Some(0) {
675            return Err(ValidationError::OutOfRange { field: "recency_minutes", min: 1, max: u64::MAX });
676        }
677        if self.recency_minutes.is_some() && (self.after.is_some() || self.before.is_some()) {
678            return Err(ValidationError::Conflict { left: "recency_minutes", right: "after/before" });
679        }
680        if self.after.zip(self.before).is_some_and(|(after, before)| after > before) {
681            return Err(ValidationError::Invalid { field: "after", reason: "must not be later than before".into() });
682        }
683        if (self.pub_year_min.is_some() || self.pub_year_max.is_some()) && self.search_type != SearchType::Research {
684            return Err(ValidationError::Invalid {
685                field: "pub_year_min/pub_year_max",
686                reason: "publication years are available only for research search".into(),
687            });
688        }
689        if self.pub_year_min.zip(self.pub_year_max).is_some_and(|(min, max)| min > max) {
690            return Err(ValidationError::Invalid {
691                field: "pub_year_min",
692                reason: "must not exceed pub_year_max".into(),
693            });
694        }
695        for domain in &self.include_domains {
696            validate_domain(domain, "include_domains")?;
697        }
698        for domain in &self.exclude_domains {
699            validate_domain(domain, "exclude_domains")?;
700        }
701        for (field, value) in [("location", self.location.as_deref()), ("language", self.language.as_deref())] {
702            if let Some(value) = value {
703                bounded(value, field, 256)?;
704            }
705        }
706        Ok(())
707    }
708}
709
710#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
711pub struct SearchResult {
712    pub rank: u32,
713    pub title: String,
714    pub url: String,
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub snippet: Option<String>,
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub published_at: Option<DateTime<Utc>>,
719}
720
721#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
722pub struct SearchResponse {
723    pub results: Vec<SearchResult>,
724    pub page: u8,
725    #[serde(default)]
726    pub queued_ms: u64,
727}
728
729#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
730#[serde(rename_all = "snake_case")]
731pub enum FetchFormat {
732    #[default]
733    Markdown,
734    Html,
735    Json,
736}
737
738#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
739#[serde(deny_unknown_fields)]
740pub struct FetchRequest {
741    pub urls: Vec<String>,
742    pub purpose: String,
743    #[serde(default)]
744    pub format: FetchFormat,
745    #[serde(default)]
746    pub links: bool,
747    #[serde(default)]
748    pub image_links: bool,
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub ttl_seconds: Option<u64>,
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub timeout_ms: Option<u64>,
753    #[serde(default, skip_serializing_if = "Vec::is_empty")]
754    pub include_selectors: Vec<String>,
755    #[serde(default, skip_serializing_if = "Vec::is_empty")]
756    pub exclude_selectors: Vec<String>,
757}
758
759impl FetchRequest {
760    pub const UPSTREAM_BATCH_SIZE: usize = 10;
761
762    /// Stable, allocation-free batches; callers can queue these in input order.
763    pub fn batches(&self) -> impl Iterator<Item = &[String]> {
764        self.urls.chunks(Self::UPSTREAM_BATCH_SIZE)
765    }
766}
767
768impl Validate for FetchRequest {
769    fn validate(&self) -> Result<(), ValidationError> {
770        if self.urls.is_empty() {
771            return Err(ValidationError::Required { field: "urls" });
772        }
773        collection_len(self.urls.len(), "urls", 1_000)?;
774        purpose(&self.purpose)?;
775        for url in &self.urls {
776            validate_http_url(url, "urls")?;
777        }
778        if let Some(timeout) = self.timeout_ms
779            && !(1..=110_000).contains(&timeout)
780        {
781            return Err(ValidationError::OutOfRange { field: "timeout_ms", min: 1, max: 110_000 });
782        }
783        collection_len(
784            self.include_selectors.len().saturating_add(self.exclude_selectors.len()),
785            "include_selectors/exclude_selectors",
786            20,
787        )?;
788        validate_selectors(&self.include_selectors, "include_selectors")?;
789        validate_selectors(&self.exclude_selectors, "exclude_selectors")?;
790        Ok(())
791    }
792}
793
794#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
795#[serde(rename_all = "snake_case")]
796pub enum FetchStatus {
797    Ok,
798    Error,
799}
800
801#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
802pub struct FetchItem {
803    pub url: String,
804    pub status: FetchStatus,
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub content: Option<String>,
807    #[serde(default, skip_serializing_if = "Vec::is_empty")]
808    pub links: Vec<String>,
809    #[serde(default, skip_serializing_if = "Vec::is_empty")]
810    pub image_links: Vec<String>,
811    #[serde(default, skip_serializing_if = "Option::is_none")]
812    pub error: Option<ApiError>,
813    #[serde(default)]
814    pub cached: bool,
815}
816
817#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
818pub struct FetchResponse {
819    /// One item per requested URL, in caller order.
820    pub items: Vec<FetchItem>,
821    #[serde(default)]
822    pub queued_ms: u64,
823}
824
825#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
826#[serde(deny_unknown_fields)]
827pub struct RunRequest {
828    pub session_id: SessionId,
829    /// Shell-decoded agent-browser command; implementations must not rewrite it.
830    pub command: String,
831    #[serde(default, skip_serializing_if = "Vec::is_empty")]
832    pub flags: Vec<String>,
833}
834
835impl Validate for RunRequest {
836    fn validate(&self) -> Result<(), ValidationError> {
837        identifier(&self.session_id, "session_id")?;
838        bounded(&self.command, "command", 1_048_576)?;
839        if self.command.contains('\0') {
840            return Err(ValidationError::Invalid { field: "command", reason: "must not contain NUL".into() });
841        }
842        collection_len(self.flags.len(), "flags", 256)?;
843        for flag in &self.flags {
844            if flag.chars().count() > 16_384 {
845                return Err(ValidationError::TooLong { field: "flags", max: 16_384 });
846            }
847            if flag.contains('\0') {
848                return Err(ValidationError::Invalid { field: "flags", reason: "must not contain NUL".into() });
849            }
850        }
851        Ok(())
852    }
853}
854
855#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
856#[serde(tag = "type", rename_all = "snake_case")]
857pub enum RunEvent {
858    Queued {
859        position: u64,
860    },
861    Started {
862        at: DateTime<Utc>,
863    },
864    Stdout {
865        chunk: String,
866    },
867    Stderr {
868        chunk: String,
869    },
870    Warning {
871        message: String,
872    },
873    /// Terminal client-side failure. A non-zero `Finished` remains reserved
874    /// for an agent-browser process that actually ran and exited non-zero.
875    Failed {
876        error: ApiError,
877    },
878    Finished {
879        result: RunResult,
880    },
881}
882
883#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
884pub struct RunResult {
885    pub session_id: SessionId,
886    pub exit_code: i32,
887    pub started_at: DateTime<Utc>,
888    pub finished_at: DateTime<Utc>,
889    #[serde(default, skip_serializing_if = "String::is_empty")]
890    pub stdout: String,
891    #[serde(default, skip_serializing_if = "String::is_empty")]
892    pub stderr: String,
893}
894
895impl RunResult {
896    pub fn succeeded(&self) -> bool {
897        self.exit_code == 0
898    }
899}
900
901pub(crate) fn same_principal(left: &str, right: &str) -> bool {
902    left.trim().trim_start_matches('@') == right.trim().trim_start_matches('@')
903}
904
905fn validate_http_url(value: &str, field: &'static str) -> Result<(), ValidationError> {
906    if value.chars().count() > 16_384 {
907        return Err(ValidationError::TooLong { field, max: 16_384 });
908    }
909    let parsed = Url::parse(value).map_err(|error| ValidationError::Invalid { field, reason: error.to_string() })?;
910    if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
911        return Err(ValidationError::Invalid { field, reason: "expected an absolute http(s) URL".into() });
912    }
913    Ok(())
914}
915
916fn validate_domain(value: &str, field: &'static str) -> Result<(), ValidationError> {
917    bounded(value, field, 253)?;
918    if value.chars().any(char::is_whitespace) || value.contains('/') || value.contains("://") {
919        return Err(ValidationError::Invalid { field, reason: "expected a hostname, not a URL".into() });
920    }
921    let candidate = format!("https://{}", value.trim_start_matches("*."));
922    let parsed =
923        Url::parse(&candidate).map_err(|error| ValidationError::Invalid { field, reason: error.to_string() })?;
924    if parsed.host_str().is_none() {
925        return Err(ValidationError::Invalid { field, reason: "expected a hostname".into() });
926    }
927    Ok(())
928}
929
930fn validate_selectors(selectors: &[String], field: &'static str) -> Result<(), ValidationError> {
931    collection_len(selectors.len(), field, 20)?;
932    for selector in selectors {
933        bounded(selector, field, 4_096)?;
934    }
935    Ok(())
936}
937
938#[cfg(test)]
939mod model_tests {
940    use chrono::TimeZone;
941    use serde::de::DeserializeOwned;
942    use serde_json::{Value, json};
943
944    use super::*;
945
946    fn search() -> SearchRequest {
947        SearchRequest {
948            query: "browser research".into(),
949            purpose: "Find primary sources".into(),
950            search_type: SearchType::Web,
951            include_domains: Vec::new(),
952            exclude_domains: Vec::new(),
953            location: None,
954            language: None,
955            recency_minutes: None,
956            after: None,
957            before: None,
958            pub_year_min: None,
959            pub_year_max: None,
960            page: 0,
961        }
962    }
963
964    fn fetch(urls: usize) -> FetchRequest {
965        FetchRequest {
966            urls: (0..urls).map(|index| format!("https://example.com/{index}")).collect(),
967            purpose: "Read the relevant sections".into(),
968            format: FetchFormat::Markdown,
969            links: false,
970            image_links: false,
971            ttl_seconds: None,
972            timeout_ms: None,
973            include_selectors: Vec::new(),
974            exclude_selectors: Vec::new(),
975        }
976    }
977
978    fn rejects_unknown_field<T: DeserializeOwned>(mut value: Value) {
979        value.as_object_mut().unwrap().insert("unexpected".into(), Value::Bool(true));
980        assert!(serde_json::from_value::<T>(value).is_err());
981    }
982
983    /// Test group: every client-supplied JSON object rejects misspelled or future fields explicitly.
984    #[test]
985    fn request_payloads_reject_unknown_fields() {
986        rejects_unknown_field::<AuthExchangeRequest>(json!({"short_lived_token": "slt_value", "org_id": "tos"}));
987        rejects_unknown_field::<AuthRefreshRequest>(json!({"refresh_token": "ort_value", "org_id": "tos"}));
988        rejects_unknown_field::<ProfileCreate>(json!({"name": "primary", "location": "in", "access": []}));
989        rejects_unknown_field::<ProfileUpdate>(json!({"name": "renamed"}));
990        rejects_unknown_field::<ProfileEnd>(json!({"note": "done"}));
991        rejects_unknown_field::<SessionCreate>(json!({
992            "profile_id": "profile-1",
993            "name": "research",
994            "description": "primary sources",
995            "ttl": "30m"
996        }));
997        rejects_unknown_field::<SessionEnd>(json!({"note": "done"}));
998        rejects_unknown_field::<LiveRedeemRequest>(json!({"grant": "opaque-grant"}));
999        rejects_unknown_field::<SearchRequest>(json!({"query": "browsers", "purpose": "research"}));
1000        rejects_unknown_field::<FetchRequest>(json!({"urls": ["https://example.com"], "purpose": "read"}));
1001        rejects_unknown_field::<RunRequest>(json!({"session_id": "session-1", "command": "snapshot"}));
1002    }
1003
1004    /// Test group: a live-link grant is opaque, bounded, and safe to place only in a request body.
1005    #[test]
1006    fn live_redeem_grant_is_bounded_and_control_free() {
1007        assert!(LiveRedeemRequest { grant: "opaque-grant".into() }.validate().is_ok());
1008        assert!(LiveRedeemRequest { grant: String::new() }.validate().is_err());
1009        assert!(LiveRedeemRequest { grant: "grant\nheader".into() }.validate().is_err());
1010        assert!(LiveRedeemRequest { grant: "x".repeat(64 * 1024 + 1) }.validate().is_err());
1011    }
1012
1013    /// Test group: TTL accepts and serializes only the six documented values.
1014    #[test]
1015    fn ttl_contract_is_exact() {
1016        let expected = ["15m", "30m", "45m", "60m", "120m", "240m"];
1017        for (ttl, expected) in SessionTtl::ALL.into_iter().zip(expected) {
1018            assert_eq!(ttl.to_string(), expected);
1019            assert_eq!(expected.parse::<SessionTtl>().unwrap(), ttl);
1020            assert_eq!(serde_json::to_string(&ttl).unwrap(), format!(r#""{expected}""#));
1021        }
1022        assert!("90m".parse::<SessionTtl>().is_err());
1023    }
1024
1025    /// Test group: a new session is exactly one of profile-backed or incognito.
1026    #[test]
1027    fn session_mode_is_exclusive() {
1028        assert!(
1029            SessionCreate::with_profile("profile-1", "name", "description", SessionTtl::Minutes30).validate().is_ok()
1030        );
1031        assert!(SessionCreate::incognito("name", "description", SessionTtl::Minutes15).validate().is_ok());
1032
1033        let neither = SessionCreate {
1034            profile_id: None,
1035            incognito: false,
1036            name: "name".into(),
1037            description: "description".into(),
1038            ttl: SessionTtl::Minutes15,
1039        };
1040        assert!(neither.validate().is_err());
1041
1042        let both = SessionCreate { profile_id: Some("profile-1".into()), incognito: true, ..neither };
1043        assert!(both.validate().is_err());
1044    }
1045
1046    /// Test group: countdown never becomes negative or advertises time after a session ends.
1047    #[test]
1048    fn ttl_left_is_clamped_at_zero() {
1049        let expires = Utc.with_ymd_and_hms(2026, 8, 1, 12, 0, 0).unwrap();
1050        let mut session = Session {
1051            id: "session-1".into(),
1052            profile_id: None,
1053            incognito: true,
1054            location: None,
1055            name: "research".into(),
1056            description: "test".into(),
1057            status: SessionStatus::Expired,
1058            initiator_id: "silicon-1".into(),
1059            participant_ids: Vec::new(),
1060            ttl: SessionTtl::Minutes15,
1061            started_at: expires - chrono::Duration::minutes(15),
1062            expires_at: expires,
1063            ended_at: Some(expires),
1064            end_note: Some("ttl reached".into()),
1065            usage: UsageTotal::default(),
1066        };
1067        assert_eq!(session.ttl_left_seconds_at(expires + chrono::Duration::seconds(3)), 0);
1068        let before_expiry = expires - chrono::Duration::minutes(5);
1069        session.status = SessionStatus::Active;
1070        assert_eq!(session.ttl_left_seconds_at(before_expiry), 300);
1071        assert_eq!(session.ttl_left_seconds_at(expires + chrono::Duration::seconds(3)), 0);
1072        session.status = SessionStatus::Ended;
1073        session.ended_at = Some(before_expiry);
1074        assert_eq!(session.ttl_left_seconds_at(before_expiry), 0);
1075    }
1076
1077    /// Test group: profile updates cannot be empty and immutable fields are absent.
1078    #[test]
1079    fn profile_update_requires_a_mutable_field() {
1080        assert!(ProfileUpdate::default().validate().is_err());
1081        assert!(ProfileUpdate { name: Some("new name".into()), access: None }.validate().is_ok());
1082    }
1083
1084    /// Test group: IAM secrets never appear in Debug output and token families validate.
1085    #[test]
1086    fn auth_tokens_are_redacted_and_typed() {
1087        let exchange = AuthExchangeRequest { short_lived_token: "oac_single_use".into(), org_id: "tos".into() };
1088        assert!(exchange.validate().is_ok());
1089        assert_eq!(
1090            serde_json::to_value(&exchange).unwrap(),
1091            json!({"short_lived_token": "oac_single_use", "org_id": "tos"})
1092        );
1093        assert!(!format!("{exchange:?}").contains("oac_single_use"));
1094        assert!(
1095            AuthExchangeRequest { short_lived_token: "oac_single_use".into(), org_id: String::new() }
1096                .validate()
1097                .is_err()
1098        );
1099        assert!(
1100            AuthExchangeRequest { short_lived_token: "oat_wrong_family".into(), org_id: "tos".into() }
1101                .validate()
1102                .is_err()
1103        );
1104        assert!(
1105            serde_json::from_value::<AuthExchangeRequest>(json!({
1106                "short_lived_token": "oac_single_use"
1107            }))
1108            .is_err()
1109        );
1110
1111        let request = AuthRefreshRequest { refresh_token: "ort_secret".into(), org_id: "tos".into() };
1112        assert!(request.validate().is_ok());
1113        assert!(!format!("{request:?}").contains("ort_secret"));
1114
1115        let bad = AuthRefreshRequest { refresh_token: "oat_wrong-family".into(), org_id: "tos".into() };
1116        assert!(bad.validate().is_err());
1117
1118        let mut session = AuthSession {
1119            access_token: "oat_access".into(),
1120            refresh_token: "ort_refresh".into(),
1121            expires_at: Utc.timestamp_opt(1_800_000_000, 0).unwrap(),
1122            identity: Identity {
1123                id: "silicon-1".into(),
1124                name: "Silicon".into(),
1125                kind: IdentityKind::Silicon,
1126                tags: vec![],
1127                verified_aliases: Vec::new(),
1128            },
1129            org: Org { id: "tos".into(), name: "TOS".into() },
1130            services: vec!["session".into()],
1131        };
1132        assert!(session.validate().is_ok());
1133        session.access_token = "oat_bad\nheader".into();
1134        assert!(session.validate().is_err());
1135    }
1136
1137    /// Test group: search validates page, date mode, research-only years, and purpose length.
1138    #[test]
1139    fn search_constraints_match_the_cli_contract() {
1140        let mut request = search();
1141        request.page = 11;
1142        assert!(request.validate().is_err());
1143
1144        let mut request = search();
1145        request.recency_minutes = Some(60);
1146        request.after = NaiveDate::from_ymd_opt(2026, 8, 1);
1147        assert!(request.validate().is_err());
1148
1149        let mut request = search();
1150        request.pub_year_min = Some(2020);
1151        assert!(request.validate().is_err());
1152        request.search_type = SearchType::Research;
1153        assert!(request.validate().is_ok());
1154
1155        let mut request = search();
1156        request.purpose = "x".repeat(2_001);
1157        assert!(request.validate().is_err());
1158    }
1159
1160    /// Test group: search text and domain collections have generous denial-of-service bounds.
1161    #[test]
1162    fn search_text_and_domain_bounds_are_enforced() {
1163        let mut request = search();
1164        request.query = "q".repeat(16_385);
1165        assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "query", max: 16_384 }));
1166
1167        let mut request = search();
1168        request.include_domains = vec!["example.com".into(); 101];
1169        assert_eq!(request.validate(), Err(ValidationError::TooMany { field: "include_domains", max: 100 }));
1170
1171        let mut request = search();
1172        request.exclude_domains = vec!["x".repeat(254)];
1173        assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "exclude_domains", max: 253 }));
1174    }
1175
1176    /// Test group: fetch uses at most ten upstream URLs while preserving order.
1177    #[test]
1178    fn fetch_batches_boundaries_without_reordering() {
1179        let request = fetch(21);
1180        assert!(request.validate().is_ok());
1181        let sizes: Vec<_> = request.batches().map(<[String]>::len).collect();
1182        assert_eq!(sizes, [10, 10, 1]);
1183        assert_eq!(request.batches().nth(1).unwrap()[0], "https://example.com/10");
1184    }
1185
1186    /// Test group: fetch validates URL schemes, timeouts, selectors, and ttl=0.
1187    #[test]
1188    fn fetch_validation_accepts_live_cache_bypass_but_rejects_unsafe_inputs() {
1189        let mut request = fetch(1);
1190        request.ttl_seconds = Some(0);
1191        request.timeout_ms = Some(110_000);
1192        assert!(request.validate().is_ok());
1193
1194        request.timeout_ms = Some(110_001);
1195        assert!(request.validate().is_err());
1196
1197        let mut request = fetch(1);
1198        request.urls[0] = "file:///etc/passwd".into();
1199        assert!(request.validate().is_err());
1200
1201        let mut request = fetch(1);
1202        request.include_selectors = vec!["main".into(); 21];
1203        assert!(request.validate().is_err());
1204
1205        let mut request = fetch(1);
1206        request.include_selectors = vec!["main".into(); 10];
1207        request.exclude_selectors = vec!["nav".into(); 11];
1208        assert_eq!(
1209            request.validate(),
1210            Err(ValidationError::TooMany { field: "include_selectors/exclude_selectors", max: 20 })
1211        );
1212    }
1213
1214    /// Test group: fetch batches remain finite and individual URLs/selectors cannot dominate a request.
1215    #[test]
1216    fn fetch_collection_and_string_bounds_are_enforced() {
1217        let request = fetch(1_001);
1218        assert_eq!(request.validate(), Err(ValidationError::TooMany { field: "urls", max: 1_000 }));
1219
1220        let mut request = fetch(1);
1221        request.urls[0] = format!("https://example.com/{}", "x".repeat(16_365));
1222        assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "urls", max: 16_384 }));
1223
1224        let mut request = fetch(1);
1225        request.exclude_selectors = vec!["x".repeat(4_097)];
1226        assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "exclude_selectors", max: 4_096 }));
1227    }
1228
1229    /// Test group: user-authored labels and terminal notes are bounded without trimming their content.
1230    #[test]
1231    fn profile_and_session_text_bounds_are_enforced() {
1232        let profile = ProfileCreate { name: "x".repeat(101), location: "in".into(), access: AccessList::default() };
1233        assert_eq!(profile.validate(), Err(ValidationError::TooLong { field: "name", max: 100 }));
1234
1235        let profile = ProfileCreate { name: "name".into(), location: "1n".into(), access: AccessList::default() };
1236        assert!(profile.validate().is_err());
1237
1238        let session = SessionCreate::incognito("research", "x".repeat(2_001), SessionTtl::Minutes15);
1239        assert_eq!(session.validate(), Err(ValidationError::TooLong { field: "description", max: 2_000 }));
1240
1241        let end = SessionEnd { note: "x".repeat(4_001) };
1242        assert_eq!(end.validate(), Err(ValidationError::TooLong { field: "note", max: 4_000 }));
1243    }
1244
1245    /// Test group: Briefcase paths cannot escape the initiating principal's private root.
1246    #[test]
1247    fn recording_path_is_safe_and_deterministic() {
1248        assert_eq!(Recording::private_path("silicon-1", "session-1").unwrap(), "private/silicon-1/sb/session-1");
1249        assert!(Recording::private_path("../other", "session-1").is_err());
1250    }
1251
1252    /// Test group: recording discovery metadata is explicit on the wire.
1253    #[test]
1254    fn recording_wire_exposes_source_session_metadata() {
1255        let recording: Recording = serde_json::from_value(json!({
1256            "session_id": "session-1",
1257            "profile_id": "profile-1",
1258            "incognito": false,
1259            "session_name": "Market scan",
1260            "session_description": "Research browser vendors",
1261            "owner_id": "silicon-1",
1262            "participant_ids": ["silicon-1", "carbon-1"],
1263            "briefcase_path": "private/silicon-1/sb/session-1/recording.mp4",
1264            "briefcase_link": null,
1265            "duration_seconds": 60,
1266            "size_bytes": 100,
1267            "status": "pending",
1268            "created_at": "2026-08-10T12:00:00Z"
1269        }))
1270        .unwrap();
1271        assert_eq!(recording.profile_id.as_deref(), Some("profile-1"));
1272        assert!(!recording.incognito);
1273        assert_eq!(recording.participant_ids, ["silicon-1", "carbon-1"]);
1274
1275        let value = serde_json::to_value(recording).unwrap();
1276        assert_eq!(value["profile_id"], "profile-1");
1277        assert_eq!(value["incognito"], false);
1278        assert_eq!(value["participant_ids"], json!(["silicon-1", "carbon-1"]));
1279    }
1280
1281    /// Test group: usage conversions expose minutes and decimal GB without changing stored integers.
1282    #[test]
1283    fn usage_units_are_derived_from_exact_counters() {
1284        let usage = UsageTotal {
1285            browser_seconds: 90,
1286            proxy_bytes_in: 1_500_000_000,
1287            proxy_bytes_out: 2_000_000_000,
1288            ..UsageTotal::default()
1289        };
1290        assert_eq!(usage.browser_minutes(), 1.5);
1291        assert_eq!(usage.proxy_gb_in(), 1.5);
1292        assert_eq!(usage.proxy_gb_out(), 2.0);
1293    }
1294
1295    /// Test group: run commands retain significant whitespace and pass-through flags.
1296    #[test]
1297    fn run_request_validation_does_not_rewrite_commands() {
1298        let request = RunRequest {
1299            session_id: "session-1".into(),
1300            command: "evaluate 'a  b'  ".into(),
1301            flags: vec!["--json".into()],
1302        };
1303        let before = request.clone();
1304        request.validate().unwrap();
1305        assert_eq!(request, before);
1306    }
1307
1308    /// Test group: pass-through command arguments are preserved but bounded and NUL-free.
1309    #[test]
1310    fn run_request_bounds_command_and_flags() {
1311        let mut request = RunRequest {
1312            session_id: "session-1".into(),
1313            command: "snapshot".into(),
1314            flags: vec!["--json".into(); 257],
1315        };
1316        assert_eq!(request.validate(), Err(ValidationError::TooMany { field: "flags", max: 256 }));
1317
1318        request.flags = vec!["x".repeat(16_385)];
1319        assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "flags", max: 16_384 }));
1320
1321        request.flags = vec!["--header\0secret".into()];
1322        assert!(matches!(request.validate(), Err(ValidationError::Invalid { field: "flags", .. })));
1323
1324        request.flags.clear();
1325        request.command = "x".repeat(1_048_577);
1326        assert_eq!(request.validate(), Err(ValidationError::TooLong { field: "command", max: 1_048_576 }));
1327    }
1328}
1329
1330/// A sensitive, direct provider connection. The client controls the browser;
1331/// this is not a backend proxy and grants are bounded by the browser's lifetime.
1332#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1333pub struct SessionConnection {
1334    pub session_id: SessionId,
1335    /// Immutable requesting caller identity for local controller isolation.
1336    pub principal_id: String,
1337    pub cdp_url: String,
1338    pub expires_at: DateTime<Utc>,
1339}
1340impl std::fmt::Debug for SessionConnection {
1341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1342        f.debug_struct("SessionConnection")
1343            .field("session_id", &self.session_id)
1344            .field("cdp_url", &"[REDACTED]")
1345            .field("expires_at", &self.expires_at)
1346            .finish()
1347    }
1348}
1349
1350/// Cooperative client telemetry, not proof that these were all browser actions.
1351/// Retrying this report must never execute the command again. Browser output
1352/// remains on the client and has no field in this telemetry protocol.
1353#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1354#[serde(deny_unknown_fields)]
1355pub struct CommandReport {
1356    pub command_id: uuid::Uuid,
1357    pub command: String,
1358    #[serde(default)]
1359    pub flags: Vec<String>,
1360    pub started_at: DateTime<Utc>,
1361    pub finished_at: DateTime<Utc>,
1362    pub exit_code: i32,
1363    #[serde(default)]
1364    pub truncated: bool,
1365}
1366impl std::fmt::Debug for CommandReport {
1367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1368        f.debug_struct("CommandReport").field("command_id", &self.command_id).field("content", &"[REDACTED]").finish()
1369    }
1370}
1371impl Validate for CommandReport {
1372    fn validate(&self) -> Result<(), ValidationError> {
1373        if self.command_id.is_nil() || self.finished_at < self.started_at {
1374            return Err(ValidationError::Invalid {
1375                field: "command_report",
1376                reason: "requires a nonzero ID and ordered timestamps".into(),
1377            });
1378        }
1379        RunRequest { session_id: "report".into(), command: self.command.clone(), flags: self.flags.clone() }
1380            .validate()?;
1381        if self.command.len() + self.flags.iter().map(String::len).sum::<usize>() > 1_048_576 {
1382            return Err(ValidationError::TooLong { field: "command", max: 1_048_576 });
1383        }
1384        Ok(())
1385    }
1386}
1387
1388#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1389pub struct CommandReportReceipt {
1390    pub command_id: uuid::Uuid,
1391    /// Carbon sessions intentionally do not retain command history.
1392    pub sequence: Option<u64>,
1393}