@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.
- package/dist/fileService/client.d.ts +54 -0
- package/dist/fileService/client.d.ts.map +1 -0
- package/dist/fileService/client.js +137 -0
- package/dist/fileService/client.js.map +1 -0
- package/dist/fileService/index.d.ts +4 -0
- package/dist/fileService/index.d.ts.map +1 -0
- package/dist/fileService/index.js +40 -0
- package/dist/fileService/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/corsMiddleware.d.ts.map +1 -1
- package/dist/middleware/corsMiddleware.js +4 -1
- package/dist/middleware/corsMiddleware.js.map +1 -1
- package/dist/middleware/errorHandler.d.ts.map +1 -1
- package/dist/middleware/errorHandler.js +5 -8
- package/dist/middleware/errorHandler.js.map +1 -1
- package/dist/utils/escapeRegex.d.ts +8 -0
- package/dist/utils/escapeRegex.d.ts.map +1 -0
- package/dist/utils/escapeRegex.js +12 -0
- package/dist/utils/escapeRegex.js.map +1 -0
- package/package.json +5 -1
- package/.versionrc.json +0 -7
- package/__mocks__/uuid.js +0 -8
- package/jest.config.js +0 -24
- package/src/apiKeys/ApiKey.test.ts +0 -142
- package/src/apiKeys/ApiKey.ts +0 -102
- package/src/apiKeys/crypto.test.ts +0 -161
- package/src/apiKeys/crypto.ts +0 -163
- package/src/apiKeys/index.test.ts +0 -72
- package/src/apiKeys/index.ts +0 -35
- package/src/apiKeys/middleware.test.ts +0 -651
- package/src/apiKeys/middleware.ts +0 -341
- package/src/apiKeys/service.test.ts +0 -401
- package/src/apiKeys/service.ts +0 -168
- package/src/apiKeys/types.test.ts +0 -41
- package/src/apiKeys/types.ts +0 -124
- package/src/app/createBaseApp.test.ts +0 -109
- package/src/app/createBaseApp.ts +0 -102
- package/src/app/shutdown.test.ts +0 -129
- package/src/app/shutdown.ts +0 -81
- package/src/audit/AuditEvent.ts +0 -123
- package/src/audit/actor.test.ts +0 -95
- package/src/audit/actor.ts +0 -68
- package/src/audit/context.test.ts +0 -91
- package/src/audit/context.ts +0 -83
- package/src/audit/index.ts +0 -11
- package/src/audit/plugin.test.ts +0 -258
- package/src/audit/plugin.ts +0 -254
- package/src/audit/reads.test.ts +0 -164
- package/src/audit/reads.ts +0 -88
- package/src/audit/service.test.ts +0 -115
- package/src/audit/service.ts +0 -95
- package/src/auth/session.test.ts +0 -89
- package/src/auth/session.ts +0 -75
- package/src/config/env.test.ts +0 -28
- package/src/config/env.ts +0 -13
- package/src/database/connection.test.ts +0 -188
- package/src/database/connection.ts +0 -90
- package/src/entitlements/UsageMeter.ts +0 -49
- package/src/entitlements/client.test.ts +0 -200
- package/src/entitlements/client.ts +0 -179
- package/src/entitlements/definitions.test.ts +0 -161
- package/src/entitlements/definitions.ts +0 -268
- package/src/entitlements/index.ts +0 -42
- package/src/entitlements/middleware.test.ts +0 -196
- package/src/entitlements/middleware.ts +0 -150
- package/src/entitlements/reconcile.test.ts +0 -333
- package/src/entitlements/reconcile.ts +0 -384
- package/src/entitlements/types.ts +0 -21
- package/src/entitlements/usage.test.ts +0 -314
- package/src/entitlements/usage.ts +0 -223
- package/src/errors/HttpError.test.ts +0 -76
- package/src/errors/HttpError.ts +0 -91
- package/src/groups/client.test.ts +0 -215
- package/src/groups/client.ts +0 -182
- package/src/groups/index.ts +0 -7
- package/src/groups/membership.test.ts +0 -84
- package/src/groups/membership.ts +0 -133
- package/src/groups/subject.test.ts +0 -85
- package/src/groups/subject.ts +0 -50
- package/src/health/createHealthCheck.test.ts +0 -89
- package/src/health/createHealthCheck.ts +0 -67
- package/src/health/healthController.test.ts +0 -113
- package/src/health/healthController.ts +0 -56
- package/src/index.ts +0 -88
- package/src/logging/logger.test.ts +0 -91
- package/src/logging/logger.ts +0 -103
- package/src/metrics/index.test.ts +0 -116
- package/src/metrics/index.ts +0 -111
- package/src/middleware/authMiddleware.test.ts +0 -275
- package/src/middleware/authMiddleware.ts +0 -91
- package/src/middleware/corsMiddleware.test.ts +0 -135
- package/src/middleware/corsMiddleware.ts +0 -65
- package/src/middleware/errorHandler.test.ts +0 -188
- package/src/middleware/errorHandler.ts +0 -103
- package/src/middleware/internalServiceAuth.test.ts +0 -173
- package/src/middleware/internalServiceAuth.ts +0 -96
- package/src/middleware/requestLogger.test.ts +0 -81
- package/src/middleware/requestLogger.ts +0 -48
- package/src/middleware/security.test.ts +0 -45
- package/src/middleware/security.ts +0 -43
- package/src/middleware/validate.test.ts +0 -72
- package/src/middleware/validate.ts +0 -23
- package/src/oauth/index.ts +0 -29
- package/src/oauth/models.ts +0 -164
- package/src/oauth/service.test.ts +0 -432
- package/src/oauth/service.ts +0 -299
- package/src/oauth/tokens.test.ts +0 -146
- package/src/oauth/tokens.ts +0 -186
- package/src/testing/serviceTestSetup.ts +0 -17
- package/src/tracing/index.test.ts +0 -272
- package/src/tracing/index.ts +0 -110
- package/src/types/auth.ts +0 -45
- package/src/utils/correlation.test.ts +0 -47
- package/src/utils/correlation.ts +0 -22
- package/src/utils/permissionUtils.test.ts +0 -47
- package/src/utils/permissionUtils.ts +0 -68
- package/src/utils/response.test.ts +0 -64
- package/src/utils/response.ts +0 -60
- package/tsconfig.build.json +0 -7
- 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
|
-
});
|