medOS ultra

Feature Entitlement

Subscription-tier feature gating on the module base: two-layer gate (module enabled, deployment flag, tier entitled), demo-safe.

4 min read diagramsUpdated 2026-06-10docs/architecture/feature-entitlement.md

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) reads VITE_ENABLED_MODULES (comma-separated allowlist; unset = all on). A module is declared in infrastructure/modules/<id>/module.json and provisioned by install-module.sh. This answers “is the feature installed in this deployment?” — see docs/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()):
    • UsersPage BIO_ENROLL_ENABLED (add-user wizard + the per-row “Enroll” action)
    • patient-roster FormRegister bioEnrollEnabled (post-registration wizard)
    • (The BiometricEnrollmentWizard component stays env-defaulted so the sandbox—which has no @/ alias—still mounts it; callers pass entitlement-derived faceEnabled/voiceEnabled.)
  • Backend (the trust anchor — services/auth/.../bioEntitlement.helper.ts isBiometricEntitled('face'|'voice'|'ai-order-signoff')): every one of the 6 biometric endpoints (face/voice × challenge/sign-in|verify/enroll) throws 403 FEATURE_NOT_ENTITLED when the tenant tier excludes it — after the existing *_LOGIN_ENABLED flag check.
  • Module-kit registration (Layer 1, the “module base”): infrastructure/modules/biometric-attestation/module.json (migration 20260531a, the 4 flags, tierByFeature) — install-module.sh biometric-attestation --dry-run emits the plan, and biometricEnrollmentAvailable() ANDs in moduleEnabled('biometric-attestation'), so VITE_ENABLED_MODULES toggles it like any other module. Catalogued as first-class features in docs/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)

  1. Add a FeatureKey to useFeatureCapability.ts (e.g. 'analytics.advanced').
  2. Add a row to FEATURE_PLANS in entitlements.ts (e.g. 'analytics.advanced': ['premium','enterprise']).
  3. 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.

Ask Anything