use crate::browser::ProxySettings; use crate::events; use crate::group_manager::GROUP_MANAGER; use crate::profile::manager::ProfileManager; use crate::proxy_manager::PROXY_MANAGER; use crate::tag_manager::TAG_MANAGER; use axum::{ extract::{ ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, Path, Query, State, }, http::{header, HeaderMap, Method, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Json, Response}, routing::get, Router, }; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::net::TcpListener; use tokio::sync::{mpsc, Mutex}; use tower_http::cors::CorsLayer; use utoipa::{OpenApi, ToSchema}; use utoipa_axum::{router::OpenApiRouter, routes}; // API Types #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct ApiProfile { pub id: String, pub name: String, pub browser: String, pub version: String, pub proxy_id: Option, pub launch_hook: Option, pub process_id: Option, pub last_launch: Option, pub release_type: String, pub group_id: Option, pub tags: Vec, pub is_running: bool, pub proxy_bypass_rules: Vec, pub vpn_id: Option, pub clear_on_close: bool, /// Cloud sync mode: `"Disabled"`, `"Regular"` or `"Encrypted"`. /// Settable via `PUT /v1/profiles/{id}`; exposed here so a caller can read /// back what it set, and so a remote-launch caller can tell whether the /// profile is actually available in cloud storage. pub sync_mode: String, /// Convenience form of `sync_mode` — true for Regular or Encrypted. pub cloud_sync_enabled: bool, /// OS the profile was created on (`"macos"`, `"windows"`, `"linux"`). /// `null` when neither `host_os` nor the browser config records one. pub host_os: Option, /// True when the profile belongs to a different OS than this machine. /// Such a profile cannot be launched locally, and must only ever run on a /// remote host of its own OS — Chromium profile state is OS-specific. pub is_cross_os: bool, } impl From<&crate::profile::types::BrowserProfile> for ApiProfile { /// Single conversion for every profile-returning route. Previously open-coded /// at three call sites, which is how `sync_mode` came to be settable but not /// readable: a field added to the struct had to be remembered three times. fn from(profile: &crate::profile::types::BrowserProfile) -> Self { Self { id: profile.id.to_string(), name: profile.name.clone(), browser: profile.browser.clone(), version: profile.version.clone(), proxy_id: profile.proxy_id.clone(), launch_hook: profile.launch_hook.clone(), process_id: profile.process_id, last_launch: profile.last_launch, release_type: profile.release_type.clone(), group_id: profile.group_id.clone(), tags: profile.tags.clone(), is_running: profile.process_id.is_some(), proxy_bypass_rules: profile.proxy_bypass_rules.clone(), vpn_id: profile.vpn_id.clone(), clear_on_close: profile.clear_on_close, sync_mode: format!("{:?}", profile.sync_mode), cloud_sync_enabled: profile.is_sync_enabled(), host_os: profile.resolved_os().map(|os| os.to_string()), is_cross_os: profile.is_cross_os(), } } } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ApiProfilesResponse { pub profiles: Vec, pub total: usize, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ApiProfileResponse { pub profile: ApiProfile, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateProfileRequest { pub name: String, /// Browser engine. Must be `"wayfern"` (anti-detect Chromium). Any other /// value (e.g. `"chromium"`) is rejected with 400. pub browser: String, /// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded /// version of the chosen browser. A concrete version must already be /// downloaded; the create path does not fetch new versions. #[serde(default)] pub version: Option, pub proxy_id: Option, pub vpn_id: Option, pub launch_hook: Option, pub release_type: Option, /// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`. /// Omit it, or pass an empty object `{}`, to have a fresh fingerprint /// generated automatically at creation. Provide a `fingerprint` field to /// pin a specific one. #[schema(value_type = Option)] pub wayfern_config: Option, pub group_id: Option, pub tags: Option>, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct UpdateProfileRequest { pub name: Option, // No `browser` field: a profile's engine is fixed at creation (changing it // would invalidate the generated fingerprint and on-disk profile dir). // Accepting it here only to silently ignore it misled API clients. pub version: Option, pub proxy_id: Option, pub vpn_id: Option, pub launch_hook: Option, pub release_type: Option, pub group_id: Option, pub tags: Option>, pub extension_group_id: Option, pub proxy_bypass_rules: Option>, /// One of "Disabled", "Regular", "Encrypted". pub sync_mode: Option, /// Wipe browsing data (keeping extensions and bookmarks) when the browser /// exits. Rejected (400) for ephemeral or password-protected profiles. pub clear_on_close: Option, } #[derive(Clone)] struct ApiServerState { app_handle: tauri::AppHandle, } #[derive(Debug, Serialize, Deserialize, ToSchema)] struct ApiGroupResponse { id: String, name: String, profile_count: usize, } #[derive(Debug, Deserialize, ToSchema)] struct CreateGroupRequest { name: String, } #[derive(Debug, Deserialize, ToSchema)] struct UpdateGroupRequest { name: String, } #[derive(Debug, Serialize, Deserialize, ToSchema)] struct ApiProxyResponse { id: String, name: String, #[schema(value_type = Object)] proxy_settings: ProxySettings, } #[derive(Debug, Deserialize, ToSchema)] struct CreateProxyRequest { name: String, #[schema(value_type = Object)] proxy_settings: ProxySettings, } #[derive(Debug, Deserialize, ToSchema)] struct UpdateProxyRequest { name: Option, #[schema(value_type = Option)] proxy_settings: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] struct ApiVpnResponse { id: String, name: String, /// Always "WireGuard" vpn_type: String, created_at: i64, last_used: Option, } #[derive(Debug, Serialize, ToSchema)] struct ApiVpnExportResponse { id: String, name: String, /// Always "WireGuard" vpn_type: String, /// Raw `.conf` file content (decrypted) config_data: String, } #[derive(Debug, Deserialize, ToSchema)] struct ImportVpnRequest { /// Raw WireGuard `.conf` file content content: String, /// Original filename filename: String, /// Optional display name; defaults to filename-based name name: Option, } #[derive(Debug, Deserialize, ToSchema)] struct CreateVpnRequest { name: String, /// Must be "WireGuard" vpn_type: String, config_data: String, } #[derive(Debug, Deserialize, ToSchema)] struct UpdateVpnRequest { name: String, } #[derive(Debug, Deserialize, ToSchema)] struct DownloadBrowserRequest { browser: String, version: String, } #[derive(Debug, Serialize, ToSchema)] struct DownloadBrowserResponse { browser: String, version: String, status: String, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ToastPayload { pub message: String, pub variant: String, pub title: String, pub description: Option, } #[derive(Debug, Serialize, ToSchema)] struct RunProfileResponse { profile_id: String, remote_debugging_port: u16, headless: bool, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct RunRemoteRequest { /// Optional URL to open once the remote browser is up. pub url: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SetCloudSyncRequest { /// `Disabled`, `Regular`, or `Encrypted`. /// /// `Encrypted` derives its key from a passphrase that never leaves this /// machine, so a profile in that mode can be synced but NOT run remotely — /// a remote host would download ciphertext it cannot decrypt. pub mode: String, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SetCloudSyncResponse { pub profile_id: String, pub mode: String, /// Whether the profile can now be launched on a remote host. pub remote_launchable: bool, /// Why not, when `remote_launchable` is false. pub remote_blocked_reason: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct RunRemoteResponse { pub profile_id: String, /// Remote session id, for polling or closing the session. pub session_id: String, /// Operating system the session was scheduled onto — always the profile's own. pub platform: String, pub status: String, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct StopRemoteResponse { pub session_id: String, pub status: String, /// What the session actually cost, in seconds. pub billed_seconds: u64, } /// Every remote session the signed-in account currently owns. /// /// `run-remote` hands back a session id and the literal string `provisioning`; /// without a way to read the real state back, an automation client can only /// discover that a session became usable by trying to drive it. #[derive(Debug, Serialize, ToSchema)] struct ApiRemoteSessionsResponse { sessions: Vec, } /// Enrol a profile in the nightly cookie bot, or replace its enrolment. /// /// `platform` and `profile_name` are optional because this machine already /// knows both: the platform is the profile's own operating system, and a /// caller-supplied one that disagrees is a mistake, not a choice. #[derive(Debug, Deserialize, ToSchema)] struct SetCookieBotScheduleRequest { /// Defaults to the profile's local name. profile_name: Option, /// `windows` or `macos`. Defaults to the profile's own operating system, and /// must match it when supplied. platform: Option, /// Whether the nightly run is armed. A disabled schedule keeps its settings. enabled: bool, /// Minutes past local midnight the run is anchored to (0..1439). run_at_minute: u16, /// Bitmask of local weekdays, bit 0 = Monday (1..127). days_mask: u8, /// IANA zone the run time is expressed in. timezone: String, /// Server-issued preset id from `GET /v1/cookie-bot/presets`. preset: String, /// Upper bound on one run, in minutes. max_minutes: u32, /// Absolute http(s) URLs to browse. The bot visits only these. #[serde(default)] sites: Vec, /// Random spread around the anchor time, in seconds. jitter_seconds: Option, /// Write anyway when a teammate already enrols this profile. Without it, a /// colliding write is refused with 409 and the teammate's details. #[serde(default)] acknowledge_conflict: bool, } #[derive(Debug, Deserialize, ToSchema)] struct StartCookieBotRunRequest { /// Profile to warm. It must already have a schedule: the preset and the site /// list live there, so a run never carries a behaviour of its own. profile_id: String, /// Overrides the schedule's own cap for this run only. max_minutes: Option, } #[derive(Debug, Deserialize)] struct CookieBotScopeQuery { scope: Option, } #[derive(Debug, Deserialize)] struct CookieBotConflictsQuery { profile_id: String, run_at_minute: Option, timezone: Option, days_mask: Option, } #[derive(Debug, Deserialize)] struct CookieBotRunsQuery { profile_id: Option, scope: Option, limit: Option, before: Option, } #[derive(Debug, Deserialize)] struct CookieBotUsageQuery { period: Option, } #[derive(Debug, Deserialize, ToSchema)] struct RunProfileRequest { url: Option, headless: Option, } #[derive(Debug, Deserialize, ToSchema)] struct OpenUrlRequest { url: String, } #[derive(Debug, Deserialize, ToSchema)] struct ImportCookiesRequest { /// Raw cookie file content. Format is auto-detected: a JSON array /// (Puppeteer / EditThisCookie style) or a Netscape `cookies.txt`. content: String, } #[derive(Debug, Serialize, ToSchema)] struct ImportCookiesResponse { cookies_imported: usize, cookies_replaced: usize, errors: Vec, } #[derive(Debug, Deserialize, ToSchema)] struct BatchRunRequest { /// Profile IDs to launch. profile_ids: Vec, /// Optional URL to open in every launched profile. url: Option, /// Launch headless. Defaults to false. headless: Option, } #[derive(Debug, Serialize, ToSchema)] struct BatchRunResult { profile_id: String, /// Whether this profile launched successfully. ok: bool, /// Remote debugging port if launched, otherwise null. remote_debugging_port: Option, /// Failure reason if not launched, otherwise null. error: Option, } #[derive(Debug, Serialize, ToSchema)] struct BatchRunResponse { results: Vec, } #[derive(Debug, Deserialize, ToSchema)] struct BatchStopRequest { /// Profile IDs to stop. profile_ids: Vec, } #[derive(Debug, Serialize, ToSchema)] struct BatchStopResult { profile_id: String, /// Whether this profile was stopped successfully. ok: bool, /// Failure reason if not stopped, otherwise null. error: Option, } #[derive(Debug, Serialize, ToSchema)] struct BatchStopResponse { results: Vec, } #[derive(Debug, Serialize, ToSchema)] struct DetectedProfilesResponse { profiles: Vec, total: usize, } #[derive(Debug, Deserialize)] struct DetectImportQuery { /// Optional folder to scan instead of the default browser locations. folder: Option, } #[derive(Debug, Deserialize, ToSchema)] struct ImportProfilesRequest { /// Profiles to import. Each item is isolated — one failure doesn't stop the rest. items: Vec, /// Optional group to assign every imported profile to. group_id: Option, /// How to handle an already-taken profile name: "skip" or "rename" /// (auto-suffix). Defaults to "rename". duplicate_strategy: Option, /// Wayfern fingerprint/config applied to every imported profile. Omit to /// have fresh fingerprints generated automatically. #[schema(value_type = Option)] wayfern_config: Option, } #[derive(Debug, Deserialize, ToSchema)] struct ImportProxiesRequest { /// "txt" — one proxy per line (`host:port`, `host:port:user:pass`, or URL /// forms like `http://user:pass@host:port`). "json" — a Donut proxy export. format: String, /// Raw proxy list / export content. content: String, /// Name prefix for txt imports; proxies are named "{prefix} Proxy {n}". name_prefix: Option, } #[derive(Debug, Serialize, ToSchema)] struct ImportProxiesResponse { imported_count: usize, skipped_count: usize, errors: Vec, proxies: Vec, } #[derive(OpenApi)] #[openapi( paths( get_profiles, get_profile, create_profile, update_profile, delete_profile, run_profile, run_profile_remote, stop_remote_session, remote_session_cdp, list_remote_sessions_api, get_remote_session_api, get_remote_hours, set_profile_cloud_sync, list_cookie_bot_schedules, get_cookie_bot_schedule, set_cookie_bot_schedule, delete_cookie_bot_schedule, get_cookie_bot_conflicts, list_cookie_bot_runs, start_cookie_bot_run, cancel_cookie_bot_run, list_cookie_bot_presets, get_cookie_bot_usage, open_url_in_profile, kill_profile, batch_run_profiles, batch_stop_profiles, detect_import_profiles, import_profiles_api, import_profile_cookies, get_groups, get_group, create_group, update_group, delete_group, get_tags, get_proxies, get_proxy, create_proxy, import_proxies_api, update_proxy, delete_proxy, get_vpns, get_vpn, export_vpn, import_vpn, create_vpn, update_vpn, delete_vpn, get_extensions, get_extension_groups, delete_extension_api, delete_extension_group_api, download_browser_api, get_browser_versions, check_browser_downloaded, ), components(schemas( ApiProfile, ApiProfilesResponse, ApiProfileResponse, CreateProfileRequest, UpdateProfileRequest, ApiGroupResponse, CreateGroupRequest, UpdateGroupRequest, ApiProxyResponse, CreateProxyRequest, UpdateProxyRequest, ApiVpnResponse, ApiVpnExportResponse, ImportVpnRequest, CreateVpnRequest, UpdateVpnRequest, DownloadBrowserRequest, DownloadBrowserResponse, RunProfileResponse, RunRemoteRequest, RunRemoteResponse, StopRemoteResponse, SetCloudSyncRequest, SetCloudSyncResponse, ApiRemoteSessionsResponse, SetCookieBotScheduleRequest, StartCookieBotRunRequest, crate::remote_session::RemoteSessionState, crate::cookie_bot::CookieBotSchedule, crate::cookie_bot::CookieBotScheduleList, crate::cookie_bot::CookieBotScheduleSaved, crate::cookie_bot::CookieBotScheduleDeleted, crate::cookie_bot::CookieBotConflict, crate::cookie_bot::CookieBotConflictCheck, crate::cookie_bot::CookieBotRun, crate::cookie_bot::CookieBotRunPage, crate::cookie_bot::CookieBotRunStarted, crate::cookie_bot::CookieBotPreset, crate::cookie_bot::CookieBotPresetList, crate::cookie_bot::CookieBotUsage, crate::cookie_bot::CookieBotUsageMember, crate::cookie_bot::CookieBotUsageProfile, crate::cookie_bot::RemoteHoursQuota, crate::cookie_bot::RemoteHoursMember, crate::cookie_bot::RemoteHoursBreakdown, RunProfileRequest, BatchRunRequest, BatchRunResult, BatchRunResponse, BatchStopRequest, BatchStopResult, BatchStopResponse, OpenUrlRequest, ImportCookiesRequest, ImportCookiesResponse, ProxySettings, DetectedProfilesResponse, ImportProfilesRequest, ImportProxiesRequest, ImportProxiesResponse, crate::profile_importer::DetectedProfile, crate::profile_importer::ImportProfileItem, crate::profile_importer::DuplicateStrategy, crate::profile_importer::ProfileImportItemResult, crate::profile_importer::ProfileImportBatchResult, )), tags( (name = "profiles", description = "Profile management endpoints"), (name = "groups", description = "Group management endpoints"), (name = "tags", description = "Tag management endpoints"), (name = "proxies", description = "Proxy management endpoints"), (name = "vpns", description = "VPN management endpoints"), (name = "extensions", description = "Extension management endpoints"), (name = "browsers", description = "Browser management endpoints"), (name = "cookies", description = "Cookie management endpoints"), (name = "remote-sessions", description = "Sessions running on the leased remote fleet"), (name = "cookie-bot", description = "Scheduled cookie-warming runs on the remote fleet"), ), modifiers(&SecurityAddon), )] struct ApiDoc; struct SecurityAddon; impl utoipa::Modify for SecurityAddon { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { if let Some(components) = openapi.components.as_mut() { components.add_security_scheme( "bearer_auth", utoipa::openapi::security::SecurityScheme::Http( utoipa::openapi::security::HttpBuilder::new() .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer) .bearer_format("JWT") .build(), ), ); } } } pub struct ApiServer { port: Option, shutdown_tx: Option>, task_handle: Option>, } impl ApiServer { fn new() -> Self { Self { port: None, shutdown_tx: None, task_handle: None, } } fn get_port(&self) -> Option { self.port } async fn start( &mut self, app_handle: tauri::AppHandle, preferred_port: u16, ) -> Result { // Stop existing server if running self.stop().await.ok(); let (shutdown_tx, mut shutdown_rx) = mpsc::channel(1); let state = ApiServerState { app_handle: app_handle.clone(), }; // Try preferred port first, then random port let listener = match TcpListener::bind(format!("127.0.0.1:{preferred_port}")).await { Ok(listener) => listener, Err(_) => { // Port conflict, try random port let random_port = rand::random::().saturating_add(10000); match TcpListener::bind(format!("127.0.0.1:{random_port}")).await { Ok(listener) => { let _ = events::emit( "api-port-conflict", format!("API server using fallback port {random_port}"), ); listener } Err(e) => { return Err(crate::backend_error_with_detail("API_PORT_UNAVAILABLE", e)); } } } }; let actual_port = listener .local_addr() .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? .port(); let v1_routes = build_v1_router(); let api = ApiDoc::openapi(); let v1_routes = v1_routes // Innermost so only authenticated automation requests consume quota. .layer(middleware::from_fn(rate_limit_middleware)) .layer(middleware::from_fn_with_state( state.clone(), auth_middleware, )) .layer(middleware::from_fn(terms_check_middleware)); let api_for_v1 = api.clone(); let app = Router::new() .merge(v1_routes) .route("/openapi.json", get(move || async move { Json(api) })) .route( "/v1/openapi.json", get(move || async move { Json(api_for_v1) }), ) // Outermost layer: logs every request so customer reports show what // their automation is actually calling, what the response status was, // and how long it took. Never logs request bodies or auth headers. .layer(middleware::from_fn(request_logging_middleware)) .layer(CorsLayer::permissive()) .with_state(state); // Start server task let task_handle = tokio::spawn(async move { let server = axum::serve(listener, app); tokio::select! { _ = server => {}, _ = shutdown_rx.recv() => {}, } }); self.port = Some(actual_port); self.shutdown_tx = Some(shutdown_tx); self.task_handle = Some(task_handle); Ok(actual_port) } async fn stop(&mut self) -> Result<(), String> { if let Some(shutdown_tx) = self.shutdown_tx.take() { let _ = shutdown_tx.send(()).await; } if let Some(handle) = self.task_handle.take() { handle.abort(); } self.port = None; Ok(()) } } /// Register every `/v1` handler. /// /// Pulled out of `start` so a test can build it. Axum panics when two handlers /// claim the same path, and until this was callable the only thing that /// exercised it was starting the real server — a conflict introduced here /// would have shipped as an app that dies the moment the API is switched on. /// /// The OpenAPI half of `split_for_parts` is discarded on purpose: the served /// spec comes from the hand-maintained `ApiDoc`, which is why /// `openapi_spec_covers_registered_routes` exists. fn build_v1_router() -> Router { let (routes, _) = OpenApiRouter::new() .routes(routes!(get_profiles, create_profile)) .routes(routes!(get_profile, update_profile, delete_profile)) .routes(routes!(run_profile)) .routes(routes!(run_profile_remote)) // One `routes!` per PATH, not per handler: the GET and the DELETE share // `/v1/remote-sessions/{id}`, and registering them separately would have // the second overwrite the first. .routes(routes!(get_remote_session_api, stop_remote_session)) .routes(routes!(remote_session_cdp)) .routes(routes!(list_remote_sessions_api)) .routes(routes!(get_remote_hours)) .routes(routes!(set_profile_cloud_sync)) .routes(routes!(list_cookie_bot_schedules)) .routes(routes!( get_cookie_bot_schedule, set_cookie_bot_schedule, delete_cookie_bot_schedule )) .routes(routes!(get_cookie_bot_conflicts)) .routes(routes!(list_cookie_bot_runs, start_cookie_bot_run)) .routes(routes!(cancel_cookie_bot_run)) .routes(routes!(list_cookie_bot_presets)) .routes(routes!(get_cookie_bot_usage)) .routes(routes!(open_url_in_profile)) .routes(routes!(kill_profile)) .routes(routes!(batch_run_profiles)) .routes(routes!(batch_stop_profiles)) .routes(routes!(detect_import_profiles)) .routes(routes!(import_profiles_api)) .routes(routes!(import_profile_cookies)) .routes(routes!(get_groups, create_group)) .routes(routes!(get_group, update_group, delete_group)) .routes(routes!(get_tags)) .routes(routes!(get_proxies, create_proxy)) .routes(routes!(import_proxies_api)) .routes(routes!(get_proxy, update_proxy, delete_proxy)) .routes(routes!(get_vpns, create_vpn)) .routes(routes!(import_vpn)) .routes(routes!(export_vpn)) .routes(routes!(get_vpn, update_vpn, delete_vpn)) .routes(routes!(get_extensions)) .routes(routes!(delete_extension_api)) .routes(routes!(get_extension_groups)) .routes(routes!(delete_extension_group_api)) .routes(routes!(download_browser_api)) .routes(routes!(get_browser_versions)) .routes(routes!(check_browser_downloaded)) .split_for_parts(); routes } // Terms and Conditions check middleware async fn terms_check_middleware( request: axum::extract::Request, next: Next, ) -> Result { // Check if Wayfern terms have been accepted if !crate::wayfern_terms::WayfernTermsManager::instance().is_terms_accepted() { return Err(StatusCode::FORBIDDEN); } Ok(next.run(request).await) } // Authentication middleware async fn auth_middleware( State(state): State, headers: HeaderMap, request: axum::extract::Request, next: Next, ) -> Result { let path = request.uri().path().to_string(); // Get the Authorization header let auth_header = headers .get("Authorization") .and_then(|h| h.to_str().ok()) .and_then(|h| h.strip_prefix("Bearer ")); let token = match auth_header { Some(token) => token, None => { log::warn!("[api] Rejected {path}: missing Authorization header"); return Err(StatusCode::UNAUTHORIZED); } }; // Get the stored token let settings_manager = crate::settings_manager::SettingsManager::instance(); let stored_token = match settings_manager.get_api_token(&state.app_handle).await { Ok(Some(stored_token)) => stored_token, Ok(None) => { log::warn!( "[api] Rejected {path}: API server has no stored token (was the API toggled off?)" ); return Err(StatusCode::UNAUTHORIZED); } Err(e) => { log::error!("[api] Failed to read stored API token: {e}"); return Err(StatusCode::INTERNAL_SERVER_ERROR); } }; // Constant-time comparison so the auth check doesn't leak the shared-prefix // length via timing. `ConstantTimeEq` on equal-length byte slices; differing // lengths simply compare unequal. use subtle::ConstantTimeEq; let token_bytes = token.as_bytes(); let stored_bytes = stored_token.as_bytes(); let matches = token_bytes.len() == stored_bytes.len() && token_bytes.ct_eq(stored_bytes).into(); if !matches { log::warn!("[api] Rejected {path}: token mismatch"); return Err(StatusCode::UNAUTHORIZED); } // Token is valid, continue with the request Ok(next.run(request).await) } /// Logs every request: method, path, query, response status, duration. /// Skips Authorization header and request bodies entirely. async fn request_logging_middleware(request: axum::extract::Request, next: Next) -> Response { let method = request.method().clone(); let path = request.uri().path().to_string(); let query = request.uri().query().map(|q| q.to_string()); let started = std::time::Instant::now(); let response = next.run(request).await; let status = response.status(); let elapsed_ms = started.elapsed().as_millis(); let level = if status.is_server_error() { log::Level::Error } else if status.is_client_error() { log::Level::Warn } else { log::Level::Info }; match query { Some(q) => log::log!( level, "[api] {method} {path}?{q} -> {status} ({elapsed_ms} ms)" ), None => log::log!(level, "[api] {method} {path} -> {status} ({elapsed_ms} ms)"), } response } fn is_automation_request(method: &Method, path: &str) -> bool { // Ending a remote session is the one automation action that is not a POST. // Its handler declares a 429, which could never fire while this function // returned early for every non-POST method. // // Cancelling a cookie-bot run joins it: both reach across to the fleet, and // treating one stop as metered and the other as free would be arbitrary. // Note that the desktop's own stop button goes through a Tauri command, not // this server, so a human can always stop a run the limiter has cut off. if method == Method::DELETE { let mut segments = match path .strip_prefix("/v1/remote-sessions/") .or_else(|| path.strip_prefix("/v1/cookie-bot/runs/")) { Some(rest) => rest.split('/'), None => return false, }; return matches!((segments.next(), segments.next()), (Some(id), None) if !id.is_empty()); } if method != Method::POST { return false; } // Starting a bot run leases a host for up to two hours and spends the // account's pooled remote-hour budget, which makes it the single most // expensive thing this API can be asked to do. // // Deliberately NOT here: the cookie-bot schedule writes (PUT and DELETE on // /v1/cookie-bot/schedules/{profile_id}). They are configuration — a small // row in donutbrowser-infra — and lease nothing. Metering them would 429 a // client enrolling a fleet of profiles at start-up, while the thing that // actually protects the hardware, the pooled hour budget, is enforced // server-side on every run whether or not it was scheduled from here. if matches!( path, "/v1/profiles/batch/run" | "/v1/profiles/batch/stop" | "/v1/cookie-bot/runs" ) { return true; } let Some(profile_action) = path.strip_prefix("/v1/profiles/") else { return false; }; let mut segments = profile_action.split('/'); matches!( (segments.next(), segments.next(), segments.next()), // `run-remote` is a separate segment from `run`, so it matched nothing here // and every remote launch bypassed the quota it declares a 429 for. ( Some(_), Some("run" | "open-url" | "kill" | "run-remote"), None ) ) } async fn rate_limit_middleware(request: axum::extract::Request, next: Next) -> Response { if !is_automation_request(request.method(), request.uri().path()) { return next.run(request).await; } match crate::automation_rate_limiter::check_automation_rate_limit().await { crate::automation_rate_limiter::RateLimitOutcome::Limited { retry_after_secs } => { log::warn!( "[api] Rejected {}: automation rate limit exceeded; retry in {}s", request.uri().path(), retry_after_secs ); ( StatusCode::TOO_MANY_REQUESTS, [(header::RETRY_AFTER, retry_after_secs.to_string())], "automation request rate limit exceeded", ) .into_response() } crate::automation_rate_limiter::RateLimitOutcome::Unlimited | crate::automation_rate_limiter::RateLimitOutcome::Allowed { .. } => next.run(request).await, } } // Global API server instance lazy_static! { pub static ref API_SERVER: Arc> = Arc::new(Mutex::new(ApiServer::new())); } // Tauri commands #[tauri::command] pub async fn start_api_server_internal( port: u16, app_handle: &tauri::AppHandle, ) -> Result { let mut server_guard = API_SERVER.lock().await; server_guard.start(app_handle.clone(), port).await } #[tauri::command] pub async fn stop_api_server() -> Result<(), String> { let mut server_guard = API_SERVER.lock().await; server_guard.stop().await } #[tauri::command] pub async fn start_api_server( port: Option, app_handle: tauri::AppHandle, ) -> Result { let actual_port = port.unwrap_or(10108); start_api_server_internal(actual_port, &app_handle).await } #[tauri::command] pub async fn get_api_server_status() -> Result, String> { let server_guard = API_SERVER.lock().await; Ok(server_guard.get_port()) } // API Handlers - Profiles /// Maps a manager-layer error onto a consistent HTTP status: 404 for missing /// entities, 400 for validation/duplicate/client-input errors, 500 for /// everything else (IO and other internal failures). The error text passes /// through as the response body so API clients get a diagnostic instead of a /// bare status code. Matching is on message content because the managers /// return plain strings (some are the JSON `{"code": ...}` strings shared /// with the Tauri commands). /// Codes meaning "this profile is held by someone else right now". /// /// Kept as one list so the REST layer, which has no other way to tell a refusal /// apart from a validation failure, cannot drift from the guards that produce /// them. `PROFILE_REMOTE_SYNC_PENDING` in particular is temporary by nature: the /// pull that clears it is already running. const LAUNCH_CONFLICT_CODES: [&str; 5] = [ "PROFILE_RUNNING", "PROFILE_RUNNING_REMOTELY", "PROFILE_REMOTE_SYNC_PENDING", "PROFILE_LOCKED_BY_MEMBER", "PROFILE_LOCKED_ELSEWHERE", ]; fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) { let msg = err.to_string(); // Structured {"code": ...} errors from the shared managers classify exactly. if let Ok(value) = serde_json::from_str::(&msg) { if let Some(code) = value.get("code").and_then(|c| c.as_str()) { let status = if code.ends_with("_NOT_FOUND") { StatusCode::NOT_FOUND } else if LAUNCH_CONFLICT_CODES.contains(&code) { // Someone or something else holds this profile: another team member, a // browser already open, or a remote session whose work has not been // pulled back yet. All of them are "try again later", not "your request // was malformed", and 400 would tell an automation client to give up. StatusCode::CONFLICT } else if code == "INTERNAL_ERROR" { StatusCode::INTERNAL_SERVER_ERROR } else if code == "PROFILE_LOCK_UNAVAILABLE" { // The lock service could not be reached. The launch is refused because // it cannot be proven safe, which is an upstream failure, not the // caller's fault. StatusCode::SERVICE_UNAVAILABLE } else if code.ends_with("_REQUIRES_PRO") || code.ends_with("_PAYMENT_REQUIRED") { // Paid-feature gates (FINGERPRINT_REQUIRES_PRO, PROXY_PAYMENT_REQUIRED). // Mapping them here lets the gate live in the shared manager instead of // being re-implemented in each handler to get the status right. StatusCode::PAYMENT_REQUIRED } else { // Validation-style codes (NAME_CANNOT_BE_EMPTY, GROUP_ALREADY_EXISTS, // WAYFERN_VERSION_NOT_AVAILABLE, ...). StatusCode::BAD_REQUEST }; return (status, msg); } } // Plain-text manager messages: match the known phrases narrowly so raw // OS/serde/network error text (e.g. "invalid type: ..." from a corrupt // store) falls through to 500 instead of masquerading as a client error. let lower = msg.to_lowercase(); let status = if lower.contains("not found") { StatusCode::NOT_FOUND } else if lower.contains("already exists") || lower.contains("cannot set both") || lower.contains("cannot edit") || lower.contains("cannot delete") || lower.contains("cannot open url") || lower.contains("invalid browser") || lower.contains("invalid profile id") || lower.contains("unsupported browser") || lower.contains("not supported on your platform") || lower.contains("is not downloaded") || lower.contains("terms and conditions") { StatusCode::BAD_REQUEST } else { StatusCode::INTERNAL_SERVER_ERROR }; (status, msg) } /// Real per-group profile counts, computed from the profile list (the same /// source of truth the GUI uses). fn group_profile_counts() -> std::collections::HashMap { let mut counts = std::collections::HashMap::new(); if let Ok(profiles) = ProfileManager::instance().list_profiles() { for profile in profiles { if let Some(group_id) = profile.group_id { *counts.entry(group_id).or_insert(0) += 1; } } } counts } #[utoipa::path( get, path = "/v1/profiles", responses( (status = 200, description = "List of all profiles", body = ApiProfilesResponse), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn get_profiles() -> Result, StatusCode> { let profile_manager = ProfileManager::instance(); match profile_manager.list_profiles() { Ok(profiles) => { let api_profiles: Vec = profiles.iter().map(ApiProfile::from).collect(); Ok(Json(ApiProfilesResponse { profiles: api_profiles, total: profiles.len(), })) } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } #[utoipa::path( get, path = "/v1/profiles/{id}", params( ("id" = String, Path, description = "Profile ID") ), responses( (status = 200, description = "Profile details", body = ApiProfileResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "Profile not found"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn get_profile( Path(id): Path, State(_state): State, ) -> Result, StatusCode> { let profile_manager = ProfileManager::instance(); match profile_manager.list_profiles() { Ok(profiles) => { if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) { Ok(Json(ApiProfileResponse { profile: ApiProfile::from(profile), })) } else { Err(StatusCode::NOT_FOUND) } } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } /// Create a profile. /// /// - `browser` must be `"wayfern"`; any other value is rejected /// with 400. /// - `version` is optional: omit it or pass `"latest"` to use the newest /// already-downloaded version of that browser. The version must be present /// locally (this endpoint does not download new versions); 400 if none is. /// - Omitting the matching `wayfern_config`, or passing an /// empty object `{}`, generates a fresh fingerprint automatically. #[utoipa::path( post, path = "/v1/profiles", request_body = CreateProfileRequest, responses( (status = 200, description = "Profile created successfully", body = ApiProfileResponse), (status = 400, description = "Invalid browser, or no downloaded version available"), (status = 401, description = "Unauthorized"), (status = 402, description = "Selected proxy requires payment"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn create_profile( State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { let profile_manager = ProfileManager::instance(); // Only Wayfern profiles are launchable; the rest of the system // (fingerprint generation, launch, run) supports nothing else. Reject anything // else up front — otherwise the profile is created with no fingerprint and an // unrecognized browser, then crashes with a 500 on /run. Mirrors the MCP // create_profile validation. if request.browser != "wayfern" { return Err(( StatusCode::BAD_REQUEST, format!( "Invalid browser \"{}\". Must be \"wayfern\" (anti-detect Chromium).", request.browser ), )); } // Resolve the version. Omitted, empty, or "latest" means "newest version // already downloaded for this browser". The create path generates the // fingerprint by launching that binary, so the version must be present // locally — we don't fetch new versions here. 400 if none is downloaded. let version = match request.version.as_deref() { Some(v) if !v.is_empty() && v != "latest" => v.to_string(), _ => { let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(); let mut versions = registry.get_downloaded_versions(&request.browser); // browsers is a HashMap, so keys are unordered — sort newest-first by // semver before taking the latest. versions.sort_by(|a, b| crate::api_client::compare_versions(b, a)); match versions.into_iter().next() { Some(v) => v, None => { return Err(( StatusCode::BAD_REQUEST, format!( "No downloaded version of \"{}\" is available. Download the browser in Donut Browser first — this endpoint does not download browsers.", request.browser ), )); } } } }; // Parse wayfern config if provided let wayfern_config = if let Some(config) = &request.wayfern_config { serde_json::from_value(config.clone()).ok() } else { None }; // Reject a dead/unreachable proxy or VPN before creating the profile. A 402 // (expired proxy subscription) maps to 402; anything else is a 400. if let Err(err) = crate::validate_profile_network(request.proxy_id.as_deref(), request.vpn_id.as_deref()).await { return Err(if err.contains("PROXY_PAYMENT_REQUIRED") { ( StatusCode::PAYMENT_REQUIRED, "The selected proxy requires an active subscription.".to_string(), ) } else { ( StatusCode::BAD_REQUEST, format!("Profile network validation failed: {err}"), ) }); } // Create profile using the async create_profile_with_group method match profile_manager .create_profile_with_group( &state.app_handle, &request.name, &request.browser, &version, request.release_type.as_deref().unwrap_or("stable"), request.proxy_id.clone(), request.vpn_id.clone(), wayfern_config, request.group_id.clone(), false, None, request.launch_hook.clone(), ) .await { Ok(mut profile) => { // Apply tags if provided if let Some(tags) = &request.tags { if profile_manager .update_profile_tags(&state.app_handle, &profile.name, tags.clone()) .is_err() { return Err(( StatusCode::INTERNAL_SERVER_ERROR, "Profile created but failed to apply tags.".to_string(), )); } profile.tags = tags.clone(); } // Update tag manager with new tags if let Ok(profiles) = profile_manager.list_profiles() { let _ = crate::tag_manager::TAG_MANAGER .lock() .map(|manager| manager.rebuild_from_profiles(&profiles)); } Ok(Json(ApiProfileResponse { profile: ApiProfile::from(&profile), })) } Err(e) => Err(( StatusCode::BAD_REQUEST, format!("Failed to create profile: {e}"), )), } } #[utoipa::path( put, path = "/v1/profiles/{id}", params( ("id" = String, Path, description = "Profile ID") ), request_body = UpdateProfileRequest, responses( (status = 200, description = "Profile updated successfully", body = ApiProfileResponse), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 404, description = "Profile not found"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn update_profile( Path(id): Path, State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { let profile_manager = ProfileManager::instance(); if request.proxy_id.as_deref().is_some_and(|s| !s.is_empty()) && request.vpn_id.as_deref().is_some_and(|s| !s.is_empty()) { return Err(( StatusCode::BAD_REQUEST, "Cannot set both proxy_id and vpn_id".to_string(), )); } // Update profile fields if let Some(new_name) = request.name { if let Err(e) = profile_manager.rename_profile(&state.app_handle, &id, &new_name) { return Err(manager_error_response(e)); } } if let Some(version) = request.version { if let Err(e) = profile_manager.update_profile_version(&state.app_handle, &id, &version) { return Err(manager_error_response(e)); } } if let Some(proxy_id) = request.proxy_id { if let Err(e) = profile_manager .update_profile_proxy(state.app_handle.clone(), &id, Some(proxy_id)) .await { return Err(manager_error_response(e)); } } if let Some(vpn_id) = request.vpn_id { let normalized = if vpn_id.is_empty() { None } else { Some(vpn_id) }; if let Err(e) = profile_manager .update_profile_vpn(state.app_handle.clone(), &id, normalized) .await { return Err(manager_error_response(e)); } } if let Some(launch_hook) = request.launch_hook { let normalized = if launch_hook.trim().is_empty() { None } else { Some(launch_hook) }; if let Err(e) = profile_manager.update_profile_launch_hook(&state.app_handle, &id, normalized) { return Err(manager_error_response(e)); } } if let Some(group_id) = request.group_id { if let Err(e) = profile_manager.assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id)) { return Err(manager_error_response(e)); } } if let Some(tags) = request.tags { if let Err(e) = profile_manager.update_profile_tags(&state.app_handle, &id, tags) { return Err(manager_error_response(e)); } // Update tag manager with new tags from all profiles if let Ok(profiles) = profile_manager.list_profiles() { let _ = crate::tag_manager::TAG_MANAGER .lock() .map(|manager| manager.rebuild_from_profiles(&profiles)); } } if let Some(extension_group_id) = request.extension_group_id { let ext_group = if extension_group_id.is_empty() { None } else { Some(extension_group_id) }; if let Err(e) = profile_manager.update_profile_extension_group(&id, ext_group) { return Err(manager_error_response(e)); } } if let Some(proxy_bypass_rules) = request.proxy_bypass_rules { if let Err(e) = profile_manager.update_profile_proxy_bypass_rules(&state.app_handle, &id, proxy_bypass_rules) { return Err(manager_error_response(e)); } } if let Some(sync_mode) = request.sync_mode { if let Err(e) = crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode).await { return Err(manager_error_response(e)); } } if let Some(clear_on_close) = request.clear_on_close { if let Err(e) = profile_manager.update_profile_clear_on_close(&state.app_handle, &id, clear_on_close) { return Err(manager_error_response(e)); } } // Return updated profile get_profile(Path(id), State(state)) .await .map_err(|status| (status, String::new())) } #[utoipa::path( delete, path = "/v1/profiles/{id}", params( ("id" = String, Path, description = "Profile ID") ), responses( (status = 204, description = "Profile deleted successfully"), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 404, description = "Profile not found"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn delete_profile( Path(id): Path, State(state): State, ) -> Result { let profile_manager = ProfileManager::instance(); match profile_manager.delete_profile(&state.app_handle, &id) { Ok(_) => Ok(StatusCode::NO_CONTENT), Err(e) => Err(manager_error_response(e)), } } // API Handlers - Groups #[utoipa::path( get, path = "/v1/groups", responses( (status = 200, description = "List of all groups", body = Vec), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "groups" )] async fn get_groups( State(_state): State, ) -> Result>, StatusCode> { match GROUP_MANAGER.lock() { Ok(manager) => match manager.get_all_groups() { Ok(groups) => { let counts = group_profile_counts(); let api_groups = groups .into_iter() .map(|group| ApiGroupResponse { profile_count: counts.get(&group.id).copied().unwrap_or(0), id: group.id, name: group.name, }) .collect(); Ok(Json(api_groups)) } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), }, Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } #[utoipa::path( get, path = "/v1/groups/{id}", params( ("id" = String, Path, description = "Group ID") ), responses( (status = 200, description = "Group details", body = ApiGroupResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "Group not found"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "groups" )] async fn get_group( Path(id): Path, State(_state): State, ) -> Result, StatusCode> { match GROUP_MANAGER.lock() { Ok(manager) => match manager.get_all_groups() { Ok(groups) => { if let Some(group) = groups.into_iter().find(|g| g.id == id) { Ok(Json(ApiGroupResponse { profile_count: group_profile_counts().get(&group.id).copied().unwrap_or(0), id: group.id, name: group.name, })) } else { Err(StatusCode::NOT_FOUND) } } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), }, Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } #[utoipa::path( post, path = "/v1/groups", request_body = CreateGroupRequest, responses( (status = 200, description = "Group created successfully", body = ApiGroupResponse), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "groups" )] async fn create_group( State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { match GROUP_MANAGER.lock() { Ok(manager) => match manager.create_group(&state.app_handle, request.name) { Ok(group) => Ok(Json(ApiGroupResponse { id: group.id, name: group.name, profile_count: 0, })), Err(e) => Err(manager_error_response(e)), }, Err(_) => Err(( StatusCode::INTERNAL_SERVER_ERROR, "group manager unavailable".to_string(), )), } } #[utoipa::path( put, path = "/v1/groups/{id}", params( ("id" = String, Path, description = "Group ID") ), request_body = UpdateGroupRequest, responses( (status = 200, description = "Group updated successfully", body = ApiGroupResponse), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 404, description = "Group not found"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "groups" )] async fn update_group( Path(id): Path, State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { match GROUP_MANAGER.lock() { Ok(manager) => match manager.update_group(&state.app_handle, id.clone(), request.name) { Ok(group) => Ok(Json(ApiGroupResponse { profile_count: group_profile_counts().get(&group.id).copied().unwrap_or(0), id: group.id, name: group.name, })), Err(e) => Err(manager_error_response(e)), }, Err(_) => Err(( StatusCode::INTERNAL_SERVER_ERROR, "group manager unavailable".to_string(), )), } } #[utoipa::path( delete, path = "/v1/groups/{id}", params( ("id" = String, Path, description = "Group ID") ), responses( (status = 204, description = "Group deleted successfully"), (status = 401, description = "Unauthorized"), (status = 404, description = "Group not found"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "groups" )] async fn delete_group( Path(id): Path, State(state): State, ) -> Result { match GROUP_MANAGER.lock() { Ok(manager) => match manager.delete_group(&state.app_handle, id.clone()) { Ok(_) => Ok(StatusCode::NO_CONTENT), Err(e) => Err(manager_error_response(e)), }, Err(_) => Err(( StatusCode::INTERNAL_SERVER_ERROR, "group manager unavailable".to_string(), )), } } // API Handlers - Tags #[utoipa::path( get, path = "/v1/tags", responses( (status = 200, description = "List of all tags", body = Vec), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "tags" )] async fn get_tags(State(_state): State) -> Result>, StatusCode> { match TAG_MANAGER.lock() { Ok(manager) => match manager.get_all_tags() { Ok(tags) => Ok(Json(tags)), Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), }, Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } // API Handlers - Proxies #[utoipa::path( get, path = "/v1/proxies", responses( (status = 200, description = "List of all proxies", body = Vec), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "proxies" )] async fn get_proxies( State(_state): State, ) -> Result>, StatusCode> { let proxies = PROXY_MANAGER.get_stored_proxies(); Ok(Json( proxies .into_iter() .map(|p| ApiProxyResponse { id: p.id, name: p.name, proxy_settings: p.proxy_settings, }) .collect(), )) } #[utoipa::path( get, path = "/v1/proxies/{id}", params( ("id" = String, Path, description = "Proxy ID") ), responses( (status = 200, description = "Proxy details", body = ApiProxyResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "Proxy not found"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "proxies" )] async fn get_proxy( Path(id): Path, State(_state): State, ) -> Result, StatusCode> { let proxies = PROXY_MANAGER.get_stored_proxies(); if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) { Ok(Json(ApiProxyResponse { id: proxy.id, name: proxy.name, proxy_settings: proxy.proxy_settings, })) } else { Err(StatusCode::NOT_FOUND) } } #[utoipa::path( post, path = "/v1/proxies", request_body = CreateProxyRequest, responses( (status = 200, description = "Proxy created successfully", body = ApiProxyResponse), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "proxies" )] async fn create_proxy( State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { let result = PROXY_MANAGER.create_stored_proxy( &state.app_handle, request.name.clone(), request.proxy_settings, ); match result { Ok(proxy) => Ok(Json(ApiProxyResponse { id: proxy.id, name: proxy.name, proxy_settings: proxy.proxy_settings, })), Err(e) => Err(manager_error_response(e)), } } // API Handler - Bulk-import proxies from a txt list or a Donut JSON export. // Mirrors the MCP `import_proxies` tool. #[utoipa::path( post, path = "/v1/proxies/import", request_body = ImportProxiesRequest, responses( (status = 200, description = "Import completed; inspect counts and per-proxy errors", body = ImportProxiesResponse), (status = 400, description = "Invalid format or no valid proxies in content"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "proxies" )] async fn import_proxies_api( State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { let result = match request.format.as_str() { "json" => PROXY_MANAGER .import_proxies_json(&state.app_handle, &request.content) .map_err(manager_error_response)?, "txt" => { use crate::proxy_manager::{ProxyManager, ProxyParseResult}; let parsed: Vec<_> = ProxyManager::parse_txt_proxies(&request.content) .into_iter() .filter_map(|r| match r { ProxyParseResult::Parsed(p) => Some(p), _ => None, }) .collect(); if parsed.is_empty() { return Err(( StatusCode::BAD_REQUEST, "No valid proxies found in content".to_string(), )); } PROXY_MANAGER .import_proxies_from_parsed(&state.app_handle, parsed, request.name_prefix) .map_err(manager_error_response)? } other => { return Err(( StatusCode::BAD_REQUEST, format!("Invalid format \"{other}\", must be \"json\" or \"txt\""), )) } }; Ok(Json(ImportProxiesResponse { imported_count: result.imported_count, skipped_count: result.skipped_count, errors: result.errors, proxies: result .proxies .into_iter() .map(|p| ApiProxyResponse { id: p.id, name: p.name, proxy_settings: p.proxy_settings, }) .collect(), })) } #[utoipa::path( put, path = "/v1/proxies/{id}", params( ("id" = String, Path, description = "Proxy ID") ), request_body = UpdateProxyRequest, responses( (status = 200, description = "Proxy updated successfully", body = ApiProxyResponse), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 404, description = "Proxy not found"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "proxies" )] async fn update_proxy( Path(id): Path, State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { let result = PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings); match result { Ok(proxy) => Ok(Json(ApiProxyResponse { id: proxy.id, name: proxy.name, proxy_settings: proxy.proxy_settings, })), Err(e) => Err(manager_error_response(e)), } } #[utoipa::path( delete, path = "/v1/proxies/{id}", params( ("id" = String, Path, description = "Proxy ID") ), responses( (status = 204, description = "Proxy deleted successfully"), (status = 400, description = "Bad request (e.g. cloud-managed proxy)"), (status = 401, description = "Unauthorized"), (status = 404, description = "Proxy not found"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "proxies" )] async fn delete_proxy( Path(id): Path, State(state): State, ) -> Result { match PROXY_MANAGER.delete_stored_proxy(&state.app_handle, &id) { Ok(_) => Ok(StatusCode::NO_CONTENT), Err(e) => Err(manager_error_response(e)), } } // API Handlers - VPNs fn vpn_to_api_response(c: &crate::vpn::VpnConfig) -> ApiVpnResponse { ApiVpnResponse { id: c.id.clone(), name: c.name.clone(), vpn_type: c.vpn_type.to_string(), created_at: c.created_at, last_used: c.last_used, } } fn parse_vpn_type(s: &str) -> Option { match s.to_ascii_lowercase().as_str() { "wireguard" | "wg" => Some(crate::vpn::VpnType::WireGuard), _ => None, } } #[utoipa::path( get, path = "/v1/vpns", responses( (status = 200, description = "List of all VPN configurations", body = Vec), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security(("bearer_auth" = [])), tag = "vpns" )] async fn get_vpns( State(_state): State, ) -> Result>, StatusCode> { let storage = crate::vpn::VPN_STORAGE .lock() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let configs = storage .list_configs() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(configs.iter().map(vpn_to_api_response).collect())) } #[utoipa::path( get, path = "/v1/vpns/{id}", params(("id" = String, Path, description = "VPN configuration ID")), responses( (status = 200, description = "VPN configuration details", body = ApiVpnResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "VPN configuration not found"), (status = 500, description = "Internal server error") ), security(("bearer_auth" = [])), tag = "vpns" )] async fn get_vpn( Path(id): Path, State(_state): State, ) -> Result, StatusCode> { let storage = crate::vpn::VPN_STORAGE .lock() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let configs = storage .list_configs() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; configs .iter() .find(|c| c.id == id) .map(|c| Json(vpn_to_api_response(c))) .ok_or(StatusCode::NOT_FOUND) } #[utoipa::path( get, path = "/v1/vpns/{id}/export", params(("id" = String, Path, description = "VPN configuration ID")), responses( (status = 200, description = "Decrypted VPN configuration", body = ApiVpnExportResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "VPN configuration not found"), (status = 500, description = "Internal server error") ), security(("bearer_auth" = [])), tag = "vpns" )] async fn export_vpn( Path(id): Path, State(_state): State, ) -> Result, StatusCode> { let storage = crate::vpn::VPN_STORAGE .lock() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; match storage.load_config(&id) { Ok(config) => Ok(Json(ApiVpnExportResponse { id: config.id, name: config.name, vpn_type: config.vpn_type.to_string(), config_data: config.config_data, })), Err(_) => Err(StatusCode::NOT_FOUND), } } #[utoipa::path( post, path = "/v1/vpns/import", request_body = ImportVpnRequest, responses( (status = 200, description = "VPN configuration imported successfully", body = ApiVpnResponse), (status = 400, description = "Invalid or unrecognized VPN config"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security(("bearer_auth" = [])), tag = "vpns" )] async fn import_vpn( State(_state): State, Json(request): Json, ) -> Result, StatusCode> { let result = { let storage = crate::vpn::VPN_STORAGE .lock() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; storage.import_config(&request.content, &request.filename, request.name) }; match result { Ok(config) => { let _ = events::emit("vpn-configs-changed", ()); Ok(Json(vpn_to_api_response(&config))) } Err(_) => Err(StatusCode::BAD_REQUEST), } } #[utoipa::path( post, path = "/v1/vpns", request_body = CreateVpnRequest, responses( (status = 200, description = "VPN configuration created successfully", body = ApiVpnResponse), (status = 400, description = "Invalid VPN config or unknown vpn_type"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security(("bearer_auth" = [])), tag = "vpns" )] async fn create_vpn( State(_state): State, Json(request): Json, ) -> Result, StatusCode> { let vpn_type = parse_vpn_type(&request.vpn_type).ok_or(StatusCode::BAD_REQUEST)?; let result = { let storage = crate::vpn::VPN_STORAGE .lock() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; storage.create_config_manual(&request.name, vpn_type, &request.config_data) }; match result { Ok(config) => { let _ = events::emit("vpn-configs-changed", ()); Ok(Json(vpn_to_api_response(&config))) } Err(_) => Err(StatusCode::BAD_REQUEST), } } #[utoipa::path( put, path = "/v1/vpns/{id}", params(("id" = String, Path, description = "VPN configuration ID")), request_body = UpdateVpnRequest, responses( (status = 200, description = "VPN configuration updated successfully", body = ApiVpnResponse), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 404, description = "VPN configuration not found"), (status = 500, description = "Internal server error") ), security(("bearer_auth" = [])), tag = "vpns" )] async fn update_vpn( Path(id): Path, State(_state): State, Json(request): Json, ) -> Result, StatusCode> { let result = { let storage = crate::vpn::VPN_STORAGE .lock() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; storage.update_config_name(&id, &request.name) }; match result { Ok(config) => { let _ = events::emit("vpn-configs-changed", ()); Ok(Json(vpn_to_api_response(&config))) } Err(_) => Err(StatusCode::NOT_FOUND), } } #[utoipa::path( delete, path = "/v1/vpns/{id}", params(("id" = String, Path, description = "VPN configuration ID")), responses( (status = 204, description = "VPN configuration deleted successfully"), (status = 401, description = "Unauthorized"), (status = 404, description = "VPN configuration not found"), (status = 500, description = "Internal server error") ), security(("bearer_auth" = [])), tag = "vpns" )] async fn delete_vpn( Path(id): Path, State(_state): State, ) -> Result { let _ = crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(&id).await; let result = { let storage = crate::vpn::VPN_STORAGE .lock() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; storage.delete_config(&id) }; match result { Ok(_) => { let _ = events::emit("vpn-configs-changed", ()); Ok(StatusCode::NO_CONTENT) } Err(_) => Err(StatusCode::NOT_FOUND), } } // Extension API endpoints #[utoipa::path( get, path = "/v1/extensions", responses( (status = 200, description = "List of extensions"), (status = 401, description = "Unauthorized"), ), security(("bearer_auth" = [])), tag = "extensions" )] async fn get_extensions( State(_state): State, ) -> Result>, StatusCode> { let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); mgr .list_extensions() .map(Json) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } #[utoipa::path( get, path = "/v1/extension-groups", responses( (status = 200, description = "List of extension groups"), (status = 401, description = "Unauthorized"), ), security(("bearer_auth" = [])), tag = "extensions" )] async fn get_extension_groups( State(_state): State, ) -> Result>, StatusCode> { let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); mgr .list_groups() .map(Json) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } #[utoipa::path( delete, path = "/v1/extensions/{id}", params(("id" = String, Path, description = "Extension ID")), responses( (status = 204, description = "Extension deleted"), (status = 401, description = "Unauthorized"), (status = 404, description = "Extension not found"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])), tag = "extensions" )] async fn delete_extension_api( Path(id): Path, State(state): State, ) -> Result { let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); mgr .delete_extension(&state.app_handle, &id) .map(|_| StatusCode::NO_CONTENT) .map_err(manager_error_response) } #[utoipa::path( delete, path = "/v1/extension-groups/{id}", params(("id" = String, Path, description = "Extension Group ID")), responses( (status = 204, description = "Extension group deleted"), (status = 401, description = "Unauthorized"), (status = 404, description = "Extension group not found"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])), tag = "extensions" )] async fn delete_extension_group_api( Path(id): Path, State(state): State, ) -> Result { let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); mgr .delete_group(&state.app_handle, &id) .map(|_| StatusCode::NO_CONTENT) .map_err(manager_error_response) } // API Handler - Run Profile with Remote Debugging #[utoipa::path( post, path = "/v1/profiles/{id}/run", params( ("id" = String, Path, description = "Profile ID") ), request_body = RunProfileRequest, responses( (status = 200, description = "Profile launched successfully", body = RunProfileResponse), (status = 400, description = "Cannot launch cross-OS profile"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), (status = 409, description = "Profile is locked by another team member, running on the remote fleet, or waiting for a finished remote session to be pulled back"), (status = 429, description = "Automation request rate limit exceeded"), (status = 503, description = "The profile lock service could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn run_profile( Path(id): Path, State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { if !crate::cloud_auth::CLOUD_AUTH .can_use_browser_automation() .await { return Err((StatusCode::PAYMENT_REQUIRED, String::new())); } let headless = request.headless.unwrap_or(false); let url = request.url; let profile_manager = ProfileManager::instance(); let profiles = profile_manager .list_profiles() .map_err(manager_error_response)?; let profile = profiles .iter() .find(|p| p.id.to_string() == id) .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; if profile.is_cross_os() { return Err(( StatusCode::BAD_REQUEST, "cannot launch a cross-OS profile locally; use /run-remote".to_string(), )); } // Team lock check. Routed through the shared mapper so a profile held by the // user's OWN remote session is a 409 that says so, rather than a bare status // with no body, which is what an automation client had to guess from. crate::team_lock::acquire_team_lock_if_needed(profile) .await .map_err(manager_error_response)?; let remote_debugging_port = { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .map_err(manager_error_response)?; let port = listener .local_addr() .map_err(manager_error_response)? .port(); drop(listener); port }; // Use the same launch path as the main app, but force a fresh instance with // remote debugging enabled so the returned port is the one the browser binds. let updated_profile = crate::browser_runner::launch_browser_profile_impl( state.app_handle.clone(), profile.clone(), url, Some(remote_debugging_port), headless, true, ) .await .map_err(manager_error_response)?; Ok(Json(RunProfileResponse { profile_id: updated_profile.id.to_string(), remote_debugging_port, headless, })) } // API Handler - Launch this profile on a REMOTE VM of its own operating system #[utoipa::path( post, path = "/v1/profiles/{id}/run-remote", params( ("id" = String, Path, description = "Profile ID") ), request_body = RunRemoteRequest, responses( (status = 200, description = "Remote session started", body = RunRemoteResponse), (status = 400, description = "Profile does not have cloud sync enabled"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), (status = 409, description = "Profile is locked by another session"), (status = 429, description = "Automation request rate limit exceeded"), (status = 503, description = "No remote capacity for this operating system"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn run_profile_remote( Path(id): Path, State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { if !crate::cloud_auth::CLOUD_AUTH .can_use_browser_automation() .await { return Err((StatusCode::PAYMENT_REQUIRED, String::new())); } let profile_manager = ProfileManager::instance(); let profiles = profile_manager .list_profiles() .map_err(manager_error_response)?; let profile = profiles .iter() .find(|p| p.id.to_string() == id) .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; // The profile must exist in cloud storage before a remote host can open it — // the VM pulls it from donut-sync, and a profile that has never synced would // launch an empty browser and then push that emptiness back over the real one. if let Err(reason) = remote_launch_precondition(profile).await { return Err((StatusCode::BAD_REQUEST, reason)); } // Deliberately NO is_cross_os() guard here. Local /run refuses a foreign // profile because this machine is the wrong OS; running it remotely on a host // of its OWN OS is precisely what this endpoint exists for. let outcome = crate::remote_session::start_remote_session(state.app_handle.clone(), profile, request.url) .await .map_err(remote_session_error_response)?; Ok(Json(RunRemoteResponse { profile_id: profile.id.to_string(), session_id: outcome.session_id, platform: outcome.platform, status: outcome.status, })) } #[utoipa::path( post, path = "/v1/profiles/{id}/cloud-sync", params( ("id" = String, Path, description = "Profile ID") ), request_body = SetCloudSyncRequest, responses( (status = 200, description = "Cloud sync mode updated", body = SetCloudSyncResponse), (status = 400, description = "Invalid mode, or the profile cannot be synced"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with cloud backup required"), (status = 404, description = "Profile not found"), (status = 409, description = "Profile is running — stop it before enabling sync"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn set_profile_cloud_sync( Path(id): Path, State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { // Remote launch requires cloud sync, and until now sync could only be turned // on from the GUI — so an automation-only caller could never reach the state // that makes /run-remote work. let mode = match request.mode.as_str() { "Disabled" | "Regular" | "Encrypted" => request.mode.clone(), other => { return Err(( StatusCode::BAD_REQUEST, format!("invalid sync mode {other:?}; expected Disabled, Regular or Encrypted"), )); } }; crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), mode.clone()) .await .map_err(sync_mode_error_response)?; let profile_manager = ProfileManager::instance(); let profiles = profile_manager .list_profiles() .map_err(manager_error_response)?; let profile = profiles .iter() .find(|p| p.id.to_string() == id) .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; // Reported rather than left for the caller to discover at launch time: the // most common reason a caller enables sync is to run the profile remotely, // and Encrypted mode silently makes that impossible. let blocked = remote_launch_precondition(profile).await.err(); Ok(Json(SetCloudSyncResponse { profile_id: profile.id.to_string(), mode, remote_launchable: blocked.is_none(), remote_blocked_reason: blocked, })) } /// Map a sync-mode failure onto the status the caller can act on. /// /// `set_profile_sync_mode` reports a running profile as a JSON body rather than /// a plain message, because enabling sync under a live browser would race the /// browser's own writes. fn sync_mode_error_response(err: String) -> (StatusCode, String) { if err.contains("PROFILE_RUNNING") { return ( StatusCode::CONFLICT, "profile is running; stop it before changing cloud sync".to_string(), ); } if err.contains("cross-OS") || err.contains("ephemeral") { return (StatusCode::BAD_REQUEST, err); } (StatusCode::INTERNAL_SERVER_ERROR, err) } /// Whether a profile may be launched on a remote host. /// /// The one gate between "the user asked" and "a browser opens somewhere else /// holding their cookies". Adds the live check the pure rules cannot make: a /// launch that races this profile's own upload hands the host a torn snapshot. pub async fn remote_launch_precondition( profile: &crate::profile::types::BrowserProfile, ) -> Result<(), String> { remote_launch_profile_rules(profile)?; // The manifest is written last, so a host pulling mid-upload gets files // that are about to be replaced and a manifest that does not describe them. // The browser then comes up on a profile that never existed on this machine // and pushes it back over the real one. if let Some(scheduler) = crate::sync::get_global_scheduler() { if scheduler .is_profile_sync_in_progress(&profile.id.to_string()) .await { return Err(serde_json::json!({ "code": "REMOTE_SYNC_IN_PROGRESS" }).to_string()); } } Ok(()) } /// The parts of the rule that depend only on the profile itself. /// /// Split out so the rules stay unit-testable without a running app or a sync /// scheduler, and so the live check above cannot be reached without them. pub fn remote_launch_profile_rules( profile: &crate::profile::types::BrowserProfile, ) -> Result<(), String> { if !profile.is_sync_enabled() { return Err( "profile does not have cloud sync enabled; a remote host has no way to \ obtain it" .to_string(), ); } if profile.is_encrypted_sync() { // The key is derived from a passphrase that never leaves this machine, so // the host would download ciphertext, launch Chromium on it, and push the // corruption back over the user's real profile. return Err( "profile uses end-to-end encrypted sync; a remote host cannot decrypt \ it. Switch the profile to Regular sync to run it remotely." .to_string(), ); } if profile.resolved_os().is_none() { return Err( "profile has no recorded operating system, so it cannot be scheduled \ onto a matching host" .to_string(), ); } Ok(()) } // API Handler - Stop a REMOTE session started by run-remote #[utoipa::path( delete, path = "/v1/remote-sessions/{id}", params( ("id" = String, Path, description = "Remote session ID from run-remote") ), responses( (status = 200, description = "Remote session stopped", body = StopRemoteResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "No such remote session"), (status = 429, description = "Automation request rate limit exceeded"), (status = 503, description = "The fleet could not be reached; the session is still running"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn stop_remote_session( Path(id): Path, ) -> Result, (StatusCode, String)> { // Without this route, `run-remote` hands back a session id nothing can act // on: the only thing that ends a session is the fleet's own two-hour cap, so // every launch bills 7200s no matter how briefly it ran. let outcome = crate::remote_session::end_remote_session(&id) .await .map_err(remote_session_error_response)?; Ok(Json(StopRemoteResponse { session_id: outcome.session_id, status: outcome.status, billed_seconds: outcome.billed_seconds, })) } fn remote_session_error_response( err: crate::remote_session::RemoteSessionError, ) -> (StatusCode, String) { use crate::remote_session::RemoteSessionError; match err { RemoteSessionError::NoCapacity(m) => (StatusCode::SERVICE_UNAVAILABLE, m), RemoteSessionError::Conflict(m) => (StatusCode::CONFLICT, m), RemoteSessionError::NotAuthorised(m) => (StatusCode::PAYMENT_REQUIRED, m), RemoteSessionError::Other(m) => (StatusCode::INTERNAL_SERVER_ERROR, m), } } /// Map a read of remote state onto a status, and answer with a machine code. /// /// Separate from `remote_session_error_response` on purpose. That one serves /// the LAUNCH path, whose documented contract is a plain-English diagnostic and /// whose only interesting failures are "busy", "already open" and "not on your /// plan". A read has a different failure set — chiefly "no such session", which /// the launch mapping would report as a 500 — and it is new, so it can answer /// with the `{"code":…}` envelope from the start instead of English a client /// would have to pattern-match. fn remote_session_read_response( err: crate::remote_session::RemoteSessionError, ) -> (StatusCode, String) { use crate::remote_session::RemoteSessionError; let upstream = match &err { RemoteSessionError::NoCapacity(_) => 503, RemoteSessionError::Conflict(_) => 409, RemoteSessionError::NotAuthorised(_) => 403, // The status was consumed on the way in; the code is recovered from the // backend's own envelope instead. RemoteSessionError::Other(_) => 0, }; let body = err.to_error_json(); let status = cloud_failure_status(upstream, &error_code_of(&body)); (status, body) } fn cookie_bot_error_response(err: crate::cookie_bot::CookieBotError) -> (StatusCode, String) { let status = cloud_failure_status(err.status(), err.code()); (status, err.to_error_json()) } /// Read the machine code out of a `{"code":…}` body. fn error_code_of(body: &str) -> String { serde_json::from_str::(body) .ok() .and_then(|value| { value .get("code") .and_then(serde_json::Value::as_str) .map(str::to_string) }) .unwrap_or_default() } /// Turn a donutbrowser-infra failure into the status a local client can act on. /// /// The upstream status is not echoed blindly. A 401 up there means THIS desktop /// has no cloud session, which has nothing to do with the caller's own bearer /// token — answering 401 would send an automation client off to rotate a token /// that is perfectly good. Likewise the cloud's 403 covers two unrelated /// things: "your plan does not include this", which is the 402 this API uses /// everywhere else, and "you are not a member of that team", which no payment /// fixes. fn cloud_failure_status(upstream: u16, code: &str) -> StatusCode { // The disambiguations first, because no status can express them. if code == crate::cloud_errors::NOT_SIGNED_IN { return StatusCode::FORBIDDEN; } if code.ends_with("NOT_ENTITLED") || code == "REMOTE_HOURS_EXHAUSTED" { return StatusCode::PAYMENT_REQUIRED; } if code == crate::cloud_errors::RATE_LIMITED { return StatusCode::TOO_MANY_REQUESTS; } if code == crate::cloud_errors::UNREACHABLE || code == crate::cloud_errors::NO_CAPACITY { return StatusCode::SERVICE_UNAVAILABLE; } match upstream { 400 | 422 => StatusCode::BAD_REQUEST, 402 => StatusCode::PAYMENT_REQUIRED, 403 => StatusCode::FORBIDDEN, 404 => StatusCode::NOT_FOUND, 409 => StatusCode::CONFLICT, 429 => StatusCode::TOO_MANY_REQUESTS, 503 => StatusCode::SERVICE_UNAVAILABLE, // Some transports keep only the body, so the status is gone by the time // it gets here. Falling straight through to 500 would report every one of // those as our fault, including "no such run". _ => status_for_code(code), } } /// The status a machine code implies when the HTTP status did not survive. fn status_for_code(code: &str) -> StatusCode { if code.ends_with("NOT_FOUND") || code == "COOKIE_BOT_NOT_ENROLLED" { StatusCode::NOT_FOUND } else if code.ends_with("CONFLICT") || code == "COOKIE_BOT_RUN_IN_PROGRESS" || code == "REMOTE_SYNC_IN_PROGRESS" { StatusCode::CONFLICT } else if code.starts_with("COOKIE_BOT_INVALID") || code == "COOKIE_BOT_SITE_LIMIT" || code == "REMOTE_SESSION_REFUSED" { StatusCode::BAD_REQUEST } else if code == "NOT_TEAM_MEMBER" { StatusCode::FORBIDDEN } else { StatusCode::INTERNAL_SERVER_ERROR } } // API Handler - Attach a CDP client (Playwright, Puppeteer, chrome-remote-interface) // to a remote session. // // This is what makes `run-remote` usable. Without it the endpoint hands back a // session id that nothing outside this app can do anything with: the fleet's // relay only accepts the user's Donut cloud credential, an automation client // does not have one, and it must not be given one — an API token is scoped to // "drive my browsers", not "act as my account". // // So the socket is opened here with the credential this process already holds // and the frames are pumped verbatim in both directions. The caller presents // the ordinary API bearer token and gets a browser-level CDP endpoint at // `ws://127.0.0.1:/v1/remote-sessions/{id}/cdp`: // // const browser = await chromium.connectOverCDP({ // endpointURL: `ws://127.0.0.1:10108/v1/remote-sessions/${id}/cdp`, // headers: { Authorization: `Bearer ${API_TOKEN}` }, // }); // // Nothing is attached to a page first, deliberately: Playwright drives // `Target.setAutoAttach` and builds its own session map, and a socket already // bound to one page would hide every other target from it. #[utoipa::path( get, path = "/v1/remote-sessions/{id}/cdp", params( ("id" = String, Path, description = "Remote session ID from run-remote") ), responses( (status = 101, description = "Switching Protocols; a browser-level CDP WebSocket follows"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "No such remote session, or it is not attachable yet"), (status = 502, description = "The relay could not be reached"), (status = 426, description = "Not a WebSocket upgrade request") ), security( ("bearer_auth" = []) ), tag = "remote-sessions" )] async fn remote_session_cdp( Path(id): Path, upgrade: WebSocketUpgrade, ) -> Result { if !crate::cloud_auth::CLOUD_AUTH .can_use_browser_automation() .await { return Err((StatusCode::PAYMENT_REQUIRED, String::new())); } // Dialled BEFORE the upgrade is accepted, so a session that is not attachable // is an HTTP status the client can read. Accepting the upgrade first would // turn every such failure into a socket that opens and immediately closes, // which is what a CDP client reports as "browser closed unexpectedly". let upstream = crate::cdp_target::open_relay_socket(&id) .await .map_err(cdp_error_response)?; Ok( upgrade .max_message_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES) .max_frame_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES) .on_upgrade(move |client| pump_cdp(id, client, upstream)), ) } fn cdp_error_response(err: crate::cdp_target::CdpError) -> (StatusCode, String) { use crate::cdp_target::CdpError; let status = match err { CdpError::Unauthorized(_) => StatusCode::UNAUTHORIZED, // "Not drivable" covers a session that is still provisioning and one that // is not the caller's. Both are 404 to a CDP client: there is no browser at // this address right now. CdpError::NotDrivable(_) => StatusCode::NOT_FOUND, CdpError::Unreachable(_) => StatusCode::BAD_GATEWAY, CdpError::Transport(_) | CdpError::Protocol(_) => StatusCode::BAD_GATEWAY, }; (status, err.to_string()) } /// Copy CDP frames between the local client and the fleet relay until either /// side hangs up. /// /// Verbatim in both directions. This proxy deliberately understands nothing /// about CDP: a client that speaks a newer protocol, or a target type this /// build has never heard of, must keep working without a Donut release. async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_target::RelaySocket) { use futures_util::{SinkExt, StreamExt}; use tokio_tungstenite::tungstenite::Message as RelayMessage; let (mut client_tx, mut client_rx) = client.split(); let (mut relay_tx, mut relay_rx) = upstream.split(); let to_relay = async { while let Some(Ok(message)) = client_rx.next().await { let forwarded = match message { WsMessage::Text(text) => RelayMessage::Text(text.as_str().into()), WsMessage::Binary(bytes) => RelayMessage::Binary(bytes), WsMessage::Ping(bytes) => RelayMessage::Ping(bytes), WsMessage::Pong(bytes) => RelayMessage::Pong(bytes), WsMessage::Close(_) => break, }; if relay_tx.send(forwarded).await.is_err() { break; } } let _ = relay_tx.close().await; }; let to_client = async { while let Some(Ok(message)) = relay_rx.next().await { let forwarded = match message { RelayMessage::Text(text) => WsMessage::Text(text.as_str().into()), RelayMessage::Binary(bytes) => WsMessage::Binary(bytes), RelayMessage::Ping(bytes) => WsMessage::Ping(bytes), RelayMessage::Pong(bytes) => WsMessage::Pong(bytes), // A relay close carries the only diagnosis the server gives (1008 is a // rejected credential, 1013 is "not up yet"), so it is passed through // rather than swallowed into a bare disconnect. RelayMessage::Close(frame) => { let _ = client_tx .send(WsMessage::Close(frame.map(|f| { axum::extract::ws::CloseFrame { code: u16::from(f.code), reason: f.reason.as_str().into(), } }))) .await; return; } RelayMessage::Frame(_) => continue, }; if client_tx.send(forwarded).await.is_err() { break; } } let _ = client_tx.close().await; }; // Either direction ending means the conversation is over. Waiting for both // would hold a relay socket open — and one of the session's four allowed // attachments with it — after the client had gone. tokio::select! { () = to_relay => {} () = to_client => {} } log::info!("CDP proxy for remote session {session_id} closed"); } // API Handler - Every remote session this account currently owns #[utoipa::path( get, path = "/v1/remote-sessions", responses( (status = 200, description = "Sessions owned by the signed-in account", body = ApiRemoteSessionsResponse), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 403, description = "This desktop is not signed in to Donut cloud"), (status = 503, description = "Donut cloud could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "remote-sessions" )] async fn list_remote_sessions_api() -> Result, (StatusCode, String)> { let sessions = crate::remote_session::list_remote_sessions() .await .map_err(remote_session_read_response)?; Ok(Json(ApiRemoteSessionsResponse { sessions })) } // API Handler - One remote session's real state #[utoipa::path( get, path = "/v1/remote-sessions/{id}", params( ("id" = String, Path, description = "Remote session ID from run-remote") ), responses( (status = 200, description = "Current session state", body = crate::remote_session::RemoteSessionState), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 403, description = "This desktop is not signed in to Donut cloud"), (status = 404, description = "No such remote session"), (status = 503, description = "Donut cloud could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "remote-sessions" )] async fn get_remote_session_api( Path(id): Path, ) -> Result, (StatusCode, String)> { // `run-remote` answers `provisioning` and nothing more. Until this route // existed, an automation client had no way to learn a session had become // usable other than repeatedly trying to drive it. crate::remote_session::get_remote_session(&id) .await .map(Json) .map_err(remote_session_read_response) } // API Handler - The pooled remote-hour budget #[utoipa::path( get, path = "/v1/remote-hours", responses( (status = 200, description = "Pooled remote-hour budget and its breakdown", body = crate::cookie_bot::RemoteHoursQuota), (status = 401, description = "Unauthorized"), (status = 403, description = "This desktop is not signed in to Donut cloud"), (status = 503, description = "Donut cloud could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "remote-sessions" )] async fn get_remote_hours( ) -> Result, (StatusCode, String)> { // Bot runs and interactive remote sessions spend one pool. Being refused a // launch should not be the only way to find out how much of it is left. crate::cookie_bot::remote_hours_quota() .await .map(Json) .map_err(cookie_bot_error_response) } // --- Cookie bot ------------------------------------------------------------- // // Thin proxies onto donutbrowser-infra, which owns the schedule, the calendar // arithmetic, the browsing model and the pooled hour budget. Nothing here // decides when a run happens or what it does. What this file DOES decide is // which profiles may be offered to it at all. /// Resolve a profile the cookie bot is allowed to touch. /// /// The bot exists only on the leased fleet: a run materialises the profile on a /// remote host from cloud sync, warms it, and pushes it back. A profile that /// cannot make that round trip — never synced, encrypted with a key that never /// leaves this machine, no recorded OS, an OS the fleet cannot lease, or no /// proxy or VPN to egress through — has no path to a run and must never reach /// an enrolment, a quota check or a leased host. /// /// Every cookie-bot WRITE on this server goes through here, so there is no /// surface on which a local-only profile can be pointed at the bot. The server /// re-checks all of it; this exists so the refusal happens at the moment the /// caller asks rather than silently at 02:00. fn cookie_bot_eligible_profile( profile_id: &str, ) -> Result { let profiles = ProfileManager::instance() .list_profiles() .map_err(manager_error_response)?; let profile = profiles .into_iter() .find(|p| p.id.to_string() == profile_id) .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; crate::cookie_bot::bot_precondition(&profile) .map_err(|reason| (StatusCode::BAD_REQUEST, reason))?; Ok(profile) } // API Handler - Every cookie-bot enrolment the caller can see #[utoipa::path( get, path = "/v1/cookie-bot/schedules", params( ("scope" = Option, Query, description = "`mine` (default) or `team`") ), responses( (status = 200, description = "Enrolled profiles", body = crate::cookie_bot::CookieBotScheduleList), (status = 401, description = "Unauthorized"), (status = 402, description = "Plan does not include the cookie bot"), (status = 403, description = "Not signed in, or scope=team from a non-member"), (status = 503, description = "Donut cloud could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "cookie-bot" )] async fn list_cookie_bot_schedules( Query(query): Query, ) -> Result, (StatusCode, String)> { crate::cookie_bot::list_schedules(query.scope.as_deref()) .await .map(Json) .map_err(cookie_bot_error_response) } // API Handler - One profile's enrolment #[utoipa::path( get, path = "/v1/cookie-bot/schedules/{profile_id}", params( ("profile_id" = String, Path, description = "Profile ID") ), responses( (status = 200, description = "The profile's enrolment", body = crate::cookie_bot::CookieBotSchedule), (status = 401, description = "Unauthorized"), (status = 403, description = "This desktop is not signed in to Donut cloud"), (status = 404, description = "This profile is not enrolled"), (status = 503, description = "Donut cloud could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "cookie-bot" )] async fn get_cookie_bot_schedule( Path(profile_id): Path, ) -> Result, (StatusCode, String)> { // Deliberately NOT gated on eligibility: a profile whose sync was turned off // after it was enrolled must still be able to show what it is enrolled as, // otherwise the only way to see the schedule is to be allowed to run it. match crate::cookie_bot::get_schedule(&profile_id) .await .map_err(cookie_bot_error_response)? { Some(schedule) => Ok(Json(schedule)), None => Err(( StatusCode::NOT_FOUND, serde_json::json!({ "code": "COOKIE_BOT_NOT_ENROLLED" }).to_string(), )), } } // API Handler - Enrol a profile, or replace its enrolment #[utoipa::path( put, path = "/v1/cookie-bot/schedules/{profile_id}", params( ("profile_id" = String, Path, description = "Profile ID") ), request_body = SetCookieBotScheduleRequest, responses( (status = 200, description = "Enrolment saved", body = crate::cookie_bot::CookieBotScheduleSaved), (status = 400, description = "Invalid schedule, or a profile the bot cannot run"), (status = 401, description = "Unauthorized"), (status = 402, description = "Plan does not include the cookie bot"), (status = 403, description = "This desktop is not signed in to Donut cloud"), (status = 404, description = "Profile not found"), (status = 409, description = "A teammate already enrols this profile; retry with acknowledge_conflict"), (status = 503, description = "Donut cloud could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "cookie-bot" )] async fn set_cookie_bot_schedule( Path(profile_id): Path, Json(request): Json, ) -> Result, (StatusCode, String)> { let profile = cookie_bot_eligible_profile(&profile_id)?; // `bot_precondition` already proved the profile has a resolvable OS the // fleet can lease, so this cannot fail; taking it from the profile rather // than the request is what stops a caller enrolling a macOS profile onto a // Windows host. let platform = profile .resolved_os() .ok_or(( StatusCode::BAD_REQUEST, serde_json::json!({ "code": "COOKIE_BOT_UNKNOWN_PLATFORM" }).to_string(), ))? .to_string(); if let Some(requested) = request.platform.as_deref() { if requested != platform { return Err(( StatusCode::BAD_REQUEST, serde_json::json!({ "code": "COOKIE_BOT_UNSUPPORTED_PLATFORM", "params": { "platform": requested } }) .to_string(), )); } } let input = crate::cookie_bot::CookieBotScheduleInput { profile_name: request.profile_name.unwrap_or_else(|| profile.name.clone()), platform, enabled: request.enabled, run_at_minute: request.run_at_minute, days_mask: request.days_mask, timezone: request.timezone, preset: request.preset, max_minutes: request.max_minutes, sites: request.sites, jitter_seconds: request.jitter_seconds, ..Default::default() } // The server requires these and cannot read them itself — the profile lives // in the user's sync namespace, not its database. .with_profile_state(crate::cookie_bot::profile_state(&profile)); crate::cookie_bot::save_schedule(&profile_id, &input, request.acknowledge_conflict) .await .map(Json) .map_err(cookie_bot_error_response) } // API Handler - Turn the bot off for a profile #[utoipa::path( delete, path = "/v1/cookie-bot/schedules/{profile_id}", params( ("profile_id" = String, Path, description = "Profile ID") ), responses( (status = 200, description = "Enrolment removed, or there was none", body = crate::cookie_bot::CookieBotScheduleDeleted), (status = 401, description = "Unauthorized"), (status = 403, description = "This desktop is not signed in to Donut cloud"), (status = 503, description = "Donut cloud could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "cookie-bot" )] async fn delete_cookie_bot_schedule( Path(profile_id): Path, ) -> Result, (StatusCode, String)> { // No eligibility gate and no 404: "turn the bot off" must be safe to repeat, // and a profile that has since become ineligible is exactly the one a caller // most needs to be able to unenrol. crate::cookie_bot::delete_schedule(&profile_id) .await .map(Json) .map_err(cookie_bot_error_response) } // API Handler - Who else already warms this profile #[utoipa::path( get, path = "/v1/cookie-bot/conflicts", params( ("profile_id" = String, Query, description = "Profile ID"), ("run_at_minute" = Option, Query, description = "Proposed minute past local midnight"), ("timezone" = Option, Query, description = "Proposed IANA zone"), ("days_mask" = Option, Query, description = "Proposed weekday bitmask, bit 0 = Monday") ), responses( (status = 200, description = "Teammates enrolling the same profile", body = crate::cookie_bot::CookieBotConflictCheck), (status = 400, description = "profile_id missing"), (status = 401, description = "Unauthorized"), (status = 403, description = "This desktop is not signed in to Donut cloud"), (status = 503, description = "Donut cloud could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "cookie-bot" )] async fn get_cookie_bot_conflicts( Query(query): Query, ) -> Result, (StatusCode, String)> { // A dry run that writes nothing, so an automation client can find a // collision before it makes one instead of after two operators have quietly // scheduled the same profile against itself. crate::cookie_bot::check_conflicts( &query.profile_id, query.run_at_minute, query.timezone.as_deref(), query.days_mask, ) .await .map(Json) .map_err(cookie_bot_error_response) } // API Handler - Run history #[utoipa::path( get, path = "/v1/cookie-bot/runs", params( ("profile_id" = Option, Query, description = "Restrict to one profile"), ("scope" = Option, Query, description = "`mine` (default) or `team`"), ("limit" = Option, Query, description = "Page size, 1..100 (default 30)"), ("before" = Option, Query, description = "Keyset cursor from a previous page's next_before") ), responses( (status = 200, description = "One page of runs, newest first", body = crate::cookie_bot::CookieBotRunPage), (status = 400, description = "limit out of range or malformed cursor"), (status = 401, description = "Unauthorized"), (status = 403, description = "Not signed in, or scope=team from a non-member"), (status = 503, description = "Donut cloud could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "cookie-bot" )] async fn list_cookie_bot_runs( Query(query): Query, ) -> Result, (StatusCode, String)> { crate::cookie_bot::list_runs( query.profile_id.as_deref(), query.scope.as_deref(), query.limit, query.before.as_deref(), ) .await .map(Json) .map_err(cookie_bot_error_response) } // API Handler - Warm a profile now instead of waiting for tonight #[utoipa::path( post, path = "/v1/cookie-bot/runs", request_body = StartCookieBotRunRequest, responses( (status = 202, description = "Run accepted; it keeps executing for minutes after this response", body = crate::cookie_bot::CookieBotRunStarted), (status = 400, description = "A profile the bot cannot run"), (status = 401, description = "Unauthorized"), (status = 402, description = "Plan does not include the cookie bot, or the pooled hours are spent"), (status = 403, description = "This desktop is not signed in to Donut cloud"), (status = 404, description = "Profile not found, or not enrolled"), (status = 409, description = "A run or remote session already holds this profile"), (status = 429, description = "Automation request rate limit exceeded"), (status = 503, description = "No host of that operating system has a free slot"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "cookie-bot" )] async fn start_cookie_bot_run( Json(request): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { if !crate::cloud_auth::CLOUD_AUTH .can_use_browser_automation() .await { return Err((StatusCode::PAYMENT_REQUIRED, String::new())); } cookie_bot_eligible_profile(&request.profile_id)?; let started = crate::cookie_bot::run_now(&request.profile_id, request.max_minutes) .await .map_err(cookie_bot_error_response)?; // 202, not 200: the fleet is still browsing when this returns. Answering 200 // would tell a client the work is done when it has barely started. Ok((StatusCode::ACCEPTED, Json(started))) } // API Handler - Stop a run that is still going #[utoipa::path( delete, path = "/v1/cookie-bot/runs/{run_id}", params( ("run_id" = String, Path, description = "Run ID") ), responses( (status = 200, description = "The run, cancelled (or unchanged if it had already finished)", body = crate::cookie_bot::CookieBotRun), (status = 401, description = "Unauthorized"), (status = 403, description = "This desktop is not signed in to Donut cloud"), (status = 404, description = "No such run for this account"), (status = 429, description = "Automation request rate limit exceeded"), (status = 503, description = "The fleet could not be reached; the run is still live"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "cookie-bot" )] async fn cancel_cookie_bot_run( Path(run_id): Path, ) -> Result, (StatusCode, String)> { // No entitlement gate. A lapsed plan must never be the reason a user cannot // stop something that is spending their hours. crate::cookie_bot::cancel_run(&run_id) .await .map(Json) .map_err(cookie_bot_error_response) } // API Handler - The intensities the server offers #[utoipa::path( get, path = "/v1/cookie-bot/presets", responses( (status = 200, description = "Selectable presets", body = crate::cookie_bot::CookieBotPresetList), (status = 401, description = "Unauthorized"), (status = 403, description = "This desktop is not signed in to Donut cloud"), (status = 503, description = "Donut cloud could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "cookie-bot" )] async fn list_cookie_bot_presets( ) -> Result, (StatusCode, String)> { // Ids and a rough duration only. What a preset expands to — the site // ordering, the dwell model, the scroll and click programme — is the // server's, and stays there. crate::cookie_bot::list_presets() .await .map(Json) .map_err(cookie_bot_error_response) } // API Handler - Who spent what, for a calendar month #[utoipa::path( get, path = "/v1/cookie-bot/usage", params( ("period" = Option, Query, description = "`YYYY-MM`; defaults to the current UTC month") ), responses( (status = 200, description = "Per-member and per-profile spend", body = crate::cookie_bot::CookieBotUsage), (status = 400, description = "Malformed period"), (status = 401, description = "Unauthorized"), (status = 403, description = "Not signed in, or not a member of that team"), (status = 503, description = "Donut cloud could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "cookie-bot" )] async fn get_cookie_bot_usage( Query(query): Query, ) -> Result, (StatusCode, String)> { // Reporting, never enforcement: the pooled budget is spent against by the // server, and this is how an owner finds out where it went. crate::cookie_bot::team_usage(query.period.as_deref()) .await .map(Json) .map_err(cookie_bot_error_response) } // API Handler - Open URL in existing browser // // Works against a profile running here OR one running on the leased fleet: a // remote session is navigated over the same CDP path the automation tools use, // so a caller does not have to know where the browser is. The cross-OS refusal // therefore only applies to a profile that would have to be launched locally. #[utoipa::path( post, path = "/v1/profiles/{id}/open-url", params( ("id" = String, Path, description = "Profile ID") ), request_body = OpenUrlRequest, responses( (status = 200, description = "URL opened successfully, locally or on the profile's remote session"), (status = 400, description = "Cannot open URL with a cross-OS profile that is not running remotely"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), (status = 409, description = "Profile is locked by another team member, or waiting for a finished remote session to be pulled back"), (status = 429, description = "Automation request rate limit exceeded"), (status = 503, description = "The profile lock service could not be reached"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn open_url_in_profile( Path(id): Path, State(state): State, Json(request): Json, ) -> Result { if !crate::cloud_auth::CLOUD_AUTH .can_use_browser_automation() .await { return Err((StatusCode::PAYMENT_REQUIRED, String::new())); } let browser_runner = crate::browser_runner::BrowserRunner::instance(); browser_runner .open_url_with_profile(state.app_handle.clone(), id, request.url) .await .map_err(manager_error_response)?; Ok(StatusCode::OK) } // API Handler - Kill browser process // // Stops the browser wherever it is. A profile open on the leased fleet is ended // through the backend, which is what makes this endpoint mean "stop this // profile" rather than "stop this profile if it happens to be on this machine" — // the latter reported success, killed nothing, and left the session billing to // its two-hour cap. #[utoipa::path( post, path = "/v1/profiles/{id}/kill", params( ("id" = String, Path, description = "Profile ID") ), responses( (status = 204, description = "Browser stopped, locally or on the profile's remote session"), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan required"), (status = 404, description = "Profile not found"), (status = 429, description = "Automation request rate limit exceeded"), (status = 503, description = "The fleet could not be reached; the remote browser is still running"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn kill_profile( Path(id): Path, State(state): State, ) -> Result { // Programmatically launching and stopping profiles is a paid feature; the // run/open-url handlers gate the same way. if !crate::cloud_auth::CLOUD_AUTH .can_use_browser_automation() .await { return Err((StatusCode::PAYMENT_REQUIRED, String::new())); } let profile_manager = ProfileManager::instance(); let profiles = profile_manager .list_profiles() .map_err(manager_error_response)?; let profile = profiles .iter() .find(|p| p.id.to_string() == id) .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; let browser_runner = crate::browser_runner::BrowserRunner::instance(); browser_runner .kill_browser_process(state.app_handle.clone(), profile) .await .map_err(|e| { let message = e.to_string(); // The backend refuses to retire a session it could not stop on the fleet. // Reporting that as a 500 invites a retry loop against a browser that is // still running; 503 says "it is still up, try again". if message.contains("REMOTE_") { (StatusCode::SERVICE_UNAVAILABLE, message) } else { (StatusCode::INTERNAL_SERVER_ERROR, message) } })?; crate::team_lock::release_team_lock_if_needed(profile).await; Ok(StatusCode::NO_CONTENT) } // API Handler - Batch run profiles (paid: browser automation). Mirrors the // single `/run` gate; never breaks the batch on a single profile's failure — // each profile gets its own result entry. #[utoipa::path( post, path = "/v1/profiles/batch/run", request_body = BatchRunRequest, responses( (status = 200, description = "Batch launch completed; inspect per-profile results", body = BatchRunResponse), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 429, description = "Automation request rate limit exceeded"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn batch_run_profiles( State(state): State, Json(request): Json, ) -> Result, StatusCode> { if !crate::cloud_auth::CLOUD_AUTH .can_use_browser_automation() .await { return Err(StatusCode::PAYMENT_REQUIRED); } let headless = request.headless.unwrap_or(false); let profile_manager = ProfileManager::instance(); let profiles = profile_manager .list_profiles() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let mut results = Vec::with_capacity(request.profile_ids.len()); for profile_id in &request.profile_ids { let fail = |error: &str| BatchRunResult { profile_id: profile_id.clone(), ok: false, remote_debugging_port: None, error: Some(error.to_string()), }; let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else { results.push(fail("profile not found")); continue; }; if profile.is_cross_os() { results.push(fail("cross-OS profiles cannot be launched")); continue; } if crate::team_lock::acquire_team_lock_if_needed(profile) .await .is_err() { results.push(fail("profile is locked by another team member")); continue; } let port = match tokio::net::TcpListener::bind("127.0.0.1:0").await { Ok(listener) => match listener.local_addr() { Ok(addr) => addr.port(), Err(_) => { results.push(fail("failed to allocate debugging port")); continue; } }, Err(_) => { results.push(fail("failed to allocate debugging port")); continue; } }; match crate::browser_runner::launch_browser_profile_impl( state.app_handle.clone(), profile.clone(), request.url.clone(), Some(port), headless, true, ) .await { Ok(_) => results.push(BatchRunResult { profile_id: profile_id.clone(), ok: true, remote_debugging_port: Some(port), error: None, }), Err(e) => results.push(fail(&format!("launch failed: {e}"))), } } Ok(Json(BatchRunResponse { results })) } // API Handler - Batch stop profiles (paid: browser automation). #[utoipa::path( post, path = "/v1/profiles/batch/stop", request_body = BatchStopRequest, responses( (status = 200, description = "Batch stop completed; inspect per-profile results", body = BatchStopResponse), (status = 401, description = "Unauthorized"), (status = 402, description = "Active paid plan with browser automation required"), (status = 429, description = "Automation request rate limit exceeded"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn batch_stop_profiles( State(state): State, Json(request): Json, ) -> Result, StatusCode> { if !crate::cloud_auth::CLOUD_AUTH .can_use_browser_automation() .await { return Err(StatusCode::PAYMENT_REQUIRED); } let profile_manager = ProfileManager::instance(); let profiles = profile_manager .list_profiles() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let browser_runner = crate::browser_runner::BrowserRunner::instance(); let mut results = Vec::with_capacity(request.profile_ids.len()); for profile_id in &request.profile_ids { let Some(profile) = profiles.iter().find(|p| p.id.to_string() == *profile_id) else { results.push(BatchStopResult { profile_id: profile_id.clone(), ok: false, error: Some("profile not found".to_string()), }); continue; }; match browser_runner .kill_browser_process(state.app_handle.clone(), profile) .await { Ok(_) => { crate::team_lock::release_team_lock_if_needed(profile).await; results.push(BatchStopResult { profile_id: profile_id.clone(), ok: true, error: None, }); } Err(e) => results.push(BatchStopResult { profile_id: profile_id.clone(), ok: false, error: Some(format!("stop failed: {e}")), }), } } Ok(Json(BatchStopResponse { results })) } // API Handler - Detect importable browser profiles on this machine, or scan a // custom folder. Free: importing is not gated behind browser automation. #[utoipa::path( get, path = "/v1/profiles/import/detect", params( ("folder" = Option, Query, description = "Optional folder to scan instead of the default browser locations. Accepts a single profile dir, a Chromium user-data dir, or a folder holding one profile dir per child.") ), responses( (status = 200, description = "Detected importable profiles", body = DetectedProfilesResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "Folder not found"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn detect_import_profiles( Query(query): Query, State(_state): State, ) -> Result, (StatusCode, String)> { let importer = crate::profile_importer::ProfileImporter::instance(); let profiles = match query.folder.as_deref() { Some(folder) => importer.scan_folder(std::path::Path::new(folder)), None => importer.detect_existing_profiles(), } .map_err(manager_error_response)?; let total = profiles.len(); Ok(Json(DetectedProfilesResponse { profiles, total })) } // API Handler - Bulk-import browser profiles from on-disk profile folders. // Free (parity with create_profile); only fingerprint OS spoofing is Pro. // Items are isolated — one failure doesn't stop the rest. #[utoipa::path( post, path = "/v1/profiles/import", request_body = ImportProfilesRequest, responses( (status = 200, description = "Batch import completed; inspect per-item results", body = crate::profile_importer::ProfileImportBatchResult), (status = 400, description = "No items, or invalid input"), (status = 401, description = "Unauthorized"), (status = 402, description = "Fingerprint OS spoofing requires an active Pro subscription"), (status = 404, description = "Group not found"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "profiles" )] async fn import_profiles_api( State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { let wayfern_config: Option = request .wayfern_config .as_ref() .and_then(|config| serde_json::from_value(config.clone()).ok()); // The Pro gate for fingerprint OS spoofing lives inside import_profiles, so // every surface inherits it; manager_error_response maps the code to 402. let importer = crate::profile_importer::ProfileImporter::instance(); importer .import_profiles( &state.app_handle, request.items, request.group_id, request.duplicate_strategy.unwrap_or_default(), wayfern_config, ) .await .map(Json) .map_err(manager_error_response) } #[utoipa::path( post, path = "/v1/profiles/{id}/cookies/import", params( ("id" = String, Path, description = "Profile ID") ), request_body = ImportCookiesRequest, responses( (status = 200, description = "Cookies imported successfully", body = ImportCookiesResponse), (status = 400, description = "Invalid cookie file or unsupported browser"), (status = 401, description = "Unauthorized"), (status = 404, description = "Profile not found"), (status = 409, description = "Browser is currently running"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "cookies" )] async fn import_profile_cookies( Path(id): Path, State(state): State, Json(request): Json, ) -> Result, StatusCode> { let profile_manager = ProfileManager::instance(); let profiles = profile_manager .list_profiles() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if !profiles.iter().any(|p| p.id.to_string() == id) { return Err(StatusCode::NOT_FOUND); } match crate::cookie_manager::CookieManager::import_cookies( &state.app_handle, &id, &request.content, ) .await { Ok(result) => { if let Some(scheduler) = crate::sync::get_global_scheduler() { if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) { if profile.is_sync_enabled() { let pid = id.clone(); tauri::async_runtime::spawn(async move { scheduler.queue_profile_sync(pid).await; }); } } } Ok(Json(ImportCookiesResponse { cookies_imported: result.cookies_imported, cookies_replaced: result.cookies_replaced, errors: result.errors, })) } Err(e) => { let msg = e.to_lowercase(); if msg.contains("running") { Err(StatusCode::CONFLICT) } else if msg.contains("no valid cookies") || msg.contains("unsupported browser") { Err(StatusCode::BAD_REQUEST) } else { Err(StatusCode::INTERNAL_SERVER_ERROR) } } } } // API Handler - Download Browser #[utoipa::path( post, path = "/v1/browsers/download", request_body = DownloadBrowserRequest, responses( (status = 200, description = "Browser downloaded (or already present)", body = DownloadBrowserResponse), (status = 400, description = "Invalid browser or version not available for download"), (status = 401, description = "Unauthorized"), (status = 409, description = "This browser version is already being downloaded"), (status = 500, description = "Internal server error (e.g. network failure)") ), security( ("bearer_auth" = []) ), tag = "browsers" )] async fn download_browser_api( State(state): State, Json(request): Json, ) -> Result, (StatusCode, String)> { match crate::downloader::download_browser( state.app_handle.clone(), request.browser.clone(), request.version, ) .await { // Echo the version the downloader actually installed, not the requested one. Ok(version) => Ok(Json(DownloadBrowserResponse { browser: request.browser, version, status: "downloaded".to_string(), })), Err(e) => { if e.contains("already being downloaded") { Err((StatusCode::CONFLICT, e)) } else { Err(manager_error_response(e)) } } } } // API Handler - Get Browser Versions #[utoipa::path( get, path = "/v1/browsers/{browser}/versions", params( ("browser" = String, Path, description = "Browser name") ), responses( (status = 200, description = "List of available browser versions", body = Vec), (status = 400, description = "Unsupported browser"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "browsers" )] async fn get_browser_versions( Path(browser): Path, State(_state): State, ) -> Result>, (StatusCode, String)> { let version_manager = crate::browser_version_manager::BrowserVersionManager::instance(); match version_manager .fetch_browser_versions_with_count(&browser, false) .await { Ok(result) => Ok(Json(result.versions)), Err(e) => Err(manager_error_response(e)), } } // API Handler - Check if Browser is Downloaded #[utoipa::path( get, path = "/v1/browsers/{browser}/versions/{version}/downloaded", params( ("browser" = String, Path, description = "Browser name"), ("version" = String, Path, description = "Browser version") ), responses( (status = 200, description = "Browser download status", body = bool), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), security( ("bearer_auth" = []) ), tag = "browsers" )] async fn check_browser_downloaded( Path((browser, version)): Path<(String, String)>, State(_state): State, ) -> Result, StatusCode> { let is_downloaded = crate::downloaded_browsers_registry::is_browser_downloaded(browser, version); Ok(Json(is_downloaded)) } #[cfg(test)] mod tests { use super::*; use crate::profile::types::{BrowserProfile, SyncMode}; fn profile_with(sync_mode: SyncMode, host_os: Option<&str>) -> BrowserProfile { BrowserProfile { id: uuid::Uuid::nil(), name: "p".to_string(), browser: "wayfern".to_string(), version: "latest".to_string(), sync_mode, host_os: host_os.map(|s| s.to_string()), ..Default::default() } } // Cloud sync has been settable through PUT /v1/profiles/{id} but was absent // from every profile RESPONSE, so a caller could turn it on and never // confirm it. A remote-launch caller must be able to see this before it can // decide whether the profile exists in cloud storage at all. // /run-remote exists precisely so a profile can run on a host of ITS OWN OS // when this machine is the wrong one. The gate is cloud sync: a remote host // obtains the profile from donut-sync, so a profile that has never synced // would launch an empty browser and push that emptiness over the real one. #[test] fn remote_launch_requires_cloud_sync() { let err = remote_launch_profile_rules(&profile_with(SyncMode::Disabled, Some("macos"))) .expect_err("a non-synced profile must be refused"); assert!(err.contains("cloud sync"), "unhelpful message: {err}"); assert!( remote_launch_profile_rules(&profile_with(SyncMode::Regular, Some("macos"))).is_ok(), "a synced profile must be allowed" ); } #[test] fn remote_launch_refuses_an_end_to_end_encrypted_profile() { // The key is derived from a passphrase that never leaves this machine, so // a remote host downloads ciphertext, launches Chromium on it, and pushes // the corruption back over the user's real profile. Refusing here also // saves taking the profile lock and a slot on leased hardware for a // session that cannot possibly work. let err = remote_launch_profile_rules(&profile_with(SyncMode::Encrypted, Some("macos"))) .expect_err("an encrypted profile must be refused"); assert!( err.contains("encrypted") && err.contains("Regular"), "the message must say what to change: {err}" ); } #[test] fn remote_launch_requires_a_known_operating_system() { // Without one there is no way to pick a matching host, and guessing would // be the cross-OS mismatch this whole design exists to prevent. assert!(remote_launch_profile_rules(&profile_with(SyncMode::Regular, None)).is_err()); } #[test] fn remote_launch_allows_a_cross_os_profile() { let host = crate::profile::types::get_host_os(); let other = if host == "windows" { "macos" } else { "windows" }; let foreign = profile_with(SyncMode::Regular, Some(other)); assert!( foreign.is_cross_os(), "test setup: profile should be foreign" ); // Local /run refuses this; running it remotely on a host of its own OS is // exactly what /run-remote is for. assert!(remote_launch_profile_rules(&foreign).is_ok()); } #[tokio::test] async fn remote_launch_is_refused_while_the_profile_is_mid_upload() { // The manifest is written last. A host that pulls during the upload gets // files that are about to be replaced described by a manifest that does // not match them, launches Chromium on that, and pushes the result back // over the real profile. let scheduler = std::sync::Arc::new(crate::sync::SyncScheduler::new()); crate::sync::set_global_scheduler(scheduler.clone()); let mut profile = profile_with(SyncMode::Regular, Some("macos")); profile.id = uuid::Uuid::new_v4(); assert!( remote_launch_precondition(&profile).await.is_ok(), "an idle profile must be launchable" ); scheduler.queue_profile_sync(profile.id.to_string()).await; let err = remote_launch_precondition(&profile) .await .expect_err("a profile mid-upload must be refused"); assert!( err.contains("REMOTE_SYNC_IN_PROGRESS"), "the refusal must be a code the frontend can translate: {err}" ); } #[test] fn api_profile_exposes_cloud_sync_state() { let disabled = ApiProfile::from(&profile_with(SyncMode::Disabled, None)); assert_eq!(disabled.sync_mode, "Disabled"); assert!(!disabled.cloud_sync_enabled); let regular = ApiProfile::from(&profile_with(SyncMode::Regular, None)); assert_eq!(regular.sync_mode, "Regular"); assert!(regular.cloud_sync_enabled); let encrypted = ApiProfile::from(&profile_with(SyncMode::Encrypted, None)); assert_eq!(encrypted.sync_mode, "Encrypted"); assert!(encrypted.cloud_sync_enabled); } // A profile must only ever run on its own operating system: Chromium's // on-disk state is OS-specific, so replaying a macOS profile on Windows is a // mismatch no amount of user-agent spoofing repairs. #[test] fn api_profile_reports_its_operating_system() { let host = crate::profile::types::get_host_os(); let same = ApiProfile::from(&profile_with(SyncMode::Regular, Some(&host))); assert_eq!(same.host_os.as_deref(), Some(host.as_str())); assert!(!same.is_cross_os); let other = if host == "windows" { "macos" } else { "windows" }; let foreign = ApiProfile::from(&profile_with(SyncMode::Regular, Some(other))); assert_eq!(foreign.host_os.as_deref(), Some(other)); assert!(foreign.is_cross_os); } #[test] fn api_profile_without_a_recorded_os_is_not_cross_os() { // An older profile that predates host_os must stay locally launchable // rather than being treated as foreign. let unknown = ApiProfile::from(&profile_with(SyncMode::Disabled, None)); assert_eq!(unknown.host_os, None); assert!(!unknown.is_cross_os); } // Removing `browser` from UpdateProfileRequest, and rejecting invalid // `browser` values on create, must NOT make the API reject requests that // carry extra/unknown fields — old clients still send them. serde ignores // unknown fields by default; these tests lock that in so a future // `#[serde(deny_unknown_fields)]` can't silently break compatibility. #[test] fn update_profile_request_ignores_unknown_fields() { // `browser` is no longer a field, plus a wholly unknown field. Both must // be accepted and ignored, not rejected. let json = r#"{"name": "p", "browser": "wayfern", "totally_unknown": 123}"#; let parsed: UpdateProfileRequest = serde_json::from_str(json).expect("unknown fields must be ignored, not rejected"); assert_eq!(parsed.name.as_deref(), Some("p")); } #[test] fn create_profile_request_ignores_unknown_fields() { let json = r#"{"name": "p", "browser": "wayfern", "version": "latest", "future_field": true}"#; let parsed: CreateProfileRequest = serde_json::from_str(json).expect("unknown fields must be ignored, not rejected"); assert_eq!(parsed.browser, "wayfern"); } #[test] fn create_profile_request_allows_omitting_version_and_configs() { // Minimal body: no version, no wayfern_config. Must // deserialize (version resolves to latest-downloaded at the handler; an // absent config triggers fresh-fingerprint generation). let json = r#"{"name": "p", "browser": "wayfern"}"#; let parsed: CreateProfileRequest = serde_json::from_str(json).expect("version and configs are optional"); assert_eq!(parsed.browser, "wayfern"); assert!(parsed.version.is_none()); assert!(parsed.wayfern_config.is_none()); } #[test] fn create_profile_browser_validation_matches_supported_engines() { // The handler rejects anything that isn't a launchable engine; this is the // same predicate it uses, kept in lockstep with MCP's create_profile. let is_valid = |b: &str| b == "wayfern"; assert!(is_valid("wayfern")); assert!(!is_valid("chromium")); assert!(!is_valid("")); } #[test] fn rate_limit_only_classifies_browser_automation_routes() { for path in [ "/v1/profiles/profile-id/run", "/v1/profiles/profile-id/open-url", "/v1/profiles/profile-id/kill", // Launching on leased remote hardware is the most expensive automation // action there is; it went unmetered because `run-remote` is its own // path segment and never matched `run`. "/v1/profiles/profile-id/run-remote", "/v1/profiles/batch/run", "/v1/profiles/batch/stop", // Starting a bot run leases a host for up to two hours and spends the // account's pooled remote-hour budget. "/v1/cookie-bot/runs", ] { assert!( is_automation_request(&Method::POST, path), "automation route was not limited: {path}" ); } // Stopping a remote session is a DELETE, and its handler declares a 429. // Cancelling a bot run reaches the same fleet and is metered the same way. for path in [ "/v1/remote-sessions/session-id", "/v1/cookie-bot/runs/run-id", ] { assert!( is_automation_request(&Method::DELETE, path), "metered stop was not limited: {path}" ); } for (method, path) in [ (Method::GET, "/v1/profiles/profile-id/run"), (Method::POST, "/v1/profiles"), (Method::POST, "/v1/profiles/import"), (Method::GET, "/v1/profiles"), (Method::GET, "/openapi.json"), // Only the single-session DELETE is automation; the collection is not a // route, and a GET of one never launches anything. (Method::DELETE, "/v1/remote-sessions/"), (Method::GET, "/v1/remote-sessions/session-id"), (Method::GET, "/v1/remote-sessions"), // Enrolling a profile writes one row on the server and leases nothing. // Metering it would 429 a client setting up a fleet of profiles, while // the budget that actually protects the hardware is spent per RUN and // enforced server-side however the run was scheduled. (Method::PUT, "/v1/cookie-bot/schedules/profile-id"), (Method::DELETE, "/v1/cookie-bot/schedules/profile-id"), (Method::GET, "/v1/cookie-bot/schedules"), (Method::GET, "/v1/cookie-bot/runs"), (Method::GET, "/v1/cookie-bot/usage"), (Method::GET, "/v1/remote-hours"), // A run id is required; the collection DELETE is not a route. (Method::DELETE, "/v1/cookie-bot/runs/"), ] { assert!( !is_automation_request(&method, path), "free or non-mutating route was limited: {method} {path}" ); } } // The bot exists only on the leased fleet. Every write surface resolves the // profile through `bot_precondition` first, so there is no request shape on // this server that points it at a profile which could never make the round // trip to a remote host and back. #[test] fn a_profile_the_bot_could_never_run_is_refused_before_the_cloud_is_asked() { let mut local_only = profile_with(SyncMode::Disabled, Some("macos")); local_only.proxy_id = Some("proxy-1".to_string()); assert!( crate::cookie_bot::bot_precondition(&local_only).is_err(), "a profile with no cloud copy has nothing for a host to open" ); let mut encrypted = profile_with(SyncMode::Encrypted, Some("macos")); encrypted.proxy_id = Some("proxy-1".to_string()); assert!( crate::cookie_bot::bot_precondition(&encrypted).is_err(), "a host cannot decrypt a profile whose key never leaves this machine" ); let mut datacenter_egress = profile_with(SyncMode::Regular, Some("macos")); datacenter_egress.proxy_id = None; datacenter_egress.vpn_id = None; assert!( crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(), "hours of traffic from a hosting ASN damages the identity being warmed" ); let mut eligible = profile_with(SyncMode::Regular, Some("macos")); eligible.proxy_id = Some("proxy-1".to_string()); assert!(crate::cookie_bot::bot_precondition(&eligible).is_ok()); } #[test] fn a_cloud_401_is_not_reported_as_the_callers_own_token_being_wrong() { // The caller's bearer token was accepted — the auth middleware ran. It is // THIS desktop that has no cloud session, and answering 401 would send an // automation client off to rotate a token that is perfectly good. assert_eq!( cloud_failure_status(401, crate::cloud_errors::NOT_SIGNED_IN), StatusCode::FORBIDDEN ); } #[test] fn the_clouds_403_splits_into_the_two_things_it_means() { // "Your plan does not include this" is the 402 this API uses everywhere // else; "you are not in that team" is not something a payment fixes. assert_eq!( cloud_failure_status(403, "COOKIE_BOT_NOT_ENTITLED"), StatusCode::PAYMENT_REQUIRED ); assert_eq!( cloud_failure_status(403, "NOT_TEAM_MEMBER"), StatusCode::FORBIDDEN ); } #[test] fn spending_the_pooled_hours_is_a_payment_problem_not_a_server_fault() { // It arrives as a 403 with a code. Reporting it as a plain forbidden would // hide the one thing the user can act on. assert_eq!( cloud_failure_status(403, "REMOTE_HOURS_EXHAUSTED"), StatusCode::PAYMENT_REQUIRED ); } #[test] fn a_busy_or_unreachable_fleet_is_never_reported_as_broken() { // 503 means "try again shortly". Turning it into a 500 tells the user // their automation is broken when nothing is. assert_eq!( cloud_failure_status(503, crate::cloud_errors::NO_CAPACITY), StatusCode::SERVICE_UNAVAILABLE ); assert_eq!( cloud_failure_status(0, crate::cloud_errors::UNREACHABLE), StatusCode::SERVICE_UNAVAILABLE ); assert_eq!( cloud_failure_status(429, crate::cloud_errors::RATE_LIMITED), StatusCode::TOO_MANY_REQUESTS ); } #[test] fn a_missing_schedule_and_a_missing_run_both_stay_a_404() { assert_eq!( cloud_failure_status(404, "COOKIE_BOT_NOT_ENROLLED"), StatusCode::NOT_FOUND ); assert_eq!( cloud_failure_status(404, "COOKIE_BOT_RUN_NOT_FOUND"), StatusCode::NOT_FOUND ); assert_eq!( cloud_failure_status(409, "COOKIE_BOT_SCHEDULE_CONFLICT"), StatusCode::CONFLICT ); assert_eq!( cloud_failure_status(400, "COOKIE_BOT_INVALID_SCHEDULE"), StatusCode::BAD_REQUEST ); } #[test] fn a_read_of_a_session_that_does_not_exist_is_a_404_not_a_500() { // The launch mapping folds every unrecognised status into 500, which for a // read means "no such session" is indistinguishable from "our backend // fell over". let missing = crate::remote_session::classify_backend_status(404, r#"{"code":"REMOTE_SESSION_NOT_FOUND"}"#); let (status, body) = remote_session_read_response(missing); assert_eq!(status, StatusCode::NOT_FOUND); assert_eq!(error_code_of(&body), "REMOTE_SESSION_NOT_FOUND"); } #[test] fn a_read_answers_with_a_code_rather_than_the_backends_english() { let busy = crate::remote_session::classify_backend_status(503, "no macos host has a free slot"); let (status, body) = remote_session_read_response(busy); assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(error_code_of(&body), crate::cloud_errors::NO_CAPACITY); } // Axum panics when two handlers claim one path, and the router is only built // when the API server is switched on — so a conflict introduced here would // ship as an app that dies the first time a user enables the API. Both // `/v1/remote-sessions/{id}` and `/v1/cookie-bot/schedules/{profile_id}` now // carry several methods, which is exactly the shape that trips it. #[test] fn every_v1_route_can_be_registered_together() { let _router: Router = build_v1_router(); } fn schema_required(spec: &serde_json::Value, schema: &str) -> Vec { spec["components"]["schemas"][schema]["required"] .as_array() .map(|a| { a.iter() .filter_map(|v| v.as_str().map(str::to_string)) .collect() }) .unwrap_or_default() } // `#[schema(value_type = Object)]` on an `Option` erases the optionality // and marks the field required in the served spec; these fields must stay // optional so generated clients aren't forced to send them. #[test] fn openapi_optional_fields_are_not_required() { let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); let create_profile = schema_required(&spec, "CreateProfileRequest"); assert!( !create_profile.iter().any(|f| f == "wayfern_config"), "wayfern_config must be optional, required list: {create_profile:?}" ); // `ApiProfile` is the response body of every profile-returning route, so a // wrongly-required `group_id` makes generated clients assume a group is // always present on an ungrouped profile. let api_profile = schema_required(&spec, "ApiProfile"); assert!( !api_profile.iter().any(|f| f == "group_id"), "group_id must be optional on ApiProfile, required list: {api_profile:?}" ); assert_eq!( spec["components"]["schemas"]["ApiProfile"]["properties"]["group_id"]["type"], serde_json::json!(["string", "null"]), "group_id must be a nullable string, not a free-form object" ); let update_profile = schema_required(&spec, "UpdateProfileRequest"); assert!( !update_profile.iter().any(|f| f == "group_id"), "group_id must be optional, required list: {update_profile:?}" ); let update_proxy = schema_required(&spec, "UpdateProxyRequest"); assert!( !update_proxy.iter().any(|f| f == "proxy_settings"), "proxy_settings must be optional on update, required list: {update_proxy:?}" ); let proxy_settings = schema_required(&spec, "ProxySettings"); for field in ["username", "password", "vless_uri"] { assert!( !proxy_settings.iter().any(|candidate| candidate == field), "{field} must be optional in proxy settings, required list: {proxy_settings:?}" ); assert!( spec["components"]["schemas"]["ProxySettings"]["properties"][field].is_object(), "{field} must be present in the served ProxySettings schema" ); } let import_profiles = schema_required(&spec, "ImportProfilesRequest"); for field in ["group_id", "duplicate_strategy", "wayfern_config"] { assert!( !import_profiles.iter().any(|f| f == field), "{field} must be optional on profile import, required list: {import_profiles:?}" ); } let import_item = schema_required(&spec, "ImportProfileItem"); for field in ["proxy_id", "vpn_id", "browser_type"] { assert!( !import_item.iter().any(|f| f == field), "{field} must be optional on import items, required list: {import_item:?}" ); } // A remote launch with no URL just opens the browser; forcing generated // clients to send one would make the common case the awkward one. let run_remote = schema_required(&spec, "RunRemoteRequest"); assert!( !run_remote.iter().any(|f| f == "url"), "url must be optional on a remote launch, required list: {run_remote:?}" ); // A run-now with no cap inherits the schedule's own. let start_run = schema_required(&spec, "StartCookieBotRunRequest"); assert!( start_run.iter().any(|f| f == "profile_id"), "profile_id is the one thing a run cannot infer, required list: {start_run:?}" ); assert!( !start_run.iter().any(|f| f == "max_minutes"), "max_minutes must be optional, required list: {start_run:?}" ); // This machine already knows the profile's name and operating system, and // a caller-supplied platform that disagrees is refused rather than // honoured — so neither may be marked required. let set_schedule = schema_required(&spec, "SetCookieBotScheduleRequest"); for field in [ "profile_name", "platform", "jitter_seconds", "sites", "acknowledge_conflict", ] { assert!( !set_schedule.iter().any(|f| f == field), "{field} must be optional when enrolling, required list: {set_schedule:?}" ); } // A freshly created enrolment has never run, so every one of these is // absent on the first read. Marking them required would make a generated // client reject the response it gets immediately after enrolling. let schedule = schema_required(&spec, "CookieBotSchedule"); for field in ["next_run_at", "last_run_at", "last_run_id", "updated_at"] { assert!( !schedule.iter().any(|f| f == field), "{field} must be optional on a schedule, required list: {schedule:?}" ); } let run = schema_required(&spec, "CookieBotRun"); for field in ["started_at", "ended_at", "outcome_code", "session_id"] { assert!( !run.iter().any(|f| f == field), "{field} must be optional on a run, required list: {run:?}" ); } // Only `session_id` and `status` are guaranteed while a session is still // provisioning; everything else arrives as the session progresses. let session = schema_required(&spec, "RemoteSessionState"); for field in [ "profile_id", "platform", "kind", "run_id", "started_at", "ready_at", "closed_at", "close_reason", "billed_seconds", ] { assert!( !session.iter().any(|f| f == field), "{field} must be optional on a session, required list: {session:?}" ); } // The route predates the pooled budget and returned only two keys. A // deployment that has not rolled forward must still satisfy the spec. let quota = schema_required(&spec, "RemoteHoursQuota"); for field in ["members", "breakdown", "scope", "team_id", "seats"] { assert!( !quota.iter().any(|f| f == field), "{field} must be optional on the quota, required list: {quota:?}" ); } } #[test] fn import_profiles_request_allows_minimal_body() { // Only items with source_path + new_profile_name are required; everything // else has defaults. let json = r#"{"items": [{"source_path": "/tmp/p", "new_profile_name": "Imported"}]}"#; let parsed: ImportProfilesRequest = serde_json::from_str(json).expect("minimal import body must deserialize"); assert_eq!(parsed.items.len(), 1); assert!(parsed.group_id.is_none()); assert!(parsed.duplicate_strategy.is_none()); assert_eq!(parsed.items[0].browser_type, "chromium"); } // The served /openapi.json comes from the hand-maintained ApiDoc `paths(...)` // list, not from the router — endpoints registered on the router but missing // from ApiDoc silently disappear from the spec. Lock in the ones that were // once dropped, and that removed endpoints stay gone. #[test] fn a_profile_held_elsewhere_is_a_conflict_not_a_bad_request() { // These four refusals all mean "come back in a moment". Answering 400 tells // an automation client its request was malformed and to stop retrying, and // that is what every one of them did before they had codes at all. for code in [ "PROFILE_RUNNING_REMOTELY", "PROFILE_REMOTE_SYNC_PENDING", "PROFILE_LOCKED_BY_MEMBER", "PROFILE_LOCKED_ELSEWHERE", ] { let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string()); assert_eq!(status, StatusCode::CONFLICT, "{code} must be a 409"); assert!(body.contains(code), "{code} must reach the caller"); } } #[test] fn an_unreachable_lock_service_is_not_the_callers_fault() { let (status, _) = manager_error_response(serde_json::json!({ "code": "PROFILE_LOCK_UNAVAILABLE" }).to_string()); assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); } #[test] fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() { // Without this route `run-remote` hands back a session id that nothing // outside the app can use: the fleet relay accepts only the user's cloud // credential, which an API consumer does not have and must not be given. // A Playwright user reads the spec to find this, so it has to be in it. let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); let operation = &spec["paths"]["/v1/remote-sessions/{id}/cdp"]["get"]; assert!( operation.is_object(), "the CDP attach endpoint must be in the served spec" ); assert!( operation["responses"].get("101").is_some(), "a WebSocket endpoint must document its upgrade" ); assert_eq!(operation["tags"][0], "remote-sessions"); } #[test] fn a_cdp_attach_failure_is_not_reported_as_a_broken_relay() { // A CDP client retries a 502 and gives up on a 404. Reporting "this session // is not up yet" as a gateway failure sends it into a loop against a // session that is doing exactly what it should. use crate::cdp_target::CdpError; assert_eq!( cdp_error_response(CdpError::NotDrivable("provisioning".into())).0, StatusCode::NOT_FOUND ); assert_eq!( cdp_error_response(CdpError::Unauthorized("no token".into())).0, StatusCode::UNAUTHORIZED ); assert_eq!( cdp_error_response(CdpError::Unreachable("dns".into())).0, StatusCode::BAD_GATEWAY ); } #[test] fn the_kill_route_documents_that_it_can_fail_to_stop_a_remote_browser() { // The backend refuses to retire a session it could not stop on the fleet, so // stopping can genuinely fail with the browser still running. A spec that // only lists 204 tells a client that never happens. let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); let responses = &spec["paths"]["/v1/profiles/{id}/kill"]["post"]["responses"]; assert!( responses.get("503").is_some(), "kill must document that the fleet may be unreachable" ); } #[test] fn the_local_launch_routes_document_their_conflict() { // A profile waiting on a finished remote session refuses a local launch. // Undocumented, that reaches an integrator as an unexplained 409. let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); for path in ["/v1/profiles/{id}/run", "/v1/profiles/{id}/open-url"] { let responses = &spec["paths"][path]["post"]["responses"]; assert!( responses.get("409").is_some(), "{path} must document its conflict" ); } } #[test] fn openapi_spec_covers_registered_routes() { let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); let paths = spec["paths"].as_object().expect("paths object"); for path in [ "/v1/vpns/{id}/export", "/v1/extensions", "/v1/extension-groups", "/v1/extensions/{id}", "/v1/extension-groups/{id}", "/v1/profiles/import", "/v1/profiles/import/detect", "/v1/proxies/import", // The whole remote-execution surface was registered on the router but // absent from ApiDoc, so it never appeared in the served spec. This list // is a hand-maintained allowlist, which is exactly why that drift went // unnoticed — every route added here must also be added below. "/v1/profiles/{id}/run-remote", "/v1/profiles/{id}/cloud-sync", "/v1/remote-sessions/{id}", // Remote-session observability and the whole cookie-bot surface. Same // hazard, so the same guard: registered on the router is not registered // in the spec, and the spec is what an automation client is written from. "/v1/remote-sessions", "/v1/remote-hours", "/v1/cookie-bot/schedules", "/v1/cookie-bot/schedules/{profile_id}", "/v1/cookie-bot/conflicts", "/v1/cookie-bot/runs", "/v1/cookie-bot/runs/{run_id}", "/v1/cookie-bot/presets", "/v1/cookie-bot/usage", ] { assert!(paths.contains_key(path), "missing from ApiDoc: {path}"); } // Every method of every shared path must survive. Registering two handlers // on one path in separate `routes!` calls silently drops one of them, and // the spec is where that shows up. for (path, method) in [ ("/v1/remote-sessions/{id}", "get"), ("/v1/remote-sessions/{id}", "delete"), ("/v1/cookie-bot/schedules/{profile_id}", "get"), ("/v1/cookie-bot/schedules/{profile_id}", "put"), ("/v1/cookie-bot/schedules/{profile_id}", "delete"), ("/v1/cookie-bot/runs", "get"), ("/v1/cookie-bot/runs", "post"), ("/v1/cookie-bot/runs/{run_id}", "delete"), ] { assert!( paths[path].get(method).is_some(), "missing from ApiDoc: {method} {path}" ); } // Every cookie-bot operation must be findable by tag, or it is invisible in // a generated client's grouping even though the path exists. for (path, method) in [ ("/v1/cookie-bot/schedules", "get"), ("/v1/cookie-bot/schedules/{profile_id}", "put"), ("/v1/cookie-bot/runs", "post"), ("/v1/cookie-bot/usage", "get"), ] { let tags = paths[path][method]["tags"] .as_array() .unwrap_or_else(|| panic!("{method} {path} has no tags")); assert!( tags.iter().any(|tag| tag == "cookie-bot"), "{method} {path} is not tagged cookie-bot: {tags:?}" ); } // A bot run is accepted, not completed: the fleet browses for minutes // after the response. A 200 here would be a lie the client acts on. assert!( paths["/v1/cookie-bot/runs"]["post"]["responses"] .get("202") .is_some(), "starting a bot run must declare 202 Accepted" ); assert!( !paths.keys().any(|p| p.contains("wayfern-token")), "wayfern-token endpoints were removed and must stay out of the spec" ); // A path with a body that resolves to nothing is worse than a missing // path: a generator emits a client for it and the response type is empty. // These live in other modules, so `components(schemas(...))` is the only // thing pulling them in. for schema in [ "RemoteSessionState", "ApiRemoteSessionsResponse", "SetCookieBotScheduleRequest", "StartCookieBotRunRequest", "CookieBotSchedule", "CookieBotScheduleList", "CookieBotScheduleSaved", "CookieBotScheduleDeleted", "CookieBotConflict", "CookieBotConflictCheck", "CookieBotRun", "CookieBotRunPage", "CookieBotRunStarted", "CookieBotPreset", "CookieBotPresetList", "CookieBotUsage", "CookieBotUsageMember", "CookieBotUsageProfile", "RemoteHoursQuota", "RemoteHoursMember", "RemoteHoursBreakdown", ] { assert!( spec["components"]["schemas"][schema]["properties"].is_object(), "schema is missing from the served spec: {schema}" ); } // A response body declared as a path outside this module must resolve to // the component that path registered, not to a dangling or inlined name. for (path, method, status, schema) in [ ( "/v1/cookie-bot/schedules", "get", "200", "CookieBotScheduleList", ), ("/v1/cookie-bot/runs", "post", "202", "CookieBotRunStarted"), ( "/v1/cookie-bot/runs/{run_id}", "delete", "200", "CookieBotRun", ), ( "/v1/remote-sessions/{id}", "get", "200", "RemoteSessionState", ), ("/v1/remote-hours", "get", "200", "RemoteHoursQuota"), ] { let reference = &paths[path][method]["responses"][status]["content"]["application/json"]["schema"]["$ref"]; assert_eq!( reference.as_str(), Some(format!("#/components/schemas/{schema}").as_str()), "{method} {path} {status} does not reference {schema}: {reference:?}" ); } // The presets a client may choose from must never carry the behaviour they // expand to. A site list, a dwell range or a step programme appearing here // would mean the browsing model had leaked out of the server. let preset_properties = spec["components"]["schemas"]["CookieBotPreset"]["properties"] .as_object() .expect("preset properties"); for leaked in [ "sites", "dwell", "dwell_seconds", "steps", "actions", "corpus", ] { assert!( !preset_properties.contains_key(leaked), "the browsing model leaked into the client contract: {leaked}" ); } for path in [ "/v1/profiles/{id}/run", "/v1/profiles/{id}/open-url", "/v1/profiles/{id}/kill", "/v1/profiles/{id}/run-remote", "/v1/profiles/batch/run", "/v1/profiles/batch/stop", ] { assert!( paths[path]["post"]["responses"].get("429").is_some(), "automation route is missing its 429 response: {path}" ); } assert!( paths["/v1/cookie-bot/runs"]["post"]["responses"] .get("429") .is_some(), "starting a bot run is metered and must declare its 429" ); // The automation routes that are not POSTs. Both declared a 429 that // `is_automation_request` could never produce, because that function // returned early for every non-POST method. for path in ["/v1/remote-sessions/{id}", "/v1/cookie-bot/runs/{run_id}"] { assert!( paths[path]["delete"]["responses"].get("429").is_some(), "metered stop route is missing its 429 response: {path}" ); } // Schedule writes are configuration, not automation. Declaring a 429 they // can never return would send a client building retry logic for a status // it will never see. for method in ["put", "delete"] { assert!( paths["/v1/cookie-bot/schedules/{profile_id}"][method]["responses"] .get("429") .is_none(), "a schedule write must not declare a 429: {method}" ); } } }