@tumbaland/backend-core 1.44.0 → 1.46.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 (123) hide show
  1. package/dist/fileService/client.d.ts +54 -0
  2. package/dist/fileService/client.d.ts.map +1 -0
  3. package/dist/fileService/client.js +137 -0
  4. package/dist/fileService/client.js.map +1 -0
  5. package/dist/fileService/index.d.ts +4 -0
  6. package/dist/fileService/index.d.ts.map +1 -0
  7. package/dist/fileService/index.js +40 -0
  8. package/dist/fileService/index.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +6 -2
  12. package/dist/index.js.map +1 -1
  13. package/dist/middleware/corsMiddleware.d.ts.map +1 -1
  14. package/dist/middleware/corsMiddleware.js +4 -1
  15. package/dist/middleware/corsMiddleware.js.map +1 -1
  16. package/dist/middleware/errorHandler.d.ts.map +1 -1
  17. package/dist/middleware/errorHandler.js +5 -8
  18. package/dist/middleware/errorHandler.js.map +1 -1
  19. package/dist/utils/escapeRegex.d.ts +8 -0
  20. package/dist/utils/escapeRegex.d.ts.map +1 -0
  21. package/dist/utils/escapeRegex.js +12 -0
  22. package/dist/utils/escapeRegex.js.map +1 -0
  23. package/package.json +5 -1
  24. package/.versionrc.json +0 -7
  25. package/__mocks__/uuid.js +0 -8
  26. package/jest.config.js +0 -24
  27. package/src/apiKeys/ApiKey.test.ts +0 -142
  28. package/src/apiKeys/ApiKey.ts +0 -102
  29. package/src/apiKeys/crypto.test.ts +0 -161
  30. package/src/apiKeys/crypto.ts +0 -163
  31. package/src/apiKeys/index.test.ts +0 -72
  32. package/src/apiKeys/index.ts +0 -35
  33. package/src/apiKeys/middleware.test.ts +0 -651
  34. package/src/apiKeys/middleware.ts +0 -341
  35. package/src/apiKeys/service.test.ts +0 -401
  36. package/src/apiKeys/service.ts +0 -168
  37. package/src/apiKeys/types.test.ts +0 -41
  38. package/src/apiKeys/types.ts +0 -124
  39. package/src/app/createBaseApp.test.ts +0 -109
  40. package/src/app/createBaseApp.ts +0 -102
  41. package/src/app/shutdown.test.ts +0 -129
  42. package/src/app/shutdown.ts +0 -81
  43. package/src/audit/AuditEvent.ts +0 -123
  44. package/src/audit/actor.test.ts +0 -95
  45. package/src/audit/actor.ts +0 -68
  46. package/src/audit/context.test.ts +0 -91
  47. package/src/audit/context.ts +0 -83
  48. package/src/audit/index.ts +0 -11
  49. package/src/audit/plugin.test.ts +0 -258
  50. package/src/audit/plugin.ts +0 -254
  51. package/src/audit/reads.test.ts +0 -164
  52. package/src/audit/reads.ts +0 -88
  53. package/src/audit/service.test.ts +0 -115
  54. package/src/audit/service.ts +0 -95
  55. package/src/auth/session.test.ts +0 -89
  56. package/src/auth/session.ts +0 -75
  57. package/src/config/env.test.ts +0 -28
  58. package/src/config/env.ts +0 -13
  59. package/src/database/connection.test.ts +0 -188
  60. package/src/database/connection.ts +0 -90
  61. package/src/entitlements/UsageMeter.ts +0 -49
  62. package/src/entitlements/client.test.ts +0 -200
  63. package/src/entitlements/client.ts +0 -179
  64. package/src/entitlements/definitions.test.ts +0 -161
  65. package/src/entitlements/definitions.ts +0 -268
  66. package/src/entitlements/index.ts +0 -42
  67. package/src/entitlements/middleware.test.ts +0 -196
  68. package/src/entitlements/middleware.ts +0 -150
  69. package/src/entitlements/reconcile.test.ts +0 -333
  70. package/src/entitlements/reconcile.ts +0 -384
  71. package/src/entitlements/types.ts +0 -21
  72. package/src/entitlements/usage.test.ts +0 -314
  73. package/src/entitlements/usage.ts +0 -223
  74. package/src/errors/HttpError.test.ts +0 -76
  75. package/src/errors/HttpError.ts +0 -91
  76. package/src/groups/client.test.ts +0 -215
  77. package/src/groups/client.ts +0 -182
  78. package/src/groups/index.ts +0 -7
  79. package/src/groups/membership.test.ts +0 -84
  80. package/src/groups/membership.ts +0 -133
  81. package/src/groups/subject.test.ts +0 -85
  82. package/src/groups/subject.ts +0 -50
  83. package/src/health/createHealthCheck.test.ts +0 -89
  84. package/src/health/createHealthCheck.ts +0 -67
  85. package/src/health/healthController.test.ts +0 -113
  86. package/src/health/healthController.ts +0 -56
  87. package/src/index.ts +0 -88
  88. package/src/logging/logger.test.ts +0 -91
  89. package/src/logging/logger.ts +0 -103
  90. package/src/metrics/index.test.ts +0 -116
  91. package/src/metrics/index.ts +0 -111
  92. package/src/middleware/authMiddleware.test.ts +0 -275
  93. package/src/middleware/authMiddleware.ts +0 -91
  94. package/src/middleware/corsMiddleware.test.ts +0 -135
  95. package/src/middleware/corsMiddleware.ts +0 -65
  96. package/src/middleware/errorHandler.test.ts +0 -188
  97. package/src/middleware/errorHandler.ts +0 -103
  98. package/src/middleware/internalServiceAuth.test.ts +0 -173
  99. package/src/middleware/internalServiceAuth.ts +0 -96
  100. package/src/middleware/requestLogger.test.ts +0 -81
  101. package/src/middleware/requestLogger.ts +0 -48
  102. package/src/middleware/security.test.ts +0 -45
  103. package/src/middleware/security.ts +0 -43
  104. package/src/middleware/validate.test.ts +0 -72
  105. package/src/middleware/validate.ts +0 -23
  106. package/src/oauth/index.ts +0 -29
  107. package/src/oauth/models.ts +0 -164
  108. package/src/oauth/service.test.ts +0 -432
  109. package/src/oauth/service.ts +0 -299
  110. package/src/oauth/tokens.test.ts +0 -146
  111. package/src/oauth/tokens.ts +0 -186
  112. package/src/testing/serviceTestSetup.ts +0 -17
  113. package/src/tracing/index.test.ts +0 -272
  114. package/src/tracing/index.ts +0 -110
  115. package/src/types/auth.ts +0 -45
  116. package/src/utils/correlation.test.ts +0 -47
  117. package/src/utils/correlation.ts +0 -22
  118. package/src/utils/permissionUtils.test.ts +0 -47
  119. package/src/utils/permissionUtils.ts +0 -68
  120. package/src/utils/response.test.ts +0 -64
  121. package/src/utils/response.ts +0 -60
  122. package/tsconfig.build.json +0 -7
  123. package/tsconfig.json +0 -23
@@ -1,314 +0,0 @@
1
- /**
2
- * Integration tests against a real MongoDB, not mocks.
3
- *
4
- * `tickerPriceHistory.service.test.ts` reports 100% line coverage while mocking
5
- * Mongoose entirely, and a real upsert bug survived it. Quota code has the same
6
- * failure mode with worse consequences: a mocked `$inc` always looks correct,
7
- * and a broken one either gives the product away or bills people for capacity
8
- * they cannot use. The concurrency tests below are meaningless without a real
9
- * server — they are the whole reason the atomic reserve is written the way it is.
10
- */
11
- import mongoose from 'mongoose';
12
- import { MongoMemoryServer } from 'mongodb-memory-server';
13
-
14
- jest.mock('../logging/logger', () => ({
15
- __esModule: true,
16
- default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
17
- }));
18
-
19
- import logger from '../logging/logger';
20
- import { UsageMeter, currentPeriod } from './UsageMeter';
21
- import { checkQuota, consumeQuota, getUsage, getUsageSnapshot, recordUsage, releaseQuota, setUsage } from './usage';
22
- import { Entitlements } from './types';
23
- import { UNLIMITED, normalizeLimits } from './definitions';
24
-
25
- jest.setTimeout(120_000);
26
-
27
- let mongod: MongoMemoryServer;
28
-
29
- const ORIGINAL_ENV = process.env;
30
-
31
- function entitlements(overrides: Partial<Entitlements> = {}): Entitlements {
32
- return {
33
- email: 'user@example.com',
34
- planCode: 'free',
35
- status: null,
36
- limits: normalizeLimits({ meters: { aiJobs: 5, storageBytes: 1000, trackedTickers: 3, seats: 1 } }, 'free'),
37
- upgradeTo: 'personal',
38
- ...overrides
39
- };
40
- }
41
-
42
- beforeAll(async () => {
43
- mongod = await MongoMemoryServer.create();
44
- await mongoose.connect(mongod.getUri());
45
- });
46
-
47
- afterAll(async () => {
48
- await mongoose.disconnect();
49
- await mongod.stop();
50
- });
51
-
52
- beforeEach(async () => {
53
- process.env = { ...ORIGINAL_ENV };
54
- await UsageMeter.deleteMany({});
55
- // The unique index is what makes the concurrent upserts below collapse onto
56
- // one document instead of racing into duplicates.
57
- await UsageMeter.syncIndexes();
58
- (logger.info as jest.Mock).mockClear();
59
- });
60
-
61
- afterAll(() => {
62
- process.env = ORIGINAL_ENV;
63
- });
64
-
65
- describe('consumeQuota', () => {
66
- it('records usage and allows a request that lands exactly on the limit', async () => {
67
- await consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', amount: 5, entitlements: entitlements() });
68
-
69
- expect(await getUsage('user@example.com', 'aiJobs')).toBe(5);
70
- });
71
-
72
- it('rejects with a 402 carrying everything the upgrade dialog renders', async () => {
73
- await setUsage('user@example.com', 'aiJobs', 5);
74
-
75
- await expect(
76
- consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', amount: 1, entitlements: entitlements() })
77
- ).rejects.toMatchObject({
78
- statusCode: 402,
79
- details: { code: 'QUOTA_EXCEEDED', meter: 'aiJobs', used: 5, limit: 5, planCode: 'free', upgradeTo: 'personal' }
80
- });
81
- });
82
-
83
- it('rolls its own increment back when it rejects, so a refusal costs the user nothing', async () => {
84
- await setUsage('user@example.com', 'aiJobs', 5);
85
-
86
- await expect(
87
- consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', entitlements: entitlements() })
88
- ).rejects.toMatchObject({ statusCode: 402 });
89
-
90
- expect(await getUsage('user@example.com', 'aiJobs')).toBe(5);
91
- });
92
-
93
- it('admits exactly the limit under concurrency and rejects the rest', async () => {
94
- const results = await Promise.allSettled(
95
- Array.from({ length: 12 }, () =>
96
- consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', entitlements: entitlements() })
97
- )
98
- );
99
-
100
- const admitted = results.filter(r => r.status === 'fulfilled');
101
- expect(admitted).toHaveLength(5);
102
- // The rejected requests all rolled back, so the counter reflects the
103
- // admitted ones only — no permanent drift from the losers of the race.
104
- expect(await getUsage('user@example.com', 'aiJobs')).toBe(5);
105
- });
106
-
107
- it('never blocks an unlimited meter', async () => {
108
- const unlimited = entitlements({
109
- planCode: 'family',
110
- limits: normalizeLimits({ meters: { trackedTickers: UNLIMITED } }, 'family')
111
- });
112
-
113
- await consumeQuota({ subjectId: 'user@example.com', meter: 'trackedTickers', amount: 10_000, entitlements: unlimited });
114
-
115
- expect(await getUsage('user@example.com', 'trackedTickers')).toBe(10_000);
116
- });
117
-
118
- it('counts monthly meters into the calendar month, so usage rolls over', async () => {
119
- await consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', amount: 5, entitlements: entitlements() });
120
-
121
- // A document written under a previous month's bucket must not count.
122
- const previousMonth = await UsageMeter.findOne({ meter: 'aiJobs' });
123
- expect(previousMonth?.period).toBe(currentPeriod('aiJobs'));
124
-
125
- await UsageMeter.updateOne({ meter: 'aiJobs' }, { $set: { period: '2001-01' } });
126
- expect(await getUsage('user@example.com', 'aiJobs')).toBe(0);
127
- await expect(
128
- consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', entitlements: entitlements() })
129
- ).resolves.toBeUndefined();
130
- });
131
-
132
- it('bills the same subject regardless of email casing', async () => {
133
- await consumeQuota({ subjectId: 'User@Example.com', meter: 'seats', entitlements: entitlements() });
134
-
135
- expect(await getUsage('user@example.com', 'seats')).toBe(1);
136
- });
137
- });
138
-
139
- describe('rejection logging', () => {
140
- it('logs the block alongside throwing, so an operator sees what a user saw', async () => {
141
- await setUsage('user@example.com', 'aiJobs', 5);
142
-
143
- await expect(
144
- consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', entitlements: entitlements(), route: '/ai/dispatch' })
145
- ).rejects.toMatchObject({ statusCode: 402 });
146
-
147
- expect(logger.info).toHaveBeenCalledWith(
148
- 'entitlement.blocked',
149
- expect.objectContaining({ meter: 'aiJobs', used: 5, limit: 5, route: '/ai/dispatch' })
150
- );
151
- });
152
-
153
- it('flags a block that came from stale fallback limits rather than the real plan', async () => {
154
- await setUsage('user@example.com', 'aiJobs', 5);
155
-
156
- await expect(
157
- consumeQuota({
158
- subjectId: 'user@example.com',
159
- meter: 'aiJobs',
160
- entitlements: entitlements({ stale: true })
161
- })
162
- ).rejects.toMatchObject({ statusCode: 402 });
163
-
164
- // A run of these means payment-service is down and paying users are being
165
- // held to free limits — a different incident from a limit set too tight.
166
- expect(logger.info).toHaveBeenCalledWith('entitlement.blocked', expect.objectContaining({ stale: true }));
167
- });
168
- });
169
-
170
- describe('checkQuota', () => {
171
- it('passes when the requested amount still fits and writes nothing', async () => {
172
- await setUsage('user@example.com', 'storageBytes', 400);
173
-
174
- await expect(
175
- checkQuota({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 600, entitlements: entitlements() })
176
- ).resolves.toBeUndefined();
177
- expect(await getUsage('user@example.com', 'storageBytes')).toBe(400);
178
- });
179
-
180
- it('rejects, and writes nothing, when the requested amount would overrun', async () => {
181
- await setUsage('user@example.com', 'storageBytes', 400);
182
-
183
- await expect(
184
- checkQuota({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 601, entitlements: entitlements() })
185
- ).rejects.toMatchObject({ statusCode: 402 });
186
-
187
- // Read-only by contract: the increment belongs to the point the resource
188
- // actually comes into existence, not to the gate in front of it.
189
- expect(await getUsage('user@example.com', 'storageBytes')).toBe(400);
190
- expect(logger.info).toHaveBeenCalledWith(
191
- 'entitlement.blocked',
192
- expect.objectContaining({ meter: 'storageBytes', used: 400, limit: 1000 })
193
- );
194
- });
195
-
196
- it('skips the usage read entirely for an unlimited meter', async () => {
197
- const unlimited = entitlements({ limits: normalizeLimits({ meters: { storageBytes: UNLIMITED } }, 'free') });
198
-
199
- await expect(
200
- checkQuota({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 10 ** 12, entitlements: unlimited })
201
- ).resolves.toBeUndefined();
202
- });
203
-
204
- it('rejects when the amount would overrun, reporting current usage', async () => {
205
- await setUsage('user@example.com', 'storageBytes', 400);
206
-
207
- await expect(
208
- checkQuota({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 601, entitlements: entitlements() })
209
- ).rejects.toMatchObject({ statusCode: 402, details: { meter: 'storageBytes', used: 400, limit: 1000 } });
210
- });
211
- });
212
-
213
- describe('releaseQuota', () => {
214
- it('hands capacity back after a failure', async () => {
215
- await consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', amount: 3, entitlements: entitlements() });
216
-
217
- await releaseQuota('user@example.com', 'aiJobs', 3);
218
-
219
- expect(await getUsage('user@example.com', 'aiJobs')).toBe(0);
220
- });
221
-
222
- it('clamps at zero rather than going negative', async () => {
223
- await setUsage('user@example.com', 'storageBytes', 100);
224
-
225
- await releaseQuota('user@example.com', 'storageBytes', 500);
226
-
227
- expect(await getUsage('user@example.com', 'storageBytes')).toBe(0);
228
- });
229
-
230
- it('does not lose concurrent releases', async () => {
231
- await setUsage('user@example.com', 'storageBytes', 100);
232
-
233
- await Promise.all(Array.from({ length: 10 }, () => releaseQuota('user@example.com', 'storageBytes', 10)));
234
-
235
- expect(await getUsage('user@example.com', 'storageBytes')).toBe(0);
236
- });
237
-
238
- it('is a no-op when no counter exists', async () => {
239
- await expect(releaseQuota('nobody@example.com', 'seats', 1)).resolves.toBeUndefined();
240
- expect(await getUsage('nobody@example.com', 'seats')).toBe(0);
241
- });
242
- });
243
-
244
- describe('recordUsage', () => {
245
- it('increments a meter that does not exist yet', async () => {
246
- await recordUsage('user@example.com', 'storageBytes', 4_096);
247
-
248
- expect(await getUsage('user@example.com', 'storageBytes')).toBe(4_096);
249
- });
250
-
251
- it('records past the limit rather than refusing bytes already on disk', async () => {
252
- // The gate ran at the signed-URL request; these bytes are in MinIO whatever
253
- // the counter says. Rolling the increment back — what consumeQuota would do
254
- // — would understate real storage and let the next upload through too.
255
- await setUsage('user@example.com', 'storageBytes', 900);
256
-
257
- await recordUsage('user@example.com', 'storageBytes', 500);
258
-
259
- expect(await getUsage('user@example.com', 'storageBytes')).toBe(1_400);
260
- // And the next request is the one that is refused.
261
- await expect(
262
- checkQuota({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 1, entitlements: entitlements() })
263
- ).rejects.toMatchObject({ statusCode: 402 });
264
- });
265
-
266
- it('does not lose concurrent increments', async () => {
267
- await Promise.all(Array.from({ length: 10 }, () => recordUsage('user@example.com', 'storageBytes', 10)));
268
-
269
- expect(await getUsage('user@example.com', 'storageBytes')).toBe(100);
270
- });
271
-
272
- it('ignores a non-positive amount instead of writing a no-op row', async () => {
273
- await recordUsage('user@example.com', 'storageBytes', 0);
274
- await recordUsage('user@example.com', 'storageBytes', -50);
275
-
276
- expect(await UsageMeter.countDocuments({ subjectId: 'user@example.com' })).toBe(0);
277
- });
278
-
279
- it('never throws — the resource exists and the caller is on its success path', async () => {
280
- const failing = jest
281
- .spyOn(UsageMeter, 'updateOne')
282
- .mockRejectedValueOnce(new Error('connection lost') as never);
283
-
284
- await expect(recordUsage('user@example.com', 'storageBytes', 10)).resolves.toBeUndefined();
285
- expect(logger.warn).toHaveBeenCalledWith('Failed to record usage', expect.objectContaining({ meter: 'storageBytes' }));
286
-
287
- failing.mockRestore();
288
- });
289
- });
290
-
291
- describe('getUsageSnapshot', () => {
292
- it('reports every meter, defaulting the untouched ones to zero', async () => {
293
- await setUsage('user@example.com', 'storageBytes', 42);
294
- await setUsage('user@example.com', 'aiJobs', 7);
295
- await setUsage('other@example.com', 'seats', 99);
296
-
297
- expect(await getUsageSnapshot('user@example.com')).toEqual({
298
- storageBytes: 42,
299
- aiJobs: 7,
300
- trackedTickers: 0,
301
- seats: 0
302
- });
303
- });
304
- });
305
-
306
- describe('setUsage', () => {
307
- it('overwrites for backfill and reconciliation, refusing negatives', async () => {
308
- await setUsage('user@example.com', 'storageBytes', 5_000);
309
- expect(await getUsage('user@example.com', 'storageBytes')).toBe(5_000);
310
-
311
- await setUsage('user@example.com', 'storageBytes', -20);
312
- expect(await getUsage('user@example.com', 'storageBytes')).toBe(0);
313
- });
314
- });
@@ -1,223 +0,0 @@
1
- import logger from '../logging/logger';
2
- import { PaymentRequiredError } from '../errors/HttpError';
3
- import { UsageMeter, currentPeriod } from './UsageMeter';
4
- import { MeterKey, METER_KEYS, UNLIMITED, fitsWithin } from './definitions';
5
- import { Entitlements } from './types';
6
-
7
- export interface QuotaRequest {
8
- /** Who the usage is billed to — see `IUsageMeter.subjectId`. */
9
- subjectId: string;
10
- meter: MeterKey;
11
- /** How much this request wants. Bytes for `storageBytes`, otherwise a count. */
12
- amount?: number;
13
- entitlements: Entitlements;
14
- /** Attached to the rejection log so a 402 can be traced back to a route. */
15
- route?: string;
16
- }
17
-
18
- /** Current count for one meter in its live period. */
19
- export async function getUsage(subjectId: string, meter: MeterKey, now?: Date): Promise<number> {
20
- const doc = await UsageMeter.findOne({
21
- subjectId: subjectId.toLowerCase(),
22
- meter,
23
- period: currentPeriod(meter, now)
24
- }).lean();
25
- return doc?.count ?? 0;
26
- }
27
-
28
- /** Every meter at once, for the account page and the pricing table's live numbers. */
29
- export async function getUsageSnapshot(subjectId: string, now?: Date): Promise<Record<MeterKey, number>> {
30
- const periods = METER_KEYS.map(meter => ({ meter, period: currentPeriod(meter, now) }));
31
- const docs = await UsageMeter.find({
32
- subjectId: subjectId.toLowerCase(),
33
- $or: periods.map(p => ({ meter: p.meter, period: p.period }))
34
- }).lean();
35
-
36
- const snapshot = Object.fromEntries(METER_KEYS.map(k => [k, 0])) as Record<MeterKey, number>;
37
- for (const doc of docs) {
38
- if ((METER_KEYS as readonly string[]).includes(doc.meter)) snapshot[doc.meter as MeterKey] = doc.count;
39
- }
40
- return snapshot;
41
- }
42
-
43
- function quotaError(req: QuotaRequest, used: number, limit: number): PaymentRequiredError {
44
- return new PaymentRequiredError(`Limit reached for ${req.meter}`, {
45
- code: 'QUOTA_EXCEEDED',
46
- meter: req.meter,
47
- used,
48
- limit,
49
- planCode: req.entitlements.planCode,
50
- upgradeTo: req.entitlements.upgradeTo
51
- });
52
- }
53
-
54
- /**
55
- * Every rejection is logged, not just thrown. The 402 reaches the user, but
56
- * only this line reaches the operator — and the two questions worth asking of a
57
- * paywall (which meter fires most, and how often it fires on a *stale* fallback
58
- * rather than on real limits) are answerable from nothing else.
59
- */
60
- function logBlocked(req: QuotaRequest, used: number, limit: number): void {
61
- logger.info('entitlement.blocked', {
62
- event: 'entitlement.blocked',
63
- meter: req.meter,
64
- subjectId: req.subjectId,
65
- amount: req.amount ?? 1,
66
- used,
67
- limit,
68
- planCode: req.entitlements.planCode,
69
- route: req.route,
70
- stale: req.entitlements.stale === true
71
- });
72
- }
73
-
74
- /**
75
- * Read-only check for cumulative meters whose real increment happens later
76
- * (storage is counted when the upload lands, not when it is authorized).
77
- *
78
- * Racy by construction — two concurrent requests can both pass — which is
79
- * acceptable here precisely because the meters it guards are reconciled from
80
- * the underlying data. Use `consumeQuota` where the count is the only record.
81
- */
82
- export async function checkQuota(req: QuotaRequest): Promise<void> {
83
- const limit = req.entitlements.limits.meters[req.meter] ?? UNLIMITED;
84
- if (limit === UNLIMITED) return;
85
-
86
- const used = await getUsage(req.subjectId, req.meter);
87
- if (fitsWithin(limit, used, req.amount ?? 1)) return;
88
-
89
- logBlocked(req, used, limit);
90
- throw quotaError(req, used, limit);
91
- }
92
-
93
- /**
94
- * Atomically reserve capacity, throwing when the reservation would overrun.
95
- *
96
- * The increment and the check are one `$inc` + read, not a read-then-write:
97
- * two concurrent requests that both read "24 used" against a limit of 25 would
98
- * both proceed and overrun. Incrementing first and rejecting on the
99
- * *post-increment* value means the loser of that race is the one rejected.
100
- *
101
- * A rejected reservation rolls its own increment back before throwing — unlike
102
- * the provider-quota version this generalizes, where an over-limit count just
103
- * sits there until the day rolls over. A per-user cumulative meter never rolls
104
- * over, so a leaked increment is permanent and shows up months later as a user
105
- * who cannot upload despite being well under their limit.
106
- */
107
- export async function consumeQuota(req: QuotaRequest): Promise<void> {
108
- const amount = req.amount ?? 1;
109
- const limit = req.entitlements.limits.meters[req.meter] ?? UNLIMITED;
110
- const period = currentPeriod(req.meter);
111
- const subjectId = req.subjectId.toLowerCase();
112
-
113
- const usage = await UsageMeter.findOneAndUpdate(
114
- { subjectId, meter: req.meter, period },
115
- { $inc: { count: amount } },
116
- { upsert: true, returnDocument: 'after', setDefaultsOnInsert: true }
117
- );
118
-
119
- if (limit === UNLIMITED || usage.count <= limit) return;
120
-
121
- const usedBefore = usage.count - amount;
122
-
123
- logBlocked(req, usedBefore, limit);
124
- await releaseQuota(subjectId, req.meter, amount);
125
- throw quotaError(req, usedBefore, limit);
126
- }
127
-
128
- /**
129
- * Hand back capacity after a failure that never consumed it (the upload errored,
130
- * the AI service refused the job), and on deletes — a cumulative meter that only
131
- * counts up is a bug that surfaces as an angry support email six months in.
132
- *
133
- * Never drops below zero, and never throws: a failed release must not turn a
134
- * recoverable error into a 500 for the caller that was already unwinding.
135
- */
136
- export async function releaseQuota(
137
- subjectId: string,
138
- meter: MeterKey,
139
- amount = 1,
140
- now?: Date
141
- ): Promise<void> {
142
- try {
143
- const filter = { subjectId: subjectId.toLowerCase(), meter, period: currentPeriod(meter, now) };
144
-
145
- // Conditional `$inc` rather than read-then-write: a read-modify-write here
146
- // loses concurrent releases, which on a cumulative meter is permanent drift.
147
- const result = await UsageMeter.updateOne({ ...filter, count: { $gte: amount } }, { $inc: { count: -amount } });
148
- if (result.matchedCount === 0) {
149
- // Either no counter exists, or it holds less than we are handing back
150
- // (a reconciliation reset it in between). Clamp at zero, never negative.
151
- await UsageMeter.updateOne({ ...filter, count: { $lt: amount } }, { $set: { count: 0 } });
152
- }
153
- } catch (err) {
154
- logger.warn('Failed to release usage quota', {
155
- subjectId,
156
- meter,
157
- amount,
158
- error: (err as Error)?.message
159
- });
160
- }
161
- }
162
-
163
- /**
164
- * Set a meter to a known value. For the backfill (existing storage has to be
165
- * seeded or every current user starts at zero and silently exceeds later) and
166
- * for periodic reconciliation against the source of truth.
167
- */
168
- export async function setUsage(
169
- subjectId: string,
170
- meter: MeterKey,
171
- count: number,
172
- now?: Date
173
- ): Promise<void> {
174
- await UsageMeter.updateOne(
175
- { subjectId: subjectId.toLowerCase(), meter, period: currentPeriod(meter, now) },
176
- { $set: { count: Math.max(0, Math.floor(count)) } },
177
- { upsert: true, setDefaultsOnInsert: true }
178
- );
179
- }
180
-
181
- /**
182
- * Record usage that has already happened, without a limit check.
183
- *
184
- * The storage gate runs on the *signed-URL request* — the only point where a
185
- * file's size is known before its bytes are written — so by the time the photo
186
- * record is created the object is already in MinIO. Neither of the existing
187
- * primitives fits that moment: `checkQuota` records nothing, and `consumeQuota`
188
- * would reject and roll back, leaving the meter understating bytes that are
189
- * genuinely stored while the user is refused a record for storage they have
190
- * already paid for.
191
- *
192
- * So this increments past the limit deliberately. A cumulative meter can only
193
- * be pushed over by a race or a stale entitlement, both bounded by one upload,
194
- * and the gate then refuses the *next* one — which is the correct behaviour:
195
- * the ceiling stops new writes, it does not un-store bytes.
196
- *
197
- * Never throws, for the same reason `releaseQuota` does not: the resource
198
- * exists, the caller is on its success path, and a failed counter update is
199
- * repaired by the nightly reconciliation this meter is derived for.
200
- */
201
- export async function recordUsage(
202
- subjectId: string,
203
- meter: MeterKey,
204
- amount = 1,
205
- now?: Date
206
- ): Promise<void> {
207
- if (amount <= 0) return;
208
-
209
- try {
210
- await UsageMeter.updateOne(
211
- { subjectId: subjectId.toLowerCase(), meter, period: currentPeriod(meter, now) },
212
- { $inc: { count: amount } },
213
- { upsert: true, setDefaultsOnInsert: true }
214
- );
215
- } catch (err) {
216
- logger.warn('Failed to record usage', {
217
- subjectId,
218
- meter,
219
- amount,
220
- error: (err as Error)?.message
221
- });
222
- }
223
- }
@@ -1,76 +0,0 @@
1
- import { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError, PaymentRequiredError } from './HttpError';
2
-
3
- describe('HttpError', () => {
4
- it('carries the given status code and message', () => {
5
- const error = new HttpError(418, "I'm a teapot");
6
- expect(error.statusCode).toBe(418);
7
- expect(error.message).toBe("I'm a teapot");
8
- expect(error).toBeInstanceOf(Error);
9
- });
10
-
11
- it('sets name to the concrete subclass name', () => {
12
- expect(new HttpError(400, 'x').name).toBe('HttpError');
13
- });
14
-
15
- it('carries optional details for the error handler to serialize', () => {
16
- expect(new HttpError(400, 'x').details).toBeUndefined();
17
- expect(new HttpError(400, 'x', { field: 'title' }).details).toEqual({ field: 'title' });
18
- });
19
- });
20
-
21
- describe('PaymentRequiredError', () => {
22
- it('is a 402 — "you may, for money" — and never a 403', () => {
23
- const error = new PaymentRequiredError('Storage limit reached', {
24
- code: 'QUOTA_EXCEEDED',
25
- meter: 'storageBytes',
26
- used: 10,
27
- limit: 10,
28
- planCode: 'free',
29
- upgradeTo: 'support'
30
- });
31
-
32
- expect(error.statusCode).toBe(402);
33
- expect(error).toBeInstanceOf(HttpError);
34
- expect(error.details).toEqual({
35
- code: 'QUOTA_EXCEEDED',
36
- meter: 'storageBytes',
37
- used: 10,
38
- limit: 10,
39
- planCode: 'free',
40
- upgradeTo: 'support'
41
- });
42
- });
43
-
44
- it('copies the details, so a caller reusing its object cannot mutate the error', () => {
45
- const details = { code: 'QUOTA_EXCEEDED', meter: 'seats' };
46
- const error = new PaymentRequiredError('Seat limit reached', details);
47
-
48
- details.meter = 'aiJobs';
49
-
50
- expect(error.details?.meter).toBe('seats');
51
- });
52
- });
53
-
54
- describe.each([
55
- [BadRequestError, 400, 'Bad request'],
56
- [UnauthorizedError, 401, 'Unauthorized'],
57
- [ForbiddenError, 403, 'Access denied'],
58
- [NotFoundError, 404, 'Not found'],
59
- [ConflictError, 409, 'Conflict'],
60
- [TooManyRequestsError, 429, 'Too many requests']
61
- ] as const)('%p', (ErrorClass, expectedStatus, defaultMessage) => {
62
- it(`defaults to status ${expectedStatus} and a sensible message`, () => {
63
- const error = new ErrorClass();
64
- expect(error.statusCode).toBe(expectedStatus);
65
- expect(error.message).toBe(defaultMessage);
66
- expect(error).toBeInstanceOf(HttpError);
67
- expect(error).toBeInstanceOf(Error);
68
- expect(error.name).toBe(ErrorClass.name);
69
- });
70
-
71
- it('accepts a custom message', () => {
72
- const error = new ErrorClass('custom message');
73
- expect(error.statusCode).toBe(expectedStatus);
74
- expect(error.message).toBe('custom message');
75
- });
76
- });