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}