/** * License validation and caching for CloakBrowser Pro. * Mirrors Python cloakbrowser/license.py. * * Handles license key resolution, server validation with local caching, * and Pro version checks. */ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { getCacheDir } from "./config.js"; const VALIDATE_URL = "https://cloakbrowser.dev/api/license/validate"; const PRO_VERSION_URL = "https://cloakbrowser.dev/api/download/version"; const LICENSE_CACHE_TTL_MS = 86_400_000; // 24 hours const PRO_VERSION_CHECK_INTERVAL_MS = 3_600_000; // 1 hour export interface LicenseInfo { valid: boolean; plan: string; expires: string | null; } /** * Resolve the license key: explicit param > env var > file > undefined. */ export function resolveLicenseKey(licenseKey?: string): string | undefined { const trimmed = licenseKey?.trim(); if (trimmed) return trimmed; const envKey = (process.env.CLOAKBROWSER_LICENSE_KEY ?? "").trim(); if (envKey) return envKey; try { const keyFile = path.join(getCacheDir(), "license.key"); const content = fs.readFileSync(keyFile, "utf-8").trim(); if (content) return content; } catch { // File doesn't exist or unreadable } return undefined; } /** * Validate a license key with the CloakBrowser server. * * Checks a local file cache first (24h TTL). Falls back to stale * cache if the server is unreachable. * * Returns LicenseInfo if validation succeeded, null on total failure. */ export async function validateLicense(licenseKey: string): Promise { const cachePath = path.join(getCacheDir(), ".license_cache"); const keySha = createHash("sha256").update(licenseKey).digest("hex"); const cached = readCache(cachePath, keySha); if (cached) return cached; try { const resp = await fetch(VALIDATE_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ license_key: licenseKey }), signal: AbortSignal.timeout(10_000), }); if (!resp.ok) { throw new Error(`HTTP ${resp.status} ${resp.statusText}`); } const data = (await resp.json()) as Record; const info: LicenseInfo = { valid: Boolean(data.valid ?? false), plan: String(data.plan ?? "solo"), expires: data.expires != null ? String(data.expires) : null, }; if (info.valid) { writeCache(cachePath, keySha, info); } return info; } catch (e) { console.warn( `[cloakbrowser] License validation request failed: ${e instanceof Error ? e.message : e}` ); // Fall back to stale cache const stale = readCache(cachePath, keySha, true); if (stale) { console.warn("[cloakbrowser] Using cached license validation (server unreachable)"); return stale; } return null; } } /** * Get the latest Pro binary version from the server. * Rate-limited to 1 call per hour via a marker file. */ export async function getProLatestVersion(): Promise { const marker = path.join(getCacheDir(), ".last_pro_version_check"); try { if (fs.existsSync(marker)) { const stats = fs.statSync(marker); const age = Date.now() - stats.mtimeMs; if (age < PRO_VERSION_CHECK_INTERVAL_MS) { const content = fs.readFileSync(marker, "utf-8").trim(); return content || null; } } } catch { // Marker unreadable — proceed with fetch } try { const resp = await fetch(PRO_VERSION_URL, { signal: AbortSignal.timeout(10_000), }); if (!resp.ok) { throw new Error(`HTTP ${resp.status} ${resp.statusText}`); } const data = (await resp.json()) as Record; const version = data.version != null ? String(data.version) : null; if (!version) return null; try { fs.mkdirSync(path.dirname(marker), { recursive: true }); fs.writeFileSync(marker, version); } catch { // Non-fatal } return version; } catch { return null; } } // --------------------------------------------------------------------------- // Cache helpers // --------------------------------------------------------------------------- interface CacheData { key_sha256: string; valid: boolean; plan: string; expires: string | null; validated_at: number; } function readCache( cachePath: string, keySha: string, ignoreTtl = false, ): LicenseInfo | null { try { if (!fs.existsSync(cachePath)) return null; const data = JSON.parse(fs.readFileSync(cachePath, "utf-8")) as CacheData; if (data.key_sha256 !== keySha) return null; if (!ignoreTtl) { const validatedAt = data.validated_at ?? 0; // A non-numeric validated_at (corrupted cache) is treated as absent rather // than coercing to NaN and silently trusting the entry. if (!Number.isFinite(validatedAt) || Date.now() - validatedAt * 1000 > LICENSE_CACHE_TTL_MS) { return null; } } if (data.expires) { try { if (new Date(data.expires).getTime() < Date.now()) { return { valid: false, plan: String(data.plan ?? "solo"), expires: data.expires }; } } catch { // unparseable date — skip check } } return { valid: Boolean(data.valid ?? false), plan: String(data.plan ?? "solo"), expires: data.expires ?? null, }; } catch { return null; } } function writeCache(cachePath: string, keySha: string, info: LicenseInfo): void { try { const dir = path.dirname(cachePath); fs.mkdirSync(dir, { recursive: true }); const tmpPath = cachePath + ".tmp"; fs.writeFileSync( tmpPath, JSON.stringify({ key_sha256: keySha, valid: info.valid, plan: info.plan, expires: info.expires, validated_at: Date.now() / 1000, }), ); fs.renameSync(tmpPath, cachePath); } catch { // Non-fatal } }