Files
donutbrowser/src-tauri/src/cookie_bot.rs
T
2026-08-06 14:39:53 -07:00

2055 lines
73 KiB
Rust

//! Cookie-bot transport.
//!
//! The bot warms a profile's cookies overnight by driving it on a leased
//! remote host. NONE of that lives here: the schedule, the calendar maths, the
//! preset expansion, the site ordering, the dwell and scroll model, the pooled
//! budget and the nightly dispatcher are all held by donutbrowser-infra and
//! the Wayfern manager.
//!
//! This module is the wire only. It sends the user's own scalars — when to
//! run, for how long, which of their sites, which server-issued preset id —
//! and renders back what the server says happened. It deliberately keeps NO
//! local copy of a schedule: the server holds the only one, so two desktops
//! signed into one account cannot disagree about when the bot runs.
use crate::cloud_errors::{self, BackendFailure, FailureCodes};
use crate::profile::types::BrowserProfile;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use std::time::Duration;
/// Operating systems the fleet can lease. Linux is refused by the manager, so
/// refusing it here turns a nightly failure at 02:00 into a refusal at the
/// moment the user picks the profile.
pub const BOT_PLATFORMS: [&str; 2] = ["windows", "macos"];
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// Failure codes for the schedule routes.
const SCHEDULE_CODES: FailureCodes = FailureCodes {
bad_request: "COOKIE_BOT_INVALID_SCHEDULE",
forbidden: "COOKIE_BOT_NOT_ENTITLED",
not_found: "COOKIE_BOT_NOT_ENROLLED",
conflict: "COOKIE_BOT_SCHEDULE_CONFLICT",
};
/// Failure codes for the run routes.
const RUN_CODES: FailureCodes = FailureCodes {
bad_request: "COOKIE_BOT_INVALID_SCHEDULE",
forbidden: "COOKIE_BOT_NOT_ENTITLED",
not_found: "COOKIE_BOT_RUN_NOT_FOUND",
conflict: "COOKIE_BOT_RUN_IN_PROGRESS",
};
/// Failure codes for the read-only reporting routes.
const REPORT_CODES: FailureCodes = FailureCodes {
bad_request: "COOKIE_BOT_INVALID_PERIOD",
forbidden: "NOT_TEAM_MEMBER",
not_found: cloud_errors::UNAVAILABLE,
conflict: cloud_errors::UNAVAILABLE,
};
/// Failure codes for the user-template routes.
///
/// Distinct from `SCHEDULE_CODES` on every axis that matters: a 404 here is a
/// template that was deleted (possibly from another device), not an unenrolled
/// profile, and a 409 is a name the user already used, not a teammate's
/// enrolment. Sharing the schedule set would have told someone renaming a site
/// list that a colleague already warms this profile.
const TEMPLATE_CODES: FailureCodes = FailureCodes {
bad_request: "COOKIE_BOT_INVALID_TEMPLATE_NAME",
forbidden: "COOKIE_BOT_NOT_ENTITLED",
not_found: "COOKIE_BOT_TEMPLATE_NOT_FOUND",
conflict: "COOKIE_BOT_TEMPLATE_NAME_TAKEN",
};
/// Every cookie-bot call fails as a code the frontend can translate.
///
/// There is no `Other(String)` carrying backend English: a raw message reaches
/// the user untranslated, which is the bug pattern the `{"code":…}` convention
/// exists to block.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CookieBotError(pub BackendFailure);
impl CookieBotError {
pub fn code(&self) -> &str {
&self.0.code
}
pub fn status(&self) -> u16 {
self.0.status
}
/// The `{"code":…,"params":{…}}` string a Tauri command returns.
pub fn to_error_json(&self) -> String {
self.0.to_error_json()
}
}
impl std::fmt::Display for CookieBotError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_error_json())
}
}
impl From<BackendFailure> for CookieBotError {
fn from(failure: BackendFailure) -> Self {
Self(failure)
}
}
// --- Wire types -------------------------------------------------------------
//
// One place for every request and response shape, so a backend contract change
// is a single edit here rather than a hunt through call sites.
/// One time-of-day an enrolment fires, on a set of local weekdays.
///
/// Copy, and deliberately tiny: a calendar is a list of these, and the desktop
/// rebuilds that list on every keystroke in the enrolment form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotSlot {
/// Bitmask of local weekdays, bit 0 = Monday. At least one bit set.
pub days_mask: u8,
/// Minutes past local midnight, in the schedule's timezone.
pub run_at_minute: u16,
}
/// A profile enrolled in the nightly bot.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotSchedule {
pub profile_id: String,
pub profile_name: String,
pub platform: String,
pub enabled: bool,
/// Minutes past local midnight the FIRST slot is anchored to. The server
/// mirrors `slots[0]` onto this pair on every write.
pub run_at_minute: u16,
/// The first slot's weekdays, bit 0 = Monday. See `run_at_minute`.
pub days_mask: u8,
/// Every time-of-day this enrolment fires.
///
/// `default` rather than required because a server older than multi-slot
/// scheduling sends only the mirrored pair above, and a decode failure there
/// would blank the whole Cookie Bot surface rather than show one time instead
/// of several. Callers must therefore fall back to the pair when this is
/// empty — never treat an empty list as "fires at no time".
#[serde(default)]
pub slots: Vec<CookieBotSlot>,
pub timezone: String,
/// Server-issued preset id. Opaque here — what it expands to is infra's.
pub preset: String,
/// The template the sites came from, or `None` for the user's own list.
///
/// A built-in id (`low-intent-purchaser`) means `sites` is EMPTY on purpose:
/// its URLs are server-owned and never sent to a client. A `user:<uuid>` id
/// is provenance only — those sites were copied onto the enrolment and are
/// present below.
#[serde(default)]
pub template_id: Option<String>,
pub max_minutes: u32,
#[serde(default)]
pub sites: Vec<String>,
#[serde(default)]
pub jitter_seconds: u32,
// The profile facts the desktop declared, echoed back on every read. Kept so
// the UI can tell that what the server believes about a profile no longer
// matches what this machine can see, and re-declare it.
#[serde(default)]
pub sync_enabled: bool,
#[serde(default)]
pub encrypted_sync: bool,
#[serde(default)]
pub has_proxy: bool,
/// Whether that exit is one a leased fleet host could dial. Defaults to false
/// on an older server that does not send it, which reads as "not reachable"
/// and is the safe direction.
#[serde(default)]
pub proxy_remote_reachable: bool,
#[serde(default)]
pub touch_fingerprint: bool,
#[serde(default)]
pub sticky_exit: bool,
/// When those facts were last refreshed.
#[serde(default)]
pub profile_state_at: Option<String>,
/// Why tonight would be refused, or `None`.
///
/// The server computes this on every read precisely so a broken enrolment is
/// visible the moment it breaks. Dropping it meant a profile whose proxy was
/// detached in the afternoon still showed a healthy row and a next-run time,
/// and first announced itself with a skipped run at 02:00.
#[serde(default)]
pub blocked_by: Option<String>,
#[serde(default)]
pub next_run_at: Option<String>,
#[serde(default)]
pub last_run_at: Option<String>,
#[serde(default)]
pub last_run_id: Option<String>,
#[serde(default)]
pub owner_user_id: Option<String>,
#[serde(default)]
pub owner_email: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
/// What the desktop sends when enrolling or editing.
///
/// `next_run_at` is absent by design: the server recomputes it and ignores any
/// client value, so there is nothing here for two devices to disagree about.
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotScheduleInput {
pub profile_name: String,
pub platform: String,
pub enabled: bool,
pub run_at_minute: u16,
pub days_mask: u8,
/// The whole calendar, when the caller has one.
///
/// `skip_serializing_if` is load-bearing rather than tidiness: the server
/// reads an ABSENT `slots` as "one slot, from the pair above" and refuses a
/// present-but-empty one, and `null` takes the refusing branch. Serialising
/// `None` as null would 400 every write from a single-slot form.
///
/// The pair above is still sent, mirrored from `slots[0]`, so a server that
/// predates multi-slot stores the first time rather than nothing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slots: Option<Vec<CookieBotSlot>>,
pub timezone: String,
pub preset: String,
/// A browsing template instead of a typed site list.
///
/// Mutually exclusive with a non-empty `sites`: the server refuses a write
/// carrying both, because merging a curated persona with the user's own list
/// produces neither. A caller naming a template sends `sites: []`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template_id: Option<String>,
pub max_minutes: u32,
pub sites: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jitter_seconds: Option<u32>,
// The profile facts the server refuses a run on. It cannot read them itself —
// the profile lives in the user's sync namespace, not in its database — so the
// desktop reports them and the server decides.
//
// Every caller (Tauri, REST, MCP) overwrites all five through
// `with_profile_state`, derived from the profile itself, so a caller can never
// assert them. They are therefore `default` on the way IN — which is what the
// GUI sends, and what the Tauri command's own argument deserialization
// requires, since demanding them made every enrolment fail with
// `invalid args schedule: missing field sync_enabled` before the stamping
// could run — and unconditionally present on the way OUT, because the server
// rejects a write that omits them.
//
// Defaulting is safe in exactly one direction: `bool::default()` is false, so
// an unstamped input reads as "no sync, no proxy" and is REFUSED. The failure
// this must never have is the opposite one, a defaulted `has_proxy: true`
// warming a profile out of the fleet's own datacenter address.
#[serde(default)]
pub sync_enabled: bool,
#[serde(default)]
pub has_proxy: bool,
#[serde(default)]
pub proxy_remote_reachable: bool,
#[serde(default)]
pub encrypted_sync: bool,
#[serde(default)]
pub touch_fingerprint: bool,
#[serde(default)]
pub sticky_exit: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotScheduleList {
#[serde(default)]
pub schedules: Vec<CookieBotSchedule>,
#[serde(default)]
pub team_id: Option<String>,
#[serde(default)]
pub scope: Option<String>,
}
/// A teammate's enrolment of the same profile.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotConflict {
pub user_id: String,
pub email: String,
pub run_at_minute: u16,
pub timezone: String,
pub days_mask: u8,
pub enabled: bool,
/// Set on the dry-run check: the two enrolments share a weekday and fire
/// within an hour of each other.
#[serde(default)]
pub overlaps: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotScheduleSaved {
pub schedule: CookieBotSchedule,
/// Repeated on a successful acknowledged write so the UI can keep showing
/// the warning rather than pretending the collision went away.
#[serde(default)]
pub conflicts: Vec<CookieBotConflict>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotConflictCheck {
pub profile_id: String,
#[serde(default)]
pub conflicts: Vec<CookieBotConflict>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotScheduleDeleted {
pub profile_id: String,
pub deleted: bool,
}
/// One night's work on one profile.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotRun {
pub id: String,
pub profile_id: String,
#[serde(default)]
pub profile_name: Option<String>,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub email: Option<String>,
/// `schedule` or `manual`.
#[serde(default)]
pub team_id: Option<String>,
pub trigger: String,
/// `pending` | `running` | `succeeded` | `partial` | `failed` | `skipped` |
/// `cancelled`.
pub status: String,
pub scheduled_for: String,
/// The jittered instant the run was allowed to start.
#[serde(default)]
pub dispatch_after: Option<String>,
#[serde(default)]
pub started_at: Option<String>,
#[serde(default)]
pub ended_at: Option<String>,
/// The night's whole budget, which may be split across several chunks.
#[serde(default)]
pub max_minutes: u32,
/// How many browser sessions this night is split into, and which one is
/// running. A night longer than one session's cap is checkpointed at each
/// boundary, and "chunk 2 of 3" is the only honest way to report that.
#[serde(default)]
pub chunks_total: u32,
#[serde(default)]
pub chunk_index: u32,
#[serde(default)]
pub sites_total: u32,
#[serde(default)]
pub sites_visited: u32,
#[serde(default)]
pub sites_failed: u32,
#[serde(default)]
pub consent_dismissed: u32,
#[serde(default)]
pub billed_seconds: u64,
/// Why it ended the way it did, e.g. `profile_locked`, `no_capacity`.
#[serde(default)]
pub outcome_code: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotRunPage {
#[serde(default)]
pub runs: Vec<CookieBotRun>,
/// Keyset cursor; `None` on the last page.
#[serde(default)]
pub next_before: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotRunStarted {
pub run: CookieBotRun,
#[serde(default)]
pub session_id: Option<String>,
}
/// A named intensity the user can pick. The client never learns what it
/// expands to; only enough to label the choice and show its rough cost.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotPreset {
pub id: String,
#[serde(default)]
pub typical_minutes: Option<u32>,
#[serde(default)]
pub recommended: bool,
/// Server-supplied English label, present only so a preset added after this
/// build still renders. The UI must prefer its own `t()` key for a known id.
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
/// A server-owned browsing template: a named answer to "what is this profile
/// for", which the user picks INSTEAD of typing a site list.
///
/// Carries no URLs, and must not gain any. The pool a template draws from is
/// server-side for the same reason a preset's browsing model is: a published
/// list is one a retailer can filter, and each profile is given its own sample
/// so the template never becomes a fleet-wide fingerprint.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotTemplate {
pub id: String,
/// How many sites this template browses. Not which.
#[serde(default)]
pub site_count: u32,
/// Server-supplied English label and blurb, present only so a template added
/// after this build still renders. The UI prefers its own `t()` key for an id
/// it recognises.
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
/// The bounds the schedule routes actually enforce, as this build reads them.
///
/// Every field is optional because a server that predates `limits` sends none
/// of them, and a client that read a missing bound as `0` would refuse every
/// value the form can produce. Only the bounds the desktop acts on are decoded
/// — serde drops the rest, and this struct is what the GUI ultimately receives,
/// so adding a field here is what makes one reachable from TypeScript.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotLimits {
#[serde(default)]
pub min_minutes: Option<u32>,
#[serde(default)]
pub max_minutes: Option<u32>,
#[serde(default)]
pub min_sites: Option<u32>,
#[serde(default)]
pub max_sites: Option<u32>,
/// Most entries a calendar may carry.
#[serde(default)]
pub max_slots: Option<u32>,
/// Longest name a saved site list may be given.
#[serde(default)]
pub max_template_name_length: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotPresetList {
#[serde(default)]
pub presets: Vec<CookieBotPreset>,
/// Which preset the server suggests when the user has expressed no
/// preference.
#[serde(default)]
pub default_preset: Option<String>,
/// The curated templates on offer. Served beside the presets so a template
/// added server-side appears without a desktop release.
#[serde(default)]
pub templates: Vec<CookieBotTemplate>,
/// The server's own bounds, when it publishes them. The desktop mirrors a
/// copy for offline form validation; these win where they disagree.
#[serde(default)]
pub limits: Option<CookieBotLimits>,
}
/// One of the caller's OWN saved site lists.
///
/// Carries its URLs, unlike {@link CookieBotTemplate} — they are the user's own
/// and there is nothing to withhold. Applying one copies the sites onto the
/// enrolment, so a list edited later does not silently change what an existing
/// enrolment browses until it is saved again.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotUserTemplate {
/// Already carries the `user:` prefix: this id's job is to be pasted into a
/// schedule's `template_id`, and assembling that convention on the client is
/// how the two kinds of template get confused.
pub id: String,
pub name: String,
#[serde(default)]
pub sites: Vec<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RemoteHoursBreakdown {
#[serde(default)]
pub interactive_hours: f64,
#[serde(default)]
pub bot_hours: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RemoteHoursMember {
pub user_id: String,
pub email: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub used_hours: f64,
#[serde(default)]
pub interactive_hours: f64,
#[serde(default)]
pub bot_hours: f64,
}
/// The single pooled remote-hour budget. Bot and interactive hours share it;
/// the breakdown is reporting, never a sub-cap.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct RemoteHoursQuota {
pub granted_hours: f64,
pub remaining_hours: f64,
#[serde(default)]
pub used_hours: f64,
#[serde(default)]
pub period_start: Option<String>,
#[serde(default)]
pub period_end: Option<String>,
/// `user` or `team`.
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub team_id: Option<String>,
#[serde(default)]
pub seats: u32,
#[serde(default)]
pub per_seat_hours: f64,
#[serde(default)]
pub breakdown: Option<RemoteHoursBreakdown>,
/// The full roster for an owner or admin; just the caller otherwise.
#[serde(default)]
pub members: Vec<RemoteHoursMember>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotUsageMember {
pub user_id: String,
pub email: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub interactive_hours: f64,
#[serde(default)]
pub bot_hours: f64,
#[serde(default)]
pub used_hours: f64,
#[serde(default)]
pub sessions: u32,
#[serde(default)]
pub bot_runs: u32,
#[serde(default)]
pub bot_runs_failed: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotUsageProfile {
pub profile_id: String,
#[serde(default)]
pub profile_name: Option<String>,
#[serde(default)]
pub owner_email: Option<String>,
#[serde(default)]
pub bot_hours: f64,
#[serde(default)]
pub runs: u32,
/// How many of those runs did not do what they were asked. Its sibling on the
/// member view is `bot_runs_failed`; both are on the wire and both belong in
/// the report.
#[serde(default)]
pub runs_failed: u32,
#[serde(default)]
pub last_run_at: Option<String>,
#[serde(default)]
pub last_status: Option<String>,
}
/// The team owner's after-the-fact view of who spent what.
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CookieBotUsage {
pub period: String,
#[serde(default)]
pub period_start: Option<String>,
#[serde(default)]
pub period_end: Option<String>,
#[serde(default)]
pub team_id: Option<String>,
#[serde(default)]
pub seats: u32,
#[serde(default)]
pub granted_hours: f64,
#[serde(default)]
pub used_hours: f64,
#[serde(default)]
pub remaining_hours: f64,
#[serde(default)]
pub members: Vec<CookieBotUsageMember>,
#[serde(default)]
pub profiles: Vec<CookieBotUsageProfile>,
}
// --- Client-side preconditions ---------------------------------------------
/// Whether this profile could ever be warmed by the bot.
///
/// The server is authoritative — it re-checks all of this and owns the parts
/// the client cannot see — but a profile that can never qualify should never
/// reach a confirm dialog, an hour of quota or a leased host. Returns the
/// `{"code":…}` string a Tauri command surfaces directly.
pub fn bot_precondition(
profile: &BrowserProfile,
exit: &crate::remote_exit::ExitReachability,
) -> Result<(), String> {
if !profile.is_sync_enabled() {
// The host materialises the profile by pulling it from donut-sync. A
// local-only profile has nothing there, so there is no path to a run.
return Err(error("COOKIE_BOT_REQUIRES_CLOUD_SYNC", &[]));
}
if profile.is_encrypted_sync() {
// The key never leaves this machine, so the host would launch Chromium on
// ciphertext and push the corruption back over the real profile.
return Err(error("COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED", &[]));
}
let Some(platform) = profile.resolved_os() else {
return Err(error("COOKIE_BOT_UNKNOWN_PLATFORM", &[]));
};
if !BOT_PLATFORMS.contains(&platform) {
return Err(error(
"COOKIE_BOT_UNSUPPORTED_PLATFORM",
&[("platform", platform)],
));
}
if profile.proxy_id.is_none() && profile.vpn_id.is_none() {
// Without one the run egresses from the fleet's own datacenter address.
// Hours of traffic from a hosting ASN is worse for the profile's identity
// than not warming it at all.
return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[]));
}
// ...and the exit has to be one the leased host can reach. The profile and its
// proxy record are pulled onto the fleet with no address rewriting, so
// 127.0.0.1 arrives meaning THAT host's loopback — an ordinary mistake (an SSH
// tunnel, a local MITM proxy, a locally-run SOCKS client), and by the time the
// run fails an hour has been leased and billed.
//
// Taken as an ARGUMENT rather than resolved here, for the same reason
// `ProfileState` is required rather than defaulted: resolving it needs the
// proxy and VPN stores, and a function that reaches into those globals is one
// no test can set up and every caller silently depends on. `exit_reachability`
// is the one place that resolution happens; this stays a pure predicate over
// facts it is handed.
if !exit.is_remote() {
return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[]));
}
Ok(())
}
/// The profile facts the server needs but cannot see.
///
/// The server holds the schedule; the PROFILE lives in the user's sync
/// namespace, so `sync_enabled`, `has_proxy` and the rest are only knowable
/// here. It requires them on every write rather than defaulting them, because
/// a defaulted `has_proxy` is a profile warmed out of the fleet's own
/// datacenter address.
///
/// Derived in one place so the Tauri, REST and MCP call sites cannot drift into
/// three different answers about the same profile.
pub fn profile_state(profile: &BrowserProfile) -> ProfileState {
ProfileState {
sync_enabled: profile.is_sync_enabled(),
encrypted_sync: profile.is_encrypted_sync(),
// A VPN is an exit node just as much as a proxy is; the server only asks
// whether the traffic leaves through something the user brought.
has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(),
// ...and, separately, whether anyone OTHER than this machine could use it.
// `has_proxy` answers "did the user bring an exit"; this answers "is that
// exit an address a leased host can dial". They disagree for every local
// proxy, which is the case that used to be accepted and then fail on the
// fleet. See `remote_exit`.
proxy_remote_reachable: exit_reachability(profile).is_remote(),
// Always false: this data model has no mobile/touch profile. `resolved_os`
// yields only windows, macos or linux, and `bot_precondition` already
// refuses everything but the first two. Reported rather than omitted so the
// server keeps one required shape, and it stays authoritative — it sees the
// real fingerprint on the host and can still refuse a run this cannot know
// to reject.
touch_fingerprint: false,
// A VPN is one persistent tunnel, so the night's chunks share an exit. A
// stored proxy may rotate per connection, and claiming stickiness we cannot
// guarantee is worse than declining it: the server's fallback is to run the
// night as a single chunk, which is the safe answer either way.
sticky_exit: profile.vpn_id.is_some(),
}
}
/// What {@link profile_state} derives. Applied onto a schedule input before it
/// is sent.
#[derive(Debug, Clone, Copy)]
pub struct ProfileState {
pub sync_enabled: bool,
pub encrypted_sync: bool,
pub has_proxy: bool,
/// Whether that exit is an address a leased fleet host can dial.
pub proxy_remote_reachable: bool,
pub touch_fingerprint: bool,
pub sticky_exit: bool,
}
/// Whether this profile's exit could be used from a host that is not this one.
///
/// Resolves the profile's proxy or VPN out of local storage — the server cannot
/// do this, because it never sees a proxy record until sync has uploaded one and
/// even then would have to re-derive what the browser will actually dial.
///
/// A profile carrying BOTH a proxy and a VPN is judged on the proxy: that is
/// what the browser is pointed at, and it is the address the fleet has to reach.
pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability {
use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability};
if let Some(proxy_id) = profile.proxy_id.as_deref() {
let stored = crate::proxy_manager::PROXY_MANAGER
.get_stored_proxies()
.into_iter()
.find(|candidate| candidate.id == proxy_id);
return match stored {
Some(proxy) => classify_proxy(&proxy.proxy_settings),
// Referenced but missing. Fail closed: a dangling id is not evidence of a
// reachable exit, and the launch would fail anyway.
None => ExitReachability::Unknown {
reason: "the profile references a proxy that no longer exists".to_string(),
source: "proxy",
},
};
}
if let Some(vpn_id) = profile.vpn_id.as_deref() {
let config = crate::vpn::VPN_STORAGE
.lock()
.ok()
.and_then(|storage| storage.load_config(vpn_id).ok());
return match config {
Some(config) => match crate::vpn::parse_wireguard_config(&config.config_data) {
Ok(parsed) => classify_wireguard_endpoint(&parsed.peer_endpoint),
Err(error) => ExitReachability::Unknown {
reason: format!("VPN config could not be parsed ({error})"),
source: "VPN",
},
},
None => ExitReachability::Unknown {
reason: "the profile references a VPN config that no longer exists".to_string(),
source: "VPN",
},
};
}
ExitReachability::None
}
impl CookieBotScheduleInput {
/// Stamp the profile facts onto an input built from user-chosen values.
pub fn with_profile_state(mut self, state: ProfileState) -> Self {
self.sync_enabled = state.sync_enabled;
self.encrypted_sync = state.encrypted_sync;
self.has_proxy = state.has_proxy;
self.proxy_remote_reachable = state.proxy_remote_reachable;
self.touch_fingerprint = state.touch_fingerprint;
self.sticky_exit = state.sticky_exit;
self
}
}
fn error(code: &str, params: &[(&str, &str)]) -> String {
let mut object = serde_json::Map::new();
object.insert(
"code".to_string(),
serde_json::Value::String(code.to_string()),
);
if !params.is_empty() {
let map = params
.iter()
.map(|(k, v)| {
(
(*k).to_string(),
serde_json::Value::String((*v).to_string()),
)
})
.collect::<serde_json::Map<_, _>>();
object.insert("params".to_string(), serde_json::Value::Object(map));
}
serde_json::Value::Object(object).to_string()
}
// --- Routes -----------------------------------------------------------------
fn base() -> String {
format!("{}/api/cookie-bot", crate::cloud_auth::CLOUD_API_URL)
}
/// Every enrolment the caller can see.
pub async fn list_schedules(scope: Option<&str>) -> Result<CookieBotScheduleList, CookieBotError> {
let query = scope
.map(|s| vec![("scope".to_string(), s.to_string())])
.unwrap_or_default();
request(
reqwest::Method::GET,
format!("{}/schedules", base()),
query,
None,
SCHEDULE_CODES,
)
.await
}
/// This profile's enrolment, or `None` when there is none.
///
/// "Not enrolled" is a state the UI renders, not a failure it reports, so the
/// 404 is folded into `Ok(None)` here rather than at every call site.
pub async fn get_schedule(profile_id: &str) -> Result<Option<CookieBotSchedule>, CookieBotError> {
let result: Result<ScheduleEnvelope, CookieBotError> = request(
reqwest::Method::GET,
format!("{}/schedules/{}", base(), urlencoding::encode(profile_id)),
Vec::new(),
None,
SCHEDULE_CODES,
)
.await;
match result {
Ok(envelope) => Ok(Some(envelope.schedule)),
Err(e) if e.code() == "COOKIE_BOT_NOT_ENROLLED" => Ok(None),
Err(e) => Err(e),
}
}
#[derive(Debug, Deserialize)]
struct ScheduleEnvelope {
schedule: CookieBotSchedule,
}
/// Create or replace this profile's enrolment.
///
/// `acknowledge_conflict` is the second half of a two-step write: the first
/// PUT is refused with the teammate's name and time, and the same PUT with the
/// flag set goes through. Two operators colliding into a silent nightly 409 is
/// exactly what that costs to avoid.
pub async fn save_schedule(
profile_id: &str,
input: &CookieBotScheduleInput,
acknowledge_conflict: bool,
) -> Result<CookieBotScheduleSaved, CookieBotError> {
let mut body = serde_json::to_value(input).map_err(|e| {
CookieBotError(cloud_errors::transport_failure(&format!(
"encode schedule: {e}"
)))
})?;
if let Some(object) = body.as_object_mut() {
object.insert(
"acknowledge_conflict".to_string(),
serde_json::Value::Bool(acknowledge_conflict),
);
}
request(
reqwest::Method::PUT,
format!("{}/schedules/{}", base(), urlencoding::encode(profile_id)),
Vec::new(),
Some(body),
SCHEDULE_CODES,
)
.await
}
/// Re-declare the profile facts the server cannot observe for itself.
///
/// Narrow on purpose: a detached proxy should not have to resend a whole
/// schedule and risk clobbering an edit made from another device in between.
pub async fn update_profile_state(
profile_id: &str,
state: ProfileState,
timezone: Option<&str>,
) -> Result<CookieBotSchedule, CookieBotError> {
let mut body = serde_json::Map::new();
body.insert("sync_enabled".to_string(), state.sync_enabled.into());
body.insert("encrypted_sync".to_string(), state.encrypted_sync.into());
body.insert("has_proxy".to_string(), state.has_proxy.into());
body.insert(
"proxy_remote_reachable".to_string(),
state.proxy_remote_reachable.into(),
);
body.insert(
"touch_fingerprint".to_string(),
state.touch_fingerprint.into(),
);
body.insert("sticky_exit".to_string(), state.sticky_exit.into());
if let Some(zone) = timezone {
body.insert("timezone".to_string(), zone.into());
}
let envelope: ScheduleEnvelope = request(
reqwest::Method::POST,
format!(
"{}/schedules/{}/profile-state",
base(),
urlencoding::encode(profile_id)
),
Vec::new(),
Some(serde_json::Value::Object(body)),
SCHEDULE_CODES,
)
.await?;
Ok(envelope.schedule)
}
/// Push this profile's current facts to the server, without blocking the edit
/// that changed them.
///
/// The server refuses a run on the copy the desktop last declared —
/// `has_proxy: false` is `proxy_required`, and that check exists because a run
/// without an exit node egresses from the leased host's own datacenter address.
/// Nothing but a full schedule write refreshed that copy, so detaching a proxy
/// from an enrolled profile left `has_proxy: true` on the row and the night ran
/// anyway. This closes that gap at the moment the profile changes.
///
/// Silent on failure by design: a profile edit must not fail because the cloud
/// is unreachable, an unenrolled profile answers `COOKIE_BOT_NOT_ENROLLED`
/// which is the normal case, and the next edit re-declares the same facts.
pub fn report_profile_state(profile: &BrowserProfile) {
let profile_id = profile.id.to_string();
let state = profile_state(profile);
tauri::async_runtime::spawn(async move {
if !crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
return;
}
match update_profile_state(&profile_id, state, None).await {
Ok(_) => {
log::debug!("Re-declared cookie-bot profile state for {profile_id}");
}
// Not enrolled is the common answer and not worth a log line at warn.
Err(e) if e.code() == "COOKIE_BOT_NOT_ENROLLED" => {}
Err(e) => {
log::warn!("Could not re-declare cookie-bot profile state for {profile_id}: {e}");
}
}
});
}
/// Turn the bot off for this profile. Safe to repeat: deleting an enrolment
/// that is already gone succeeds with `deleted: false`.
pub async fn delete_schedule(profile_id: &str) -> Result<CookieBotScheduleDeleted, CookieBotError> {
request(
reqwest::Method::DELETE,
format!("{}/schedules/{}", base(), urlencoding::encode(profile_id)),
Vec::new(),
None,
SCHEDULE_CODES,
)
.await
}
/// Ask, without writing anything, who else already warms this profile.
pub async fn check_conflicts(
profile_id: &str,
run_at_minute: Option<u16>,
timezone: Option<&str>,
days_mask: Option<u8>,
) -> Result<CookieBotConflictCheck, CookieBotError> {
let mut query = vec![("profile_id".to_string(), profile_id.to_string())];
if let Some(minute) = run_at_minute {
query.push(("run_at_minute".to_string(), minute.to_string()));
}
if let Some(zone) = timezone {
query.push(("timezone".to_string(), zone.to_string()));
}
if let Some(mask) = days_mask {
query.push(("days_mask".to_string(), mask.to_string()));
}
request(
reqwest::Method::GET,
format!("{}/conflicts", base()),
query,
None,
SCHEDULE_CODES,
)
.await
}
/// One page of run history, newest first.
pub async fn list_runs(
profile_id: Option<&str>,
scope: Option<&str>,
limit: Option<u32>,
before: Option<&str>,
) -> Result<CookieBotRunPage, CookieBotError> {
let mut query = Vec::new();
if let Some(id) = profile_id {
query.push(("profile_id".to_string(), id.to_string()));
}
if let Some(s) = scope {
query.push(("scope".to_string(), s.to_string()));
}
if let Some(n) = limit {
query.push(("limit".to_string(), n.to_string()));
}
if let Some(cursor) = before {
query.push(("before".to_string(), cursor.to_string()));
}
request(
reqwest::Method::GET,
format!("{}/runs", base()),
query,
None,
RUN_CODES,
)
.await
}
/// Start a run now instead of waiting for tonight.
///
/// The preset and the site list come from the stored schedule, so this carries
/// no behaviour of its own — an unenrolled profile is a 404, not an implicit
/// enrolment with client-chosen defaults.
pub async fn run_now(
profile_id: &str,
max_minutes: Option<u32>,
) -> Result<CookieBotRunStarted, CookieBotError> {
let mut body = serde_json::Map::new();
body.insert(
"profile_id".to_string(),
serde_json::Value::String(profile_id.to_string()),
);
if let Some(minutes) = max_minutes {
body.insert("max_minutes".to_string(), serde_json::Value::from(minutes));
}
request(
reqwest::Method::POST,
format!("{}/runs", base()),
Vec::new(),
Some(serde_json::Value::Object(body)),
RUN_CODES,
)
.await
}
/// Stop a run that is still going.
///
/// A 503 here means the fleet could not be reached and the browser is still
/// up, so the run stays `running` rather than being marked cancelled under a
/// live browser — retiring a row while something is still writing the cookie
/// jar is the two-writer case the profile lock exists to prevent.
pub async fn cancel_run(run_id: &str) -> Result<CookieBotRun, CookieBotError> {
let envelope: RunEnvelope = request(
reqwest::Method::DELETE,
format!("{}/runs/{}", base(), urlencoding::encode(run_id)),
Vec::new(),
None,
RUN_CODES,
)
.await?;
Ok(envelope.run)
}
#[derive(Debug, Deserialize)]
struct RunEnvelope {
run: CookieBotRun,
}
/// The intensities the server offers today.
pub async fn list_presets() -> Result<CookieBotPresetList, CookieBotError> {
request(
reqwest::Method::GET,
format!("{}/presets", base()),
Vec::new(),
None,
REPORT_CODES,
)
.await
}
// --- User-defined templates -------------------------------------------------
//
// The caller's own saved site lists. Unlike every other route in this file
// these are addressed by an id the SERVER minted and the client echoes back,
// so each one percent-encodes it: the id is spelled `user:<uuid>`, and a bare
// colon in a path segment is a spelling the router is free to read differently.
#[derive(Debug, Deserialize)]
struct UserTemplateListEnvelope {
#[serde(default)]
templates: Vec<CookieBotUserTemplate>,
}
#[derive(Debug, Deserialize)]
struct UserTemplateEnvelope {
template: CookieBotUserTemplate,
}
#[derive(Debug, Deserialize)]
struct UserTemplateDeleted {
#[serde(default)]
deleted: bool,
}
/// Every site list this user has saved, most recently edited first.
pub async fn list_user_templates() -> Result<Vec<CookieBotUserTemplate>, CookieBotError> {
let envelope: UserTemplateListEnvelope = request(
reqwest::Method::GET,
format!("{}/user-templates", base()),
Vec::new(),
None,
TEMPLATE_CODES,
)
.await?;
Ok(envelope.templates)
}
/// Save a new one.
pub async fn create_user_template(
name: &str,
sites: &[String],
) -> Result<CookieBotUserTemplate, CookieBotError> {
let body = serde_json::json!({ "name": name, "sites": sites });
let envelope: UserTemplateEnvelope = request(
reqwest::Method::POST,
format!("{}/user-templates", base()),
Vec::new(),
Some(body),
TEMPLATE_CODES,
)
.await?;
Ok(envelope.template)
}
/// Rename one, replace its sites, or both.
///
/// A PATCH with only the fields that changed, because the two are independent:
/// a rename that had to carry the whole site list is a rename that silently
/// reverts an edit made to it from another device in the meantime. Sending an
/// omitted field as `null` would defeat that, so each is skipped when absent.
pub async fn update_user_template(
id: &str,
name: Option<&str>,
sites: Option<&[String]>,
) -> Result<CookieBotUserTemplate, CookieBotError> {
let mut body = serde_json::Map::new();
if let Some(name) = name {
body.insert(
"name".to_string(),
serde_json::Value::String(name.to_string()),
);
}
if let Some(sites) = sites {
body.insert("sites".to_string(), serde_json::json!(sites));
}
let envelope: UserTemplateEnvelope = request(
reqwest::Method::PATCH,
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
Vec::new(),
Some(serde_json::Value::Object(body)),
TEMPLATE_CODES,
)
.await?;
Ok(envelope.template)
}
/// Delete one. Enrolments that used it keep the sites they copied, so this is
/// never a way to stop a profile being warmed tonight.
///
/// Safe to repeat: deleting a list that is already gone answers `false` rather
/// than 404, which is what makes a retry after a dropped response harmless.
pub async fn delete_user_template(id: &str) -> Result<bool, CookieBotError> {
let deleted: UserTemplateDeleted = request(
reqwest::Method::DELETE,
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
Vec::new(),
None,
TEMPLATE_CODES,
)
.await?;
Ok(deleted.deleted)
}
// --- Tauri commands ---------------------------------------------------------
//
// The user-template commands live here rather than in `lib.rs` beside the
// schedule ones because they carry no local precondition: nothing about a saved
// site list depends on a profile this machine holds, so there is no profile to
// look up and no `bot_precondition` to apply. They must still be registered in
// `lib.rs`'s `invoke_handler` to be reachable.
/// Log a refusal and hand the frontend the envelope it translates.
///
/// The raw HTTP text never reaches the user: an untranslated backend sentence
/// in a Japanese UI is the failure the `{"code":…}` convention exists to stop.
fn command_error(context: &str, err: CookieBotError) -> String {
log::warn!(
"Cookie bot {context} failed: {} (HTTP {})",
err.code(),
err.status()
);
err.to_error_json()
}
/// Every site list this user has saved.
#[tauri::command]
pub async fn get_cookie_bot_user_templates() -> Result<Vec<CookieBotUserTemplate>, String> {
list_user_templates()
.await
.map_err(|e| command_error("template list", e))
}
/// Save the current site list under a name.
#[tauri::command]
pub async fn create_cookie_bot_user_template(
name: String,
sites: Vec<String>,
) -> Result<CookieBotUserTemplate, String> {
create_user_template(&name, &sites)
.await
.map_err(|e| command_error("template create", e))
}
/// Rename a saved list, replace its sites, or both. Omitted fields are left
/// exactly as they are.
#[tauri::command]
pub async fn update_cookie_bot_user_template(
id: String,
name: Option<String>,
sites: Option<Vec<String>>,
) -> Result<CookieBotUserTemplate, String> {
update_user_template(&id, name.as_deref(), sites.as_deref())
.await
.map_err(|e| command_error("template update", e))
}
/// Delete a saved list. `false` means there was nothing left to delete.
#[tauri::command]
pub async fn delete_cookie_bot_user_template(id: String) -> Result<bool, String> {
delete_user_template(&id)
.await
.map_err(|e| command_error("template delete", e))
}
/// Per-member and per-profile spend for a calendar month (`YYYY-MM`).
pub async fn team_usage(period: Option<&str>) -> Result<CookieBotUsage, CookieBotError> {
let query = period
.map(|p| vec![("period".to_string(), p.to_string())])
.unwrap_or_default();
request(
reqwest::Method::GET,
format!("{}/usage", base()),
query,
None,
REPORT_CODES,
)
.await
}
/// The pooled remote-hour budget.
///
/// Being refused a launch must not be the only way to learn a limit exists,
/// which is what this route has been for as long as nothing called it.
pub async fn remote_hours_quota() -> Result<RemoteHoursQuota, CookieBotError> {
request(
reqwest::Method::GET,
format!(
"{}/api/remote-sessions/quota",
crate::cloud_auth::CLOUD_API_URL
),
Vec::new(),
None,
REPORT_CODES,
)
.await
}
// --- Transport --------------------------------------------------------------
fn http() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.connect_timeout(CONNECT_TIMEOUT)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
})
}
/// Append a percent-encoded query string.
///
/// Built here rather than left to the HTTP client so a profile id or a keyset
/// cursor containing a `&` cannot smuggle a second parameter into the request.
fn with_query(url: &str, query: &[(String, String)]) -> String {
if query.is_empty() {
return url.to_string();
}
let encoded = query
.iter()
.map(|(key, value)| {
format!(
"{}={}",
urlencoding::encode(key),
urlencoding::encode(value)
)
})
.collect::<Vec<_>>()
.join("&");
let separator = if url.contains('?') { '&' } else { '?' };
format!("{url}{separator}{encoded}")
}
/// One request, one place.
///
/// Goes through `api_call_with_retry` so an expired access token is refreshed
/// and the call retried once — otherwise a user whose token aged out overnight
/// sees "not signed in" on a machine that is signed in.
async fn request<T: DeserializeOwned>(
method: reqwest::Method,
url: String,
query: Vec<(String, String)>,
body: Option<serde_json::Value>,
codes: FailureCodes,
) -> Result<T, CookieBotError> {
crate::cloud_auth::CLOUD_AUTH
.api_call_with_retry(|token| {
let method = method.clone();
let url = url.clone();
let query = query.clone();
let body = body.clone();
async move {
let url = with_query(&url, &query);
let mut builder = http().request(method, &url).bearer_auth(token);
if let Some(payload) = body {
builder = builder.json(&payload);
}
let response = builder
.send()
.await
.map_err(|e| format!("reach backend: {e}"))?;
let status = response.status().as_u16();
if !(200..300).contains(&status) {
let text = response.text().await.unwrap_or_default();
// Encode the status so api_call_with_retry can spot a 401 and
// classify_message can recover the code afterwards.
return Err(format!("({status}) {text}"));
}
response
.json::<T>()
.await
.map_err(|e| format!("decode response: {e}"))
}
})
.await
.map_err(|e| CookieBotError(cloud_errors::classify_message(&e, codes)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::types::SyncMode;
use crate::remote_exit::ExitReachability;
fn eligible_profile() -> BrowserProfile {
BrowserProfile {
id: uuid::Uuid::nil(),
name: "warm me".to_string(),
browser: "wayfern".to_string(),
version: "latest".to_string(),
sync_mode: SyncMode::Regular,
host_os: Some("macos".to_string()),
proxy_id: Some("proxy-1".to_string()),
..Default::default()
}
}
#[test]
fn the_profile_facts_the_server_requires_are_derived_from_the_profile() {
let state = profile_state(&eligible_profile());
assert!(state.sync_enabled);
assert!(!state.encrypted_sync);
assert!(state.has_proxy);
assert!(!state.touch_fingerprint);
// A stored proxy is not claimed to be sticky — only a VPN tunnel is.
assert!(!state.sticky_exit);
}
#[test]
fn a_vpn_counts_as_the_exit_node_and_as_a_sticky_one() {
// `has_proxy` asks whether the user brought an exit, not whether that exit
// is specifically a stored proxy. A VPN-only profile that reported false
// would be refused a run it is perfectly entitled to.
let profile = BrowserProfile {
proxy_id: None,
vpn_id: Some("vpn-1".to_string()),
..eligible_profile()
};
let state = profile_state(&profile);
assert!(state.has_proxy);
assert!(state.sticky_exit);
}
#[test]
fn a_profile_with_no_exit_reports_none() {
let profile = BrowserProfile {
proxy_id: None,
vpn_id: None,
..eligible_profile()
};
assert!(!profile_state(&profile).has_proxy);
}
fn code_of(err: &str) -> String {
serde_json::from_str::<serde_json::Value>(err)
.expect("a precondition failure must be a JSON error envelope")["code"]
.as_str()
.expect("the envelope must name a code")
.to_string()
}
#[test]
fn a_local_only_profile_has_no_path_to_a_run() {
// The host obtains the profile from donut-sync. Without sync there is
// nothing to pull, so the run would warm an empty browser and then push
// that emptiness over the user's real profile.
let mut profile = eligible_profile();
profile.sync_mode = SyncMode::Disabled;
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("a local-only profile must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_CLOUD_SYNC");
}
#[test]
fn end_to_end_encrypted_sync_is_refused_with_its_own_code() {
// Distinct from "turn sync on": the fix is to switch to Regular sync, and
// one code cannot carry two different instructions.
let mut profile = eligible_profile();
profile.sync_mode = SyncMode::Encrypted;
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("encrypted sync must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED");
}
#[test]
fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
let mut profile = eligible_profile();
profile.host_os = Some("linux".to_string());
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("linux has no host to lease");
let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope");
assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM");
assert_eq!(
parsed["params"]["platform"], "linux",
"the message must name the platform that cannot run"
);
}
#[test]
fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() {
let mut profile = eligible_profile();
profile.host_os = None;
let err = bot_precondition(&profile, &ExitReachability::Remote)
.expect_err("no OS means no matching host");
assert_eq!(code_of(&err), "COOKIE_BOT_UNKNOWN_PLATFORM");
}
#[test]
fn a_run_without_a_proxy_or_vpn_is_refused() {
// Hours of overnight traffic from a hosting ASN damages the profile's
// identity more than not warming it would.
let mut profile = eligible_profile();
profile.proxy_id = None;
profile.vpn_id = None;
let err = bot_precondition(&profile, &ExitReachability::None)
.expect_err("datacenter egress must be refused");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE");
}
#[test]
fn a_vpn_satisfies_the_exit_node_requirement_just_as_a_proxy_does() {
let mut profile = eligible_profile();
profile.proxy_id = None;
profile.vpn_id = Some("vpn-1".to_string());
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
}
#[test]
fn a_windows_profile_with_sync_and_a_proxy_qualifies() {
let mut profile = eligible_profile();
profile.host_os = Some("windows".to_string());
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
}
#[test]
fn an_exit_only_this_machine_can_reach_is_refused() {
// The gap `has_proxy` alone could never see, and — before the verdict became
// an argument — a case no unit test could construct, because resolving it
// reached into the global proxy store. The profile is otherwise perfect.
let profile = eligible_profile();
let err = bot_precondition(
&profile,
&ExitReachability::LocalOnly {
host: "127.0.0.1".to_string(),
source: "proxy",
},
)
.expect_err("a loopback exit cannot be dialled from a leased host");
// Its own code: "attach a proxy" is unactionable advice for someone whose
// proxy is plainly attached.
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
#[test]
fn an_exit_we_could_not_read_is_refused_too() {
// Fails closed. Refusing a working setup costs one support question;
// accepting a broken one burns a leased hour and damages an identity.
let err = bot_precondition(
&eligible_profile(),
&ExitReachability::Unknown {
reason: "VPN config could not be parsed".to_string(),
source: "VPN",
},
)
.expect_err("an unreadable exit must not be assumed reachable");
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
}
/// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView`
/// in donutbrowser-infra's `cookie-bot.service.ts` builds it.
const SERVER_SCHEDULE_VIEW: &str = r#"{
"profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true,
"run_at_minute":120,"days_mask":127,
"slots":[{"days_mask":127,"run_at_minute":120},{"days_mask":31,"run_at_minute":690}],
"timezone":"Europe/Berlin","template_id":null,
"preset":"balanced","max_minutes":45,"sites":["https://example.com"],
"jitter_seconds":900,"sync_enabled":true,"encrypted_sync":false,
"has_proxy":true,"touch_fingerprint":false,"sticky_exit":false,
"profile_state_at":"2026-08-03T09:00:00.000Z","next_run_at":"2026-08-04T00:00:00.000Z",
"last_run_at":null,"last_run_id":null,"blocked_by":null,"owner_user_id":"u1",
"owner_email":"a@example.com","updated_at":"2026-08-03T10:00:00.000Z"
}"#;
#[test]
fn the_schedule_payload_matches_what_the_backend_sends() {
// Pinned against the Schedule shape in donutbrowser-infra's
// cookie-bot controller. A field name that drifts makes every read fail
// at the decode step, which surfaces as "something went wrong" with no
// hint that the contract moved.
let schedule: CookieBotSchedule = serde_json::from_str(SERVER_SCHEDULE_VIEW)
.expect("the backend's schedule payload must deserialize");
assert_eq!(schedule.run_at_minute, 120);
assert_eq!(schedule.days_mask, 127);
assert_eq!(schedule.max_minutes, 45);
assert_eq!(schedule.sites, vec!["https://example.com".to_string()]);
assert!(schedule.last_run_at.is_none());
// The declared facts, echoed back. Dropped, they left the UI unable to see
// that the server's copy of a profile no longer matched this machine's.
assert!(schedule.sync_enabled);
assert!(schedule.has_proxy);
assert!(!schedule.encrypted_sync);
assert!(schedule.profile_state_at.is_some());
assert!(schedule.blocked_by.is_none());
}
#[test]
fn a_schedule_carries_its_whole_calendar_not_just_the_first_time() {
// The mirrored pair is `slots[0]`, so a client that read only the pair
// would show "every night at 02:00" for an enrolment that also runs at
// 11:30 on weeknights — fewer runs than the user booked, silently.
let schedule: CookieBotSchedule =
serde_json::from_str(SERVER_SCHEDULE_VIEW).expect("a multi-slot schedule must deserialize");
assert_eq!(schedule.slots.len(), 2);
assert_eq!(schedule.slots[0].run_at_minute, schedule.run_at_minute);
assert_eq!(schedule.slots[0].days_mask, schedule.days_mask);
assert_eq!(schedule.slots[1].run_at_minute, 690);
assert_eq!(schedule.slots[1].days_mask, 31);
}
#[test]
fn a_server_that_predates_multi_slot_still_decodes_with_no_slots() {
// `slots` absent is a deployment that has not rolled forward, not a broken
// enrolment. Requiring it would blank the whole Cookie Bot surface against
// an older backend rather than show the one time it does know about.
let schedule: CookieBotSchedule = serde_json::from_str(
r#"{"profile_id":"p1","profile_name":"Yu","platform":"windows","enabled":true,
"run_at_minute":120,"days_mask":31,"timezone":"UTC","preset":"light",
"max_minutes":10}"#,
)
.expect("a pre-multi-slot schedule must deserialize");
assert!(schedule.slots.is_empty());
assert!(schedule.template_id.is_none());
}
#[test]
fn a_templated_enrolment_reports_its_template_and_no_sites() {
// A built-in template's URLs are server-owned. An empty `sites` here is the
// contract working, not a schedule with nothing to browse — anything that
// reads it as "no sites" would show a healthy enrolment as broken.
let schedule: CookieBotSchedule = serde_json::from_str(
&SERVER_SCHEDULE_VIEW
.replace(
"\"template_id\":null",
"\"template_id\":\"low-intent-purchaser\"",
)
.replace("\"sites\":[\"https://example.com\"]", "\"sites\":[]"),
)
.expect("a templated schedule must deserialize");
assert_eq!(
schedule.template_id.as_deref(),
Some("low-intent-purchaser")
);
assert!(schedule.sites.is_empty());
assert!(schedule.blocked_by.is_none());
}
#[test]
fn a_calendar_is_sent_as_slots_and_omitted_entirely_when_there_is_none() {
// The server reads an ABSENT `slots` as "one slot, from the legacy pair"
// and REFUSES a null or empty one. Serialising `None` as null would 400
// every write from a form with a single time on it.
let one_slot = CookieBotScheduleInput {
profile_name: "Yu".to_string(),
platform: "macos".to_string(),
enabled: true,
run_at_minute: 120,
days_mask: 127,
timezone: "Europe/Berlin".to_string(),
preset: "balanced".to_string(),
max_minutes: 45,
sites: vec!["https://example.com".to_string()],
..Default::default()
};
let encoded = serde_json::to_value(&one_slot).expect("input must serialize");
assert!(
encoded.get("slots").is_none(),
"an absent calendar must be absent on the wire, not null"
);
assert!(encoded.get("template_id").is_none());
let many = CookieBotScheduleInput {
slots: Some(vec![
CookieBotSlot {
days_mask: 127,
run_at_minute: 120,
},
CookieBotSlot {
days_mask: 31,
run_at_minute: 690,
},
]),
..one_slot
};
let encoded = serde_json::to_value(&many).expect("input must serialize");
let slots = encoded["slots"].as_array().expect("slots must be a list");
assert_eq!(slots.len(), 2);
// Mirrored, because a server that predates multi-slot ignores `slots` and
// stores this pair. Dropping it would leave that server with no time at all.
assert_eq!(encoded["run_at_minute"], 120);
assert_eq!(encoded["days_mask"], 127);
}
#[test]
fn a_templated_write_names_the_template_and_sends_no_sites() {
// The server refuses a body carrying both: a curated persona merged with
// the user's own list is neither.
let input = CookieBotScheduleInput {
profile_name: "Yu".to_string(),
platform: "macos".to_string(),
enabled: true,
run_at_minute: 120,
days_mask: 127,
timezone: "UTC".to_string(),
preset: "balanced".to_string(),
max_minutes: 45,
sites: Vec::new(),
template_id: Some("low-intent-purchaser".to_string()),
..Default::default()
};
let encoded = serde_json::to_value(&input).expect("input must serialize");
assert_eq!(encoded["template_id"], "low-intent-purchaser");
assert_eq!(
encoded["sites"].as_array().map(Vec::len),
Some(0),
"sites must still be sent, and must be empty, beside a template"
);
}
#[test]
fn a_broken_enrolment_carries_the_reason_it_cannot_run() {
// The whole point of `blocked_by`: a profile whose proxy was detached in
// the afternoon should not first announce itself with a skipped run at
// 02:00. Dropping the field made the enrolment look healthy until it
// failed.
let schedule: CookieBotSchedule = serde_json::from_str(
&SERVER_SCHEDULE_VIEW
.replace("\"has_proxy\":true", "\"has_proxy\":false")
.replace("\"blocked_by\":null", "\"blocked_by\":\"proxy_required\""),
)
.expect("a blocked schedule must deserialize");
assert!(!schedule.has_proxy);
assert_eq!(schedule.blocked_by.as_deref(), Some("proxy_required"));
}
#[test]
fn a_schedule_missing_every_optional_field_still_decodes() {
// A freshly created enrolment has never run, so the backend omits or
// nulls half the object. Failing to decode that would make the enrolment
// the user just made look broken.
let schedule: CookieBotSchedule = serde_json::from_str(
r#"{"profile_id":"p1","profile_name":"Yu","platform":"windows","enabled":false,
"run_at_minute":0,"days_mask":1,"timezone":"UTC","preset":"light",
"max_minutes":5}"#,
)
.expect("a never-run schedule must deserialize");
assert!(schedule.sites.is_empty());
assert_eq!(schedule.jitter_seconds, 0);
assert!(schedule.next_run_at.is_none());
}
#[test]
fn the_run_payload_matches_what_the_backend_sends() {
// Verbatim `CookieBotRunView`, as `toRunViews` builds it. `max_minutes`,
// `chunks_total`, `chunk_index`, `dispatch_after` and `team_id` were all
// already on the wire and all silently discarded, so a multi-chunk night
// could not be reported as one.
let page: CookieBotRunPage = serde_json::from_str(
r#"{"runs":[{"id":"r1","profile_id":"p1","profile_name":"Yu","user_id":"u1",
"email":"a@example.com","team_id":"t1","trigger":"schedule","status":"running",
"scheduled_for":"2026-08-03T00:00:00.000Z",
"dispatch_after":"2026-08-03T00:07:30.000Z","started_at":"2026-08-03T00:08:00.000Z",
"ended_at":null,"max_minutes":180,"chunks_total":3,"chunk_index":2,
"sites_total":12,"sites_visited":11,
"sites_failed":1,"consent_dismissed":4,"billed_seconds":2220,
"outcome_code":null,"session_id":"s1"}],"next_before":null}"#,
)
.expect("the backend's run page must deserialize");
let run = &page.runs[0];
assert_eq!(run.status, "running");
assert_eq!(run.billed_seconds, 2220);
assert_eq!(run.sites_visited, 11);
assert_eq!(run.team_id.as_deref(), Some("t1"));
assert_eq!(run.max_minutes, 180);
assert_eq!(run.chunks_total, 3);
assert_eq!(run.chunk_index, 2);
assert!(run.dispatch_after.is_some());
assert!(page.next_before.is_none());
}
#[test]
fn the_usage_report_keeps_the_per_profile_failure_count() {
// `runs_failed` sits beside `runs` on the wire and is the one number that
// answers "is this enrolment actually working?" in the team dashboard.
let usage: CookieBotUsage = serde_json::from_str(
r#"{"period":"2026-08","period_start":"2026-08-01T00:00:00.000Z",
"period_end":"2026-09-01T00:00:00.000Z","team_id":"t1","seats":2,
"granted_hours":400,"used_hours":12.5,"remaining_hours":387.5,
"members":[],
"profiles":[{"profile_id":"p1","profile_name":"Yu","owner_email":"a@example.com",
"bot_hours":12.5,"runs":9,"runs_failed":4,
"last_run_at":"2026-08-03T00:41:00.000Z","last_status":"failed"}]}"#,
)
.expect("the usage report must deserialize");
assert_eq!(usage.profiles[0].runs, 9);
assert_eq!(usage.profiles[0].runs_failed, 4);
}
#[test]
fn the_profile_state_body_declares_every_fact_the_server_gates_on() {
// The narrow re-declaration route. `has_proxy` false is `proxy_required`
// server-side, so an omitted field silently keeps the stale value and the
// night runs unproxied.
let state = profile_state(&eligible_profile());
let body = serde_json::json!({
"sync_enabled": state.sync_enabled,
"encrypted_sync": state.encrypted_sync,
"has_proxy": state.has_proxy,
"touch_fingerprint": state.touch_fingerprint,
"sticky_exit": state.sticky_exit,
});
for key in [
"sync_enabled",
"encrypted_sync",
"has_proxy",
"touch_fingerprint",
"sticky_exit",
] {
assert!(
body.get(key).is_some_and(serde_json::Value::is_boolean),
"{key} must be declared, not left to the server's stale copy"
);
}
}
#[test]
fn the_gui_payload_deserialises_without_the_facts_the_command_stamps() {
// This is exactly what `saveCookieBotSchedule` in src/lib/cookie-bot.ts
// sends: the user's own scalars and nothing else. Requiring the profile
// facts here made Tauri reject the argument with
// `invalid args schedule: missing field sync_enabled` before
// `with_profile_state` ever ran, so every enrolment from the GUI failed
// while REST and MCP — which build the struct in Rust — worked.
let input: CookieBotScheduleInput = serde_json::from_str(
r#"{"profile_name":"Yu","platform":"macos","enabled":true,"run_at_minute":120,
"days_mask":127,"timezone":"Europe/Berlin","preset":"balanced",
"max_minutes":45,"sites":["https://example.com"]}"#,
)
.expect("the frontend's schedule payload must deserialize");
// Fail-closed: an input nobody stamped claims no sync and no exit node, and
// the server refuses both. The dangerous default would be the other way.
assert!(!input.sync_enabled);
assert!(!input.has_proxy);
let stamped = input.with_profile_state(profile_state(&eligible_profile()));
assert!(stamped.sync_enabled);
assert!(stamped.has_proxy);
}
#[test]
fn a_schedule_input_serialises_without_a_next_run_at() {
// The server always recomputes the next fire instant. Sending one would
// invite a client and a server that disagree about when the bot runs.
let input = CookieBotScheduleInput {
profile_name: "Yu".to_string(),
platform: "macos".to_string(),
enabled: true,
run_at_minute: 120,
days_mask: 127,
timezone: "Europe/Berlin".to_string(),
preset: "balanced".to_string(),
max_minutes: 45,
sites: vec!["https://example.com".to_string()],
jitter_seconds: None,
..Default::default()
};
// The server rejects a write missing either of these with
// COOKIE_BOT_INVALID_SCHEDULE, so they must be on the wire unconditionally
// — `skip_serializing_if` on them would make every enrolment a 400.
let encoded = serde_json::to_value(&input).expect("input must serialize");
assert!(encoded.get("sync_enabled").is_some());
assert!(encoded.get("has_proxy").is_some());
assert!(encoded.get("next_run_at").is_none());
assert!(
encoded.get("jitter_seconds").is_none(),
"an unset jitter must be omitted so the server's default applies"
);
}
#[test]
fn the_quota_payload_survives_a_backend_that_only_sends_the_original_two_keys() {
// The route predates this feature and returned only these two fields.
// A deployment that has not rolled forward must still render a budget.
let quota: RemoteHoursQuota =
serde_json::from_str(r#"{"granted_hours":200,"remaining_hours":187.25}"#)
.expect("the legacy quota payload must deserialize");
assert_eq!(quota.granted_hours, 200.0);
assert_eq!(quota.seats, 0);
assert!(quota.members.is_empty());
}
#[test]
fn a_pooled_team_quota_decodes_its_roster() {
let quota: RemoteHoursQuota = serde_json::from_str(
r#"{"granted_hours":600,"remaining_hours":0,"used_hours":612.75,"scope":"team",
"team_id":"t1","seats":3,"per_seat_hours":200,
"breakdown":{"interactive_hours":100.5,"bot_hours":512.25},
"members":[{"user_id":"u1","email":"a@example.com","role":"owner",
"used_hours":400,"interactive_hours":90,"bot_hours":310}]}"#,
)
.expect("the pooled quota payload must deserialize");
// used_hours is deliberately unclamped while remaining_hours is: an
// over-spent team must be able to see how far over it went.
assert_eq!(quota.used_hours, 612.75);
assert_eq!(quota.remaining_hours, 0.0);
assert_eq!(quota.seats, 3);
assert_eq!(quota.members.len(), 1);
assert_eq!(
quota.breakdown.map(|b| b.bot_hours),
Some(512.25),
"the bot/interactive split is reporting only, but it must survive the wire"
);
}
#[test]
fn a_conflict_response_carries_the_teammate_the_ui_has_to_name() {
let saved: CookieBotScheduleSaved = serde_json::from_str(
r#"{"schedule":{"profile_id":"p1","profile_name":"Yu","platform":"macos",
"enabled":true,"run_at_minute":120,"days_mask":127,"timezone":"UTC",
"preset":"deep","max_minutes":90},
"conflicts":[{"user_id":"u2","email":"alex@example.com","run_at_minute":120,
"timezone":"UTC","days_mask":127,"enabled":true}]}"#,
)
.expect("an acknowledged write must still report the conflict");
assert_eq!(saved.conflicts[0].email, "alex@example.com");
assert!(!saved.conflicts[0].overlaps);
}
#[test]
fn errors_render_as_the_envelope_the_frontend_translates() {
let err = CookieBotError(cloud_errors::classify_message(
r#"(403) {"code":"COOKIE_BOT_NOT_ENTITLED"}"#,
SCHEDULE_CODES,
));
assert_eq!(err.code(), "COOKIE_BOT_NOT_ENTITLED");
assert_eq!(err.status(), 403);
assert_eq!(err.to_error_json(), r#"{"code":"COOKIE_BOT_NOT_ENTITLED"}"#);
}
#[test]
fn a_bare_404_means_something_different_on_a_schedule_and_on_a_run() {
// Both routes 404. Sharing one code would tell a user with no enrolment
// that their run id is wrong, and vice versa.
assert_eq!(
cloud_errors::classify_message("(404) Not Found", SCHEDULE_CODES).code,
"COOKIE_BOT_NOT_ENROLLED"
);
assert_eq!(
cloud_errors::classify_message("(404) Not Found", RUN_CODES).code,
"COOKIE_BOT_RUN_NOT_FOUND"
);
}
#[test]
fn query_values_are_encoded_so_they_cannot_smuggle_a_parameter() {
// A keyset cursor is a server-issued opaque string. One containing `&`
// would otherwise inject a second parameter into the request.
let url = with_query(
"https://api.example.com/runs",
&[
("scope".to_string(), "team".to_string()),
(
"before".to_string(),
"2026-08-03T00:00:00Z&limit=100".to_string(),
),
],
);
assert_eq!(
url,
"https://api.example.com/runs?scope=team&before=2026-08-03T00%3A00%3A00Z%26limit%3D100"
);
assert_eq!(
with_query("https://api.example.com/runs", &[]),
"https://api.example.com/runs",
"an empty query must not leave a dangling separator"
);
}
#[test]
fn the_preset_list_carries_ids_not_behaviour() {
// If this type ever gained a site list, a dwell range or a step
// programme, the browsing model would have leaked into the open-source
// client. Ids and a rough duration are all that may cross.
let presets: CookieBotPresetList = serde_json::from_str(
r#"{"presets":[{"id":"balanced","typical_minutes":35,"recommended":true}],
"default_preset":"balanced"}"#,
)
.expect("the preset list must deserialize");
assert_eq!(presets.presets[0].id, "balanced");
assert_eq!(presets.presets[0].typical_minutes, Some(35));
assert_eq!(presets.default_preset.as_deref(), Some("balanced"));
// An older deployment sends neither of these, and the dialog has to render
// against it: no templates simply means the picker offers the user's own
// list, and no limits means the mirrored bounds apply.
assert!(presets.templates.is_empty());
assert!(presets.limits.is_none());
}
#[test]
fn a_template_crosses_the_wire_as_a_count_and_never_as_urls() {
// The pool is server-owned for the same reason a preset's browsing model
// is. If this type ever gained a `sites` field the curation would be
// published, and a published list is one a retailer can filter.
let presets: CookieBotPresetList = serde_json::from_str(
r#"{"presets":[],"default_preset":"balanced",
"templates":[{"id":"low-intent-purchaser","site_count":32,
"name":"Low-Intent Purchaser","description":"Price-sensitive browsing."}],
"limits":{"min_minutes":5,"max_minutes":120,"min_sites":1,"max_sites":40,
"max_site_length":2048,"max_jitter_seconds":3600,"max_slots":14,
"max_template_name_length":80}}"#,
)
.expect("the preset list must carry templates and limits");
assert_eq!(presets.templates[0].id, "low-intent-purchaser");
assert_eq!(presets.templates[0].site_count, 32);
let limits = presets.limits.expect("limits must decode");
assert_eq!(limits.max_slots, Some(14));
assert_eq!(limits.max_template_name_length, Some(80));
assert_eq!(limits.max_sites, Some(40));
}
#[test]
fn a_saved_list_arrives_with_the_prefix_a_schedule_write_needs() {
// The id is what `template_id` takes verbatim. Handing the client a bare
// uuid and expecting it to prepend `user:` is how a saved list gets looked
// up against the built-in catalogue instead — which answers "no sites" and
// silently unschedules the profile.
let envelope: UserTemplateListEnvelope = serde_json::from_str(
r#"{"templates":[{"id":"user:1c9a…","name":"My shops",
"sites":["https://example.com"],"updated_at":"2026-08-05T10:00:00.000Z"}]}"#,
)
.expect("the user template list must deserialize");
let template = &envelope.templates[0];
assert!(template.id.starts_with("user:"));
assert_eq!(template.name, "My shops");
assert_eq!(template.sites.len(), 1);
}
#[test]
fn deleting_a_saved_list_that_is_already_gone_is_not_a_failure() {
// The route never 404s, so a delete retried after a dropped response has to
// read as "nothing left to do" rather than as an error the user must act on.
let deleted: UserTemplateDeleted =
serde_json::from_str(r#"{"deleted":false,"id":"user:gone"}"#)
.expect("a no-op delete must deserialize");
assert!(!deleted.deleted);
}
#[test]
fn a_template_404_is_a_missing_list_and_not_an_unenrolled_profile() {
// Sharing SCHEDULE_CODES here would tell someone renaming a site list that
// their profile is not enrolled, and a name collision that a teammate
// already warms the profile.
assert_eq!(
cloud_errors::classify_message("(404) Not Found", TEMPLATE_CODES).code,
"COOKIE_BOT_TEMPLATE_NOT_FOUND"
);
assert_eq!(
cloud_errors::classify_message("(409) Conflict", TEMPLATE_CODES).code,
"COOKIE_BOT_TEMPLATE_NAME_TAKEN"
);
}
}