@tumbaland/backend-core 1.43.0 → 1.45.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 (101) hide show
  1. package/package.json +5 -1
  2. package/.versionrc.json +0 -7
  3. package/__mocks__/uuid.js +0 -8
  4. package/jest.config.js +0 -24
  5. package/src/apiKeys/ApiKey.test.ts +0 -142
  6. package/src/apiKeys/ApiKey.ts +0 -102
  7. package/src/apiKeys/crypto.test.ts +0 -161
  8. package/src/apiKeys/crypto.ts +0 -163
  9. package/src/apiKeys/index.test.ts +0 -72
  10. package/src/apiKeys/index.ts +0 -35
  11. package/src/apiKeys/middleware.test.ts +0 -651
  12. package/src/apiKeys/middleware.ts +0 -341
  13. package/src/apiKeys/service.test.ts +0 -401
  14. package/src/apiKeys/service.ts +0 -168
  15. package/src/apiKeys/types.test.ts +0 -41
  16. package/src/apiKeys/types.ts +0 -124
  17. package/src/app/createBaseApp.test.ts +0 -109
  18. package/src/app/createBaseApp.ts +0 -102
  19. package/src/app/shutdown.test.ts +0 -129
  20. package/src/app/shutdown.ts +0 -81
  21. package/src/audit/AuditEvent.ts +0 -123
  22. package/src/audit/actor.test.ts +0 -95
  23. package/src/audit/actor.ts +0 -68
  24. package/src/audit/context.test.ts +0 -91
  25. package/src/audit/context.ts +0 -83
  26. package/src/audit/index.ts +0 -11
  27. package/src/audit/plugin.test.ts +0 -258
  28. package/src/audit/plugin.ts +0 -254
  29. package/src/audit/reads.test.ts +0 -164
  30. package/src/audit/reads.ts +0 -88
  31. package/src/audit/service.test.ts +0 -115
  32. package/src/audit/service.ts +0 -95
  33. package/src/auth/session.test.ts +0 -89
  34. package/src/auth/session.ts +0 -75
  35. package/src/config/env.test.ts +0 -28
  36. package/src/config/env.ts +0 -13
  37. package/src/database/connection.test.ts +0 -188
  38. package/src/database/connection.ts +0 -90
  39. package/src/entitlements/UsageMeter.ts +0 -49
  40. package/src/entitlements/client.test.ts +0 -200
  41. package/src/entitlements/client.ts +0 -179
  42. package/src/entitlements/definitions.test.ts +0 -161
  43. package/src/entitlements/definitions.ts +0 -268
  44. package/src/entitlements/index.ts +0 -42
  45. package/src/entitlements/middleware.test.ts +0 -196
  46. package/src/entitlements/middleware.ts +0 -150
  47. package/src/entitlements/reconcile.test.ts +0 -333
  48. package/src/entitlements/reconcile.ts +0 -384
  49. package/src/entitlements/types.ts +0 -21
  50. package/src/entitlements/usage.test.ts +0 -314
  51. package/src/entitlements/usage.ts +0 -223
  52. package/src/errors/HttpError.test.ts +0 -76
  53. package/src/errors/HttpError.ts +0 -91
  54. package/src/groups/client.test.ts +0 -215
  55. package/src/groups/client.ts +0 -182
  56. package/src/groups/index.ts +0 -7
  57. package/src/groups/membership.test.ts +0 -84
  58. package/src/groups/membership.ts +0 -133
  59. package/src/groups/subject.test.ts +0 -85
  60. package/src/groups/subject.ts +0 -50
  61. package/src/health/createHealthCheck.test.ts +0 -89
  62. package/src/health/createHealthCheck.ts +0 -67
  63. package/src/health/healthController.test.ts +0 -113
  64. package/src/health/healthController.ts +0 -56
  65. package/src/index.ts +0 -88
  66. package/src/logging/logger.test.ts +0 -91
  67. package/src/logging/logger.ts +0 -103
  68. package/src/metrics/index.test.ts +0 -116
  69. package/src/metrics/index.ts +0 -111
  70. package/src/middleware/authMiddleware.test.ts +0 -275
  71. package/src/middleware/authMiddleware.ts +0 -91
  72. package/src/middleware/corsMiddleware.test.ts +0 -135
  73. package/src/middleware/corsMiddleware.ts +0 -65
  74. package/src/middleware/errorHandler.test.ts +0 -188
  75. package/src/middleware/errorHandler.ts +0 -103
  76. package/src/middleware/internalServiceAuth.test.ts +0 -173
  77. package/src/middleware/internalServiceAuth.ts +0 -96
  78. package/src/middleware/requestLogger.test.ts +0 -81
  79. package/src/middleware/requestLogger.ts +0 -48
  80. package/src/middleware/security.test.ts +0 -45
  81. package/src/middleware/security.ts +0 -43
  82. package/src/middleware/validate.test.ts +0 -72
  83. package/src/middleware/validate.ts +0 -23
  84. package/src/oauth/index.ts +0 -29
  85. package/src/oauth/models.ts +0 -164
  86. package/src/oauth/service.test.ts +0 -432
  87. package/src/oauth/service.ts +0 -299
  88. package/src/oauth/tokens.test.ts +0 -146
  89. package/src/oauth/tokens.ts +0 -186
  90. package/src/testing/serviceTestSetup.ts +0 -17
  91. package/src/tracing/index.test.ts +0 -272
  92. package/src/tracing/index.ts +0 -110
  93. package/src/types/auth.ts +0 -45
  94. package/src/utils/correlation.test.ts +0 -47
  95. package/src/utils/correlation.ts +0 -22
  96. package/src/utils/permissionUtils.test.ts +0 -47
  97. package/src/utils/permissionUtils.ts +0 -68
  98. package/src/utils/response.test.ts +0 -64
  99. package/src/utils/response.ts +0 -60
  100. package/tsconfig.build.json +0 -7
  101. package/tsconfig.json +0 -23
@@ -1,268 +0,0 @@
1
- /**
2
- * The entitlement *vocabulary*: which meters and features exist, what they
3
- * mean, and what a plan gets when nothing else says otherwise.
4
- *
5
- * The actual per-plan numbers are NOT here — they live on the `Plan` document
6
- * in Mongo and are editable by an admin at runtime (see payment-service's
7
- * `Plan.limits` and `PUT /api/plans/:code/limits`). Shipping a code change to
8
- * move a limit from 50 GB to 75 GB would be absurd, and a hardcoded table
9
- * would immediately drift from what the pricing page renders.
10
- *
11
- * What code still owns:
12
- * - the set of valid keys, so a typo in an admin form can't invent a meter
13
- * that nothing enforces (`normalizeLimits` drops unknown keys);
14
- * - `DEFAULT_PLAN_LIMITS`, the values used when a Plan document has no
15
- * `limits` yet (fresh install, newly created plan) or when payment-service
16
- * is unreachable and the client has to fail closed;
17
- * - `ENTITLEMENT_CATALOG`, the metadata an admin UI renders its form from,
18
- * so adding a meter needs no frontend change.
19
- */
20
-
21
- export type MeterKey = 'storageBytes' | 'aiJobs' | 'trackedTickers' | 'seats';
22
-
23
- /**
24
- * Deliberately small. The portfolio planner is NOT a feature flag — it is gated
25
- * by `retentionDays` via the price-history range, because the planner runs
26
- * entirely client-side and a boolean flag would be bypassable from devtools.
27
- */
28
- export type FeatureKey = 'cleanExport';
29
-
30
- /** `ALL` = counts up forever (storage, seats). `MONTH` = resets each calendar month (UTC). */
31
- export type MeterPeriod = 'ALL' | 'MONTH';
32
-
33
- /** Sentinel for "no ceiling". Chosen over `Infinity` because it survives JSON and BSON. */
34
- export const UNLIMITED = -1;
35
-
36
- export interface MeterDefinition {
37
- key: MeterKey;
38
- label: string;
39
- /** Drives formatting in the admin form and in `<UsageMeter>`: bytes get humanized, counts don't. */
40
- unit: 'bytes' | 'count';
41
- period: MeterPeriod;
42
- description: string;
43
- }
44
-
45
- export interface FeatureDefinition {
46
- key: FeatureKey;
47
- label: string;
48
- description: string;
49
- }
50
-
51
- export const METER_DEFINITIONS: readonly MeterDefinition[] = [
52
- {
53
- key: 'storageBytes',
54
- label: 'Storage',
55
- unit: 'bytes',
56
- period: 'ALL',
57
- description: 'Total bytes of uploaded photos and files held for the user.'
58
- },
59
- {
60
- key: 'aiJobs',
61
- label: 'AI analyses',
62
- unit: 'count',
63
- period: 'MONTH',
64
- description: 'Segmentation and object-detection jobs dispatched per calendar month.'
65
- },
66
- {
67
- key: 'trackedTickers',
68
- label: 'Tracked tickers',
69
- unit: 'count',
70
- period: 'ALL',
71
- description: 'Instruments followed for price history. Each one spends the deployment-wide market-data budget.'
72
- },
73
- {
74
- key: 'seats',
75
- label: 'Group seats',
76
- unit: 'count',
77
- period: 'ALL',
78
- description: 'Members that may join groups owned by the user, including the owner.'
79
- }
80
- ] as const;
81
-
82
- export const FEATURE_DEFINITIONS: readonly FeatureDefinition[] = [
83
- {
84
- key: 'cleanExport',
85
- label: 'Clean export',
86
- description: 'Exports and downloads without a watermark.'
87
- }
88
- ] as const;
89
-
90
- export const METER_KEYS: readonly MeterKey[] = METER_DEFINITIONS.map(m => m.key);
91
- export const FEATURE_KEYS: readonly FeatureKey[] = FEATURE_DEFINITIONS.map(f => f.key);
92
-
93
- const METER_PERIODS = Object.fromEntries(
94
- METER_DEFINITIONS.map(m => [m.key, m.period])
95
- ) as Record<MeterKey, MeterPeriod>;
96
-
97
- export function meterPeriod(meter: MeterKey): MeterPeriod {
98
- return METER_PERIODS[meter] ?? 'ALL';
99
- }
100
-
101
- export function isMeterKey(value: string): value is MeterKey {
102
- return (METER_KEYS as readonly string[]).includes(value);
103
- }
104
-
105
- export function isFeatureKey(value: string): value is FeatureKey {
106
- return (FEATURE_KEYS as readonly string[]).includes(value);
107
- }
108
-
109
- export interface PlanLimits {
110
- /** `-1` (UNLIMITED) means no ceiling. */
111
- meters: Record<MeterKey, number>;
112
- features: Record<FeatureKey, boolean>;
113
- /** How far back reads may reach. `-1` (UNLIMITED) means the full history. */
114
- retentionDays: number;
115
- }
116
-
117
- /** What an admin form or an API caller may send: any subset, in any order. */
118
- export interface PartialPlanLimits {
119
- meters?: Partial<Record<string, number>>;
120
- features?: Partial<Record<string, boolean>>;
121
- retentionDays?: number;
122
- }
123
-
124
- const GB = 1024 * 1024 * 1024;
125
-
126
- /**
127
- * Bootstrap values only — a Plan document's own `limits` wins over anything
128
- * here. These are what a plan gets before an admin has ever touched it, and
129
- * what the entitlement client falls back to when payment-service is down.
130
- *
131
- * Keep `free` conservative for exactly that reason: an outage degrades paying
132
- * users to these numbers, and the alternative (fail-open) gives the product
133
- * away for the length of the outage.
134
- */
135
- export const DEFAULT_PLAN_LIMITS: Readonly<Record<string, PlanLimits>> = {
136
- free: {
137
- meters: { storageBytes: 1 * GB, aiJobs: 25, trackedTickers: 3, seats: 1 },
138
- features: { cleanExport: false },
139
- retentionDays: 90
140
- },
141
- /**
142
- * The single paid plan, and it is a *support* tier rather than a product
143
- * edition. `personal` and `family` were consolidated into it: two paid tiers
144
- * imply a business deciding what to withhold from which customer, which is
145
- * the wrong shape for a project asking people to chip in. One tier that
146
- * grants everything removes that question entirely.
147
- *
148
- * Limits are the old `family` numbers — pooled seats, unlimited retention —
149
- * except storage, which is 50 GB rather than 250. 250 GB was priced for a
150
- * household paying €12; at €5 it is more object storage than the support is
151
- * covering.
152
- */
153
- support: {
154
- meters: { storageBytes: 50 * GB, aiJobs: 600, trackedTickers: 100, seats: 5 },
155
- features: { cleanExport: true },
156
- retentionDays: UNLIMITED
157
- },
158
- /**
159
- * Everything unlimited, for the maintainers and for comped friends. Held as a
160
- * real plan with a real subscription rather than an exemption branch in the
161
- * code, so the people running the product go through the identical
162
- * enforcement path as a paying customer — an `if (isStaff) skipEverything`
163
- * flag would mean the paywall is never exercised by anyone who could notice
164
- * it was broken.
165
- *
166
- * Kept off the public catalog through `Plan.isPublic = false`.
167
- */
168
- staff: {
169
- meters: { storageBytes: UNLIMITED, aiJobs: UNLIMITED, trackedTickers: UNLIMITED, seats: UNLIMITED },
170
- features: { cleanExport: true },
171
- retentionDays: UNLIMITED
172
- }
173
- };
174
-
175
- /** Plan code the seeded staff/comp plan uses. */
176
- export const STAFF_PLAN = 'staff';
177
-
178
- export const FALLBACK_PLAN = 'free';
179
-
180
- /**
181
- * Code defaults for a plan code, with unknown codes resolving to free.
182
- *
183
- * That fallback is deliberate: a typo'd or newly-added plan code must never
184
- * silently grant unlimited. Callers that want the admin-configured numbers
185
- * read them from the Plan document and pass them through `normalizeLimits`;
186
- * this function is the floor underneath that.
187
- */
188
- export function resolveLimits(planCode?: string): PlanLimits {
189
- const limits = DEFAULT_PLAN_LIMITS[planCode ?? FALLBACK_PLAN] ?? DEFAULT_PLAN_LIMITS[FALLBACK_PLAN];
190
- return cloneLimits(limits);
191
- }
192
-
193
- function cloneLimits(limits: PlanLimits): PlanLimits {
194
- return {
195
- meters: { ...limits.meters },
196
- features: { ...limits.features },
197
- retentionDays: limits.retentionDays
198
- };
199
- }
200
-
201
- function normalizeCeiling(value: unknown, fallback: number): number {
202
- if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
203
- // Anything negative means "unlimited"; nothing else is a meaningful ceiling.
204
- if (value < 0) return UNLIMITED;
205
- return Math.floor(value);
206
- }
207
-
208
- /**
209
- * Layers a partial set of limits over a complete one.
210
- *
211
- * Entitlements resolve through three layers, each one partial and each one
212
- * overriding the last: code defaults → the Plan document's `limits` → a
213
- * per-user `EntitlementOverride`. This is the single step all three share.
214
- *
215
- * - unknown meter/feature keys are dropped — no store can invent enforcement;
216
- * - missing keys keep the base value, so adding a new meter in code works
217
- * everywhere before any stored document mentions it;
218
- * - negative numbers normalize to `UNLIMITED`, fractions floor.
219
- */
220
- export function applyLimits(base: PlanLimits, input?: PartialPlanLimits | null): PlanLimits {
221
- const result = cloneLimits(base);
222
- if (!input) return result;
223
-
224
- for (const key of METER_KEYS) {
225
- result.meters[key] = normalizeCeiling(input.meters?.[key], result.meters[key]);
226
- }
227
- for (const key of FEATURE_KEYS) {
228
- const value = input.features?.[key];
229
- result.features[key] = typeof value === 'boolean' ? value : result.features[key];
230
- }
231
- result.retentionDays = normalizeCeiling(input.retentionDays, result.retentionDays);
232
-
233
- return result;
234
- }
235
-
236
- /**
237
- * Turns whatever is stored on a Plan document (or arrived from an admin form)
238
- * into a complete, valid `PlanLimits`, filling the gaps from that plan code's
239
- * code defaults.
240
- */
241
- export function normalizeLimits(input?: PartialPlanLimits | null, planCode?: string): PlanLimits {
242
- return applyLimits(resolveLimits(planCode), input);
243
- }
244
-
245
- /** True when `used + amount` fits under `limit`. `UNLIMITED` always fits. */
246
- export function fitsWithin(limit: number, used: number, amount = 0): boolean {
247
- if (limit === UNLIMITED || limit < 0) return true;
248
- return used + amount <= limit;
249
- }
250
-
251
- /**
252
- * Everything an admin UI needs to render a limits editor without hardcoding a
253
- * single meter name — new keys appear in the form as soon as they exist here.
254
- */
255
- export const ENTITLEMENT_CATALOG = {
256
- unlimited: UNLIMITED,
257
- meters: METER_DEFINITIONS,
258
- features: FEATURE_DEFINITIONS,
259
- retentionDays: {
260
- key: 'retentionDays' as const,
261
- label: 'History retention (days)',
262
- unit: 'days' as const,
263
- description:
264
- 'How far back reads may reach. Gates relationship history and, through the price-history range, the portfolio planner.'
265
- },
266
- defaults: DEFAULT_PLAN_LIMITS,
267
- fallbackPlan: FALLBACK_PLAN
268
- };
@@ -1,42 +0,0 @@
1
- export {
2
- UNLIMITED,
3
- FALLBACK_PLAN,
4
- STAFF_PLAN,
5
- applyLimits,
6
- METER_KEYS,
7
- FEATURE_KEYS,
8
- METER_DEFINITIONS,
9
- FEATURE_DEFINITIONS,
10
- DEFAULT_PLAN_LIMITS,
11
- ENTITLEMENT_CATALOG,
12
- resolveLimits,
13
- normalizeLimits,
14
- meterPeriod,
15
- isMeterKey,
16
- isFeatureKey,
17
- fitsWithin
18
- } from './definitions';
19
- export type {
20
- MeterKey,
21
- FeatureKey,
22
- MeterPeriod,
23
- MeterDefinition,
24
- FeatureDefinition,
25
- PlanLimits,
26
- PartialPlanLimits
27
- } from './definitions';
28
-
29
- export type { Entitlements } from './types';
30
-
31
- export { UsageMeter, currentPeriod } from './UsageMeter';
32
- export type { IUsageMeter } from './UsageMeter';
33
- export { getUsage, getUsageSnapshot, checkQuota, consumeQuota, recordUsage, releaseQuota, setUsage } from './usage';
34
- export type { QuotaRequest } from './usage';
35
-
36
- export { reconcileUsageMeters, runUsageReconciliation, RECONCILED_METERS } from './reconcile';
37
- export type { ReconcileOptions, ReconcileReport, MeterChange, OrphanedMeter } from './reconcile';
38
-
39
- export { getEntitlements, invalidateEntitlements, clearEntitlementsCache } from './client';
40
-
41
- export { loadEntitlements, requireEntitlement, requireQuota, retentionFloor } from './middleware';
42
- export type { EntitlementOptions, QuotaOptions, SubjectResolver } from './middleware';
@@ -1,196 +0,0 @@
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: 'support',
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 };
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: 'support', limits: normalizeLimits(undefined, 'support') }));
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: 'support' }
88
- })
89
- );
90
- });
91
-
92
- it('logs the denial alongside throwing it', async () => {
93
- await requireEntitlement('cleanExport')(mockReq(), {} as Response, jest.fn());
94
-
95
- expect(logger.info).toHaveBeenCalledWith('entitlement.blocked', expect.objectContaining({ feature: 'cleanExport' }));
96
- });
97
- });
98
-
99
- describe('requireQuota', () => {
100
- it('checks the meter for the amount the request costs', async () => {
101
- const next = jest.fn();
102
- const req = mockReq({ body: { sizeBytes: 2048 } } as Partial<Request>);
103
-
104
- await requireQuota('storageBytes', { amount: r => (r.body as { sizeBytes: number }).sizeBytes })(
105
- req,
106
- {} as Response,
107
- next
108
- );
109
-
110
- expect(mockCheckQuota).toHaveBeenCalledWith(
111
- expect.objectContaining({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 2048, route: '/api/photos' })
112
- );
113
- expect(next).toHaveBeenCalledWith();
114
- });
115
-
116
- it('defaults the amount to one', async () => {
117
- await requireQuota('trackedTickers')(mockReq(), {} as Response, jest.fn());
118
-
119
- expect(mockCheckQuota).toHaveBeenCalledWith(expect.objectContaining({ amount: 1 }));
120
- });
121
-
122
- it('forwards a quota rejection to the error handler', async () => {
123
- const denial = Object.assign(new Error('nope'), { statusCode: 402 });
124
- mockCheckQuota.mockRejectedValue(denial);
125
- const next = jest.fn();
126
-
127
- await requireQuota('storageBytes')(mockReq(), {} as Response, next);
128
-
129
- expect(next).toHaveBeenCalledWith(denial);
130
- });
131
-
132
- });
133
-
134
- describe('retentionFloor', () => {
135
- const now = new Date('2026-08-09T00:00:00.000Z');
136
-
137
- it('returns the cutoff date for a capped plan', async () => {
138
- const floor = await retentionFloor('user@example.com', now);
139
-
140
- expect(floor).toEqual(new Date('2026-05-11T00:00:00.000Z')); // 90 days back
141
- });
142
-
143
- it('returns null for an unlimited plan, so no $match is added', async () => {
144
- mockGetEntitlements.mockResolvedValue(
145
- entitlements({ planCode: 'support', limits: normalizeLimits({ retentionDays: UNLIMITED }, 'support') })
146
- );
147
-
148
- expect(await retentionFloor('user@example.com', now)).toBeNull();
149
- });
150
-
151
- it('still applies the floor when the limits are a stale fallback', async () => {
152
- mockGetEntitlements.mockResolvedValue(entitlements({ stale: true }));
153
-
154
- // Fail closed, same as every other gate: an outage clips history to the
155
- // free tier for a minute rather than handing the full archive out.
156
- expect(await retentionFloor('user@example.com', now)).toEqual(new Date('2026-05-11T00:00:00.000Z'));
157
- });
158
- });
159
-
160
- describe('retentionFloor across several subjects', () => {
161
- const now = new Date('2026-08-09T00:00:00.000Z');
162
-
163
- it('takes the most generous floor, so a paid group lifts it for every member', () => {
164
- mockGetEntitlements.mockImplementation(async (email: string) =>
165
- email === 'owner@example.com'
166
- ? entitlements({ planCode: 'support', limits: normalizeLimits({ retentionDays: UNLIMITED }, 'support') })
167
- : entitlements()
168
- );
169
-
170
- // A free member reading a Family group's data must not be clipped to 90
171
- // days of a household's history the household paid to keep.
172
- return expect(retentionFloor(['member@example.com', 'owner@example.com'], now)).resolves.toBeNull();
173
- });
174
-
175
- it('picks the floor that reaches furthest back when none is unlimited', async () => {
176
- mockGetEntitlements.mockImplementation(async (email: string) =>
177
- entitlements({ limits: normalizeLimits({ retentionDays: email === 'owner@example.com' ? 365 : 90 }, 'free') })
178
- );
179
-
180
- expect(await retentionFloor(['member@example.com', 'owner@example.com'], now)).toEqual(
181
- new Date('2025-08-09T00:00:00.000Z')
182
- );
183
- });
184
-
185
- it('still accepts a single email', async () => {
186
- mockGetEntitlements.mockResolvedValue(entitlements());
187
-
188
- expect(await retentionFloor('user@example.com', now)).toEqual(new Date('2026-05-11T00:00:00.000Z'));
189
- });
190
-
191
- it('falls back to the free floor when no subject can be identified', async () => {
192
- // An unidentifiable caller must never resolve to unlimited history.
193
- expect(await retentionFloor([], now)).toEqual(new Date('2026-05-11T00:00:00.000Z'));
194
- expect(mockGetEntitlements).not.toHaveBeenCalled();
195
- });
196
- });
@@ -1,150 +0,0 @@
1
- import { Request, RequestHandler } from 'express';
2
- import logger from '../logging/logger';
3
- import { PaymentRequiredError, UnauthorizedError } from '../errors/HttpError';
4
- import { FALLBACK_PLAN, FeatureKey, MeterKey, UNLIMITED, resolveLimits } from './definitions';
5
- import { getEntitlements } from './client';
6
- import { checkQuota } from './usage';
7
- import { Entitlements } from './types';
8
-
9
- declare global {
10
- namespace Express {
11
- interface Request {
12
- /** Populated by the entitlement middlewares so controllers can read limits without a second lookup. */
13
- entitlements?: Entitlements;
14
- }
15
- }
16
- }
17
-
18
- /**
19
- * Who the check is billed to. Defaults to the authenticated user; group
20
- * chokepoints override it with the group owner, since pooled quota belongs to
21
- * whoever pays for the group.
22
- */
23
- export type SubjectResolver = (req: Request) => string | undefined | Promise<string | undefined>;
24
-
25
- export interface EntitlementOptions {
26
- subject?: SubjectResolver;
27
- }
28
-
29
- const defaultSubject: SubjectResolver = req => req.user?.email;
30
-
31
- /**
32
- * Resolves and caches entitlements on the request. Two checks on one route
33
- * (a feature gate plus a quota gate) therefore cost one lookup, not two.
34
- */
35
- export async function loadEntitlements(req: Request, resolve: SubjectResolver = defaultSubject): Promise<Entitlements> {
36
- if (req.entitlements) return req.entitlements;
37
-
38
- const subject = await resolve(req);
39
- if (!subject) throw new UnauthorizedError('Authentication required');
40
-
41
- const entitlements = await getEntitlements(subject);
42
- req.entitlements = entitlements;
43
- return entitlements;
44
- }
45
-
46
- /**
47
- * Gate a feature flag. Reads are never gated by design — use this on writes,
48
- * dispatches and exports only.
49
- */
50
- export function requireEntitlement(feature: FeatureKey, options: EntitlementOptions = {}): RequestHandler {
51
- return async (req, _res, next) => {
52
- try {
53
- const entitlements = await loadEntitlements(req, options.subject);
54
- if (entitlements.limits.features[feature]) return next();
55
-
56
- logger.info('entitlement.blocked', {
57
- event: 'entitlement.blocked',
58
- feature,
59
- subjectId: entitlements.email,
60
- planCode: entitlements.planCode,
61
- route: req.originalUrl,
62
- stale: entitlements.stale === true
63
- });
64
-
65
- throw new PaymentRequiredError(`Your plan does not include ${feature}`, {
66
- code: 'FEATURE_NOT_IN_PLAN',
67
- feature,
68
- planCode: entitlements.planCode,
69
- upgradeTo: entitlements.upgradeTo
70
- });
71
- } catch (err) {
72
- next(err);
73
- }
74
- };
75
- }
76
-
77
- export interface QuotaOptions extends EntitlementOptions {
78
- /**
79
- * How much this request costs. A function when the amount is in the payload
80
- * — an upload's `sizeBytes`, say — which is also why the storage gate sits on
81
- * the signed-URL request: it is the only point where the size is known
82
- * *before* the bytes are written.
83
- */
84
- amount?: number | ((req: Request) => number);
85
- }
86
-
87
- /**
88
- * Read-only quota gate. Records nothing — the increment belongs at the point
89
- * the resource actually comes into existence, via `consumeQuota`.
90
- */
91
- export function requireQuota(meter: MeterKey, options: QuotaOptions = {}): RequestHandler {
92
- return async (req, _res, next) => {
93
- try {
94
- const entitlements = await loadEntitlements(req, options.subject);
95
- const amount = typeof options.amount === 'function' ? options.amount(req) : (options.amount ?? 1);
96
-
97
- await checkQuota({
98
- subjectId: entitlements.email,
99
- meter,
100
- amount,
101
- entitlements,
102
- route: req.originalUrl
103
- });
104
- next();
105
- } catch (err) {
106
- next(err);
107
- }
108
- };
109
- }
110
-
111
- /**
112
- * The oldest timestamp a caller may read, or `null` for unlimited history.
113
- *
114
- * Meant to be dropped straight into a `$match` or a `find` filter — this is what
115
- * gates the portfolio planner, since the planner runs entirely client-side and
116
- * any `<PaywallGate>` around it is bypassable from devtools. Truncating the
117
- * price history it depends on is not.
118
- *
119
- * **Several subjects resolve to the most generous floor.** Retention is not a
120
- * counter, so unlike a quota it has no single payer: data held in a group is
121
- * read by every member, and if it resolved to the *caller's* plan alone, a free
122
- * member of a paid Family group would see ninety days of a household's history
123
- * that the household paid to keep. Pass the caller together with the owners of
124
- * the groups being read, and the group's plan lifts the floor for everyone in
125
- * it — which is what "shared across a household" was sold as.
126
- *
127
- * An empty list resolves to the free plan's floor rather than to `null`: no
128
- * identifiable subject must never mean unlimited history.
129
- */
130
- export async function retentionFloor(
131
- subject: string | readonly string[],
132
- now: Date = new Date()
133
- ): Promise<Date | null> {
134
- const emails = (Array.isArray(subject) ? subject : [subject as string]).filter(Boolean);
135
- const floorFor = (days: number): Date | null =>
136
- days === UNLIMITED || days < 0 ? null : new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
137
-
138
- if (emails.length === 0) {
139
- return floorFor(resolveLimits(FALLBACK_PLAN).retentionDays);
140
- }
141
-
142
- const floors = await Promise.all(
143
- emails.map(async email => floorFor((await getEntitlements(email)).limits.retentionDays))
144
- );
145
-
146
- // `null` is unlimited, which beats every date; otherwise the earliest floor
147
- // is the one that reaches furthest back.
148
- if (floors.some(floor => floor === null)) return null;
149
- return (floors as Date[]).reduce((a, b) => (a < b ? a : b));
150
- }