Skip to main content

logicaffeine_verify/
license.rs

1//! License validation for Logicaffeine verification.
2//!
3//! Uses the Stripe-based license system. License keys are Stripe subscription
4//! IDs (`sub_*` format) validated against the Logicaffeine API.
5//!
6//! ## Usage
7//!
8//! ```ignore
9//! use logicaffeine_verify::LicenseValidator;
10//!
11//! let validator = LicenseValidator::new();
12//! match validator.validate("sub_abc123xyz") {
13//!     Ok(plan) => println!("License valid: {} plan", plan),
14//!     Err(e) => eprintln!("License error: {}", e),
15//! }
16//! ```
17//!
18//! ## License Tiers
19//!
20//! | Plan | Verification | Price |
21//! |------|--------------|-------|
22//! | Free | No | $0 |
23//! | Supporter | No | $5/mo |
24//! | Pro | Yes | $25/mo |
25//! | Premium | Yes | $50/mo |
26//! | Lifetime | Yes | $50/seat |
27//! | Enterprise | Yes | Custom |
28//!
29//! ## Validation Flow
30//!
31//! 1. **Format check**: Key must start with `sub_`
32//! 2. **Cache check**: Return cached result if fresh (< 24 hours)
33//! 3. **API validation**: Call `api.logicaffeine.com/validate`
34//! 4. **Fallback**: Use stale cache if network fails
35//!
36//! ## Caching
37//!
38//! Results are cached for 24 hours at:
39//! - **macOS/Linux**: `~/.cache/logos/verification_license.json`
40//! - **Windows**: `%LOCALAPPDATA%\logos\verification_license.json`
41
42use serde::{Deserialize, Serialize};
43use std::fs;
44use std::path::PathBuf;
45use std::time::{SystemTime, UNIX_EPOCH};
46
47use crate::error::{VerificationError, VerificationResult};
48
49/// The license validation API endpoint.
50const LICENSE_API: &str = "https://api.logicaffeine.com/validate";
51
52/// Cache duration in seconds (24 hours).
53const CACHE_DURATION_SECS: u64 = 24 * 60 * 60;
54
55/// License plan tiers.
56///
57/// Each plan has different feature access. Only Pro, Premium, Lifetime,
58/// and Enterprise plans include verification capabilities.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum LicensePlan {
62    /// No license or unknown plan.
63    None,
64    /// Free tier, no verification.
65    Free,
66    /// Supporter tier, no verification.
67    Supporter,
68    /// Pro tier, includes verification.
69    Pro,
70    /// Premium tier, includes verification.
71    Premium,
72    /// Lifetime tier, includes verification.
73    Lifetime,
74    /// Enterprise tier, includes verification.
75    Enterprise,
76}
77
78impl LicensePlan {
79    /// Check if this plan includes verification access.
80    ///
81    /// Returns `true` for Pro, Premium, Lifetime, and Enterprise plans.
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use logicaffeine_verify::LicensePlan;
87    ///
88    /// assert!(!LicensePlan::Free.can_verify());
89    /// assert!(LicensePlan::Pro.can_verify());
90    /// ```
91    pub fn can_verify(&self) -> bool {
92        matches!(
93            self,
94            Self::Pro | Self::Premium | Self::Lifetime | Self::Enterprise
95        )
96    }
97
98    /// Parse a plan from a string.
99    ///
100    /// Returns [`LicensePlan::None`] for unrecognized strings.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use logicaffeine_verify::LicensePlan;
106    ///
107    /// assert_eq!(LicensePlan::from_str("pro"), LicensePlan::Pro);
108    /// assert_eq!(LicensePlan::from_str("PRO"), LicensePlan::Pro);
109    /// assert_eq!(LicensePlan::from_str("unknown"), LicensePlan::None);
110    /// ```
111    pub fn from_str(s: &str) -> Self {
112        match s.to_lowercase().as_str() {
113            "free" => Self::Free,
114            "supporter" => Self::Supporter,
115            "pro" => Self::Pro,
116            "premium" => Self::Premium,
117            "lifetime" => Self::Lifetime,
118            "enterprise" => Self::Enterprise,
119            _ => Self::None,
120        }
121    }
122}
123
124impl std::fmt::Display for LicensePlan {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        match self {
127            Self::None => write!(f, "None"),
128            Self::Free => write!(f, "Free"),
129            Self::Supporter => write!(f, "Supporter"),
130            Self::Pro => write!(f, "Pro"),
131            Self::Premium => write!(f, "Premium"),
132            Self::Lifetime => write!(f, "Lifetime"),
133            Self::Enterprise => write!(f, "Enterprise"),
134        }
135    }
136}
137
138/// Cached license validation result.
139#[derive(Debug, Serialize, Deserialize)]
140struct CachedLicense {
141    key: String,
142    plan: String,
143    valid: bool,
144    validated_at: u64,
145}
146
147/// Response from the license validation API.
148#[derive(Debug, Deserialize)]
149struct LicenseResponse {
150    valid: bool,
151    #[serde(default)]
152    plan: Option<String>,
153    #[serde(default)]
154    error: Option<String>,
155}
156
157/// License validator that checks keys against the API with caching.
158///
159/// The validator implements a multi-tier validation strategy:
160/// 1. Check key format (must start with `sub_`)
161/// 2. Check local cache for fresh result
162/// 3. Call API if cache is stale or missing
163/// 4. Fall back to stale cache if network fails
164///
165/// # Examples
166///
167/// ```ignore
168/// use logicaffeine_verify::LicenseValidator;
169///
170/// let validator = LicenseValidator::new();
171///
172/// // Validate a license key
173/// match validator.validate("sub_abc123") {
174///     Ok(plan) => {
175///         if plan.can_verify() {
176///             println!("Verification enabled with {} plan", plan);
177///         }
178///     }
179///     Err(e) => eprintln!("License error: {}", e),
180/// }
181/// ```
182pub struct LicenseValidator {
183    cache_path: PathBuf,
184}
185
186impl LicenseValidator {
187    /// Create a new license validator.
188    ///
189    /// The validator stores its cache in the system cache directory:
190    /// - **macOS/Linux**: `~/.cache/logos/verification_license.json`
191    /// - **Windows**: `%LOCALAPPDATA%\logos\verification_license.json`
192    ///
193    /// # Examples
194    ///
195    /// ```ignore
196    /// use logicaffeine_verify::LicenseValidator;
197    ///
198    /// let validator = LicenseValidator::new();
199    /// ```
200    pub fn new() -> Self {
201        let cache_dir = dirs::cache_dir()
202            .unwrap_or_else(|| PathBuf::from("."))
203            .join("logos");
204
205        let _ = fs::create_dir_all(&cache_dir);
206
207        Self {
208            cache_path: cache_dir.join("verification_license.json"),
209        }
210    }
211
212    /// Validate a license key.
213    ///
214    /// Returns the license plan if valid and verification-capable.
215    /// Returns an error if the key is invalid, the plan doesn't include
216    /// verification, or network validation fails.
217    ///
218    /// # Validation Steps
219    ///
220    /// 1. **Format check**: Key must start with `sub_`
221    /// 2. **Cache check**: Return cached result if < 24 hours old
222    /// 3. **API call**: Validate against `api.logicaffeine.com`
223    /// 4. **Fallback**: Use stale cache if network fails
224    ///
225    /// # Errors
226    ///
227    /// - `LicenseInvalid` - Key format is wrong or API rejected it
228    /// - `LicenseInsufficientPlan` - License valid but plan lacks verification
229    ///
230    /// # Examples
231    ///
232    /// ```ignore
233    /// use logicaffeine_verify::LicenseValidator;
234    ///
235    /// let validator = LicenseValidator::new();
236    ///
237    /// // Invalid format
238    /// assert!(validator.validate("invalid").is_err());
239    ///
240    /// // Valid key (requires network)
241    /// let result = validator.validate("sub_abc123");
242    /// ```
243    pub fn validate(&self, key: &str) -> VerificationResult<LicensePlan> {
244        // Check key format
245        if !key.starts_with("sub_") {
246            return Err(VerificationError::license_invalid(
247                "Invalid license key format. Keys should start with 'sub_'.",
248            ));
249        }
250
251        // Check cache first
252        if let Some(cached) = self.load_cache() {
253            if cached.key == key && self.is_cache_fresh(&cached) {
254                let plan = LicensePlan::from_str(&cached.plan);
255                if cached.valid && plan.can_verify() {
256                    return Ok(plan);
257                } else if !cached.valid {
258                    return Err(VerificationError::license_invalid("License key is invalid"));
259                } else {
260                    return Err(VerificationError::insufficient_plan(plan.to_string()));
261                }
262            }
263        }
264
265        // Validate with API
266        match self.validate_with_api(key) {
267            Ok((valid, plan)) => {
268                // Cache the result
269                self.save_cache(key, &plan.to_string().to_lowercase(), valid);
270
271                if valid && plan.can_verify() {
272                    Ok(plan)
273                } else if !valid {
274                    Err(VerificationError::license_invalid("License key is invalid"))
275                } else {
276                    Err(VerificationError::insufficient_plan(plan.to_string()))
277                }
278            }
279            Err(e) => {
280                // If network fails, try to use stale cache
281                if let Some(cached) = self.load_cache() {
282                    if cached.key == key {
283                        eprintln!(
284                            "Warning: Could not validate license ({}). Using cached result.",
285                            e
286                        );
287                        let plan = LicensePlan::from_str(&cached.plan);
288                        if cached.valid && plan.can_verify() {
289                            return Ok(plan);
290                        }
291                    }
292                }
293                Err(VerificationError::license_invalid(format!(
294                    "Could not validate license: {}",
295                    e
296                )))
297            }
298        }
299    }
300
301    /// Validate the key against the API.
302    fn validate_with_api(&self, key: &str) -> Result<(bool, LicensePlan), String> {
303        let response = ureq::post(LICENSE_API)
304            .set("Content-Type", "application/json")
305            .send_json(ureq::json!({ "licenseKey": key }))
306            .map_err(|e| format!("Network error: {}", e))?;
307
308        let body: LicenseResponse = response
309            .into_json()
310            .map_err(|e| format!("Invalid response: {}", e))?;
311
312        if let Some(error) = body.error {
313            return Err(error);
314        }
315
316        let plan = body
317            .plan
318            .map(|p| LicensePlan::from_str(&p))
319            .unwrap_or(LicensePlan::None);
320
321        Ok((body.valid, plan))
322    }
323
324    /// Load cached license validation.
325    fn load_cache(&self) -> Option<CachedLicense> {
326        let content = fs::read_to_string(&self.cache_path).ok()?;
327        serde_json::from_str(&content).ok()
328    }
329
330    /// Save license validation to cache.
331    fn save_cache(&self, key: &str, plan: &str, valid: bool) {
332        let now = SystemTime::now()
333            .duration_since(UNIX_EPOCH)
334            .unwrap_or_default()
335            .as_secs();
336
337        let cached = CachedLicense {
338            key: key.to_string(),
339            plan: plan.to_string(),
340            valid,
341            validated_at: now,
342        };
343
344        if let Ok(json) = serde_json::to_string_pretty(&cached) {
345            let _ = fs::write(&self.cache_path, json);
346        }
347    }
348
349    /// Check if the cache is still fresh (< 24 hours).
350    fn is_cache_fresh(&self, cached: &CachedLicense) -> bool {
351        let now = SystemTime::now()
352            .duration_since(UNIX_EPOCH)
353            .unwrap_or_default()
354            .as_secs();
355
356        now.saturating_sub(cached.validated_at) < CACHE_DURATION_SECS
357    }
358}
359
360impl Default for LicenseValidator {
361    fn default() -> Self {
362        Self::new()
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn test_plan_can_verify() {
372        assert!(!LicensePlan::None.can_verify());
373        assert!(!LicensePlan::Free.can_verify());
374        assert!(!LicensePlan::Supporter.can_verify());
375        assert!(LicensePlan::Pro.can_verify());
376        assert!(LicensePlan::Premium.can_verify());
377        assert!(LicensePlan::Lifetime.can_verify());
378        assert!(LicensePlan::Enterprise.can_verify());
379    }
380
381    #[test]
382    fn test_invalid_key_format() {
383        let validator = LicenseValidator::new();
384        let result = validator.validate("invalid_key");
385        assert!(result.is_err());
386    }
387}