1010 * @description LemonSqueezy license validation service. Handles activation,
1111 * validation, and deactivation of license keys via the LemonSqueezy API.
1212 *
13- * The validation result is cached in `electron.safeStorage` (encrypted AES-256
14- * on Windows) so the app can start instantly without a network round-trip. The
15- * cache is considered stale after 7 days and re-validated in the background.
16- * If the network is unavailable during re-validation, a 7-day grace period
17- * keeps the cached status active. On expiry or an explicit invalid response
18- * the app falls back to the free tier.
13+ * The validation result is encrypted via `electron.safeStorage` (AES-256 on
14+ * Windows) and persisted to `<userData>/license.enc` so the app can start
15+ * instantly without a network round-trip on every launch. The cache is
16+ * considered stale after 7 days and re-validated in the background. If the
17+ * network is unavailable during re-validation, a 7-day grace period keeps the
18+ * cached status active. On expiry or an explicit invalid response the app
19+ * falls back to the free tier.
1920 *
2021 * Lifecycle:
21- * app start → getStatus() reads cache → background re-validate if stale
22- * user enters key → activateLicense(key) → validate → update cache
23- * user deactivates → deactivateLicense() → API call → clear cache
22+ * app start → getStatus() reads cache file → background re-validate if stale
23+ * user enters key → activateLicense(key) → validate → update cache file
24+ * user deactivates → deactivateLicense() → API call → delete cache file
2425 */
2526
26- import { safeStorage } from 'electron'
27+ import { safeStorage , app } from 'electron'
28+ import { existsSync , readFileSync , writeFileSync , unlinkSync } from 'fs'
29+ import { join } from 'path'
2730import log from 'electron-log'
2831import type { LicenseStatus , PlanTier } from '@shared/types'
2932
@@ -47,8 +50,11 @@ const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000
4750 */
4851const GRACE_PERIOD_MS = 7 * 24 * 60 * 60 * 1000
4952
50- /** safeStorage encryption key name used to persist the license cache. */
51- const STORAGE_KEY = 'aidrelay-license-cache'
53+ /**
54+ * Returns the absolute path to the encrypted license cache file.
55+ * Stored in the per-user Electron userData directory so it survives app restarts.
56+ */
57+ const cachePath = ( ) : string => join ( app . getPath ( 'userData' ) , 'license.enc' )
5258
5359// ─── Internal Cache Shape ─────────────────────────────────────────────────────
5460
@@ -76,44 +82,53 @@ const freeTierStatus = (): LicenseStatus => ({
7682} )
7783
7884/**
79- * Reads and decrypts the cached license data from `electron.safeStorage`.
80- * Returns `null` if nothing is stored or decryption fails.
85+ * Reads and decrypts the cached license data from the encrypted file on disk.
86+ * Returns `null` if the file does not exist, encryption is unavailable, or
87+ * the file contents cannot be decrypted (e.g. the OS key changed).
8188 */
8289const readCache = ( ) : LicenseCache | null => {
8390 try {
8491 if ( ! safeStorage . isEncryptionAvailable ( ) ) return null
85- const stored = process . env [ '_AIDRELAY_LICENSE_CACHE' ]
86- if ( ! stored ) return null
87- const decrypted = safeStorage . decryptString ( Buffer . from ( stored , 'base64' ) )
92+ const file = cachePath ( )
93+ if ( ! existsSync ( file ) ) return null
94+ const encrypted = readFileSync ( file )
95+ const decrypted = safeStorage . decryptString ( encrypted )
8896 return JSON . parse ( decrypted ) as LicenseCache
8997 } catch {
9098 return null
9199 }
92100}
93101
94102/**
95- * Encrypts and writes license cache data to `electron.safeStorage`.
103+ * Encrypts the license cache and writes it to `<userData>/license.enc`.
104+ * The file is replaced atomically so a partial write never corrupts it.
105+ *
106+ * @param cache - The cache data to persist.
96107 */
97108const writeCache = ( cache : LicenseCache ) : void => {
98109 try {
99110 if ( ! safeStorage . isEncryptionAvailable ( ) ) return
100111 const json = JSON . stringify ( cache )
101112 const encrypted = safeStorage . encryptString ( json )
102- // Store in memory env var as a stand-in for a real persistent key-value store.
103- // In production this would be written to app.getPath('userData')/license.enc
104- process . env [ '_AIDRELAY_LICENSE_CACHE' ] = encrypted . toString ( 'base64' )
113+ writeFileSync ( cachePath ( ) , encrypted )
105114 log . debug ( `[license] cache written for key ending ...${ cache . key . slice ( - 4 ) } ` )
106115 } catch ( err ) {
107116 log . warn ( '[license] failed to write cache:' , err )
108117 }
109118}
110119
111120/**
112- * Clears the cached license data from `electron.safeStorage`.
121+ * Deletes the encrypted license cache file from disk.
122+ * Safe to call even if the file does not exist.
113123 */
114124const clearCache = ( ) : void => {
115- delete process . env [ '_AIDRELAY_LICENSE_CACHE' ]
116- log . debug ( '[license] cache cleared' )
125+ try {
126+ const file = cachePath ( )
127+ if ( existsSync ( file ) ) unlinkSync ( file )
128+ log . debug ( '[license] cache cleared' )
129+ } catch ( err ) {
130+ log . warn ( '[license] failed to clear cache:' , err )
131+ }
117132}
118133
119134// ─── API Calls ────────────────────────────────────────────────────────────────
@@ -294,7 +309,6 @@ export const deactivateLicense = async (): Promise<void> => {
294309 await apiDeactivate ( cache . key )
295310 }
296311 clearCache ( )
297- // Suppress unused variable warning for STORE_ID in stub implementation.
298- void STORAGE_KEY
312+ // STORE_ID is used via environment variable — suppress the unused warning.
299313 void STORE_ID
300314}
0 commit comments