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

@12-apps/entitlements

v3.10.0

Published

Generic, portable plan/feature entitlements: layered resolution (code/plan/status/tenant), numeric quotas, per-feature revoke policy and 402-vs-403 denial semantics. Framework-free core plus createApiEntitlements (./server, ./hono adapter) and createWebEn

Readme

@12-apps/entitlements

A generic, portable plan/feature entitlements layer. Framework-free, storage-free, billing-free. Zero runtime dependencies.

For the step-by-step wiring guide see ADOPTING.md; this file is the concepts + API reference.

Beyond the framework-free core, the package ships the SURFACE as two factories (the plug-and-play contract): createApiEntitlements(config) under ./server (framework-neutral route descriptors — snapshot, plan view + pricing cards, plan-change request — plus the guards, the usage-counter registry, the atomic quota guard, retention watermarks, the tenant-settings writer and the notification channel policy), a ./hono adapter that mounts them, and createWebEntitlements(config) under ./react (the plan screen, the withEntitlement page gate and the upgrade prompt host). The prisma partial for RetentionWatermark and the entitlements-coverage gate script ship with the package. See ADOPTING.md §10.

The commercial policy is the HOST's, and it is required. The tiers, what they are called, what they cost, the currency they read in, the interval they recur on and the permission that may ask for a change all arrive as config, checked at ASSEMBLY by assertApiEntitlementsConfig. The one thing this package contributes back is entitlementsPermissions(labels) — the single id (plan:request) guarding its own write, worded by the host — which the host composes into its RBAC catalog. See ADOPTING.md §11.


The problem it solves

Most apps end up with two axes of access control and try to make one of them do a third job:

| layer | question | owner | scope | denial | |---|---|---|---|---| | code | does the build support it? | devs | global | 404 | | entitlement | does the tenant's plan cover it? | platform / billing | tenant | 402 | | tenant setting | did the tenant switch it off? | tenant admin | tenant | hidden | | RBAC permission | may this user do it? | tenant admin | user | 403 |

Encoding plans into RBAC fails on cardinality: permissions are per-user, plans are per-tenant, so a plan change has to rewrite every role of every member — including any roles the tenant authored themselves, which then have to be un-rewritten on upgrade or destroyed. As a separate layer, a downgrade is one write and roles are untouched, so re-upgrading restores everything instantly.

Encoding plans into the tenant's own feature flags fails on trust: those are tenant-writable operational switches. Entitlements are platform-written and adversarial. Same shape, opposite direction.

So: a third layer. This package.

Two rules that make it work

1. It knows nothing about money. No price, no currency, no interval, no provider ids. It accepts an already-resolved entitlement map plus a coarse lifecycle status (active / restricted / suspended). A billing system maps its own states into those three and becomes one possible writer of the plan layer — never a dependency. That is what lets a host with hand-assigned tiers, an internal beta programme, or no billing at all use this unchanged.

2. Check entitlement and permission sequentially, never intersecting.

// ✗ intersecting — loses the reason
const effective = permissions ∩ entitlements;   // everything is just a 403

// ✓ sequential — keeps it
await requireEntitlement('exports.bulk', { tenantId }); // 402 → upsell
await requirePermission('exports:read', { scope });     // 403 → "Ask your admin"

Intersect and you can no longer tell "your plan doesn't include this" from "your role doesn't include this". The upsell surface — the commercial point of having entitlements at all — dies with the distinction.

Resolution order

A feature is usable only when every layer agrees:

1. code    registry declares it                      → 'not-supported'
2. plan    plan ∪ overrides grants it                → 'not-entitled'      (upsell)
3. status  account not restricted/suspended          → 'restricted' | 'suspended'
4. tenant  tenant hasn't switched it off             → 'disabled-by-tenant'

The reason survives all the way to the caller. That is the whole design: only not-entitled is an upsell. Offering "upgrade to unlock" for a feature the tenant already pays for and turned off themselves would be a lie, so requiredPlan is null for every other denial.

An entitled tenant is never forced — layer 4 always wins over layer 2.


API

defineFeatures(catalog)

The typed feature registry. Mirrors @12-apps/rbac's definePermissions: pass a const map so unknown keys fail typecheck instead of silently resolving to "not entitled".

const FEATURES = defineFeatures({
  'exports.bulk':      { onRevoke: 'hide' },
  'webhooks.outbound': { onRevoke: 'disable' },
  'seats.included':    { kind: 'quota', onRevoke: 'readonly' },
  approvals:           { onRevoke: 'disable', defaultWhenEntitled: false },
  'records.read':      { retainWhenRestricted: true },
} as const);

type AppFeature = (typeof FEATURES.list)[number];

| field | default | meaning | |---|---|---| | kind | 'boolean' | 'quota' for numeric limits | | onRevoke | 'hide' | what the host does to existing data — hide / readonly / disable | | defaultWhenEntitled | true | off until the tenant opts in when false (use for write-gating features) | | retainWhenRestricted | false | survives dunning — keep read paths open so restriction degrades rather than bricks |

A feature key is a wire to a code gate. Plans can only reference keys that already exist; they can never mint new ones.

definePlans(registry, plans)

Tiers, authored with extends and flattened at compile time so runtime resolution never walks a chain and a bad catalog fails at boot.

const PLANS = definePlans(FEATURES, {
  solo:  { entitlements: { 'records.read': true, 'seats.included': 1 } },
  team:  { extends: 'solo', entitlements: { 'webhooks.outbound': true, 'seats.included': 5 } },
  scale: { extends: 'team', entitlements: { 'exports.bulk': true, 'seats.included': 'unlimited' } },
} as const);

Declaration order is assumed cheapest → richest and drives cheapestWith(), i.e. the requiredPlan on every 402. A richer tier may lower an inherited value (set a quota to 0) as well as raise it. Cycles and undeclared feature keys throw.

createEntitlements(config)

export const entitlements = createEntitlements({
  features: FEATURES,
  plans: PLANS,          // optional — omit for hand-assigned entitlements
  source,                // port: load(tenantId)
  usage,                 // port: count(tenantId, feature) — quotas only
  cache,                 // port: optional read-through cache
});

| method | returns | |---|---| | check(tenantId, feature) | EntitlementDecision — never throws for a denial | | checkQuota(tenantId, feature) | decision + used / remaining / exceeded | | checkAll(tenantId) | every feature in one pass | | require(tenantId, feature) | throws EntitlementRequiredError402 | | requireQuota(tenantId, feature, need?) | throws QuotaExceededError402 | | toSnapshot(tenantId) | JSON-serializable projection for the browser | | invalidate(tenantId) | drop cached state |

Ports — the entire host seam

interface EntitlementSource<F> {
  load(tenantId: string): Promise<{
    plan: EntitlementMap<F>;
    planKey?: string | null;
    overrides?: EntitlementMap<F>;   // comped / beta / enterprise, layered OVER the plan
    settings?: SettingsMap<F>;       // the tenant's own off-switches
    status?: LifecycleStatus;
  }>;
}

interface UsageCounter<F> {
  count(tenantId: string, feature: F): Promise<number>;
}

interface EntitlementCache {
  get(key): Promise<string | null>;
  set(key, value, ttlSeconds?): Promise<void>;
  del(key): Promise<void>;
}

An override layers over the plan in both directions: it can grant a comped feature and revoke one, without touching the plan catalog. Because an override replaces the plan value rather than merging with it, a denial that came from one carries requiredPlan: null — no plan the tenant could buy would lift a platform revocation, and their current plan may well be the one that grants it.

Quotas

EntitlementValue = boolean | number | 'unlimited'.

0 is a real value meaning "entitled to none", so a tier can explicitly zero out an inherited quota. 'unlimited' stays a sentinel rather than Infinity so a map survives JSON.stringify.

await entitlements.requireQuota(tenantId, 'seats.included');    // +1
await entitlements.requireQuota(tenantId, 'seats.included', 5); // bulk create

⚠️ Check-then-act is not atomic. Two concurrent creates can both read used = 9 against a limit of 10 and both pass. The engine returns a decision; enforce atomically by counting inside the insert's transaction or with a DB constraint wherever an overage actually matters (always for seats, usually not for metered calls).

Downgrade

Downgrade must never delete data. Every decision carries the feature's policy even while denied, so the host knows what to do with rows that already exist:

| policy | host behaviour | |---|---| | hide | surface disappears, rows untouched | | readonly | rows stay visible and usable, create/update refuse | | disable | rows deactivated but retained; reactivate on re-entitlement |

Over-quota is the same shape: existing rows survive, create returns 402.

Dunning comes free

restricted is just another resolution layer, so a delinquent tenant needs no separate lock mechanism — one status write closes every gate through the guard you already wrote, while features flagged retainWhenRestricted stay open so the account degrades instead of bricking. suspended admits no exceptions.

Neither offers an upsell: the tenant already paid for those features, so the fix is settling up, and that message belongs to the host.


React

import { EntitlementsProvider, Entitled, Locked, useQuota } from '@12-apps/entitlements/react';

<EntitlementsProvider snapshot={snapshot} onUpsell={openPricingModal}>
  <Entitled feature="exports.bulk" fallback={<ExportsTeaser />}>
    <ExportsPage />
  </Entitled>

  <Locked feature="webhooks.outbound">
    {({ requiredPlan, upsell }) => (
      <button onClick={upsell}>🔒 Disponível no plano {requiredPlan}</button>
    )}
  </Locked>
</EntitlementsProvider>

The client never re-resolves — it receives a server-built snapshot and renders, exactly like @12-apps/rbac's RbacProvider takes an already-resolved permission set.

<Locked> is headless and renders nothing for disabled-by-tenant or not-supported, so an upsell only ever appears where a sale is actually possible. onUpsell is the escape hatch: the package does not own the upgrade modal — pricing copy and CTA are app branding, and owning them is what would stop this being reusable.

useQuota(feature, used) takes usage from the caller because live counts are not in the snapshot; the component rendering the list already knows.

Boundary: /react may import types and pure helpers only — never a port, adapter or the engine. Enforced by src/react/__tests__/boundary.test.ts.


Testing

pnpm --filter @12-apps/entitlements test

Three of the suites are the portability gate, and they answer different questions:

| suite | what it proves | |---|---| | portability.test.ts | the ENGINE is portable — a toy note-taking SaaS with its own catalog, its own plans and the two ports | | portability-surface.test.tsx | the SURFACES are — it MOUNTS the Hono router and the React factory for a concert hall and refuses a word from any other host's vocabulary | | packed-artifact.test.ts | the TARBALL is — it asks npm pack what would upload and sweeps every published file |

The middle one exists because the first was not enough: every default that actually leaked (a BRL price formatter, a /mês interval, a hardcoded top tier, copy naming a host's own noun) sat on a surface no engine call reaches. The last one exists because files publishes src, prisma, scripts and every *.md — more than any rendered screen can show.

memory.ts provides in-memory source/usage/cache adapters for tests and local development.