@prsm/entitle 1.0.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 +183 -0
- package/package.json +66 -0
- package/src/entitlements.js +260 -0
- package/src/index.js +3 -0
- package/src/memoryDriver.js +36 -0
- package/src/postgresDriver.js +69 -0
- package/types/entitlements.d.ts +156 -0
- package/types/index.d.ts +3 -0
- package/types/memoryDriver.d.ts +8 -0
- package/types/postgresDriver.d.ts +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="logo.svg" width="80" height="80" alt="entitle logo">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">@prsm/entitle</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://github.com/prsmjs/entitle/actions/workflows/test.yml"><img src="https://github.com/prsmjs/entitle/actions/workflows/test.yml/badge.svg" alt="test"></a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/@prsm/entitle"><img src="https://img.shields.io/npm/v/@prsm/entitle" alt="npm"></a>
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
It pairs with [@prsm/meter](https://www.npmjs.com/package/@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.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @prsm/entitle pg
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { createEntitlements, postgresDriver } from "@prsm/entitle"
|
|
26
|
+
import pg from "pg"
|
|
27
|
+
|
|
28
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })
|
|
29
|
+
|
|
30
|
+
const entitlements = createEntitlements({
|
|
31
|
+
driver: postgresDriver({ pool }),
|
|
32
|
+
defaultPlan: "free",
|
|
33
|
+
plans: {
|
|
34
|
+
free: {
|
|
35
|
+
features: { api_access: true, export_csv: false, sso: false },
|
|
36
|
+
limits: { tokens: 100_000, seats: 1 },
|
|
37
|
+
},
|
|
38
|
+
pro: {
|
|
39
|
+
features: { api_access: true, export_csv: true, sso: false },
|
|
40
|
+
limits: { tokens: 5_000_000, seats: 10 },
|
|
41
|
+
},
|
|
42
|
+
enterprise: {
|
|
43
|
+
features: { api_access: true, export_csv: true, sso: true },
|
|
44
|
+
limits: { tokens: null, seats: null }, // null means unlimited
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
await entitlements.setup() // create tables if they do not exist; idempotent
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Gating a feature and a quota on a request:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
async function exportReport(account) {
|
|
56
|
+
if (!(await entitlements.can(account.id, "export_csv"))) {
|
|
57
|
+
throw new Error("CSV export is not available on your plan")
|
|
58
|
+
}
|
|
59
|
+
// ...
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Assigning plans and per-subject overrides as customers upgrade or negotiate:
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
await entitlements.assign(account.id, "pro") // takes effect immediately
|
|
67
|
+
await entitlements.override(account.id, { limits: { seats: 50 } }) // the enterprise customer who negotiated more seats
|
|
68
|
+
await entitlements.override(account.id, { features: { sso: true } })
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Composing with the meter
|
|
72
|
+
|
|
73
|
+
`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:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
import { createMeter, postgresDriver as meterPostgres } from "@prsm/meter"
|
|
77
|
+
|
|
78
|
+
const meter = createMeter({
|
|
79
|
+
driver: meterPostgres({ pool }),
|
|
80
|
+
metrics: { tokens: { unit: "tokens", aggregate: "sum" } },
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const entitlements = createEntitlements({
|
|
84
|
+
driver: postgresDriver({ pool }),
|
|
85
|
+
defaultPlan: "free",
|
|
86
|
+
plans: { /* ... */ },
|
|
87
|
+
meter,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const quota = await entitlements.check(account.id, "tokens")
|
|
91
|
+
// { allowed: true, used: 84210, remaining: 4915790, limit: 5000000, unit: "tokens", feature: "tokens" }
|
|
92
|
+
|
|
93
|
+
if (!quota.allowed) throw new Error("monthly token limit reached")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
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
|
+
|
|
98
|
+
## Plans, assignments, and overrides
|
|
99
|
+
|
|
100
|
+
The **plan catalog** is declared once at construction, the way you declare your pricing tiers in code. A plan grants:
|
|
101
|
+
|
|
102
|
+
- **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.
|
|
104
|
+
|
|
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`.
|
|
106
|
+
|
|
107
|
+
Resolved 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.
|
|
108
|
+
|
|
109
|
+
## API
|
|
110
|
+
|
|
111
|
+
### `createEntitlements({ driver, plans, defaultPlan, meter?, cacheTtl?, tracer? })`
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
### `entitlements.setup()`
|
|
116
|
+
|
|
117
|
+
Creates the backing tables if they do not exist. Idempotent.
|
|
118
|
+
|
|
119
|
+
### `entitlements.assign(subject, plan)`
|
|
120
|
+
|
|
121
|
+
Assigns a subject to a plan. Takes effect immediately.
|
|
122
|
+
|
|
123
|
+
### `entitlements.override(subject, { features?, limits? })`
|
|
124
|
+
|
|
125
|
+
Layers a per-subject override on top of the plan. Shallow-merges; pass only what differs.
|
|
126
|
+
|
|
127
|
+
### `entitlements.clearOverride(subject)`
|
|
128
|
+
|
|
129
|
+
Removes the subject's override.
|
|
130
|
+
|
|
131
|
+
### `entitlements.can(subject, feature)`
|
|
132
|
+
|
|
133
|
+
Returns whether a capability flag is granted (`false` for an ungranted or unknown feature).
|
|
134
|
+
|
|
135
|
+
### `entitlements.limit(subject, key)`
|
|
136
|
+
|
|
137
|
+
Returns the numeric ceiling for a limit key after overrides, or `null` for an unlimited or undeclared limit. Does not read usage.
|
|
138
|
+
|
|
139
|
+
### `entitlements.check(subject, key, usageQuery?)`
|
|
140
|
+
|
|
141
|
+
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`.
|
|
142
|
+
|
|
143
|
+
### `entitlements.plan(subject)`
|
|
144
|
+
|
|
145
|
+
Returns the subject's effective plan name.
|
|
146
|
+
|
|
147
|
+
### `entitlements.describe(subject)`
|
|
148
|
+
|
|
149
|
+
Returns the full effective snapshot `{ plan, features, limits }`, for a settings or billing page.
|
|
150
|
+
|
|
151
|
+
### `entitlements.close()`
|
|
152
|
+
|
|
153
|
+
Releases driver resources.
|
|
154
|
+
|
|
155
|
+
## Storage
|
|
156
|
+
|
|
157
|
+
Two postgres tables, prefixed `entitle_` by default (pass `prefix` to `postgresDriver` to run several resolvers in one database):
|
|
158
|
+
|
|
159
|
+
- `entitle_assignments` maps a subject to a plan.
|
|
160
|
+
- `entitle_overrides` holds each subject's override as JSON.
|
|
161
|
+
|
|
162
|
+
The plan catalog itself is code, not data, so it is not stored.
|
|
163
|
+
|
|
164
|
+
## Testing
|
|
165
|
+
|
|
166
|
+
The `memoryDriver` mirrors the postgres driver and needs no infrastructure:
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
import { createEntitlements, memoryDriver } from "@prsm/entitle"
|
|
170
|
+
|
|
171
|
+
const entitlements = createEntitlements({
|
|
172
|
+
driver: memoryDriver(),
|
|
173
|
+
defaultPlan: "free",
|
|
174
|
+
plans: { free: { features: { api_access: true }, limits: { seats: 1 } } },
|
|
175
|
+
})
|
|
176
|
+
await entitlements.setup()
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
It is not durable; use it for tests, not production.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prsm/entitle",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Plan-based entitlements and feature gating, resolved at runtime, backed by postgres",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./types/index.d.ts",
|
|
9
|
+
"default": "./src/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./postgres": {
|
|
12
|
+
"types": "./types/postgresDriver.d.ts",
|
|
13
|
+
"default": "./src/postgresDriver.js"
|
|
14
|
+
},
|
|
15
|
+
"./memory": {
|
|
16
|
+
"types": "./types/memoryDriver.d.ts",
|
|
17
|
+
"default": "./src/memoryDriver.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"types": "./types/index.d.ts",
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"types"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "vitest --reporter=verbose --run",
|
|
27
|
+
"test:watch": "vitest",
|
|
28
|
+
"prepublishOnly": "npx tsc --declaration --allowJs --emitDeclarationOnly --skipLibCheck --target es2020 --module nodenext --moduleResolution nodenext --strict false --esModuleInterop true --outDir ./types src/index.js src/postgresDriver.js src/memoryDriver.js"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"entitlements",
|
|
32
|
+
"feature-flags",
|
|
33
|
+
"feature-gating",
|
|
34
|
+
"plans",
|
|
35
|
+
"quota",
|
|
36
|
+
"postgres"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/prsmjs/entitle.git"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/prsmjs/entitle#readme",
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://github.com/prsmjs/entitle/issues"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@prsm/ms": "^2.0.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"pg": "^8.0.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"pg": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"pg": "^8.16.3",
|
|
60
|
+
"typescript": "^5.9.3",
|
|
61
|
+
"vitest": "^3.2.4"
|
|
62
|
+
},
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": ">=18"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import ms from "@prsm/ms"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} Plan
|
|
5
|
+
* @property {Record<string, boolean>} [features] - capability flags this plan grants (`{ sso: true, export_csv: false }`)
|
|
6
|
+
* @property {Record<string, number|null>} [limits] - numeric ceilings keyed by metric name; `null` means unlimited (`{ tokens: 1000000, seats: 5 }`)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} Override
|
|
11
|
+
* A per-subject override layered on top of the subject's plan. Shallow-merges
|
|
12
|
+
* over the plan, so you only specify what differs (the enterprise customer who
|
|
13
|
+
* negotiated more seats, or got one feature switched on).
|
|
14
|
+
* @property {Record<string, boolean>} [features]
|
|
15
|
+
* @property {Record<string, number|null>} [limits]
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {Object} EntitlementsOptions
|
|
20
|
+
* @property {Object} driver - storage backend: `postgresDriver({ pool })` for production, `memoryDriver()` for tests
|
|
21
|
+
* @property {Record<string, Plan>} plans - the plan catalog, declared once at construction (your pricing tiers)
|
|
22
|
+
* @property {string} defaultPlan - plan applied to a subject with no assignment; must be a key of `plans`
|
|
23
|
+
* @property {{ usage: Function }} [meter] - optional `@prsm/meter` instance; required only for `check()`, which reads live usage from it
|
|
24
|
+
* @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
|
+
* @property {{ startSpan: Function }} [tracer] - optional `@prsm/trace` tracer
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} Effective
|
|
30
|
+
* @property {string} plan - the effective plan name
|
|
31
|
+
* @property {Record<string, boolean>} features
|
|
32
|
+
* @property {Record<string, number|null>} limits
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {Object} CheckResult
|
|
37
|
+
* @property {boolean} allowed - whether current usage is below the limit (always true when the limit is unlimited)
|
|
38
|
+
* @property {number} used - current usage, read live from the meter
|
|
39
|
+
* @property {number|null} remaining - `max(0, limit - used)`, or `null` when unlimited
|
|
40
|
+
* @property {number|null} limit - the resolved ceiling, or `null` when unlimited
|
|
41
|
+
* @property {string} [unit] - the meter unit for this metric
|
|
42
|
+
* @property {string} feature - the limit key that was checked
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
function validatePlans(plans, defaultPlan) {
|
|
46
|
+
if (!plans || typeof plans !== "object" || Object.keys(plans).length === 0) {
|
|
47
|
+
throw new Error("createEntitlements requires a non-empty `plans` catalog")
|
|
48
|
+
}
|
|
49
|
+
for (const [name, plan] of Object.entries(plans)) {
|
|
50
|
+
if (plan.features && typeof plan.features !== "object") throw new Error(`plan "${name}" features must be an object`)
|
|
51
|
+
if (plan.limits && typeof plan.limits !== "object") throw new Error(`plan "${name}" limits must be an object`)
|
|
52
|
+
}
|
|
53
|
+
if (!defaultPlan || !plans[defaultPlan]) {
|
|
54
|
+
throw new Error(`createEntitlements requires \`defaultPlan\` to be one of the declared plans: ${Object.keys(plans).join(", ")}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateOverride(data) {
|
|
59
|
+
if (!data || typeof data !== "object") throw new Error("override data must be an object with `features` and/or `limits`")
|
|
60
|
+
for (const [k, v] of Object.entries(data.features ?? {})) {
|
|
61
|
+
if (typeof v !== "boolean") throw new Error(`override feature "${k}" must be a boolean`)
|
|
62
|
+
}
|
|
63
|
+
for (const [k, v] of Object.entries(data.limits ?? {})) {
|
|
64
|
+
if (v !== null && (typeof v !== "number" || !Number.isFinite(v))) throw new Error(`override limit "${k}" must be a finite number or null`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createCache(ttl) {
|
|
69
|
+
const store = new Map()
|
|
70
|
+
return {
|
|
71
|
+
get(subject) {
|
|
72
|
+
if (ttl <= 0) return undefined
|
|
73
|
+
const hit = store.get(subject)
|
|
74
|
+
if (!hit) return undefined
|
|
75
|
+
if (hit.expires <= Date.now()) {
|
|
76
|
+
store.delete(subject)
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
return hit.value
|
|
80
|
+
},
|
|
81
|
+
set(subject, value) {
|
|
82
|
+
if (ttl <= 0) return
|
|
83
|
+
store.set(subject, { value, expires: Date.now() + ttl })
|
|
84
|
+
},
|
|
85
|
+
invalidate(subject) {
|
|
86
|
+
store.delete(subject)
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function traced(tracer, name, attrs, fn) {
|
|
92
|
+
const span = tracer?.startSpan(name, attrs)
|
|
93
|
+
try {
|
|
94
|
+
return await fn()
|
|
95
|
+
} catch (err) {
|
|
96
|
+
span?.setError(err)
|
|
97
|
+
throw err
|
|
98
|
+
} finally {
|
|
99
|
+
span?.end()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Create an entitlements resolver: given a subject, decide what their plan
|
|
105
|
+
* allows right now. Plans are declared up front; assignments and overrides live
|
|
106
|
+
* in the driver and are resolved live on every call, so a plan change or a
|
|
107
|
+
* crossed usage threshold takes effect immediately, not at the next restart.
|
|
108
|
+
*
|
|
109
|
+
* @param {EntitlementsOptions} options
|
|
110
|
+
*/
|
|
111
|
+
export function createEntitlements(options = {}) {
|
|
112
|
+
const { driver, plans, defaultPlan, meter = null, tracer = null } = options
|
|
113
|
+
if (!driver) throw new Error("createEntitlements requires a `driver` (postgresDriver or memoryDriver)")
|
|
114
|
+
validatePlans(plans, defaultPlan)
|
|
115
|
+
|
|
116
|
+
const catalog = { ...plans }
|
|
117
|
+
const cache = createCache(ms(options.cacheTtl ?? "10s"))
|
|
118
|
+
|
|
119
|
+
async function resolve(subject) {
|
|
120
|
+
if (!subject) throw new Error("a `subject` is required")
|
|
121
|
+
const cached = cache.get(subject)
|
|
122
|
+
if (cached) return cached
|
|
123
|
+
|
|
124
|
+
const state = await driver.getState(subject)
|
|
125
|
+
const planName = state?.plan ?? defaultPlan
|
|
126
|
+
const planDef = catalog[planName]
|
|
127
|
+
if (!planDef) {
|
|
128
|
+
throw new Error(`subject "${subject}" is assigned to unknown plan "${planName}"`)
|
|
129
|
+
}
|
|
130
|
+
const override = state?.override ?? {}
|
|
131
|
+
const effective = {
|
|
132
|
+
plan: planName,
|
|
133
|
+
features: { ...(planDef.features ?? {}), ...(override.features ?? {}) },
|
|
134
|
+
limits: { ...(planDef.limits ?? {}), ...(override.limits ?? {}) },
|
|
135
|
+
}
|
|
136
|
+
cache.set(subject, effective)
|
|
137
|
+
return effective
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
/** Create the backing tables if they do not exist. Idempotent. */
|
|
142
|
+
setup() {
|
|
143
|
+
return driver.setup()
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Assign a subject to a plan. Takes effect immediately.
|
|
148
|
+
* @param {string} subject
|
|
149
|
+
* @param {string} plan - a key of the plan catalog
|
|
150
|
+
*/
|
|
151
|
+
async assign(subject, plan) {
|
|
152
|
+
if (!subject) throw new Error("assign requires a `subject`")
|
|
153
|
+
if (!catalog[plan]) throw new Error(`unknown plan "${plan}". declared plans: ${Object.keys(catalog).join(", ")}`)
|
|
154
|
+
await driver.assign(subject, plan)
|
|
155
|
+
cache.invalidate(subject)
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Layer a per-subject override on top of the subject's plan. Shallow-merges,
|
|
160
|
+
* so pass only what differs. Takes effect immediately.
|
|
161
|
+
* @param {string} subject
|
|
162
|
+
* @param {Override} data
|
|
163
|
+
*/
|
|
164
|
+
async override(subject, data) {
|
|
165
|
+
if (!subject) throw new Error("override requires a `subject`")
|
|
166
|
+
validateOverride(data)
|
|
167
|
+
await driver.setOverride(subject, { features: data.features ?? {}, limits: data.limits ?? {} })
|
|
168
|
+
cache.invalidate(subject)
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Remove a subject's override, falling back to plain plan entitlements.
|
|
173
|
+
* @param {string} subject
|
|
174
|
+
*/
|
|
175
|
+
async clearOverride(subject) {
|
|
176
|
+
if (!subject) throw new Error("clearOverride requires a `subject`")
|
|
177
|
+
await driver.clearOverride(subject)
|
|
178
|
+
cache.invalidate(subject)
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The subject's effective plan name (the default plan if unassigned).
|
|
183
|
+
* @param {string} subject
|
|
184
|
+
* @returns {Promise<string>}
|
|
185
|
+
*/
|
|
186
|
+
async plan(subject) {
|
|
187
|
+
return (await resolve(subject)).plan
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether a capability flag is granted to the subject.
|
|
192
|
+
* @param {string} subject
|
|
193
|
+
* @param {string} feature
|
|
194
|
+
* @returns {Promise<boolean>}
|
|
195
|
+
*/
|
|
196
|
+
async can(subject, feature) {
|
|
197
|
+
return traced(tracer, "entitle.can", { "entitle.subject": subject, "entitle.feature": feature }, async () => {
|
|
198
|
+
const eff = await resolve(subject)
|
|
199
|
+
return eff.features[feature] === true
|
|
200
|
+
})
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The numeric ceiling for a limit key, after overrides. Returns `null` for an
|
|
205
|
+
* unlimited or undeclared limit. This is the static ceiling; it does not read usage.
|
|
206
|
+
* @param {string} subject
|
|
207
|
+
* @param {string} key
|
|
208
|
+
* @returns {Promise<number|null>}
|
|
209
|
+
*/
|
|
210
|
+
async limit(subject, key) {
|
|
211
|
+
const eff = await resolve(subject)
|
|
212
|
+
return key in eff.limits ? eff.limits[key] : null
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Check live usage against the subject's limit. Resolves the ceiling, then
|
|
217
|
+
* reads current usage from the meter for the metric of the same name. This is
|
|
218
|
+
* the composition seam: entitle supplies the limit, meter supplies the usage.
|
|
219
|
+
* Requires a `meter` to have been passed to `createEntitlements`.
|
|
220
|
+
* @param {string} subject
|
|
221
|
+
* @param {string} key - a limit key that is also a meter metric
|
|
222
|
+
* @param {{ period?: any, range?: any }} [usageQuery] - forwarded to `meter.usage`
|
|
223
|
+
* @returns {Promise<CheckResult>}
|
|
224
|
+
*/
|
|
225
|
+
async check(subject, key, usageQuery = {}) {
|
|
226
|
+
if (!meter) {
|
|
227
|
+
throw new Error("check requires a `meter`; pass it to createEntitlements, or use limit() for the static ceiling")
|
|
228
|
+
}
|
|
229
|
+
return traced(tracer, "entitle.check", { "entitle.subject": subject, "entitle.feature": key }, async () => {
|
|
230
|
+
const limit = await this.limit(subject, key)
|
|
231
|
+
const usage = await meter.usage({ subject, metric: key, ...usageQuery })
|
|
232
|
+
const used = usage.quantity
|
|
233
|
+
const allowed = limit === null || used < limit
|
|
234
|
+
return {
|
|
235
|
+
allowed,
|
|
236
|
+
used,
|
|
237
|
+
remaining: limit === null ? null : Math.max(0, limit - used),
|
|
238
|
+
limit,
|
|
239
|
+
unit: usage.unit,
|
|
240
|
+
feature: key,
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The subject's full effective entitlements, for a settings or billing page.
|
|
247
|
+
* @param {string} subject
|
|
248
|
+
* @returns {Promise<Effective>}
|
|
249
|
+
*/
|
|
250
|
+
async describe(subject) {
|
|
251
|
+
const eff = await resolve(subject)
|
|
252
|
+
return { plan: eff.plan, features: { ...eff.features }, limits: { ...eff.limits } }
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
/** Release backing resources (driver connections). */
|
|
256
|
+
close() {
|
|
257
|
+
return driver.close?.()
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory entitlements driver. Mirrors the postgres driver's behavior so the
|
|
3
|
+
* test suite runs without infrastructure. Not durable - state lives for the
|
|
4
|
+
* lifetime of the process.
|
|
5
|
+
*
|
|
6
|
+
* @returns {object} a driver for `createEntitlements({ driver })`
|
|
7
|
+
*/
|
|
8
|
+
export function memoryDriver() {
|
|
9
|
+
const assignments = new Map()
|
|
10
|
+
const overrides = new Map()
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
async setup() {},
|
|
14
|
+
|
|
15
|
+
async getState(subject) {
|
|
16
|
+
return {
|
|
17
|
+
plan: assignments.get(subject) ?? null,
|
|
18
|
+
override: overrides.get(subject) ?? null,
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
async assign(subject, plan) {
|
|
23
|
+
assignments.set(subject, plan)
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async setOverride(subject, data) {
|
|
27
|
+
overrides.set(subject, data)
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async clearOverride(subject) {
|
|
31
|
+
overrides.delete(subject)
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async close() {},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} PostgresDriverOptions
|
|
3
|
+
* @property {import("pg").Pool} pool - a `pg` Pool
|
|
4
|
+
* @property {string} [prefix] - table name prefix (default `"entitle"`), for keeping several resolvers in one database
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Durable postgres entitlements driver. Two tables: plan assignments and
|
|
9
|
+
* per-subject overrides. Both are keyed by subject and read live on resolution.
|
|
10
|
+
*
|
|
11
|
+
* @param {PostgresDriverOptions} options
|
|
12
|
+
* @returns {object} a driver for `createEntitlements({ driver })`
|
|
13
|
+
*/
|
|
14
|
+
export function postgresDriver(options = {}) {
|
|
15
|
+
const { pool, prefix = "entitle" } = options
|
|
16
|
+
if (!pool) throw new Error("postgresDriver requires a `pool`")
|
|
17
|
+
|
|
18
|
+
const assignments = `${prefix}_assignments`
|
|
19
|
+
const overrides = `${prefix}_overrides`
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
async setup() {
|
|
23
|
+
await pool.query(`
|
|
24
|
+
create table if not exists ${assignments} (
|
|
25
|
+
subject text primary key,
|
|
26
|
+
plan text not null,
|
|
27
|
+
updated_at timestamptz not null default now()
|
|
28
|
+
);
|
|
29
|
+
create table if not exists ${overrides} (
|
|
30
|
+
subject text primary key,
|
|
31
|
+
data jsonb not null,
|
|
32
|
+
updated_at timestamptz not null default now()
|
|
33
|
+
);
|
|
34
|
+
`)
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async getState(subject) {
|
|
38
|
+
const r = await pool.query(
|
|
39
|
+
`select
|
|
40
|
+
(select plan from ${assignments} where subject = $1) as plan,
|
|
41
|
+
(select data from ${overrides} where subject = $1) as override`,
|
|
42
|
+
[subject],
|
|
43
|
+
)
|
|
44
|
+
return { plan: r.rows[0].plan ?? null, override: r.rows[0].override ?? null }
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async assign(subject, plan) {
|
|
48
|
+
await pool.query(
|
|
49
|
+
`insert into ${assignments} (subject, plan) values ($1, $2)
|
|
50
|
+
on conflict (subject) do update set plan = excluded.plan, updated_at = now()`,
|
|
51
|
+
[subject, plan],
|
|
52
|
+
)
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async setOverride(subject, data) {
|
|
56
|
+
await pool.query(
|
|
57
|
+
`insert into ${overrides} (subject, data) values ($1, $2)
|
|
58
|
+
on conflict (subject) do update set data = excluded.data, updated_at = now()`,
|
|
59
|
+
[subject, JSON.stringify(data)],
|
|
60
|
+
)
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
async clearOverride(subject) {
|
|
64
|
+
await pool.query(`delete from ${overrides} where subject = $1`, [subject])
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async close() {},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create an entitlements resolver: given a subject, decide what their plan
|
|
3
|
+
* allows right now. Plans are declared up front; assignments and overrides live
|
|
4
|
+
* in the driver and are resolved live on every call, so a plan change or a
|
|
5
|
+
* crossed usage threshold takes effect immediately, not at the next restart.
|
|
6
|
+
*
|
|
7
|
+
* @param {EntitlementsOptions} options
|
|
8
|
+
*/
|
|
9
|
+
export function createEntitlements(options?: EntitlementsOptions): {
|
|
10
|
+
/** Create the backing tables if they do not exist. Idempotent. */
|
|
11
|
+
setup(): any;
|
|
12
|
+
/**
|
|
13
|
+
* Assign a subject to a plan. Takes effect immediately.
|
|
14
|
+
* @param {string} subject
|
|
15
|
+
* @param {string} plan - a key of the plan catalog
|
|
16
|
+
*/
|
|
17
|
+
assign(subject: string, plan: string): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Layer a per-subject override on top of the subject's plan. Shallow-merges,
|
|
20
|
+
* so pass only what differs. Takes effect immediately.
|
|
21
|
+
* @param {string} subject
|
|
22
|
+
* @param {Override} data
|
|
23
|
+
*/
|
|
24
|
+
override(subject: string, data: Override): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Remove a subject's override, falling back to plain plan entitlements.
|
|
27
|
+
* @param {string} subject
|
|
28
|
+
*/
|
|
29
|
+
clearOverride(subject: string): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* The subject's effective plan name (the default plan if unassigned).
|
|
32
|
+
* @param {string} subject
|
|
33
|
+
* @returns {Promise<string>}
|
|
34
|
+
*/
|
|
35
|
+
plan(subject: string): Promise<string>;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a capability flag is granted to the subject.
|
|
38
|
+
* @param {string} subject
|
|
39
|
+
* @param {string} feature
|
|
40
|
+
* @returns {Promise<boolean>}
|
|
41
|
+
*/
|
|
42
|
+
can(subject: string, feature: string): Promise<boolean>;
|
|
43
|
+
/**
|
|
44
|
+
* The numeric ceiling for a limit key, after overrides. Returns `null` for an
|
|
45
|
+
* unlimited or undeclared limit. This is the static ceiling; it does not read usage.
|
|
46
|
+
* @param {string} subject
|
|
47
|
+
* @param {string} key
|
|
48
|
+
* @returns {Promise<number|null>}
|
|
49
|
+
*/
|
|
50
|
+
limit(subject: string, key: string): Promise<number | null>;
|
|
51
|
+
/**
|
|
52
|
+
* Check live usage against the subject's limit. Resolves the ceiling, then
|
|
53
|
+
* reads current usage from the meter for the metric of the same name. This is
|
|
54
|
+
* the composition seam: entitle supplies the limit, meter supplies the usage.
|
|
55
|
+
* Requires a `meter` to have been passed to `createEntitlements`.
|
|
56
|
+
* @param {string} subject
|
|
57
|
+
* @param {string} key - a limit key that is also a meter metric
|
|
58
|
+
* @param {{ period?: any, range?: any }} [usageQuery] - forwarded to `meter.usage`
|
|
59
|
+
* @returns {Promise<CheckResult>}
|
|
60
|
+
*/
|
|
61
|
+
check(subject: string, key: string, usageQuery?: {
|
|
62
|
+
period?: any;
|
|
63
|
+
range?: any;
|
|
64
|
+
}): Promise<CheckResult>;
|
|
65
|
+
/**
|
|
66
|
+
* The subject's full effective entitlements, for a settings or billing page.
|
|
67
|
+
* @param {string} subject
|
|
68
|
+
* @returns {Promise<Effective>}
|
|
69
|
+
*/
|
|
70
|
+
describe(subject: string): Promise<Effective>;
|
|
71
|
+
/** Release backing resources (driver connections). */
|
|
72
|
+
close(): any;
|
|
73
|
+
};
|
|
74
|
+
export type Plan = {
|
|
75
|
+
/**
|
|
76
|
+
* - capability flags this plan grants (`{ sso: true, export_csv: false }`)
|
|
77
|
+
*/
|
|
78
|
+
features?: Record<string, boolean>;
|
|
79
|
+
/**
|
|
80
|
+
* - numeric ceilings keyed by metric name; `null` means unlimited (`{ tokens: 1000000, seats: 5 }`)
|
|
81
|
+
*/
|
|
82
|
+
limits?: Record<string, number | null>;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* A per-subject override layered on top of the subject's plan. Shallow-merges
|
|
86
|
+
* over the plan, so you only specify what differs (the enterprise customer who
|
|
87
|
+
* negotiated more seats, or got one feature switched on).
|
|
88
|
+
*/
|
|
89
|
+
export type Override = {
|
|
90
|
+
features?: Record<string, boolean>;
|
|
91
|
+
limits?: Record<string, number | null>;
|
|
92
|
+
};
|
|
93
|
+
export type EntitlementsOptions = {
|
|
94
|
+
/**
|
|
95
|
+
* - storage backend: `postgresDriver({ pool })` for production, `memoryDriver()` for tests
|
|
96
|
+
*/
|
|
97
|
+
driver: any;
|
|
98
|
+
/**
|
|
99
|
+
* - the plan catalog, declared once at construction (your pricing tiers)
|
|
100
|
+
*/
|
|
101
|
+
plans: Record<string, Plan>;
|
|
102
|
+
/**
|
|
103
|
+
* - plan applied to a subject with no assignment; must be a key of `plans`
|
|
104
|
+
*/
|
|
105
|
+
defaultPlan: string;
|
|
106
|
+
/**
|
|
107
|
+
* - optional `@prsm/meter` instance; required only for `check()`, which reads live usage from it
|
|
108
|
+
*/
|
|
109
|
+
meter?: {
|
|
110
|
+
usage: Function;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* - 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
|
|
114
|
+
*/
|
|
115
|
+
cacheTtl?: number | string;
|
|
116
|
+
/**
|
|
117
|
+
* - optional `@prsm/trace` tracer
|
|
118
|
+
*/
|
|
119
|
+
tracer?: {
|
|
120
|
+
startSpan: Function;
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
export type Effective = {
|
|
124
|
+
/**
|
|
125
|
+
* - the effective plan name
|
|
126
|
+
*/
|
|
127
|
+
plan: string;
|
|
128
|
+
features: Record<string, boolean>;
|
|
129
|
+
limits: Record<string, number | null>;
|
|
130
|
+
};
|
|
131
|
+
export type CheckResult = {
|
|
132
|
+
/**
|
|
133
|
+
* - whether current usage is below the limit (always true when the limit is unlimited)
|
|
134
|
+
*/
|
|
135
|
+
allowed: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* - current usage, read live from the meter
|
|
138
|
+
*/
|
|
139
|
+
used: number;
|
|
140
|
+
/**
|
|
141
|
+
* - `max(0, limit - used)`, or `null` when unlimited
|
|
142
|
+
*/
|
|
143
|
+
remaining: number | null;
|
|
144
|
+
/**
|
|
145
|
+
* - the resolved ceiling, or `null` when unlimited
|
|
146
|
+
*/
|
|
147
|
+
limit: number | null;
|
|
148
|
+
/**
|
|
149
|
+
* - the meter unit for this metric
|
|
150
|
+
*/
|
|
151
|
+
unit?: string;
|
|
152
|
+
/**
|
|
153
|
+
* - the limit key that was checked
|
|
154
|
+
*/
|
|
155
|
+
feature: string;
|
|
156
|
+
};
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory entitlements driver. Mirrors the postgres driver's behavior so the
|
|
3
|
+
* test suite runs without infrastructure. Not durable - state lives for the
|
|
4
|
+
* lifetime of the process.
|
|
5
|
+
*
|
|
6
|
+
* @returns {object} a driver for `createEntitlements({ driver })`
|
|
7
|
+
*/
|
|
8
|
+
export function memoryDriver(): object;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} PostgresDriverOptions
|
|
3
|
+
* @property {import("pg").Pool} pool - a `pg` Pool
|
|
4
|
+
* @property {string} [prefix] - table name prefix (default `"entitle"`), for keeping several resolvers in one database
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Durable postgres entitlements driver. Two tables: plan assignments and
|
|
8
|
+
* per-subject overrides. Both are keyed by subject and read live on resolution.
|
|
9
|
+
*
|
|
10
|
+
* @param {PostgresDriverOptions} options
|
|
11
|
+
* @returns {object} a driver for `createEntitlements({ driver })`
|
|
12
|
+
*/
|
|
13
|
+
export function postgresDriver(options?: PostgresDriverOptions): object;
|
|
14
|
+
export type PostgresDriverOptions = {
|
|
15
|
+
/**
|
|
16
|
+
* - a `pg` Pool
|
|
17
|
+
*/
|
|
18
|
+
pool: any;
|
|
19
|
+
/**
|
|
20
|
+
* - table name prefix (default `"entitle"`), for keeping several resolvers in one database
|
|
21
|
+
*/
|
|
22
|
+
prefix?: string;
|
|
23
|
+
};
|