Feature Entitlement
Subscription-tier feature gating on the module base: two-layer gate (module enabled, deployment flag, tier entitled), demo-safe.
Status: core shipped + sandbox-verified (2026-06-09), demo-safe (off by default). First feature gated: biometric attestation.
How a paid feature (e.g. biometric face/voice attestation) is gated by price / subscription tier, layered on top of the existing module system rather than replacing it.
The two-layer model
featureAvailable(feature) =
moduleEnabled(moduleId) // Layer 1: this DEPLOYMENT installed the module (VITE_ENABLED_MODULES)
&& deploymentFlag // + the feature is configured (model URL / VITE_*_ENABLED)
&& isEntitled(feature) // Layer 2: this TENANT's subscription tier includes the feature
- Layer 1 — module availability (existed):
moduleEnabled(id)readsVITE_ENABLED_MODULES(comma-separated allowlist; unset = all on). A module is declared ininfrastructure/modules/<id>/module.jsonand provisioned byinstall-module.sh. This answers “is the feature installed in this deployment?” — seedocs/architecture/modular-multicountry-deployment.md. - Layer 2 — subscription tier (new, this doc):
isEntitled(featureKey)resolves the tenant’s tier against a feature→tier matrix. This answers “has the tenant paid for it?”
Both are demo-safe: unset ⇒ enabled, so nothing breaks until a deployment opts into enforcement.
What it builds on (the seam was half-built)
| Piece | Status before | Now |
|---|---|---|
web/src/hooks/useFeatureCapability.ts |
stub returning enabled:true for all keys |
delegates to isEntitled(); FeatureKey extended with biometric.* |
web/src/common/components/feature-gate/ProGate.tsx |
passthrough (always renders children) | the live “Upgrade” lock card when !enabled |
web/src/config/stripe.config.ts |
tiers free / basic $300 / premium $1000 | the tier price source (+ enterprise) |
moduleEnabled() (fpa-dashboard/entitlements.ts) |
deployment module gate | unchanged; Layer 1 |
The core — web/src/config/entitlements.ts
type PlanId = 'free' | 'basic' | 'premium' | 'enterprise';
// Feature → tiers that INCLUDE it. Absent ⇒ included everywhere (back-compat). Add a row to sell a feature.
const FEATURE_PLANS = {
'biometric.attestation': ['premium', 'enterprise'],
'biometric.face': ['premium', 'enterprise'],
'biometric.voice': ['premium', 'enterprise'],
'biometric.ai-order-signoff': ['enterprise'],
};
resolveTier(): PlanId | null // VITE_SUBSCRIPTION_TIER now; Supabase tenants.subscription_tier later. null = demo-safe.
entitledForTier(key, tier) // pure: is key in tier? → CapabilityResult {enabled, reason?, upgradeUrl?}
isEntitled(key) // entitledForTier(key, resolveTier())
featureAvailable(key, moduleId?) // moduleEnabled(moduleId) && isEntitled(key)
biometricEnrollmentAvailable(modality?) // deploymentFlag && moduleEnabled('biometric-attestation') && isEntitled('biometric.<modality>')
useFeatureCapability(key) / getFeatureCapability(key) (non-hook) both return isEntitled(key), so … shows the upsell automatically when a tenant isn’t entitled.
How biometrics is gated (the chokepoints)
- Frontend (the trigger surfaces, all via
biometricEnrollmentAvailable()):UsersPageBIO_ENROLL_ENABLED(add-user wizard + the per-row “Enroll” action)patient-rosterFormRegisterbioEnrollEnabled(post-registration wizard)- (The
BiometricEnrollmentWizardcomponent stays env-defaulted so the sandbox—which has no@/alias—still mounts it; callers pass entitlement-derivedfaceEnabled/voiceEnabled.)
- Backend (the trust anchor —
services/auth/.../bioEntitlement.helper.tsisBiometricEntitled('face'|'voice'|'ai-order-signoff')): every one of the 6 biometric endpoints (face/voice×challenge/sign-in|verify/enroll) throws403 FEATURE_NOT_ENTITLEDwhen the tenant tier excludes it — after the existing*_LOGIN_ENABLEDflag check. - Module-kit registration (Layer 1, the “module base”):
infrastructure/modules/biometric-attestation/module.json(migration20260531a, the 4 flags,tierByFeature) —install-module.sh biometric-attestation --dry-runemits the plan, andbiometricEnrollmentAvailable()ANDs inmoduleEnabled('biometric-attestation'), soVITE_ENABLED_MODULEStoggles it like any other module. Catalogued as first-class features indocs/pricing-module-catalog.csv(ST-9/10/11, “Security and Privacy” → “Biometric Attestation”). So biometrics is gated both ways: the module-kit layer (deployment has it) AND the subscription tier (tenant paid for it).
Adding a new paid feature (3 steps)
- Add a
FeatureKeytouseFeatureCapability.ts(e.g.'analytics.advanced'). - Add a row to
FEATURE_PLANSinentitlements.ts(e.g.'analytics.advanced': ['premium','enterprise']). - Gate the surface: wrap UI in ``, or branch on
featureAvailable('analytics.advanced', 'analytics-module'); add a backend check if it has endpoints.
Config flags
| Flag | Where | Purpose |
|---|---|---|
VITE_SUBSCRIPTION_TIER |
frontend | active tier (free/basic/premium/enterprise); unset ⇒ all entitled |
SUBSCRIPTION_TIER |
backend (auth) | same, for the server-side guard |
VITE_ENABLED_MODULES |
frontend | Layer-1 module allowlist (existing) |
Verified vs. remaining
Verified: auth tsc = 0 errors; sandbox ?target=FeatureEntitlement (tier selector visibly gates the real wizard — free/basic ⇒ upgrade lock + wizard hidden; premium/enterprise ⇒ wizard available).
Remaining for full SaaS billing (the dormant scaffolding already names these — subscription.service.ts, supabase-subscription.service.ts, stripe.config.ts): create the subscription_plans / tenant_subscriptions Supabase tables; make resolveTier() read the tenant’s tier per-request (JWT claim / tenants.subscription_tier) instead of a deployment env var; wire Stripe checkout + webhooks; build the /admin/subscription management UI the UPGRADE_URL points at; add usage metering for the METERED_DIMENSIONS already defined. This doc’s layer is the gate; those are the commerce.