@12-apps/entitlements
通用的、便携式的 计划/福利 层。 无框架, 无存储, 无帐单零运行时间依赖.
分步线导览 ADOPTING.md; 此文件
是概念 + API 引用.
在无框架核心之外,包裹将SURFACE作为两所工厂运送
(插件和游戏合同): createApiEntitlements(config) 下级 ./server
(框架中立路线描述符——快照,计划视图+定价卡,
更改计划的请求——加上警卫、使用记录、原子
定额警卫、保留水印、租户设定作家和
通知频道政策),a ./hono 搭载他们的适配器,以及
createWebEntitlements(config) 下级 ./react (计划屏幕
withEntitlement 页面门和升级的快捷主机)。 棱镜部分
页:1 RetentionWatermark 编辑 entitlements-coverage 门脚本船
包裹。 见Adopting.md 第10节.
商业政策是HOST的,是必需的. 等级,什么
他们被称为,他们的成本, 他们读的货币, 他们的间隔
重复和可能要求更改的许可都作为配置到达,
在大会上检查 assertApiEntitlementsConfig唯一一个
软件包回馈是 entitlementsPermissions(labels) - 单位代号
(单位:千美元)plan:request(a) 保护自己的文字,由东道主写成——东道主
编成其RBAC目录. 见 ADOPTING.md 第11节.
问题解决
大部分的应用软件最后都有两条进入控制轴,并试图让其中之一做 第三个工作:
| 层 | 问题 | 拥有者 | 范围 | 拒绝 |
|---|---|---|---|---|
| 代码 | 建筑支持它吗? | 页:1 | 全球 | 404 (韩语) |
| 应享权利 | 房客的计划能弥补吗? | 平台/计费 | 租户 | 402 (韩语) |
| 租户的设定 | 房客把它关了吗? | 租户管理员 | 租户 | 隐藏 |
| 非洲区域局许可 | 可能 此用户 做到这一点? | 租户管理员 | 用户 | 403 (韩语) |
将计划编码为 非洲区域局 关键值失败 : 权限为每个用户, 计划是按租户计的 所以计划改变必须重写每个角色 - 包括租户自己写作的任何角色,然后必须 升级时不重写或被销毁。 作为单独的一层,降级就是一层 写作和角色不受影响,所以再升级立即恢复了一切.
给房客自己编码 特性旗 无法信任 : 租户可注销的操作开关。 应享权利为平台写作和 对抗。 形状相同,方向相反.
所以:第三层. 这个包裹.
有两条规则能奏效
1. 它对金钱一无所知. 没有价格,没有货币,没有间隔,没有
提供单位 ID 。 它接受一个已经解决的权利地图 加上粗糙的
寿命周期状况active 编号 : restricted 编号 : suspended) (中文(简体) ). 计费系统地图
进入这三个国家 一个可能的作者 计划
层——从不依赖. 使一个有手分级的宿主,
内部的β程序,或者没有任何帐单 使用这个不变.
2. 依次检查应享权利和许可,不得交叉.
// ✗ 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"
交叉,你无法再分辨 "你的计划不包括这个" 从 "你的角色不包括这个". 上市面—— 完全享有权利——因这种区别而死亡.
决议令
一个特性只有在每层都同意时才可用:
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'
这个 原因 一直到呼叫者为止 这就是整个设计:
仅限 not-entitled 是一个上市。 提供“ 升级以解锁” 特性
房客已经付了钱 自己也关了 都是假的
requiredPlan 实值 null 对于其他的否认.
有资格的租户永远不会被迫——第4层总是胜过第2层.
API 密码
defineFeatures(catalog)
已键入特性注册 。 镜像 @12-apps/rbac是因为 definePermissions: 通过一个
const 映射如此未知的密钥失败类型检查而不是静默解析到
"没有资格".
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];
| 字段 | 默认 | 含义 |
|---|---|---|
kind | 'boolean' | 'quota' 数字限制 |
onRevoke | 'hide' | 主机对现有数据所做的—— hide 编号 : readonly 编号 : disable |
defaultWhenEntitled | true | 直到房客选择何时 false (用于写吉他) |
retainWhenRestricted | false | 活下来了 : 保持读取路径的开放, 限制会降解而不是砖块 |
特征密钥是线到 密码门。计划只能引用 已经存在;它们永远不能铸造出新的.
definePlans(registry, plans)
等级, 用 extends 财务报告和审定财务报表 编译时被平整 所以运行时间
解析从不走一链 坏目录在启动时失败.
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);
申报令被认为是最便宜的 – 最富有和驱动器 cheapestWith(), (中文(简体) ),
(即: requiredPlan 每402个 更丰富的层次可能 下调 继承
值( 设置配额到 0以及提高它。 周期和未申报特征
键投出.
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
});
| 方法 | 返回时 |
|---|---|
check(tenantId, feature) | EntitlementDecision 永远不要拒绝 |
checkQuota(tenantId, feature) | 决定+ used 编号 : remaining 编号 : exceeded |
checkAll(tenantId) | 一个传中每个特性 |
require(tenantId, feature) | 掷出 EntitlementRequiredError → 402 (韩语) |
requireQuota(tenantId, feature, need?) | 掷出 QuotaExceededError → 402 (韩语) |
toSnapshot(tenantId) | 浏览器的 JSON 可串行投影 |
invalidate(tenantId) | 丢弃缓存状态 |
港口——整个东道主接头
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>;
}
一个 覆盖 双向的图层: 它可以授予计算
特性 财务报告和审定财务报表 取消一个,不碰计划目录。 因为一个超能力
替换 计划的价值,而不是与它合并, 一个否认来自
一载 requiredPlan: null - 租户买不起的计划 都抬不起
平台撤销,他们目前的计划 很可能是给予它.
配额
EntitlementValue = boolean | number | 'unlimited'。 。 。 .
0 表示“ 无标题” 的真正值, 因此一个等级可以明确为零
放弃了继承的配额. 'unlimited' 保持一个哨兵,而不是 Infinity 这样
地图幸存 JSON.stringify。 。 。 .
await entitlements.requireQuota(tenantId, 'seats.included'); // +1
await entitlements.requireQuota(tenantId, 'seats.included', 5); // bulk create
⚠️ 检查时的行为不是原子. 两个同时生成可以同时读取
used = 9以10为限 和两个通行证。 引擎返回 a 决定; 通过在插入的交易中计数来进行解剖执行或 如果超龄确实重要,则有DB限制(始终是座位), 通常不用于电话).
降级
降级绝不能删除数据 . 每个决定都带有
policy 即使拒绝,所以主机知道 如何处理已经行
已存在 :
| 政策 | 主机行为 |
|---|---|
hide | 表面消失,行未动 |
readonly | 列保持可见和可用, create编号 :update 拒绝 |
disable | 已停用但保留的行; 重新获得资格时重新启动 |
超等同形: 现存行能活下来, create 返回 402.
邓宁自由了
restricted 只是另一个解析层,所以一个违法的房客需要 没有
单独锁定机制 一个 status 书写关闭每道门
守护您已经写好了, 而特性标记 retainWhenRestricted 保持开放
故此账户会退化而不是砖砌. suspended 不允许有任何例外.
也不提供高价: 房客已经付了钱,所以 正在安顿下来, 消息属于主机.
反应
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>
客户 永远不再重解 它收到一个由服务器制作的快照
完全一样 @12-apps/rbac是因为 RbacProvider 取一个已经解决的
权限设置 .
<Locked> 无头而无所为 disabled-by-tenant 或者说
not-supported所以只要在销售时出现涨价
也许吧. onUpsell 是逃生舱门: 软件包不拥有升级
模式 ——定价副本和CTA正在应用品牌,拥有这些品牌将是什么
停止重用此选项 .
useQuota(feature, used) 从呼叫者那里取用,因为活计数是
不在快照中; 列表中的组件已经知道 .
边界 :
/react只能导入类型和纯洁的助手——绝不是一个端口, 适配器或发动机. 由src/react/__tests__/boundary.test.ts。 。 。 .
测试
pnpm --filter @12-apps/entitlements test
三个套房是可移植门 他们回答不同 问:
| 套间 | 证明什么 |
|---|---|
portability.test.ts | eNGINE是可移植的——一个玩具取款的SaaS,有自己的目录、自己的计划以及两个港口 |
portability-surface.test.tsx | sURFACES是 ——它AMONTS Hono路由器 和反应工厂 一个音乐厅 并拒绝一个字 从任何其他东道主的词汇 |
packed-artifact.test.ts | "TARBALL是,它问 npm pack 哪些文件可以上传和扫描所有已公布的文件 |
中间一个之所以存在,是因为第一个还不够:每一个默认
实际泄漏(BRL价格 /mês 间隔, 硬编码顶端
级, 复制一个主机的名词) 坐在一个没有引擎呼叫的表面上.
最后一个存在是因为 files 发表 src, (中文(简体) ), prisma, (中文(简体) ), scripts 财务报告和审定财务报表
每个 *.md ——超过任何渲染的屏幕所能显示.
memory.ts 为测试和当地提供内源/使用/缓存适配器
发展.