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

@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import {
adminRoleAtLeast,
entitlementFeatures,
maxEntitlementTier,
normalizeAdminRole,
normalizeEntitlementTier,
resolveEntitlement,
} from '@d3ro/core/entitlement'
import { Feature } from '@d3ro/core/types'
describe('entitlement tier normalization', () => {
it('maps cloud and desktop tiers onto the canonical set', () => {
expect(normalizeEntitlementTier('free')).toBe('free')
expect(normalizeEntitlementTier('pro')).toBe('pro')
expect(normalizeEntitlementTier('pro_plus')).toBe('pro_plus')
expect(normalizeEntitlementTier('team')).toBe('pro_plus')
expect(normalizeEntitlementTier('enterprise')).toBe('pro_plus')
})
it('falls back to free for unknown values', () => {
expect(normalizeEntitlementTier(undefined)).toBe('free')
expect(normalizeEntitlementTier(42)).toBe('free')
expect(normalizeEntitlementTier('vip')).toBe('free')
})
it('picks the higher tier', () => {
expect(maxEntitlementTier('free', 'pro')).toBe('pro')
expect(maxEntitlementTier('pro_plus', 'pro')).toBe('pro_plus')
expect(maxEntitlementTier('free', 'free')).toBe('free')
})
})
describe('admin role normalization', () => {
it('normalizes .NET and Supabase spellings', () => {
expect(normalizeAdminRole('SuperAdmin')).toBe('superadmin')
expect(normalizeAdminRole('super_admin')).toBe('superadmin')
expect(normalizeAdminRole('admin')).toBe('admin')
expect(normalizeAdminRole('Manager')).toBe('manager')
expect(normalizeAdminRole('LegacyDisabled')).toBe('user')
expect(normalizeAdminRole(undefined)).toBe('user')
})
it('compares roles by rank', () => {
expect(adminRoleAtLeast('admin', 'manager')).toBe(true)
expect(adminRoleAtLeast('manager', 'admin')).toBe(false)
expect(adminRoleAtLeast('superadmin', 'superadmin')).toBe(true)
})
})
describe('resolveEntitlement', () => {
it('takes the highest paid tier across sources', () => {
const snapshot = resolveEntitlement({ subscriptionTier: 'free', licenseTier: 'pro_plus' })
expect(snapshot.tier).toBe('pro_plus')
expect(snapshot.licenseTier).toBe('pro_plus')
expect(snapshot.adFree).toBe(true)
expect(snapshot.features).toContain(Feature.TEAM_WORKSPACE)
})
it('does not downgrade a paid subscription with a stale free license', () => {
const snapshot = resolveEntitlement({ subscriptionTier: 'pro', licenseTier: 'free' })
expect(snapshot.tier).toBe('pro')
expect(snapshot.source).toBe('supabase')
})
it('collapses an expired period to free', () => {
const snapshot = resolveEntitlement({
subscriptionTier: 'pro_plus',
expiresAt: Date.now() - 1000,
})
expect(snapshot.tier).toBe('free')
expect(snapshot.adFree).toBe(false)
expect(snapshot.features).not.toContain(Feature.TEAM_WORKSPACE)
})
it('prefers the Supabase role over the .NET role for admin capabilities', () => {
const snapshot = resolveEntitlement({ dotnetRole: 'SuperAdmin', supabaseRole: 'manager' })
expect(snapshot.adminRole).toBe('manager')
expect(snapshot.adminCapabilities).toEqual({
canManage: true,
canAdmin: false,
canSuperAdmin: false,
})
})
it('reports no source when nothing is provided', () => {
const snapshot = resolveEntitlement()
expect(snapshot.tier).toBe('free')
expect(snapshot.adminRole).toBe('user')
expect(snapshot.source).toBe('none')
})
it('grants the team feature only to paid tiers', () => {
expect(entitlementFeatures('free')).not.toContain(Feature.TEAM_WORKSPACE)
expect(entitlementFeatures('pro')).toContain(Feature.TEAM_WORKSPACE)
expect(entitlementFeatures('free')).toContain(Feature.DICTATION)
})
})