diff --git a/apps/desktop/src/main/services/LicenseService.ts b/apps/desktop/src/main/services/LicenseService.ts index 608aad9..da9cb26 100644 --- a/apps/desktop/src/main/services/LicenseService.ts +++ b/apps/desktop/src/main/services/LicenseService.ts @@ -1,13 +1,18 @@ // src/main/services/LicenseService.ts // Phase 11: Freemium 라이센스 관리 — Feature Gating + 사용량 추적 +// +// 엔타이틀먼트는 두 갈래로 분리해서 보관한다. +// - 키 엔타이틀먼트: 로컬에 저장한 서명 키(또는 dev fixture, 로컬 Reverse Trial) +// - 클라우드 엔타이틀먼트: 로그인 중 Supabase subscriptions에서 동기화한 티어 +// 실효 티어는 둘 중 높은 쪽이다. 클라우드 로그인/로그아웃은 키를 건드리지 않고, 키는 자기 +// 자신이 만료됐을 때만 지운다. 순수 규칙은 ./license/*, IO는 포트(LicenseStore, +// UsageRepository)를 통해 주입한다. import { EventEmitter } from 'events' import { createHash } from 'crypto' import os from 'os' -import { eq, and } from 'drizzle-orm' import { getLogger } from './LoggerService' -import { getDatabase, isLocalMode } from '../db' -import { dailyUsage } from '../db/schema' +import { isLocalMode } from '../db' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { LicenseTier, @@ -20,8 +25,38 @@ import type { } from '@d3ro/core/types' import { Feature } from '@d3ro/core/types' import { normalizeEntitlementTier } from '@d3ro/core/entitlement' -import { PLAN_QUOTA } from '@d3ro/core/plan-catalog' -import { verifySignedLicenseKey, createDefaultTrialPayload } from '@d3ro/core/utils/crypto-license' +import { + verifySignedLicenseKey, + createDefaultTrialPayload, + type LicenseVerificationResult, +} from '@d3ro/core/utils/crypto-license' +import { + CLOUD_FEATURES, + FEATURE_MIN_TIER, + buildTierComparison, + quotaLimitFor, + tierAtLeast, + upgradeTargetFor, +} from './license/license-policy' +import { + LOCAL_TRIAL_KEY_PREFIX, + composeLicenseInfo, + effectiveTier, + entitlementFromVerification, + resolveCachedCloudTier, + resolveStoredKey, + trialEntitlement, + type KeyEntitlement, +} from './license/license-entitlement' +import { + FileLicenseStore, + deleteLicenseStoreFile, + type LicenseStore, + type LicenseStoreEntries, +} from './license/license-store' +import { SqliteUsageRepository, type UsageRepository } from './license/usage-repository' + +export { HISTORY_RETENTION_DAYS } from './license/license-policy' const logger = getLogger('license') @@ -48,91 +83,6 @@ function generateMachineId(): string { return createHash('sha256').update(raw).digest('hex').substring(0, 32) } -// ── 티어별 쿼터 한도 ────────────────────────────────────── -// -1 = 무제한, 값이 있으면 일일 한도. -// 빅뱅 Phase 4: 로컬 기능은 전부 무제한. 클라우드 기능만 quota 적용. -// Phase 3.2: 엔트리 흡수 전략으로 free 쿼터 대폭 상향 (5 → 250). -// 오버리지 크레딧은 서버 subscriptions.overage_credits 컬럼에서 별도 관리. -// Phase 3.2: 모델별 쿼터. 서버(quota.ts)와 동기화. -// 클라이언트에서는 PREMIUM_LLM feature로 묶어서 canUse() 체크하고, -// 실제 모델별 세분화는 서버 llm-proxy가 담당. -// 여기의 값은 Settings UI 표시용 + upgrade 유도 시점 판단용. -// 값의 정본은 @d3ro/core PLAN_QUOTA. PREMIUM_LLM은 모델별 한도의 합으로 표시하고, -// 한 모델이라도 무제한이면 한도를 두지 않는다(Free는 Haiku 주 250회). -function premiumLlmLimit(tier: LicenseTier): number | undefined { - const quota = PLAN_QUOTA[tier] - const models = [quota.llm_haiku, quota.llm_sonnet, quota.llm_opus].filter((q) => q.limit !== 0) - if (models.some((q) => q.limit < 0)) return undefined - return models.reduce((sum, q) => sum + q.limit, 0) -} - -const QUOTA_LIMITS: Record>> = Object.fromEntries( - (['free', 'pro', 'pro_plus', 'team', 'enterprise'] as const).map((tier) => { - const limit = premiumLlmLimit(tier) - return [tier, limit === undefined ? {} : { [Feature.PREMIUM_LLM]: limit }] - }), -) as Record>> - -// ── 기능별 최소 필요 티어 ────────────────────────────────── -// 빅뱅 Phase 4: 모든 로컬 기능을 'free'로 해방. -// 클라우드 기능(PREMIUM_LLM, CLOUD_SYNC)만 로그인 요구 + 티어 gate. -const FEATURE_MIN_TIER: Record = { - // ── 로컬 기능 (전부 free) ── - [Feature.DICTATION]: 'free', - [Feature.LLM_PROCESS]: 'free', - [Feature.HISTORY_UNLIMITED]: 'free', - [Feature.HISTORY_EXPORT]: 'free', - [Feature.CUSTOM_INSTRUCTION_CREATE]: 'free', - [Feature.LIVE_CAPTION]: 'free', - [Feature.SCREEN_CONTEXT]: 'free', - [Feature.VOICE_MEMO]: 'free', - [Feature.VOICE_COMMAND]: 'free', - [Feature.LLM_CHAIN]: 'free', - [Feature.FILE_TRANSCRIPTION]: 'free', - [Feature.VOICE_CONVERSATION]: 'free', - [Feature.DICTATION_TEMPLATE]: 'free', - [Feature.MEETING_SUMMARY]: 'free', - [Feature.LOCAL_RAG]: 'free', - [Feature.OS_AUTOMATION]: 'free', - - // ── 클라우드 기능 (로그인 필요 + 일부는 pro gate) ── - [Feature.PREMIUM_LLM]: 'free', // 로그인하면 free도 5회/일, pro는 500/일, pro_plus는 무제한 - [Feature.CLOUD_SYNC]: 'free', // 로그인만 하면 free도 사용 가능 - [Feature.TEAM_WORKSPACE]: 'team', -} - -// ── 클라우드 기능 집합 (익명 로컬 모드에서는 login_required) ── -const CLOUD_FEATURES: Set = new Set([ - Feature.PREMIUM_LLM, - Feature.CLOUD_SYNC, - Feature.TEAM_WORKSPACE, -]) - -// ── 히스토리 보존 기간 (일) ──────────────────────────────── -export const HISTORY_RETENTION_DAYS: Record = { - free: 3, - pro: -1, - pro_plus: -1, - team: -1, - enterprise: -1, -} - -// ── 티어 순서 (비교용) ──────────────────────────────────── -const TIER_ORDER: Record = { - free: 0, - pro: 1, - pro_plus: 2, - team: 3, - enterprise: 4, -} - -/** 오프라인 유예 기간: 30일 */ -const OFFLINE_GRACE_PERIOD_MS = 30 * 24 * 60 * 60 * 1000 - -function tierAtLeast(current: LicenseTier, required: LicenseTier): boolean { - return TIER_ORDER[current] >= TIER_ORDER[required] -} - function getTodayDate(): string { const now = new Date() const y = now.getFullYear() @@ -148,120 +98,126 @@ function getTomorrowMidnight(): string { return tomorrow.toISOString() } -// ── LicenseService 싱글톤 ────────────────────────────────── -class LicenseService extends EventEmitter { - private _info: LicenseInfo +/** Key fields cleared from the store when a key is deactivated or has expired. */ +const CLEARED_KEY_ENTRIES: LicenseStoreEntries = { + licenseKey: null, + licenseKeyTier: null, + licenseActivatedAt: null, + licenseExpiresAt: null, + licenseCustomerEmail: null, + licenseIsTrial: false, + licenseTrialExpiresAt: null, + licenseOfflineGraceUntil: null, +} - constructor() { +// ── LicenseService 싱글톤 ────────────────────────────────── +export class LicenseService extends EventEmitter { + private _machineId = '' + private _key: KeyEntitlement | null = null + private _keyVerifiedAt: number | null = null + private _cloudTier: LicenseTier = 'free' + private _cloudVerifiedAt: number | null = null + + constructor( + private readonly _store: LicenseStore = new FileLicenseStore(), + private readonly _usage: UsageRepository = new SqliteUsageRepository(), + ) { super() - this._info = { - tier: 'free', - licenseKey: null, - activatedAt: null, - machineId: '', - lastVerifiedAt: null, - offlineGraceUntil: null, - } } - /** 서비스 초기화 — bootstrap에서 호출 */ + /** 서비스 초기화 — bootstrap에서 호출. 저장소에서 상태를 다시 읽는다. */ initialize(): void { - // machineId: 하드웨어 기반 해시 생성 - const storedMachineId = this._readStoredField('licenseMachineId') - const generatedId = generateMachineId() - - if (storedMachineId && storedMachineId === generatedId) { - this._info.machineId = storedMachineId - } else if (storedMachineId) { - // 하드웨어가 바뀐 경우 — 기존 ID 유지 (이미 활성화된 키와 연결) - this._info.machineId = storedMachineId - logger.warn('Hardware changed but keeping existing machineId for license continuity') - } else { - this._info.machineId = generatedId - this._writeStoredField('licenseMachineId', this._info.machineId) - } - - // 저장된 라이센스 정보 로드 - const storedTier = this._readStoredField('licenseTier') - const storedKey = this._readStoredField('licenseKey') - const storedActivatedAt = this._readStoredField('licenseActivatedAt') - const storedLastVerified = this._readStoredField('licenseLastVerifiedAt') - const storedGrace = this._readStoredField('licenseOfflineGraceUntil') - const storedIsTrial = this._readStoredField('licenseIsTrial') - const storedTrialExpiresAt = this._readStoredField('licenseTrialExpiresAt') - const storedExpiresAt = this._readStoredField('licenseExpiresAt') - const storedCustomerEmail = this._readStoredField('licenseCustomerEmail') + this._machineId = this._resolveMachineId() const now = Date.now() - if (storedTier && storedTier !== 'free') { - this._info.tier = storedTier - this._info.licenseKey = storedKey ?? null - this._info.activatedAt = storedActivatedAt ?? null - this._info.lastVerifiedAt = storedLastVerified ?? null - this._info.offlineGraceUntil = storedGrace ?? null - this._info.isTrial = storedIsTrial ?? false - this._info.trialExpiresAt = storedTrialExpiresAt ?? null - this._info.expiresAt = storedExpiresAt ?? null - this._info.customerEmail = storedCustomerEmail ?? null + // 1. 저장된 키를 다시 검증 (서명 + 키 자체 만료 + 머신 바인딩). 온라인 유예 창은 쓰지 않는다. + const keyResolution = resolveStoredKey( + { + licenseKey: this._store.read('licenseKey'), + keyTier: this._store.read('licenseKeyTier') ?? this._legacyTrialTier(), + activatedAt: this._store.read('licenseActivatedAt'), + customerEmail: this._store.read('licenseCustomerEmail'), + trialExpiresAt: this._store.read('licenseTrialExpiresAt'), + }, + now, + (key) => this._verifyKey(key), + ) - // 1. 체험판 만료 체크 (14-Day Reverse Trial) - if (this._info.isTrial && this._info.trialExpiresAt && now > this._info.trialExpiresAt) { - logger.info('14-day Reverse Trial expired, smoothly transitioning to 100% Free on-device mode') - this._downgradeToFree() - } else if (this._info.expiresAt && now > this._info.expiresAt) { - // 2. 정기 라이센스 만료 체크 - logger.warn('License expired, transitioning to Free tier') - this._downgradeToFree() - } else if (this._info.offlineGraceUntil && now > this._info.offlineGraceUntil) { - // 3. 오프라인 유예 기간 체크 (30일 경과) - logger.warn('Offline grace period expired, transitioning to Free tier') - this._downgradeToFree() - } + this._key = null + this._keyVerifiedAt = null + switch (keyResolution.status) { + case 'active': + this._key = keyResolution.entitlement + this._keyVerifiedAt = now + break + case 'expired': + logger.info( + keyResolution.kind === 'trial' + ? '14-day Reverse Trial expired, smoothly transitioning to 100% Free on-device mode' + : 'License key expired, dropping key entitlement', + ) + this._store.writeAll(CLEARED_KEY_ENTRIES) + break + case 'unverified': + // 검증 키 누락 등 일시적 사유일 수 있으므로 저장된 키는 지우지 않는다. + logger.warn(`Stored license key could not be verified, not applied: ${keyResolution.message}`) + break + case 'none': + break } - logger.info(`LicenseService initialized: tier=${this._info.tier}, trial=${this._info.isTrial ?? false}, machineId=${this._info.machineId.substring(0, 8)}...`) + // 2. 캐시된 클라우드 티어 (구버전 파일은 결합 licenseTier에서 복원) + const storedCloudTier = this._store.read('licenseCloudTier') + const storedCombinedTier = this._store.read('licenseTier') + this._cloudTier = resolveCachedCloudTier(storedCloudTier, storedCombinedTier, keyResolution) + this._cloudVerifiedAt = + this._cloudTier === 'free' + ? null + : this._store.read('licenseCloudVerifiedAt') ?? + this._store.read('licenseLastVerifiedAt') + + if (storedCloudTier === null || storedCombinedTier !== this.tier) { + this._persistSummary() + } + + logger.info( + `LicenseService initialized: tier=${this.tier}, key=${this._key?.tier ?? 'none'}, cloud=${this._cloudTier}, trial=${this._key?.isTrial ?? false}, machineId=${this._machineId.substring(0, 8)}...`, + ) } /** 14일 Reverse-Trial 시작 (신용카드 불필요) */ startTrial(userEmail: string = 'trial-user@local'): ActivateLicenseResult { - const trialEverStarted = this._readStoredField('licenseTrialEverStarted') + const trialEverStarted = this._store.read('licenseTrialEverStarted') if (trialEverStarted) { - return { success: false, tier: this._info.tier, message: 'Reverse trial already used on this machine' } + return { success: false, tier: this.tier, message: 'Reverse trial already used on this machine' } } - const trialPayload = createDefaultTrialPayload(this._info.machineId, userEmail) - this._info.tier = 'pro_plus' - this._info.licenseKey = `TRIAL-PRO-PLUS-${this._info.machineId.substring(0, 8)}` - this._info.activatedAt = trialPayload.issuedAt - this._info.lastVerifiedAt = trialPayload.issuedAt - this._info.offlineGraceUntil = trialPayload.expiresAt - this._info.isTrial = true - this._info.trialExpiresAt = trialPayload.expiresAt - this._info.customerEmail = trialPayload.customerEmail - - this._writeStoredField('licenseTier', 'pro_plus') - this._writeStoredField('licenseKey', this._info.licenseKey) - this._writeStoredField('licenseActivatedAt', this._info.activatedAt) - this._writeStoredField('licenseLastVerifiedAt', this._info.lastVerifiedAt) - this._writeStoredField('licenseOfflineGraceUntil', this._info.offlineGraceUntil) - this._writeStoredField('licenseIsTrial', true) - this._writeStoredField('licenseTrialExpiresAt', this._info.trialExpiresAt) - this._writeStoredField('licenseCustomerEmail', this._info.customerEmail) - this._writeStoredField('licenseTrialEverStarted', true) + const trialPayload = createDefaultTrialPayload(this._machineId, userEmail) + const licenseKey = `${LOCAL_TRIAL_KEY_PREFIX}PRO-PLUS-${this._machineId.substring(0, 8)}` + this._setKey(trialEntitlement(licenseKey, trialPayload), trialPayload.issuedAt, { + licenseTrialEverStarted: true, + }) this.emit('tier-changed', this.getInfo()) - logger.info(`Started 14-day Reverse Trial (Pro+) for machine ${this._info.machineId.substring(0, 8)}`) + logger.info(`Started 14-day Reverse Trial (Pro+) for machine ${this._machineId.substring(0, 8)}`) return { success: true, tier: 'pro_plus', message: '14-Day Reverse Trial Activated' } } // ── Public API ────────────────────────────────────────── + /** 실효 티어 = max(키 티어, 클라우드 티어) */ get tier(): LicenseTier { - return this._info.tier + return effectiveTier({ key: this._key, cloudTier: this._cloudTier }) } getInfo(): LicenseInfo { - return { ...this._info } + return composeLicenseInfo({ + machineId: this._machineId, + key: this._key, + keyVerifiedAt: this._keyVerifiedAt, + cloudTier: this._cloudTier, + cloudVerifiedAt: this._cloudVerifiedAt, + }) } /** @@ -284,10 +240,11 @@ class LicenseService extends EventEmitter { } } + const tier = this.tier const minTier = FEATURE_MIN_TIER[feature] // 티어 체크 (현재 로컬 기능은 전부 'free'라 익명도 통과) - if (!tierAtLeast(this._info.tier, minTier)) { + if (!tierAtLeast(tier, minTier)) { return { allowed: false, reason: 'tier_required', @@ -296,15 +253,13 @@ class LicenseService extends EventEmitter { } // 쿼터 체크 (쿼터가 있는 기능만 — 현재는 PREMIUM_LLM만 해당) - const tierLimits = QUOTA_LIMITS[this._info.tier] - const limit = tierLimits[feature] - if (limit !== undefined) { + if (quotaLimitFor(tier, feature) !== undefined) { const quota = this.getUsage(feature) if (quota.remaining === 0) { return { allowed: false, reason: 'quota_exceeded', - requiredTier: this._info.tier === 'free' ? 'pro' : 'pro_plus', + requiredTier: upgradeTargetFor(tier), quota, } } @@ -315,39 +270,31 @@ class LicenseService extends EventEmitter { /** * 빅뱅 Phase 4: Supabase `subscriptions` 테이블에서 받은 티어를 로컬 캐시에 반영. - * CloudSyncService._onAuthenticated에서 호출. + * CloudSyncService._onAuthenticated에서 호출. 키 엔타이틀먼트는 건드리지 않는다. */ syncFromCloud(tier: LicenseTier): void { const normalized = normalizeEntitlementTier(tier) - if (this._info.tier === normalized) return - const previous = this._info.tier - this._info.tier = normalized - this._info.lastVerifiedAt = Date.now() - this._writeStoredField('licenseTier', normalized) - this._writeStoredField('licenseLastVerifiedAt', this._info.lastVerifiedAt) - this.emit('tier-changed', this.getInfo()) - logger.info(`License tier synced from cloud: ${previous} → ${normalized}`) + if (this._cloudTier === normalized) return + const previous = this.tier + this._cloudTier = normalized + this._cloudVerifiedAt = Date.now() + this._persistSummary() + this._emitIfTierChanged(previous) + logger.info(`License cloud tier synced: ${normalized} (effective ${previous} → ${this.tier})`) } /** - * 빅뱅 Phase 4: 로그아웃 시 로컬 모드로 복귀 — 캐시된 pro/pro_plus 티어를 free로 리셋. - * CloudSyncService._onSignOut에서 호출. + * 빅뱅 Phase 4: 로그아웃 시 로컬 모드로 복귀 — 캐시된 클라우드 티어만 free로 리셋. + * CloudSyncService._onSignOut에서 호출. 로컬에 활성화한 라이선스 키는 유지한다. */ resetToFree(): void { - if (this._info.tier === 'free') return - const previous = this._info.tier - this._info.tier = 'free' - this._info.licenseKey = null - this._info.activatedAt = null - this._info.lastVerifiedAt = null - this._info.offlineGraceUntil = null - this._writeStoredField('licenseTier', 'free') - this._writeStoredField('licenseKey', null) - this._writeStoredField('licenseActivatedAt', null) - this._writeStoredField('licenseLastVerifiedAt', null) - this._writeStoredField('licenseOfflineGraceUntil', null) - this.emit('tier-changed', this.getInfo()) - logger.info(`License tier reset to free (was ${previous}) — local mode`) + if (this._cloudTier === 'free' && this._cloudVerifiedAt === null) return + const previous = this.tier + this._cloudTier = 'free' + this._cloudVerifiedAt = null + this._persistSummary() + this._emitIfTierChanged(previous) + logger.info(`License cloud tier reset to free (effective ${previous} → ${this.tier}) — local mode`) } /** @@ -384,16 +331,14 @@ class LicenseService extends EventEmitter { } // 쿼터 있는 기능만 DB에 기록 - const tierLimits = QUOTA_LIMITS[this._info.tier] - if (tierLimits[feature] !== undefined) { - this._incrementUsage(feature) + if (quotaLimitFor(this.tier, feature) !== undefined) { + this._usage.increment(getTodayDate(), feature) } } /** 일일 사용량 조회 */ getUsage(feature: Feature): UsageQuota { - const tierLimits = QUOTA_LIMITS[this._info.tier] - const limit = tierLimits[feature] + const limit = quotaLimitFor(this.tier, feature) // 쿼터 없는 기능 (무제한) if (limit === undefined) { @@ -406,8 +351,7 @@ class LicenseService extends EventEmitter { } } - const today = getTodayDate() - const used = this._getUsageCount(today, feature) + const used = this._usage.getCount(getTodayDate(), feature) return { feature, @@ -435,13 +379,7 @@ class LicenseService extends EventEmitter { return { success: false, tier: 'free', message: 'License key is empty' } } - const verificationConfig = resolveLicenseVerificationConfig() - const verification = verifySignedLicenseKey( - trimmedKey, - this._info.machineId, - verificationConfig.publicKeyPem, - verificationConfig, - ) + const verification = this._verifyKey(trimmedKey) if (!verification.valid) { return { success: false, tier: 'free', message: verification.message } } @@ -449,46 +387,35 @@ class LicenseService extends EventEmitter { const activatedTier = verification.tier logger.info(`License validated (${verification.reason}): tier=${activatedTier}`) const now = Date.now() - this._info = { - ...this._info, - tier: activatedTier, - licenseKey: trimmedKey, - activatedAt: now, - lastVerifiedAt: now, - offlineGraceUntil: now + OFFLINE_GRACE_PERIOD_MS, - expiresAt: verification.payload?.expiresAt ?? null, - customerEmail: verification.payload?.customerEmail ?? null, - isTrial: verification.payload?.isTrial ?? false, - } - this._persistLicenseInfo() + this._setKey(entitlementFromVerification(trimmedKey, verification, now), now) this.emit('tier-changed', this.getInfo()) return { success: true, tier: activatedTier, message: verification.message } } - /** 라이센스 비활성화 (Free로 복귀) */ + /** 라이센스 키 비활성화 — 키 엔타이틀먼트만 제거 (클라우드 티어는 유지) */ async deactivate(): Promise { - this._downgradeToFree() + this._key = null + this._keyVerifiedAt = null + this._store.writeAll({ ...CLEARED_KEY_ENTRIES, ...this._summaryEntries() }) this.emit('tier-changed', this.getInfo()) - logger.info('License deactivated, reverted to free') + logger.info(`License key deactivated, effective tier=${this.tier}`) } /** 업그레이드 유도 이벤트 발생 */ promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void { - const minTier = FEATURE_MIN_TIER[feature] + const tier = this.tier // 쿼터 초과: 현재 tier 상위. login_required: free(로그인하면 바로 사용 가능). 그 외: feature의 최소 tier. const requiredTier: LicenseTier = reason === 'quota_exceeded' - ? this._info.tier === 'free' - ? 'pro' - : 'pro_plus' + ? upgradeTargetFor(tier) : reason === 'login_required' ? 'free' - : minTier + : FEATURE_MIN_TIER[feature] const event: UpgradePromptEvent = { feature, reason, - currentTier: this._info.tier, + currentTier: tier, requiredTier, quota: reason === 'quota_exceeded' ? this.getUsage(feature) : undefined, } @@ -498,249 +425,68 @@ class LicenseService extends EventEmitter { /** 티어 비교표 생성 */ getTierComparison(): TierComparison[] { - return [ - { - feature: Feature.DICTATION, - featureLabel: 'license.feature.dictation', - free: 'unlimited (local)', - pro: 'unlimited', - proPlus: 'unlimited', - }, - { - feature: Feature.LLM_PROCESS, - featureLabel: 'license.feature.llmProcess', - free: 'unlimited (local)', - pro: 'unlimited', - proPlus: 'unlimited', - }, - { - feature: Feature.HISTORY_UNLIMITED, - featureLabel: 'license.feature.historyUnlimited', - free: false, - pro: true, - proPlus: true, - }, - { - feature: Feature.LIVE_CAPTION, - featureLabel: 'license.feature.liveCaption', - free: false, - pro: true, - proPlus: true, - }, - { - feature: Feature.SCREEN_CONTEXT, - featureLabel: 'license.feature.screenContext', - free: false, - pro: true, - proPlus: true, - }, - { - feature: Feature.VOICE_MEMO, - featureLabel: 'license.feature.voiceMemo', - free: false, - pro: true, - proPlus: true, - }, - { - feature: Feature.VOICE_COMMAND, - featureLabel: 'license.feature.voiceCommand', - free: false, - pro: true, - proPlus: true, - }, - { - feature: Feature.LLM_CHAIN, - featureLabel: 'license.feature.llmChain', - free: false, - pro: true, - proPlus: true, - }, - { - feature: Feature.CUSTOM_INSTRUCTION_CREATE, - featureLabel: 'license.feature.customInstruction', - free: false, - pro: true, - proPlus: true, - }, - { - feature: Feature.HISTORY_EXPORT, - featureLabel: 'license.feature.historyExport', - free: false, - pro: true, - proPlus: true, - }, - { - feature: Feature.FILE_TRANSCRIPTION, - featureLabel: 'license.feature.fileTranscription', - free: false, - pro: false, - proPlus: true, - }, - { - feature: Feature.VOICE_CONVERSATION, - featureLabel: 'license.feature.voiceConversation', - free: false, - pro: false, - proPlus: true, - }, - { - feature: Feature.MEETING_SUMMARY, - featureLabel: 'license.feature.meetingSummary', - free: false, - pro: false, - proPlus: true, - }, - { - feature: Feature.DICTATION_TEMPLATE, - featureLabel: 'license.feature.dictationTemplate', - free: false, - pro: false, - proPlus: true, - }, - { - feature: Feature.LOCAL_RAG, - featureLabel: 'license.feature.localRag', - free: false, - pro: false, - proPlus: true, - }, - { - feature: Feature.OS_AUTOMATION, - featureLabel: 'license.feature.osAutomation', - free: false, - pro: false, - proPlus: true, - }, - { - feature: Feature.TEAM_WORKSPACE, - featureLabel: 'license.feature.teamWorkspace', - free: false, - pro: false, - proPlus: false, - }, - ] + return buildTierComparison() } // ── Private helpers ──────────────────────────────────── - private _downgradeToFree(): void { - this._info.tier = 'free' - this._info.licenseKey = null - this._info.activatedAt = null - this._info.lastVerifiedAt = null - this._info.offlineGraceUntil = null - this._info.isTrial = false - this._info.trialExpiresAt = null - this._info.expiresAt = null - this._info.customerEmail = null - this._persistLicenseInfo() + private _resolveMachineId(): string { + const storedMachineId = this._store.read('licenseMachineId') + const generatedId = generateMachineId() + if (storedMachineId && storedMachineId === generatedId) return storedMachineId + if (storedMachineId) { + // 하드웨어가 바뀐 경우 — 기존 ID 유지 (이미 활성화된 키와 연결) + logger.warn('Hardware changed but keeping existing machineId for license continuity') + return storedMachineId + } + this._store.writeAll({ licenseMachineId: generatedId }) + return generatedId } - private _persistLicenseInfo(): void { - this._writeStoredField('licenseTier', this._info.tier) - this._writeStoredField('licenseKey', this._info.licenseKey) - this._writeStoredField('licenseActivatedAt', this._info.activatedAt) - this._writeStoredField('licenseLastVerifiedAt', this._info.lastVerifiedAt) - this._writeStoredField('licenseOfflineGraceUntil', this._info.offlineGraceUntil) - this._writeStoredField('licenseIsTrial', this._info.isTrial ?? false) - this._writeStoredField('licenseTrialExpiresAt', this._info.trialExpiresAt ?? null) - this._writeStoredField('licenseExpiresAt', this._info.expiresAt ?? null) - this._writeStoredField('licenseCustomerEmail', this._info.customerEmail ?? null) + /** 구버전 파일의 Reverse Trial은 licenseKeyTier 없이 licenseTier만 가지고 있다. */ + private _legacyTrialTier(): LicenseTier | null { + const isTrial = this._store.read('licenseIsTrial') + return isTrial ? this._store.read('licenseTier') : null } - private _getUsageCount(date: string, feature: Feature): number { - try { - const db = getDatabase() - const rows = db - .select() - .from(dailyUsage) - .where(and(eq(dailyUsage.date, date), eq(dailyUsage.feature, feature))) - .all() - return rows.length > 0 ? rows[0].count : 0 - } catch { - logger.warn(`Failed to get usage count for ${feature}`) - return 0 + private _verifyKey(licenseKey: string): LicenseVerificationResult { + const config = resolveLicenseVerificationConfig() + return verifySignedLicenseKey(licenseKey, this._machineId, config.publicKeyPem, config) + } + + private _setKey(entitlement: KeyEntitlement, verifiedAt: number, extra: LicenseStoreEntries = {}): void { + this._key = entitlement + this._keyVerifiedAt = verifiedAt + this._store.writeAll({ + licenseKey: entitlement.licenseKey, + licenseKeyTier: entitlement.tier, + licenseActivatedAt: entitlement.activatedAt, + licenseExpiresAt: entitlement.expiresAt, + licenseCustomerEmail: entitlement.customerEmail, + licenseIsTrial: entitlement.isTrial, + licenseTrialExpiresAt: entitlement.trialExpiresAt, + licenseOfflineGraceUntil: entitlement.trialExpiresAt, + ...extra, + ...this._summaryEntries(), + }) + } + + /** 클라우드 엔타이틀먼트 + 구버전 호환용 결합 뷰 */ + private _summaryEntries(): LicenseStoreEntries { + return { + licenseCloudTier: this._cloudTier, + licenseCloudVerifiedAt: this._cloudVerifiedAt, + licenseTier: this.tier, + licenseLastVerifiedAt: this.getInfo().lastVerifiedAt, } } - private _incrementUsage(feature: Feature): void { - try { - const db = getDatabase() - const today = getTodayDate() - - // UPSERT: 있으면 count+1, 없으면 insert - const existing = db - .select() - .from(dailyUsage) - .where(and(eq(dailyUsage.date, today), eq(dailyUsage.feature, feature))) - .all() - - if (existing.length > 0) { - db.update(dailyUsage) - .set({ count: existing[0].count + 1 }) - .where(eq(dailyUsage.id, existing[0].id)) - .run() - } else { - db.insert(dailyUsage) - .values({ date: today, feature, count: 1 }) - .run() - } - } catch (err) { - logger.warn(`Failed to increment usage for ${feature}: ${err}`) - } + private _persistSummary(): void { + this._store.writeAll(this._summaryEntries()) } - // ── 라이센스 전용 파일 기반 저장소 ────────────────────── - // AppConfig에 라이센스 필드가 없으므로 별도 JSON 파일 사용 - - private _licenseStore: Map = new Map() - private _licenseStoreLoaded = false - - private _ensureLicenseStore(): void { - if (this._licenseStoreLoaded) return - try { - const fs = require('fs') as typeof import('fs') - const path = require('path') as typeof import('path') - const electron = require('electron') as typeof import('electron') - const filePath = path.join(electron.app.getPath('userData'), 'd3ro-license.json') - if (fs.existsSync(filePath)) { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record - for (const [k, v] of Object.entries(data)) { - this._licenseStore.set(k, v) - } - } - } catch { - // 파일 없으면 빈 상태로 시작 - } - this._licenseStoreLoaded = true - } - - private _saveLicenseStore(): void { - try { - const fs = require('fs') as typeof import('fs') - const path = require('path') as typeof import('path') - const electron = require('electron') as typeof import('electron') - const filePath = path.join(electron.app.getPath('userData'), 'd3ro-license.json') - const obj: Record = {} - for (const [k, v] of this._licenseStore.entries()) { - obj[k] = v - } - fs.writeFileSync(filePath, JSON.stringify(obj, null, 2), 'utf-8') - } catch (err) { - logger.warn(`Failed to save license store: ${err}`) - } - } - - private _readStoredField(key: string): T | null { - this._ensureLicenseStore() - const val = this._licenseStore.get(key) - return (val as T) ?? null - } - - private _writeStoredField(key: string, value: unknown): void { - this._ensureLicenseStore() - this._licenseStore.set(key, value) - this._saveLicenseStore() + private _emitIfTierChanged(previous: LicenseTier): void { + if (this.tier !== previous) this.emit('tier-changed', this.getInfo()) } } @@ -760,15 +506,5 @@ export function initLicenseService(): void { export function resetLicenseServiceForTests(): void { instance = null - try { - const fs = require('fs') as typeof import('fs') - const path = require('path') as typeof import('path') - const { app } = require('electron') as typeof import('electron') - const filePath = path.join(app.getPath('userData'), 'd3ro-license.json') - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath) - } - } catch { - // 테스트 격리용 — 파일 없으면 무시 - } + deleteLicenseStoreFile() } diff --git a/apps/desktop/src/main/services/license/license-entitlement.ts b/apps/desktop/src/main/services/license/license-entitlement.ts new file mode 100644 index 0000000..87eb281 --- /dev/null +++ b/apps/desktop/src/main/services/license/license-entitlement.ts @@ -0,0 +1,177 @@ +// src/main/services/license/license-entitlement.ts +// Pure entitlement rules. A user can hold two independent entitlements: +// 1. a locally stored key (signed Ed25519 key, dev fixture, or the local reverse trial), and +// 2. the cloud subscription tier synced from Supabase while signed in. +// The effective tier is the higher of the two. Neither source may erase the other: a cloud +// sign-in/sign-out never touches the key, and a key is only dropped when it has itself expired. + +import type { LicenseInfo, LicenseTier } from '@d3ro/core/types' +import type { + LicenseVerificationResult, + SignedLicensePayload, +} from '@d3ro/core/utils/crypto-license' +import { TIER_ORDER, higherTier } from './license-policy' + +/** Prefix of the unsigned, machine-local reverse-trial token created by startTrial(). */ +export const LOCAL_TRIAL_KEY_PREFIX = 'TRIAL-' + +export function isLocalTrialKey(licenseKey: string): boolean { + return licenseKey.startsWith(LOCAL_TRIAL_KEY_PREFIX) +} + +/** Entitlement granted by a locally stored key. */ +export interface KeyEntitlement { + tier: LicenseTier + licenseKey: string + activatedAt: number | null + /** The key's own expiry (null = perpetual). */ + expiresAt: number | null + customerEmail: string | null + isTrial: boolean + /** Only set for the local reverse trial. */ + trialExpiresAt: number | null +} + +/** Key fields as read back from the license store; any of them may be missing in older files. */ +export interface StoredKeyRecord { + licenseKey: string | null + keyTier: LicenseTier | null + activatedAt: number | null + customerEmail: string | null + trialExpiresAt: number | null +} + +export type KeyVerifier = (licenseKey: string) => LicenseVerificationResult + +export type StoredKeyResolution = + | { status: 'none' } + | { status: 'active'; entitlement: KeyEntitlement } + | { status: 'expired'; kind: 'trial' | 'license' } + /** The key could not be verified right now (e.g. verification key missing). Keep it stored. */ + | { status: 'unverified'; message: string } + +/** Builds the entitlement for a key that verifySignedLicenseKey() accepted. */ +export function entitlementFromVerification( + licenseKey: string, + verification: LicenseVerificationResult, + activatedAt: number | null, +): KeyEntitlement { + const payload = verification.payload + return { + tier: verification.tier, + licenseKey, + activatedAt, + expiresAt: payload?.expiresAt ?? null, + customerEmail: payload?.customerEmail ?? null, + isTrial: payload?.isTrial ?? false, + trialExpiresAt: null, + } +} + +/** Builds the entitlement for the local reverse trial. */ +export function trialEntitlement(licenseKey: string, payload: SignedLicensePayload): KeyEntitlement { + return { + tier: payload.tier, + licenseKey, + activatedAt: payload.issuedAt, + expiresAt: null, + customerEmail: payload.customerEmail, + isTrial: true, + trialExpiresAt: payload.expiresAt, + } +} + +/** + * Re-checks a stored key at startup. Signed keys are re-verified (signature, expiresAt and + * machine binding) instead of relying on an online grace window that nothing ever refreshes. + */ +export function resolveStoredKey( + record: StoredKeyRecord, + now: number, + verify: KeyVerifier, +): StoredKeyResolution { + const licenseKey = record.licenseKey + if (!licenseKey) return { status: 'none' } + + if (isLocalTrialKey(licenseKey)) { + if (record.trialExpiresAt === null || now > record.trialExpiresAt) { + return { status: 'expired', kind: 'trial' } + } + return { + status: 'active', + entitlement: { + tier: record.keyTier ?? 'pro_plus', + licenseKey, + activatedAt: record.activatedAt, + expiresAt: null, + customerEmail: record.customerEmail, + isTrial: true, + trialExpiresAt: record.trialExpiresAt, + }, + } + } + + const verification = verify(licenseKey) + if (verification.valid) { + return { + status: 'active', + entitlement: entitlementFromVerification(licenseKey, verification, record.activatedAt), + } + } + if (verification.reason === 'expired') return { status: 'expired', kind: 'license' } + return { status: 'unverified', message: verification.message } +} + +/** + * Cached cloud tier at startup. Files written before the key/cloud split only carry the combined + * `licenseTier`; recover the cloud part from it without letting a key-derived tier leak into it. + */ +export function resolveCachedCloudTier( + storedCloudTier: LicenseTier | null, + legacyCombinedTier: LicenseTier | null, + key: StoredKeyResolution, +): LicenseTier { + if (storedCloudTier) return storedCloudTier + if (!legacyCombinedTier || legacyCombinedTier === 'free') return 'free' + if (key.status === 'none') return legacyCombinedTier + if (key.status === 'active' && TIER_ORDER[legacyCombinedTier] > TIER_ORDER[key.entitlement.tier]) { + return legacyCombinedTier + } + return 'free' +} + +export interface EntitlementState { + machineId: string + key: KeyEntitlement | null + keyVerifiedAt: number | null + cloudTier: LicenseTier + cloudVerifiedAt: number | null +} + +export function effectiveTier(state: Pick): LicenseTier { + return higherTier(state.key?.tier ?? 'free', state.cloudTier) +} + +function latest(a: number | null, b: number | null): number | null { + if (a === null) return b + if (b === null) return a + return Math.max(a, b) +} + +/** The public LicenseInfo view of the combined entitlement state. */ +export function composeLicenseInfo(state: EntitlementState): LicenseInfo { + const key = state.key + return { + tier: effectiveTier(state), + licenseKey: key?.licenseKey ?? null, + activatedAt: key?.activatedAt ?? null, + machineId: state.machineId, + lastVerifiedAt: latest(state.keyVerifiedAt, state.cloudVerifiedAt), + // Only the local reverse trial has a time window; signed keys carry their own expiresAt. + offlineGraceUntil: key?.trialExpiresAt ?? null, + isTrial: key?.isTrial ?? false, + trialExpiresAt: key?.trialExpiresAt ?? null, + expiresAt: key?.expiresAt ?? null, + customerEmail: key?.customerEmail ?? null, + } +} diff --git a/apps/desktop/src/main/services/license/license-policy.ts b/apps/desktop/src/main/services/license/license-policy.ts new file mode 100644 index 0000000..8510cc5 --- /dev/null +++ b/apps/desktop/src/main/services/license/license-policy.ts @@ -0,0 +1,136 @@ +// src/main/services/license/license-policy.ts +// Pure licensing policy: tier ordering, per-feature minimum tier, quotas, retention and the +// tier comparison table. No IO — LicenseService composes these rules with its stores. + +import type { LicenseTier, TierComparison } from '@d3ro/core/types' +import { Feature } from '@d3ro/core/types' +import { PLAN_QUOTA } from '@d3ro/core/plan-catalog' + +// ── 티어 순서 (비교용) ──────────────────────────────────── +export const TIER_ORDER: Readonly> = { + free: 0, + pro: 1, + pro_plus: 2, + team: 3, + enterprise: 4, +} + +export function tierAtLeast(current: LicenseTier, required: LicenseTier): boolean { + return TIER_ORDER[current] >= TIER_ORDER[required] +} + +/** The higher of two tiers — used to combine a key entitlement with the cloud subscription. */ +export function higherTier(a: LicenseTier, b: LicenseTier): LicenseTier { + return TIER_ORDER[a] >= TIER_ORDER[b] ? a : b +} + +/** The tier to suggest when the current tier ran out of quota. */ +export function upgradeTargetFor(current: LicenseTier): LicenseTier { + return current === 'free' ? 'pro' : 'pro_plus' +} + +// ── 티어별 쿼터 한도 ────────────────────────────────────── +// -1 = 무제한, 값이 있으면 일일 한도. +// 빅뱅 Phase 4: 로컬 기능은 전부 무제한. 클라우드 기능만 quota 적용. +// Phase 3.2: 엔트리 흡수 전략으로 free 쿼터 대폭 상향 (5 → 250). +// 오버리지 크레딧은 서버 subscriptions.overage_credits 컬럼에서 별도 관리. +// Phase 3.2: 모델별 쿼터. 서버(quota.ts)와 동기화. +// 클라이언트에서는 PREMIUM_LLM feature로 묶어서 canUse() 체크하고, +// 실제 모델별 세분화는 서버 llm-proxy가 담당. +// 여기의 값은 Settings UI 표시용 + upgrade 유도 시점 판단용. +// 값의 정본은 @d3ro/core PLAN_QUOTA. PREMIUM_LLM은 모델별 한도의 합으로 표시하고, +// 한 모델이라도 무제한이면 한도를 두지 않는다(Free는 Haiku 주 250회). +function premiumLlmLimit(tier: LicenseTier): number | undefined { + const quota = PLAN_QUOTA[tier] + const models = [quota.llm_haiku, quota.llm_sonnet, quota.llm_opus].filter((q) => q.limit !== 0) + if (models.some((q) => q.limit < 0)) return undefined + return models.reduce((sum, q) => sum + q.limit, 0) +} + +export const QUOTA_LIMITS: Readonly>>> = + Object.fromEntries( + (['free', 'pro', 'pro_plus', 'team', 'enterprise'] as const).map((tier) => { + const limit = premiumLlmLimit(tier) + return [tier, limit === undefined ? {} : { [Feature.PREMIUM_LLM]: limit }] + }), + ) as Record>> + +/** Daily limit for a feature on a tier, or undefined when the feature is unlimited. */ +export function quotaLimitFor(tier: LicenseTier, feature: Feature): number | undefined { + return QUOTA_LIMITS[tier][feature] +} + +// ── 기능별 최소 필요 티어 ────────────────────────────────── +// 빅뱅 Phase 4: 모든 로컬 기능을 'free'로 해방. +// 클라우드 기능(PREMIUM_LLM, CLOUD_SYNC)만 로그인 요구 + 티어 gate. +export const FEATURE_MIN_TIER: Readonly> = { + // ── 로컬 기능 (전부 free) ── + [Feature.DICTATION]: 'free', + [Feature.LLM_PROCESS]: 'free', + [Feature.HISTORY_UNLIMITED]: 'free', + [Feature.HISTORY_EXPORT]: 'free', + [Feature.CUSTOM_INSTRUCTION_CREATE]: 'free', + [Feature.LIVE_CAPTION]: 'free', + [Feature.SCREEN_CONTEXT]: 'free', + [Feature.VOICE_MEMO]: 'free', + [Feature.VOICE_COMMAND]: 'free', + [Feature.LLM_CHAIN]: 'free', + [Feature.FILE_TRANSCRIPTION]: 'free', + [Feature.VOICE_CONVERSATION]: 'free', + [Feature.DICTATION_TEMPLATE]: 'free', + [Feature.MEETING_SUMMARY]: 'free', + [Feature.LOCAL_RAG]: 'free', + [Feature.OS_AUTOMATION]: 'free', + + // ── 클라우드 기능 (로그인 필요 + 일부는 pro gate) ── + [Feature.PREMIUM_LLM]: 'free', // 로그인하면 free도 5회/일, pro는 500/일, pro_plus는 무제한 + [Feature.CLOUD_SYNC]: 'free', // 로그인만 하면 free도 사용 가능 + [Feature.TEAM_WORKSPACE]: 'team', +} + +// ── 클라우드 기능 집합 (익명 로컬 모드에서는 login_required) ── +export const CLOUD_FEATURES: ReadonlySet = new Set([ + Feature.PREMIUM_LLM, + Feature.CLOUD_SYNC, + Feature.TEAM_WORKSPACE, +]) + +// ── 히스토리 보존 기간 (일) ──────────────────────────────── +export const HISTORY_RETENTION_DAYS: Record = { + free: 3, + pro: -1, + pro_plus: -1, + team: -1, + enterprise: -1, +} + +/** 티어 비교표 — 호출마다 새 배열을 돌려준다. */ +export function buildTierComparison(): TierComparison[] { + const row = ( + feature: Feature, + featureLabel: string, + free: TierComparison['free'], + pro: TierComparison['pro'], + proPlus: TierComparison['proPlus'], + ): TierComparison => ({ feature, featureLabel, free, pro, proPlus }) + + return [ + row(Feature.DICTATION, 'license.feature.dictation', 'unlimited (local)', 'unlimited', 'unlimited'), + row(Feature.LLM_PROCESS, 'license.feature.llmProcess', 'unlimited (local)', 'unlimited', 'unlimited'), + row(Feature.HISTORY_UNLIMITED, 'license.feature.historyUnlimited', false, true, true), + row(Feature.LIVE_CAPTION, 'license.feature.liveCaption', false, true, true), + row(Feature.SCREEN_CONTEXT, 'license.feature.screenContext', false, true, true), + row(Feature.VOICE_MEMO, 'license.feature.voiceMemo', false, true, true), + row(Feature.VOICE_COMMAND, 'license.feature.voiceCommand', false, true, true), + row(Feature.LLM_CHAIN, 'license.feature.llmChain', false, true, true), + row(Feature.CUSTOM_INSTRUCTION_CREATE, 'license.feature.customInstruction', false, true, true), + row(Feature.HISTORY_EXPORT, 'license.feature.historyExport', false, true, true), + row(Feature.FILE_TRANSCRIPTION, 'license.feature.fileTranscription', false, false, true), + row(Feature.VOICE_CONVERSATION, 'license.feature.voiceConversation', false, false, true), + row(Feature.MEETING_SUMMARY, 'license.feature.meetingSummary', false, false, true), + row(Feature.DICTATION_TEMPLATE, 'license.feature.dictationTemplate', false, false, true), + row(Feature.LOCAL_RAG, 'license.feature.localRag', false, false, true), + row(Feature.OS_AUTOMATION, 'license.feature.osAutomation', false, false, true), + row(Feature.TEAM_WORKSPACE, 'license.feature.teamWorkspace', false, false, false), + ] +} diff --git a/apps/desktop/src/main/services/license/license-store.ts b/apps/desktop/src/main/services/license/license-store.ts new file mode 100644 index 0000000..93bfc8d --- /dev/null +++ b/apps/desktop/src/main/services/license/license-store.ts @@ -0,0 +1,118 @@ +// src/main/services/license/license-store.ts +// Port + file adapter for license persistence. AppConfig has no license fields, so the license +// lives in its own JSON file (userData/d3ro-license.json). + +import { getLogger } from '../LoggerService' + +const logger = getLogger('license') + +export const LICENSE_STORE_FILE = 'd3ro-license.json' + +/** Every key the license store may hold. */ +export type LicenseStoreField = + // machine + | 'licenseMachineId' + // key entitlement + | 'licenseKey' + | 'licenseKeyTier' + | 'licenseActivatedAt' + | 'licenseExpiresAt' + | 'licenseCustomerEmail' + | 'licenseIsTrial' + | 'licenseTrialExpiresAt' + | 'licenseOfflineGraceUntil' + | 'licenseTrialEverStarted' + // cloud entitlement + | 'licenseCloudTier' + | 'licenseCloudVerifiedAt' + // combined view (kept for older app versions that read the same file) + | 'licenseTier' + | 'licenseLastVerifiedAt' + +export type LicenseStoreEntries = Partial> + +export interface LicenseStore { + read(field: LicenseStoreField): T | null + /** Writes several fields and persists them once. */ + writeAll(entries: LicenseStoreEntries): void +} + +function licenseFilePath(): string { + const path = require('path') as typeof import('path') + const electron = require('electron') as typeof import('electron') + return path.join(electron.app.getPath('userData'), LICENSE_STORE_FILE) +} + +export class FileLicenseStore implements LicenseStore { + private readonly _values = new Map() + private _loaded = false + + read(field: LicenseStoreField): T | null { + this._ensureLoaded() + const value = this._values.get(field) + return (value as T | undefined) ?? null + } + + writeAll(entries: LicenseStoreEntries): void { + this._ensureLoaded() + for (const [field, value] of Object.entries(entries)) { + this._values.set(field, value) + } + this._save() + } + + private _ensureLoaded(): void { + if (this._loaded) return + try { + const fs = require('fs') as typeof import('fs') + const filePath = licenseFilePath() + if (fs.existsSync(filePath)) { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record + for (const [k, v] of Object.entries(data)) { + this._values.set(k, v) + } + } + } catch { + // 파일 없으면 빈 상태로 시작 + } + this._loaded = true + } + + private _save(): void { + try { + const fs = require('fs') as typeof import('fs') + const obj: Record = Object.fromEntries(this._values.entries()) + fs.writeFileSync(licenseFilePath(), JSON.stringify(obj, null, 2), 'utf-8') + } catch (err) { + logger.warn(`Failed to save license store: ${err}`) + } + } +} + +/** In-memory store — for tests and for callers that must not touch the disk. */ +export class MemoryLicenseStore implements LicenseStore { + private readonly _values = new Map() + + read(field: LicenseStoreField): T | null { + return (this._values.get(field) as T | undefined) ?? null + } + + writeAll(entries: LicenseStoreEntries): void { + for (const [field, value] of Object.entries(entries)) { + this._values.set(field, value) + } + } +} + +/** Deletes the license file — test isolation only. */ +export function deleteLicenseStoreFile(): void { + try { + const fs = require('fs') as typeof import('fs') + const filePath = licenseFilePath() + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + } + } catch { + // 테스트 격리용 — 파일 없으면 무시 + } +} diff --git a/apps/desktop/src/main/services/license/usage-repository.ts b/apps/desktop/src/main/services/license/usage-repository.ts new file mode 100644 index 0000000..16b1519 --- /dev/null +++ b/apps/desktop/src/main/services/license/usage-repository.ts @@ -0,0 +1,55 @@ +// src/main/services/license/usage-repository.ts +// Port + SQLite adapter for the per-day feature usage counters used by quota checks. + +import { eq, and } from 'drizzle-orm' +import { getLogger } from '../LoggerService' +import { getDatabase } from '../../db' +import { dailyUsage } from '../../db/schema' + +const logger = getLogger('license') + +export interface UsageRepository { + /** Count recorded for `feature` on `date` (YYYY-MM-DD); 0 when unknown or on error. */ + getCount(date: string, feature: string): number + /** Adds one use of `feature` on `date`. Failures are logged, never thrown. */ + increment(date: string, feature: string): void +} + +export class SqliteUsageRepository implements UsageRepository { + getCount(date: string, feature: string): number { + try { + const rows = getDatabase() + .select() + .from(dailyUsage) + .where(and(eq(dailyUsage.date, date), eq(dailyUsage.feature, feature))) + .all() + return rows.length > 0 ? rows[0].count : 0 + } catch { + logger.warn(`Failed to get usage count for ${feature}`) + return 0 + } + } + + increment(date: string, feature: string): void { + try { + const db = getDatabase() + // UPSERT: 있으면 count+1, 없으면 insert + const existing = db + .select() + .from(dailyUsage) + .where(and(eq(dailyUsage.date, date), eq(dailyUsage.feature, feature))) + .all() + + if (existing.length > 0) { + db.update(dailyUsage) + .set({ count: existing[0].count + 1 }) + .where(eq(dailyUsage.id, existing[0].id)) + .run() + } else { + db.insert(dailyUsage).values({ date, feature, count: 1 }).run() + } + } catch (err) { + logger.warn(`Failed to increment usage for ${feature}: ${err}`) + } + } +} diff --git a/apps/desktop/tests/main/services/LicenseService-redteam-r1-11.test.ts b/apps/desktop/tests/main/services/LicenseService-redteam-r1-11.test.ts new file mode 100644 index 0000000..712cb5e --- /dev/null +++ b/apps/desktop/tests/main/services/LicenseService-redteam-r1-11.test.ts @@ -0,0 +1,212 @@ +// Regression: a signed offline license key must survive (a) app restarts more than +// 30 days after activation and (b) a cloud sign-in followed by sign-out. +// A "restart" is simulated by calling initialize() again on the same service, +// which reloads every field from the license store. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { generateLicenseKeyPair, issueSignedLicenseKey } from '@d3ro/core/utils/crypto-license' +import { + getLicenseService, + resetLicenseServiceForTests, +} from '../../../src/main/services/LicenseService' + +const DAY_MS = 24 * 60 * 60 * 1000 +const T0 = new Date('2026-01-01T00:00:00Z').getTime() + +let previousPublicKey: string | undefined +/** issueKey() generates a fresh key pair per call; remember the public key per issued key. */ +const publicKeyByLicense = new Map() + +function previousKeyFor(key: string): string { + const pem = publicKeyByLicense.get(key) + if (!pem) throw new Error('unknown key') + return pem +} + +function issueKey(tier: 'pro' | 'pro_plus', expiresAt: number | null): string { + const { publicKeyPem, privateKeyPem } = generateLicenseKeyPair() + process.env.D3RO_LICENSE_PUBLIC_KEY = publicKeyPem + const key = issueSignedLicenseKey( + { + licenseId: `lic-r1-11-${tier}`, + tier, + customerEmail: 'buyer@example.test', + issuedAt: T0, + expiresAt, + machineId: null, + }, + privateKeyPem, + ) + publicKeyByLicense.set(key, publicKeyPem) + return key +} + +beforeEach(() => { + previousPublicKey = process.env.D3RO_LICENSE_PUBLIC_KEY + vi.useFakeTimers() + vi.setSystemTime(T0) + resetLicenseServiceForTests() +}) + +afterEach(() => { + vi.useRealTimers() + if (previousPublicKey === undefined) delete process.env.D3RO_LICENSE_PUBLIC_KEY + else process.env.D3RO_LICENSE_PUBLIC_KEY = previousPublicKey + resetLicenseServiceForTests() +}) + +describe('LicenseService — signed offline key lifetime (redteam r1-11)', () => { + it('keeps a one-year pro key after a restart 31 days after activation', async () => { + const key = issueKey('pro', T0 + 365 * DAY_MS) + const svc = getLicenseService() + svc.initialize() + const result = await svc.activate(key) + expect(result.success).toBe(true) + + vi.setSystemTime(T0 + 31 * DAY_MS) + svc.initialize() + + expect(svc.tier).toBe('pro') + expect(svc.getInfo().licenseKey).toBe(key) + expect(svc.getInfo().expiresAt).toBe(T0 + 365 * DAY_MS) + }) + + it('keeps a perpetual key after a restart a year later', async () => { + const key = issueKey('pro_plus', null) + const svc = getLicenseService() + svc.initialize() + await svc.activate(key) + + vi.setSystemTime(T0 + 400 * DAY_MS) + svc.initialize() + + expect(svc.tier).toBe('pro_plus') + expect(svc.getInfo().licenseKey).toBe(key) + }) + + it('drops the key once its own expiresAt has passed', async () => { + const key = issueKey('pro', T0 + 60 * DAY_MS) + const svc = getLicenseService() + svc.initialize() + await svc.activate(key) + + vi.setSystemTime(T0 + 61 * DAY_MS) + svc.initialize() + + expect(svc.tier).toBe('free') + expect(svc.getInfo().licenseKey).toBeNull() + }) + + it('a free cloud account does not demote the key, before or after a restart', async () => { + const key = issueKey('pro', T0 + 365 * DAY_MS) + const svc = getLicenseService() + svc.initialize() + await svc.activate(key) + + svc.syncFromCloud('free') + expect(svc.tier).toBe('pro') + + svc.initialize() + expect(svc.tier).toBe('pro') + expect(svc.getInfo().licenseKey).toBe(key) + }) + + it('sign-out after a pro cloud account keeps the key and its tier', async () => { + const key = issueKey('pro', T0 + 365 * DAY_MS) + const svc = getLicenseService() + svc.initialize() + await svc.activate(key) + + svc.syncFromCloud('pro_plus') + expect(svc.tier).toBe('pro_plus') + + svc.resetToFree() + expect(svc.tier).toBe('pro') + expect(svc.getInfo().licenseKey).toBe(key) + + svc.initialize() + expect(svc.tier).toBe('pro') + expect(svc.getInfo().licenseKey).toBe(key) + }) + + it('a higher cloud tier wins over the key and survives a restart until sign-out', async () => { + const key = issueKey('pro', T0 + 365 * DAY_MS) + const svc = getLicenseService() + svc.initialize() + await svc.activate(key) + svc.syncFromCloud('pro_plus') + + svc.initialize() + expect(svc.tier).toBe('pro_plus') + + svc.resetToFree() + expect(svc.tier).toBe('pro') + }) + + it('emits tier-changed only when the effective tier changes', async () => { + const key = issueKey('pro', T0 + 365 * DAY_MS) + const svc = getLicenseService() + svc.initialize() + await svc.activate(key) + const tiers: string[] = [] + svc.on('tier-changed', (info: { tier: string }) => tiers.push(info.tier)) + + svc.syncFromCloud('free') + svc.syncFromCloud('pro') + svc.syncFromCloud('pro_plus') + svc.resetToFree() + + expect(tiers).toEqual(['pro_plus', 'pro']) + }) + + it('deactivate clears the key but keeps the cloud tier', async () => { + const key = issueKey('pro', T0 + 365 * DAY_MS) + const svc = getLicenseService() + svc.initialize() + await svc.activate(key) + svc.syncFromCloud('pro_plus') + + await svc.deactivate() + expect(svc.getInfo().licenseKey).toBeNull() + expect(svc.tier).toBe('pro_plus') + + svc.resetToFree() + expect(svc.tier).toBe('free') + }) + + it('does not erase a stored key it cannot verify at startup (e.g. verification key missing)', async () => { + const key = issueKey('pro', T0 + 365 * DAY_MS) + const svc = getLicenseService() + svc.initialize() + await svc.activate(key) + + // Verification key rotated / missing: the key is not honoured, but it stays on disk. + const { publicKeyPem } = generateLicenseKeyPair() + process.env.D3RO_LICENSE_PUBLIC_KEY = publicKeyPem + svc.initialize() + expect(svc.tier).toBe('free') + + // Restored verification key: the same stored key is honoured again. + process.env.D3RO_LICENSE_PUBLIC_KEY = previousKeyFor(key) + svc.initialize() + expect(svc.tier).toBe('pro') + expect(svc.getInfo().licenseKey).toBe(key) + }) + + it('a reverse trial still ends after 14 days', () => { + const svc = getLicenseService() + svc.initialize() + expect(svc.startTrial('trial@example.test').success).toBe(true) + expect(svc.tier).toBe('pro_plus') + + vi.setSystemTime(T0 + 13 * DAY_MS) + svc.initialize() + expect(svc.tier).toBe('pro_plus') + + vi.setSystemTime(T0 + 15 * DAY_MS) + svc.initialize() + expect(svc.tier).toBe('free') + expect(svc.getInfo().isTrial).toBe(false) + expect(svc.startTrial('again@example.test').success).toBe(false) + }) +}) diff --git a/apps/desktop/tests/main/services/license-entitlement-r1-11.test.ts b/apps/desktop/tests/main/services/license-entitlement-r1-11.test.ts new file mode 100644 index 0000000..2198373 --- /dev/null +++ b/apps/desktop/tests/main/services/license-entitlement-r1-11.test.ts @@ -0,0 +1,241 @@ +// Unit tests for the extracted licensing policy/entitlement rules and for LicenseService running +// against an injected in-memory store (including files written before the key/cloud split). + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Feature } from '@d3ro/core/types' +import type { LicenseVerificationResult } from '@d3ro/core/utils/crypto-license' +import { generateLicenseKeyPair, issueSignedLicenseKey } from '@d3ro/core/utils/crypto-license' +import { + higherTier, + quotaLimitFor, + tierAtLeast, + upgradeTargetFor, + buildTierComparison, +} from '../../../src/main/services/license/license-policy' +import { + composeLicenseInfo, + resolveCachedCloudTier, + resolveStoredKey, + type StoredKeyRecord, +} from '../../../src/main/services/license/license-entitlement' +import { MemoryLicenseStore } from '../../../src/main/services/license/license-store' +import type { UsageRepository } from '../../../src/main/services/license/usage-repository' +import { LicenseService } from '../../../src/main/services/LicenseService' + +const DAY_MS = 24 * 60 * 60 * 1000 +const T0 = new Date('2026-03-01T00:00:00Z').getTime() + +const EMPTY_RECORD: StoredKeyRecord = { + licenseKey: null, + keyTier: null, + activatedAt: null, + customerEmail: null, + trialExpiresAt: null, +} + +function verification(overrides: Partial): LicenseVerificationResult { + return { valid: true, tier: 'pro', reason: 'valid', payload: null, message: 'ok', ...overrides } +} + +const noUsage: UsageRepository = { getCount: () => 0, increment: () => undefined } + +describe('license-policy', () => { + it('orders tiers and picks the higher one', () => { + expect(tierAtLeast('pro_plus', 'pro')).toBe(true) + expect(tierAtLeast('free', 'pro')).toBe(false) + expect(higherTier('pro', 'free')).toBe('pro') + expect(higherTier('free', 'pro_plus')).toBe('pro_plus') + expect(higherTier('team', 'pro')).toBe('team') + }) + + it('suggests the next paid tier on quota exhaustion', () => { + expect(upgradeTargetFor('free')).toBe('pro') + expect(upgradeTargetFor('pro')).toBe('pro_plus') + }) + + it('only PREMIUM_LLM carries a quota and local features stay unlimited', () => { + expect(quotaLimitFor('free', Feature.DICTATION)).toBeUndefined() + expect(typeof quotaLimitFor('free', Feature.PREMIUM_LLM)).toBe('number') + }) + + it('returns a fresh comparison table per call', () => { + const a = buildTierComparison() + const b = buildTierComparison() + expect(a).toEqual(b) + expect(a).not.toBe(b) + expect(a).toHaveLength(17) + }) +}) + +describe('license-entitlement.resolveStoredKey', () => { + it('returns none without a stored key', () => { + expect(resolveStoredKey(EMPTY_RECORD, T0, () => verification({})).status).toBe('none') + }) + + it('re-verifies a signed key instead of applying a grace window', () => { + const verify = vi.fn(() => verification({ tier: 'pro_plus', payload: null })) + const res = resolveStoredKey({ ...EMPTY_RECORD, licenseKey: 'D3RO-LIC-x', activatedAt: 1 }, T0, verify) + expect(verify).toHaveBeenCalledWith('D3RO-LIC-x') + expect(res).toMatchObject({ status: 'active', entitlement: { tier: 'pro_plus', activatedAt: 1 } }) + }) + + it('reports a key whose own expiry passed as expired', () => { + const res = resolveStoredKey({ ...EMPTY_RECORD, licenseKey: 'D3RO-LIC-x' }, T0, () => + verification({ valid: false, reason: 'expired', tier: 'free' }), + ) + expect(res).toEqual({ status: 'expired', kind: 'license' }) + }) + + it('reports other verification failures as unverified (the key is kept)', () => { + const res = resolveStoredKey({ ...EMPTY_RECORD, licenseKey: 'D3RO-LIC-x' }, T0, () => + verification({ valid: false, reason: 'invalid_signature', tier: 'free', message: 'no key' }), + ) + expect(res).toEqual({ status: 'unverified', message: 'no key' }) + }) + + it('handles the local reverse trial by its own expiry without calling the verifier', () => { + const verify = vi.fn(() => verification({})) + const record = { ...EMPTY_RECORD, licenseKey: 'TRIAL-PRO-PLUS-abc', trialExpiresAt: T0 + DAY_MS } + expect(resolveStoredKey(record, T0, verify)).toMatchObject({ + status: 'active', + entitlement: { tier: 'pro_plus', isTrial: true }, + }) + expect(resolveStoredKey(record, T0 + 2 * DAY_MS, verify)).toEqual({ status: 'expired', kind: 'trial' }) + expect(verify).not.toHaveBeenCalled() + }) +}) + +describe('license-entitlement.resolveCachedCloudTier', () => { + const activePro = { + status: 'active' as const, + entitlement: { + tier: 'pro' as const, + licenseKey: 'k', + activatedAt: null, + expiresAt: null, + customerEmail: null, + isTrial: false, + trialExpiresAt: null, + }, + } + + it('prefers the explicitly stored cloud tier', () => { + expect(resolveCachedCloudTier('pro', 'free', { status: 'none' })).toBe('pro') + }) + + it('recovers a legacy cloud tier only when no key explains it', () => { + expect(resolveCachedCloudTier(null, 'pro', { status: 'none' })).toBe('pro') + expect(resolveCachedCloudTier(null, 'pro', activePro)).toBe('free') + expect(resolveCachedCloudTier(null, 'pro_plus', activePro)).toBe('pro_plus') + expect(resolveCachedCloudTier(null, 'pro', { status: 'unverified', message: '' })).toBe('free') + expect(resolveCachedCloudTier(null, null, { status: 'none' })).toBe('free') + }) + + it('composes the effective tier as max(key, cloud)', () => { + const info = composeLicenseInfo({ + machineId: 'm', + key: activePro.entitlement, + keyVerifiedAt: 5, + cloudTier: 'free', + cloudVerifiedAt: 9, + }) + expect(info.tier).toBe('pro') + expect(info.lastVerifiedAt).toBe(9) + expect(info.offlineGraceUntil).toBeNull() + }) +}) + +describe('LicenseService with an injected store', () => { + let previousPublicKey: string | undefined + let signedKey = '' + + beforeEach(() => { + previousPublicKey = process.env.D3RO_LICENSE_PUBLIC_KEY + vi.useFakeTimers() + vi.setSystemTime(T0) + const { publicKeyPem, privateKeyPem } = generateLicenseKeyPair() + process.env.D3RO_LICENSE_PUBLIC_KEY = publicKeyPem + signedKey = issueSignedLicenseKey( + { + licenseId: 'lic-legacy', + tier: 'pro', + customerEmail: 'legacy@example.test', + issuedAt: T0 - 90 * DAY_MS, + expiresAt: T0 + 275 * DAY_MS, + machineId: null, + }, + privateKeyPem, + ) + }) + + afterEach(() => { + vi.useRealTimers() + if (previousPublicKey === undefined) delete process.env.D3RO_LICENSE_PUBLIC_KEY + else process.env.D3RO_LICENSE_PUBLIC_KEY = previousPublicKey + }) + + it('restores a legacy file whose 30-day grace already ran out', () => { + const store = new MemoryLicenseStore() + store.writeAll({ + licenseTier: 'pro', + licenseKey: signedKey, + licenseActivatedAt: T0 - 90 * DAY_MS, + licenseOfflineGraceUntil: T0 - 60 * DAY_MS, + }) + const svc = new LicenseService(store, noUsage) + svc.initialize() + expect(svc.tier).toBe('pro') + expect(svc.getInfo().licenseKey).toBe(signedKey) + expect(store.read('licenseCloudTier')).toBe('free') + }) + + it('restores a key that a free cloud sync had hidden in a legacy file', () => { + const store = new MemoryLicenseStore() + store.writeAll({ licenseTier: 'free', licenseKey: signedKey }) + const svc = new LicenseService(store, noUsage) + svc.initialize() + expect(svc.tier).toBe('pro') + expect(store.read('licenseTier')).toBe('pro') + }) + + it('keeps a legacy cached cloud tier until sign-out', () => { + const store = new MemoryLicenseStore() + store.writeAll({ licenseTier: 'pro_plus', licenseLastVerifiedAt: T0 - DAY_MS }) + const svc = new LicenseService(store, noUsage) + svc.initialize() + expect(svc.tier).toBe('pro_plus') + expect(svc.getInfo().lastVerifiedAt).toBe(T0 - DAY_MS) + svc.resetToFree() + expect(svc.tier).toBe('free') + expect(store.read('licenseCloudTier')).toBe('free') + }) + + it('keeps a legacy reverse trial until its expiry', () => { + const store = new MemoryLicenseStore() + store.writeAll({ + licenseTier: 'pro_plus', + licenseKey: 'TRIAL-PRO-PLUS-abcdef12', + licenseIsTrial: true, + licenseTrialExpiresAt: T0 + 3 * DAY_MS, + licenseTrialEverStarted: true, + }) + const svc = new LicenseService(store, noUsage) + svc.initialize() + expect(svc.tier).toBe('pro_plus') + expect(svc.getInfo().isTrial).toBe(true) + + vi.setSystemTime(T0 + 4 * DAY_MS) + svc.initialize() + expect(svc.tier).toBe('free') + expect(store.read('licenseKey')).toBeNull() + expect(store.read('licenseTrialEverStarted')).toBe(true) + }) + + it('records quota usage through the injected repository', () => { + const increment = vi.fn() + const svc = new LicenseService(new MemoryLicenseStore(), { getCount: () => 0, increment }) + svc.initialize() + svc.consumeQuota(Feature.DICTATION) + expect(increment).not.toHaveBeenCalled() + }) +})