fix(license): keep signed offline keys across restarts and cloud sign-out
This commit is contained in:
parent
95aa95e986
commit
9d5d043e8b
7 changed files with 1157 additions and 482 deletions
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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에서 호출 */
|
/** 서비스 초기화 — 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 {
|
|
||||||
// 테스트 격리용 — 파일 없으면 무시
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
177
apps/desktop/src/main/services/license/license-entitlement.ts
Normal file
177
apps/desktop/src/main/services/license/license-entitlement.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
136
apps/desktop/src/main/services/license/license-policy.ts
Normal file
136
apps/desktop/src/main/services/license/license-policy.ts
Normal 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),
|
||||||
|
]
|
||||||
|
}
|
||||||
118
apps/desktop/src/main/services/license/license-store.ts
Normal file
118
apps/desktop/src/main/services/license/license-store.ts
Normal 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 {
|
||||||
|
// 테스트 격리용 — 파일 없으면 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
55
apps/desktop/src/main/services/license/usage-repository.ts
Normal file
55
apps/desktop/src/main/services/license/usage-repository.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue