@tumbaland/backend-core 1.44.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,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
- });