@tumbaland/backend-core 1.23.0 → 1.24.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.
Files changed (68) hide show
  1. package/dist/entitlements/UsageMeter.d.ts +30 -0
  2. package/dist/entitlements/UsageMeter.d.ts.map +1 -0
  3. package/dist/entitlements/UsageMeter.js +27 -0
  4. package/dist/entitlements/UsageMeter.js.map +1 -0
  5. package/dist/entitlements/client.d.ts +17 -0
  6. package/dist/entitlements/client.d.ts.map +1 -0
  7. package/dist/entitlements/client.js +142 -0
  8. package/dist/entitlements/client.js.map +1 -0
  9. package/dist/entitlements/definitions.d.ts +124 -0
  10. package/dist/entitlements/definitions.d.ts.map +1 -0
  11. package/dist/entitlements/definitions.js +210 -0
  12. package/dist/entitlements/definitions.js.map +1 -0
  13. package/dist/entitlements/index.d.ts +12 -0
  14. package/dist/entitlements/index.d.ts.map +1 -0
  15. package/dist/entitlements/index.js +43 -0
  16. package/dist/entitlements/index.js.map +1 -0
  17. package/dist/entitlements/middleware.d.ts +54 -0
  18. package/dist/entitlements/middleware.d.ts.map +1 -0
  19. package/dist/entitlements/middleware.js +109 -0
  20. package/dist/entitlements/middleware.js.map +1 -0
  21. package/dist/entitlements/mode.d.ts +10 -0
  22. package/dist/entitlements/mode.d.ts.map +1 -0
  23. package/dist/entitlements/mode.js +32 -0
  24. package/dist/entitlements/mode.js.map +1 -0
  25. package/dist/entitlements/types.d.ts +29 -0
  26. package/dist/entitlements/types.d.ts.map +1 -0
  27. package/dist/entitlements/types.js +3 -0
  28. package/dist/entitlements/types.js.map +1 -0
  29. package/dist/entitlements/usage.d.ts +56 -0
  30. package/dist/entitlements/usage.d.ts.map +1 -0
  31. package/dist/entitlements/usage.js +159 -0
  32. package/dist/entitlements/usage.js.map +1 -0
  33. package/dist/errors/HttpError.d.ts +30 -1
  34. package/dist/errors/HttpError.d.ts.map +1 -1
  35. package/dist/errors/HttpError.js +20 -2
  36. package/dist/errors/HttpError.js.map +1 -1
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +26 -2
  40. package/dist/index.js.map +1 -1
  41. package/dist/middleware/errorHandler.d.ts.map +1 -1
  42. package/dist/middleware/errorHandler.js +4 -1
  43. package/dist/middleware/errorHandler.js.map +1 -1
  44. package/dist/middleware/internalServiceAuth.d.ts +44 -0
  45. package/dist/middleware/internalServiceAuth.d.ts.map +1 -0
  46. package/dist/middleware/internalServiceAuth.js +86 -0
  47. package/dist/middleware/internalServiceAuth.js.map +1 -0
  48. package/package.json +2 -1
  49. package/src/entitlements/UsageMeter.ts +49 -0
  50. package/src/entitlements/client.test.ts +170 -0
  51. package/src/entitlements/client.ts +162 -0
  52. package/src/entitlements/definitions.test.ts +161 -0
  53. package/src/entitlements/definitions.ts +261 -0
  54. package/src/entitlements/index.ts +40 -0
  55. package/src/entitlements/middleware.test.ts +179 -0
  56. package/src/entitlements/middleware.ts +136 -0
  57. package/src/entitlements/mode.test.ts +50 -0
  58. package/src/entitlements/mode.ts +28 -0
  59. package/src/entitlements/types.ts +30 -0
  60. package/src/entitlements/usage.test.ts +271 -0
  61. package/src/entitlements/usage.ts +189 -0
  62. package/src/errors/HttpError.test.ts +39 -1
  63. package/src/errors/HttpError.ts +35 -1
  64. package/src/index.ts +13 -1
  65. package/src/middleware/errorHandler.test.ts +43 -1
  66. package/src/middleware/errorHandler.ts +6 -1
  67. package/src/middleware/internalServiceAuth.test.ts +173 -0
  68. package/src/middleware/internalServiceAuth.ts +96 -0
@@ -0,0 +1,179 @@
1
+ import { Request, Response } from 'express';
2
+
3
+ jest.mock('../logging/logger', () => ({
4
+ __esModule: true,
5
+ default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
6
+ }));
7
+ jest.mock('./client', () => ({ getEntitlements: jest.fn() }));
8
+ jest.mock('./usage', () => ({ checkQuota: jest.fn() }));
9
+
10
+ import logger from '../logging/logger';
11
+ import { getEntitlements } from './client';
12
+ import { checkQuota } from './usage';
13
+ import { loadEntitlements, requireEntitlement, requireQuota, retentionFloor } from './middleware';
14
+ import { Entitlements } from './types';
15
+ import { UNLIMITED, normalizeLimits } from './definitions';
16
+
17
+ const mockGetEntitlements = getEntitlements as jest.MockedFunction<typeof getEntitlements>;
18
+ const mockCheckQuota = checkQuota as jest.MockedFunction<typeof checkQuota>;
19
+
20
+ const ORIGINAL_ENV = process.env;
21
+
22
+ function entitlements(overrides: Partial<Entitlements> = {}): Entitlements {
23
+ return {
24
+ email: 'user@example.com',
25
+ planCode: 'free',
26
+ status: null,
27
+ limits: normalizeLimits(undefined, 'free'),
28
+ upgradeTo: 'personal',
29
+ ...overrides
30
+ };
31
+ }
32
+
33
+ function mockReq(overrides: Partial<Request> = {}): Request {
34
+ return { user: { email: 'user@example.com' }, originalUrl: '/api/photos', ...overrides } as Request;
35
+ }
36
+
37
+ beforeEach(() => {
38
+ process.env = { ...ORIGINAL_ENV, ENTITLEMENTS_MODE: 'enforce' };
39
+ jest.clearAllMocks();
40
+ mockGetEntitlements.mockResolvedValue(entitlements());
41
+ });
42
+
43
+ afterAll(() => {
44
+ process.env = ORIGINAL_ENV;
45
+ });
46
+
47
+ describe('loadEntitlements', () => {
48
+ it('resolves once per request even across several checks', async () => {
49
+ const req = mockReq();
50
+
51
+ await loadEntitlements(req);
52
+ await loadEntitlements(req);
53
+
54
+ expect(mockGetEntitlements).toHaveBeenCalledTimes(1);
55
+ expect(req.entitlements?.planCode).toBe('free');
56
+ });
57
+
58
+ it('rejects when there is no subject to bill', async () => {
59
+ await expect(loadEntitlements(mockReq({ user: undefined }))).rejects.toMatchObject({ statusCode: 401 });
60
+ });
61
+
62
+ it('bills the subject a resolver names, not the caller', async () => {
63
+ await loadEntitlements(mockReq(), () => 'owner@example.com');
64
+
65
+ expect(mockGetEntitlements).toHaveBeenCalledWith('owner@example.com');
66
+ });
67
+ });
68
+
69
+ describe('requireEntitlement', () => {
70
+ it('passes a plan that includes the feature', async () => {
71
+ mockGetEntitlements.mockResolvedValue(entitlements({ planCode: 'personal', limits: normalizeLimits(undefined, 'personal') }));
72
+ const next = jest.fn();
73
+
74
+ await requireEntitlement('cleanExport')(mockReq(), {} as Response, next);
75
+
76
+ expect(next).toHaveBeenCalledWith();
77
+ });
78
+
79
+ it('rejects with a 402 naming the feature and the upgrade target', async () => {
80
+ const next = jest.fn();
81
+
82
+ await requireEntitlement('cleanExport')(mockReq(), {} as Response, next);
83
+
84
+ expect(next).toHaveBeenCalledWith(
85
+ expect.objectContaining({
86
+ statusCode: 402,
87
+ details: { code: 'FEATURE_NOT_IN_PLAN', feature: 'cleanExport', planCode: 'free', upgradeTo: 'personal' }
88
+ })
89
+ );
90
+ });
91
+
92
+ it('logs instead of blocking in observe mode', async () => {
93
+ process.env.ENTITLEMENTS_MODE = 'observe';
94
+ const next = jest.fn();
95
+
96
+ await requireEntitlement('cleanExport')(mockReq(), {} as Response, next);
97
+
98
+ expect(next).toHaveBeenCalledWith();
99
+ expect(logger.info).toHaveBeenCalledWith('entitlement.would_block', expect.objectContaining({ feature: 'cleanExport' }));
100
+ });
101
+
102
+ it('does not even look entitlements up when the mode is off', async () => {
103
+ process.env.ENTITLEMENTS_MODE = 'off';
104
+ const next = jest.fn();
105
+
106
+ await requireEntitlement('cleanExport')(mockReq(), {} as Response, next);
107
+
108
+ expect(next).toHaveBeenCalledWith();
109
+ expect(mockGetEntitlements).not.toHaveBeenCalled();
110
+ });
111
+ });
112
+
113
+ describe('requireQuota', () => {
114
+ it('checks the meter for the amount the request costs', async () => {
115
+ const next = jest.fn();
116
+ const req = mockReq({ body: { sizeBytes: 2048 } } as Partial<Request>);
117
+
118
+ await requireQuota('storageBytes', { amount: r => (r.body as { sizeBytes: number }).sizeBytes })(
119
+ req,
120
+ {} as Response,
121
+ next
122
+ );
123
+
124
+ expect(mockCheckQuota).toHaveBeenCalledWith(
125
+ expect.objectContaining({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 2048, route: '/api/photos' })
126
+ );
127
+ expect(next).toHaveBeenCalledWith();
128
+ });
129
+
130
+ it('defaults the amount to one', async () => {
131
+ await requireQuota('trackedTickers')(mockReq(), {} as Response, jest.fn());
132
+
133
+ expect(mockCheckQuota).toHaveBeenCalledWith(expect.objectContaining({ amount: 1 }));
134
+ });
135
+
136
+ it('forwards a quota rejection to the error handler', async () => {
137
+ const denial = Object.assign(new Error('nope'), { statusCode: 402 });
138
+ mockCheckQuota.mockRejectedValue(denial);
139
+ const next = jest.fn();
140
+
141
+ await requireQuota('storageBytes')(mockReq(), {} as Response, next);
142
+
143
+ expect(next).toHaveBeenCalledWith(denial);
144
+ });
145
+
146
+ it('is inert when the mode is off', async () => {
147
+ process.env.ENTITLEMENTS_MODE = 'off';
148
+ const next = jest.fn();
149
+
150
+ await requireQuota('storageBytes')(mockReq(), {} as Response, next);
151
+
152
+ expect(mockCheckQuota).not.toHaveBeenCalled();
153
+ expect(next).toHaveBeenCalledWith();
154
+ });
155
+ });
156
+
157
+ describe('retentionFloor', () => {
158
+ const now = new Date('2026-08-09T00:00:00.000Z');
159
+
160
+ it('returns the cutoff date for a capped plan', async () => {
161
+ const floor = await retentionFloor('user@example.com', now);
162
+
163
+ expect(floor).toEqual(new Date('2026-05-11T00:00:00.000Z')); // 90 days back
164
+ });
165
+
166
+ it('returns null for an unlimited plan, so no $match is added', async () => {
167
+ mockGetEntitlements.mockResolvedValue(
168
+ entitlements({ planCode: 'family', limits: normalizeLimits({ retentionDays: UNLIMITED }, 'family') })
169
+ );
170
+
171
+ expect(await retentionFloor('user@example.com', now)).toBeNull();
172
+ });
173
+
174
+ it('never truncates history outside enforce mode', async () => {
175
+ process.env.ENTITLEMENTS_MODE = 'observe';
176
+
177
+ expect(await retentionFloor('user@example.com', now)).toBeNull();
178
+ });
179
+ });
@@ -0,0 +1,136 @@
1
+ import { Request, RequestHandler } from 'express';
2
+ import logger from '../logging/logger';
3
+ import { PaymentRequiredError, UnauthorizedError } from '../errors/HttpError';
4
+ import { FeatureKey, MeterKey, UNLIMITED } from './definitions';
5
+ import { getEntitlements } from './client';
6
+ import { checkQuota } from './usage';
7
+ import { getEntitlementsMode } from './mode';
8
+ import { Entitlements } from './types';
9
+
10
+ declare global {
11
+ namespace Express {
12
+ interface Request {
13
+ /** Populated by the entitlement middlewares so controllers can read limits without a second lookup. */
14
+ entitlements?: Entitlements;
15
+ }
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Who the check is billed to. Defaults to the authenticated user; group
21
+ * chokepoints override it with the group owner, since pooled quota belongs to
22
+ * whoever pays for the group.
23
+ */
24
+ export type SubjectResolver = (req: Request) => string | undefined | Promise<string | undefined>;
25
+
26
+ export interface EntitlementOptions {
27
+ subject?: SubjectResolver;
28
+ }
29
+
30
+ const defaultSubject: SubjectResolver = req => req.user?.email;
31
+
32
+ /**
33
+ * Resolves and caches entitlements on the request. Two checks on one route
34
+ * (a feature gate plus a quota gate) therefore cost one lookup, not two.
35
+ */
36
+ export async function loadEntitlements(req: Request, resolve: SubjectResolver = defaultSubject): Promise<Entitlements> {
37
+ if (req.entitlements) return req.entitlements;
38
+
39
+ const subject = await resolve(req);
40
+ if (!subject) throw new UnauthorizedError('Authentication required');
41
+
42
+ const entitlements = await getEntitlements(subject);
43
+ req.entitlements = entitlements;
44
+ return entitlements;
45
+ }
46
+
47
+ /**
48
+ * Gate a feature flag. Reads are never gated by design — use this on writes,
49
+ * dispatches and exports only.
50
+ */
51
+ export function requireEntitlement(feature: FeatureKey, options: EntitlementOptions = {}): RequestHandler {
52
+ return async (req, _res, next) => {
53
+ try {
54
+ const mode = getEntitlementsMode();
55
+ if (mode === 'off') return next();
56
+
57
+ const entitlements = await loadEntitlements(req, options.subject);
58
+ if (entitlements.limits.features[feature]) return next();
59
+
60
+ if (mode === 'observe') {
61
+ logger.info('entitlement.would_block', {
62
+ event: 'entitlement.would_block',
63
+ feature,
64
+ subjectId: entitlements.email,
65
+ planCode: entitlements.planCode,
66
+ route: req.originalUrl,
67
+ stale: entitlements.stale === true
68
+ });
69
+ return next();
70
+ }
71
+
72
+ throw new PaymentRequiredError(`Your plan does not include ${feature}`, {
73
+ code: 'FEATURE_NOT_IN_PLAN',
74
+ feature,
75
+ planCode: entitlements.planCode,
76
+ upgradeTo: entitlements.upgradeTo
77
+ });
78
+ } catch (err) {
79
+ next(err);
80
+ }
81
+ };
82
+ }
83
+
84
+ export interface QuotaOptions extends EntitlementOptions {
85
+ /**
86
+ * How much this request costs. A function when the amount is in the payload
87
+ * — an upload's `sizeBytes`, say — which is also why the storage gate sits on
88
+ * the signed-URL request: it is the only point where the size is known
89
+ * *before* the bytes are written.
90
+ */
91
+ amount?: number | ((req: Request) => number);
92
+ }
93
+
94
+ /**
95
+ * Read-only quota gate. Records nothing — the increment belongs at the point
96
+ * the resource actually comes into existence, via `consumeQuota`.
97
+ */
98
+ export function requireQuota(meter: MeterKey, options: QuotaOptions = {}): RequestHandler {
99
+ return async (req, _res, next) => {
100
+ try {
101
+ if (getEntitlementsMode() === 'off') return next();
102
+
103
+ const entitlements = await loadEntitlements(req, options.subject);
104
+ const amount = typeof options.amount === 'function' ? options.amount(req) : (options.amount ?? 1);
105
+
106
+ await checkQuota({
107
+ subjectId: entitlements.email,
108
+ meter,
109
+ amount,
110
+ entitlements,
111
+ route: req.originalUrl
112
+ });
113
+ next();
114
+ } catch (err) {
115
+ next(err);
116
+ }
117
+ };
118
+ }
119
+
120
+ /**
121
+ * The oldest timestamp a user may read, or `null` for unlimited history.
122
+ *
123
+ * Meant to be dropped straight into an aggregation `$match` — this is what
124
+ * gates the portfolio planner, since the planner runs entirely client-side and
125
+ * any `<PaywallGate>` around it is bypassable from devtools. Truncating the
126
+ * price history it depends on is not.
127
+ */
128
+ export async function retentionFloor(email: string, now: Date = new Date()): Promise<Date | null> {
129
+ if (getEntitlementsMode() !== 'enforce') return null;
130
+
131
+ const { limits } = await getEntitlements(email);
132
+ const days = limits.retentionDays;
133
+ if (days === UNLIMITED || days < 0) return null;
134
+
135
+ return new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
136
+ }
@@ -0,0 +1,50 @@
1
+ jest.mock('../logging/logger', () => ({
2
+ __esModule: true,
3
+ default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
4
+ }));
5
+
6
+ import logger from '../logging/logger';
7
+ import { getEntitlementsMode, resetEntitlementsModeWarning } from './mode';
8
+
9
+ const ORIGINAL_ENV = process.env;
10
+
11
+ beforeEach(() => {
12
+ process.env = { ...ORIGINAL_ENV };
13
+ delete process.env.ENTITLEMENTS_MODE;
14
+ resetEntitlementsModeWarning();
15
+ (logger.warn as jest.Mock).mockClear();
16
+ });
17
+
18
+ afterAll(() => {
19
+ process.env = ORIGINAL_ENV;
20
+ });
21
+
22
+ describe('getEntitlementsMode', () => {
23
+ it('defaults to observe when unset', () => {
24
+ expect(getEntitlementsMode()).toBe('observe');
25
+ });
26
+
27
+ it('reads each valid mode, case- and whitespace-insensitively', () => {
28
+ process.env.ENTITLEMENTS_MODE = 'enforce';
29
+ expect(getEntitlementsMode()).toBe('enforce');
30
+
31
+ process.env.ENTITLEMENTS_MODE = ' Off ';
32
+ expect(getEntitlementsMode()).toBe('off');
33
+ });
34
+
35
+ it('falls back to observe and warns once on an invalid value', () => {
36
+ process.env.ENTITLEMENTS_MODE = 'enforcce';
37
+
38
+ expect(getEntitlementsMode()).toBe('observe');
39
+ expect(getEntitlementsMode()).toBe('observe');
40
+ expect(logger.warn).toHaveBeenCalledTimes(1);
41
+ });
42
+
43
+ it('picks up a change without a restart of the module', () => {
44
+ process.env.ENTITLEMENTS_MODE = 'observe';
45
+ expect(getEntitlementsMode()).toBe('observe');
46
+
47
+ process.env.ENTITLEMENTS_MODE = 'enforce';
48
+ expect(getEntitlementsMode()).toBe('enforce');
49
+ });
50
+ });
@@ -0,0 +1,28 @@
1
+ import logger from '../logging/logger';
2
+ import { EntitlementsMode } from './types';
3
+
4
+ const VALID_MODES: readonly EntitlementsMode[] = ['off', 'observe', 'enforce'];
5
+
6
+ let warnedAboutInvalidMode = false;
7
+
8
+ /**
9
+ * Read per call rather than cached at import time: a service can be flipped to
10
+ * `enforce` by restarting it with a new env value, and tests set the variable
11
+ * between cases. The cost is one `process.env` lookup per check.
12
+ */
13
+ export function getEntitlementsMode(): EntitlementsMode {
14
+ const raw = (process.env.ENTITLEMENTS_MODE || '').trim().toLowerCase();
15
+ if (!raw) return 'observe';
16
+ if ((VALID_MODES as readonly string[]).includes(raw)) return raw as EntitlementsMode;
17
+
18
+ if (!warnedAboutInvalidMode) {
19
+ warnedAboutInvalidMode = true;
20
+ logger.warn('Invalid ENTITLEMENTS_MODE, falling back to observe', { value: raw });
21
+ }
22
+ return 'observe';
23
+ }
24
+
25
+ /** Test seam — the "already warned" latch would otherwise leak between cases. */
26
+ export function resetEntitlementsModeWarning(): void {
27
+ warnedAboutInvalidMode = false;
28
+ }
@@ -0,0 +1,30 @@
1
+ import { PlanLimits } from './definitions';
2
+
3
+ /**
4
+ * What payment-service answers for a user, and what the client caches.
5
+ * `limits` is already normalized — consumers never merge defaults themselves.
6
+ */
7
+ export interface Entitlements {
8
+ email: string;
9
+ planCode: string;
10
+ /** Subscription status backing the plan; `null` for users on the implicit free plan. */
11
+ status: string | null;
12
+ limits: PlanLimits;
13
+ /** Cheapest active plan priced above the current one, for the upgrade dialog's CTA. */
14
+ upgradeTo?: string;
15
+ /**
16
+ * Set when payment-service could not be reached and these are fallback
17
+ * limits rather than the user's real ones. Enforcement still applies (fail
18
+ * closed), but the value lets callers log the difference.
19
+ */
20
+ stale?: boolean;
21
+ }
22
+
23
+ /**
24
+ * `off` — no metering, no blocking. `observe` — record usage and log what
25
+ * would have been blocked, block nothing. `enforce` — block.
26
+ *
27
+ * Observe is the default because the configured limits start as guesses; two
28
+ * weeks of `entitlement.would_block` volume is what turns them into numbers.
29
+ */
30
+ export type EntitlementsMode = 'off' | 'observe' | 'enforce';