fix(license): keep signed offline keys across restarts and cloud sign-out

This commit is contained in:
Yun Chan 2026-09-28 00:53:45 +09:00
parent 95aa95e986
commit 9d5d043e8b
7 changed files with 1157 additions and 482 deletions

View file

@ -1,13 +1,18 @@
// src/main/services/LicenseService.ts // src/main/services/LicenseService.ts
// Phase 11: Freemium 라이센스 관리 — Feature Gating + 사용량 추적 // Phase 11: Freemium 라이센스 관리 — Feature Gating + 사용량 추적
//
// 엔타이틀먼트는 두 갈래로 분리해서 보관한다.
// - 키 엔타이틀먼트: 로컬에 저장한 서명 키(또는 dev fixture, 로컬 Reverse Trial)
// - 클라우드 엔타이틀먼트: 로그인 중 Supabase subscriptions에서 동기화한 티어
// 실효 티어는 둘 중 높은 쪽이다. 클라우드 로그인/로그아웃은 키를 건드리지 않고, 키는 자기
// 자신이 만료됐을 때만 지운다. 순수 규칙은 ./license/*, IO는 포트(LicenseStore,
// UsageRepository)를 통해 주입한다.
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { createHash } from 'crypto' import { createHash } from 'crypto'
import os from 'os' import os from 'os'
import { eq, and } from 'drizzle-orm'
import { getLogger } from './LoggerService' import { getLogger } from './LoggerService'
import { getDatabase, isLocalMode } from '../db' import { isLocalMode } from '../db'
import { dailyUsage } from '../db/schema'
import { D3ROError, ErrorCode } from '@d3ro/core/errors' import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { import type {
LicenseTier, LicenseTier,
@ -20,8 +25,38 @@ import type {
} from '@d3ro/core/types' } from '@d3ro/core/types'
import { Feature } from '@d3ro/core/types' import { Feature } from '@d3ro/core/types'
import { normalizeEntitlementTier } from '@d3ro/core/entitlement' import { normalizeEntitlementTier } from '@d3ro/core/entitlement'
import { PLAN_QUOTA } from '@d3ro/core/plan-catalog' import {
import { verifySignedLicenseKey, createDefaultTrialPayload } from '@d3ro/core/utils/crypto-license' 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') const logger = getLogger('license')
@ -48,91 +83,6 @@ function generateMachineId(): string {
return createHash('sha256').update(raw).digest('hex').substring(0, 32) 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<LicenseTier, Partial<Record<Feature, number>>> = 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<LicenseTier, Partial<Record<Feature, number>>>
// ── 기능별 최소 필요 티어 ──────────────────────────────────
// 빅뱅 Phase 4: 모든 로컬 기능을 'free'로 해방.
// 클라우드 기능(PREMIUM_LLM, CLOUD_SYNC)만 로그인 요구 + 티어 gate.
const FEATURE_MIN_TIER: Record<Feature, LicenseTier> = {
// ── 로컬 기능 (전부 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<Feature> = new Set([
Feature.PREMIUM_LLM,
Feature.CLOUD_SYNC,
Feature.TEAM_WORKSPACE,
])
// ── 히스토리 보존 기간 (일) ────────────────────────────────
export const HISTORY_RETENTION_DAYS: Record<LicenseTier, number> = {
free: 3,
pro: -1,
pro_plus: -1,
team: -1,
enterprise: -1,
}
// ── 티어 순서 (비교용) ────────────────────────────────────
const TIER_ORDER: Record<LicenseTier, number> = {
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 { function getTodayDate(): string {
const now = new Date() const now = new Date()
const y = now.getFullYear() const y = now.getFullYear()
@ -148,120 +98,126 @@ function getTomorrowMidnight(): string {
return tomorrow.toISOString() return tomorrow.toISOString()
} }
// ── LicenseService 싱글톤 ────────────────────────────────── /** Key fields cleared from the store when a key is deactivated or has expired. */
class LicenseService extends EventEmitter { const CLEARED_KEY_ENTRIES: LicenseStoreEntries = {
private _info: LicenseInfo
constructor() {
super()
this._info = {
tier: 'free',
licenseKey: null, licenseKey: null,
activatedAt: null, licenseKeyTier: null,
machineId: '', licenseActivatedAt: null,
lastVerifiedAt: null, licenseExpiresAt: null,
offlineGraceUntil: null, licenseCustomerEmail: null,
} licenseIsTrial: false,
licenseTrialExpiresAt: null,
licenseOfflineGraceUntil: null,
} }
/** 서비스 초기화 — bootstrap에서 호출 */ // ── 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()
}
/** 서비스 초기화 — bootstrap에서 호출. 저장소에서 상태를 다시 읽는다. */
initialize(): void { initialize(): void {
// machineId: 하드웨어 기반 해시 생성 this._machineId = this._resolveMachineId()
const storedMachineId = this._readStoredField<string>('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>('licenseTier')
const storedKey = this._readStoredField<string>('licenseKey')
const storedActivatedAt = this._readStoredField<number>('licenseActivatedAt')
const storedLastVerified = this._readStoredField<number>('licenseLastVerifiedAt')
const storedGrace = this._readStoredField<number>('licenseOfflineGraceUntil')
const storedIsTrial = this._readStoredField<boolean>('licenseIsTrial')
const storedTrialExpiresAt = this._readStoredField<number>('licenseTrialExpiresAt')
const storedExpiresAt = this._readStoredField<number>('licenseExpiresAt')
const storedCustomerEmail = this._readStoredField<string>('licenseCustomerEmail')
const now = Date.now() const now = Date.now()
if (storedTier && storedTier !== 'free') { // 1. 저장된 키를 다시 검증 (서명 + 키 자체 만료 + 머신 바인딩). 온라인 유예 창은 쓰지 않는다.
this._info.tier = storedTier const keyResolution = resolveStoredKey(
this._info.licenseKey = storedKey ?? null {
this._info.activatedAt = storedActivatedAt ?? null licenseKey: this._store.read<string>('licenseKey'),
this._info.lastVerifiedAt = storedLastVerified ?? null keyTier: this._store.read<LicenseTier>('licenseKeyTier') ?? this._legacyTrialTier(),
this._info.offlineGraceUntil = storedGrace ?? null activatedAt: this._store.read<number>('licenseActivatedAt'),
this._info.isTrial = storedIsTrial ?? false customerEmail: this._store.read<string>('licenseCustomerEmail'),
this._info.trialExpiresAt = storedTrialExpiresAt ?? null trialExpiresAt: this._store.read<number>('licenseTrialExpiresAt'),
this._info.expiresAt = storedExpiresAt ?? null },
this._info.customerEmail = storedCustomerEmail ?? null now,
(key) => this._verifyKey(key),
)
// 1. 체험판 만료 체크 (14-Day Reverse Trial) this._key = null
if (this._info.isTrial && this._info.trialExpiresAt && now > this._info.trialExpiresAt) { this._keyVerifiedAt = null
logger.info('14-day Reverse Trial expired, smoothly transitioning to 100% Free on-device mode') switch (keyResolution.status) {
this._downgradeToFree() case 'active':
} else if (this._info.expiresAt && now > this._info.expiresAt) { this._key = keyResolution.entitlement
// 2. 정기 라이센스 만료 체크 this._keyVerifiedAt = now
logger.warn('License expired, transitioning to Free tier') break
this._downgradeToFree() case 'expired':
} else if (this._info.offlineGraceUntil && now > this._info.offlineGraceUntil) { logger.info(
// 3. 오프라인 유예 기간 체크 (30일 경과) keyResolution.kind === 'trial'
logger.warn('Offline grace period expired, transitioning to Free tier') ? '14-day Reverse Trial expired, smoothly transitioning to 100% Free on-device mode'
this._downgradeToFree() : '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<LicenseTier>('licenseCloudTier')
const storedCombinedTier = this._store.read<LicenseTier>('licenseTier')
this._cloudTier = resolveCachedCloudTier(storedCloudTier, storedCombinedTier, keyResolution)
this._cloudVerifiedAt =
this._cloudTier === 'free'
? null
: this._store.read<number>('licenseCloudVerifiedAt') ??
this._store.read<number>('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 시작 (신용카드 불필요) */ /** 14일 Reverse-Trial 시작 (신용카드 불필요) */
startTrial(userEmail: string = 'trial-user@local'): ActivateLicenseResult { startTrial(userEmail: string = 'trial-user@local'): ActivateLicenseResult {
const trialEverStarted = this._readStoredField<boolean>('licenseTrialEverStarted') const trialEverStarted = this._store.read<boolean>('licenseTrialEverStarted')
if (trialEverStarted) { 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) const trialPayload = createDefaultTrialPayload(this._machineId, userEmail)
this._info.tier = 'pro_plus' const licenseKey = `${LOCAL_TRIAL_KEY_PREFIX}PRO-PLUS-${this._machineId.substring(0, 8)}`
this._info.licenseKey = `TRIAL-PRO-PLUS-${this._info.machineId.substring(0, 8)}` this._setKey(trialEntitlement(licenseKey, trialPayload), trialPayload.issuedAt, {
this._info.activatedAt = trialPayload.issuedAt licenseTrialEverStarted: true,
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)
this.emit('tier-changed', this.getInfo()) 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' } return { success: true, tier: 'pro_plus', message: '14-Day Reverse Trial Activated' }
} }
// ── Public API ────────────────────────────────────────── // ── Public API ──────────────────────────────────────────
/** 실효 티어 = max(키 티어, 클라우드 티어) */
get tier(): LicenseTier { get tier(): LicenseTier {
return this._info.tier return effectiveTier({ key: this._key, cloudTier: this._cloudTier })
} }
getInfo(): LicenseInfo { 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] const minTier = FEATURE_MIN_TIER[feature]
// 티어 체크 (현재 로컬 기능은 전부 'free'라 익명도 통과) // 티어 체크 (현재 로컬 기능은 전부 'free'라 익명도 통과)
if (!tierAtLeast(this._info.tier, minTier)) { if (!tierAtLeast(tier, minTier)) {
return { return {
allowed: false, allowed: false,
reason: 'tier_required', reason: 'tier_required',
@ -296,15 +253,13 @@ class LicenseService extends EventEmitter {
} }
// 쿼터 체크 (쿼터가 있는 기능만 — 현재는 PREMIUM_LLM만 해당) // 쿼터 체크 (쿼터가 있는 기능만 — 현재는 PREMIUM_LLM만 해당)
const tierLimits = QUOTA_LIMITS[this._info.tier] if (quotaLimitFor(tier, feature) !== undefined) {
const limit = tierLimits[feature]
if (limit !== undefined) {
const quota = this.getUsage(feature) const quota = this.getUsage(feature)
if (quota.remaining === 0) { if (quota.remaining === 0) {
return { return {
allowed: false, allowed: false,
reason: 'quota_exceeded', reason: 'quota_exceeded',
requiredTier: this._info.tier === 'free' ? 'pro' : 'pro_plus', requiredTier: upgradeTargetFor(tier),
quota, quota,
} }
} }
@ -315,39 +270,31 @@ class LicenseService extends EventEmitter {
/** /**
* 빅뱅 Phase 4: Supabase `subscriptions` 테이블에서 받은 티어를 로컬 캐시에 반영. * 빅뱅 Phase 4: Supabase `subscriptions` 테이블에서 받은 티어를 로컬 캐시에 반영.
* CloudSyncService._onAuthenticated에서 호출. * CloudSyncService._onAuthenticated에서 호출. 키 엔타이틀먼트는 건드리지 않는다.
*/ */
syncFromCloud(tier: LicenseTier): void { syncFromCloud(tier: LicenseTier): void {
const normalized = normalizeEntitlementTier(tier) const normalized = normalizeEntitlementTier(tier)
if (this._info.tier === normalized) return if (this._cloudTier === normalized) return
const previous = this._info.tier const previous = this.tier
this._info.tier = normalized this._cloudTier = normalized
this._info.lastVerifiedAt = Date.now() this._cloudVerifiedAt = Date.now()
this._writeStoredField('licenseTier', normalized) this._persistSummary()
this._writeStoredField('licenseLastVerifiedAt', this._info.lastVerifiedAt) this._emitIfTierChanged(previous)
this.emit('tier-changed', this.getInfo()) logger.info(`License cloud tier synced: ${normalized} (effective ${previous} → ${this.tier})`)
logger.info(`License tier synced from cloud: ${previous} → ${normalized}`)
} }
/** /**
* 빅뱅 Phase 4: 로그아웃 시 로컬 모드로 복귀 — 캐시된 pro/pro_plus 티어를 free로 리셋. * 빅뱅 Phase 4: 로그아웃 시 로컬 모드로 복귀 — 캐시된 클라우드 티어만 free로 리셋.
* CloudSyncService._onSignOut에서 호출. * CloudSyncService._onSignOut에서 호출. 로컬에 활성화한 라이선스 키는 유지한다.
*/ */
resetToFree(): void { resetToFree(): void {
if (this._info.tier === 'free') return if (this._cloudTier === 'free' && this._cloudVerifiedAt === null) return
const previous = this._info.tier const previous = this.tier
this._info.tier = 'free' this._cloudTier = 'free'
this._info.licenseKey = null this._cloudVerifiedAt = null
this._info.activatedAt = null this._persistSummary()
this._info.lastVerifiedAt = null this._emitIfTierChanged(previous)
this._info.offlineGraceUntil = null logger.info(`License cloud tier reset to free (effective ${previous} → ${this.tier}) — local mode`)
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`)
} }
/** /**
@ -384,16 +331,14 @@ class LicenseService extends EventEmitter {
} }
// 쿼터 있는 기능만 DB에 기록 // 쿼터 있는 기능만 DB에 기록
const tierLimits = QUOTA_LIMITS[this._info.tier] if (quotaLimitFor(this.tier, feature) !== undefined) {
if (tierLimits[feature] !== undefined) { this._usage.increment(getTodayDate(), feature)
this._incrementUsage(feature)
} }
} }
/** 일일 사용량 조회 */ /** 일일 사용량 조회 */
getUsage(feature: Feature): UsageQuota { getUsage(feature: Feature): UsageQuota {
const tierLimits = QUOTA_LIMITS[this._info.tier] const limit = quotaLimitFor(this.tier, feature)
const limit = tierLimits[feature]
// 쿼터 없는 기능 (무제한) // 쿼터 없는 기능 (무제한)
if (limit === undefined) { if (limit === undefined) {
@ -406,8 +351,7 @@ class LicenseService extends EventEmitter {
} }
} }
const today = getTodayDate() const used = this._usage.getCount(getTodayDate(), feature)
const used = this._getUsageCount(today, feature)
return { return {
feature, feature,
@ -435,13 +379,7 @@ class LicenseService extends EventEmitter {
return { success: false, tier: 'free', message: 'License key is empty' } return { success: false, tier: 'free', message: 'License key is empty' }
} }
const verificationConfig = resolveLicenseVerificationConfig() const verification = this._verifyKey(trimmedKey)
const verification = verifySignedLicenseKey(
trimmedKey,
this._info.machineId,
verificationConfig.publicKeyPem,
verificationConfig,
)
if (!verification.valid) { if (!verification.valid) {
return { success: false, tier: 'free', message: verification.message } return { success: false, tier: 'free', message: verification.message }
} }
@ -449,46 +387,35 @@ class LicenseService extends EventEmitter {
const activatedTier = verification.tier const activatedTier = verification.tier
logger.info(`License validated (${verification.reason}): tier=${activatedTier}`) logger.info(`License validated (${verification.reason}): tier=${activatedTier}`)
const now = Date.now() const now = Date.now()
this._info = { this._setKey(entitlementFromVerification(trimmedKey, verification, now), now)
...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.emit('tier-changed', this.getInfo()) this.emit('tier-changed', this.getInfo())
return { success: true, tier: activatedTier, message: verification.message } return { success: true, tier: activatedTier, message: verification.message }
} }
/** 라이센스 비활성화 (Free로 복귀) */ /** 라이센스 키 비활성화 — 키 엔타이틀먼트만 제거 (클라우드 티어는 유지) */
async deactivate(): Promise<void> { async deactivate(): Promise<void> {
this._downgradeToFree() this._key = null
this._keyVerifiedAt = null
this._store.writeAll({ ...CLEARED_KEY_ENTRIES, ...this._summaryEntries() })
this.emit('tier-changed', this.getInfo()) 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 { promptUpgrade(feature: Feature, reason: UpgradePromptEvent['reason']): void {
const minTier = FEATURE_MIN_TIER[feature] const tier = this.tier
// 쿼터 초과: 현재 tier 상위. login_required: free(로그인하면 바로 사용 가능). 그 외: feature의 최소 tier. // 쿼터 초과: 현재 tier 상위. login_required: free(로그인하면 바로 사용 가능). 그 외: feature의 최소 tier.
const requiredTier: LicenseTier = const requiredTier: LicenseTier =
reason === 'quota_exceeded' reason === 'quota_exceeded'
? this._info.tier === 'free' ? upgradeTargetFor(tier)
? 'pro'
: 'pro_plus'
: reason === 'login_required' : reason === 'login_required'
? 'free' ? 'free'
: minTier : FEATURE_MIN_TIER[feature]
const event: UpgradePromptEvent = { const event: UpgradePromptEvent = {
feature, feature,
reason, reason,
currentTier: this._info.tier, currentTier: tier,
requiredTier, requiredTier,
quota: reason === 'quota_exceeded' ? this.getUsage(feature) : undefined, quota: reason === 'quota_exceeded' ? this.getUsage(feature) : undefined,
} }
@ -498,249 +425,68 @@ class LicenseService extends EventEmitter {
/** 티어 비교표 생성 */ /** 티어 비교표 생성 */
getTierComparison(): TierComparison[] { getTierComparison(): TierComparison[] {
return [ return buildTierComparison()
{
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,
},
]
} }
// ── Private helpers ──────────────────────────────────── // ── Private helpers ────────────────────────────────────
private _downgradeToFree(): void { private _resolveMachineId(): string {
this._info.tier = 'free' const storedMachineId = this._store.read<string>('licenseMachineId')
this._info.licenseKey = null const generatedId = generateMachineId()
this._info.activatedAt = null if (storedMachineId && storedMachineId === generatedId) return storedMachineId
this._info.lastVerifiedAt = null if (storedMachineId) {
this._info.offlineGraceUntil = null // 하드웨어가 바뀐 경우 — 기존 ID 유지 (이미 활성화된 키와 연결)
this._info.isTrial = false logger.warn('Hardware changed but keeping existing machineId for license continuity')
this._info.trialExpiresAt = null return storedMachineId
this._info.expiresAt = null }
this._info.customerEmail = null this._store.writeAll({ licenseMachineId: generatedId })
this._persistLicenseInfo() return generatedId
} }
private _persistLicenseInfo(): void { /** 구버전 파일의 Reverse Trial은 licenseKeyTier 없이 licenseTier만 가지고 있다. */
this._writeStoredField('licenseTier', this._info.tier) private _legacyTrialTier(): LicenseTier | null {
this._writeStoredField('licenseKey', this._info.licenseKey) const isTrial = this._store.read<boolean>('licenseIsTrial')
this._writeStoredField('licenseActivatedAt', this._info.activatedAt) return isTrial ? this._store.read<LicenseTier>('licenseTier') : null
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)
} }
private _getUsageCount(date: string, feature: Feature): number { private _verifyKey(licenseKey: string): LicenseVerificationResult {
try { const config = resolveLicenseVerificationConfig()
const db = getDatabase() return verifySignedLicenseKey(licenseKey, this._machineId, config.publicKeyPem, config)
const rows = db }
.select()
.from(dailyUsage) private _setKey(entitlement: KeyEntitlement, verifiedAt: number, extra: LicenseStoreEntries = {}): void {
.where(and(eq(dailyUsage.date, date), eq(dailyUsage.feature, feature))) this._key = entitlement
.all() this._keyVerifiedAt = verifiedAt
return rows.length > 0 ? rows[0].count : 0 this._store.writeAll({
} catch { licenseKey: entitlement.licenseKey,
logger.warn(`Failed to get usage count for ${feature}`) licenseKeyTier: entitlement.tier,
return 0 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 { private _persistSummary(): void {
try { this._store.writeAll(this._summaryEntries())
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 _emitIfTierChanged(previous: LicenseTier): void {
// AppConfig에 라이센스 필드가 없으므로 별도 JSON 파일 사용 if (this.tier !== previous) this.emit('tier-changed', this.getInfo())
private _licenseStore: Map<string, unknown> = 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<string, unknown>
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<string, unknown> = {}
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<T>(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()
} }
} }
@ -760,15 +506,5 @@ export function initLicenseService(): void {
export function resetLicenseServiceForTests(): void { export function resetLicenseServiceForTests(): void {
instance = null instance = null
try { deleteLicenseStoreFile()
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 {
// 테스트 격리용 — 파일 없으면 무시
}
} }

View file

@ -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<EntitlementState, 'key' | 'cloudTier'>): 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,
}
}

View file

@ -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<Record<LicenseTier, number>> = {
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<Record<LicenseTier, Partial<Record<Feature, number>>>> =
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<LicenseTier, Partial<Record<Feature, number>>>
/** 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<Record<Feature, LicenseTier>> = {
// ── 로컬 기능 (전부 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<Feature> = new Set([
Feature.PREMIUM_LLM,
Feature.CLOUD_SYNC,
Feature.TEAM_WORKSPACE,
])
// ── 히스토리 보존 기간 (일) ────────────────────────────────
export const HISTORY_RETENTION_DAYS: Record<LicenseTier, number> = {
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),
]
}

View file

@ -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<Record<LicenseStoreField, unknown>>
export interface LicenseStore {
read<T>(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<string, unknown>()
private _loaded = false
read<T>(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<string, unknown>
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<string, unknown> = 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<string, unknown>()
read<T>(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 {
// 테스트 격리용 — 파일 없으면 무시
}
}

View file

@ -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}`)
}
}
}

View file

@ -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<string, string>()
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)
})
})

View file

@ -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>): 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()
})
})