Plan-based entitlements and feature gating, backed by postgres. Declare your pricing tiers once, assign subjects to plans, and ask at runtime what a subject is allowed to do. Entitlements resolve live on every call, so an upgrade, a negotiated override, or a crossed usage threshold takes effect immediately rather than at the next restart.
It pairs with @prsm/meter: meter answers how much a subject has used, entitle answers what their plan allows and where the ceiling is. Hand entitle a meter and check() reads usage live and compares it to the resolved limit.
npm install @prsm/entitle pgimport { createEntitlements, postgresDriver } from "@prsm/entitle"
import pg from "pg"
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
const entitlements = createEntitlements({
driver: postgresDriver({ pool }),
defaultPlan: "free",
features: ["api_access", "export_csv", "sso"], // the universe of valid feature keys
limits: ["tokens", "seats"], // the universe of valid limit keys
plans: {
free: {
features: { api_access: true, export_csv: false, sso: false },
limits: { tokens: 100_000, seats: 1 },
},
pro: {
features: { api_access: true, export_csv: true, sso: false },
limits: { tokens: 5_000_000, seats: 10 },
},
enterprise: {
features: { api_access: true, export_csv: true, sso: true },
limits: { tokens: null, seats: null }, // null means unlimited
},
},
})
await entitlements.setup() // create tables if they do not exist; idempotentDeclaring features and limits up front is the catalog: plans may only reference keys in it, and can()/limit()/check() throw on any other key, so a typo (can(id, "exprot_csv")) surfaces instead of silently returning false. The declarations are optional - if you omit them the universe is derived from the union of keys across your plans - but declaring them also catches typos in the plan definitions themselves.
Gating a feature and a quota on a request:
async function exportReport(account) {
if (!(await entitlements.can(account.id, "export_csv"))) {
throw new Error("CSV export is not available on your plan")
}
// ...
}Assigning plans and per-subject overrides as customers upgrade or negotiate:
await entitlements.assign(account.id, "pro") // takes effect immediately
await entitlements.override(account.id, { limits: { seats: 50 } }) // the enterprise customer who negotiated more seats
await entitlements.override(account.id, { features: { sso: true } })check() is the seam between the two packages. Entitle resolves the limit; meter supplies live usage. Pass a @prsm/meter instance and check a limit key that is also a meter metric:
import { createMeter, postgresDriver as meterPostgres } from "@prsm/meter"
const meter = createMeter({
driver: meterPostgres({ pool }),
metrics: { tokens: { unit: "tokens", aggregate: "sum" } },
})
const entitlements = createEntitlements({
driver: postgresDriver({ pool }),
defaultPlan: "free",
plans: { /* ... */ },
meter,
})
// account.id is assigned the "pro" plan, whose tokens limit is 5_000_000
const quota = await entitlements.check(account.id, "tokens")
// { allowed: true, used: 84210, remaining: 4915790, limit: 5000000, unit: "tokens", feature: "tokens" }
if (!quota.allowed) throw new Error("monthly token limit reached")Because the limit is resolved from the plan and the usage is read from the meter on every call, the same code path enforces a tightened plan, a granted override, and a depleting quota without any of it being baked in at startup.
Entitle does not track usage. It has no usage state of its own. In the result above, limit: 5000000 comes from the plan (entitle's only input), used: 84210 comes from a single live meter.usage() call that check() makes for you, and remaining is just limit - used. All accrual happens in your application calling meter.record(...) on each usage event; meter stores and aggregates it. So meter owns "how much have they used," entitle owns "what is the ceiling," and check() fetches the ceiling, asks meter for the usage, and subtracts. Pass meter only to enable that one call - without it, use limit() for the ceiling and read usage from meter yourself.
Two things must line up for check() to work:
- Same subject identifier.
check(subject, key)callsmeter.usage({ subject, ... }), so thesubjectyou pass here must be the same id you record usage under in meter. - Matching key. The entitle limit key must equal the meter metric name (
check(id, "tokens")reads thetokensmetric). If the metric does not exist in the meter, meter throws.
The plan catalog is declared once at construction, the way you declare your pricing tiers in code. A plan grants:
- features - a map of capability flags (
{ sso: true, export_csv: false }), read withcan(). - limits - a map of numeric ceilings keyed by name (
{ tokens: 5_000_000, seats: 10 }), read withlimit()and enforced withcheck(). Anulllimit means unlimited; a known limit a plan does not grant resolves to0(no allowance), never silently unlimited.
Assignments (which plan a subject is on) and overrides (per-subject adjustments) live in postgres and are mutable at runtime. An override shallow-merges over the plan, so you specify only what differs. A subject with no assignment gets defaultPlan; unassign(subject) removes an assignment and reverts the subject to the default (distinct from assigning the default explicitly, which would not follow a later change to defaultPlan).
Overrides accumulate: each override() call merges into the subject's existing override rather than replacing it, so granting more seats and later enabling a feature leaves both in place, and overriding a key again updates just that key. The merge happens in the database under a row lock, so two concurrent overrides to the same subject both land instead of one clobbering the other. clearOverride(subject) removes the whole override; clearOverride(subject, { limits: ["seats"] }) reverts only the named keys and keeps the rest.
await entitlements.override(account.id, { limits: { seats: 50 } })
await entitlements.override(account.id, { features: { sso: true } }) // seats override stays
await entitlements.clearOverride(account.id, { limits: ["seats"] }) // seats reverts to plan, sso stays
await entitlements.clearOverride(account.id) // back to plain planResolved entitlements are cached per subject for a short, configurable window (cacheTtl, default "10s") and the cache is invalidated immediately on assign, override, and clearOverride, so the instance making a change sees it at once and other instances converge within the TTL. Set cacheTtl: 0 to read postgres on every call.
Creates a resolver. driver is postgresDriver({ pool }) or memoryDriver(). plans is the catalog; defaultPlan must be one of its keys. features and limits declare the universe of valid keys (defaulting to the union across plans); when given, plans may only reference declared keys. meter is an optional @prsm/meter instance, required only for check(). cacheTtl accepts ms or a string like "10s". tracer is an optional @prsm/trace tracer; can() and check() are wrapped in spans when it is present.
Creates the backing tables if they do not exist. Idempotent.
Assigns a subject to a plan. Takes effect immediately.
Removes a subject's assignment, reverting them to the default plan.
Layers a per-subject override on top of the plan. Shallow-merges; pass only what differs.
With no keys, removes the subject's entire override. With keys ({ features?: string[], limits?: string[] }), removes only those entries and keeps the rest.
Returns whether a capability flag is granted (false for an ungranted feature). Throws on a feature key outside the catalog, so typos surface instead of silently returning false.
Returns the numeric ceiling for a limit key after overrides: null only for an explicitly unlimited limit, 0 for a known limit the plan does not grant. Throws on a key outside the catalog. Does not read usage.
Resolves the limit and reads live usage from the meter, returning { allowed, used, remaining, limit, unit, feature }. usageQuery is forwarded to meter.usage (for example { period: "day" }). Requires a meter.
Returns the subject's effective plan name.
Returns the full effective snapshot { plan, features, limits }, for a settings or billing page.
Lists subjects that have an explicit assignment or override, most-recently-configured first, capped at limit (default 100). Subjects on the default plan with no override are never stored, so they do not appear - this lists the configured subjects, for discovery in dashboards and admin tools. Returns [{ subject, assigned, overridden, lastConfiguredAt }].
Returns the static configuration { defaultPlan, plans, features, limits }: every declared plan, the default plan, and the full feature and limit universes. Where describe resolves a single subject, catalog exposes the whole offering, for a plan comparison table, an admin dashboard, or documenting what the system grants. Subject-independent and read-only, so it never touches storage. The returned object is a fresh copy.
Releases driver resources.
Two postgres tables, prefixed entitle_ by default (pass prefix to postgresDriver to run several resolvers in one database):
entitle_assignmentsmaps a subject to a plan.entitle_overridesholds each subject's override as JSON.
The plan catalog itself is code, not data, so it is not stored.
The memoryDriver mirrors the postgres driver and needs no infrastructure:
import { createEntitlements, memoryDriver } from "@prsm/entitle"
const entitlements = createEntitlements({
driver: memoryDriver(),
defaultPlan: "free",
plans: { free: { features: { api_access: true }, limits: { seats: 1 } } },
})
await entitlements.setup()It is not durable; use it for tests, not production.
MIT