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.
166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
// 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,
|
|
}
|
|
}
|