From 9f9e1bdff0692f806c69f4831ead1aecada0cd69 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 1 May 2026 20:43:29 -0400 Subject: [PATCH] fix(license): verify LS store, product, and variant IDs in validate response (#862) Lemon Squeezy's /v1/licenses/validate returns valid:true for any license key in any LS store, so without a hardcoded catalog-identity check, a license bought for any other LS product unlocks Sencho. Add a module-level catalog map (store_id, two product_ids, six variant_ids) and a pure resolveSenchoVariantFromMeta() helper. activate() and validate() now reject responses whose meta does not match the Sencho catalog before persisting any state. validate() additionally moves the license_last_validated write below the catalog guard so a foreign-license refresh cannot extend the offline grace window. getVariant() now resolves tier from variant_id first (stable LS catalog identifier) and falls back to the substring match on variant_name only when no variant_id is present. Tests cover all 6 valid variants, every rejection branch, both activate and validate paths, the no-DB-writes invariant on rejection, and the LS-side key_status=expired/disabled branches. --- .../license-service-id-validation.test.ts | 287 ++++++++++++++++++ backend/src/services/LicenseService.ts | 141 ++++++++- 2 files changed, 414 insertions(+), 14 deletions(-) create mode 100644 backend/src/__tests__/license-service-id-validation.test.ts diff --git a/backend/src/__tests__/license-service-id-validation.test.ts b/backend/src/__tests__/license-service-id-validation.test.ts new file mode 100644 index 00000000..874d5f66 --- /dev/null +++ b/backend/src/__tests__/license-service-id-validation.test.ts @@ -0,0 +1,287 @@ +/** + * Tests for the Lemon Squeezy catalog-ID guard in LicenseService.activate() + * and validate(). Without this guard, any LS license (from any store, any + * product) returns valid: true on /v1/licenses/validate and unlocks Sencho. + * + * The pure-function tests below exercise resolveSenchoVariantFromMeta() + * directly. The activate() / validate() tests mock axios and DatabaseService + * so we can drive each rejection branch and assert that no DB writes happen + * on a non-matching response. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + resolveSenchoVariantFromMeta, + SENCHO_LS_STORE_ID, + SENCHO_LS_PRODUCT_ID_SKIPPER, + SENCHO_LS_PRODUCT_ID_ADMIRAL, +} from '../services/LicenseService'; + +// LS catalog used in the live store. Tests reference these directly so a future +// catalog change forces an explicit test update rather than silently passing. +const VARIANT_SKIPPER_MONTHLY = 1453178; +const VARIANT_SKIPPER_ANNUAL = 1453197; +const VARIANT_SKIPPER_LIFETIME = 1453198; +const VARIANT_ADMIRAL_MONTHLY = 1453209; +const VARIANT_ADMIRAL_ANNUAL = 1453212; +const VARIANT_ADMIRAL_LIFETIME = 1453217; + +const buildMeta = (overrides: Partial<{ store_id: number; product_id: number; variant_id: number }> = {}) => ({ + store_id: SENCHO_LS_STORE_ID, + product_id: SENCHO_LS_PRODUCT_ID_SKIPPER, + variant_id: VARIANT_SKIPPER_MONTHLY, + ...overrides, +}); + +describe('resolveSenchoVariantFromMeta()', () => { + it('returns null for undefined meta', () => { + expect(resolveSenchoVariantFromMeta(undefined)).toBeNull(); + }); + + it('returns null when store_id is missing', () => { + expect(resolveSenchoVariantFromMeta({ product_id: SENCHO_LS_PRODUCT_ID_SKIPPER, variant_id: VARIANT_SKIPPER_MONTHLY })).toBeNull(); + }); + + it('returns null when store_id does not match the Sencho store', () => { + expect(resolveSenchoVariantFromMeta(buildMeta({ store_id: 999999 }))).toBeNull(); + }); + + it('returns null when product_id is missing', () => { + expect(resolveSenchoVariantFromMeta({ store_id: SENCHO_LS_STORE_ID, variant_id: VARIANT_SKIPPER_MONTHLY })).toBeNull(); + }); + + it('returns null when product_id is not a recognized Sencho product', () => { + expect(resolveSenchoVariantFromMeta(buildMeta({ product_id: 555555 }))).toBeNull(); + }); + + it('returns null when variant_id is missing', () => { + expect(resolveSenchoVariantFromMeta({ store_id: SENCHO_LS_STORE_ID, product_id: SENCHO_LS_PRODUCT_ID_SKIPPER })).toBeNull(); + }); + + it('returns null when variant_id is unknown', () => { + expect(resolveSenchoVariantFromMeta(buildMeta({ variant_id: 1 }))).toBeNull(); + }); + + it.each([ + ['Skipper Monthly', VARIANT_SKIPPER_MONTHLY], + ['Skipper Annual', VARIANT_SKIPPER_ANNUAL], + ['Skipper Lifetime', VARIANT_SKIPPER_LIFETIME], + ])('resolves %s variant to skipper', (_label, variantId) => { + expect(resolveSenchoVariantFromMeta(buildMeta({ product_id: SENCHO_LS_PRODUCT_ID_SKIPPER, variant_id: variantId }))).toBe('skipper'); + }); + + it.each([ + ['Admiral Monthly', VARIANT_ADMIRAL_MONTHLY], + ['Admiral Annual', VARIANT_ADMIRAL_ANNUAL], + ['Admiral Lifetime', VARIANT_ADMIRAL_LIFETIME], + ])('resolves %s variant to admiral', (_label, variantId) => { + expect(resolveSenchoVariantFromMeta(buildMeta({ product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL, variant_id: variantId }))).toBe('admiral'); + }); +}); + +const { + mockAxiosPost, + mockGetSystemState, + mockSetSystemState, +} = vi.hoisted(() => ({ + mockAxiosPost: vi.fn(), + mockGetSystemState: vi.fn(), + mockSetSystemState: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: { post: mockAxiosPost, isAxiosError: () => false }, + isAxiosError: () => false, +})); + +vi.mock('../services/DatabaseService', () => ({ + DatabaseService: { + getInstance: () => ({ + getSystemState: mockGetSystemState, + setSystemState: mockSetSystemState, + }), + }, +})); + +describe('LicenseService.activate() - catalog ID guard', () => { + let svc: import('../services/LicenseService').LicenseService; + + beforeEach(async () => { + vi.clearAllMocks(); + mockGetSystemState.mockReturnValue('test-instance-uuid'); + const mod = await import('../services/LicenseService'); + svc = mod.LicenseService.getInstance(); + }); + + const buildActivationResponse = (meta: object | undefined) => ({ + data: { + activated: true, + license_key: { id: 1, status: 'active', key: 'k', activation_limit: 1, activation_usage: 1, created_at: '2026-01-01', expires_at: null }, + instance: { id: 'ls-inst', name: 'test', created_at: '2026-01-01' }, + meta, + }, + }); + + it('rejects activation when meta is absent', async () => { + mockAxiosPost.mockResolvedValueOnce(buildActivationResponse(undefined)); + const result = await svc.activate('VALID-LOOKING-KEY'); + expect(result.success).toBe(false); + expect(result.error).toBe('This license key is not valid for Sencho.'); + expect(mockSetSystemState).not.toHaveBeenCalledWith('license_status', 'active'); + expect(mockSetSystemState).not.toHaveBeenCalledWith('license_key', expect.any(String)); + }); + + it('rejects activation when store_id does not match', async () => { + mockAxiosPost.mockResolvedValueOnce(buildActivationResponse(buildMeta({ store_id: 999999 }))); + const result = await svc.activate('FOREIGN-STORE-KEY'); + expect(result.success).toBe(false); + expect(result.error).toBe('This license key is not valid for Sencho.'); + expect(mockSetSystemState).not.toHaveBeenCalledWith('license_status', 'active'); + }); + + it('rejects activation when product_id is not a Sencho product', async () => { + mockAxiosPost.mockResolvedValueOnce(buildActivationResponse(buildMeta({ product_id: 555555 }))); + const result = await svc.activate('OTHER-PRODUCT-KEY'); + expect(result.success).toBe(false); + expect(mockSetSystemState).not.toHaveBeenCalledWith('license_status', 'active'); + }); + + it('rejects activation when variant_id is unknown', async () => { + mockAxiosPost.mockResolvedValueOnce(buildActivationResponse(buildMeta({ variant_id: 1 }))); + const result = await svc.activate('UNKNOWN-VARIANT-KEY'); + expect(result.success).toBe(false); + expect(mockSetSystemState).not.toHaveBeenCalledWith('license_status', 'active'); + }); + + it('writes nothing to system_state when the catalog guard rejects', async () => { + // Stronger than checking individual keys: any future code that adds a + // setSystemState() call above the guard would silently break the + // "rejection persists no state" invariant unless the test catches it. + mockAxiosPost.mockResolvedValueOnce(buildActivationResponse(buildMeta({ store_id: 999999 }))); + await svc.activate('FOREIGN-STORE-KEY'); + expect(mockSetSystemState).not.toHaveBeenCalled(); + }); + + it('succeeds and stores admiral variant for an Admiral Lifetime license', async () => { + mockAxiosPost.mockResolvedValueOnce(buildActivationResponse({ + store_id: SENCHO_LS_STORE_ID, + product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL, + variant_id: VARIANT_ADMIRAL_LIFETIME, + variant_name: 'Admiral Lifetime', + product_name: 'Sencho Admiral', + })); + const result = await svc.activate('GOOD-ADMIRAL-KEY'); + expect(result.success).toBe(true); + expect(mockSetSystemState).toHaveBeenCalledWith('license_status', 'active'); + expect(mockSetSystemState).toHaveBeenCalledWith('license_variant_type', 'admiral'); + expect(mockSetSystemState).toHaveBeenCalledWith('license_variant_id', String(VARIANT_ADMIRAL_LIFETIME)); + }); + + it('succeeds and stores skipper variant for a Skipper Monthly license', async () => { + mockAxiosPost.mockResolvedValueOnce(buildActivationResponse({ + store_id: SENCHO_LS_STORE_ID, + product_id: SENCHO_LS_PRODUCT_ID_SKIPPER, + variant_id: VARIANT_SKIPPER_MONTHLY, + variant_name: 'Skipper Monthly', + product_name: 'Sencho Skipper', + })); + const result = await svc.activate('GOOD-SKIPPER-KEY'); + expect(result.success).toBe(true); + expect(mockSetSystemState).toHaveBeenCalledWith('license_variant_type', 'skipper'); + }); +}); + +describe('LicenseService.validate() - catalog ID guard', () => { + let svc: import('../services/LicenseService').LicenseService; + + beforeEach(async () => { + vi.clearAllMocks(); + // validate() reads license_key + license_instance_id from DB before + // calling LS; provide both so the call proceeds to the response check. + mockGetSystemState.mockImplementation((key: string) => { + if (key === 'license_key') return 'STORED-KEY'; + if (key === 'license_instance_id') return 'stored-instance'; + return null; + }); + const mod = await import('../services/LicenseService'); + svc = mod.LicenseService.getInstance(); + }); + + const buildValidationResponse = (meta: object | undefined) => ({ + data: { + valid: true, + license_key: { id: 1, status: 'active', key: 'k', activation_limit: 1, activation_usage: 1, created_at: '2026-01-01', expires_at: null }, + meta, + }, + }); + + it('rejects validation when meta is absent and disables the license', async () => { + mockAxiosPost.mockResolvedValueOnce(buildValidationResponse(undefined)); + const result = await svc.validate(); + expect(result.success).toBe(false); + expect(result.error).toBe('License is not valid for Sencho.'); + expect(mockSetSystemState).toHaveBeenCalledWith('license_status', 'disabled'); + }); + + it('rejects validation when store_id no longer matches and disables the license', async () => { + mockAxiosPost.mockResolvedValueOnce(buildValidationResponse(buildMeta({ store_id: 999999 }))); + const result = await svc.validate(); + expect(result.success).toBe(false); + expect(mockSetSystemState).toHaveBeenCalledWith('license_status', 'disabled'); + }); + + it('keeps the license active when the catalog meta still matches', async () => { + mockAxiosPost.mockResolvedValueOnce(buildValidationResponse({ + store_id: SENCHO_LS_STORE_ID, + product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL, + variant_id: VARIANT_ADMIRAL_ANNUAL, + variant_name: 'Admiral Annual', + product_name: 'Sencho Admiral', + })); + const result = await svc.validate(); + expect(result.success).toBe(true); + expect(mockSetSystemState).toHaveBeenCalledWith('license_status', 'active'); + expect(mockSetSystemState).toHaveBeenCalledWith('license_variant_type', 'admiral'); + }); + + it('marks the license expired when LS reports key_status=expired even with matching meta', async () => { + mockAxiosPost.mockResolvedValueOnce({ + data: { + valid: true, + license_key: { id: 1, status: 'expired', key: 'k', activation_limit: 1, activation_usage: 1, created_at: '2026-01-01', expires_at: '2026-04-01' }, + meta: { + store_id: SENCHO_LS_STORE_ID, + product_id: SENCHO_LS_PRODUCT_ID_SKIPPER, + variant_id: VARIANT_SKIPPER_MONTHLY, + variant_name: 'Skipper Monthly', + product_name: 'Sencho Skipper', + }, + }, + }); + const result = await svc.validate(); + expect(result.success).toBe(false); + expect(result.error).toBe('License has expired'); + expect(mockSetSystemState).toHaveBeenCalledWith('license_status', 'expired'); + expect(mockSetSystemState).not.toHaveBeenCalledWith('license_status', 'active'); + }); + + it('disables the license when LS reports key_status=disabled even with matching meta', async () => { + mockAxiosPost.mockResolvedValueOnce({ + data: { + valid: true, + license_key: { id: 1, status: 'disabled', key: 'k', activation_limit: 1, activation_usage: 1, created_at: '2026-01-01', expires_at: null }, + meta: { + store_id: SENCHO_LS_STORE_ID, + product_id: SENCHO_LS_PRODUCT_ID_ADMIRAL, + variant_id: VARIANT_ADMIRAL_LIFETIME, + variant_name: 'Admiral Lifetime', + product_name: 'Sencho Admiral', + }, + }, + }); + const result = await svc.validate(); + expect(result.success).toBe(false); + expect(result.error).toBe('License has been disabled'); + expect(mockSetSystemState).toHaveBeenCalledWith('license_status', 'disabled'); + expect(mockSetSystemState).not.toHaveBeenCalledWith('license_status', 'active'); + }); +}); diff --git a/backend/src/services/LicenseService.ts b/backend/src/services/LicenseService.ts index ecfbf487..21c368bc 100644 --- a/backend/src/services/LicenseService.ts +++ b/backend/src/services/LicenseService.ts @@ -123,6 +123,56 @@ interface LemonSqueezyValidationResponse { 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 @@ -207,10 +257,15 @@ export class LicenseService { } /** - * Resolve Lemon Squeezy metadata to the internal variant type. - * Checks both variant_name and product_name since LS variant names may only - * describe the billing period (e.g. "Lifetime", "Monthly") while the product - * name contains the tier (e.g. "Sencho Admiral"). + * 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(); @@ -219,11 +274,24 @@ export class LicenseService { return 'skipper'; } - /** Persist variant metadata from Lemon Squeezy response to DB. */ - private storeVariantMeta(db: DatabaseService, meta: { variant_name?: string; variant_id?: number; product_name?: string }): void { + /** + * 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); - db.setSystemState('license_variant_type', this.resolveVariantType(meta.variant_name, meta.product_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)); @@ -242,11 +310,30 @@ export class LicenseService { */ public getVariant(): LicenseVariant { const db = DatabaseService.getInstance(); - const variantName = db.getSystemState('license_variant_name'); - const productName = db.getSystemState('license_product_name') || undefined; + const variantIdStr = db.getSystemState('license_variant_id'); const storedType = db.getSystemState('license_variant_type'); - // Self-heal: if we have source metadata, always cross-check the stored 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) { @@ -255,7 +342,7 @@ export class LicenseService { return resolved; } - // No variant name available; trust stored type if valid + // No source metadata available; trust the stored type if it parses. if (isLicenseVariant(storedType)) return normalizeVariant(storedType); return null; @@ -355,6 +442,15 @@ export class LicenseService { 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.' }; + } + // Store license data db.setSystemState('license_key', licenseKey); db.setSystemState('license_instance_id', data.instance?.id || ''); @@ -375,7 +471,7 @@ export class LicenseService { db.setSystemState('license_product_name', data.meta.product_name); } if (data.meta) { - this.storeVariantMeta(db, data.meta); + this.storeVariantMeta(db, data.meta, variantType); } if (data.meta?.customer_id) { db.setSystemState('customer_id', String(data.meta.customer_id)); @@ -475,7 +571,6 @@ export class LicenseService { ); const data = response.data; - db.setSystemState('license_last_validated', Date.now().toString()); if (!data.valid) { // License revoked or invalid @@ -484,6 +579,24 @@ export class LicenseService { 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') { @@ -510,7 +623,7 @@ export class LicenseService { db.setSystemState('license_product_name', data.meta.product_name); } if (data.meta) { - this.storeVariantMeta(db, 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));