@molecule/api-entitlements
v1.0.1
Published
Tier-based entitlements core interface for molecule.dev: typed quantitative limits per subscription plan, plan-key cache, and tier-aware middleware factories
Downloads
750
Maintainers
Readme
@molecule/api-entitlements
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.
Quick Start
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)Type
core
Installation
npm install @molecule/api-entitlements @molecule/api-bond @molecule/api-database @molecule/api-i18n @molecule/api-rate-limitAPI
Interfaces
BuildLimitErrorOptions
Options 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
}DefineTiersOptions
Options 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[]
}EnforceLimitOptions
Options 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
}LimitErrorPayload
Structured 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
}PlanCacheEntry
Cached 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
}PlanCacheOptions
Configuration 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
}Tier
A 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
}TierRegistry
Registry 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
}UserPlanFields
Minimal 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
}Types
EffectivePlanKeyResolver
App-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 | nullLimitType
Identifies the kind of limit that triggered a 429-style entitlement error. Apps may extend this with domain-specific keys via module augmentation.
type LimitType = stringRequestHandler
Express-compatible request handler.
type RequestHandler = (req: Request, res: Response, next: NextFunction) => void | Promise<void>Functions
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>): LimitErrorPayloadoptions— 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(): voidconfigurePlanCache(options)
Reconfigures the plan cache. Existing entries remain; only future insertions and TTL checks observe the new settings.
function configurePlanCache(options?: PlanCacheOptions): voidoptions— 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>): RequestHandleroptions— 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:
- Returns
'anonymous'for users flagged as anonymous, regardless of stored plan. - Returns
nullfor users whoseplanExpiresAtis in the past — callers should treat this as the default tier. - Returns the stored plan key otherwise (or
nullif 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 whoselocals.session.userIdidentifies 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(): booleanReturns: 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): voiduserId— 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(): numberReturns: 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[]): RequestHandlerallowedCategories— 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): RequestHandlerminCategory— 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>): voidprovider— 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(): voidInjection Notes
Requirements
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
Runtime Dependencies
@molecule/api-bond@molecule/api-database@molecule/api-i18n@molecule/api-rate-limitEnforcement 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 fromres.locals.session.userId, so it must be registered AFTER the auth middleware; unauthenticated requests get a 401.enforceLimitblocks atcurrent >= limitand responds with a structuredLimitErrorPayload(default 403; passstatus: 429for 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 —
getCurrentCOUNTS, then the handler CREATES the resource afterwards. Under concurrency N requests can all read the samecurrent < limitand 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 conditionalUPDATE ... WHERE remaining >= $n RETURNINGthat 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
planKeymust callinvalidateCachedPlanKey(userId)or the old tier lingers until TTL.Unknown, expired, or missing plan keys resolve to the
defaultPlanKeytier — 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).
E2E Tests
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:
- [ ] The pricing/plans surface lists every tier with its name, price, and limits
(rendered from
/api/billing/tiers, not hardcoded). - [ ] A free-tier user who hits a quantitative limit (e.g. creates the max allowed items, then one more) gets a visible limit/upgrade notice — never a silent failure, a blank page, or a raw 500.
- [ ] The blocked action really is blocked server-side: after a full page reload the over-limit item was NOT created.
- [ ] A higher-tier user (seed or upgrade one) can perform the same action that was blocked on the free tier.
- [ ] Tier-gated features/sections are hidden or clearly locked for tiers that lack them, and usable for tiers that have them.
