@prsm/entitle 1.1.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -7
- package/package.json +1 -1
- package/src/entitlements.js +83 -33
- package/src/memoryDriver.js +20 -2
- package/src/postgresDriver.js +27 -4
- package/types/entitlements.d.ts +23 -3
package/README.md
CHANGED
|
@@ -30,6 +30,8 @@ const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
|
|
|
30
30
|
const entitlements = createEntitlements({
|
|
31
31
|
driver: postgresDriver({ pool }),
|
|
32
32
|
defaultPlan: "free",
|
|
33
|
+
features: ["api_access", "export_csv", "sso"], // the universe of valid feature keys
|
|
34
|
+
limits: ["tokens", "seats"], // the universe of valid limit keys
|
|
33
35
|
plans: {
|
|
34
36
|
free: {
|
|
35
37
|
features: { api_access: true, export_csv: false, sso: false },
|
|
@@ -49,6 +51,8 @@ const entitlements = createEntitlements({
|
|
|
49
51
|
await entitlements.setup() // create tables if they do not exist; idempotent
|
|
50
52
|
```
|
|
51
53
|
|
|
54
|
+
Declaring `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.
|
|
55
|
+
|
|
52
56
|
Gating a feature and a quota on a request:
|
|
53
57
|
|
|
54
58
|
```js
|
|
@@ -87,6 +91,7 @@ const entitlements = createEntitlements({
|
|
|
87
91
|
meter,
|
|
88
92
|
})
|
|
89
93
|
|
|
94
|
+
// account.id is assigned the "pro" plan, whose tokens limit is 5_000_000
|
|
90
95
|
const quota = await entitlements.check(account.id, "tokens")
|
|
91
96
|
// { allowed: true, used: 84210, remaining: 4915790, limit: 5000000, unit: "tokens", feature: "tokens" }
|
|
92
97
|
|
|
@@ -95,16 +100,25 @@ if (!quota.allowed) throw new Error("monthly token limit reached")
|
|
|
95
100
|
|
|
96
101
|
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.
|
|
97
102
|
|
|
103
|
+
### Who owns what
|
|
104
|
+
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
Two things must line up for `check()` to work:
|
|
108
|
+
|
|
109
|
+
- **Same subject identifier.** `check(subject, key)` calls `meter.usage({ subject, ... })`, so the `subject` you pass here must be the same id you record usage under in meter.
|
|
110
|
+
- **Matching key.** The entitle limit key must equal the meter metric name (`check(id, "tokens")` reads the `tokens` metric). If the metric does not exist in the meter, meter throws.
|
|
111
|
+
|
|
98
112
|
## Plans, assignments, and overrides
|
|
99
113
|
|
|
100
114
|
The **plan catalog** is declared once at construction, the way you declare your pricing tiers in code. A plan grants:
|
|
101
115
|
|
|
102
116
|
- **features** - a map of capability flags (`{ sso: true, export_csv: false }`), read with `can()`.
|
|
103
|
-
- **limits** - a map of numeric ceilings keyed by name (`{ tokens: 5_000_000, seats: 10 }`), read with `limit()` and enforced with `check()`. A `null` limit means unlimited.
|
|
117
|
+
- **limits** - a map of numeric ceilings keyed by name (`{ tokens: 5_000_000, seats: 10 }`), read with `limit()` and enforced with `check()`. A `null` limit means unlimited; a known limit a plan does not grant resolves to `0` (no allowance), never silently unlimited.
|
|
104
118
|
|
|
105
|
-
**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
|
|
119
|
+
**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`).
|
|
106
120
|
|
|
107
|
-
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. `clearOverride(subject)` removes the whole override; `clearOverride(subject, { limits: ["seats"] })` reverts only the named keys and keeps the rest.
|
|
121
|
+
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.
|
|
108
122
|
|
|
109
123
|
```js
|
|
110
124
|
await entitlements.override(account.id, { limits: { seats: 50 } })
|
|
@@ -117,9 +131,9 @@ Resolved entitlements are cached per subject for a short, configurable window (`
|
|
|
117
131
|
|
|
118
132
|
## API
|
|
119
133
|
|
|
120
|
-
### `createEntitlements({ driver, plans, defaultPlan, meter?, cacheTtl?, tracer? })`
|
|
134
|
+
### `createEntitlements({ driver, plans, defaultPlan, features?, limits?, meter?, cacheTtl?, tracer? })`
|
|
121
135
|
|
|
122
|
-
Creates a resolver. `driver` is `postgresDriver({ pool })` or `memoryDriver()`. `plans` is the catalog; `defaultPlan` must be one of its 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.
|
|
136
|
+
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.
|
|
123
137
|
|
|
124
138
|
### `entitlements.setup()`
|
|
125
139
|
|
|
@@ -129,6 +143,10 @@ Creates the backing tables if they do not exist. Idempotent.
|
|
|
129
143
|
|
|
130
144
|
Assigns a subject to a plan. Takes effect immediately.
|
|
131
145
|
|
|
146
|
+
### `entitlements.unassign(subject)`
|
|
147
|
+
|
|
148
|
+
Removes a subject's assignment, reverting them to the default plan.
|
|
149
|
+
|
|
132
150
|
### `entitlements.override(subject, { features?, limits? })`
|
|
133
151
|
|
|
134
152
|
Layers a per-subject override on top of the plan. Shallow-merges; pass only what differs.
|
|
@@ -139,11 +157,11 @@ With no `keys`, removes the subject's entire override. With `keys` (`{ features?
|
|
|
139
157
|
|
|
140
158
|
### `entitlements.can(subject, feature)`
|
|
141
159
|
|
|
142
|
-
Returns whether a capability flag is granted (`false` for an ungranted
|
|
160
|
+
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`.
|
|
143
161
|
|
|
144
162
|
### `entitlements.limit(subject, key)`
|
|
145
163
|
|
|
146
|
-
Returns the numeric ceiling for a limit key after overrides
|
|
164
|
+
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.
|
|
147
165
|
|
|
148
166
|
### `entitlements.check(subject, key, usageQuery?)`
|
|
149
167
|
|
package/package.json
CHANGED
package/src/entitlements.js
CHANGED
|
@@ -20,6 +20,8 @@ import ms from "@prsm/ms"
|
|
|
20
20
|
* @property {Object} driver - storage backend: `postgresDriver({ pool })` for production, `memoryDriver()` for tests
|
|
21
21
|
* @property {Record<string, Plan>} plans - the plan catalog, declared once at construction (your pricing tiers)
|
|
22
22
|
* @property {string} defaultPlan - plan applied to a subject with no assignment; must be a key of `plans`
|
|
23
|
+
* @property {string[]} [features] - the universe of valid feature keys. When given, plans may only reference these and `can()` throws on any other key (catches typos). Defaults to the union of feature keys across all plans
|
|
24
|
+
* @property {string[]} [limits] - the universe of valid limit keys. When given, plans may only reference these and `limit()`/`check()` throw on any other key. Defaults to the union of limit keys across all plans
|
|
23
25
|
* @property {{ usage: Function }} [meter] - optional `@prsm/meter` instance; required only for `check()`, which reads live usage from it
|
|
24
26
|
* @property {number|string} [cacheTtl] - how long resolved plan/override state is cached per subject, ms or a string like `"10s"` (default `"10s"`, `0` disables). Kept short and invalidated on writes so entitlements stay runtime-evaluated, never startup-frozen
|
|
25
27
|
* @property {{ startSpan: Function }} [tracer] - optional `@prsm/trace` tracer
|
|
@@ -42,26 +44,60 @@ import ms from "@prsm/ms"
|
|
|
42
44
|
* @property {string} feature - the limit key that was checked
|
|
43
45
|
*/
|
|
44
46
|
|
|
45
|
-
function
|
|
47
|
+
function assertBoolean(scope, key, v) {
|
|
48
|
+
if (typeof v !== "boolean") throw new Error(`${scope} feature "${key}" must be a boolean`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assertLimitValue(scope, key, v) {
|
|
52
|
+
if (v !== null && (typeof v !== "number" || !Number.isFinite(v))) {
|
|
53
|
+
throw new Error(`${scope} limit "${key}" must be a finite number or null (null means unlimited)`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate the plan catalog, derive or enforce the feature/limit universes, and
|
|
59
|
+
* type-check every plan value. Returns the two universes as Sets.
|
|
60
|
+
*/
|
|
61
|
+
function buildCatalog(plans, defaultPlan, declaredFeatures, declaredLimits) {
|
|
46
62
|
if (!plans || typeof plans !== "object" || Object.keys(plans).length === 0) {
|
|
47
63
|
throw new Error("createEntitlements requires a non-empty `plans` catalog")
|
|
48
64
|
}
|
|
65
|
+
if (!defaultPlan || !plans[defaultPlan]) {
|
|
66
|
+
throw new Error(`createEntitlements requires \`defaultPlan\` to be one of the declared plans: ${Object.keys(plans).join(", ")}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const featureUniverse = new Set(declaredFeatures ?? [])
|
|
70
|
+
const limitUniverse = new Set(declaredLimits ?? [])
|
|
71
|
+
const enforceFeatures = Array.isArray(declaredFeatures)
|
|
72
|
+
const enforceLimits = Array.isArray(declaredLimits)
|
|
73
|
+
|
|
49
74
|
for (const [name, plan] of Object.entries(plans)) {
|
|
50
75
|
if (plan.features && typeof plan.features !== "object") throw new Error(`plan "${name}" features must be an object`)
|
|
51
76
|
if (plan.limits && typeof plan.limits !== "object") throw new Error(`plan "${name}" limits must be an object`)
|
|
77
|
+
for (const [k, v] of Object.entries(plan.features ?? {})) {
|
|
78
|
+
assertBoolean(`plan "${name}"`, k, v)
|
|
79
|
+
if (enforceFeatures && !featureUniverse.has(k)) throw new Error(`plan "${name}" references undeclared feature "${k}"`)
|
|
80
|
+
featureUniverse.add(k)
|
|
81
|
+
}
|
|
82
|
+
for (const [k, v] of Object.entries(plan.limits ?? {})) {
|
|
83
|
+
assertLimitValue(`plan "${name}"`, k, v)
|
|
84
|
+
if (enforceLimits && !limitUniverse.has(k)) throw new Error(`plan "${name}" references undeclared limit "${k}"`)
|
|
85
|
+
limitUniverse.add(k)
|
|
86
|
+
}
|
|
52
87
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
88
|
+
|
|
89
|
+
return { featureUniverse, limitUniverse }
|
|
56
90
|
}
|
|
57
91
|
|
|
58
|
-
function validateOverride(data) {
|
|
92
|
+
function validateOverride(data, featureUniverse, limitUniverse) {
|
|
59
93
|
if (!data || typeof data !== "object") throw new Error("override data must be an object with `features` and/or `limits`")
|
|
60
94
|
for (const [k, v] of Object.entries(data.features ?? {})) {
|
|
61
|
-
|
|
95
|
+
assertBoolean("override", k, v)
|
|
96
|
+
if (!featureUniverse.has(k)) throw new Error(`override references unknown feature "${k}"`)
|
|
62
97
|
}
|
|
63
98
|
for (const [k, v] of Object.entries(data.limits ?? {})) {
|
|
64
|
-
|
|
99
|
+
assertLimitValue("override", k, v)
|
|
100
|
+
if (!limitUniverse.has(k)) throw new Error(`override references unknown limit "${k}"`)
|
|
65
101
|
}
|
|
66
102
|
}
|
|
67
103
|
|
|
@@ -111,11 +147,22 @@ async function traced(tracer, name, attrs, fn) {
|
|
|
111
147
|
export function createEntitlements(options = {}) {
|
|
112
148
|
const { driver, plans, defaultPlan, meter = null, tracer = null } = options
|
|
113
149
|
if (!driver) throw new Error("createEntitlements requires a `driver` (postgresDriver or memoryDriver)")
|
|
114
|
-
|
|
150
|
+
const { featureUniverse, limitUniverse } = buildCatalog(plans, defaultPlan, options.features, options.limits)
|
|
115
151
|
|
|
116
152
|
const catalog = { ...plans }
|
|
117
153
|
const cache = createCache(ms(options.cacheTtl ?? "10s"))
|
|
118
154
|
|
|
155
|
+
function requireFeature(feature) {
|
|
156
|
+
if (!featureUniverse.has(feature)) {
|
|
157
|
+
throw new Error(`unknown feature "${feature}". declared features: ${[...featureUniverse].join(", ") || "(none)"}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function requireLimit(key) {
|
|
161
|
+
if (!limitUniverse.has(key)) {
|
|
162
|
+
throw new Error(`unknown limit "${key}". declared limits: ${[...limitUniverse].join(", ") || "(none)"}`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
119
166
|
async function resolve(subject) {
|
|
120
167
|
if (!subject) throw new Error("a `subject` is required")
|
|
121
168
|
const cached = cache.get(subject)
|
|
@@ -155,6 +202,18 @@ export function createEntitlements(options = {}) {
|
|
|
155
202
|
cache.invalidate(subject)
|
|
156
203
|
},
|
|
157
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Remove a subject's plan assignment, reverting them to the default plan.
|
|
207
|
+
* Distinct from assigning the default plan explicitly: an unassigned subject
|
|
208
|
+
* follows `defaultPlan` if it later changes.
|
|
209
|
+
* @param {string} subject
|
|
210
|
+
*/
|
|
211
|
+
async unassign(subject) {
|
|
212
|
+
if (!subject) throw new Error("unassign requires a `subject`")
|
|
213
|
+
await driver.unassign(subject)
|
|
214
|
+
cache.invalidate(subject)
|
|
215
|
+
},
|
|
216
|
+
|
|
158
217
|
/**
|
|
159
218
|
* Add or adjust a per-subject override, layered on top of the subject's plan.
|
|
160
219
|
* Merges into any existing override for the subject, so repeated calls
|
|
@@ -166,12 +225,8 @@ export function createEntitlements(options = {}) {
|
|
|
166
225
|
*/
|
|
167
226
|
async override(subject, data) {
|
|
168
227
|
if (!subject) throw new Error("override requires a `subject`")
|
|
169
|
-
validateOverride(data)
|
|
170
|
-
|
|
171
|
-
await driver.setOverride(subject, {
|
|
172
|
-
features: { ...(current.features ?? {}), ...(data.features ?? {}) },
|
|
173
|
-
limits: { ...(current.limits ?? {}), ...(data.limits ?? {}) },
|
|
174
|
-
})
|
|
228
|
+
validateOverride(data, featureUniverse, limitUniverse)
|
|
229
|
+
await driver.mergeOverride(subject, { features: data.features ?? {}, limits: data.limits ?? {} })
|
|
175
230
|
cache.invalidate(subject)
|
|
176
231
|
},
|
|
177
232
|
|
|
@@ -186,20 +241,8 @@ export function createEntitlements(options = {}) {
|
|
|
186
241
|
if (!subject) throw new Error("clearOverride requires a `subject`")
|
|
187
242
|
if (!keys) {
|
|
188
243
|
await driver.clearOverride(subject)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
const current = (await driver.getState(subject))?.override
|
|
193
|
-
if (current) {
|
|
194
|
-
const features = { ...(current.features ?? {}) }
|
|
195
|
-
const limits = { ...(current.limits ?? {}) }
|
|
196
|
-
for (const k of keys.features ?? []) delete features[k]
|
|
197
|
-
for (const k of keys.limits ?? []) delete limits[k]
|
|
198
|
-
if (Object.keys(features).length === 0 && Object.keys(limits).length === 0) {
|
|
199
|
-
await driver.clearOverride(subject)
|
|
200
|
-
} else {
|
|
201
|
-
await driver.setOverride(subject, { features, limits })
|
|
202
|
-
}
|
|
244
|
+
} else {
|
|
245
|
+
await driver.removeOverrideKeys(subject, { features: keys.features ?? [], limits: keys.limits ?? [] })
|
|
203
246
|
}
|
|
204
247
|
cache.invalidate(subject)
|
|
205
248
|
},
|
|
@@ -214,12 +257,15 @@ export function createEntitlements(options = {}) {
|
|
|
214
257
|
},
|
|
215
258
|
|
|
216
259
|
/**
|
|
217
|
-
* Whether a capability flag is granted to the subject.
|
|
260
|
+
* Whether a capability flag is granted to the subject. Throws on a feature
|
|
261
|
+
* key outside the declared/derived universe, so typos surface instead of
|
|
262
|
+
* silently returning false.
|
|
218
263
|
* @param {string} subject
|
|
219
264
|
* @param {string} feature
|
|
220
265
|
* @returns {Promise<boolean>}
|
|
221
266
|
*/
|
|
222
267
|
async can(subject, feature) {
|
|
268
|
+
requireFeature(feature)
|
|
223
269
|
return traced(tracer, "entitle.can", { "entitle.subject": subject, "entitle.feature": feature }, async () => {
|
|
224
270
|
const eff = await resolve(subject)
|
|
225
271
|
return eff.features[feature] === true
|
|
@@ -227,15 +273,19 @@ export function createEntitlements(options = {}) {
|
|
|
227
273
|
},
|
|
228
274
|
|
|
229
275
|
/**
|
|
230
|
-
* The numeric ceiling for a limit key, after overrides. Returns `null`
|
|
231
|
-
*
|
|
276
|
+
* The numeric ceiling for a limit key, after overrides. Returns `null` only
|
|
277
|
+
* when the limit is explicitly unlimited, and `0` for a known limit the
|
|
278
|
+
* subject's plan does not grant - never silently unlimited. Throws on a key
|
|
279
|
+
* outside the declared/derived universe. This is the static ceiling; it does
|
|
280
|
+
* not read usage.
|
|
232
281
|
* @param {string} subject
|
|
233
282
|
* @param {string} key
|
|
234
283
|
* @returns {Promise<number|null>}
|
|
235
284
|
*/
|
|
236
285
|
async limit(subject, key) {
|
|
286
|
+
requireLimit(key)
|
|
237
287
|
const eff = await resolve(subject)
|
|
238
|
-
return key in eff.limits ? eff.limits[key] :
|
|
288
|
+
return key in eff.limits ? eff.limits[key] : 0
|
|
239
289
|
},
|
|
240
290
|
|
|
241
291
|
/**
|
|
@@ -254,7 +304,7 @@ export function createEntitlements(options = {}) {
|
|
|
254
304
|
}
|
|
255
305
|
return traced(tracer, "entitle.check", { "entitle.subject": subject, "entitle.feature": key }, async () => {
|
|
256
306
|
const limit = await this.limit(subject, key)
|
|
257
|
-
const usage = await meter.usage({ subject, metric: key
|
|
307
|
+
const usage = await meter.usage({ ...usageQuery, subject, metric: key })
|
|
258
308
|
const used = usage.quantity
|
|
259
309
|
const allowed = limit === null || used < limit
|
|
260
310
|
return {
|
package/src/memoryDriver.js
CHANGED
|
@@ -23,8 +23,26 @@ export function memoryDriver() {
|
|
|
23
23
|
assignments.set(subject, plan)
|
|
24
24
|
},
|
|
25
25
|
|
|
26
|
-
async
|
|
27
|
-
|
|
26
|
+
async unassign(subject) {
|
|
27
|
+
assignments.delete(subject)
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async mergeOverride(subject, delta) {
|
|
31
|
+
const current = overrides.get(subject) ?? { features: {}, limits: {} }
|
|
32
|
+
overrides.set(subject, {
|
|
33
|
+
features: { ...(current.features ?? {}), ...(delta.features ?? {}) },
|
|
34
|
+
limits: { ...(current.limits ?? {}), ...(delta.limits ?? {}) },
|
|
35
|
+
})
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async removeOverrideKeys(subject, keys) {
|
|
39
|
+
const current = overrides.get(subject)
|
|
40
|
+
if (!current) return
|
|
41
|
+
const features = { ...(current.features ?? {}) }
|
|
42
|
+
const limits = { ...(current.limits ?? {}) }
|
|
43
|
+
for (const k of keys.features ?? []) delete features[k]
|
|
44
|
+
for (const k of keys.limits ?? []) delete limits[k]
|
|
45
|
+
overrides.set(subject, { features, limits })
|
|
28
46
|
},
|
|
29
47
|
|
|
30
48
|
async clearOverride(subject) {
|
package/src/postgresDriver.js
CHANGED
|
@@ -52,11 +52,34 @@ export function postgresDriver(options = {}) {
|
|
|
52
52
|
)
|
|
53
53
|
},
|
|
54
54
|
|
|
55
|
-
async
|
|
55
|
+
async unassign(subject) {
|
|
56
|
+
await pool.query(`delete from ${assignments} where subject = $1`, [subject])
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async mergeOverride(subject, delta) {
|
|
60
|
+
await pool.query(
|
|
61
|
+
`insert into ${overrides} (subject, data)
|
|
62
|
+
values ($1, jsonb_build_object('features', $2::jsonb, 'limits', $3::jsonb))
|
|
63
|
+
on conflict (subject) do update set
|
|
64
|
+
data = jsonb_build_object(
|
|
65
|
+
'features', coalesce(${overrides}.data -> 'features', '{}'::jsonb) || (excluded.data -> 'features'),
|
|
66
|
+
'limits', coalesce(${overrides}.data -> 'limits', '{}'::jsonb) || (excluded.data -> 'limits')
|
|
67
|
+
),
|
|
68
|
+
updated_at = now()`,
|
|
69
|
+
[subject, JSON.stringify(delta.features ?? {}), JSON.stringify(delta.limits ?? {})],
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async removeOverrideKeys(subject, keys) {
|
|
56
74
|
await pool.query(
|
|
57
|
-
`
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
`update ${overrides} set
|
|
76
|
+
data = jsonb_build_object(
|
|
77
|
+
'features', coalesce(data -> 'features', '{}'::jsonb) - $2::text[],
|
|
78
|
+
'limits', coalesce(data -> 'limits', '{}'::jsonb) - $3::text[]
|
|
79
|
+
),
|
|
80
|
+
updated_at = now()
|
|
81
|
+
where subject = $1`,
|
|
82
|
+
[subject, keys.features ?? [], keys.limits ?? []],
|
|
60
83
|
)
|
|
61
84
|
},
|
|
62
85
|
|
package/types/entitlements.d.ts
CHANGED
|
@@ -15,6 +15,13 @@ export function createEntitlements(options?: EntitlementsOptions): {
|
|
|
15
15
|
* @param {string} plan - a key of the plan catalog
|
|
16
16
|
*/
|
|
17
17
|
assign(subject: string, plan: string): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Remove a subject's plan assignment, reverting them to the default plan.
|
|
20
|
+
* Distinct from assigning the default plan explicitly: an unassigned subject
|
|
21
|
+
* follows `defaultPlan` if it later changes.
|
|
22
|
+
* @param {string} subject
|
|
23
|
+
*/
|
|
24
|
+
unassign(subject: string): Promise<void>;
|
|
18
25
|
/**
|
|
19
26
|
* Add or adjust a per-subject override, layered on top of the subject's plan.
|
|
20
27
|
* Merges into any existing override for the subject, so repeated calls
|
|
@@ -43,15 +50,20 @@ export function createEntitlements(options?: EntitlementsOptions): {
|
|
|
43
50
|
*/
|
|
44
51
|
plan(subject: string): Promise<string>;
|
|
45
52
|
/**
|
|
46
|
-
* Whether a capability flag is granted to the subject.
|
|
53
|
+
* Whether a capability flag is granted to the subject. Throws on a feature
|
|
54
|
+
* key outside the declared/derived universe, so typos surface instead of
|
|
55
|
+
* silently returning false.
|
|
47
56
|
* @param {string} subject
|
|
48
57
|
* @param {string} feature
|
|
49
58
|
* @returns {Promise<boolean>}
|
|
50
59
|
*/
|
|
51
60
|
can(subject: string, feature: string): Promise<boolean>;
|
|
52
61
|
/**
|
|
53
|
-
* The numeric ceiling for a limit key, after overrides. Returns `null`
|
|
54
|
-
*
|
|
62
|
+
* The numeric ceiling for a limit key, after overrides. Returns `null` only
|
|
63
|
+
* when the limit is explicitly unlimited, and `0` for a known limit the
|
|
64
|
+
* subject's plan does not grant - never silently unlimited. Throws on a key
|
|
65
|
+
* outside the declared/derived universe. This is the static ceiling; it does
|
|
66
|
+
* not read usage.
|
|
55
67
|
* @param {string} subject
|
|
56
68
|
* @param {string} key
|
|
57
69
|
* @returns {Promise<number|null>}
|
|
@@ -112,6 +124,14 @@ export type EntitlementsOptions = {
|
|
|
112
124
|
* - plan applied to a subject with no assignment; must be a key of `plans`
|
|
113
125
|
*/
|
|
114
126
|
defaultPlan: string;
|
|
127
|
+
/**
|
|
128
|
+
* - the universe of valid feature keys. When given, plans may only reference these and `can()` throws on any other key (catches typos). Defaults to the union of feature keys across all plans
|
|
129
|
+
*/
|
|
130
|
+
features?: string[];
|
|
131
|
+
/**
|
|
132
|
+
* - the universe of valid limit keys. When given, plans may only reference these and `limit()`/`check()` throw on any other key. Defaults to the union of limit keys across all plans
|
|
133
|
+
*/
|
|
134
|
+
limits?: string[];
|
|
115
135
|
/**
|
|
116
136
|
* - optional `@prsm/meter` instance; required only for `check()`, which reads live usage from it
|
|
117
137
|
*/
|