@molecule/api-entitlements 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/dist/index.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Tier-based entitlements core for molecule.dev.
3
+ *
4
+ * Provides the typed `Tier<TLimits>` / `TierRegistry<TLimits>` shapes, a
5
+ * per-process plan-key cache, and Express middleware factories that gate
6
+ * endpoints by tier category or quantitative limit.
7
+ *
8
+ * Apps declare their own `TLimits` shape, construct a registry via
9
+ * `defineTiers(...)`, and bond it via `setProvider(...)` at startup. The
10
+ * webhook glue that maps Stripe / Apple / Google subscription events to
11
+ * `users.planKey` already lives in `@molecule/api-resource-user`.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { defineTiers, setProvider, enforceLimit, requireCategoryAtLeast } from '@molecule/api-entitlements'
16
+ * import { count } from '@molecule/api-database'
17
+ *
18
+ * interface BlogLimits {
19
+ * maxPosts: number
20
+ * maxCommentsPerDay: number
21
+ * }
22
+ *
23
+ * const registry = defineTiers<BlogLimits>({
24
+ * tiers: {
25
+ * free: { planKey: 'free', category: 'free', name: 'Free', limits: { maxPosts: 5, maxCommentsPerDay: 50 } },
26
+ * stripeMonthly: { planKey: 'stripeMonthly', category: 'pro', name: 'Pro', limits: { maxPosts: 100, maxCommentsPerDay: 1000 } },
27
+ * },
28
+ * defaultPlanKey: 'free',
29
+ * categoryOrder: ['free', 'pro'],
30
+ * })
31
+ *
32
+ * setProvider(registry)
33
+ *
34
+ * // Gate the API routes — the SERVER enforces tiers, never the UI alone:
35
+ * router.post('/posts',
36
+ * enforceLimit<BlogLimits>({
37
+ * limitType: 'maxPosts',
38
+ * getLimit: (limits) => limits.maxPosts,
39
+ * getCurrent: (userId) => count('posts', [{ field: 'userId', operator: '=', value: userId }]),
40
+ * }),
41
+ * handlers.createPost,
42
+ * )
43
+ * router.get('/analytics', requireCategoryAtLeast('pro'), handlers.analytics)
44
+ * ```
45
+ *
46
+ * @remarks
47
+ * - **Enforcement is middleware on the API route** (`requireCategory`,
48
+ * `requireCategoryAtLeast`, `enforceLimit`) — hiding a button in the UI is
49
+ * not entitlement enforcement. The middleware reads the authenticated user
50
+ * from `res.locals.session.userId`, so it must be registered AFTER the auth
51
+ * middleware; unauthenticated requests get a 401.
52
+ * - **`enforceLimit` blocks at `current >= limit`** and responds with a
53
+ * structured `LimitErrorPayload` (default 403; pass `status: 429` for
54
+ * usage-style limits) that the app's limit/upgrade notice renders — don't
55
+ * swallow it into a generic error page.
56
+ * - **It is a SOFT ceiling — `getCurrent` COUNTS, then the handler CREATES the
57
+ * resource afterwards.** Under concurrency N requests can all read the same
58
+ * `current < limit` and all create, so the limit can be exceeded by a few.
59
+ * That is fine for plan limits (max projects / seats / collaborators — a
60
+ * bounded, harmless overshoot). It is NOT enough for a HARD limit where going
61
+ * over is a real loss: money / wallet balances, physical inventory (stock,
62
+ * tickets, seats), or metered credits. Enforce THOSE atomically at the write
63
+ * with a conditional `UPDATE ... WHERE remaining >= $n RETURNING` that affects
64
+ * 0 rows when it wouldn't fit (or an advisory-lock reserve for a ledger SUM) —
65
+ * never a count-then-allow middleware.
66
+ * - **Plan keys are cached per process** (default 5-minute TTL). The
67
+ * resource-user payment webhook glue invalidates on plan change; any custom
68
+ * path that mutates a user's `planKey` must call
69
+ * `invalidateCachedPlanKey(userId)` or the old tier lingers until TTL.
70
+ * - Unknown, expired, or missing plan keys resolve to the `defaultPlanKey`
71
+ * tier — make the default tier's limits the safe floor.
72
+ * - The middleware factories are connect/Express-shaped conveniences. Other
73
+ * stacks (queues, websockets, non-Express frameworks) enforce the same tiers
74
+ * directly via `getProvider()` + `getCachedPlanKey(userId)`.
75
+ *
76
+ * @e2e
77
+ * Integration checklist — drive the real UI (live preview, no mocks), adapt
78
+ * each item to this app's actual screens/flows, and check every box off one
79
+ * by one. A box you can't check is an integration bug to fix — not a skip:
80
+ * - [ ] The pricing/plans surface lists every tier with its name, price, and limits
81
+ * (rendered from `/api/billing/tiers`, not hardcoded).
82
+ * - [ ] A free-tier user who hits a quantitative limit (e.g. creates the max
83
+ * allowed items, then one more) gets a visible limit/upgrade notice — never a
84
+ * silent failure, a blank page, or a raw 500.
85
+ * - [ ] The blocked action really is blocked server-side: after a full page reload
86
+ * the over-limit item was NOT created.
87
+ * - [ ] A higher-tier user (seed or upgrade one) can perform the same action that
88
+ * was blocked on the free tier.
89
+ * - [ ] Tier-gated features/sections are hidden or clearly locked for tiers that
90
+ * lack them, and usable for tiers that have them.
91
+ *
92
+ * @module
93
+ */
94
+ export * from './browser-guard.js';
95
+ export * from './cache.js';
96
+ export * from './error.js';
97
+ export * from './middleware.js';
98
+ export * from './provider.js';
99
+ export * from './registry.js';
100
+ export * from './types.js';
101
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4FG;AAEH,cAAc,oBAAoB,CAAA;AAClC,cAAc,YAAY,CAAA;AAC1B,cAAc,YAAY,CAAA;AAC1B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"}
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Express middleware factories for tier-based entitlements.
3
+ *
4
+ * `requireCategory` and `requireCategoryAtLeast` gate endpoints by the user's
5
+ * tier category. `enforceLimit` checks a quantitative limit before allowing
6
+ * the request to proceed (e.g. "max 10 projects per user").
7
+ *
8
+ * Tier-aware windowed rate limiting (e.g. "60 requests per minute on free,
9
+ * 300 on pro") is intentionally not handled here — wire one
10
+ * `@molecule/api-rate-limit` middleware per tier and dispatch on category,
11
+ * or use the bonded rate-limit provider directly.
12
+ *
13
+ * @module
14
+ */
15
+ import type { LimitType, Tier } from './types.js';
16
+ /**
17
+ * Minimal request shape consumed by the entitlements middleware. The
18
+ * structural type lets Express's full `Request` flow in transparently
19
+ * (Express types are assignable to this) without forcing this package to
20
+ * take a hard dependency on `express`.
21
+ *
22
+ * The middleware never reads off `req` directly; auth context lives on
23
+ * `res.locals.session.userId` and is read via the response object instead.
24
+ * The optional `_skipReason` field exists purely so this is not an empty
25
+ * interface (which ESLint's `@typescript-eslint/no-empty-interface` flags).
26
+ */
27
+ interface Request {
28
+ /** Reserved — not consumed by the middleware. */
29
+ readonly _skipReason?: never;
30
+ }
31
+ /** Minimal response shape — Express's `Response` is assignable to this. */
32
+ interface Response {
33
+ status(code: number): Response;
34
+ json(body: unknown): Response;
35
+ set(field: string, value: string): Response;
36
+ locals: {
37
+ session?: {
38
+ userId?: string;
39
+ };
40
+ } & Record<string, unknown>;
41
+ }
42
+ /** Express-compatible next function. */
43
+ type NextFunction = (err?: unknown) => void;
44
+ /** Express-compatible request handler. */
45
+ export type RequestHandler = (req: Request, res: Response, next: NextFunction) => void | Promise<void>;
46
+ /**
47
+ * Resolves the effective tier for the user attached to the request via
48
+ * `res.locals.session.userId`. Falls back to the registry's default tier
49
+ * when no user is on the request, when the user record cannot be found, or
50
+ * when the stored plan has expired.
51
+ *
52
+ * @param res - The response object whose `locals.session.userId` identifies the user.
53
+ * @returns The user's effective tier.
54
+ */
55
+ export declare const getEffectiveTier: <TLimits = unknown>(res: Response) => Promise<Tier<TLimits>>;
56
+ /**
57
+ * Creates middleware that allows the request only when the user's tier
58
+ * category is one of the listed values. Responds 401 if the request is
59
+ * unauthenticated, 403 with a `LimitErrorPayload`-shaped body if the user's
60
+ * tier is not in the list.
61
+ *
62
+ * @param allowedCategories - The tier categories that are permitted (e.g. `['pro', 'team']`).
63
+ * @returns An Express request handler.
64
+ */
65
+ export declare const requireCategory: <TLimits = unknown>(...allowedCategories: string[]) => RequestHandler;
66
+ /**
67
+ * Creates middleware that allows the request only when the user's tier
68
+ * rank is at least as high as the named category. Useful for "pro and above"
69
+ * style gates without listing every category individually.
70
+ *
71
+ * Apps must include all gated categories in `categoryOrder` when calling
72
+ * `defineTiers(...)`; categories absent from the order produce `null` ranks
73
+ * and therefore fail the check.
74
+ *
75
+ * @param minCategory - The minimum acceptable category.
76
+ * @returns An Express request handler.
77
+ */
78
+ export declare const requireCategoryAtLeast: <TLimits = unknown>(minCategory: string) => RequestHandler;
79
+ /** Options for the `enforceLimit` middleware. */
80
+ export interface EnforceLimitOptions<TLimits = unknown> {
81
+ /** Stable identifier for the limit (used in error payloads, telemetry). */
82
+ limitType: LimitType;
83
+ /**
84
+ * Pulls the numeric cap out of the user's tier `limits` object.
85
+ *
86
+ * @param limits - The tier-specific limits.
87
+ * @returns The numeric cap to enforce.
88
+ */
89
+ getLimit: (limits: TLimits) => number;
90
+ /**
91
+ * Computes the user's current usage. Receives the userId resolved from the
92
+ * session and the request object so apps can scope by additional fields
93
+ * (e.g. organization, project) when needed.
94
+ *
95
+ * @param userId - The authenticated user ID.
96
+ * @param req - The incoming request, in case scoping needs query/body data.
97
+ * @returns The current usage count.
98
+ */
99
+ getCurrent: (userId: string, req: Request) => Promise<number> | number;
100
+ /**
101
+ * Optional override for the response status. Defaults to 403; some apps
102
+ * prefer 429 for usage-style limits.
103
+ */
104
+ status?: number;
105
+ }
106
+ /**
107
+ * Creates middleware that allows the request only when the user is below
108
+ * their tier limit for the given resource. The user's tier `limits` object
109
+ * supplies the cap, and the caller-supplied `getCurrent` function counts
110
+ * the current usage.
111
+ *
112
+ * @param options - The limit type, limit accessor, and current-usage accessor.
113
+ * @returns An Express request handler.
114
+ *
115
+ * @example
116
+ * ```typescript
117
+ * router.post('/posts',
118
+ * enforceLimit<BlogLimits>({
119
+ * limitType: 'maxPosts',
120
+ * getLimit: (limits) => limits.maxPosts,
121
+ * getCurrent: (userId) => count('posts', [{ field: 'userId', operator: '=', value: userId }]),
122
+ * }),
123
+ * handlers.createPost,
124
+ * )
125
+ * ```
126
+ */
127
+ export declare const enforceLimit: <TLimits = unknown>(options: EnforceLimitOptions<TLimits>) => RequestHandler;
128
+ export {};
129
+ //# sourceMappingURL=middleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAOH,OAAO,KAAK,EAAqB,SAAS,EAAE,IAAI,EAAE,MAAM,YAAY,CAAA;AAEpE;;;;;;;;;;GAUG;AACH,UAAU,OAAO;IACf,iDAAiD;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,KAAK,CAAA;CAC7B;AAED,2EAA2E;AAC3E,UAAU,QAAQ;IAChB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAAA;IAC9B,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAAA;IAC7B,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ,CAAA;IAC3C,MAAM,EAAE;QAAE,OAAO,CAAC,EAAE;YAAE,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CACpE;AAED,wCAAwC;AACxC,KAAK,YAAY,GAAG,CAAC,GAAG,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;AAE3C,0CAA0C;AAC1C,MAAM,MAAM,cAAc,GAAG,CAC3B,GAAG,EAAE,OAAO,EACZ,GAAG,EAAE,QAAQ,EACb,IAAI,EAAE,YAAY,KACf,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;AAEzB;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,GAAU,OAAO,GAAG,OAAO,EACtD,KAAK,QAAQ,KACZ,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAOvB,CAAA;AAaD;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,GAAG,OAAO,EAC/C,GAAG,mBAAmB,MAAM,EAAE,KAC7B,cAqBF,CAAA;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,sBAAsB,GAAI,OAAO,GAAG,OAAO,EAAE,aAAa,MAAM,KAAG,cA0B/E,CAAA;AAED,iDAAiD;AACjD,MAAM,WAAW,mBAAmB,CAAC,OAAO,GAAG,OAAO;IACpD,2EAA2E;IAC3E,SAAS,EAAE,SAAS,CAAA;IAEpB;;;;;OAKG;IACH,QAAQ,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,MAAM,CAAA;IAErC;;;;;;;;OAQG;IACH,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAA;IAEtE;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,GAAG,OAAO,EAC5C,SAAS,mBAAmB,CAAC,OAAO,CAAC,KACpC,cA2BF,CAAA"}
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Express middleware factories for tier-based entitlements.
3
+ *
4
+ * `requireCategory` and `requireCategoryAtLeast` gate endpoints by the user's
5
+ * tier category. `enforceLimit` checks a quantitative limit before allowing
6
+ * the request to proceed (e.g. "max 10 projects per user").
7
+ *
8
+ * Tier-aware windowed rate limiting (e.g. "60 requests per minute on free,
9
+ * 300 on pro") is intentionally not handled here — wire one
10
+ * `@molecule/api-rate-limit` middleware per tier and dispatch on category,
11
+ * or use the bonded rate-limit provider directly.
12
+ *
13
+ * @module
14
+ */
15
+ import { t } from '@molecule/api-i18n';
16
+ import { getCachedPlanKey } from './cache.js';
17
+ import { buildLimitError } from './error.js';
18
+ import { getProvider } from './provider.js';
19
+ /**
20
+ * Resolves the effective tier for the user attached to the request via
21
+ * `res.locals.session.userId`. Falls back to the registry's default tier
22
+ * when no user is on the request, when the user record cannot be found, or
23
+ * when the stored plan has expired.
24
+ *
25
+ * @param res - The response object whose `locals.session.userId` identifies the user.
26
+ * @returns The user's effective tier.
27
+ */
28
+ export const getEffectiveTier = async (res) => {
29
+ const registry = getProvider();
30
+ const userId = res.locals?.session?.userId;
31
+ if (!userId)
32
+ return registry.getDefaultTier();
33
+ const planKey = await getCachedPlanKey(userId);
34
+ return registry.findTier(planKey);
35
+ };
36
+ /**
37
+ * Builds a 401 response payload for unauthenticated requests.
38
+ *
39
+ * @returns An error envelope shaped like `LimitErrorPayload` for client uniformity.
40
+ */
41
+ const buildUnauthorizedPayload = () => ({
42
+ error: t('entitlements.error.unauthenticated', undefined, {
43
+ defaultValue: 'Authentication required.',
44
+ }),
45
+ });
46
+ /**
47
+ * Creates middleware that allows the request only when the user's tier
48
+ * category is one of the listed values. Responds 401 if the request is
49
+ * unauthenticated, 403 with a `LimitErrorPayload`-shaped body if the user's
50
+ * tier is not in the list.
51
+ *
52
+ * @param allowedCategories - The tier categories that are permitted (e.g. `['pro', 'team']`).
53
+ * @returns An Express request handler.
54
+ */
55
+ export const requireCategory = (...allowedCategories) => {
56
+ return async (_req, res, next) => {
57
+ const userId = res.locals?.session?.userId;
58
+ if (!userId) {
59
+ res.status(401).json(buildUnauthorizedPayload());
60
+ return;
61
+ }
62
+ const tier = await getEffectiveTier(res);
63
+ if (!allowedCategories.includes(tier.category)) {
64
+ const payload = buildLimitError({
65
+ limitType: 'category',
66
+ category: tier.category,
67
+ currentLimit: 0,
68
+ });
69
+ res.status(403).json(payload);
70
+ return;
71
+ }
72
+ next();
73
+ };
74
+ };
75
+ /**
76
+ * Creates middleware that allows the request only when the user's tier
77
+ * rank is at least as high as the named category. Useful for "pro and above"
78
+ * style gates without listing every category individually.
79
+ *
80
+ * Apps must include all gated categories in `categoryOrder` when calling
81
+ * `defineTiers(...)`; categories absent from the order produce `null` ranks
82
+ * and therefore fail the check.
83
+ *
84
+ * @param minCategory - The minimum acceptable category.
85
+ * @returns An Express request handler.
86
+ */
87
+ export const requireCategoryAtLeast = (minCategory) => {
88
+ return async (_req, res, next) => {
89
+ const userId = res.locals?.session?.userId;
90
+ if (!userId) {
91
+ res.status(401).json(buildUnauthorizedPayload());
92
+ return;
93
+ }
94
+ const registry = getProvider();
95
+ const minRank = registry.getCategoryRank(minCategory);
96
+ const tier = await getEffectiveTier(res);
97
+ const userRank = registry.getCategoryRank(tier.category);
98
+ if (minRank == null || userRank == null || userRank < minRank) {
99
+ const payload = buildLimitError({
100
+ limitType: 'categoryAtLeast',
101
+ category: tier.category,
102
+ currentLimit: userRank ?? 0,
103
+ });
104
+ res.status(403).json(payload);
105
+ return;
106
+ }
107
+ next();
108
+ };
109
+ };
110
+ /**
111
+ * Creates middleware that allows the request only when the user is below
112
+ * their tier limit for the given resource. The user's tier `limits` object
113
+ * supplies the cap, and the caller-supplied `getCurrent` function counts
114
+ * the current usage.
115
+ *
116
+ * @param options - The limit type, limit accessor, and current-usage accessor.
117
+ * @returns An Express request handler.
118
+ *
119
+ * @example
120
+ * ```typescript
121
+ * router.post('/posts',
122
+ * enforceLimit<BlogLimits>({
123
+ * limitType: 'maxPosts',
124
+ * getLimit: (limits) => limits.maxPosts,
125
+ * getCurrent: (userId) => count('posts', [{ field: 'userId', operator: '=', value: userId }]),
126
+ * }),
127
+ * handlers.createPost,
128
+ * )
129
+ * ```
130
+ */
131
+ export const enforceLimit = (options) => {
132
+ const { limitType, getLimit, getCurrent, status = 403 } = options;
133
+ return async (req, res, next) => {
134
+ const userId = res.locals?.session?.userId;
135
+ if (!userId) {
136
+ res.status(401).json(buildUnauthorizedPayload());
137
+ return;
138
+ }
139
+ const tier = await getEffectiveTier(res);
140
+ const limit = getLimit(tier.limits);
141
+ const current = await getCurrent(userId, req);
142
+ if (current >= limit) {
143
+ const payload = buildLimitError({
144
+ limitType,
145
+ category: tier.category,
146
+ currentLimit: limit,
147
+ resolveUpgradedLimit: getLimit,
148
+ });
149
+ res.status(status).json(payload);
150
+ return;
151
+ }
152
+ next();
153
+ };
154
+ };
155
+ //# sourceMappingURL=middleware.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"middleware.js","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Entitlements provider bond accessor.
3
+ *
4
+ * Apps construct a `TierRegistry` (typically via `defineTiers(...)`) and call
5
+ * `setProvider(registry)` at startup. Handler code, middleware, and any other
6
+ * consumer then resolves the bonded registry via `getProvider<TLimits>()`.
7
+ *
8
+ * @module
9
+ */
10
+ import type { TierRegistry } from './types.js';
11
+ /**
12
+ * Registers a tier registry as the active entitlements provider.
13
+ * Called by the application during startup.
14
+ *
15
+ * @param provider - The tier registry to bond.
16
+ */
17
+ export declare const setProvider: <TLimits = unknown>(provider: TierRegistry<TLimits>) => void;
18
+ /**
19
+ * Retrieves the bonded tier registry, throwing if none is configured.
20
+ *
21
+ * The generic parameter is the caller's responsibility — entitlements is
22
+ * inherently app-specific in its `TLimits` shape, and bonds are erased at
23
+ * runtime. Callers should pass their app's `TLimits` type at the call site.
24
+ *
25
+ * @returns The bonded tier registry.
26
+ * @throws {Error} If no entitlements provider has been bonded.
27
+ */
28
+ export declare const getProvider: <TLimits = unknown>() => TierRegistry<TLimits>;
29
+ /**
30
+ * Checks whether an entitlements provider is currently bonded.
31
+ *
32
+ * @returns `true` if a tier registry is bonded.
33
+ */
34
+ export declare const hasProvider: () => boolean;
35
+ //# sourceMappingURL=provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAK9C;;;;;GAKG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,GAAG,OAAO,EAAE,UAAU,YAAY,CAAC,OAAO,CAAC,KAAG,IAEhF,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,GAAG,OAAO,OAAK,YAAY,CAAC,OAAO,CAYrE,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,WAAW,QAAO,OAE9B,CAAA"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Entitlements provider bond accessor.
3
+ *
4
+ * Apps construct a `TierRegistry` (typically via `defineTiers(...)`) and call
5
+ * `setProvider(registry)` at startup. Handler code, middleware, and any other
6
+ * consumer then resolves the bonded registry via `getProvider<TLimits>()`.
7
+ *
8
+ * @module
9
+ */
10
+ import { bond, expectBond, isBonded, require as bondRequire } from '@molecule/api-bond';
11
+ import { t } from '@molecule/api-i18n';
12
+ const BOND_TYPE = 'entitlements';
13
+ expectBond(BOND_TYPE);
14
+ /**
15
+ * Registers a tier registry as the active entitlements provider.
16
+ * Called by the application during startup.
17
+ *
18
+ * @param provider - The tier registry to bond.
19
+ */
20
+ export const setProvider = (provider) => {
21
+ bond(BOND_TYPE, provider);
22
+ };
23
+ /**
24
+ * Retrieves the bonded tier registry, throwing if none is configured.
25
+ *
26
+ * The generic parameter is the caller's responsibility — entitlements is
27
+ * inherently app-specific in its `TLimits` shape, and bonds are erased at
28
+ * runtime. Callers should pass their app's `TLimits` type at the call site.
29
+ *
30
+ * @returns The bonded tier registry.
31
+ * @throws {Error} If no entitlements provider has been bonded.
32
+ */
33
+ export const getProvider = () => {
34
+ try {
35
+ return bondRequire(BOND_TYPE);
36
+ }
37
+ catch (error) {
38
+ throw new Error(t('entitlements.error.noProvider', undefined, {
39
+ defaultValue: 'Entitlements provider not configured. Call setProvider(tierRegistry) at startup.',
40
+ }), { cause: error });
41
+ }
42
+ };
43
+ /**
44
+ * Checks whether an entitlements provider is currently bonded.
45
+ *
46
+ * @returns `true` if a tier registry is bonded.
47
+ */
48
+ export const hasProvider = () => {
49
+ return isBonded(BOND_TYPE);
50
+ };
51
+ //# sourceMappingURL=provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provider.js","sourceRoot":"","sources":["../src/provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,oBAAoB,CAAA;AACvF,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAItC,MAAM,SAAS,GAAG,cAAc,CAAA;AAChC,UAAU,CAAC,SAAS,CAAC,CAAA;AAErB;;;;;GAKG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAoB,QAA+B,EAAQ,EAAE;IACtF,IAAI,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAA;AAC3B,CAAC,CAAA;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,GAA6C,EAAE;IACxE,IAAI,CAAC;QACH,OAAO,WAAW,CAAwB,SAAS,CAAC,CAAA;IACtD,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CACb,CAAC,CAAC,+BAA+B,EAAE,SAAS,EAAE;YAC5C,YAAY,EACV,kFAAkF;SACrF,CAAC,EACF,EAAE,KAAK,EAAE,KAAK,EAAE,CACjB,CAAA;IACH,CAAC;AACH,CAAC,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,GAAY,EAAE;IACvC,OAAO,QAAQ,CAAC,SAAS,CAAC,CAAA;AAC5B,CAAC,CAAA"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Tier registry construction.
3
+ *
4
+ * `defineTiers(...)` builds a `TierRegistry` from a flat record of tiers plus
5
+ * a category upgrade order. Apps typically call this once at startup and
6
+ * bond the result via `setProvider(...)`.
7
+ *
8
+ * @module
9
+ */
10
+ import type { DefineTiersOptions, TierRegistry } from './types.js';
11
+ /**
12
+ * Constructs a `TierRegistry` from a tier record and category order.
13
+ *
14
+ * Validates that the `defaultPlanKey` exists in the `tiers` record and that
15
+ * every tier's `category` appears in `categoryOrder`. Throws synchronously
16
+ * on misconfiguration so problems surface at startup, not at request time.
17
+ *
18
+ * @param options - The tier set, default plan key, and category upgrade order.
19
+ * @returns A typed tier registry suitable for `setProvider(...)`.
20
+ * @throws {Error} If `defaultPlanKey` is missing from `tiers` or any tier's
21
+ * category is missing from `categoryOrder`.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * interface BlogLimits {
26
+ * maxPosts: number
27
+ * maxCommentsPerDay: number
28
+ * }
29
+ *
30
+ * const registry = defineTiers<BlogLimits>({
31
+ * tiers: {
32
+ * free: { planKey: 'free', category: 'free', name: 'Free', limits: { maxPosts: 5, maxCommentsPerDay: 50 } },
33
+ * stripeMonthly: { planKey: 'stripeMonthly', category: 'pro', name: 'Pro', limits: { maxPosts: 100, maxCommentsPerDay: 1000 } },
34
+ * },
35
+ * defaultPlanKey: 'free',
36
+ * categoryOrder: ['free', 'pro'],
37
+ * })
38
+ * ```
39
+ */
40
+ export declare const defineTiers: <TLimits = unknown>(options: DefineTiersOptions<TLimits>) => TierRegistry<TLimits>;
41
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,OAAO,KAAK,EAAE,kBAAkB,EAAQ,YAAY,EAAE,MAAM,YAAY,CAAA;AAExE;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,GAAG,OAAO,EAC3C,SAAS,kBAAkB,CAAC,OAAO,CAAC,KACnC,YAAY,CAAC,OAAO,CAiEtB,CAAA"}
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Tier registry construction.
3
+ *
4
+ * `defineTiers(...)` builds a `TierRegistry` from a flat record of tiers plus
5
+ * a category upgrade order. Apps typically call this once at startup and
6
+ * bond the result via `setProvider(...)`.
7
+ *
8
+ * @module
9
+ */
10
+ import { t } from '@molecule/api-i18n';
11
+ /**
12
+ * Constructs a `TierRegistry` from a tier record and category order.
13
+ *
14
+ * Validates that the `defaultPlanKey` exists in the `tiers` record and that
15
+ * every tier's `category` appears in `categoryOrder`. Throws synchronously
16
+ * on misconfiguration so problems surface at startup, not at request time.
17
+ *
18
+ * @param options - The tier set, default plan key, and category upgrade order.
19
+ * @returns A typed tier registry suitable for `setProvider(...)`.
20
+ * @throws {Error} If `defaultPlanKey` is missing from `tiers` or any tier's
21
+ * category is missing from `categoryOrder`.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * interface BlogLimits {
26
+ * maxPosts: number
27
+ * maxCommentsPerDay: number
28
+ * }
29
+ *
30
+ * const registry = defineTiers<BlogLimits>({
31
+ * tiers: {
32
+ * free: { planKey: 'free', category: 'free', name: 'Free', limits: { maxPosts: 5, maxCommentsPerDay: 50 } },
33
+ * stripeMonthly: { planKey: 'stripeMonthly', category: 'pro', name: 'Pro', limits: { maxPosts: 100, maxCommentsPerDay: 1000 } },
34
+ * },
35
+ * defaultPlanKey: 'free',
36
+ * categoryOrder: ['free', 'pro'],
37
+ * })
38
+ * ```
39
+ */
40
+ export const defineTiers = (options) => {
41
+ const { tiers, defaultPlanKey, categoryOrder } = options;
42
+ const defaultTier = tiers[defaultPlanKey];
43
+ if (!defaultTier) {
44
+ throw new Error(t('entitlements.error.missingDefaultTier', { planKey: defaultPlanKey }, {
45
+ defaultValue: `defineTiers: defaultPlanKey "${defaultPlanKey}" not found in tiers record.`,
46
+ }));
47
+ }
48
+ const orderRanks = new Map();
49
+ categoryOrder.forEach((category, index) => {
50
+ orderRanks.set(category, index);
51
+ });
52
+ for (const [planKey, tier] of Object.entries(tiers)) {
53
+ if (!orderRanks.has(tier.category)) {
54
+ throw new Error(t('entitlements.error.missingCategory', { planKey, category: tier.category }, {
55
+ defaultValue: `defineTiers: tier "${planKey}" has category "${tier.category}" which is not in categoryOrder.`,
56
+ }));
57
+ }
58
+ }
59
+ const allTiers = Object.values(tiers);
60
+ return {
61
+ findTier(planKey) {
62
+ if (planKey == null)
63
+ return defaultTier;
64
+ const tier = tiers[planKey];
65
+ if (tier)
66
+ return tier;
67
+ return defaultTier;
68
+ },
69
+ getDefaultTier() {
70
+ return defaultTier;
71
+ },
72
+ getAllTiers() {
73
+ return allTiers;
74
+ },
75
+ getCategoryRank(category) {
76
+ const rank = orderRanks.get(category);
77
+ return rank ?? null;
78
+ },
79
+ getNextCategory(category) {
80
+ const rank = orderRanks.get(category);
81
+ if (rank == null)
82
+ return null;
83
+ const next = categoryOrder[rank + 1];
84
+ return next ?? null;
85
+ },
86
+ };
87
+ };
88
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,oBAAoB,CAAA;AAItC;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CACzB,OAAoC,EACb,EAAE;IACzB,MAAM,EAAE,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,GAAG,OAAO,CAAA;IAExD,MAAM,WAAW,GAAG,KAAK,CAAC,cAAc,CAAC,CAAA;IACzC,IAAI,CAAC,WAAW,EAAE,CAAC;QACjB,MAAM,IAAI,KAAK,CACb,CAAC,CACC,uCAAuC,EACvC,EAAE,OAAO,EAAE,cAAc,EAAE,EAC3B;YACE,YAAY,EAAE,gCAAgC,cAAc,8BAA8B;SAC3F,CACF,CACF,CAAA;IACH,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,GAAG,EAAkB,CAAA;IAC5C,aAAa,CAAC,OAAO,CAAC,CAAC,QAAQ,EAAE,KAAK,EAAE,EAAE;QACxC,UAAU,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;IACjC,CAAC,CAAC,CAAA;IAEF,KAAK,MAAM,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACpD,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;YACnC,MAAM,IAAI,KAAK,CACb,CAAC,CACC,oCAAoC,EACpC,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,EACpC;gBACE,YAAY,EAAE,sBAAsB,OAAO,mBAAmB,IAAI,CAAC,QAAQ,kCAAkC;aAC9G,CACF,CACF,CAAA;QACH,CAAC;IACH,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,KAAK,CAAoB,CAAA;IAExD,OAAO;QACL,QAAQ,CAAC,OAAO;YACd,IAAI,OAAO,IAAI,IAAI;gBAAE,OAAO,WAAW,CAAA;YACvC,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,CAAA;YAC3B,IAAI,IAAI;gBAAE,OAAO,IAAI,CAAA;YACrB,OAAO,WAAW,CAAA;QACpB,CAAC;QAED,cAAc;YACZ,OAAO,WAAW,CAAA;QACpB,CAAC;QAED,WAAW;YACT,OAAO,QAAQ,CAAA;QACjB,CAAC;QAED,eAAe,CAAC,QAAQ;YACtB,MAAM,IAAI,GAAG,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;YACrC,OAAO,IAAI,IAAI,IAAI,CAAA;QACrB,CAAC;QAED,eAAe,CAAC,QAAQ;YACtB,MAAM,IAAI,GAAG,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;YACrC,IAAI,IAAI,IAAI,IAAI;gBAAE,OAAO,IAAI,CAAA;YAC7B,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,GAAG,CAAC,CAAC,CAAA;YACpC,OAAO,IAAI,IAAI,IAAI,CAAA;QACrB,CAAC;KACF,CAAA;AACH,CAAC,CAAA"}