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:
parent
6ba25f53b7
commit
f6a29db95a
13 changed files with 576 additions and 11 deletions
|
|
@ -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"
|
||||
|
|
|
|||
166
packages/core/src/entitlement.ts
Normal file
166
packages/core/src/entitlement.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue