//! HTTP transport and high-level API with transparent end-to-end encryption. //! //! This module provides [`SyncKitClient`], the primary interface to the MNW //! SyncKit server. All encryption and decryption happens transparently inside //! the client -- callers work with plaintext [`ChangeEntry`] values and never //! handle ciphertext directly. //! //! ## Method groups //! //! - **Authentication**: [`authenticate`](SyncKitClient::authenticate) (email/password), //! [`authenticate_with_code`](SyncKitClient::authenticate_with_code) (OAuth2 PKCE), //! [`restore_session`](SyncKitClient::restore_session), [`clear_session`](SyncKitClient::clear_session). //! - **Encryption setup**: [`setup_encryption_new`](SyncKitClient::setup_encryption_new) (first device), //! [`setup_encryption_existing`](SyncKitClient::setup_encryption_existing) (subsequent devices), //! [`try_load_key_from_keychain`](SyncKitClient::try_load_key_from_keychain), //! [`change_password`](SyncKitClient::change_password). //! - **Device management**: [`register_device`](SyncKitClient::register_device), //! [`list_devices`](SyncKitClient::list_devices). //! - **Push/Pull sync**: [`push`](SyncKitClient::push), [`pull`](SyncKitClient::pull), //! [`status`](SyncKitClient::status). //! - **Blob storage**: [`blob_upload_url`](SyncKitClient::blob_upload_url), //! [`blob_upload`](SyncKitClient::blob_upload), [`blob_confirm`](SyncKitClient::blob_confirm), //! [`blob_download_url`](SyncKitClient::blob_download_url), //! [`blob_download`](SyncKitClient::blob_download). //! //! ## Internal state //! //! The client holds two `RwLock`-wrapped fields: the authenticated session //! (JWT token, user ID, app ID) and the 256-bit master encryption key. Both //! start as `None` and are populated by the authentication and encryption //! setup methods respectively. //! //! ## Thread safety //! //! `SyncKitClient` is `Send + Sync` and safe to share via `Arc`. All public //! methods take `&self`, acquiring the internal locks only briefly to read //! or update state. The locks are never held across `.await` points. //! //! ## Retry strategy //! //! All HTTP operations retry transient failures (network errors, 5xx, //! 429) up to 3 times with exponential backoff (1s, 2s, 4s). Client errors //! (4xx except 429) are permanent and returned immediately. //! //! ## Token handling //! //! The client decodes the JWT `exp` claim (without signature verification) //! and applies a 30-second expiry buffer. If the token is about to expire, //! `require_token()` returns [`SyncKitError::TokenExpired`] so the caller //! can re-authenticate before the request fails on the server. mod auth; mod blob; mod encryption; pub(crate) mod helpers; mod ota; mod rotation; mod subscribe; pub mod subscription; mod sync; pub use ota::{OtaArtifactUpload, OtaManifest, OtaRelease}; pub use subscribe::SyncNotifyStream; use parking_lot::RwLock; use reqwest::Client; use std::sync::Arc; use std::time::Duration; use uuid::Uuid; use crate::{ crypto, error::{Result, SyncKitError}, }; /// Maximum number of retry attempts for transient failures. const MAX_RETRIES: u32 = 3; /// Base delay for exponential backoff (1s, 2s, 4s). const BASE_DELAY: Duration = Duration::from_secs(1); /// Seconds before actual expiry to consider the token expired. /// Avoids sending a request with a token that expires mid-flight. const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30; /// Configuration for the SyncKit client. #[derive(Debug, Clone)] pub struct SyncKitConfig { /// Base URL of the MNW server (e.g. "https://makenot.work"). pub server_url: String, /// App API key (obtained from MNW dashboard). pub api_key: String, } /// Pre-built endpoint URLs, computed once at client construction. struct Endpoints { auth: String, oauth_token: String, devices: String, push: String, pull: String, subscribe: String, status: String, keys: String, blobs_upload: String, blobs_confirm: String, blobs_download: String, subscription: String, subscription_checkout: String, subscription_quote: String, subscription_storage_cap: String, app_pricing: String, account: String, } impl Endpoints { fn new(base: &str) -> Self { let base = base.trim_end_matches('/'); Self { auth: format!("{base}/api/v1/sync/auth"), oauth_token: format!("{base}/oauth/token"), devices: format!("{base}/api/v1/sync/devices"), push: format!("{base}/api/v1/sync/push"), pull: format!("{base}/api/v1/sync/pull"), subscribe: format!("{base}/api/v1/sync/subscribe"), status: format!("{base}/api/v1/sync/status"), keys: format!("{base}/api/v1/sync/keys"), blobs_upload: format!("{base}/api/v1/sync/blobs/upload"), blobs_confirm: format!("{base}/api/v1/sync/blobs/confirm"), blobs_download: format!("{base}/api/v1/sync/blobs/download"), subscription: format!("{base}/api/v1/sync/subscription"), subscription_checkout: format!("{base}/api/v1/sync/subscription/checkout"), subscription_quote: format!("{base}/api/v1/sync/subscription/quote"), subscription_storage_cap: format!("{base}/api/v1/sync/subscription/storage-cap"), app_pricing: format!("{base}/api/v1/sync/app/pricing"), account: format!("{base}/api/v1/sync/account"), } } } /// Session state obtained after authentication. struct Session { token: Arc, /// Cached `exp` claim from the JWT, extracted once at session creation. token_exp: Option, user_id: Uuid, app_id: Uuid, } /// Public session info returned by `session_info()`. pub struct SessionInfo { /// The JWT bearer token for API requests (shared ref-counted to avoid cloning). pub token: Arc, /// The authenticated user's UUID. pub user_id: Uuid, /// The SyncKit app UUID this session belongs to. pub app_id: Uuid, } /// Info about a pending key rotation, cached from `GET /keys`. pub(crate) struct PendingKeyState { pub key: crypto::ZeroizeOnDrop, pub key_id: i32, } /// The SyncKit client. Handles authentication, encryption, and HTTP transport. pub struct SyncKitClient { config: SyncKitConfig, http: Client, endpoints: Endpoints, session: RwLock>, master_key: RwLock>, /// The key_id associated with the current master_key. Default 1 (pre-rotation). master_key_id: RwLock, /// Pending rotation key, if a rotation is in progress. pending_key: RwLock>, } impl SyncKitClient { /// Create a new client with the given configuration. pub fn new(config: SyncKitConfig) -> Self { let http = Client::builder() .timeout(Duration::from_secs(30)) .connect_timeout(Duration::from_secs(10)) .pool_max_idle_per_host(5) .pool_idle_timeout(Duration::from_secs(90)) .build() .expect("failed to build HTTP client"); let endpoints = Endpoints::new(&config.server_url); Self { config, http, endpoints, session: RwLock::new(None), master_key: RwLock::new(None), master_key_id: RwLock::new(1), pending_key: RwLock::new(None), } } /// Create a new client with a custom HTTP client (for testing with custom timeouts). #[doc(hidden)] pub fn with_http_client(config: SyncKitConfig, http: Client) -> Self { let endpoints = Endpoints::new(&config.server_url); Self { config, http, endpoints, session: RwLock::new(None), master_key: RwLock::new(None), master_key_id: RwLock::new(1), pending_key: RwLock::new(None), } } /// Returns the client configuration. pub fn config(&self) -> &SyncKitConfig { &self.config } /// Returns whether the master encryption key is loaded and ready. pub fn has_master_key(&self) -> bool { self.master_key.read().is_some() } /// Returns the current session info, if authenticated. pub fn session_info(&self) -> Option { let guard = self.session.read(); guard.as_ref().map(|s| SessionInfo { token: Arc::clone(&s.token), user_id: s.user_id, app_id: s.app_id, }) } /// Set a raw 256-bit master key directly (for testing without Argon2 overhead). #[doc(hidden)] pub fn set_master_key_raw(&self, key: [u8; 32]) { *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key)); } // ── Internal helpers ── /// Extract the bearer token from the current session. /// /// Returns `NotAuthenticated` if no session exists. Also checks token /// expiry and returns `TokenExpired` if the JWT `exp` claim is within /// 30 seconds of the current time. pub(crate) fn require_token(&self) -> Result> { let guard = self.session.read(); let session = guard.as_ref().ok_or(SyncKitError::NotAuthenticated)?; if let Some(exp) = session.token_exp { let now = chrono::Utc::now().timestamp(); if now >= exp - TOKEN_EXPIRY_BUFFER_SECS { return Err(SyncKitError::TokenExpired); } } Ok(Arc::clone(&session.token)) } /// Extract `(app_id, user_id)` from the current session. /// /// Returns `NotAuthenticated` if no session exists. pub(crate) fn require_session_ids(&self) -> Result<(Uuid, Uuid)> { let guard = self.session.read(); guard .as_ref() .map(|s| (s.app_id, s.user_id)) .ok_or(SyncKitError::NotAuthenticated) } /// Return a copy of the 256-bit master encryption key, wrapped in /// `ZeroizeOnDrop` so the caller never holds a bare `[u8; 32]`. /// /// Returns `NoMasterKey` if encryption has not been set up yet. pub(crate) fn require_master_key(&self) -> Result { let guard = self.master_key.read(); guard .as_ref() .map(|k| crypto::ZeroizeOnDrop(**k)) .ok_or(SyncKitError::NoMasterKey) } } /// Validate an API key against a SyncKit server without constructing a full client. /// /// Returns the app name on success, or an error if the key is invalid or the server /// is unreachable. This is intended for setup UIs that need to verify a key before /// saving it. pub async fn validate_api_key(server_url: &str, api_key: &str) -> Result { let url = format!("{server_url}/api/v1/sync/validate-app"); let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build()?; let resp = http .post(&url) .header("content-type", "application/json") .body(serde_json::to_vec(&serde_json::json!({"api_key": api_key}))?) .send() .await?; let status = resp.status().as_u16(); if status == 401 { return Err(SyncKitError::Server { status: 401, message: "Invalid API key".to_string(), retry_after_secs: None, }); } let resp = helpers::check_response(resp).await?; #[derive(serde::Deserialize)] struct ValidateResponse { app_name: String, } let body: ValidateResponse = resp.json().await?; Ok(body.app_name) } #[cfg(test)] mod tests { use super::*; use base64::Engine; fn test_config() -> SyncKitConfig { SyncKitConfig { server_url: "https://example.com".to_string(), api_key: "test-api-key-123".to_string(), } } fn test_ids() -> (Uuid, Uuid) { ( Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(), Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(), ) } // ── SyncKitClient::new() construction ── #[test] fn new_client_starts_unauthenticated() { let client = SyncKitClient::new(test_config()); assert!(client.session_info().is_none()); } #[test] fn new_client_has_no_master_key() { let client = SyncKitClient::new(test_config()); assert!(!client.has_master_key()); } #[test] fn config_returns_provided_values() { let client = SyncKitClient::new(test_config()); assert_eq!(client.config().server_url, "https://example.com"); assert_eq!(client.config().api_key, "test-api-key-123"); } // ── SyncKitConfig ── #[test] fn config_clone() { let config = test_config(); let cloned = config.clone(); assert_eq!(cloned.server_url, config.server_url); assert_eq!(cloned.api_key, config.api_key); } #[test] fn config_debug() { let config = test_config(); let debug = format!("{:?}", config); assert!(debug.contains("SyncKitConfig")); assert!(debug.contains("example.com")); } // ── require_token ── #[test] fn require_token_fails_without_session() { let client = SyncKitClient::new(test_config()); let err = client.require_token().unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } #[test] fn require_token_succeeds_with_session() { let client = SyncKitClient::new(test_config()); let (app_id, user_id) = test_ids(); client.restore_session("my-token", user_id, app_id); let token = client.require_token().unwrap(); assert_eq!(*token, "my-token"); } // ── require_session_ids ── #[test] fn require_session_ids_fails_without_session() { let client = SyncKitClient::new(test_config()); let err = client.require_session_ids().unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } #[test] fn require_session_ids_returns_correct_ids() { let client = SyncKitClient::new(test_config()); let (app_id, user_id) = test_ids(); client.restore_session("token", user_id, app_id); let (returned_app, returned_user) = client.require_session_ids().unwrap(); assert_eq!(returned_app, app_id); assert_eq!(returned_user, user_id); } // ── require_master_key ── #[test] fn require_master_key_fails_without_key() { let client = SyncKitClient::new(test_config()); let err = client.require_master_key().unwrap_err(); assert!(matches!(err, SyncKitError::NoMasterKey)); } #[test] fn require_master_key_succeeds_after_set() { let client = SyncKitClient::new(test_config()); let test_key = [42u8; 32]; *client.master_key.write() = Some(crypto::ZeroizeOnDrop(test_key)); let key = client.require_master_key().unwrap(); assert_eq!(*key, test_key); } // ── has_master_key ── #[test] fn has_master_key_false_initially() { let client = SyncKitClient::new(test_config()); assert!(!client.has_master_key()); } #[test] fn has_master_key_true_after_set() { let client = SyncKitClient::new(test_config()); *client.master_key.write() = Some(crypto::ZeroizeOnDrop([1u8; 32])); assert!(client.has_master_key()); } // ── set_master_key_raw ── #[test] fn set_master_key_raw_makes_key_available() { let client = SyncKitClient::new(test_config()); assert!(!client.has_master_key()); let key = [99u8; 32]; client.set_master_key_raw(key); assert!(client.has_master_key()); assert_eq!(*client.require_master_key().unwrap(), key); } #[test] fn set_master_key_raw_overwrites_previous() { let client = SyncKitClient::new(test_config()); let key1 = [1u8; 32]; let key2 = [2u8; 32]; client.set_master_key_raw(key1); assert_eq!(*client.require_master_key().unwrap(), key1); client.set_master_key_raw(key2); assert_eq!(*client.require_master_key().unwrap(), key2); } // ── with_http_client constructor ── #[test] fn with_http_client_starts_unauthenticated() { let http = Client::builder() .timeout(Duration::from_millis(100)) .build() .unwrap(); let client = SyncKitClient::with_http_client(test_config(), http); assert!(client.session_info().is_none()); assert!(!client.has_master_key()); } // ── Send + Sync assertions ── #[test] fn client_is_send_and_sync() { fn assert_send_sync() {} assert_send_sync::(); } // ── Config edge case ── #[test] fn config_with_trailing_slash_url() { let config = SyncKitConfig { server_url: "https://example.com/".to_string(), api_key: "key".to_string(), }; let client = SyncKitClient::new(config); assert_eq!(client.config().server_url, "https://example.com/"); // Endpoints should not have double slashes assert_eq!(client.endpoints.auth, "https://example.com/api/v1/sync/auth"); } // ── SyncKitError Display ── #[test] fn error_display_not_authenticated() { let err = SyncKitError::NotAuthenticated; assert!(err.to_string().contains("Not authenticated")); } #[test] fn error_display_no_master_key() { let err = SyncKitError::NoMasterKey; assert!(err.to_string().contains("Encryption not initialized")); } #[test] fn error_display_server() { let err = SyncKitError::Server { status: 500, message: "boom".to_string(), retry_after_secs: None }; let msg = err.to_string(); assert!(msg.contains("500")); assert!(msg.contains("boom")); } #[test] fn error_display_decryption_failed() { let err = SyncKitError::DecryptionFailed; assert!(err.to_string().contains("Wrong password")); } #[test] fn error_display_invalid_envelope() { let err = SyncKitError::InvalidEnvelope("bad version".to_string()); let msg = err.to_string(); assert!(msg.contains("Invalid key envelope")); assert!(msg.contains("bad version")); } #[test] fn error_display_crypto() { let err = SyncKitError::Crypto("aead failed".to_string()); let msg = err.to_string(); assert!(msg.contains("Encryption error")); assert!(msg.contains("aead failed")); } #[test] fn error_display_token_expired() { let err = SyncKitError::TokenExpired; assert!(err.to_string().contains("Token expired")); } // ── SyncKitError conversions ── #[test] fn error_from_serde_json() { let err: SyncKitError = serde_json::from_str::("{{bad}}") .unwrap_err() .into(); assert!(matches!(err, SyncKitError::Json(_))); assert!(err.to_string().contains("JSON")); } #[test] fn error_from_base64() { let err: SyncKitError = base64::engine::general_purpose::STANDARD .decode("!!!bad!!!") .unwrap_err() .into(); assert!(matches!(err, SyncKitError::Base64(_))); assert!(err.to_string().contains("Base64")); } #[test] fn error_internal_contains_message() { let err = SyncKitError::Internal("test internal error".to_string()); assert!(err.to_string().contains("test internal error")); } }