npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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.ts JSDoc, 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-limit

API

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 | null

LimitType

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 = string

RequestHandler

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>): 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:

  • Returns 'anonymous' for users flagged as anonymous, regardless of stored plan.
  • Returns null for users whose planExpiresAt is in the past — callers should treat this as the default tier.
  • Returns the stored plan key otherwise (or 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

Injection 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-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).

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.