import crypto from 'crypto'; import axios from 'axios'; import { DatabaseService } from './DatabaseService'; import type { LicenseInfo, LicenseStatus, LicenseTier, LicenseVariant, SeatLimits, } from './license-types'; import { isLicenseVariant, normalizeVariant } from './license-normalize'; const SEAT_LIMITS: Record = { skipper: { maxAdmins: 1, maxViewers: 3 }, admiral: { maxAdmins: null, maxViewers: null }, }; interface LemonSqueezyActivationResponse { activated: boolean; error?: string; license_key?: { id: number; status: string; key: string; activation_limit: number; activation_usage: number; created_at: string; expires_at: string | null; }; instance?: { id: string; name: string; created_at: string; }; meta?: { store_id: number; order_id: number; order_item_id: number; product_id: number; product_name: string; variant_id: number; variant_name: string; customer_id: number; customer_name: string; customer_email: string; }; } interface LemonSqueezyValidationResponse { valid: boolean; error?: string; license_key?: { id: number; status: string; key: string; activation_limit: number; activation_usage: number; created_at: string; expires_at: string | null; }; meta?: { store_id: number; order_id: number; order_item_id: number; product_id: number; product_name: string; variant_id: number; variant_name: string; customer_id: number; customer_name: string; customer_email: string; }; } const LEMON_SQUEEZY_API = 'https://api.lemonsqueezy.com/v1/licenses'; const VALIDATION_INTERVAL_MS = 72 * 60 * 60 * 1000; // 72 hours const OFFLINE_GRACE_DAYS = 30; /** * Lemon Squeezy catalog identifiers Sencho is willing to honor. Without these * checks, a license issued for any other LS store or product could activate * Sencho, because LS's /licenses/validate endpoint returns valid: true for * any well-formed license key regardless of which product it belongs to. * * The validate response contains store_id / product_id / variant_id under * meta; resolveSenchoVariantFromMeta() rejects any combination not listed * here. variant_id also serves as the canonical source for tier resolution, * replacing the older substring match against variant_name / product_name. * * If a new tier or billing cadence is added in the LS dashboard, this map * must be updated in the same release. */ export const SENCHO_LS_STORE_ID = 321715; export const SENCHO_LS_PRODUCT_ID_SKIPPER = 924135; export const SENCHO_LS_PRODUCT_ID_ADMIRAL = 924153; const SENCHO_LS_PRODUCT_IDS: ReadonlySet = new Set([ SENCHO_LS_PRODUCT_ID_SKIPPER, SENCHO_LS_PRODUCT_ID_ADMIRAL, ]); const SENCHO_LS_VARIANT_TO_TYPE: ReadonlyMap> = new Map([ [1453178, 'skipper'], // Skipper Monthly [1453197, 'skipper'], // Skipper Annual [1453198, 'skipper'], // Skipper Lifetime [1453209, 'admiral'], // Admiral Monthly [1453212, 'admiral'], // Admiral Annual [1453217, 'admiral'], // Admiral Lifetime ]); /** * Resolve a Lemon Squeezy validate / activate response's meta block to a * Sencho variant. Returns null when the meta is missing, the store does not * match, the product is not a Sencho product, or the variant is unknown. * * Callers must reject the activation/validation when this returns null. * Persisting any state from a non-matching response would let foreign LS * licenses unlock paid features. */ export function resolveSenchoVariantFromMeta( meta: { store_id?: number; product_id?: number; variant_id?: number } | undefined, ): Exclude | null { if (!meta) return null; if (meta.store_id !== SENCHO_LS_STORE_ID) return null; if (meta.product_id === undefined || !SENCHO_LS_PRODUCT_IDS.has(meta.product_id)) return null; if (meta.variant_id === undefined) return null; return SENCHO_LS_VARIANT_TO_TYPE.get(meta.variant_id) ?? null; } // Short TTL for the proxy-headers cache. The remote-node proxy reads tier // and variant on every forwarded request; without caching, each call hits // system_state 5+ times. Every license_status write goes through // setLicenseStatus() which invalidates the cache, so the TTL is a safety // net against any future bypass rather than a load-bearing freshness bound. const PROXY_HEADERS_CACHE_TTL_MS = 30_000; /** * Single in-tree license service. Owns Lemon Squeezy validation and * exposes the tier / variant / seat-limit API consumed across the * backend. See `docs/internal/adrs/2026-05-02-collapse-entitlement-provider.md` * for the conditions that would justify reintroducing an interface seam. */ export class LicenseService { private static instance: LicenseService; private validationTimer: ReturnType | null = null; private cachedProxyHeaders: { value: { tier: LicenseTier; variant: LicenseVariant }; expiresAt: number } | null = null; private constructor() { } public static getInstance(): LicenseService { if (!LicenseService.instance) { LicenseService.instance = new LicenseService(); } return LicenseService.instance; } /** * Two distinct identifiers live in `system_state` and the names are * confusingly close, so for future maintainers (and audit agents): * * `instance_id` local UUID generated once on first boot. We * send it to LS as the activation's * `instance_name` (LS treats this as a * free-form label, e.g. a hostname). * * `license_instance_id` the activation ID LS returns on /activate. * We send it back to LS as the `instance_id` * parameter on /validate and /deactivate. * * They are NOT redundant and NEITHER overwrites the other. Renaming * `instance_id` to e.g. `local_install_id` would be clearer but would * churn frontend consumers and migrations for no functional change. */ /** * Initialize the license service on startup. * Ensures an instance ID exists and starts periodic validation for active licenses. * Fresh installs land on Community; trials are issued by Lemon Squeezy via the * hosted checkout (email + card required) and activated locally by pasting the key. */ public initialize(): void { const db = DatabaseService.getInstance(); // Generate persistent instance ID on first boot if (!db.getSystemState('instance_id')) { db.setSystemState('instance_id', crypto.randomUUID()); } this.startPeriodicValidation(); } /** * Returns the current license tier. Synchronous - reads from cached DB state only. */ public getTier(): LicenseTier { const db = DatabaseService.getInstance(); const status = db.getSystemState('license_status') as LicenseStatus | null; if (!status || status === 'community') return 'community'; if (status === 'disabled' || status === 'expired') return 'community'; if (status === 'trial') { const validUntil = db.getSystemState('license_valid_until'); if (validUntil && new Date(validUntil) > new Date()) { return 'paid'; } // Trial expired - update status this.setLicenseStatus('community'); return 'community'; } if (status === 'active') { // Check offline grace period const lastValidated = db.getSystemState('license_last_validated'); if (lastValidated) { const daysSinceValidation = (Date.now() - parseInt(lastValidated, 10)) / (1000 * 60 * 60 * 24); if (daysSinceValidation > OFFLINE_GRACE_DAYS) { console.warn('[License] Offline grace period exceeded. Degrading to community.'); this.setLicenseStatus('community'); return 'community'; } } // Check expiry for subscription licenses const validUntil = db.getSystemState('license_valid_until'); if (validUntil && new Date(validUntil) < new Date()) { this.setLicenseStatus('expired'); return 'community'; } return 'paid'; } return 'community'; } /** * Resolve Lemon Squeezy metadata to the internal variant type from string * metadata. Used as a fallback when license_variant_id is unavailable. * * With the catalog guard in resolveSenchoVariantFromMeta(), every new * activation stores license_variant_id, so production callers always hit * the variant_id path in getVariant(). This substring fallback is retained * for test fixtures that drive getVariant() without a variant_id present. * Once the per-Directive-20 cleanup branch lands, this method and its two * call sites can be deleted. */ private resolveVariantType(variantName: string, productName?: string): 'skipper' | 'admiral' { const combined = `${variantName} ${productName || ''}`.toLowerCase(); if (combined.includes('team') || combined.includes('admiral')) return 'admiral'; if (combined.includes('personal') || combined.includes('skipper')) return 'skipper'; return 'skipper'; } /** * Persist variant metadata from a Lemon Squeezy response to the DB. * * When `resolvedType` is supplied (always, in production paths via * resolveSenchoVariantFromMeta), it wins over the legacy substring match * against variant_name / product_name. The substring fallback is kept for * tests that exercise the legacy path and as a defensive default; new * activations always carry a resolved type. */ private storeVariantMeta( db: DatabaseService, meta: { variant_name?: string; variant_id?: number; product_name?: string }, resolvedType?: Exclude, ): void { if (meta.variant_name) { db.setSystemState('license_variant_name', meta.variant_name); const type = resolvedType ?? this.resolveVariantType(meta.variant_name, meta.product_name); db.setSystemState('license_variant_type', type); } if (meta.variant_id) { db.setSystemState('license_variant_id', String(meta.variant_id)); } } /** * Get the license variant (skipper or admiral) from stored metadata. * Trial and active licenses both resolve via Lemon Squeezy metadata stored by activate(); * trial-granted variant is whatever Lemon Squeezy returned for the trial variant. * * Self-healing: on every call, cross-checks the stored variant_type against what * resolveVariantType() produces from the current product/variant names. If they * disagree (e.g. stale cache from a previous buggy version), re-resolves and * persists the corrected value. */ public getVariant(): LicenseVariant { const db = DatabaseService.getInstance(); const variantIdStr = db.getSystemState('license_variant_id'); const storedType = db.getSystemState('license_variant_type'); // Prefer variant_id-based resolution. variant_id is a stable LS catalog // identifier, while variant_name / product_name are display strings that // can be edited in the LS dashboard. Activation already rejected any // unrecognized variant_id, so a hit here is always trustworthy. if (variantIdStr) { const variantId = parseInt(variantIdStr, 10); if (Number.isFinite(variantId)) { const fromId = SENCHO_LS_VARIANT_TO_TYPE.get(variantId); if (fromId) { if (fromId !== storedType) { db.setSystemState('license_variant_type', fromId); } return fromId; } } } // Fall back to name-based resolution for any state without a // variant_id (test fixtures, partial DB writes from older code paths). const variantName = db.getSystemState('license_variant_name'); const productName = db.getSystemState('license_product_name') || undefined; if (variantName) { const resolved = this.resolveVariantType(variantName, productName); if (resolved !== storedType) { db.setSystemState('license_variant_type', resolved); } return resolved; } // No source metadata available; trust the stored type if it parses. if (isLicenseVariant(storedType)) return normalizeVariant(storedType); return null; } /** * Tier + variant snapshot for the remote-node proxy headers, cached for * a short window to spare the proxy hot path from re-running getTier() * and getVariant() on every forwarded request. All license-status writes * route through setLicenseStatus(), which invalidates this cache, so * tier changes take effect within one proxy call. */ public getProxyHeaders(): { tier: LicenseTier; variant: LicenseVariant } { const now = Date.now(); if (this.cachedProxyHeaders && this.cachedProxyHeaders.expiresAt > now) { return this.cachedProxyHeaders.value; } const value = { tier: this.getTier(), variant: this.getVariant() }; this.cachedProxyHeaders = { value, expiresAt: now + PROXY_HEADERS_CACHE_TTL_MS }; return value; } /** * Single chokepoint for license_status writes. Persists the new status * and invalidates the proxy-headers cache so tier-gated routes on * remote nodes observe the change on the next forwarded request. * Every license_status write must go through this method; bypassing * it leaves the cache stale until the TTL expires. */ private setLicenseStatus(status: LicenseStatus): void { DatabaseService.getInstance().setSystemState('license_status', status); this.cachedProxyHeaders = null; } /** * Get seat limits for the current license variant. */ public getSeatLimits(): SeatLimits { const variant = this.getVariant(); if (!variant) return { maxAdmins: 1, maxViewers: 0 }; // community return SEAT_LIMITS[variant] || SEAT_LIMITS.skipper; } /** * Get full license information for the API response. */ public getLicenseInfo(): LicenseInfo { const db = DatabaseService.getInstance(); const status = (db.getSystemState('license_status') || 'community') as LicenseStatus; const key = db.getSystemState('license_key'); const validUntil = db.getSystemState('license_valid_until'); const instanceId = db.getSystemState('instance_id') || ''; let trialDaysRemaining: number | null = null; if (status === 'trial' && validUntil) { const remaining = (new Date(validUntil).getTime() - Date.now()) / (1000 * 60 * 60 * 24); trialDaysRemaining = Math.max(0, Math.ceil(remaining)); } // Lifetime license: active with a stored key but no expiry date const isLifetime = status === 'active' && !!key && !validUntil; return { tier: this.getTier(), status, variant: this.getVariant(), customerName: db.getSystemState('license_customer_name'), productName: db.getSystemState('license_product_name'), maskedKey: key ? `****-****-****-${key.slice(-4)}` : null, validUntil, trialDaysRemaining, instanceId, portalUrl: db.getSystemState('billing_portal_url') || db.getSystemState('customer_portal_url') || null, isLifetime, }; } /** * Activate a license key with Lemon Squeezy. */ public async activate(licenseKey: string): Promise<{ success: boolean; error?: string }> { const db = DatabaseService.getInstance(); const instanceId = db.getSystemState('instance_id') || crypto.randomUUID(); try { const response = await axios.post( `${LEMON_SQUEEZY_API}/activate`, { license_key: licenseKey, instance_name: instanceId, }, { timeout: 15000 } ); const data = response.data; if (!data.activated) { return { success: false, error: data.error || 'Activation failed' }; } // Reject licenses that don't belong to the Sencho LS catalog. // LS's /activate succeeds for any product in any store, so without // this check a license bought elsewhere could unlock Sencho. const variantType = resolveSenchoVariantFromMeta(data.meta); if (variantType === null) { console.warn('[License] Activation rejected: license does not match the Sencho catalog.'); return { success: false, error: 'This license key is not valid for Sencho.' }; } // Reject if LS did not return a usable instance id. Storing an // empty license_instance_id would silently break later validate() // and deactivate() calls, both of which short-circuit on a falsy // instance id with a generic "no active license" error. Better to // surface the broken activation immediately than ship a state where // the user thinks they are activated but every subsequent call // fails. LS has historically always returned data.instance.id on // a successful activation; this is a defense against an API change // or transient malformed response, not a routinely-hit branch. const lsInstanceId = data.instance?.id; if (!lsInstanceId) { console.warn('[License] Activation rejected: LS response missing instance.id.'); return { success: false, error: 'License server returned an incomplete activation. Please try again.' }; } // Store license data db.setSystemState('license_key', licenseKey); db.setSystemState('license_instance_id', lsInstanceId); this.setLicenseStatus('active'); db.setSystemState('license_last_validated', Date.now().toString()); if (data.license_key?.expires_at) { db.setSystemState('license_valid_until', data.license_key.expires_at); } else { // Lifetime license - no expiry db.setSystemState('license_valid_until', ''); } if (data.meta?.customer_name) { db.setSystemState('license_customer_name', data.meta.customer_name); } if (data.meta?.product_name) { db.setSystemState('license_product_name', data.meta.product_name); } if (data.meta) { this.storeVariantMeta(db, data.meta, variantType); } if (data.meta?.customer_id) { db.setSystemState('customer_id', String(data.meta.customer_id)); } // Clear any cached portal URL so it's refreshed on next request db.setSystemState('billing_portal_url', ''); db.setSystemState('billing_portal_expires', ''); console.log('[License] Activated successfully.'); return { success: true }; } catch (err) { // Handle Lemon Squeezy error responses (4xx) if (axios.isAxiosError(err) && err.response?.data) { const errorMsg = err.response.data.error || 'Activation failed'; console.error('[License] Activation error:', errorMsg); return { success: false, error: errorMsg }; } console.error('[License] Activation network error:', (err as Error).message); return { success: false, error: 'Unable to reach license server. Check your internet connection.' }; } } /** * Deactivate the current license, reverting to community. */ public async deactivate(): Promise<{ success: boolean; error?: string }> { const db = DatabaseService.getInstance(); const licenseKey = db.getSystemState('license_key'); const instanceId = db.getSystemState('license_instance_id'); if (licenseKey && instanceId) { try { await axios.post( `${LEMON_SQUEEZY_API}/deactivate`, { license_key: licenseKey, instance_id: instanceId, }, { timeout: 15000 } ); } catch (err) { console.warn('[License] Deactivation API call failed (proceeding with local cleanup):', (err as Error).message); } } // Clear all license state const keysToRemove = [ 'license_key', 'license_instance_id', 'license_status', 'license_valid_until', 'license_last_validated', 'license_customer_name', 'license_product_name', 'license_variant_name', 'license_variant_type', 'license_variant_id', 'subscription_id', 'customer_id', 'customer_portal_url', 'update_payment_url', 'order_id', 'receipt_url', 'billing_portal_url', 'billing_portal_expires', ]; for (const key of keysToRemove) { db.setSystemState(key, ''); } this.setLicenseStatus('community'); console.log('[License] Deactivated. Reverted to Community tier.'); return { success: true }; } /** * Validate the current license against Lemon Squeezy. */ public async validate(): Promise<{ success: boolean; error?: string }> { const db = DatabaseService.getInstance(); const licenseKey = db.getSystemState('license_key'); const instanceId = db.getSystemState('license_instance_id'); if (!licenseKey || !instanceId) { return { success: false, error: 'No active license to validate' }; } try { const response = await axios.post( `${LEMON_SQUEEZY_API}/validate`, { license_key: licenseKey, instance_id: instanceId, }, { timeout: 15000 } ); const data = response.data; if (!data.valid) { // License revoked or invalid this.setLicenseStatus('disabled'); console.warn('[License] Validation failed: license is no longer valid.'); return { success: false, error: data.error || 'License is no longer valid' }; } // Reject if the license is no longer recognized as a Sencho catalog // entry (e.g. variant_id removed, product moved). Same defense as // activate(): without this, any LS license can pass periodic // validation and keep paid features unlocked. const variantType = resolveSenchoVariantFromMeta(data.meta); if (variantType === null) { this.setLicenseStatus('disabled'); console.warn('[License] Validation rejected: license does not match the Sencho catalog.'); return { success: false, error: 'License is not valid for Sencho.' }; } // license_last_validated means "we got an authoritative answer from // LS for a recognized Sencho license," not just "we reached the LS // API." Writing it before the catalog guard would let a foreign LS // license refresh the offline grace window during the brief window // the rejected status is being persisted. db.setSystemState('license_last_validated', Date.now().toString()); // Update status based on key status const keyStatus = data.license_key?.status; if (keyStatus === 'expired') { this.setLicenseStatus('expired'); return { success: false, error: 'License has expired' }; } if (keyStatus === 'disabled') { this.setLicenseStatus('disabled'); return { success: false, error: 'License has been disabled' }; } this.setLicenseStatus('active'); // Update expiry if changed if (data.license_key?.expires_at) { db.setSystemState('license_valid_until', data.license_key.expires_at); } // Update customer/product info if available if (data.meta?.customer_name) { db.setSystemState('license_customer_name', data.meta.customer_name); } if (data.meta?.product_name) { db.setSystemState('license_product_name', data.meta.product_name); } if (data.meta) { this.storeVariantMeta(db, data.meta, variantType); } if (data.meta?.customer_id && !db.getSystemState('customer_id')) { db.setSystemState('customer_id', String(data.meta.customer_id)); } console.log('[License] Validation successful.'); return { success: true }; } catch (err) { // Network failure - don't change status, just log console.warn('[License] Validation network error (keeping current status):', (err as Error).message); return { success: false, error: 'Unable to reach license server' }; } } /** * Fetch a signed billing portal URL via the sencho.io proxy. * Returns a pre-signed Lemon Squeezy Customer Portal URL (valid 24hrs). * Caches the URL for 12 hours to reduce external API calls. */ public async getBillingPortalUrl(): Promise<{ url: string } | { error: string }> { const db = DatabaseService.getInstance(); const status = db.getSystemState('license_status'); const licenseKey = db.getSystemState('license_key'); if (status !== 'active' || !licenseKey) { return { error: 'No billing portal available. Ensure you have an active license.' }; } // Lifetime licenses have no recurring subscription to manage const validUntil = db.getSystemState('license_valid_until'); if (!validUntil) { return { error: 'Billing portal is not available for lifetime licenses.' }; } // Check cache (12hr TTL) const cachedUrl = db.getSystemState('billing_portal_url'); const cachedExpires = db.getSystemState('billing_portal_expires'); if (cachedUrl && cachedExpires && Date.now() < parseInt(cachedExpires, 10)) { return { url: cachedUrl }; } try { const response = await axios.post<{ url: string }>( 'https://sencho.io/api/billing-portal', { license_key: licenseKey }, { timeout: 15000 } ); const url = response.data?.url; if (!url) { return { error: 'No billing portal available. Ensure you have an active license.' }; } // Cache for 12 hours const ttl = 12 * 60 * 60 * 1000; db.setSystemState('billing_portal_url', url); db.setSystemState('billing_portal_expires', String(Date.now() + ttl)); return { url }; } catch (err) { console.warn('[License] Failed to fetch billing portal URL:', (err as Error).message); // Return stale cache if available if (cachedUrl) return { url: cachedUrl }; return { error: 'Failed to retrieve billing portal URL.' }; } } /** * Start periodic background validation every 72 hours. */ public startPeriodicValidation(): void { if (this.validationTimer) { clearInterval(this.validationTimer); } this.validationTimer = setInterval(async () => { const db = DatabaseService.getInstance(); const status = db.getSystemState('license_status'); // Only validate active licenses (not trial, community, etc.) if (status === 'active') { await this.validate(); } }, VALIDATION_INTERVAL_MS); // Run an initial validation on startup for active licenses (after a short delay) const db = DatabaseService.getInstance(); if (db.getSystemState('license_status') === 'active') { setTimeout(() => this.validate(), 5000); } } /** * Cleanup on shutdown. */ public destroy(): void { if (this.validationTimer) { clearInterval(this.validationTimer); this.validationTimer = null; } } }