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
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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue