@molecule/api-entitlements 1.0.0 → 1.0.1
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 +709 -0
- package/package.json +14 -13
package/README.md
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
AUTO-GENERATED — DO NOT EDIT THIS FILE.
|
|
3
|
+
Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
|
|
4
|
+
Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
|
|
5
|
+
To change this document, edit the module-level JSDoc in src/index.ts.
|
|
6
|
+
Generated: 2026-08-04T01:48:07.034Z
|
|
7
|
+
-->
|
|
8
|
+
|
|
9
|
+
# @molecule/api-entitlements
|
|
10
|
+
|
|
11
|
+
> **Auto-generated, AI-first package reference** for the [molecule.dev](https://molecule.dev) ecosystem.
|
|
12
|
+
> It is written to be read by coding agents as much as by people, and is generated from this
|
|
13
|
+
> package's source — edit `src/index.ts` JSDoc, not this file.
|
|
14
|
+
|
|
15
|
+
Tier-based entitlements core for molecule.dev.
|
|
16
|
+
|
|
17
|
+
Provides the typed `Tier<TLimits>` / `TierRegistry<TLimits>` shapes, a
|
|
18
|
+
per-process plan-key cache, and Express middleware factories that gate
|
|
19
|
+
endpoints by tier category or quantitative limit.
|
|
20
|
+
|
|
21
|
+
Apps declare their own `TLimits` shape, construct a registry via
|
|
22
|
+
`defineTiers(...)`, and bond it via `setProvider(...)` at startup. The
|
|
23
|
+
webhook glue that maps Stripe / Apple / Google subscription events to
|
|
24
|
+
`users.planKey` already lives in `@molecule/api-resource-user`.
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
import {
|
|
30
|
+
defineTiers,
|
|
31
|
+
setProvider,
|
|
32
|
+
enforceLimit,
|
|
33
|
+
requireCategoryAtLeast,
|
|
34
|
+
} from '@molecule/api-entitlements'
|
|
35
|
+
import { count } from '@molecule/api-database'
|
|
36
|
+
|
|
37
|
+
interface BlogLimits {
|
|
38
|
+
maxPosts: number
|
|
39
|
+
maxCommentsPerDay: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const registry = defineTiers<BlogLimits>({
|
|
43
|
+
tiers: {
|
|
44
|
+
free: {
|
|
45
|
+
planKey: 'free',
|
|
46
|
+
category: 'free',
|
|
47
|
+
name: 'Free',
|
|
48
|
+
limits: { maxPosts: 5, maxCommentsPerDay: 50 },
|
|
49
|
+
},
|
|
50
|
+
stripeMonthly: {
|
|
51
|
+
planKey: 'stripeMonthly',
|
|
52
|
+
category: 'pro',
|
|
53
|
+
name: 'Pro',
|
|
54
|
+
limits: { maxPosts: 100, maxCommentsPerDay: 1000 },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
defaultPlanKey: 'free',
|
|
58
|
+
categoryOrder: ['free', 'pro'],
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
setProvider(registry)
|
|
62
|
+
|
|
63
|
+
// Gate the API routes — the SERVER enforces tiers, never the UI alone:
|
|
64
|
+
router.post(
|
|
65
|
+
'/posts',
|
|
66
|
+
enforceLimit<BlogLimits>({
|
|
67
|
+
limitType: 'maxPosts',
|
|
68
|
+
getLimit: (limits) => limits.maxPosts,
|
|
69
|
+
getCurrent: (userId) => count('posts', [{ field: 'userId', operator: '=', value: userId }]),
|
|
70
|
+
}),
|
|
71
|
+
handlers.createPost,
|
|
72
|
+
)
|
|
73
|
+
router.get('/analytics', requireCategoryAtLeast('pro'), handlers.analytics)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Type
|
|
77
|
+
|
|
78
|
+
`core`
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npm install @molecule/api-entitlements @molecule/api-bond @molecule/api-database @molecule/api-i18n @molecule/api-rate-limit
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## API
|
|
87
|
+
|
|
88
|
+
### Interfaces
|
|
89
|
+
|
|
90
|
+
#### `BuildLimitErrorOptions`
|
|
91
|
+
|
|
92
|
+
Options for building a limit error payload.
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
interface BuildLimitErrorOptions<TLimits = unknown> {
|
|
96
|
+
/** Identifier for the limit that was hit (e.g. `'maxProjects'`). */
|
|
97
|
+
limitType: LimitType
|
|
98
|
+
|
|
99
|
+
/** The current user's tier category (e.g. `'free'`, `'anonymous'`). */
|
|
100
|
+
category: string
|
|
101
|
+
|
|
102
|
+
/** The numeric limit that was exceeded. */
|
|
103
|
+
currentLimit: number
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Optional accessor that maps the next-up tier's `limits` to the relevant
|
|
107
|
+
* numeric value. When omitted, `upgradedLimit` is `null` and the upgrade
|
|
108
|
+
* prompt simply names the next tier without a number.
|
|
109
|
+
*/
|
|
110
|
+
resolveUpgradedLimit?: (nextLimits: TLimits) => number | null | undefined
|
|
111
|
+
|
|
112
|
+
/** Seconds until the client should retry, when applicable. */
|
|
113
|
+
retryAfter?: number
|
|
114
|
+
|
|
115
|
+
/** Optional override for the localized error message. */
|
|
116
|
+
message?: string
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
#### `DefineTiersOptions`
|
|
121
|
+
|
|
122
|
+
Options for constructing a `TierRegistry` via `defineTiers`.
|
|
123
|
+
|
|
124
|
+
```typescript
|
|
125
|
+
interface DefineTiersOptions<TLimits = unknown> {
|
|
126
|
+
/** All tiers indexed by `planKey`. Must include an entry matching `defaultPlanKey`. */
|
|
127
|
+
tiers: Record<string, Tier<TLimits>>
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The plan key that maps to the default tier — used for unrecognized,
|
|
131
|
+
* expired, or null plan keys. Conventionally `'free'` or `''`.
|
|
132
|
+
*/
|
|
133
|
+
defaultPlanKey: string
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The category upgrade order. Categories listed earlier are considered
|
|
137
|
+
* lower-tier; later ones higher. Used by `getNextCategory` to power
|
|
138
|
+
* upgrade prompts.
|
|
139
|
+
*
|
|
140
|
+
* @example `['anonymous', 'free', 'pro', 'team']`
|
|
141
|
+
*/
|
|
142
|
+
categoryOrder: string[]
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
#### `EnforceLimitOptions`
|
|
147
|
+
|
|
148
|
+
Options for the `enforceLimit` middleware.
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
interface EnforceLimitOptions<TLimits = unknown> {
|
|
152
|
+
/** Stable identifier for the limit (used in error payloads, telemetry). */
|
|
153
|
+
limitType: LimitType
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Pulls the numeric cap out of the user's tier `limits` object.
|
|
157
|
+
*
|
|
158
|
+
* @param limits - The tier-specific limits.
|
|
159
|
+
* @returns The numeric cap to enforce.
|
|
160
|
+
*/
|
|
161
|
+
getLimit: (limits: TLimits) => number
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Computes the user's current usage. Receives the userId resolved from the
|
|
165
|
+
* session and the request object so apps can scope by additional fields
|
|
166
|
+
* (e.g. organization, project) when needed.
|
|
167
|
+
*
|
|
168
|
+
* @param userId - The authenticated user ID.
|
|
169
|
+
* @param req - The incoming request, in case scoping needs query/body data.
|
|
170
|
+
* @returns The current usage count.
|
|
171
|
+
*/
|
|
172
|
+
getCurrent: (userId: string, req: Request) => Promise<number> | number
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Optional override for the response status. Defaults to 403; some apps
|
|
176
|
+
* prefer 429 for usage-style limits.
|
|
177
|
+
*/
|
|
178
|
+
status?: number
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
#### `LimitErrorPayload`
|
|
183
|
+
|
|
184
|
+
Structured payload returned to clients when a tier limit is exceeded.
|
|
185
|
+
|
|
186
|
+
Frontends use this to render upgrade prompts that name the user's current
|
|
187
|
+
tier, the limit that was hit, and the next tier that would lift it.
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
interface LimitErrorPayload {
|
|
191
|
+
/** Localized human-readable error message. */
|
|
192
|
+
error: string
|
|
193
|
+
|
|
194
|
+
/** Stable machine-readable identifier of the limit type (e.g. `'maxProjects'`). */
|
|
195
|
+
limitType: LimitType
|
|
196
|
+
|
|
197
|
+
/** The numeric limit on the user's current tier. */
|
|
198
|
+
currentLimit: number
|
|
199
|
+
|
|
200
|
+
/** The numeric limit the user would have on the next-up tier, or `null` if none. */
|
|
201
|
+
upgradedLimit: number | null
|
|
202
|
+
|
|
203
|
+
/** The user's current tier category. */
|
|
204
|
+
currentTier: string
|
|
205
|
+
|
|
206
|
+
/** The next-up tier category, or `null` if already at the top. */
|
|
207
|
+
upgradeTier: string | null
|
|
208
|
+
|
|
209
|
+
/** Whether the user must sign up before upgrading (anonymous → registered). */
|
|
210
|
+
requiresSignup: boolean
|
|
211
|
+
|
|
212
|
+
/** Seconds until the client should retry, when applicable (rate-limit-style errors). */
|
|
213
|
+
retryAfter?: number
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### `PlanCacheEntry`
|
|
218
|
+
|
|
219
|
+
Cached plan-key entry used by the plan cache to avoid a DB query on every
|
|
220
|
+
request. Bond packages and middleware should not depend on the cache shape
|
|
221
|
+
directly — use `getCachedPlanKey()` instead.
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
interface PlanCacheEntry {
|
|
225
|
+
/** The cached plan key, or `null` for free/expired plans. */
|
|
226
|
+
planKey: string | null
|
|
227
|
+
|
|
228
|
+
/** Absolute timestamp (ms since epoch) at which this entry expires. */
|
|
229
|
+
expiresAt: number
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The user's stored plan expiry as read on the cache miss, or `null` when
|
|
233
|
+
* unset. Cached alongside the key because the row was already fetched:
|
|
234
|
+
* consumers that need the subscription's period boundary (e.g. an allowance
|
|
235
|
+
* that refreshes with the billing period) would otherwise re-read the user on
|
|
236
|
+
* every request, which is the exact load this cache exists to avoid. NOT the
|
|
237
|
+
* same as {@link PlanCacheEntry.expiresAt}, which is when the CACHE entry
|
|
238
|
+
* goes stale.
|
|
239
|
+
*/
|
|
240
|
+
planExpiresAt: string | null
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
#### `PlanCacheOptions`
|
|
245
|
+
|
|
246
|
+
Configuration options for the plan cache.
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
interface PlanCacheOptions {
|
|
250
|
+
/** Cache entry TTL in milliseconds. Defaults to 5 minutes. */
|
|
251
|
+
ttlMs?: number
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Max number of cached entries. When exceeded, the oldest insertion-order
|
|
255
|
+
* entry is evicted on the next write. Defaults to 50,000.
|
|
256
|
+
*/
|
|
257
|
+
maxEntries?: number
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* App-specific effective-plan-key demotion (see
|
|
261
|
+
* {@link EffectivePlanKeyResolver}). Applied on every cache MISS so the cached
|
|
262
|
+
* hot-path result already reflects the app's plan-key semantics — e.g. an
|
|
263
|
+
* in-app-purchase key with no expiry demoting to free. Defaults to identity.
|
|
264
|
+
* Pass `null` to clear a previously-set resolver back to identity.
|
|
265
|
+
*/
|
|
266
|
+
effectivePlanKeyResolver?: EffectivePlanKeyResolver | null
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
#### `Tier`
|
|
271
|
+
|
|
272
|
+
A subscription tier with quantitative limits.
|
|
273
|
+
|
|
274
|
+
Each application declares its own `TLimits` shape — for example, a personal
|
|
275
|
+
finance app might use `{ maxAccounts: number; maxTransactionsPerMonth: number }`
|
|
276
|
+
while a chat app might use `{ maxMessagesPerDay: number; maxParticipants: number }`.
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
interface Tier<TLimits = unknown> {
|
|
280
|
+
/**
|
|
281
|
+
* The plan key that identifies this tier. Matches the `planKey` used by
|
|
282
|
+
* `@molecule/api-payments` `Plan` records and by the `planKey` field on
|
|
283
|
+
* the `users` resource.
|
|
284
|
+
*/
|
|
285
|
+
planKey: string
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The tier category, used for ordering and upgrade prompts.
|
|
289
|
+
* Examples: `'anonymous'`, `'free'`, `'pro'`, `'team'`.
|
|
290
|
+
*/
|
|
291
|
+
category: string
|
|
292
|
+
|
|
293
|
+
/** Human-readable display name shown on pricing pages and entitlement errors. */
|
|
294
|
+
name: string
|
|
295
|
+
|
|
296
|
+
/** Application-defined quantitative limits enforced at runtime. */
|
|
297
|
+
limits: TLimits
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
#### `TierRegistry`
|
|
302
|
+
|
|
303
|
+
Registry of all tiers defined by an application.
|
|
304
|
+
|
|
305
|
+
Apps construct a `TierRegistry` via `defineTiers(...)` at startup and bond it
|
|
306
|
+
via `setProvider(registry)`. Middleware and handler code then look up the
|
|
307
|
+
tier for a given user via the bonded registry.
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
interface TierRegistry<TLimits = unknown> {
|
|
311
|
+
/**
|
|
312
|
+
* Look up a tier by plan key. If the key is null/undefined or unrecognized,
|
|
313
|
+
* returns the default tier (typically the free tier).
|
|
314
|
+
*
|
|
315
|
+
* @param planKey - The plan key to look up, or null/undefined for the default tier.
|
|
316
|
+
* @returns The matching tier, or the default tier when the key is unknown.
|
|
317
|
+
*/
|
|
318
|
+
findTier(planKey: string | null | undefined): Tier<TLimits>
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Returns the default tier — the tier applied to unauthenticated, expired,
|
|
322
|
+
* or unrecognized plans. Typically the free tier.
|
|
323
|
+
*
|
|
324
|
+
* @returns The default tier.
|
|
325
|
+
*/
|
|
326
|
+
getDefaultTier(): Tier<TLimits>
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Returns every registered tier, in registration order.
|
|
330
|
+
*
|
|
331
|
+
* @returns All registered tiers.
|
|
332
|
+
*/
|
|
333
|
+
getAllTiers(): Tier<TLimits>[]
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Returns the rank of a category in the upgrade order (0 = lowest).
|
|
337
|
+
* Returns `null` if the category was not declared in the registry's
|
|
338
|
+
* `categoryOrder`.
|
|
339
|
+
*
|
|
340
|
+
* @param category - The category to look up.
|
|
341
|
+
* @returns The zero-based rank, or `null` if not in the order.
|
|
342
|
+
*/
|
|
343
|
+
getCategoryRank(category: string): number | null
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Returns the next-up category in the upgrade order, or `null` if the
|
|
347
|
+
* category is already at the top.
|
|
348
|
+
*
|
|
349
|
+
* @param category - The starting category.
|
|
350
|
+
* @returns The next category up, or `null` at the top of the order.
|
|
351
|
+
*/
|
|
352
|
+
getNextCategory(category: string): string | null
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
#### `UserPlanFields`
|
|
357
|
+
|
|
358
|
+
Minimal user record shape consumed by the plan cache. Only the fields
|
|
359
|
+
needed to derive the effective plan key are required; concrete user
|
|
360
|
+
resources may have many more fields.
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
interface UserPlanFields {
|
|
364
|
+
/** The user's stored plan key, or `null`/empty for free tier. */
|
|
365
|
+
planKey?: string | null
|
|
366
|
+
|
|
367
|
+
/** ISO timestamp at which the current plan expires; expired plans fall back to default. */
|
|
368
|
+
planExpiresAt?: string | null
|
|
369
|
+
|
|
370
|
+
/** Whether the user is anonymous; anonymous users get the `'anonymous'` plan key. */
|
|
371
|
+
isAnonymous?: boolean
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Types
|
|
376
|
+
|
|
377
|
+
#### `EffectivePlanKeyResolver`
|
|
378
|
+
|
|
379
|
+
App-specific hook that maps a stored `(planKey, planExpiresAt)` pair to the
|
|
380
|
+
EFFECTIVE plan key — applied by {@link getCachedPlanKey} on every cache miss
|
|
381
|
+
before the value is cached, so the cached (hot-path) result already reflects
|
|
382
|
+
the app's plan-key semantics.
|
|
383
|
+
|
|
384
|
+
The cache itself only knows the generic expiry rule (a past `planExpiresAt`
|
|
385
|
+
demotes to default). Conventions like "an in-app-purchase key with no expiry
|
|
386
|
+
is unverified → demote to free" are APP-specific (the `apple*`/`google*`
|
|
387
|
+
prefix set is defined by the app, not this package). Apps inject that rule
|
|
388
|
+
here so the hot path and the app's own `resolveEffectivePlanKey` cannot
|
|
389
|
+
diverge — there is one demotion implementation, reused.
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
type EffectivePlanKeyResolver = (
|
|
393
|
+
planKey: string | null,
|
|
394
|
+
planExpiresAt: string | null | undefined,
|
|
395
|
+
) => string | null
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
#### `LimitType`
|
|
399
|
+
|
|
400
|
+
Identifies the kind of limit that triggered a 429-style entitlement error.
|
|
401
|
+
Apps may extend this with domain-specific keys via module augmentation.
|
|
402
|
+
|
|
403
|
+
```typescript
|
|
404
|
+
type LimitType = string
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
#### `RequestHandler`
|
|
408
|
+
|
|
409
|
+
Express-compatible request handler.
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
type RequestHandler = (req: Request, res: Response, next: NextFunction) => void | Promise<void>
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### Functions
|
|
416
|
+
|
|
417
|
+
#### `buildLimitError(options)`
|
|
418
|
+
|
|
419
|
+
Build a `LimitErrorPayload` describing a tier-limit violation.
|
|
420
|
+
|
|
421
|
+
Reads the bonded entitlements registry to resolve the next-up category and
|
|
422
|
+
(optionally) the upgraded limit value. Anonymous users are flagged with
|
|
423
|
+
`requiresSignup: true` so the client can offer a sign-up prompt rather than
|
|
424
|
+
an upgrade prompt.
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
function buildLimitError(options: BuildLimitErrorOptions<TLimits>): LimitErrorPayload
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
- `options` — The limit type, current tier category, current limit, and optional upgrade-limit resolver / retry-after / message override.
|
|
431
|
+
|
|
432
|
+
**Returns:** A structured payload safe to send as a 429 / 403 response body.
|
|
433
|
+
|
|
434
|
+
#### `clearPlanCache()`
|
|
435
|
+
|
|
436
|
+
Drops every cached plan-key entry. Intended for tests and graceful
|
|
437
|
+
shutdown — production code should not need to call this.
|
|
438
|
+
|
|
439
|
+
```typescript
|
|
440
|
+
function clearPlanCache(): void
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
#### `configurePlanCache(options)`
|
|
444
|
+
|
|
445
|
+
Reconfigures the plan cache. Existing entries remain; only future
|
|
446
|
+
insertions and TTL checks observe the new settings.
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
function configurePlanCache(options?: PlanCacheOptions): void
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
- `options` — Optional overrides for TTL, maxEntries, and the effective-plan-key resolver.
|
|
453
|
+
|
|
454
|
+
#### `defineTiers(options)`
|
|
455
|
+
|
|
456
|
+
Constructs a `TierRegistry` from a tier record and category order.
|
|
457
|
+
|
|
458
|
+
Validates that the `defaultPlanKey` exists in the `tiers` record and that
|
|
459
|
+
every tier's `category` appears in `categoryOrder`. Throws synchronously
|
|
460
|
+
on misconfiguration so problems surface at startup, not at request time.
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
function defineTiers(options: DefineTiersOptions<TLimits>): TierRegistry<TLimits>
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
- `options` — The tier set, default plan key, and category upgrade order.
|
|
467
|
+
|
|
468
|
+
**Returns:** A typed tier registry suitable for `setProvider(...)`.
|
|
469
|
+
|
|
470
|
+
#### `enforceLimit(options)`
|
|
471
|
+
|
|
472
|
+
Creates middleware that allows the request only when the user is below
|
|
473
|
+
their tier limit for the given resource. The user's tier `limits` object
|
|
474
|
+
supplies the cap, and the caller-supplied `getCurrent` function counts
|
|
475
|
+
the current usage.
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
function enforceLimit(options: EnforceLimitOptions<TLimits>): RequestHandler
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
- `options` — The limit type, limit accessor, and current-usage accessor.
|
|
482
|
+
|
|
483
|
+
**Returns:** An Express request handler.
|
|
484
|
+
|
|
485
|
+
#### `getCachedPlanKey(userId)`
|
|
486
|
+
|
|
487
|
+
Resolve the effective plan key for a user, hitting the cache on warm reads
|
|
488
|
+
and falling back to a DB lookup on cache miss.
|
|
489
|
+
|
|
490
|
+
The effective plan key:
|
|
491
|
+
|
|
492
|
+
- Returns `'anonymous'` for users flagged as anonymous, regardless of stored plan.
|
|
493
|
+
- Returns `null` for users whose `planExpiresAt` is in the past — callers
|
|
494
|
+
should treat this as the default tier.
|
|
495
|
+
- Returns the stored plan key otherwise (or `null` if none was stored).
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
function getCachedPlanKey(userId: string): Promise<string | null>
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
- `userId` — The user ID to look up.
|
|
502
|
+
|
|
503
|
+
**Returns:** The effective plan key, or `null` for default-tier users.
|
|
504
|
+
|
|
505
|
+
#### `getCachedPlanState(userId)`
|
|
506
|
+
|
|
507
|
+
Resolve a user's effective plan key AND the plan expiry it was derived from,
|
|
508
|
+
hitting the same cache {@link getCachedPlanKey} uses.
|
|
509
|
+
|
|
510
|
+
Exists because the expiry is already read on every cache miss: a consumer
|
|
511
|
+
that needs the subscription's period boundary — an allowance that refreshes
|
|
512
|
+
with the billing period, a renewal countdown — can have it for free instead
|
|
513
|
+
of issuing its own per-request user lookup, which is precisely the database
|
|
514
|
+
load this cache was introduced to remove.
|
|
515
|
+
|
|
516
|
+
`planExpiresAt` is the STORED value, not an effective one: it is `null` for
|
|
517
|
+
anonymous/free users and may be in the past for a plan that has just lapsed
|
|
518
|
+
(in which case `planKey` is already demoted to `null`).
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
function getCachedPlanState(
|
|
522
|
+
userId: string,
|
|
523
|
+
): Promise<{ planKey: string | null; planExpiresAt: string | null }>
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
- `userId` — The user ID to look up.
|
|
527
|
+
|
|
528
|
+
**Returns:** The effective plan key and the stored plan expiry.
|
|
529
|
+
|
|
530
|
+
#### `getEffectiveTier(res)`
|
|
531
|
+
|
|
532
|
+
Resolves the effective tier for the user attached to the request via
|
|
533
|
+
`res.locals.session.userId`. Falls back to the registry's default tier
|
|
534
|
+
when no user is on the request, when the user record cannot be found, or
|
|
535
|
+
when the stored plan has expired.
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
function getEffectiveTier(res: Response): Promise<Tier<TLimits>>
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
- `res` — The response object whose `locals.session.userId` identifies the user.
|
|
542
|
+
|
|
543
|
+
**Returns:** The user's effective tier.
|
|
544
|
+
|
|
545
|
+
#### `getProvider()`
|
|
546
|
+
|
|
547
|
+
Retrieves the bonded tier registry, throwing if none is configured.
|
|
548
|
+
|
|
549
|
+
The generic parameter is the caller's responsibility — entitlements is
|
|
550
|
+
inherently app-specific in its `TLimits` shape, and bonds are erased at
|
|
551
|
+
runtime. Callers should pass their app's `TLimits` type at the call site.
|
|
552
|
+
|
|
553
|
+
```typescript
|
|
554
|
+
function getProvider(): TierRegistry<TLimits>
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
**Returns:** The bonded tier registry.
|
|
558
|
+
|
|
559
|
+
#### `hasProvider()`
|
|
560
|
+
|
|
561
|
+
Checks whether an entitlements provider is currently bonded.
|
|
562
|
+
|
|
563
|
+
```typescript
|
|
564
|
+
function hasProvider(): boolean
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
**Returns:** `true` if a tier registry is bonded.
|
|
568
|
+
|
|
569
|
+
#### `invalidateCachedPlanKey(userId)`
|
|
570
|
+
|
|
571
|
+
Invalidate a single user's cached plan-key entry. Call this immediately
|
|
572
|
+
after writing a new `planKey` / `planExpiresAt` to the user record (e.g.
|
|
573
|
+
from a webhook handler) so the next request reflects the change without
|
|
574
|
+
waiting out the TTL.
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
function invalidateCachedPlanKey(userId: string): void
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
- `userId` — The user ID whose cache entry should be evicted.
|
|
581
|
+
|
|
582
|
+
#### `planCacheSize()`
|
|
583
|
+
|
|
584
|
+
Returns the number of currently cached entries. Mainly useful for tests
|
|
585
|
+
and operational metrics.
|
|
586
|
+
|
|
587
|
+
```typescript
|
|
588
|
+
function planCacheSize(): number
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
**Returns:** Current cache size.
|
|
592
|
+
|
|
593
|
+
#### `requireCategory(allowedCategories)`
|
|
594
|
+
|
|
595
|
+
Creates middleware that allows the request only when the user's tier
|
|
596
|
+
category is one of the listed values. Responds 401 if the request is
|
|
597
|
+
unauthenticated, 403 with a `LimitErrorPayload`-shaped body if the user's
|
|
598
|
+
tier is not in the list.
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
function requireCategory(allowedCategories?: string[]): RequestHandler
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
- `allowedCategories` — The tier categories that are permitted (e.g. `['pro', 'team']`).
|
|
605
|
+
|
|
606
|
+
**Returns:** An Express request handler.
|
|
607
|
+
|
|
608
|
+
#### `requireCategoryAtLeast(minCategory)`
|
|
609
|
+
|
|
610
|
+
Creates middleware that allows the request only when the user's tier
|
|
611
|
+
rank is at least as high as the named category. Useful for "pro and above"
|
|
612
|
+
style gates without listing every category individually.
|
|
613
|
+
|
|
614
|
+
Apps must include all gated categories in `categoryOrder` when calling
|
|
615
|
+
`defineTiers(...)`; categories absent from the order produce `null` ranks
|
|
616
|
+
and therefore fail the check.
|
|
617
|
+
|
|
618
|
+
```typescript
|
|
619
|
+
function requireCategoryAtLeast(minCategory: string): RequestHandler
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
- `minCategory` — The minimum acceptable category.
|
|
623
|
+
|
|
624
|
+
**Returns:** An Express request handler.
|
|
625
|
+
|
|
626
|
+
#### `setProvider(provider)`
|
|
627
|
+
|
|
628
|
+
Registers a tier registry as the active entitlements provider.
|
|
629
|
+
Called by the application during startup.
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
function setProvider(provider: TierRegistry<TLimits>): void
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
- `provider` — The tier registry to bond.
|
|
636
|
+
|
|
637
|
+
#### `sweepExpiredPlanCacheEntries()`
|
|
638
|
+
|
|
639
|
+
Sweep expired entries from the cache. Safe to call from a recurring
|
|
640
|
+
cleanup interval; idempotent and O(n) in cache size.
|
|
641
|
+
|
|
642
|
+
```typescript
|
|
643
|
+
function sweepExpiredPlanCacheEntries(): void
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
## Injection Notes
|
|
647
|
+
|
|
648
|
+
### Requirements
|
|
649
|
+
|
|
650
|
+
Peer dependencies:
|
|
651
|
+
|
|
652
|
+
- `@molecule/api-bond` ^1.0.1
|
|
653
|
+
- `@molecule/api-database` ^1.0.1
|
|
654
|
+
- `@molecule/api-i18n` ^1.0.1
|
|
655
|
+
- `@molecule/api-rate-limit` ^1.0.1
|
|
656
|
+
|
|
657
|
+
### Runtime Dependencies
|
|
658
|
+
|
|
659
|
+
- `@molecule/api-bond`
|
|
660
|
+
- `@molecule/api-database`
|
|
661
|
+
- `@molecule/api-i18n`
|
|
662
|
+
- `@molecule/api-rate-limit`
|
|
663
|
+
|
|
664
|
+
- **Enforcement is middleware on the API route** (`requireCategory`,
|
|
665
|
+
`requireCategoryAtLeast`, `enforceLimit`) — hiding a button in the UI is
|
|
666
|
+
not entitlement enforcement. The middleware reads the authenticated user
|
|
667
|
+
from `res.locals.session.userId`, so it must be registered AFTER the auth
|
|
668
|
+
middleware; unauthenticated requests get a 401.
|
|
669
|
+
- **`enforceLimit` blocks at `current >= limit`** and responds with a
|
|
670
|
+
structured `LimitErrorPayload` (default 403; pass `status: 429` for
|
|
671
|
+
usage-style limits) that the app's limit/upgrade notice renders — don't
|
|
672
|
+
swallow it into a generic error page.
|
|
673
|
+
- **It is a SOFT ceiling — `getCurrent` COUNTS, then the handler CREATES the
|
|
674
|
+
resource afterwards.** Under concurrency N requests can all read the same
|
|
675
|
+
`current < limit` and all create, so the limit can be exceeded by a few.
|
|
676
|
+
That is fine for plan limits (max projects / seats / collaborators — a
|
|
677
|
+
bounded, harmless overshoot). It is NOT enough for a HARD limit where going
|
|
678
|
+
over is a real loss: money / wallet balances, physical inventory (stock,
|
|
679
|
+
tickets, seats), or metered credits. Enforce THOSE atomically at the write
|
|
680
|
+
with a conditional `UPDATE ... WHERE remaining >= $n RETURNING` that affects
|
|
681
|
+
0 rows when it wouldn't fit (or an advisory-lock reserve for a ledger SUM) —
|
|
682
|
+
never a count-then-allow middleware.
|
|
683
|
+
- **Plan keys are cached per process** (default 5-minute TTL). The
|
|
684
|
+
resource-user payment webhook glue invalidates on plan change; any custom
|
|
685
|
+
path that mutates a user's `planKey` must call
|
|
686
|
+
`invalidateCachedPlanKey(userId)` or the old tier lingers until TTL.
|
|
687
|
+
- Unknown, expired, or missing plan keys resolve to the `defaultPlanKey`
|
|
688
|
+
tier — make the default tier's limits the safe floor.
|
|
689
|
+
- The middleware factories are connect/Express-shaped conveniences. Other
|
|
690
|
+
stacks (queues, websockets, non-Express frameworks) enforce the same tiers
|
|
691
|
+
directly via `getProvider()` + `getCachedPlanKey(userId)`.
|
|
692
|
+
|
|
693
|
+
## E2E Tests
|
|
694
|
+
|
|
695
|
+
Integration checklist — drive the real UI (live preview, no mocks), adapt
|
|
696
|
+
each item to this app's actual screens/flows, and check every box off one
|
|
697
|
+
by one. A box you can't check is an integration bug to fix — not a skip:
|
|
698
|
+
|
|
699
|
+
- [ ] The pricing/plans surface lists every tier with its name, price, and limits
|
|
700
|
+
(rendered from `/api/billing/tiers`, not hardcoded).
|
|
701
|
+
- [ ] A free-tier user who hits a quantitative limit (e.g. creates the max
|
|
702
|
+
allowed items, then one more) gets a visible limit/upgrade notice — never a
|
|
703
|
+
silent failure, a blank page, or a raw 500.
|
|
704
|
+
- [ ] The blocked action really is blocked server-side: after a full page reload
|
|
705
|
+
the over-limit item was NOT created.
|
|
706
|
+
- [ ] A higher-tier user (seed or upgrade one) can perform the same action that
|
|
707
|
+
was blocked on the free tier.
|
|
708
|
+
- [ ] Tier-gated features/sections are hidden or clearly locked for tiers that
|
|
709
|
+
lack them, and usable for tiers that have them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@molecule/api-entitlements",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Tier-based entitlements core interface for molecule.dev: typed quantitative limits per subscription plan, plan-key cache, and tier-aware middleware factories",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
|
-
"dist"
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
21
22
|
],
|
|
22
23
|
"keywords": [
|
|
23
24
|
"molecule",
|
|
@@ -29,19 +30,19 @@
|
|
|
29
30
|
],
|
|
30
31
|
"license": "Apache-2.0",
|
|
31
32
|
"peerDependencies": {
|
|
32
|
-
"@molecule/api-bond": "^1.0.
|
|
33
|
-
"@molecule/api-database": "^1.0.
|
|
34
|
-
"@molecule/api-i18n": "^1.0.
|
|
35
|
-
"@molecule/api-rate-limit": "^1.0.
|
|
33
|
+
"@molecule/api-bond": "^1.0.1",
|
|
34
|
+
"@molecule/api-database": "^1.0.1",
|
|
35
|
+
"@molecule/api-i18n": "^1.0.1",
|
|
36
|
+
"@molecule/api-rate-limit": "^1.0.1"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
|
-
"@molecule/api-bond": "1.0.
|
|
39
|
-
"@molecule/api-database": "1.0.
|
|
40
|
-
"@molecule/api-i18n": "1.0.
|
|
41
|
-
"@molecule/api-payments": "1.0.
|
|
42
|
-
"@molecule/api-payments-stripe": "1.0.
|
|
43
|
-
"@molecule/api-rate-limit": "1.0.
|
|
44
|
-
"@molecule/api-resource": "1.0.
|
|
39
|
+
"@molecule/api-bond": "1.0.1",
|
|
40
|
+
"@molecule/api-database": "1.0.1",
|
|
41
|
+
"@molecule/api-i18n": "1.0.1",
|
|
42
|
+
"@molecule/api-payments": "1.0.1",
|
|
43
|
+
"@molecule/api-payments-stripe": "1.0.1",
|
|
44
|
+
"@molecule/api-rate-limit": "1.0.1",
|
|
45
|
+
"@molecule/api-resource": "1.0.1",
|
|
45
46
|
"@types/node": "26.1.2",
|
|
46
47
|
"stripe": "22.4.0",
|
|
47
48
|
"typescript": "6.0.3",
|