@12-apps/entitlements

中文文档
3.11.0发布于 2026/10/02MITES Module构建来源已验证近 30 天下载 3.2万

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

原始使用文档
文档目录13 节
  1. The problem it solves
  2. Two rules that make it work
  3. Resolution order
  4. API
  5. defineFeatures(catalog)
  6. definePlans(registry, plans)
  7. createEntitlements(config)
  8. Ports — the entire host seam
  9. Quotas
  10. Downgrade
  11. Dunning comes free
  12. React
  13. Testing

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

layerquestionownerscopedenial
codedoes the build support it?devsglobal404
entitlementdoes the tenant's plan cover it?platform / billingtenant402
tenant settingdid the tenant switch it off?tenant admintenanthidden
RBAC permissionmay this user do it?tenant adminuser403

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];
fielddefaultmeaning
kind'boolean''quota' for numeric limits
onRevoke'hide'what the host does to existing data — hide / readonly / disable
defaultWhenEntitledtrueoff until the tenant opts in when false (use for write-gating features)
retainWhenRestrictedfalsesurvives 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
});
methodreturns
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 EntitlementRequiredError → 402
requireQuota(tenantId, feature, need?)throws QuotaExceededError → 402
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:

policyhost behaviour
hidesurface disappears, rows untouched
readonlyrows stay visible and usable, create/update refuse
disablerows 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:

suitewhat it proves
portability.test.tsthe ENGINE is portable — a toy note-taking SaaS with its own catalog, its own plans and the two ports
portability-surface.test.tsxthe 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.tsthe 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.

版本与发布记录

@12-apps/entitlements 共发布 51 个版本,首次发布于 2026/08/05;过去 12 个月发布了 51 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
3.11.0latest2026/10/021 个月前
3.10.12026/09/111 个月前
3.10.02026/08/262 个月前
3.9.02026/08/252 个月前
3.8.02026/08/252 个月前
3.7.02026/08/252 个月前

关于 @12-apps/entitlements 的常见问题

@12-apps/entitlements 是什么?

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

如何安装 @12-apps/entitlements?

在项目目录运行 npm install @12-apps/entitlements,或使用 pnpm add / yarn add / bun add。

@12-apps/entitlements 支持 TypeScript 吗?

该包的 package.json 没有声明类型入口。需要类型提示时,可以在 npm 上查找社区维护的 @types/12-apps__entitlements 是否存在。

@12-apps/entitlements 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@12-apps/entitlements 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@12-apps/entitlements 可以商用吗?

@12-apps/entitlements 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@12-apps/entitlements 的下载量有多少?

根据 npm 官方下载统计,@12-apps/entitlements 在 2026/10/04 – 2026/11/02 这 30 天内被下载 32,487 次,日均约 1,083 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@12-apps/entitlements 还在维护吗?

最新版本 3.11.0 发布于 2026/10/02(1 个月前);至今共发布 51 个版本,其中过去 12 个月发布了 51 个;首次发布于 2026/08/05。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。