feat(shared): gate paid features from one entitlement source

Desktop, web, mobile, and the API each decided locally what a tier could do,
so a plan change could unlock a feature on one surface and not another.
Entitlement checks now live in `@d3ro/core` and are exercised by tests.

The shared theme and design-system packages also gain the tokens the new
surfaces consume, and the api-client exposes the dictionary and team types
the clients now send.
This commit is contained in:
Yun Chan 2026-09-16 23:24:18 +09:00
parent 6ba25f53b7
commit f6a29db95a
13 changed files with 576 additions and 11 deletions

View file

@ -27,6 +27,10 @@
"types": "./src/constants.ts",
"default": "./src/constants.ts"
},
"./entitlement": {
"types": "./src/entitlement.ts",
"default": "./src/entitlement.ts"
},
"./utils/meeting-markdown": {
"types": "./src/utils/meeting-markdown.ts",
"default": "./src/utils/meeting-markdown.ts"

View file

@ -0,0 +1,166 @@
// packages/core/src/entitlement.ts
// Single entitlement contract shared by desktop, web, and mobile.
//
// The product currently derives access from three sources: Supabase
// subscriptions (web/mobile/cloud), the desktop Ed25519 offline license, and
// the .NET back-office role. Each source uses a different shape. This module
// defines one canonical snapshot and a resolver that maps all three into it so
// consumers stop re-implementing tier comparisons.
import type { LicenseTier, Feature } from './types'
import { Feature as FeatureEnum } from './types'
/** Canonical paid tier. `team`/`enterprise` licenses collapse to `pro_plus`. */
export type EntitlementTier = 'free' | 'pro' | 'pro_plus'
/** Administrative role shared by the .NET back office and Supabase profiles. */
export type AdminRole = 'user' | 'manager' | 'admin' | 'superadmin'
export type EntitlementSource = 'supabase' | 'desktop_license' | 'dotnet' | 'none'
export interface EntitlementSnapshot {
tier: EntitlementTier
/** Original 5-value license tier when the source is a desktop license. */
licenseTier: LicenseTier | null
adminRole: AdminRole
adminCapabilities: {
canManage: boolean
canAdmin: boolean
canSuperAdmin: boolean
}
adFree: boolean
features: readonly Feature[]
source: EntitlementSource
expiresAt: number | null
refreshedAt: number
}
export interface ResolveEntitlementInput {
subscriptionTier?: unknown
licenseTier?: unknown
dotnetRole?: unknown
supabaseRole?: unknown
expiresAt?: number | null
refreshedAt?: number
}
const TIER_RANK: Record<EntitlementTier, number> = {
free: 0,
pro: 1,
pro_plus: 2,
}
const ADMIN_RANK: Record<AdminRole, number> = {
user: 0,
manager: 1,
admin: 2,
superadmin: 3,
}
const LOCAL_FEATURES: readonly Feature[] = [
FeatureEnum.DICTATION,
FeatureEnum.LLM_PROCESS,
FeatureEnum.HISTORY_UNLIMITED,
FeatureEnum.HISTORY_EXPORT,
FeatureEnum.CUSTOM_INSTRUCTION_CREATE,
FeatureEnum.LIVE_CAPTION,
FeatureEnum.SCREEN_CONTEXT,
FeatureEnum.VOICE_MEMO,
FeatureEnum.VOICE_COMMAND,
FeatureEnum.LLM_CHAIN,
FeatureEnum.FILE_TRANSCRIPTION,
FeatureEnum.VOICE_CONVERSATION,
FeatureEnum.DICTATION_TEMPLATE,
FeatureEnum.MEETING_SUMMARY,
FeatureEnum.LOCAL_RAG,
FeatureEnum.OS_AUTOMATION,
FeatureEnum.PREMIUM_LLM,
FeatureEnum.CLOUD_SYNC,
]
const PAID_FEATURES: readonly Feature[] = [...LOCAL_FEATURES, FeatureEnum.TEAM_WORKSPACE]
export function entitlementFeatures(tier: EntitlementTier): readonly Feature[] {
return tier === 'free' ? LOCAL_FEATURES : PAID_FEATURES
}
export function normalizeEntitlementTier(value: unknown): EntitlementTier {
if (typeof value !== 'string') return 'free'
const normalized = value.trim().toLowerCase()
if (normalized === 'pro') return 'pro'
if (normalized === 'pro_plus' || normalized === 'proplus') return 'pro_plus'
// Desktop licenses still carry team/enterprise; collapse them to pro_plus.
if (normalized === 'team' || normalized === 'enterprise') return 'pro_plus'
return 'free'
}
export function normalizeAdminRole(value: unknown): AdminRole {
if (typeof value !== 'string') return 'user'
const normalized = value.replace(/[_-]/g, '').trim().toLowerCase()
if (normalized === 'superadmin') return 'superadmin'
if (normalized === 'admin') return 'admin'
if (normalized === 'manager') return 'manager'
return 'user'
}
export function adminRoleAtLeast(role: AdminRole, minimum: AdminRole): boolean {
return ADMIN_RANK[role] >= ADMIN_RANK[minimum]
}
export function maxEntitlementTier(a: EntitlementTier, b: EntitlementTier): EntitlementTier {
return TIER_RANK[a] >= TIER_RANK[b] ? a : b
}
function hasValidPeriod(expiresAt: number | null | undefined, now: number): boolean {
if (expiresAt === null || expiresAt === undefined) return true
return expiresAt > now
}
/**
* Resolve a single entitlement snapshot from any combination of sources.
*
* Precedence:
* - Tier: the highest paid tier across sources wins (a paid subscription is not
* downgraded by a stale free license, and vice versa).
* - Admin role: the Supabase profile role wins over the .NET role.
* - Paid access requires a valid (non-expired) period.
*/
export function resolveEntitlement(input: ResolveEntitlementInput = {}): EntitlementSnapshot {
const now = input.refreshedAt ?? Date.now()
const subscriptionTier = normalizeEntitlementTier(input.subscriptionTier)
const licenseTier = typeof input.licenseTier === 'string' ? input.licenseTier : null
const normalizedLicenseTier = normalizeEntitlementTier(input.licenseTier)
const sources: EntitlementSource[] = []
if (input.subscriptionTier !== undefined && input.subscriptionTier !== null) sources.push('supabase')
if (licenseTier !== null) sources.push('desktop_license')
if (input.dotnetRole !== undefined && input.dotnetRole !== null) sources.push('dotnet')
const periodValid = hasValidPeriod(input.expiresAt ?? null, now)
// An expired paid period collapses to free regardless of the source label.
const tier: EntitlementTier = periodValid
? maxEntitlementTier(subscriptionTier, normalizedLicenseTier)
: 'free'
const adminRole =
input.supabaseRole !== undefined && input.supabaseRole !== null
? normalizeAdminRole(input.supabaseRole)
: normalizeAdminRole(input.dotnetRole)
return {
tier,
licenseTier: (licenseTier as LicenseTier | null) ?? null,
adminRole,
adminCapabilities: {
canManage: adminRoleAtLeast(adminRole, 'manager'),
canAdmin: adminRoleAtLeast(adminRole, 'admin'),
canSuperAdmin: adminRole === 'superadmin',
},
adFree: tier !== 'free' && periodValid,
features: entitlementFeatures(tier),
source: sources.length > 0 ? sources[0] : 'none',
expiresAt: input.expiresAt ?? null,
refreshedAt: now,
}
}

View file

@ -6,6 +6,7 @@ export * from './types'
export * from './errors'
export * from './ipc-channels'
export * from './constants'
export * from './entitlement'
export * from './utils/crypto-license'
export * from './utils/pii-redactor'
export * from './utils/secure-memory'

View file

@ -323,6 +323,12 @@ export interface LLMModel {
export type LLMAction = 'refine' | 'translate' | 'summarize' | 'expand' | 'grammar' | 'custom' | 'chain'
/**
* LLM .
* `defaultLLMAction` `'none'` .
*/
export type LLMActionSelection = LLMAction | 'none'
export interface LLMProcessParams {
text: string
action: LLMAction
@ -444,7 +450,7 @@ export interface AppConfig {
* 'realtime' OpenAI Realtime API (gpt-realtime-2.1, WebRTC , + )
*/
conversationBackend: 'local' | 'realtime'
defaultLLMAction: LLMAction
defaultLLMAction: LLMActionSelection
dictationShortcut: HotkeyBinding
handsFreeShortcut: HotkeyBinding
commandShortcut: HotkeyBinding
@ -488,6 +494,12 @@ export interface AppConfig {
activeChainId: string | null
/** Phase 10.1: Caption audio source (CaptionService, MeetingModeService) */
captionAudioSource: import('@d3ro/core/types').CaptionAudioSource
/** Auto-update 채널 (latest=stable / beta / alpha). UpdateService */
updateChannel: 'latest' | 'beta' | 'alpha'
/** staged rollout용 영구 기기 식별자 (개인정보 아님, 최초 실행 시 생성) */
updateDeviceId: string
/** 사용자가 건너뛴 버전 (강제 업데이트에는 적용되지 않음) */
skippedUpdateVersion: string | null
}
export interface ConfigGetParams {
@ -1587,10 +1599,12 @@ export type MeetingExportFormat = 'md' | 'pdf' | 'txt' | 'docx'
export interface MeetingExportDocParams {
documentId: string
format: MeetingExportFormat
targetPath?: string
}
export interface MeetingExportTranscriptParams {
sessionId: string
targetPath?: string
}
export interface MeetingDocGeneratingProgress {
@ -1682,6 +1696,8 @@ export interface AdNetworkConfig {
adUnitId?: string
appKey?: string
apiSecret?: string
/** Decision/reporting endpoint for REST-based adapters (house/direct sponsors). */
endpointUrl?: string
adapterType: 'rest_json' | 'in_app_bidding' | 'header_bidding_ssp' | 'rewarded_video_sdk' | 'direct_house'
}