mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-22 07:08:53 +02:00
2538 lines
88 KiB
Rust
2538 lines
88 KiB
Rust
use chrono::Utc;
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{Duration, Instant};
|
|
use tokio::sync::Mutex;
|
|
|
|
use crate::browser::ProxySettings;
|
|
use crate::log_streak::Streak;
|
|
use crate::proxy_manager::PROXY_MANAGER;
|
|
use crate::settings_manager::{SettingsManager, StoredMcpRemoteKey};
|
|
use crate::sync;
|
|
|
|
pub const CLOUD_API_URL: &str = "https://api.donutbrowser.com";
|
|
pub const CLOUD_SYNC_URL: &str = "https://sync.donutbrowser.com";
|
|
|
|
/// Default per-hour cap on local automation API / MCP requests, used when the
|
|
/// cloud API has not sent one.
|
|
const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
|
|
|
|
const ACCESS_TOKEN_REFRESH_MARGIN_SECS: i64 = 15 * 60;
|
|
const SYNC_TOKEN_REFRESH_MARGIN_SECS: i64 = 120;
|
|
const DEFAULT_RETRY_AFTER: Duration = Duration::from_secs(60);
|
|
const MAX_RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
|
|
|
|
static ACCESS_TOKEN_REFRESH: Streak = Streak::new(module_path!(), "Access token refresh");
|
|
static SYNC_TOKEN_FETCH: Streak = Streak::new(module_path!(), "Sync token fetch");
|
|
static PROFILE_REFRESH: Streak = Streak::new(module_path!(), "Cloud profile refresh");
|
|
static PROXY_CONFIG_FETCH: Streak = Streak::new(module_path!(), "Cloud proxy config fetch");
|
|
static PROXY_USAGE_FETCH: Streak = Streak::new(module_path!(), "Cloud proxy usage fetch");
|
|
static WAYFERN_TOKEN: Streak = Streak::new(module_path!(), "Wayfern token request");
|
|
|
|
/// What the last cloud proxy sync found, so an unchanged result is not
|
|
/// logged every ten minutes.
|
|
static CLOUD_PROXY_STATE: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
|
|
|
fn cloud_proxy_state_changed(state: String) -> bool {
|
|
let mut last = CLOUD_PROXY_STATE
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if last.as_deref() == Some(state.as_str()) {
|
|
return false;
|
|
}
|
|
*last = Some(state);
|
|
true
|
|
}
|
|
|
|
pub(crate) fn parse_retry_after(value: &str, now: chrono::DateTime<Utc>) -> Option<Duration> {
|
|
let value = value.trim();
|
|
if let Ok(seconds) = value.parse::<u64>() {
|
|
return Some(Duration::from_secs(seconds).min(MAX_RETRY_AFTER));
|
|
}
|
|
let at = chrono::DateTime::parse_from_rfc2822(value).ok()?;
|
|
let seconds = u64::try_from((at.with_timezone(&Utc) - now).num_seconds()).unwrap_or(0);
|
|
Some(Duration::from_secs(seconds).min(MAX_RETRY_AFTER))
|
|
}
|
|
|
|
pub(crate) fn retry_after(headers: &reqwest::header::HeaderMap) -> Duration {
|
|
headers
|
|
.get(reqwest::header::RETRY_AFTER)
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| parse_retry_after(value, Utc::now()))
|
|
.unwrap_or(DEFAULT_RETRY_AFTER)
|
|
}
|
|
|
|
fn is_refused_refresh(error: &str) -> bool {
|
|
error.contains("(401") || error.contains("No refresh token")
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) enum AuthorizedError {
|
|
SignedOut,
|
|
RateLimited(Duration),
|
|
Transport(String),
|
|
}
|
|
|
|
impl AuthorizedError {
|
|
fn from_token_error(error: String) -> Self {
|
|
if error.contains("Not logged in") || is_refused_refresh(&error) {
|
|
Self::SignedOut
|
|
} else {
|
|
Self::Transport(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for AuthorizedError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::SignedOut => write!(f, "not signed in"),
|
|
Self::RateLimited(wait) => write!(f, "rate limited for {}s", wait.as_secs()),
|
|
Self::Transport(error) => write!(f, "{error}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Capability + limit set the account is entitled to, derived from its plan.
|
|
/// Mirrors the entitlement set the cloud API sends. Features are gated on these
|
|
/// flags instead of a single "is paid?" boolean, so a plan like "solo" (cloud
|
|
/// backup + nightly cookie bot, no automation, no fingerprint editing, no
|
|
/// hands-on remote session) is just data here.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Entitlements {
|
|
#[serde(default)]
|
|
pub active: bool,
|
|
#[serde(rename = "browserAutomation", default)]
|
|
pub browser_automation: bool,
|
|
#[serde(rename = "crossOsFingerprints", default)]
|
|
pub cross_os_fingerprints: bool,
|
|
#[serde(rename = "cloudBackup", default)]
|
|
pub cloud_backup: bool,
|
|
#[serde(rename = "teamCollaboration", default)]
|
|
pub team_collaboration: bool,
|
|
/// Overnight profile warming on a leased remote host. Present on the wire
|
|
/// since the cookie-bot release; a field missing here is silently dropped on
|
|
/// the way to the UI, which is why every mirror of this struct has to move
|
|
/// together.
|
|
#[serde(rename = "cookieBot", default)]
|
|
pub cookie_bot: bool,
|
|
/// Whether the plan may open a HANDS-ON remote session. Distinct from
|
|
/// `cookie_bot`: solo funds a nightly bot out of its remote hours but may not
|
|
/// drive a remote browser itself, so anything that offers interactive remote
|
|
/// control must read THIS rather than `remote_browser_hours > 0`.
|
|
#[serde(rename = "remoteInteractive", default)]
|
|
pub remote_interactive: bool,
|
|
/// Whether the plan may drive THIS desktop from Donut cloud: the remote MCP
|
|
/// endpoint and the API in front of it.
|
|
///
|
|
/// Read only by the UI. The bridge itself never gates on this: the relay
|
|
/// decides who may send work, and a cached entitlement that is a refresh
|
|
/// cycle out of date must not be what refuses a customer their own machine.
|
|
#[serde(rename = "remoteControl", default)]
|
|
pub remote_control: bool,
|
|
#[serde(rename = "profileLimit", default)]
|
|
pub profile_limit: i64,
|
|
#[serde(rename = "requestsPerHour", default)]
|
|
pub requests_per_hour: i64,
|
|
/// Per-seat monthly remote-session allowance. Reporting only — a team pools
|
|
/// it across seats, so the spendable figure comes from the quota route.
|
|
#[serde(rename = "remoteBrowserHours", default)]
|
|
pub remote_browser_hours: i64,
|
|
}
|
|
|
|
/// Local fallback mirror of the backend plan -> capability matrix, used only when
|
|
/// the server hasn't sent an entitlements object (older cached state / backend).
|
|
fn derive_entitlements(
|
|
plan: &str,
|
|
plan_period: Option<&str>,
|
|
subscription_status: &str,
|
|
profile_limit: i64,
|
|
) -> Entitlements {
|
|
let active =
|
|
plan != "free" && (subscription_status == "active" || plan_period == Some("lifetime"));
|
|
if !active {
|
|
return Entitlements {
|
|
active: false,
|
|
browser_automation: false,
|
|
cross_os_fingerprints: false,
|
|
cloud_backup: false,
|
|
team_collaboration: false,
|
|
cookie_bot: false,
|
|
remote_interactive: false,
|
|
remote_control: false,
|
|
profile_limit: 0,
|
|
requests_per_hour: 0,
|
|
remote_browser_hours: 0,
|
|
};
|
|
}
|
|
// Tuple order: (browser_automation, cross_os_fingerprints, cloud_backup,
|
|
// team_collaboration, cookie_bot, remote_interactive, remote_control).
|
|
//
|
|
// pro and any unrecognized paid plan -> pro-level (never team). Solo is the
|
|
// one row where cookie_bot and browser_automation disagree, which is why
|
|
// cookie_bot can no longer be derived from browser_automation below.
|
|
//
|
|
// remote_control belongs to solo (MCP without browser automation), pro,
|
|
// team and enterprise, and is withheld from the unrecognized row rather
|
|
// than granted with the rest. Everything else here defaults
|
|
// generous so a comped account is never locked out of what it is paying for;
|
|
// an internet-facing hook into this machine is the one capability where
|
|
// guessing "probably yes" is not the safe direction to guess in.
|
|
let (
|
|
browser_automation,
|
|
cross_os_fingerprints,
|
|
cloud_backup,
|
|
team_collaboration,
|
|
cookie_bot,
|
|
remote_interactive,
|
|
remote_control,
|
|
) = match plan {
|
|
"solo" => (false, false, true, false, true, false, true),
|
|
"enterprise" => (true, true, true, true, true, true, true),
|
|
"team" => (true, true, true, true, true, true, true),
|
|
"pro" => (true, true, true, false, true, true, true),
|
|
_ => (true, true, true, false, true, true, false),
|
|
};
|
|
Entitlements {
|
|
active,
|
|
browser_automation,
|
|
cross_os_fingerprints,
|
|
cloud_backup,
|
|
team_collaboration,
|
|
cookie_bot,
|
|
remote_interactive,
|
|
remote_control,
|
|
profile_limit,
|
|
requests_per_hour: if browser_automation {
|
|
DEFAULT_REQUESTS_PER_HOUR
|
|
} else {
|
|
0
|
|
},
|
|
// Deliberately 0 in the fallback: the allowance is the server's to state and
|
|
// guessing it here would show a customer hours they may not have.
|
|
remote_browser_hours: 0,
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CloudUser {
|
|
pub id: String,
|
|
pub email: String,
|
|
pub plan: String,
|
|
#[serde(rename = "planPeriod")]
|
|
pub plan_period: Option<String>,
|
|
#[serde(rename = "subscriptionStatus")]
|
|
pub subscription_status: String,
|
|
#[serde(rename = "profileLimit")]
|
|
pub profile_limit: i64,
|
|
#[serde(rename = "cloudProfilesUsed")]
|
|
pub cloud_profiles_used: i64,
|
|
#[serde(rename = "proxyBandwidthLimitMb")]
|
|
pub proxy_bandwidth_limit_mb: i64,
|
|
#[serde(rename = "proxyBandwidthUsedMb")]
|
|
pub proxy_bandwidth_used_mb: i64,
|
|
#[serde(rename = "proxyBandwidthExtraMb", default)]
|
|
pub proxy_bandwidth_extra_mb: i64,
|
|
#[serde(rename = "teamId", default)]
|
|
pub team_id: Option<String>,
|
|
#[serde(rename = "teamName", default)]
|
|
pub team_name: Option<String>,
|
|
#[serde(rename = "teamRole", default)]
|
|
pub team_role: Option<String>,
|
|
/// The plan this account is served under. A team member's `plan` stays
|
|
/// `"free"` (the owner pays) while the backend resolves this to the owner's
|
|
/// tier. `default` keeps the login response and older backends deserializing;
|
|
/// read it through `effective_plan()`.
|
|
#[serde(rename = "effectivePlan", default)]
|
|
pub effective_plan: Option<String>,
|
|
// This desktop session's position among the user's active devices, oldest
|
|
// first, as the cloud API reports it. Shown in the UI. `default` keeps older
|
|
// login/state payloads (which lack these fields) deserializing cleanly.
|
|
#[serde(rename = "deviceOrdinal", default)]
|
|
pub device_ordinal: Option<i64>,
|
|
#[serde(rename = "deviceCount", default)]
|
|
pub device_count: Option<i64>,
|
|
#[serde(rename = "isPrimaryDevice", default)]
|
|
pub is_primary_device: Option<bool>,
|
|
/// Capability/limit set derived from the plan by the backend. `default` (None)
|
|
/// keeps older login/state payloads deserializing; resolve via `entitlements()`.
|
|
#[serde(default)]
|
|
pub entitlements: Option<Entitlements>,
|
|
}
|
|
|
|
impl CloudUser {
|
|
/// The plan the account is actually served under: `effectivePlan` when the
|
|
/// backend sent one, else the row's own `plan`. Gates that ask "is this a
|
|
/// paid / team account" read this; billing-only surfaces keep `plan`.
|
|
pub fn effective_plan(&self) -> &str {
|
|
self.effective_plan.as_deref().unwrap_or(&self.plan)
|
|
}
|
|
|
|
/// Authoritative entitlements: the server-sent set when present, else derived
|
|
/// locally from the plan fields (keeps older cached state / backends working).
|
|
pub fn entitlements(&self) -> Entitlements {
|
|
if let Some(e) = &self.entitlements {
|
|
// Returned verbatim, INCLUDING the `#[serde(default)]` false that a
|
|
// backend older than this release leaves on `cookie_bot` /
|
|
// `remote_interactive`. Repairing it here is impossible anyway — serde's
|
|
// default erases the difference between "sent false" and "not sent" — and
|
|
// it is not this layer's job: nothing in Rust gates on either flag, and
|
|
// `getEntitlements()` in `src/lib/entitlements.ts` fills both gaps at the
|
|
// single point every UI consumer already goes through.
|
|
return e.clone();
|
|
}
|
|
derive_entitlements(
|
|
&self.plan,
|
|
self.plan_period.as_deref(),
|
|
&self.subscription_status,
|
|
self.profile_limit,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CloudAuthState {
|
|
pub user: CloudUser,
|
|
pub logged_in_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct DeviceCodeChallengeResponse {
|
|
#[serde(rename = "challengeId")]
|
|
challenge_id: String,
|
|
prefix: String,
|
|
difficulty: u32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct DeviceCodeExchangeResponse {
|
|
#[serde(rename = "accessToken")]
|
|
access_token: String,
|
|
#[serde(rename = "refreshToken")]
|
|
refresh_token: String,
|
|
user: CloudUser,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RefreshTokenResponse {
|
|
#[serde(rename = "accessToken")]
|
|
access_token: String,
|
|
#[serde(rename = "refreshToken")]
|
|
refresh_token: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct SyncTokenResponse {
|
|
#[serde(rename = "syncToken")]
|
|
sync_token: String,
|
|
}
|
|
|
|
/// Prefix of a remote MCP credential. Only a key carrying this prefix may ever
|
|
/// be stored here; a credential of any other kind is rejected.
|
|
pub const MCP_KEY_PREFIX: &str = "dmk_";
|
|
|
|
/// A freshly minted remote MCP credential. The plaintext `key` is shown by the
|
|
/// server exactly once, in this response.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct McpKeyGrant {
|
|
pub id: String,
|
|
pub token_prefix: String,
|
|
pub key: String,
|
|
}
|
|
|
|
/// What a key endpoint answered once the request itself went through.
|
|
///
|
|
/// `api_call_with_retry` reads a 401 out of the ERROR string and refreshes the
|
|
/// session once, so only a 401 (and a transport failure) may be an `Err`. Every
|
|
/// other refusal travels as a value, so it reaches the code mapping below
|
|
/// instead of being mistaken for a dead session.
|
|
enum McpKeyAnswer<T> {
|
|
Granted(T),
|
|
Refused { status: u16, body: String },
|
|
}
|
|
|
|
/// The `{"code"}` the UI shows for a refused credential mint.
|
|
///
|
|
/// A 409 on `POST /api/mcp/keys` is the per-account cap, whether or not the
|
|
/// server bothered to name it; everything else is "not right now", carrying
|
|
/// the server's message as the detail so the log says why.
|
|
fn mcp_key_refusal(status: u16, body: &str) -> String {
|
|
if status == 409 {
|
|
return crate::backend_error("MCP_REMOTE_KEY_LIMIT");
|
|
}
|
|
let message = serde_json::from_str::<serde_json::Value>(body)
|
|
.ok()
|
|
.and_then(|v| {
|
|
v.get("message")
|
|
.and_then(|m| m.as_str())
|
|
.map(std::string::ToString::to_string)
|
|
})
|
|
.unwrap_or_else(|| body.to_string());
|
|
crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", format!("{status}: {message}"))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct WayfernTokenResponse {
|
|
token: String,
|
|
#[serde(rename = "expiresIn")]
|
|
#[allow(dead_code)]
|
|
expires_in: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocationItem {
|
|
pub code: String,
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[allow(dead_code)]
|
|
struct CloudProxyConfigResponse {
|
|
host: String,
|
|
port: u16,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
protocol: String,
|
|
#[serde(rename = "bandwidthLimitMb")]
|
|
bandwidth_limit_mb: i64,
|
|
#[serde(rename = "bandwidthUsedMb")]
|
|
bandwidth_used_mb: i64,
|
|
}
|
|
|
|
pub struct CloudAuthManager {
|
|
client: Client,
|
|
state: Mutex<Option<CloudAuthState>>,
|
|
refresh_lock: tokio::sync::Mutex<()>,
|
|
refresh_deferred_until: std::sync::Mutex<Option<Instant>>,
|
|
wayfern_token: Mutex<Option<String>>,
|
|
/// The server's reason for refusing this desktop a Wayfern token under a
|
|
/// device rule, until a token is acquired again. The engine reports a
|
|
/// missing token as a plan problem; this is what lets MCP say the real one.
|
|
wayfern_device_refusal: std::sync::Mutex<Option<String>>,
|
|
}
|
|
|
|
pub static CLOUD_AUTH: std::sync::LazyLock<CloudAuthManager> =
|
|
std::sync::LazyLock::new(CloudAuthManager::new);
|
|
|
|
impl CloudAuthManager {
|
|
fn new() -> Self {
|
|
let state = Self::load_auth_state_from_disk();
|
|
// Bound every cloud API call so no single slow / hung request can stall
|
|
// the startup chain (sync-token → proxy-config → wayfern-token), which
|
|
// otherwise gates Wayfern launch behind whichever endpoint is slowest.
|
|
let client = Client::builder()
|
|
.timeout(std::time::Duration::from_secs(15))
|
|
.connect_timeout(std::time::Duration::from_secs(5))
|
|
.build()
|
|
.unwrap_or_else(|_| Client::new());
|
|
Self {
|
|
client,
|
|
state: Mutex::new(state),
|
|
refresh_lock: tokio::sync::Mutex::new(()),
|
|
refresh_deferred_until: std::sync::Mutex::new(None),
|
|
wayfern_token: Mutex::new(None),
|
|
wayfern_device_refusal: std::sync::Mutex::new(None),
|
|
}
|
|
}
|
|
|
|
// --- Settings directory (reuse SettingsManager path) ---
|
|
|
|
fn get_settings_dir() -> PathBuf {
|
|
SettingsManager::instance().get_settings_dir()
|
|
}
|
|
|
|
// --- Encrypted file storage (shared with settings_manager.rs via crate::vault) ---
|
|
|
|
fn magic(header: &[u8; 5]) -> [u8; 6] {
|
|
let mut magic = [0u8; 6];
|
|
magic[..5].copy_from_slice(header);
|
|
magic[5] = 2;
|
|
magic
|
|
}
|
|
|
|
fn encrypt_and_store(file_path: &Path, header: &[u8; 5], data: &str) -> Result<(), String> {
|
|
crate::vault::seal(file_path, &Self::magic(header), data)
|
|
}
|
|
|
|
fn decrypt_from_file(file_path: &Path, header: &[u8; 5]) -> Result<Option<String>, String> {
|
|
crate::vault::open(file_path, &Self::magic(header))
|
|
}
|
|
|
|
// --- Token storage methods ---
|
|
|
|
fn store_access_token(token: &str) -> Result<(), String> {
|
|
let path = Self::get_settings_dir().join("cloud_access_token.dat");
|
|
Self::encrypt_and_store(&path, b"DBCAT", token)
|
|
}
|
|
|
|
pub(crate) fn load_access_token() -> Result<Option<String>, String> {
|
|
let path = Self::get_settings_dir().join("cloud_access_token.dat");
|
|
Self::decrypt_from_file(&path, b"DBCAT")
|
|
}
|
|
|
|
fn store_refresh_token(token: &str) -> Result<(), String> {
|
|
let path = Self::get_settings_dir().join("cloud_refresh_token.dat");
|
|
Self::encrypt_and_store(&path, b"DBCRT", token)
|
|
}
|
|
|
|
fn load_refresh_token() -> Result<Option<String>, String> {
|
|
let path = Self::get_settings_dir().join("cloud_refresh_token.dat");
|
|
Self::decrypt_from_file(&path, b"DBCRT")
|
|
}
|
|
|
|
fn store_cloud_sync_token(token: &str) -> Result<(), String> {
|
|
let path = Self::get_settings_dir().join("cloud_sync_token.dat");
|
|
Self::encrypt_and_store(&path, b"DBCST", token)
|
|
}
|
|
|
|
fn load_cloud_sync_token() -> Result<Option<String>, String> {
|
|
let path = Self::get_settings_dir().join("cloud_sync_token.dat");
|
|
Self::decrypt_from_file(&path, b"DBCST")
|
|
}
|
|
|
|
pub(crate) fn discard_cloud_sync_token(refused: &str) {
|
|
if let Ok(Some(cached)) = Self::load_cloud_sync_token() {
|
|
if cached == refused {
|
|
let _ = fs::remove_file(Self::get_settings_dir().join("cloud_sync_token.dat"));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn store_auth_state(state: &CloudAuthState) -> Result<(), String> {
|
|
let path = Self::get_settings_dir().join("cloud_auth_state.json");
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
|
|
}
|
|
let json =
|
|
serde_json::to_string_pretty(state).map_err(|e| format!("Failed to serialize: {e}"))?;
|
|
fs::write(&path, json).map_err(|e| format!("Failed to write auth state: {e}"))?;
|
|
crate::app_dirs::restrict_to_owner(&path);
|
|
Ok(())
|
|
}
|
|
|
|
fn load_auth_state_from_disk() -> Option<CloudAuthState> {
|
|
let path = Self::get_settings_dir().join("cloud_auth_state.json");
|
|
if !path.exists() {
|
|
return None;
|
|
}
|
|
let content = fs::read_to_string(path).ok()?;
|
|
serde_json::from_str(&content).ok()
|
|
}
|
|
|
|
fn delete_all_cloud_files() {
|
|
let dir = Self::get_settings_dir();
|
|
let files = [
|
|
"cloud_access_token.dat",
|
|
"cloud_refresh_token.dat",
|
|
"cloud_sync_token.dat",
|
|
"cloud_auth_state.json",
|
|
];
|
|
for f in &files {
|
|
let path = dir.join(f);
|
|
if path.exists() {
|
|
let _ = fs::remove_file(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- JWT expiry check ---
|
|
|
|
fn jwt_expiry(token: &str) -> Option<i64> {
|
|
let parts: Vec<&str> = token.split('.').collect();
|
|
if parts.len() != 3 {
|
|
return None;
|
|
}
|
|
|
|
use base64::{engine::general_purpose, Engine as _};
|
|
let payload = match general_purpose::URL_SAFE_NO_PAD.decode(parts[1]) {
|
|
Ok(bytes) => bytes,
|
|
Err(_) => {
|
|
// Try standard base64 with padding
|
|
general_purpose::STANDARD.decode(parts[1]).ok()?
|
|
}
|
|
};
|
|
|
|
let json: serde_json::Value = serde_json::from_slice(&payload).ok()?;
|
|
json.get("exp").and_then(|v| v.as_i64())
|
|
}
|
|
|
|
fn jwt_expires_within(token: &str, margin_secs: i64, now: i64) -> bool {
|
|
Self::jwt_expiry(token).is_none_or(|exp| exp - now < margin_secs)
|
|
}
|
|
|
|
fn is_jwt_expiring_soon(token: &str) -> bool {
|
|
Self::jwt_expires_within(
|
|
token,
|
|
SYNC_TOKEN_REFRESH_MARGIN_SECS,
|
|
Utc::now().timestamp(),
|
|
)
|
|
}
|
|
|
|
fn access_token_needs_refresh(token: &str) -> bool {
|
|
Self::jwt_expires_within(
|
|
token,
|
|
ACCESS_TOKEN_REFRESH_MARGIN_SECS,
|
|
Utc::now().timestamp(),
|
|
)
|
|
}
|
|
|
|
// --- API methods ---
|
|
|
|
pub async fn exchange_device_code(&self, code: &str) -> Result<CloudAuthState, String> {
|
|
let challenge_url = format!("{CLOUD_API_URL}/api/auth/device-code/challenge");
|
|
let challenge_response = self
|
|
.client
|
|
.post(&challenge_url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch challenge: {e}"))?;
|
|
|
|
if !challenge_response.status().is_success() {
|
|
let status = challenge_response.status();
|
|
let body = challenge_response.text().await.unwrap_or_default();
|
|
return Err(format!("Challenge request failed ({status}): {body}"));
|
|
}
|
|
|
|
let challenge: DeviceCodeChallengeResponse = challenge_response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse challenge: {e}"))?;
|
|
|
|
let nonce = solve_pow(&challenge.prefix, challenge.difficulty)
|
|
.ok_or_else(|| "Failed to solve proof-of-work".to_string())?;
|
|
|
|
let exchange_url = format!("{CLOUD_API_URL}/api/auth/device-code/exchange");
|
|
let response = self
|
|
.client
|
|
.post(&exchange_url)
|
|
.json(&serde_json::json!({
|
|
"code": code,
|
|
"challengeId": challenge.challenge_id,
|
|
"nonce": nonce,
|
|
}))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to verify code: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
// The cloud API returns { message, code, … } for 4xx. Surface the
|
|
// human-readable message rather than the raw JSON so the sign-in screen
|
|
// is clear.
|
|
let message = serde_json::from_str::<serde_json::Value>(&body)
|
|
.ok()
|
|
.and_then(|v| {
|
|
v.get("message")
|
|
.and_then(|m| m.as_str())
|
|
.map(std::string::ToString::to_string)
|
|
})
|
|
.unwrap_or_else(|| format!("Login failed ({status})"));
|
|
return Err(message);
|
|
}
|
|
|
|
let result: DeviceCodeExchangeResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
|
|
|
// Store tokens
|
|
Self::store_access_token(&result.access_token)?;
|
|
Self::store_refresh_token(&result.refresh_token)?;
|
|
|
|
// Verify tokens survived the encrypt/decrypt round-trip
|
|
match Self::load_access_token() {
|
|
Ok(Some(loaded)) if loaded == result.access_token => {}
|
|
Ok(Some(loaded)) => {
|
|
log::error!(
|
|
"Access token corrupted by store and load stored_len={} loaded_len={}",
|
|
result.access_token.len(),
|
|
loaded.len()
|
|
);
|
|
}
|
|
Ok(None) => {
|
|
log::error!("Access token missing right after store");
|
|
}
|
|
Err(e) => {
|
|
log::error!("Access token not readable after store err=\"{e}\"");
|
|
}
|
|
}
|
|
|
|
// Build and persist auth state
|
|
let auth_state = CloudAuthState {
|
|
user: result.user,
|
|
logged_in_at: Utc::now().to_rfc3339(),
|
|
};
|
|
Self::store_auth_state(&auth_state)?;
|
|
|
|
log::info!(
|
|
"Signed in plan={} subscription_status={} proxy_bandwidth_limit_mb={}",
|
|
auth_state.user.plan,
|
|
auth_state.user.subscription_status,
|
|
auth_state.user.proxy_bandwidth_limit_mb
|
|
);
|
|
|
|
// Update in-memory state
|
|
let mut state = self.state.lock().await;
|
|
*state = Some(auth_state.clone());
|
|
|
|
Ok(auth_state)
|
|
}
|
|
|
|
pub async fn refresh_access_token(&self) -> Result<(), String> {
|
|
let _guard = self.refresh_lock.lock().await;
|
|
self.refresh_while_locked().await
|
|
}
|
|
|
|
async fn refresh_while_locked(&self) -> Result<(), String> {
|
|
if let Some(wait) = self.refresh_deferral() {
|
|
return Err(format!(
|
|
"Token refresh deferred for {}s (429 Too Many Requests)",
|
|
wait.as_secs()
|
|
));
|
|
}
|
|
let refresh_token =
|
|
Self::load_refresh_token()?.ok_or_else(|| "No refresh token stored".to_string())?;
|
|
|
|
let url = format!("{CLOUD_API_URL}/api/auth/token/refresh");
|
|
let response = self
|
|
.client
|
|
.post(&url)
|
|
.json(&serde_json::json!({ "refreshToken": refresh_token }))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to refresh token: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
log::debug!("Access token refresh refused status={status}");
|
|
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
|
self.defer_refresh(retry_after(response.headers()));
|
|
} else if status == reqwest::StatusCode::UNAUTHORIZED {
|
|
Self::forget_refresh_token();
|
|
tauri::async_runtime::spawn(async {
|
|
CLOUD_AUTH.invalidate_session().await;
|
|
});
|
|
}
|
|
return Err(format!("Token refresh failed ({status})"));
|
|
}
|
|
|
|
let result: RefreshTokenResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse response: {e}"))?;
|
|
|
|
Self::store_access_token(&result.access_token)?;
|
|
Self::store_refresh_token(&result.refresh_token)?;
|
|
|
|
log::debug!("Access token refreshed");
|
|
Ok(())
|
|
}
|
|
|
|
fn refresh_deferral(&self) -> Option<Duration> {
|
|
let until = (*self
|
|
.refresh_deferred_until
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner))?;
|
|
until.checked_duration_since(Instant::now())
|
|
}
|
|
|
|
fn defer_refresh(&self, wait: Duration) {
|
|
*self
|
|
.refresh_deferred_until
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Instant::now() + wait);
|
|
}
|
|
|
|
fn forget_refresh_token() {
|
|
let path = Self::get_settings_dir().join("cloud_refresh_token.dat");
|
|
if path.exists() {
|
|
let _ = fs::remove_file(path);
|
|
}
|
|
}
|
|
|
|
async fn ensure_fresh_access_token(&self) -> Result<String, String> {
|
|
let token = Self::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
|
if !Self::access_token_needs_refresh(&token) {
|
|
return Ok(token);
|
|
}
|
|
let _guard = self.refresh_lock.lock().await;
|
|
let current = Self::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
|
if current != token && !Self::access_token_needs_refresh(¤t) {
|
|
return Ok(current);
|
|
}
|
|
self.refresh_while_locked().await?;
|
|
Self::load_access_token()?.ok_or_else(|| "Not logged in after refresh".to_string())
|
|
}
|
|
|
|
async fn usable_access_token(&self) -> Result<String, String> {
|
|
let token = Self::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
|
if !Self::access_token_needs_refresh(&token) {
|
|
return Ok(token);
|
|
}
|
|
match self.ensure_fresh_access_token().await {
|
|
Ok(fresh) => Ok(fresh),
|
|
Err(e) if e.contains("(401") => Err(e),
|
|
Err(e) => {
|
|
log::debug!("Early access token refresh not done err=\"{e}\"");
|
|
Ok(token)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn refresh_after_rejection(&self, rejected: &str) -> Result<String, String> {
|
|
let _guard = self.refresh_lock.lock().await;
|
|
if let Some(current) = Self::load_access_token()? {
|
|
if current != rejected && !current.is_empty() {
|
|
log::debug!("Access token already refreshed by another caller");
|
|
return Ok(current);
|
|
}
|
|
}
|
|
self.refresh_while_locked().await?;
|
|
Self::load_access_token()?.ok_or_else(|| "Not logged in after refresh".to_string())
|
|
}
|
|
|
|
pub(crate) async fn authorized_request<F>(
|
|
&self,
|
|
build: F,
|
|
) -> Result<reqwest::Response, AuthorizedError>
|
|
where
|
|
F: Fn(&Client, &str) -> reqwest::RequestBuilder + Send,
|
|
{
|
|
let token = self
|
|
.usable_access_token()
|
|
.await
|
|
.map_err(AuthorizedError::from_token_error)?;
|
|
let mut response = build(&self.client, &token)
|
|
.send()
|
|
.await
|
|
.map_err(|e| AuthorizedError::Transport(e.to_string()))?;
|
|
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
|
let token = self
|
|
.refresh_after_rejection(&token)
|
|
.await
|
|
.map_err(AuthorizedError::from_token_error)?;
|
|
response = build(&self.client, &token)
|
|
.send()
|
|
.await
|
|
.map_err(|e| AuthorizedError::Transport(e.to_string()))?;
|
|
}
|
|
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
|
return Err(AuthorizedError::RateLimited(retry_after(
|
|
response.headers(),
|
|
)));
|
|
}
|
|
Ok(response)
|
|
}
|
|
|
|
/// Invalidate the session: clear all auth state and notify the frontend.
|
|
/// Only call this when the session is definitively dead (explicit logout
|
|
/// or repeated background refresh failures).
|
|
pub async fn invalidate_session(&self) {
|
|
log::warn!("Session invalidated, auth state cleared");
|
|
PROXY_MANAGER.remove_cloud_proxy();
|
|
// Same reason `logout` does it: left running, the bridge reconnects with a
|
|
// credential that no longer exists, fails, and backs off into an
|
|
// "unauthorized" the account page shows to somebody whose session simply
|
|
// expired. This is the AUTOMATIC twin of logout, reached when the
|
|
// background refresh loop gives up, and it was the one teardown path that
|
|
// did not close the bridge.
|
|
crate::mcp_remote::stop(None);
|
|
// The stored `dmk_` key belongs to the account that just left this
|
|
// machine, and the agent configs would keep presenting it. There is no
|
|
// session left to revoke it with, so only the local copy goes; the key
|
|
// itself is retired from the account page.
|
|
Self::forget_mcp_key_locally("after the session expired");
|
|
self.clear_auth().await;
|
|
crate::team_lock::PROFILE_LOCK.disconnect().await;
|
|
sync::stop_subscription();
|
|
crate::remote_session::stop_session_events();
|
|
crate::agent_console::reset();
|
|
let _ = crate::events::emit_empty("cloud-auth-expired");
|
|
}
|
|
|
|
/// Ask the server whether this account may drive a desktop remotely.
|
|
///
|
|
/// The AUTHORITATIVE answer, and the only one that is correct for a team
|
|
/// member: a team member's locally cached plan does not describe what their
|
|
/// seat is entitled to, so the server is asked rather than guessed at.
|
|
///
|
|
/// It cannot be inferred from the socket either. A connected bridge only
|
|
/// proves the plan is active, and nothing about this capability.
|
|
pub async fn fetch_remote_control_entitlement(&self) -> Result<bool, String> {
|
|
self
|
|
.api_call_with_retry(|access_token| {
|
|
let url = format!("{CLOUD_API_URL}/api/mcp/status");
|
|
let client = self.client.clone();
|
|
async move {
|
|
let response = client
|
|
.get(&url)
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to read remote-control status: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
return Err(format!("Remote-control status failed ({status})"));
|
|
}
|
|
|
|
let body: serde_json::Value = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse remote-control status: {e}"))?;
|
|
|
|
Ok(
|
|
body
|
|
.get("entitled")
|
|
.and_then(serde_json::Value::as_bool)
|
|
.unwrap_or(false),
|
|
)
|
|
}
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Mint a remote MCP credential for this account.
|
|
///
|
|
/// The server caps how many live keys one account may hold and answers a mint
|
|
/// past that cap with a 409; the rotation command handles that by retiring the
|
|
/// key it is replacing first. The plaintext in the answer is the only copy
|
|
/// there will ever be.
|
|
pub async fn create_mcp_key(&self, label: &str) -> Result<McpKeyGrant, String> {
|
|
let answer = self
|
|
.api_call_with_retry(|access_token| {
|
|
let url = format!("{CLOUD_API_URL}/api/mcp/keys");
|
|
let client = self.client.clone();
|
|
let label = label.to_string();
|
|
async move {
|
|
let response = client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.json(&serde_json::json!({ "label": label }))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to request an MCP credential: {e}"))?;
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
if status.as_u16() == 401 {
|
|
// Worded so `api_call_with_retry` recognises it and refreshes once.
|
|
return Err(format!(
|
|
"MCP credential request failed (401 Unauthorized): {body}"
|
|
));
|
|
}
|
|
if !status.is_success() {
|
|
return Ok(McpKeyAnswer::Refused {
|
|
status: status.as_u16(),
|
|
body,
|
|
});
|
|
}
|
|
serde_json::from_str::<McpKeyGrant>(&body)
|
|
.map(McpKeyAnswer::Granted)
|
|
.map_err(|e| format!("Failed to parse the MCP credential response: {e}"))
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", e))?;
|
|
|
|
match answer {
|
|
McpKeyAnswer::Granted(grant) if grant.key.starts_with(MCP_KEY_PREFIX) => Ok(grant),
|
|
// A credential of any other kind must never be stored as if it were ours,
|
|
// however well it would authenticate.
|
|
McpKeyAnswer::Granted(_) => Err(crate::backend_error_with_detail(
|
|
"MCP_REMOTE_KEY_UNAVAILABLE",
|
|
"the server issued a credential of an unexpected shape",
|
|
)),
|
|
McpKeyAnswer::Refused { status, body } => Err(mcp_key_refusal(status, &body)),
|
|
}
|
|
}
|
|
|
|
/// Revoke a remote MCP credential by id. A key the server no longer knows
|
|
/// (404) counts as revoked: that is the state the caller wanted.
|
|
pub async fn revoke_mcp_key(&self, key_id: &str) -> Result<(), String> {
|
|
let answer = self
|
|
.api_call_with_retry(|access_token| {
|
|
let url = format!(
|
|
"{CLOUD_API_URL}/api/mcp/keys/{}",
|
|
urlencoding::encode(key_id)
|
|
);
|
|
let client = self.client.clone();
|
|
async move {
|
|
let response = client
|
|
.delete(&url)
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to revoke the MCP credential: {e}"))?;
|
|
let status = response.status();
|
|
if status.as_u16() == 401 {
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!(
|
|
"MCP credential revocation failed (401 Unauthorized): {body}"
|
|
));
|
|
}
|
|
if status.is_success() || status.as_u16() == 404 {
|
|
return Ok(McpKeyAnswer::Granted(()));
|
|
}
|
|
Ok(McpKeyAnswer::Refused {
|
|
status: status.as_u16(),
|
|
body: response.text().await.unwrap_or_default(),
|
|
})
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| crate::backend_error_with_detail("MCP_REMOTE_KEY_UNAVAILABLE", e))?;
|
|
|
|
match answer {
|
|
McpKeyAnswer::Granted(()) => Ok(()),
|
|
McpKeyAnswer::Refused { status, body } => Err(crate::backend_error_with_detail(
|
|
"MCP_REMOTE_KEY_UNAVAILABLE",
|
|
format!("{status}: {body}"),
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Retire the stored remote MCP credential on sign-out.
|
|
///
|
|
/// Best effort, and the local copy goes regardless. The revoke needs the
|
|
/// session that is about to be deleted, so this runs before `clear_auth`;
|
|
/// if it fails the key stays live server-side and the account page can
|
|
/// revoke it, but a signed-out desktop must not keep a credential that
|
|
/// belongs to the account that just left it.
|
|
async fn retire_mcp_key_on_logout(&self) {
|
|
let settings = SettingsManager::instance();
|
|
// Stringified at once: the settings error is a `Box<dyn Error>`, which is
|
|
// not `Send`, and the command future this runs in has to be.
|
|
let stored = settings.get_mcp_remote_key().map_err(|e| e.to_string());
|
|
match stored {
|
|
Ok(Some(StoredMcpRemoteKey { id: Some(id), .. })) => {
|
|
if let Err(e) = self.revoke_mcp_key(&id).await {
|
|
log::warn!(
|
|
"Remote MCP key not revoked on logout, revoke it on the account page err=\"{e}\""
|
|
);
|
|
}
|
|
}
|
|
Ok(Some(StoredMcpRemoteKey { id: None, .. })) => {
|
|
log::warn!("Remote MCP key has no stored id, revoke it on the account page");
|
|
}
|
|
Ok(None) => return,
|
|
Err(e) => {
|
|
log::warn!("Remote MCP key not readable on logout err=\"{e}\"");
|
|
}
|
|
}
|
|
Self::forget_mcp_key_locally("on logout");
|
|
}
|
|
|
|
/// Drop the local copy of the remote MCP credential: the storage half of
|
|
/// `retire_mcp_key_on_logout`, shared with the expiry path, which has no
|
|
/// session left to revoke with. Logs rather than fails, because the
|
|
/// credential is leaving with the session either way.
|
|
fn forget_mcp_key_locally(when: &str) {
|
|
if let Err(e) = SettingsManager::instance().remove_mcp_remote_key() {
|
|
log::warn!("Remote MCP key not removed locally when=\"{when}\" err=\"{e}\"");
|
|
}
|
|
}
|
|
|
|
pub async fn fetch_profile(&self) -> Result<CloudUser, String> {
|
|
let user = self
|
|
.api_call_with_retry(|access_token| {
|
|
let url = format!("{CLOUD_API_URL}/api/auth/me");
|
|
let client = self.client.clone();
|
|
async move {
|
|
let response = client
|
|
.get(&url)
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch profile: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Profile fetch failed ({status}): {body}"));
|
|
}
|
|
|
|
response
|
|
.json::<CloudUser>()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse profile: {e}"))
|
|
}
|
|
})
|
|
.await?;
|
|
|
|
// Update cached state
|
|
let mut state = self.state.lock().await;
|
|
if let Some(auth_state) = state.as_mut() {
|
|
auth_state.user = user.clone();
|
|
let _ = Self::store_auth_state(auth_state);
|
|
}
|
|
|
|
Ok(user)
|
|
}
|
|
|
|
pub async fn get_or_refresh_sync_token(&self) -> Result<Option<String>, String> {
|
|
if !self.is_logged_in().await {
|
|
return Ok(None);
|
|
}
|
|
|
|
// Check cached sync token
|
|
if let Ok(Some(token)) = Self::load_cloud_sync_token() {
|
|
if !Self::is_jwt_expiring_soon(&token) {
|
|
return Ok(Some(token));
|
|
}
|
|
}
|
|
|
|
// Fetch new sync token
|
|
let fetched = self
|
|
.api_call_with_retry(|access_token| {
|
|
let url = format!("{CLOUD_API_URL}/api/auth/sync-token");
|
|
let client = self.client.clone();
|
|
async move {
|
|
let response = client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to get sync token: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Sync token request failed ({status}): {body}"));
|
|
}
|
|
|
|
let result: SyncTokenResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse sync token response: {e}"))?;
|
|
|
|
Ok(result.sync_token)
|
|
}
|
|
})
|
|
.await;
|
|
let sync_token = match fetched {
|
|
Ok(token) => {
|
|
SYNC_TOKEN_FETCH.succeeded();
|
|
token
|
|
}
|
|
Err(e) => {
|
|
SYNC_TOKEN_FETCH.failed(&e);
|
|
return Err(e);
|
|
}
|
|
};
|
|
|
|
if let Err(e) = Self::store_cloud_sync_token(&sync_token) {
|
|
log::error!("Sync token store failed err=\"{e}\"");
|
|
return Err(e);
|
|
}
|
|
Ok(Some(sync_token))
|
|
}
|
|
|
|
pub async fn logout(&self) -> Result<(), String> {
|
|
// Clear wayfern token
|
|
self.clear_wayfern_token().await;
|
|
self.set_wayfern_device_refusal(None);
|
|
|
|
crate::team_lock::PROFILE_LOCK
|
|
.release_all_held_within(crate::team_lock::SHUTDOWN_RELEASE_TIMEOUT)
|
|
.await;
|
|
|
|
// Disconnect profile lock manager
|
|
crate::team_lock::PROFILE_LOCK.disconnect().await;
|
|
|
|
// Hang up the remote-control bridge before the credential it authenticated
|
|
// with is deleted. Left running it would reconnect, fail, and back off into
|
|
// an "unauthorized" the account page shows to somebody who has simply
|
|
// signed out, and it would hold the account's bridge slot meanwhile.
|
|
crate::mcp_remote::stop(None);
|
|
crate::agent_console::reset();
|
|
|
|
// Before the session is closed server-side and the tokens are deleted:
|
|
// both of those take away the only thing that can revoke it.
|
|
self.retire_mcp_key_on_logout().await;
|
|
|
|
// Try to call the logout API (best-effort)
|
|
if let Ok(Some(access_token)) = Self::load_access_token() {
|
|
let refresh_token = Self::load_refresh_token().ok().flatten();
|
|
let url = format!("{CLOUD_API_URL}/api/auth/logout");
|
|
let mut body = serde_json::json!({});
|
|
if let Some(rt) = &refresh_token {
|
|
body = serde_json::json!({ "refreshToken": rt });
|
|
}
|
|
let _ = self
|
|
.client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.json(&body)
|
|
.send()
|
|
.await;
|
|
}
|
|
|
|
// Remove cloud proxy on logout
|
|
PROXY_MANAGER.remove_cloud_proxy();
|
|
|
|
self.clear_auth().await;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn is_logged_in(&self) -> bool {
|
|
let state = self.state.lock().await;
|
|
state.is_some()
|
|
}
|
|
|
|
/// Resolve this session's entitlements (server-sent or locally derived).
|
|
pub async fn entitlements(&self) -> Option<Entitlements> {
|
|
let state = self.state.lock().await;
|
|
state.as_ref().map(|auth| auth.user.entitlements())
|
|
}
|
|
|
|
/// Account is in a paid/active state. Used for the "any active plan" gates
|
|
/// (sync token); per-feature access uses the capability helpers.
|
|
pub async fn has_active_paid_subscription(&self) -> bool {
|
|
#[cfg(feature = "e2e")]
|
|
if crate::e2e_automation_enabled()
|
|
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
self.entitlements().await.map(|e| e.active).unwrap_or(false)
|
|
}
|
|
|
|
/// Reads the entitlement directly rather than going through
|
|
/// `can_use_browser_automation`, whose e2e override would send the browser
|
|
/// suite off to the live API for a token it already has as a test value.
|
|
pub async fn is_entitled_to_wayfern_token(&self) -> bool {
|
|
self.entitlements().await.is_some_and(|e| e.active)
|
|
}
|
|
|
|
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
|
|
pub fn has_active_paid_subscription_sync(&self) -> bool {
|
|
match self.state.try_lock() {
|
|
Ok(state) => state
|
|
.as_ref()
|
|
.map(|auth| auth.user.entitlements().active)
|
|
.unwrap_or(false),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
/// Launch/drive profiles programmatically (local API + MCP automation).
|
|
/// Whether this account may run the nightly Cookie Bot.
|
|
///
|
|
/// NOT `can_use_browser_automation`. Solo is exactly the plan where the two
|
|
/// disagree — it pays for a nightly bot and has `browser_automation: false` —
|
|
/// so gating the bot on automation refused a Solo customer the one feature
|
|
/// their plan is sold on, and answered 402 while their scheduled runs kept
|
|
/// working server-side.
|
|
pub async fn can_use_cookie_bot(&self) -> bool {
|
|
self
|
|
.entitlements()
|
|
.await
|
|
.map(|e| e.cookie_bot)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub async fn can_use_browser_automation(&self) -> bool {
|
|
#[cfg(feature = "e2e")]
|
|
if crate::e2e_automation_enabled()
|
|
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
self
|
|
.entitlements()
|
|
.await
|
|
.map(|e| e.browser_automation)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Edit fingerprints / use a non-native OS fingerprint.
|
|
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
|
|
#[cfg(feature = "e2e")]
|
|
if crate::e2e_automation_enabled()
|
|
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
self
|
|
.entitlements()
|
|
.await
|
|
.map(|e| e.cross_os_fingerprints)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Cloud profile sync / backup (async).
|
|
pub async fn can_use_cloud_backup(&self) -> bool {
|
|
self
|
|
.entitlements()
|
|
.await
|
|
.map(|e| e.cloud_backup)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Cloud profile sync / backup (non-async, try_lock; false if unavailable).
|
|
pub fn can_use_cloud_backup_sync(&self) -> bool {
|
|
match self.state.try_lock() {
|
|
Ok(state) => state
|
|
.as_ref()
|
|
.map(|auth| auth.user.entitlements().cloud_backup)
|
|
.unwrap_or(false),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
/// Identity and positive per-hour cap for the shared REST/MCP automation
|
|
/// limiter. No active automation entitlement means no limiter entry; the
|
|
/// capability gates still reject paid operations independently.
|
|
pub async fn automation_rate_limit(&self) -> Option<(String, u64)> {
|
|
#[cfg(feature = "e2e")]
|
|
if crate::e2e_automation_enabled() {
|
|
if let Ok(limit) = std::env::var("DONUT_E2E_REQUESTS_PER_HOUR") {
|
|
if let Ok(limit) = limit.parse::<u64>() {
|
|
if limit > 0 {
|
|
return Some(("e2e-automation".to_string(), limit));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let state = self.get_user().await?;
|
|
let limit = state.user.entitlements().requests_per_hour;
|
|
(limit > 0).then_some((state.user.id, limit as u64))
|
|
}
|
|
|
|
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
|
|
let host_os = crate::profile::types::get_host_os();
|
|
match fingerprint_os {
|
|
None => true,
|
|
Some(os) if os == host_os => true,
|
|
Some(_) => self.can_use_cross_os_fingerprints().await,
|
|
}
|
|
}
|
|
|
|
pub async fn is_on_team_plan(&self) -> bool {
|
|
if let Some(state) = self.get_user().await {
|
|
return state.user.team_id.is_some();
|
|
}
|
|
false
|
|
}
|
|
|
|
pub async fn get_user(&self) -> Option<CloudAuthState> {
|
|
let state = self.state.lock().await;
|
|
state.clone()
|
|
}
|
|
|
|
async fn plan_marker(&self) -> Option<(String, bool, bool)> {
|
|
let state = self.state.lock().await;
|
|
state.as_ref().map(|auth| {
|
|
let entitlements = auth.user.entitlements();
|
|
(
|
|
auth.user.effective_plan().to_string(),
|
|
entitlements.active,
|
|
entitlements.cloud_backup,
|
|
)
|
|
})
|
|
}
|
|
|
|
async fn clear_auth(&self) {
|
|
let mut state = self.state.lock().await;
|
|
*state = None;
|
|
Self::delete_all_cloud_files();
|
|
crate::cookie_bot::forget_enrolments();
|
|
}
|
|
|
|
/// API call with 401 retry: if first attempt gets 401, refresh access token and retry once.
|
|
/// Uses refresh_lock to prevent concurrent token rotations from racing.
|
|
pub async fn api_call_with_retry<F, Fut, T>(&self, make_request: F) -> Result<T, String>
|
|
where
|
|
F: Fn(String) -> Fut + Send,
|
|
Fut: std::future::Future<Output = Result<T, String>> + Send,
|
|
{
|
|
let access_token = self.usable_access_token().await?;
|
|
|
|
match make_request(access_token.clone()).await {
|
|
Ok(result) => Ok(result),
|
|
Err(e) if e.contains("(401") || e.contains("Unauthorized") => {
|
|
log::debug!("Cloud API returned 401, refreshing the access token");
|
|
|
|
// Check if another caller already refreshed while we waited
|
|
let current_token = Self::load_access_token()?.unwrap_or_default();
|
|
if current_token != access_token && !current_token.is_empty() {
|
|
return make_request(current_token).await;
|
|
}
|
|
|
|
let new_token = self.refresh_after_rejection(&access_token).await?;
|
|
make_request(new_token).await
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// Fetch proxy configuration from the cloud backend
|
|
async fn fetch_proxy_config(&self) -> Result<Option<CloudProxyConfigResponse>, String> {
|
|
// Check cached user state for proxy bandwidth (subscription or extra)
|
|
{
|
|
let state = self.state.lock().await;
|
|
match &*state {
|
|
Some(auth)
|
|
if auth.user.proxy_bandwidth_limit_mb > 0 || auth.user.proxy_bandwidth_extra_mb > 0 => {}
|
|
_ => return Ok(None),
|
|
}
|
|
}
|
|
|
|
match self
|
|
.api_call_with_retry(|access_token| {
|
|
let url = format!("{CLOUD_API_URL}/api/proxy/config");
|
|
let client = self.client.clone();
|
|
async move {
|
|
let response = client
|
|
.get(&url)
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch proxy config: {e}"))?;
|
|
|
|
let status = response.status();
|
|
if status == reqwest::StatusCode::FORBIDDEN {
|
|
return Err("__403__".to_string());
|
|
}
|
|
|
|
if !response.status().is_success() {
|
|
return Err(format!("Proxy config fetch failed ({status})"));
|
|
}
|
|
|
|
response
|
|
.json::<CloudProxyConfigResponse>()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse proxy config: {e}"))
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(config) => {
|
|
PROXY_CONFIG_FETCH.succeeded();
|
|
Ok(Some(config))
|
|
}
|
|
Err(e) if e.contains("__403__") => {
|
|
PROXY_CONFIG_FETCH.succeeded();
|
|
Ok(None)
|
|
}
|
|
Err(e) => {
|
|
PROXY_CONFIG_FETCH.failed(e);
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sync the cloud-managed proxy: fetch config and upsert or remove
|
|
pub async fn sync_cloud_proxy(&self) {
|
|
match self.fetch_proxy_config().await {
|
|
Ok(Some(config)) => {
|
|
let state = format!(
|
|
"host={} port={} protocol={}",
|
|
config.host, config.port, config.protocol
|
|
);
|
|
if cloud_proxy_state_changed(state.clone()) {
|
|
log::info!("Cloud proxy configured {state}");
|
|
}
|
|
let settings = ProxySettings {
|
|
proxy_type: config.protocol,
|
|
host: config.host,
|
|
port: config.port,
|
|
username: config.username,
|
|
password: config.password,
|
|
vless_uri: None,
|
|
};
|
|
match PROXY_MANAGER.upsert_cloud_proxy(settings) {
|
|
Ok(_) => {
|
|
// Propagate credential changes to derived location proxies
|
|
PROXY_MANAGER.update_cloud_derived_proxies();
|
|
}
|
|
Err(e) => log::warn!("Cloud proxy not saved err=\"{e}\""),
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
if cloud_proxy_state_changed("none".to_string()) {
|
|
log::info!("Cloud proxy removed: no config available");
|
|
}
|
|
PROXY_MANAGER.remove_cloud_proxy();
|
|
}
|
|
Err(e) => {
|
|
log::error!("Cloud proxy sync failed err=\"{e}\"");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Report the number of sync-enabled profiles to the cloud backend
|
|
pub async fn report_sync_profile_count(&self, count: i64) -> Result<(), String> {
|
|
self
|
|
.api_call_with_retry(|access_token| {
|
|
let url = format!("{CLOUD_API_URL}/api/auth/sync-profile-usage");
|
|
let client = reqwest::Client::new();
|
|
async move {
|
|
let response = client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.json(&serde_json::json!({ "count": count }))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to report profile usage: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Profile usage report failed ({status}): {body}"));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Fetch country list from the cloud backend
|
|
pub async fn fetch_countries(&self) -> Result<Vec<LocationItem>, String> {
|
|
self
|
|
.api_call_with_retry(|access_token| {
|
|
let url = format!("{CLOUD_API_URL}/api/proxy/locations/countries");
|
|
let client = self.client.clone();
|
|
async move {
|
|
let response = client
|
|
.get(&url)
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch countries: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Countries fetch failed ({status}): {body}"));
|
|
}
|
|
|
|
response
|
|
.json::<Vec<LocationItem>>()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse countries: {e}"))
|
|
}
|
|
})
|
|
.await
|
|
}
|
|
|
|
/// Request a wayfern token from the cloud API. Only succeeds for plans that
|
|
/// include browser automation.
|
|
///
|
|
/// Self-gating on purpose: every caller used to repeat the check, and the one
|
|
/// they repeated was the wrong one. A plan without automation is not an error
|
|
/// state here — it clears any stale token and reports success, because there
|
|
/// is nothing to fetch and nothing wrong.
|
|
pub async fn request_wayfern_token(&self) -> Result<(), String> {
|
|
if !self.is_entitled_to_wayfern_token().await {
|
|
// Ok(()) here means callers log nothing, so a session that declined to
|
|
// mint left no trace at all and looked identical to one that succeeded.
|
|
log::info!("Wayfern token not requested: plan not active");
|
|
self.clear_wayfern_token().await;
|
|
return Ok(());
|
|
}
|
|
|
|
let result = self
|
|
.api_call_with_retry(|access_token| {
|
|
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
|
// Bound the request: without a timeout, an unreachable
|
|
// api.donutbrowser.com hangs the background fetch indefinitely,
|
|
// which in turn forces wayfern_manager's launch-time wait to
|
|
// exhaust its full polling budget every time.
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(8))
|
|
.connect_timeout(std::time::Duration::from_secs(4))
|
|
.build()
|
|
.unwrap_or_else(|_| reqwest::Client::new());
|
|
async move {
|
|
let response = client
|
|
.post(&url)
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to request wayfern token: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
// The body carries WHICH rule refused: a device-family conflict or
|
|
// a plan that lacks automation. They need different handling, so
|
|
// keep the text instead of collapsing every failure to a status.
|
|
let body = response.text().await.unwrap_or_default();
|
|
return Err(format!("Wayfern token request failed ({status}): {body}"));
|
|
}
|
|
|
|
let result: WayfernTokenResponse = response
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse wayfern token response: {e}"))?;
|
|
|
|
Ok(result.token)
|
|
}
|
|
})
|
|
.await;
|
|
|
|
let token = match result {
|
|
Ok(token) => token,
|
|
Err(e) => {
|
|
// A 403 rejects the entitlement without invalidating the login session.
|
|
// Clear the browser token and refresh account state before notifying UI.
|
|
if e.contains("(403") || e.contains("Forbidden") {
|
|
WAYFERN_TOKEN.failed(format!("refused: {}", refusal_message(&e)));
|
|
self.clear_wayfern_token().await;
|
|
if let Err(fetch_err) = self.fetch_profile().await {
|
|
PROFILE_REFRESH.failed(fetch_err);
|
|
}
|
|
// Only the device rules produce a restriction the user can lift, and
|
|
// the toast tells them to sign other devices out — so only those may
|
|
// raise it. A plan-level refusal that slipped past the gate above
|
|
// (cached entitlements the re-fetch just corrected) must stay silent:
|
|
// telling a Solo customer they are "temporarily restricted" describes
|
|
// a lockout that does not exist and hides the real answer, which is
|
|
// that their plan does not include browser automation.
|
|
if is_device_restriction(&e) {
|
|
self.set_wayfern_device_refusal(Some(refusal_message(&e)));
|
|
let _ = crate::events::emit_empty("wayfern-paid-blocked");
|
|
}
|
|
} else {
|
|
WAYFERN_TOKEN.failed(&e);
|
|
}
|
|
return Err(e);
|
|
}
|
|
};
|
|
|
|
let mut wt = self.wayfern_token.lock().await;
|
|
*wt = Some(token);
|
|
self.set_wayfern_device_refusal(None);
|
|
WAYFERN_TOKEN.succeeded();
|
|
log::info!("Wayfern token acquired");
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the current wayfern token, if any.
|
|
pub async fn get_wayfern_token(&self) -> Option<String> {
|
|
#[cfg(feature = "e2e")]
|
|
if crate::e2e_automation_enabled() {
|
|
if let Some(token) = std::env::var_os("WAYFERN_TEST_TOKEN")
|
|
.filter(|token| !token.is_empty())
|
|
.and_then(|token| token.into_string().ok())
|
|
{
|
|
return Some(token);
|
|
}
|
|
}
|
|
|
|
let wt = self.wayfern_token.lock().await;
|
|
wt.clone()
|
|
}
|
|
|
|
/// Clear the cached wayfern token.
|
|
pub async fn clear_wayfern_token(&self) {
|
|
let mut wt = self.wayfern_token.lock().await;
|
|
*wt = None;
|
|
}
|
|
|
|
fn set_wayfern_device_refusal(&self, refusal: Option<String>) {
|
|
if let Ok(mut current) = self.wayfern_device_refusal.lock() {
|
|
*current = refusal;
|
|
}
|
|
}
|
|
|
|
pub fn wayfern_device_refusal(&self) -> Option<String> {
|
|
self
|
|
.wayfern_device_refusal
|
|
.lock()
|
|
.ok()
|
|
.and_then(|current| current.clone())
|
|
}
|
|
|
|
/// Background loop that refreshes the sync token periodically
|
|
pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) {
|
|
let mut wayfern_refresh_counter: u32 = 0;
|
|
loop {
|
|
tokio::time::sleep(std::time::Duration::from_secs(600)).await; // 10 minutes
|
|
|
|
if !CLOUD_AUTH.is_logged_in().await {
|
|
continue;
|
|
}
|
|
|
|
wayfern_refresh_counter += 1;
|
|
|
|
// Proactively refresh the access token if it's expired or expiring soon.
|
|
// This runs first so subsequent API calls use a fresh token.
|
|
if let Ok(Some(token)) = Self::load_access_token() {
|
|
if Self::access_token_needs_refresh(&token) {
|
|
match CLOUD_AUTH.ensure_fresh_access_token().await {
|
|
Ok(_) => ACCESS_TOKEN_REFRESH.succeeded(),
|
|
Err(e) => {
|
|
ACCESS_TOKEN_REFRESH.failed(&e);
|
|
// If the refresh token itself was rejected, session is irrecoverable
|
|
if e.contains("(401") || e.contains("Unauthorized") {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Failures are logged inside.
|
|
let _ = CLOUD_AUTH.get_or_refresh_sync_token().await;
|
|
|
|
let plan_before = CLOUD_AUTH.plan_marker().await;
|
|
|
|
// Refresh profile data periodically. A failure here leaves the cached
|
|
// plan stale, which silently gates paid features, so it belongs at warn
|
|
// rather than debug where the shipped log level hides it.
|
|
match CLOUD_AUTH.fetch_profile().await {
|
|
Ok(_) => PROFILE_REFRESH.succeeded(),
|
|
Err(e) => PROFILE_REFRESH.failed(e),
|
|
}
|
|
|
|
if CLOUD_AUTH.plan_marker().await != plan_before {
|
|
sync::resume_subscription(app_handle.clone()).await;
|
|
}
|
|
|
|
// Reconnect profile lock manager if needed
|
|
if let Some(auth_state) = CLOUD_AUTH.get_user().await {
|
|
if auth_state.user.effective_plan() != "free"
|
|
&& !crate::team_lock::PROFILE_LOCK.is_connected().await
|
|
{
|
|
crate::team_lock::PROFILE_LOCK.connect().await;
|
|
}
|
|
}
|
|
|
|
// And the remote-control bridge, for the same reason one tick below it
|
|
// reconnects the profile lock: a setting that says "on" and a bridge that
|
|
// is not running is a disagreement only something periodic can notice.
|
|
// `mcp_remote::start` is idempotent, so a healthy bridge costs a load of
|
|
// the settings file every ten minutes and nothing else.
|
|
ensure_remote_bridge(&app_handle).await;
|
|
|
|
// Sync cloud proxy credentials
|
|
CLOUD_AUTH.sync_cloud_proxy().await;
|
|
|
|
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop).
|
|
// request_wayfern_token owns the entitlement check and clears the cached
|
|
// token when the plan doesn't include automation.
|
|
//
|
|
// Also mint one as soon as the plan starts granting it. `fetch_profile`
|
|
// above picks up an upgrade within ten minutes, but nothing watched that
|
|
// transition, so a session that signed in before upgrading stayed
|
|
// tokenless for up to ten hours while reporting the feature as unlocked.
|
|
let missing_entitled_token = CLOUD_AUTH.is_entitled_to_wayfern_token().await
|
|
&& CLOUD_AUTH.get_wayfern_token().await.is_none();
|
|
if wayfern_refresh_counter >= 60 || missing_entitled_token {
|
|
wayfern_refresh_counter = 0;
|
|
let _ = CLOUD_AUTH.request_wayfern_token().await;
|
|
}
|
|
|
|
let _ = &app_handle; // keep app_handle alive
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether a rejected wayfern-token request was refused by one of the device
|
|
/// rules rather than by the plan's capabilities.
|
|
///
|
|
/// Matches on the server's message because that is the only thing that
|
|
/// distinguishes them: both arrive as a bare 403. Only these two are a state
|
|
/// the user can clear themselves, which is what the toast asks them to do.
|
|
fn is_device_restriction(error: &str) -> bool {
|
|
error.contains("primary device") || error.contains("requires the desktop app")
|
|
}
|
|
|
|
/// The `message` of the JSON body in a refused token request, or the whole
|
|
/// error when the body is not JSON.
|
|
fn refusal_message(error: &str) -> String {
|
|
error
|
|
.find('{')
|
|
.and_then(|start| serde_json::from_str::<serde_json::Value>(&error[start..]).ok())
|
|
.and_then(|body| {
|
|
body
|
|
.get("message")
|
|
.and_then(|m| m.as_str())
|
|
.map(str::to_string)
|
|
})
|
|
.unwrap_or_else(|| error.to_string())
|
|
}
|
|
|
|
fn solve_pow(prefix: &str, difficulty: u32) -> Option<String> {
|
|
if difficulty == 0 || difficulty > 32 {
|
|
return None;
|
|
}
|
|
let prefix_bytes = prefix.as_bytes();
|
|
let mut buf = Vec::with_capacity(prefix_bytes.len() + 24);
|
|
for nonce in 0u64..u64::MAX {
|
|
buf.clear();
|
|
buf.extend_from_slice(prefix_bytes);
|
|
let nonce_str = nonce.to_string();
|
|
buf.extend_from_slice(nonce_str.as_bytes());
|
|
let digest = Sha256::digest(&buf);
|
|
if has_leading_zero_bits(&digest, difficulty) {
|
|
return Some(nonce_str);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn has_leading_zero_bits(digest: &[u8], bits: u32) -> bool {
|
|
let full_bytes = (bits / 8) as usize;
|
|
if digest.len() < full_bytes + 1 {
|
|
return false;
|
|
}
|
|
for &b in &digest[..full_bytes] {
|
|
if b != 0 {
|
|
return false;
|
|
}
|
|
}
|
|
let remainder = bits % 8;
|
|
if remainder == 0 {
|
|
return true;
|
|
}
|
|
let mask = 0xffu8 << (8 - remainder);
|
|
(digest[full_bytes] & mask) == 0
|
|
}
|
|
|
|
// --- Tauri commands ---
|
|
|
|
#[tauri::command]
|
|
pub async fn cloud_exchange_device_code(
|
|
app_handle: tauri::AppHandle,
|
|
code: String,
|
|
) -> Result<CloudAuthState, String> {
|
|
let mut state = CLOUD_AUTH.exchange_device_code(&code).await?;
|
|
|
|
// The login response carries the row's own plan and entitlements only: no
|
|
// team membership and no `effectivePlan`. For an invited member that reads
|
|
// as a free account, and it stayed that way until the ten-minute loop got
|
|
// round to `/api/auth/me`. Resolve the served plan here so the sync token,
|
|
// the wayfern token and the profile lock below all see the seat. Best
|
|
// effort: a failure leaves the login response in place.
|
|
match CLOUD_AUTH.fetch_profile().await {
|
|
Ok(user) => state.user = user,
|
|
Err(e) => log::warn!("Post-login profile refresh failed err=\"{e}\""),
|
|
}
|
|
|
|
let has_subscription = CLOUD_AUTH.has_active_paid_subscription().await;
|
|
log::info!(
|
|
"Post-login plan resolved plan={} effective_plan={} has_active_subscription={}",
|
|
state.user.plan,
|
|
state.user.effective_plan(),
|
|
has_subscription
|
|
);
|
|
|
|
// Pre-fetch sync token so sync can start immediately
|
|
if has_subscription {
|
|
// Failures of both are logged inside.
|
|
if let Ok(None) = CLOUD_AUTH.get_or_refresh_sync_token().await {
|
|
log::warn!("Sync token missing despite an active subscription");
|
|
}
|
|
|
|
// Request wayfern token for paid users
|
|
let _ = CLOUD_AUTH.request_wayfern_token().await;
|
|
}
|
|
|
|
// Sync cloud proxy after login
|
|
CLOUD_AUTH.sync_cloud_proxy().await;
|
|
|
|
// Connect profile lock manager for paid users
|
|
if state.user.effective_plan() != "free" {
|
|
crate::team_lock::PROFILE_LOCK.connect().await;
|
|
}
|
|
|
|
// Reopen the remote-control bridge the user had switched on.
|
|
//
|
|
// Signing out stops the bridge but deliberately does NOT clear the setting:
|
|
// it is a preference, not a session. So without this, "sign out, sign back
|
|
// in" left remote control switched on in Settings and dead in fact until the
|
|
// app was restarted, which is the worst of both: the UI says yes and the
|
|
// account page says no desktop is connected.
|
|
ensure_remote_bridge(&app_handle).await;
|
|
|
|
let _ = crate::events::emit_empty("cloud-auth-changed");
|
|
|
|
let _ = &app_handle;
|
|
state.user.entitlements = Some(state.user.entitlements());
|
|
Ok(state)
|
|
}
|
|
|
|
/// Open the remote-control bridge if the user asked for it and is signed in.
|
|
///
|
|
/// Idempotent, and safe to call from anywhere: `mcp_remote::start` returns
|
|
/// immediately when the bridge is already up.
|
|
///
|
|
/// The signed-in half matters as much as the setting. A bridge opened without a
|
|
/// credential cannot authenticate, so it would spend its life in the terminal
|
|
/// backoff band reporting "not signed in" on the account page: an error
|
|
/// describing nothing the user did.
|
|
pub(crate) async fn ensure_remote_bridge(app_handle: &tauri::AppHandle) {
|
|
if crate::mcp_remote::is_running() {
|
|
return;
|
|
}
|
|
// The same two gates as `start_mcp_remote_bridge`. This helper runs at
|
|
// boot, on sign-in and on the reconnect tick, and it used to check only the
|
|
// sign-in half: a desktop whose terms acceptance had been withdrawn (or
|
|
// never given, with the flag seeded on disk) opened an internet-facing
|
|
// bridge into a browser the user had not agreed to automate.
|
|
if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() {
|
|
return;
|
|
}
|
|
if !CLOUD_AUTH.is_logged_in().await {
|
|
return;
|
|
}
|
|
let enabled = crate::settings_manager::SettingsManager::instance()
|
|
.load_settings()
|
|
.map(|settings| settings.mcp_remote_enabled)
|
|
.unwrap_or(false);
|
|
if enabled {
|
|
log::info!("Remote control bridge opening: enabled and signed in");
|
|
crate::mcp_remote::start(app_handle.clone());
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
|
|
Ok(CLOUD_AUTH.get_user().await.map(|mut state| {
|
|
// Always hand the frontend a resolved entitlements object so it never has to
|
|
// derive capabilities itself (covers older cached state with no entitlements).
|
|
state.user.entitlements = Some(state.user.entitlements());
|
|
state
|
|
}))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
|
|
let mut user = CLOUD_AUTH.fetch_profile().await?;
|
|
user.entitlements = Some(user.entitlements());
|
|
|
|
// Minting the token is what actually unlocks cross-OS fingerprints, and it
|
|
// only happened at login, at startup and once every 10 hours. An account
|
|
// that upgraded after its last sign-in therefore refreshed into the correct
|
|
// entitlements while still holding no token, and "Refresh" did not fix it.
|
|
// Only mint when one is genuinely missing, so this stays a no-op afterwards.
|
|
if CLOUD_AUTH.is_entitled_to_wayfern_token().await
|
|
&& CLOUD_AUTH.get_wayfern_token().await.is_none()
|
|
{
|
|
// Failures are logged inside.
|
|
let _ = CLOUD_AUTH.request_wayfern_token().await;
|
|
}
|
|
|
|
Ok(user)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
|
|
CLOUD_AUTH.logout().await?;
|
|
|
|
// Stop the remote session-events stream. Its credential is now invalid, so
|
|
// the SSE connection would fail its next credential re-check anyway — but
|
|
// until it did, its reconnect loop keeps retrying every 1..60s against a
|
|
// signed-out account. The frontend store stops its own subscription on
|
|
// logout; nothing stopped the Rust stream, so a sign-out left it churning.
|
|
crate::remote_session::stop_session_events();
|
|
|
|
// Always clear the stored sync URL and token on cloud logout. While the
|
|
// user was signed in, the cloud auth flow populated these with the hosted
|
|
// sync server's URL + a server-issued token — leaving them in place would
|
|
// pre-fill the Self-Hosted tab with our production URL and a token the
|
|
// user never typed. The cloud-URL-only check we used to do here missed
|
|
// trailing-slash / scheme variants and any future cloud endpoint moves.
|
|
let manager = crate::settings_manager::SettingsManager::instance();
|
|
let _ = manager.save_sync_server_url(None);
|
|
let _ = manager.remove_sync_token(&app_handle).await;
|
|
|
|
// Remove cloud-managed and cloud-derived proxies
|
|
crate::proxy_manager::PROXY_MANAGER.remove_cloud_proxies();
|
|
|
|
let _ = crate::events::emit_empty("cloud-auth-changed");
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cloud_has_active_subscription() -> Result<bool, String> {
|
|
Ok(CLOUD_AUTH.has_active_paid_subscription().await)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cloud_get_wayfern_token() -> Result<Option<String>, String> {
|
|
Ok(CLOUD_AUTH.get_wayfern_token().await)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cloud_refresh_wayfern_token() -> Result<Option<String>, String> {
|
|
CLOUD_AUTH.request_wayfern_token().await?;
|
|
Ok(CLOUD_AUTH.get_wayfern_token().await)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cloud_get_countries() -> Result<Vec<LocationItem>, String> {
|
|
CLOUD_AUTH.fetch_countries().await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_cloud_location_proxy(
|
|
name: String,
|
|
country: String,
|
|
region: Option<String>,
|
|
city: Option<String>,
|
|
isp: Option<String>,
|
|
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
|
// If no cloud proxy exists yet, attempt to sync it first
|
|
if !PROXY_MANAGER.has_cloud_proxy() {
|
|
CLOUD_AUTH.sync_cloud_proxy().await;
|
|
}
|
|
PROXY_MANAGER.create_cloud_location_proxy(name, country, region, city, isp)
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct CloudProxyUsage {
|
|
pub used_mb: i64,
|
|
pub limit_mb: i64,
|
|
pub remaining_mb: i64,
|
|
pub recurring_limit_mb: i64,
|
|
pub extra_limit_mb: i64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ProxyUsageResponse {
|
|
#[serde(rename = "usedMb")]
|
|
used_mb: i64,
|
|
#[serde(rename = "limitMb")]
|
|
limit_mb: i64,
|
|
#[serde(rename = "remainingMb")]
|
|
remaining_mb: i64,
|
|
// Optional rather than defaulted to 0 so an omitted half of the split stays
|
|
// distinguishable from a backend that genuinely reports zero for it.
|
|
#[serde(rename = "recurringLimitMb")]
|
|
recurring_limit_mb: Option<i64>,
|
|
#[serde(rename = "extraLimitMb")]
|
|
extra_limit_mb: Option<i64>,
|
|
}
|
|
|
|
/// Combine the live usage response with the cached account snapshot, each half
|
|
/// of the recurring/extra split falling back on its own.
|
|
///
|
|
/// Gating both halves on `recurringLimitMb` made an omitted `extraLimitMb`
|
|
/// report 0 against a total that included it, and threw away a live
|
|
/// `extraLimitMb` whenever the recurring half happened to be 0.
|
|
fn merge_proxy_usage(
|
|
usage: &ProxyUsageResponse,
|
|
cached_recurring: i64,
|
|
cached_extra: i64,
|
|
) -> CloudProxyUsage {
|
|
CloudProxyUsage {
|
|
used_mb: usage.used_mb,
|
|
limit_mb: usage.limit_mb,
|
|
remaining_mb: usage.remaining_mb,
|
|
recurring_limit_mb: usage.recurring_limit_mb.unwrap_or(cached_recurring),
|
|
extra_limit_mb: usage.extra_limit_mb.unwrap_or(cached_extra),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String> {
|
|
let (has_proxy, cached_recurring, cached_extra) = {
|
|
let state = CLOUD_AUTH.state.lock().await;
|
|
match &*state {
|
|
Some(auth)
|
|
if auth.user.proxy_bandwidth_limit_mb > 0 || auth.user.proxy_bandwidth_extra_mb > 0 =>
|
|
{
|
|
(
|
|
true,
|
|
auth.user.proxy_bandwidth_limit_mb,
|
|
auth.user.proxy_bandwidth_extra_mb,
|
|
)
|
|
}
|
|
_ => return Ok(None),
|
|
}
|
|
};
|
|
|
|
if !has_proxy {
|
|
return Ok(None);
|
|
}
|
|
|
|
// Fetch live usage from the API
|
|
match CLOUD_AUTH
|
|
.api_call_with_retry(|access_token| {
|
|
let url = format!("{CLOUD_API_URL}/api/proxy/usage");
|
|
let client = reqwest::Client::new();
|
|
async move {
|
|
let response = client
|
|
.get(&url)
|
|
.header("Authorization", format!("Bearer {access_token}"))
|
|
.send()
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch proxy usage: {e}"))?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(format!(
|
|
"Proxy usage API returned status {}",
|
|
response.status()
|
|
));
|
|
}
|
|
|
|
response
|
|
.json::<ProxyUsageResponse>()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse proxy usage: {e}"))
|
|
}
|
|
})
|
|
.await
|
|
{
|
|
Ok(usage) => {
|
|
PROXY_USAGE_FETCH.succeeded();
|
|
Ok(Some(merge_proxy_usage(
|
|
&usage,
|
|
cached_recurring,
|
|
cached_extra,
|
|
)))
|
|
}
|
|
Err(e) => {
|
|
PROXY_USAGE_FETCH.failed(format!("{e}; using cached usage"));
|
|
// Fallback to cached values
|
|
let state = CLOUD_AUTH.state.lock().await;
|
|
match &*state {
|
|
Some(auth) => {
|
|
let used = auth.user.proxy_bandwidth_used_mb;
|
|
let total = cached_recurring + cached_extra;
|
|
Ok(Some(CloudProxyUsage {
|
|
used_mb: used,
|
|
limit_mb: total,
|
|
remaining_mb: (total - used).max(0),
|
|
recurring_limit_mb: cached_recurring,
|
|
extra_limit_mb: cached_extra,
|
|
}))
|
|
}
|
|
_ => Ok(None),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), String> {
|
|
// Rebuilding the pipeline reaches the network, so do it off the command and
|
|
// let the caller's dialog close. `start_pipeline` retires the previous
|
|
// scheduler and the previous subscription itself.
|
|
tauri::async_runtime::spawn(async move {
|
|
sync::start_pipeline(app_handle).await;
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_refused_token_request_yields_the_servers_message() {
|
|
let refused = r#"Wayfern token request failed (403 Forbidden): {"message":"Browser automation is restricted to your primary device. Log out other devices to use it here.","error":"Forbidden","statusCode":403}"#;
|
|
assert_eq!(
|
|
refusal_message(refused),
|
|
"Browser automation is restricted to your primary device. Log out other devices to use it here."
|
|
);
|
|
assert_eq!(
|
|
refusal_message("Failed to request wayfern token: timeout"),
|
|
"Failed to request wayfern token: timeout"
|
|
);
|
|
}
|
|
|
|
fn active_solo() -> Entitlements {
|
|
derive_entitlements("solo", Some("monthly"), "active", 20)
|
|
}
|
|
|
|
#[test]
|
|
fn solo_is_active_without_browser_automation() {
|
|
let solo = active_solo();
|
|
assert!(solo.active, "solo is a paid, active plan");
|
|
assert!(solo.cloud_backup, "solo buys cloud profile backups");
|
|
assert!(solo.cookie_bot, "solo buys the nightly cookie bot");
|
|
assert!(
|
|
!solo.browser_automation,
|
|
"solo is sold without browser automation"
|
|
);
|
|
assert!(
|
|
!solo.cross_os_fingerprints,
|
|
"solo is sold without fingerprint editing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn remote_control_is_granted_to_every_sold_plan_but_not_an_unknown_one() {
|
|
for plan in ["solo", "pro", "team", "enterprise"] {
|
|
let derived = derive_entitlements(plan, Some("monthly"), "active", 50);
|
|
assert!(derived.remote_control, "{plan} should get remote control");
|
|
}
|
|
assert!(
|
|
!active_solo().browser_automation,
|
|
"solo gets MCP without browser automation"
|
|
);
|
|
assert!(!derive_entitlements("some-comped-plan", Some("monthly"), "active", 50).remote_control);
|
|
assert!(!derive_entitlements("pro", Some("monthly"), "canceled", 50).remote_control);
|
|
}
|
|
|
|
#[test]
|
|
fn wayfern_token_is_gated_on_automation_not_on_being_paid() {
|
|
// The regression this guards: gating the mint on `active` asked for a token
|
|
// on behalf of a Solo account, which the backend answers with a 403.
|
|
let solo = active_solo();
|
|
assert!(!(solo.active && solo.browser_automation));
|
|
|
|
let pro = derive_entitlements("pro", Some("monthly"), "active", 50);
|
|
assert!(pro.active && pro.browser_automation);
|
|
}
|
|
|
|
#[test]
|
|
fn a_refused_credential_mint_maps_to_the_codes_the_ui_knows() {
|
|
let limit: serde_json::Value = serde_json::from_str(&mcp_key_refusal(
|
|
409,
|
|
r#"{"message":"Too many MCP keys","code":"MCP_KEY_LIMIT","statusCode":409}"#,
|
|
))
|
|
.unwrap();
|
|
assert_eq!(limit["code"], "MCP_REMOTE_KEY_LIMIT");
|
|
|
|
// The cap is the only thing a 409 on that route means, named or not.
|
|
let unnamed: serde_json::Value = serde_json::from_str(&mcp_key_refusal(409, "")).unwrap();
|
|
assert_eq!(unnamed["code"], "MCP_REMOTE_KEY_LIMIT");
|
|
|
|
let other: serde_json::Value = serde_json::from_str(&mcp_key_refusal(
|
|
429,
|
|
r#"{"message":"Too many requests","statusCode":429}"#,
|
|
))
|
|
.unwrap();
|
|
assert_eq!(other["code"], "MCP_REMOTE_KEY_UNAVAILABLE");
|
|
assert_eq!(other["params"]["detail"], "429: Too many requests");
|
|
|
|
// A body that is not JSON still reaches the log verbatim.
|
|
let plain: serde_json::Value =
|
|
serde_json::from_str(&mcp_key_refusal(502, "bad gateway")).unwrap();
|
|
assert_eq!(plain["params"]["detail"], "502: bad gateway");
|
|
}
|
|
|
|
#[test]
|
|
fn the_boot_and_sign_in_paths_require_the_terms_like_the_command_does() {
|
|
// `ensure_remote_bridge` is reached from boot, sign-in and the reconnect
|
|
// tick, none of which pass through `start_mcp_remote_bridge`, so the
|
|
// command's terms gate protects only the toggle. The helper must carry
|
|
// the same gate itself.
|
|
let source = include_str!("cloud_auth.rs");
|
|
let helper = source
|
|
.split("pub(crate) async fn ensure_remote_bridge(")
|
|
.nth(1)
|
|
.expect("ensure_remote_bridge must exist");
|
|
let body = &helper[..helper.find("\n}").unwrap_or(helper.len())];
|
|
let terms = body
|
|
.find("is_terms_accepted()")
|
|
.expect("ensure_remote_bridge must check the Wayfern terms");
|
|
let start = body
|
|
.find("crate::mcp_remote::start(")
|
|
.expect("ensure_remote_bridge must be what starts the bridge");
|
|
assert!(terms < start, "the terms gate must sit ahead of the start");
|
|
}
|
|
|
|
#[test]
|
|
fn logout_retires_the_remote_credential_before_the_session_is_gone() {
|
|
// The revoke needs the access token; `clear_auth` deletes it and the
|
|
// `/api/auth/logout` call may invalidate it server-side. Both must come
|
|
// after.
|
|
let source = include_str!("cloud_auth.rs");
|
|
let logout = source
|
|
.split("pub async fn logout(&self)")
|
|
.nth(1)
|
|
.expect("logout must exist");
|
|
let body = &logout[..logout.find("\n }").unwrap_or(logout.len())];
|
|
let retire = body
|
|
.find("retire_mcp_key_on_logout()")
|
|
.expect("logout must retire the remote MCP credential");
|
|
let api_logout = body
|
|
.find("/api/auth/logout")
|
|
.expect("logout must still call the logout endpoint");
|
|
let clear = body
|
|
.find("clear_auth()")
|
|
.expect("logout must still clear the session");
|
|
assert!(
|
|
retire < api_logout,
|
|
"revoke before the server closes the session"
|
|
);
|
|
assert!(retire < clear, "revoke before the tokens are deleted");
|
|
}
|
|
|
|
#[test]
|
|
fn session_expiry_forgets_the_remote_credential_without_a_network_revoke() {
|
|
// The automatic twin of logout: the refresh loop gave up, so the session
|
|
// is already dead and there is nothing to revoke with. The local copy
|
|
// still has to go, or the agents keep presenting a key that belongs to an
|
|
// account this machine is no longer signed in to.
|
|
let source = include_str!("cloud_auth.rs");
|
|
let expiry = source
|
|
.split("pub async fn invalidate_session(&self)")
|
|
.nth(1)
|
|
.expect("invalidate_session must exist");
|
|
let body = &expiry[..expiry.find("\n }").unwrap_or(expiry.len())];
|
|
let forget = body
|
|
.find("forget_mcp_key_locally(")
|
|
.expect("invalidate_session must forget the remote MCP credential");
|
|
let clear = body
|
|
.find("clear_auth()")
|
|
.expect("invalidate_session must still clear the session");
|
|
assert!(
|
|
forget < clear,
|
|
"forget the credential before the auth state is torn down"
|
|
);
|
|
assert!(
|
|
!body.contains("revoke_mcp_key("),
|
|
"a dead session has nothing to revoke with; the call could only fail"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_device_rules_read_as_a_restriction() {
|
|
assert!(is_device_restriction(
|
|
"Wayfern token request failed (403 Forbidden): {\"message\":\"Browser automation is restricted to your primary device. Log out other devices to use it here.\",\"statusCode\":403}"
|
|
));
|
|
assert!(is_device_restriction(
|
|
"Wayfern token request failed (403 Forbidden): {\"message\":\"Browser automation requires the desktop app. Open Donut Browser and try again.\",\"statusCode\":403}"
|
|
));
|
|
// A plan-level refusal is not a restriction, and must not raise the toast
|
|
// that tells the user to sign other devices out.
|
|
assert!(!is_device_restriction(
|
|
"Wayfern token request failed (403 Forbidden): {\"message\":\"Browser automation subscription required\",\"statusCode\":403}"
|
|
));
|
|
assert!(!is_device_restriction(
|
|
"Wayfern token request failed (500 Internal Server Error): "
|
|
));
|
|
}
|
|
|
|
fn usage_response(recurring: Option<i64>, extra: Option<i64>) -> ProxyUsageResponse {
|
|
ProxyUsageResponse {
|
|
used_mb: 40,
|
|
limit_mb: 600,
|
|
remaining_mb: 560,
|
|
recurring_limit_mb: recurring,
|
|
extra_limit_mb: extra,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn each_half_of_the_proxy_limit_falls_back_on_its_own() {
|
|
let both = merge_proxy_usage(&usage_response(Some(500), Some(100)), 400, 0);
|
|
assert_eq!(both.used_mb, 40);
|
|
assert_eq!(both.limit_mb, 600);
|
|
assert_eq!(both.remaining_mb, 560);
|
|
assert_eq!(both.recurring_limit_mb, 500);
|
|
assert_eq!(both.extra_limit_mb, 100);
|
|
|
|
// A backend that does not report the split at all keeps the cached one.
|
|
let neither = merge_proxy_usage(&usage_response(None, None), 500, 100);
|
|
assert_eq!(neither.recurring_limit_mb, 500);
|
|
assert_eq!(neither.extra_limit_mb, 100);
|
|
|
|
// An omitted extra half must not be read off the recurring half, which is
|
|
// how a cached 100 MB top-up used to vanish from the split.
|
|
let recurring_only = merge_proxy_usage(&usage_response(Some(500), None), 400, 100);
|
|
assert_eq!(recurring_only.recurring_limit_mb, 500);
|
|
assert_eq!(recurring_only.extra_limit_mb, 100);
|
|
|
|
// A fresh extra allowance survives a recurring half of zero.
|
|
let extra_only = merge_proxy_usage(&usage_response(Some(0), Some(250)), 500, 100);
|
|
assert_eq!(extra_only.recurring_limit_mb, 0);
|
|
assert_eq!(extra_only.extra_limit_mb, 250);
|
|
|
|
// And a spent top-up reported as a live zero is not resurrected from cache.
|
|
let spent_extra = merge_proxy_usage(&usage_response(Some(500), Some(0)), 500, 100);
|
|
assert_eq!(spent_extra.recurring_limit_mb, 500);
|
|
assert_eq!(spent_extra.extra_limit_mb, 0);
|
|
}
|
|
|
|
fn token_expiring_at(exp: i64) -> String {
|
|
use base64::{engine::general_purpose, Engine as _};
|
|
let header = general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
|
|
let payload =
|
|
general_purpose::URL_SAFE_NO_PAD.encode(serde_json::json!({ "exp": exp }).to_string());
|
|
format!("{header}.{payload}.signature")
|
|
}
|
|
|
|
#[test]
|
|
fn the_access_token_is_renewed_fifteen_minutes_before_it_expires() {
|
|
let now = 1_800_000_000;
|
|
let ten_minutes_left = token_expiring_at(now + 10 * 60);
|
|
let twenty_minutes_left = token_expiring_at(now + 20 * 60);
|
|
|
|
assert!(CloudAuthManager::jwt_expires_within(
|
|
&ten_minutes_left,
|
|
ACCESS_TOKEN_REFRESH_MARGIN_SECS,
|
|
now
|
|
));
|
|
assert!(!CloudAuthManager::jwt_expires_within(
|
|
&twenty_minutes_left,
|
|
ACCESS_TOKEN_REFRESH_MARGIN_SECS,
|
|
now
|
|
));
|
|
assert!(!CloudAuthManager::jwt_expires_within(
|
|
&ten_minutes_left,
|
|
SYNC_TOKEN_REFRESH_MARGIN_SECS,
|
|
now
|
|
));
|
|
assert!(CloudAuthManager::jwt_expires_within(
|
|
"not-a-jwt",
|
|
ACCESS_TOKEN_REFRESH_MARGIN_SECS,
|
|
now
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn the_background_loop_renews_before_the_margin_runs_out() {
|
|
let source = include_str!("cloud_auth.rs");
|
|
let body = source
|
|
.split("pub async fn start_sync_token_refresh_loop(")
|
|
.nth(1)
|
|
.expect("the refresh loop must exist");
|
|
let period = body
|
|
.split("tokio::time::sleep(std::time::Duration::from_secs(")
|
|
.nth(1)
|
|
.and_then(|rest| rest.split(')').next())
|
|
.and_then(|secs| secs.parse::<i64>().ok())
|
|
.expect("the loop sleeps a fixed number of seconds");
|
|
assert!(period < ACCESS_TOKEN_REFRESH_MARGIN_SECS);
|
|
assert!(body.contains("access_token_needs_refresh(&token)"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_plan_change_brings_a_stopped_sync_subscription_back() {
|
|
let source = include_str!("cloud_auth.rs");
|
|
let body = source
|
|
.split("pub async fn start_sync_token_refresh_loop(")
|
|
.nth(1)
|
|
.expect("the refresh loop must exist");
|
|
let before = body
|
|
.find("let plan_before = CLOUD_AUTH.plan_marker().await;")
|
|
.expect("the plan is read before the profile refresh");
|
|
let fetch = body
|
|
.find("CLOUD_AUTH.fetch_profile().await")
|
|
.expect("the profile refresh");
|
|
let resume = body
|
|
.find("sync::resume_subscription(app_handle.clone()).await;")
|
|
.expect("a changed plan resumes the subscription");
|
|
assert!(before < fetch && fetch < resume);
|
|
}
|
|
|
|
#[test]
|
|
fn retry_after_accepts_seconds_and_http_dates() {
|
|
let now = chrono::DateTime::parse_from_rfc3339("2026-09-27T10:00:00Z")
|
|
.unwrap()
|
|
.with_timezone(&Utc);
|
|
assert_eq!(
|
|
parse_retry_after("120", now),
|
|
Some(Duration::from_secs(120))
|
|
);
|
|
assert_eq!(
|
|
parse_retry_after("Sun, 27 Sep 2026 10:01:30 GMT", now),
|
|
Some(Duration::from_secs(90))
|
|
);
|
|
assert_eq!(
|
|
parse_retry_after("Sun, 27 Sep 2026 09:00:00 GMT", now),
|
|
Some(Duration::ZERO)
|
|
);
|
|
assert_eq!(parse_retry_after("999999", now), Some(MAX_RETRY_AFTER));
|
|
assert_eq!(parse_retry_after("soon", now), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_retry_after_falls_back_to_a_default_wait() {
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
assert_eq!(retry_after(&headers), DEFAULT_RETRY_AFTER);
|
|
headers.insert(reqwest::header::RETRY_AFTER, "7".parse().unwrap());
|
|
assert_eq!(retry_after(&headers), Duration::from_secs(7));
|
|
}
|
|
|
|
#[test]
|
|
fn only_a_dead_session_reads_as_signed_out() {
|
|
assert!(matches!(
|
|
AuthorizedError::from_token_error("Token refresh failed (401 Unauthorized)".to_string()),
|
|
AuthorizedError::SignedOut
|
|
));
|
|
assert!(matches!(
|
|
AuthorizedError::from_token_error("Not logged in".to_string()),
|
|
AuthorizedError::SignedOut
|
|
));
|
|
assert!(matches!(
|
|
AuthorizedError::from_token_error("No refresh token stored".to_string()),
|
|
AuthorizedError::SignedOut
|
|
));
|
|
assert!(matches!(
|
|
AuthorizedError::from_token_error("Failed to refresh token: connection reset".to_string()),
|
|
AuthorizedError::Transport(_)
|
|
));
|
|
assert!(matches!(
|
|
AuthorizedError::from_token_error(
|
|
"Token refresh deferred for 30s (429 Too Many Requests)".to_string()
|
|
),
|
|
AuthorizedError::Transport(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_refused_refresh_ends_the_session_and_a_throttled_one_waits() {
|
|
let source = include_str!("cloud_auth.rs");
|
|
let body = source
|
|
.split("async fn refresh_while_locked(&self)")
|
|
.nth(1)
|
|
.expect("refresh_while_locked must exist");
|
|
let body = &body[..body.find("\n }").unwrap_or(body.len())];
|
|
let refused = body
|
|
.find("StatusCode::UNAUTHORIZED")
|
|
.expect("a 401 must be handled");
|
|
let invalidate = body
|
|
.find("invalidate_session()")
|
|
.expect("a refused refresh must end the session");
|
|
assert!(refused < invalidate);
|
|
assert!(body.contains("forget_refresh_token()"));
|
|
assert!(body.contains("defer_refresh(retry_after(response.headers()))"));
|
|
assert!(body.find("refresh_deferral()") < body.find(".send()"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_ended_session_stops_every_loop_that_sends_a_token() {
|
|
let source = include_str!("cloud_auth.rs");
|
|
let body = source
|
|
.split("pub async fn invalidate_session(&self)")
|
|
.nth(1)
|
|
.expect("invalidate_session must exist");
|
|
let body = &body[..body.find("\n }").unwrap_or(body.len())];
|
|
for stop in [
|
|
"crate::team_lock::PROFILE_LOCK.disconnect()",
|
|
"sync::stop_subscription()",
|
|
"crate::remote_session::stop_session_events()",
|
|
"crate::mcp_remote::stop(None)",
|
|
] {
|
|
assert!(body.contains(stop), "invalidate_session must call {stop}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_api_call_starts_from_a_token_that_is_not_about_to_expire() {
|
|
let source = include_str!("cloud_auth.rs");
|
|
for owner in [
|
|
"pub async fn api_call_with_retry<",
|
|
"pub(crate) async fn authorized_request<",
|
|
] {
|
|
let body = source
|
|
.split(owner)
|
|
.nth(1)
|
|
.unwrap_or_else(|| panic!("{owner} must exist"));
|
|
let body = &body[..body.find("\n }").unwrap_or(body.len())];
|
|
assert!(body.contains(".usable_access_token()"), "{owner}");
|
|
assert!(body.contains("refresh_after_rejection("), "{owner}");
|
|
}
|
|
}
|
|
}
|