← All @molecule/* packages · App templates
@molecule/api-entitlementsCore interface · entitlements · API (Node) · v1.0.1 · Apache-2.0
Tier-based entitlements: typed quantitative limits per subscription plan, plan-key cache, and tier-aware middleware factories
npm install @molecule/api-entitlements@molecule/api-entitlements is the entitlements core interface on the API (Node) side: the API your app calls, with no vendor inside.
Bond a provider to choose the implementation.
import {
defineTiers,
setProvider,
enforceLimit,
requireCategoryAtLeast,
} from '@molecule/api-entitlements'
import { count } from '@molecule/api-database'
interface BlogLimits {
maxPosts: number
maxCommentsPerDay: number
}
const registry = defineTiers<BlogLimits>({
tiers: {
free: {
planKey: 'free',
category: 'free',
name: 'Free',
limits: { maxPosts: 5, maxCommentsPerDay: 50 },
},
stripeMonthly: {
planKey: 'stripeMonthly',
category: 'pro',
name: 'Pro',
limits: { maxPosts: 100, maxCommentsPerDay: 1000 },
},
},
defaultPlanKey: 'free',
categoryOrder: ['free', 'pro'],
})
setProvider(registry)
// Gate the API routes — the SERVER enforces tiers, never the UI alone:
router.post(
'/posts',
enforceLimit<BlogLimits>({
limitType: 'maxPosts',
getLimit: (limits) => limits.maxPosts,
getCurrent: (userId) => count('posts', [{ field: 'userId', operator: '=', value: userId }]),
}),
handlers.createPost,
)
router.get('/analytics', requireCategoryAtLeast('pro'), handlers.analytics)Works with: @molecule/api-bond, @molecule/api-database, @molecule/api-i18n, @molecule/api-rate-limit
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Tier-based entitlements core for molecule.dev.
Provides the typed Tier<TLimits> / TierRegistry<TLimits> shapes, a
per-process plan-key cache, and Express middleware factories that gate
endpoints by tier category or quantitative limit.
Apps declare their own TLimits shape, construct a registry via
defineTiers(...), and bond it via setProvider(...) at startup. The
webhook glue that maps Stripe / Apple / Google subscription events to
users.planKey already lives in @molecule/api-resource-user.
import {
defineTiers,
setProvider,
enforceLimit,
requireCategoryAtLeast,
} from '@molecule/api-entitlements'
import { count } from '@molecule/api-database'
interface BlogLimits {
maxPosts: number
maxCommentsPerDay: number
}
const registry = defineTiers<BlogLimits>({
tiers: {
free: {
planKey: 'free',
category: 'free',
name: 'Free',
limits: { maxPosts: 5, maxCommentsPerDay: 50 },
},
stripeMonthly: {
planKey: 'stripeMonthly',
category: 'pro',
name: 'Pro',
limits: { maxPosts: 100, maxCommentsPerDay: 1000 },
},
},
defaultPlanKey: 'free',
categoryOrder: ['free', 'pro'],
})
setProvider(registry)
// Gate the API routes — the SERVER enforces tiers, never the UI alone:
router.post(
'/posts',
enforceLimit<BlogLimits>({
limitType: 'maxPosts',
getLimit: (limits) => limits.maxPosts,
getCurrent: (userId) => count('posts', [{ field: 'userId', operator: '=', value: userId }]),
}),
handlers.createPost,
)
router.get('/analytics', requireCategoryAtLeast('pro'), handlers.analytics)
core
npm install @molecule/api-entitlements @molecule/api-bond @molecule/api-database @molecule/api-i18n @molecule/api-rate-limit
BuildLimitErrorOptionsOptions for building a limit error payload.
interface BuildLimitErrorOptions<TLimits = unknown> {
/** Identifier for the limit that was hit (e.g. `'maxProjects'`). */
limitType: LimitType
/** The current user's tier category (e.g. `'free'`, `'anonymous'`). */
category: string
/** The numeric limit that was exceeded. */
currentLimit: number
/**
* Optional accessor that maps the next-up tier's `limits` to the relevant
* numeric value. When omitted, `upgradedLimit` is `null` and the upgrade
* prompt simply names the next tier without a number.
*/
resolveUpgradedLimit?: (nextLimits: TLimits) => number | null | undefined
/** Seconds until the client should retry, when applicable. */
retryAfter?: number
/** Optional override for the localized error message. */
message?: string
}
DefineTiersOptionsOptions for constructing a TierRegistry via defineTiers.
interface DefineTiersOptions<TLimits = unknown> {
/** All tiers indexed by `planKey`. Must include an entry matching `defaultPlanKey`. */
tiers: Record<string, Tier<TLimits>>
/**
* The plan key that maps to the default tier — used for unrecognized,
* expired, or null plan keys. Conventionally `'free'` or `''`.
*/
defaultPlanKey: string
/**
* The category upgrade order. Categories listed earlier are considered
* lower-tier; later ones higher. Used by `getNextCategory` to power
* upgrade prompts.
*
* @example `['anonymous', 'free', 'pro', 'team']`
*/
categoryOrder: string[]
}
EnforceLimitOptionsOptions for the enforceLimit middleware.
interface EnforceLimitOptions<TLimits = unknown> {
/** Stable identifier for the limit (used in error payloads, telemetry). */
limitType: LimitType
/**
* Pulls the numeric cap out of the user's tier `limits` object.
*
* @param limits - The tier-specific limits.
* @returns The numeric cap to enforce.
*/
getLimit: (limits: TLimits) => number
/**
* Computes the user's current usage. Receives the userId resolved from the
* session and the request object so apps can scope by additional fields
* (e.g. organization, project) when needed.
*
* @param userId - The authenticated user ID.
* @param req - The incoming request, in case scoping needs query/body data.
* @returns The current usage count.
*/
getCurrent: (userId: string, req: Request) => Promise<number> | number
/**
* Optional override for the response status. Defaults to 403; some apps
* prefer 429 for usage-style limits.
*/
status?: number
}
LimitErrorPayloadStructured payload returned to clients when a tier limit is exceeded.
Frontends use this to render upgrade prompts that name the user's current tier, the limit that was hit, and the next tier that would lift it.
interface LimitErrorPayload {
/** Localized human-readable error message. */
error: string
/** Stable machine-readable identifier of the limit type (e.g. `'maxProjects'`). */
limitType: LimitType
/** The numeric limit on the user's current tier. */
currentLimit: number
/** The numeric limit the user would have on the next-up tier, or `null` if none. */
upgradedLimit: number | null
/** The user's current tier category. */
currentTier: string
/** The next-up tier category, or `null` if already at the top. */
upgradeTier: string | null
/** Whether the user must sign up before upgrading (anonymous → registered). */
requiresSignup: boolean
/** Seconds until the client should retry, when applicable (rate-limit-style errors). */
retryAfter?: number
}
PlanCacheEntryCached plan-key entry used by the plan cache to avoid a DB query on every
request. Bond packages and middleware should not depend on the cache shape
directly — use getCachedPlanKey() instead.
interface PlanCacheEntry {
/** The cached plan key, or `null` for free/expired plans. */
planKey: string | null
/** Absolute timestamp (ms since epoch) at which this entry expires. */
expiresAt: number
/**
* The user's stored plan expiry as read on the cache miss, or `null` when
* unset. Cached alongside the key because the row was already fetched:
* consumers that need the subscription's period boundary (e.g. an allowance
* that refreshes with the billing period) would otherwise re-read the user on
* every request, which is the exact load this cache exists to avoid. NOT the
* same as {@link PlanCacheEntry.expiresAt}, which is when the CACHE entry
* goes stale.
*/
planExpiresAt: string | null
}
PlanCacheOptionsConfiguration options for the plan cache.
interface PlanCacheOptions {
/** Cache entry TTL in milliseconds. Defaults to 5 minutes. */
ttlMs?: number
/**
* Max number of cached entries. When exceeded, the oldest insertion-order
* entry is evicted on the next write. Defaults to 50,000.
*/
maxEntries?: number
/**
* App-specific effective-plan-key demotion (see
* {@link EffectivePlanKeyResolver}). Applied on every cache MISS so the cached
* hot-path result already reflects the app's plan-key semantics — e.g. an
* in-app-purchase key with no expiry demoting to free. Defaults to identity.
* Pass `null` to clear a previously-set resolver back to identity.
*/
effectivePlanKeyResolver?: EffectivePlanKeyResolver | null
}
TierA subscription tier with quantitative limits.
Each application declares its own TLimits shape — for example, a personal
finance app might use { maxAccounts: number; maxTransactionsPerMonth: number }
while a chat app might use { maxMessagesPerDay: number; maxParticipants: number }.
interface Tier<TLimits = unknown> {
/**
* The plan key that identifies this tier. Matches the `planKey` used by
* `@molecule/api-payments` `Plan` records and by the `planKey` field on
* the `users` resource.
*/
planKey: string
/**
* The tier category, used for ordering and upgrade prompts.
* Examples: `'anonymous'`, `'free'`, `'pro'`, `'team'`.
*/
category: string
/** Human-readable display name shown on pricing pages and entitlement errors. */
name: string
/** Application-defined quantitative limits enforced at runtime. */
limits: TLimits
}
TierRegistryRegistry of all tiers defined by an application.
Apps construct a TierRegistry via defineTiers(...) at startup and bond it
via setProvider(registry). Middleware and handler code then look up the
tier for a given user via the bonded registry.
interface TierRegistry<TLimits = unknown> {
/**
* Look up a tier by plan key. If the key is null/undefined or unrecognized,
* returns the default tier (typically the free tier).
*
* @param planKey - The plan key to look up, or null/undefined for the default tier.
* @returns The matching tier, or the default tier when the key is unknown.
*/
findTier(planKey: string | null | undefined): Tier<TLimits>
/**
* Returns the default tier — the tier applied to unauthenticated, expired,
* or unrecognized plans. Typically the free tier.
*
* @returns The default tier.
*/
getDefaultTier(): Tier<TLimits>
/**
* Returns every registered tier, in registration order.
*
* @returns All registered tiers.
*/
getAllTiers(): Tier<TLimits>[]
/**
* Returns the rank of a category in the upgrade order (0 = lowest).
* Returns `null` if the category was not declared in the registry's
* `categoryOrder`.
*
* @param category - The category to look up.
* @returns The zero-based rank, or `null` if not in the order.
*/
getCategoryRank(category: string): number | null
/**
* Returns the next-up category in the upgrade order, or `null` if the
* category is already at the top.
*
* @param category - The starting category.
* @returns The next category up, or `null` at the top of the order.
*/
getNextCategory(category: string): string | null
}
UserPlanFieldsMinimal user record shape consumed by the plan cache. Only the fields needed to derive the effective plan key are required; concrete user resources may have many more fields.
interface UserPlanFields {
/** The user's stored plan key, or `null`/empty for free tier. */
planKey?: string | null
/** ISO timestamp at which the current plan expires; expired plans fall back to default. */
planExpiresAt?: string | null
/** Whether the user is anonymous; anonymous users get the `'anonymous'` plan key. */
isAnonymous?: boolean
}
EffectivePlanKeyResolverApp-specific hook that maps a stored (planKey, planExpiresAt) pair to the
EFFECTIVE plan key — applied by {@link getCachedPlanKey} on every cache miss
before the value is cached, so the cached (hot-path) result already reflects
the app's plan-key semantics.
The cache itself only knows the generic expiry rule (a past planExpiresAt
demotes to default). Conventions like "an in-app-purchase key with no expiry
is unverified → demote to free" are APP-specific (the apple*/google*
prefix set is defined by the app, not this package). Apps inject that rule
here so the hot path and the app's own resolveEffectivePlanKey cannot
diverge — there is one demotion implementation, reused.
type EffectivePlanKeyResolver = (
planKey: string | null,
planExpiresAt: string | null | undefined,
) => string | null
LimitTypeIdentifies the kind of limit that triggered a 429-style entitlement error. Apps may extend this with domain-specific keys via module augmentation.
type LimitType = string
RequestHandlerExpress-compatible request handler.
type RequestHandler = (req: Request, res: Response, next: NextFunction) => void | Promise<void>
buildLimitError(options)Build a LimitErrorPayload describing a tier-limit violation.
Reads the bonded entitlements registry to resolve the next-up category and
(optionally) the upgraded limit value. Anonymous users are flagged with
requiresSignup: true so the client can offer a sign-up prompt rather than
an upgrade prompt.
function buildLimitError(options: BuildLimitErrorOptions<TLimits>): LimitErrorPayload
options — The limit type, current tier category, current limit, and optional upgrade-limit resolver / retry-after / message override.Returns: A structured payload safe to send as a 429 / 403 response body.
clearPlanCache()Drops every cached plan-key entry. Intended for tests and graceful shutdown — production code should not need to call this.
function clearPlanCache(): void
configurePlanCache(options)Reconfigures the plan cache. Existing entries remain; only future insertions and TTL checks observe the new settings.
function configurePlanCache(options?: PlanCacheOptions): void
options — Optional overrides for TTL, maxEntries, and the effective-plan-key resolver.defineTiers(options)Constructs a TierRegistry from a tier record and category order.
Validates that the defaultPlanKey exists in the tiers record and that
every tier's category appears in categoryOrder. Throws synchronously
on misconfiguration so problems surface at startup, not at request time.
function defineTiers(options: DefineTiersOptions<TLimits>): TierRegistry<TLimits>
options — The tier set, default plan key, and category upgrade order.Returns: A typed tier registry suitable for setProvider(...).
enforceLimit(options)Creates middleware that allows the request only when the user is below
their tier limit for the given resource. The user's tier limits object
supplies the cap, and the caller-supplied getCurrent function counts
the current usage.
function enforceLimit(options: EnforceLimitOptions<TLimits>): RequestHandler
options — The limit type, limit accessor, and current-usage accessor.Returns: An Express request handler.
getCachedPlanKey(userId)Resolve the effective plan key for a user, hitting the cache on warm reads and falling back to a DB lookup on cache miss.
The effective plan key:
'anonymous' for users flagged as anonymous, regardless of stored plan.null for users whose planExpiresAt is in the past — callers
should treat this as the default tier.null if none was stored).function getCachedPlanKey(userId: string): Promise<string | null>
userId — The user ID to look up.Returns: The effective plan key, or null for default-tier users.
getCachedPlanState(userId)Resolve a user's effective plan key AND the plan expiry it was derived from, hitting the same cache {@link getCachedPlanKey} uses.
Exists because the expiry is already read on every cache miss: a consumer that needs the subscription's period boundary — an allowance that refreshes with the billing period, a renewal countdown — can have it for free instead of issuing its own per-request user lookup, which is precisely the database load this cache was introduced to remove.
planExpiresAt is the STORED value, not an effective one: it is null for
anonymous/free users and may be in the past for a plan that has just lapsed
(in which case planKey is already demoted to null).
function getCachedPlanState(
userId: string,
): Promise<{ planKey: string | null; planExpiresAt: string | null }>
userId — The user ID to look up.Returns: The effective plan key and the stored plan expiry.
getEffectiveTier(res)Resolves the effective tier for the user attached to the request via
res.locals.session.userId. Falls back to the registry's default tier
when no user is on the request, when the user record cannot be found, or
when the stored plan has expired.
function getEffectiveTier(res: Response): Promise<Tier<TLimits>>
res — The response object whose locals.session.userId identifies the user.Returns: The user's effective tier.
getProvider()Retrieves the bonded tier registry, throwing if none is configured.
The generic parameter is the caller's responsibility — entitlements is
inherently app-specific in its TLimits shape, and bonds are erased at
runtime. Callers should pass their app's TLimits type at the call site.
function getProvider(): TierRegistry<TLimits>
Returns: The bonded tier registry.
hasProvider()Checks whether an entitlements provider is currently bonded.
function hasProvider(): boolean
Returns: true if a tier registry is bonded.
invalidateCachedPlanKey(userId)Invalidate a single user's cached plan-key entry. Call this immediately
after writing a new planKey / planExpiresAt to the user record (e.g.
from a webhook handler) so the next request reflects the change without
waiting out the TTL.
function invalidateCachedPlanKey(userId: string): void
userId — The user ID whose cache entry should be evicted.planCacheSize()Returns the number of currently cached entries. Mainly useful for tests and operational metrics.
function planCacheSize(): number
Returns: Current cache size.
requireCategory(allowedCategories)Creates middleware that allows the request only when the user's tier
category is one of the listed values. Responds 401 if the request is
unauthenticated, 403 with a LimitErrorPayload-shaped body if the user's
tier is not in the list.
function requireCategory(allowedCategories?: string[]): RequestHandler
allowedCategories — The tier categories that are permitted (e.g. ['pro', 'team']).Returns: An Express request handler.
requireCategoryAtLeast(minCategory)Creates middleware that allows the request only when the user's tier rank is at least as high as the named category. Useful for "pro and above" style gates without listing every category individually.
Apps must include all gated categories in categoryOrder when calling
defineTiers(...); categories absent from the order produce null ranks
and therefore fail the check.
function requireCategoryAtLeast(minCategory: string): RequestHandler
minCategory — The minimum acceptable category.Returns: An Express request handler.
setProvider(provider)Registers a tier registry as the active entitlements provider. Called by the application during startup.
function setProvider(provider: TierRegistry<TLimits>): void
provider — The tier registry to bond.sweepExpiredPlanCacheEntries()Sweep expired entries from the cache. Safe to call from a recurring cleanup interval; idempotent and O(n) in cache size.
function sweepExpiredPlanCacheEntries(): void
Peer dependencies:
@molecule/api-bond ^1.0.1@molecule/api-database ^1.0.1@molecule/api-i18n ^1.0.1@molecule/api-rate-limit ^1.0.1@molecule/api-bond
@molecule/api-database
@molecule/api-i18n
@molecule/api-rate-limit
Enforcement is middleware on the API route (requireCategory,
requireCategoryAtLeast, enforceLimit) — hiding a button in the UI is
not entitlement enforcement. The middleware reads the authenticated user
from res.locals.session.userId, so it must be registered AFTER the auth
middleware; unauthenticated requests get a 401.
enforceLimit blocks at current >= limit and responds with a
structured LimitErrorPayload (default 403; pass status: 429 for
usage-style limits) that the app's limit/upgrade notice renders — don't
swallow it into a generic error page.
It is a SOFT ceiling — getCurrent COUNTS, then the handler CREATES the
resource afterwards. Under concurrency N requests can all read the same
current < limit and all create, so the limit can be exceeded by a few.
That is fine for plan limits (max projects / seats / collaborators — a
bounded, harmless overshoot). It is NOT enough for a HARD limit where going
over is a real loss: money / wallet balances, physical inventory (stock,
tickets, seats), or metered credits. Enforce THOSE atomically at the write
with a conditional UPDATE ... WHERE remaining >= $n RETURNING that affects
0 rows when it wouldn't fit (or an advisory-lock reserve for a ledger SUM) —
never a count-then-allow middleware.
Plan keys are cached per process (default 5-minute TTL). The
resource-user payment webhook glue invalidates on plan change; any custom
path that mutates a user's planKey must call
invalidateCachedPlanKey(userId) or the old tier lingers until TTL.
Unknown, expired, or missing plan keys resolve to the defaultPlanKey
tier — make the default tier's limits the safe floor.
The middleware factories are connect/Express-shaped conveniences. Other
stacks (queues, websockets, non-Express frameworks) enforce the same tiers
directly via getProvider() + getCachedPlanKey(userId).
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
/api/billing/tiers, not hardcoded).