@tumbaland/backend-core 1.22.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/entitlements/UsageMeter.d.ts +30 -0
- package/dist/entitlements/UsageMeter.d.ts.map +1 -0
- package/dist/entitlements/UsageMeter.js +27 -0
- package/dist/entitlements/UsageMeter.js.map +1 -0
- package/dist/entitlements/client.d.ts +17 -0
- package/dist/entitlements/client.d.ts.map +1 -0
- package/dist/entitlements/client.js +142 -0
- package/dist/entitlements/client.js.map +1 -0
- package/dist/entitlements/definitions.d.ts +124 -0
- package/dist/entitlements/definitions.d.ts.map +1 -0
- package/dist/entitlements/definitions.js +210 -0
- package/dist/entitlements/definitions.js.map +1 -0
- package/dist/entitlements/index.d.ts +12 -0
- package/dist/entitlements/index.d.ts.map +1 -0
- package/dist/entitlements/index.js +43 -0
- package/dist/entitlements/index.js.map +1 -0
- package/dist/entitlements/middleware.d.ts +54 -0
- package/dist/entitlements/middleware.d.ts.map +1 -0
- package/dist/entitlements/middleware.js +109 -0
- package/dist/entitlements/middleware.js.map +1 -0
- package/dist/entitlements/mode.d.ts +10 -0
- package/dist/entitlements/mode.d.ts.map +1 -0
- package/dist/entitlements/mode.js +32 -0
- package/dist/entitlements/mode.js.map +1 -0
- package/dist/entitlements/types.d.ts +29 -0
- package/dist/entitlements/types.d.ts.map +1 -0
- package/dist/entitlements/types.js +3 -0
- package/dist/entitlements/types.js.map +1 -0
- package/dist/entitlements/usage.d.ts +56 -0
- package/dist/entitlements/usage.d.ts.map +1 -0
- package/dist/entitlements/usage.js +159 -0
- package/dist/entitlements/usage.js.map +1 -0
- package/dist/errors/HttpError.d.ts +30 -1
- package/dist/errors/HttpError.d.ts.map +1 -1
- package/dist/errors/HttpError.js +20 -2
- package/dist/errors/HttpError.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/corsMiddleware.d.ts.map +1 -1
- package/dist/middleware/corsMiddleware.js +5 -1
- package/dist/middleware/corsMiddleware.js.map +1 -1
- package/dist/middleware/errorHandler.d.ts.map +1 -1
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/errorHandler.js.map +1 -1
- package/dist/middleware/internalServiceAuth.d.ts +44 -0
- package/dist/middleware/internalServiceAuth.d.ts.map +1 -0
- package/dist/middleware/internalServiceAuth.js +86 -0
- package/dist/middleware/internalServiceAuth.js.map +1 -0
- package/package.json +2 -1
- package/src/entitlements/UsageMeter.ts +49 -0
- package/src/entitlements/client.test.ts +170 -0
- package/src/entitlements/client.ts +162 -0
- package/src/entitlements/definitions.test.ts +161 -0
- package/src/entitlements/definitions.ts +261 -0
- package/src/entitlements/index.ts +40 -0
- package/src/entitlements/middleware.test.ts +179 -0
- package/src/entitlements/middleware.ts +136 -0
- package/src/entitlements/mode.test.ts +50 -0
- package/src/entitlements/mode.ts +28 -0
- package/src/entitlements/types.ts +30 -0
- package/src/entitlements/usage.test.ts +271 -0
- package/src/entitlements/usage.ts +189 -0
- package/src/errors/HttpError.test.ts +39 -1
- package/src/errors/HttpError.ts +35 -1
- package/src/index.ts +13 -1
- package/src/middleware/corsMiddleware.test.ts +38 -0
- package/src/middleware/corsMiddleware.ts +5 -1
- package/src/middleware/errorHandler.test.ts +43 -1
- package/src/middleware/errorHandler.ts +6 -1
- package/src/middleware/internalServiceAuth.test.ts +173 -0
- package/src/middleware/internalServiceAuth.ts +96 -0
|
@@ -0,0 +1,271 @@
|
|
|
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, 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, ENTITLEMENTS_MODE: 'enforce' };
|
|
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('modes', () => {
|
|
140
|
+
it('records nothing and blocks nothing when off', async () => {
|
|
141
|
+
process.env.ENTITLEMENTS_MODE = 'off';
|
|
142
|
+
await setUsage('user@example.com', 'aiJobs', 5);
|
|
143
|
+
|
|
144
|
+
await expect(
|
|
145
|
+
consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', entitlements: entitlements() })
|
|
146
|
+
).resolves.toBeUndefined();
|
|
147
|
+
expect(await getUsage('user@example.com', 'aiJobs')).toBe(5);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('keeps the increment and logs would_block instead of rejecting when observing', async () => {
|
|
151
|
+
process.env.ENTITLEMENTS_MODE = 'observe';
|
|
152
|
+
await setUsage('user@example.com', 'aiJobs', 5);
|
|
153
|
+
|
|
154
|
+
await expect(
|
|
155
|
+
consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', entitlements: entitlements(), route: '/ai/dispatch' })
|
|
156
|
+
).resolves.toBeUndefined();
|
|
157
|
+
|
|
158
|
+
// Observe mode exists to measure real demand — rolling the count back would
|
|
159
|
+
// under-report exactly the users the limits are being calibrated against.
|
|
160
|
+
expect(await getUsage('user@example.com', 'aiJobs')).toBe(6);
|
|
161
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
162
|
+
'entitlement.would_block',
|
|
163
|
+
expect.objectContaining({ meter: 'aiJobs', used: 5, limit: 5, route: '/ai/dispatch' })
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('checkQuota', () => {
|
|
169
|
+
it('passes when the requested amount still fits and writes nothing', async () => {
|
|
170
|
+
await setUsage('user@example.com', 'storageBytes', 400);
|
|
171
|
+
|
|
172
|
+
await expect(
|
|
173
|
+
checkQuota({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 600, entitlements: entitlements() })
|
|
174
|
+
).resolves.toBeUndefined();
|
|
175
|
+
expect(await getUsage('user@example.com', 'storageBytes')).toBe(400);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('logs would_block instead of rejecting when observing', async () => {
|
|
179
|
+
process.env.ENTITLEMENTS_MODE = 'observe';
|
|
180
|
+
await setUsage('user@example.com', 'storageBytes', 400);
|
|
181
|
+
|
|
182
|
+
await expect(
|
|
183
|
+
checkQuota({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 601, entitlements: entitlements() })
|
|
184
|
+
).resolves.toBeUndefined();
|
|
185
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
186
|
+
'entitlement.would_block',
|
|
187
|
+
expect.objectContaining({ meter: 'storageBytes', used: 400, limit: 1000 })
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('is inert when the mode is off, even over the limit', async () => {
|
|
192
|
+
process.env.ENTITLEMENTS_MODE = 'off';
|
|
193
|
+
await setUsage('user@example.com', 'storageBytes', 5000);
|
|
194
|
+
|
|
195
|
+
await expect(
|
|
196
|
+
checkQuota({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 1, entitlements: entitlements() })
|
|
197
|
+
).resolves.toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('skips the usage read entirely for an unlimited meter', async () => {
|
|
201
|
+
const unlimited = entitlements({ limits: normalizeLimits({ meters: { storageBytes: UNLIMITED } }, 'free') });
|
|
202
|
+
|
|
203
|
+
await expect(
|
|
204
|
+
checkQuota({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 10 ** 12, entitlements: unlimited })
|
|
205
|
+
).resolves.toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('rejects when the amount would overrun, reporting current usage', async () => {
|
|
209
|
+
await setUsage('user@example.com', 'storageBytes', 400);
|
|
210
|
+
|
|
211
|
+
await expect(
|
|
212
|
+
checkQuota({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 601, entitlements: entitlements() })
|
|
213
|
+
).rejects.toMatchObject({ statusCode: 402, details: { meter: 'storageBytes', used: 400, limit: 1000 } });
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('releaseQuota', () => {
|
|
218
|
+
it('hands capacity back after a failure', async () => {
|
|
219
|
+
await consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', amount: 3, entitlements: entitlements() });
|
|
220
|
+
|
|
221
|
+
await releaseQuota('user@example.com', 'aiJobs', 3);
|
|
222
|
+
|
|
223
|
+
expect(await getUsage('user@example.com', 'aiJobs')).toBe(0);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('clamps at zero rather than going negative', async () => {
|
|
227
|
+
await setUsage('user@example.com', 'storageBytes', 100);
|
|
228
|
+
|
|
229
|
+
await releaseQuota('user@example.com', 'storageBytes', 500);
|
|
230
|
+
|
|
231
|
+
expect(await getUsage('user@example.com', 'storageBytes')).toBe(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('does not lose concurrent releases', async () => {
|
|
235
|
+
await setUsage('user@example.com', 'storageBytes', 100);
|
|
236
|
+
|
|
237
|
+
await Promise.all(Array.from({ length: 10 }, () => releaseQuota('user@example.com', 'storageBytes', 10)));
|
|
238
|
+
|
|
239
|
+
expect(await getUsage('user@example.com', 'storageBytes')).toBe(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('is a no-op when no counter exists', async () => {
|
|
243
|
+
await expect(releaseQuota('nobody@example.com', 'seats', 1)).resolves.toBeUndefined();
|
|
244
|
+
expect(await getUsage('nobody@example.com', 'seats')).toBe(0);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('getUsageSnapshot', () => {
|
|
249
|
+
it('reports every meter, defaulting the untouched ones to zero', async () => {
|
|
250
|
+
await setUsage('user@example.com', 'storageBytes', 42);
|
|
251
|
+
await setUsage('user@example.com', 'aiJobs', 7);
|
|
252
|
+
await setUsage('other@example.com', 'seats', 99);
|
|
253
|
+
|
|
254
|
+
expect(await getUsageSnapshot('user@example.com')).toEqual({
|
|
255
|
+
storageBytes: 42,
|
|
256
|
+
aiJobs: 7,
|
|
257
|
+
trackedTickers: 0,
|
|
258
|
+
seats: 0
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('setUsage', () => {
|
|
264
|
+
it('overwrites for backfill and reconciliation, refusing negatives', async () => {
|
|
265
|
+
await setUsage('user@example.com', 'storageBytes', 5_000);
|
|
266
|
+
expect(await getUsage('user@example.com', 'storageBytes')).toBe(5_000);
|
|
267
|
+
|
|
268
|
+
await setUsage('user@example.com', 'storageBytes', -20);
|
|
269
|
+
expect(await getUsage('user@example.com', 'storageBytes')).toBe(0);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
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
|
+
import { getEntitlementsMode } from './mode';
|
|
7
|
+
|
|
8
|
+
export interface QuotaRequest {
|
|
9
|
+
/** Who the usage is billed to — see `IUsageMeter.subjectId`. */
|
|
10
|
+
subjectId: string;
|
|
11
|
+
meter: MeterKey;
|
|
12
|
+
/** How much this request wants. Bytes for `storageBytes`, otherwise a count. */
|
|
13
|
+
amount?: number;
|
|
14
|
+
entitlements: Entitlements;
|
|
15
|
+
/** Attached to the observe-mode log line so a would-block can be traced to a route. */
|
|
16
|
+
route?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Current count for one meter in its live period. */
|
|
20
|
+
export async function getUsage(subjectId: string, meter: MeterKey, now?: Date): Promise<number> {
|
|
21
|
+
const doc = await UsageMeter.findOne({
|
|
22
|
+
subjectId: subjectId.toLowerCase(),
|
|
23
|
+
meter,
|
|
24
|
+
period: currentPeriod(meter, now)
|
|
25
|
+
}).lean();
|
|
26
|
+
return doc?.count ?? 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Every meter at once, for the account page and the pricing table's live numbers. */
|
|
30
|
+
export async function getUsageSnapshot(subjectId: string, now?: Date): Promise<Record<MeterKey, number>> {
|
|
31
|
+
const periods = METER_KEYS.map(meter => ({ meter, period: currentPeriod(meter, now) }));
|
|
32
|
+
const docs = await UsageMeter.find({
|
|
33
|
+
subjectId: subjectId.toLowerCase(),
|
|
34
|
+
$or: periods.map(p => ({ meter: p.meter, period: p.period }))
|
|
35
|
+
}).lean();
|
|
36
|
+
|
|
37
|
+
const snapshot = Object.fromEntries(METER_KEYS.map(k => [k, 0])) as Record<MeterKey, number>;
|
|
38
|
+
for (const doc of docs) {
|
|
39
|
+
if ((METER_KEYS as readonly string[]).includes(doc.meter)) snapshot[doc.meter as MeterKey] = doc.count;
|
|
40
|
+
}
|
|
41
|
+
return snapshot;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function quotaError(req: QuotaRequest, used: number, limit: number): PaymentRequiredError {
|
|
45
|
+
return new PaymentRequiredError(`Limit reached for ${req.meter}`, {
|
|
46
|
+
code: 'QUOTA_EXCEEDED',
|
|
47
|
+
meter: req.meter,
|
|
48
|
+
used,
|
|
49
|
+
limit,
|
|
50
|
+
planCode: req.entitlements.planCode,
|
|
51
|
+
upgradeTo: req.entitlements.upgradeTo
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function logWouldBlock(req: QuotaRequest, used: number, limit: number): void {
|
|
56
|
+
logger.info('entitlement.would_block', {
|
|
57
|
+
event: 'entitlement.would_block',
|
|
58
|
+
meter: req.meter,
|
|
59
|
+
subjectId: req.subjectId,
|
|
60
|
+
amount: req.amount ?? 1,
|
|
61
|
+
used,
|
|
62
|
+
limit,
|
|
63
|
+
planCode: req.entitlements.planCode,
|
|
64
|
+
route: req.route,
|
|
65
|
+
stale: req.entitlements.stale === true
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read-only check for cumulative meters whose real increment happens later
|
|
71
|
+
* (storage is counted when the upload lands, not when it is authorized).
|
|
72
|
+
*
|
|
73
|
+
* Racy by construction — two concurrent requests can both pass — which is
|
|
74
|
+
* acceptable here precisely because the meters it guards are reconciled from
|
|
75
|
+
* the underlying data. Use `consumeQuota` where the count is the only record.
|
|
76
|
+
*/
|
|
77
|
+
export async function checkQuota(req: QuotaRequest): Promise<void> {
|
|
78
|
+
const mode = getEntitlementsMode();
|
|
79
|
+
if (mode === 'off') return;
|
|
80
|
+
|
|
81
|
+
const limit = req.entitlements.limits.meters[req.meter] ?? UNLIMITED;
|
|
82
|
+
if (limit === UNLIMITED) return;
|
|
83
|
+
|
|
84
|
+
const used = await getUsage(req.subjectId, req.meter);
|
|
85
|
+
if (fitsWithin(limit, used, req.amount ?? 1)) return;
|
|
86
|
+
|
|
87
|
+
if (mode === 'observe') {
|
|
88
|
+
logWouldBlock(req, used, limit);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
throw quotaError(req, used, limit);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Atomically reserve capacity, throwing when the reservation would overrun.
|
|
96
|
+
*
|
|
97
|
+
* The increment and the check are one `$inc` + read, not a read-then-write:
|
|
98
|
+
* two concurrent requests that both read "24 used" against a limit of 25 would
|
|
99
|
+
* both proceed and overrun. Incrementing first and rejecting on the
|
|
100
|
+
* *post-increment* value means the loser of that race is the one rejected.
|
|
101
|
+
*
|
|
102
|
+
* A rejected reservation rolls its own increment back before throwing — unlike
|
|
103
|
+
* the provider-quota version this generalizes, where an over-limit count just
|
|
104
|
+
* sits there until the day rolls over. A per-user cumulative meter never rolls
|
|
105
|
+
* over, so a leaked increment is permanent and shows up months later as a user
|
|
106
|
+
* who cannot upload despite being well under their limit.
|
|
107
|
+
*/
|
|
108
|
+
export async function consumeQuota(req: QuotaRequest): Promise<void> {
|
|
109
|
+
const mode = getEntitlementsMode();
|
|
110
|
+
if (mode === 'off') return;
|
|
111
|
+
|
|
112
|
+
const amount = req.amount ?? 1;
|
|
113
|
+
const limit = req.entitlements.limits.meters[req.meter] ?? UNLIMITED;
|
|
114
|
+
const period = currentPeriod(req.meter);
|
|
115
|
+
const subjectId = req.subjectId.toLowerCase();
|
|
116
|
+
|
|
117
|
+
const usage = await UsageMeter.findOneAndUpdate(
|
|
118
|
+
{ subjectId, meter: req.meter, period },
|
|
119
|
+
{ $inc: { count: amount } },
|
|
120
|
+
{ upsert: true, returnDocument: 'after', setDefaultsOnInsert: true }
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (limit === UNLIMITED || usage.count <= limit) return;
|
|
124
|
+
|
|
125
|
+
const usedBefore = usage.count - amount;
|
|
126
|
+
|
|
127
|
+
if (mode === 'observe') {
|
|
128
|
+
// Keep the increment: observe mode exists to measure real demand, and a
|
|
129
|
+
// rolled-back count would under-report exactly the users who matter.
|
|
130
|
+
logWouldBlock(req, usedBefore, limit);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await releaseQuota(subjectId, req.meter, amount);
|
|
135
|
+
throw quotaError(req, usedBefore, limit);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Hand back capacity after a failure that never consumed it (the upload errored,
|
|
140
|
+
* the AI service refused the job), and on deletes — a cumulative meter that only
|
|
141
|
+
* counts up is a bug that surfaces as an angry support email six months in.
|
|
142
|
+
*
|
|
143
|
+
* Never drops below zero, and never throws: a failed release must not turn a
|
|
144
|
+
* recoverable error into a 500 for the caller that was already unwinding.
|
|
145
|
+
*/
|
|
146
|
+
export async function releaseQuota(
|
|
147
|
+
subjectId: string,
|
|
148
|
+
meter: MeterKey,
|
|
149
|
+
amount = 1,
|
|
150
|
+
now?: Date
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
try {
|
|
153
|
+
const filter = { subjectId: subjectId.toLowerCase(), meter, period: currentPeriod(meter, now) };
|
|
154
|
+
|
|
155
|
+
// Conditional `$inc` rather than read-then-write: a read-modify-write here
|
|
156
|
+
// loses concurrent releases, which on a cumulative meter is permanent drift.
|
|
157
|
+
const result = await UsageMeter.updateOne({ ...filter, count: { $gte: amount } }, { $inc: { count: -amount } });
|
|
158
|
+
if (result.matchedCount === 0) {
|
|
159
|
+
// Either no counter exists, or it holds less than we are handing back
|
|
160
|
+
// (a reconciliation reset it in between). Clamp at zero, never negative.
|
|
161
|
+
await UsageMeter.updateOne({ ...filter, count: { $lt: amount } }, { $set: { count: 0 } });
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
logger.warn('Failed to release usage quota', {
|
|
165
|
+
subjectId,
|
|
166
|
+
meter,
|
|
167
|
+
amount,
|
|
168
|
+
error: (err as Error)?.message
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Set a meter to a known value. For the backfill (existing storage has to be
|
|
175
|
+
* seeded or every current user starts at zero and silently exceeds later) and
|
|
176
|
+
* for periodic reconciliation against the source of truth.
|
|
177
|
+
*/
|
|
178
|
+
export async function setUsage(
|
|
179
|
+
subjectId: string,
|
|
180
|
+
meter: MeterKey,
|
|
181
|
+
count: number,
|
|
182
|
+
now?: Date
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
await UsageMeter.updateOne(
|
|
185
|
+
{ subjectId: subjectId.toLowerCase(), meter, period: currentPeriod(meter, now) },
|
|
186
|
+
{ $set: { count: Math.max(0, Math.floor(count)) } },
|
|
187
|
+
{ upsert: true, setDefaultsOnInsert: true }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError } from './HttpError';
|
|
1
|
+
import { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError, PaymentRequiredError } from './HttpError';
|
|
2
2
|
|
|
3
3
|
describe('HttpError', () => {
|
|
4
4
|
it('carries the given status code and message', () => {
|
|
@@ -11,6 +11,44 @@ describe('HttpError', () => {
|
|
|
11
11
|
it('sets name to the concrete subclass name', () => {
|
|
12
12
|
expect(new HttpError(400, 'x').name).toBe('HttpError');
|
|
13
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: 'personal'
|
|
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: 'personal'
|
|
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
|
+
});
|
|
14
52
|
});
|
|
15
53
|
|
|
16
54
|
describe.each([
|
package/src/errors/HttpError.ts
CHANGED
|
@@ -9,11 +9,18 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export class HttpError extends Error {
|
|
11
11
|
statusCode: number;
|
|
12
|
+
/**
|
|
13
|
+
* Extra fields merged into the JSON body by the shared errorHandler. Used by
|
|
14
|
+
* errors the client has to *act* on rather than just display — a 402 tells
|
|
15
|
+
* the upgrade dialog which meter blocked and which plan lifts it.
|
|
16
|
+
*/
|
|
17
|
+
details?: Record<string, unknown>;
|
|
12
18
|
|
|
13
|
-
constructor(statusCode: number, message: string) {
|
|
19
|
+
constructor(statusCode: number, message: string, details?: Record<string, unknown>) {
|
|
14
20
|
super(message);
|
|
15
21
|
this.name = new.target.name;
|
|
16
22
|
this.statusCode = statusCode;
|
|
23
|
+
this.details = details;
|
|
17
24
|
// Restore the prototype chain (needed when compiling to ES2018 targets
|
|
18
25
|
// where `extends Error` doesn't preserve instanceof checks correctly).
|
|
19
26
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
@@ -55,3 +62,30 @@ export class TooManyRequestsError extends HttpError {
|
|
|
55
62
|
super(429, message);
|
|
56
63
|
}
|
|
57
64
|
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fields the upgrade dialog renders. Every one of them comes from the response
|
|
68
|
+
* body, so the dialog needs no knowledge of which call failed.
|
|
69
|
+
*/
|
|
70
|
+
export interface PaymentRequiredDetails {
|
|
71
|
+
/** Machine-readable reason, e.g. `QUOTA_EXCEEDED` or `FEATURE_NOT_IN_PLAN`. */
|
|
72
|
+
code: string;
|
|
73
|
+
meter?: string;
|
|
74
|
+
feature?: string;
|
|
75
|
+
used?: number;
|
|
76
|
+
limit?: number;
|
|
77
|
+
planCode?: string;
|
|
78
|
+
/** The cheapest plan that lifts this particular limit, when one exists. */
|
|
79
|
+
upgradeTo?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 402, never 403. 403 means "you may not"; 402 means "you may, for money" —
|
|
84
|
+
* and the frontend needs to tell those apart to choose between an error toast
|
|
85
|
+
* and an upgrade dialog.
|
|
86
|
+
*/
|
|
87
|
+
export class PaymentRequiredError extends HttpError {
|
|
88
|
+
constructor(message: string, details: PaymentRequiredDetails) {
|
|
89
|
+
super(402, message, { ...details });
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,10 +21,22 @@ export { connectDB, disconnectDB } from './database/connection';
|
|
|
21
21
|
export { requireEnv } from './config/env';
|
|
22
22
|
|
|
23
23
|
// Errors
|
|
24
|
-
export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError } from './errors/HttpError';
|
|
24
|
+
export { HttpError, BadRequestError, UnauthorizedError, ForbiddenError, NotFoundError, ConflictError, TooManyRequestsError, PaymentRequiredError } from './errors/HttpError';
|
|
25
|
+
export type { PaymentRequiredDetails } from './errors/HttpError';
|
|
26
|
+
|
|
27
|
+
// Entitlements (plan limits, usage meters, enforcement)
|
|
28
|
+
export * from './entitlements';
|
|
25
29
|
|
|
26
30
|
// Middleware
|
|
27
31
|
export { authenticateToken } from './middleware/authMiddleware';
|
|
32
|
+
export {
|
|
33
|
+
requireInternalServiceToken,
|
|
34
|
+
internalServiceTokenBypass,
|
|
35
|
+
allowUserOrInternalService,
|
|
36
|
+
verifyInternalToken,
|
|
37
|
+
INTERNAL_TOKEN_HEADER,
|
|
38
|
+
SERVICE_ID_HEADER
|
|
39
|
+
} from './middleware/internalServiceAuth';
|
|
28
40
|
export { createCorsMiddleware } from './middleware/corsMiddleware';
|
|
29
41
|
export { errorHandler } from './middleware/errorHandler';
|
|
30
42
|
export { requestLogger, requestLoggerWithMetrics, simpleRequestLogger } from './middleware/requestLogger';
|
|
@@ -40,6 +40,44 @@ describe('createCorsMiddleware', () => {
|
|
|
40
40
|
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
describe('the preflight', () => {
|
|
44
|
+
const preflight = (method: string) =>
|
|
45
|
+
request(app)
|
|
46
|
+
.options('/ping')
|
|
47
|
+
.set('Origin', 'https://allowed.example')
|
|
48
|
+
.set('Access-Control-Request-Method', method);
|
|
49
|
+
|
|
50
|
+
// A method left out here is the worst kind of CORS failure: the preflight
|
|
51
|
+
// returns 204, so the network tab shows nothing failing, and the browser
|
|
52
|
+
// quietly refuses to send the request it just asked about.
|
|
53
|
+
it.each([['GET'], ['POST'], ['PUT'], ['PATCH'], ['DELETE']])('permits %s', async (method) => {
|
|
54
|
+
const res = await preflight(method);
|
|
55
|
+
|
|
56
|
+
expect(res.headers['access-control-allow-methods'].split(',')).toContain(method);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not advertise a method the services do not serve', async () => {
|
|
60
|
+
const res = await preflight('GET');
|
|
61
|
+
|
|
62
|
+
expect(res.headers['access-control-allow-methods']).not.toContain('TRACE');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('exposes only the response headers a caller asks for', async () => {
|
|
67
|
+
// Cross-origin JavaScript cannot read a header that is not exposed — the
|
|
68
|
+
// CSV export reads its filename out of Content-Disposition
|
|
69
|
+
const exposing = express();
|
|
70
|
+
exposing.use(createCorsMiddleware({
|
|
71
|
+
origins: 'https://allowed.example',
|
|
72
|
+
exposedHeaders: ['Content-Disposition']
|
|
73
|
+
}));
|
|
74
|
+
exposing.get('/ping', (_req, res) => res.json({ ok: true }));
|
|
75
|
+
|
|
76
|
+
const res = await request(exposing).get('/ping').set('Origin', 'https://allowed.example');
|
|
77
|
+
|
|
78
|
+
expect(res.headers['access-control-expose-headers']).toBe('Content-Disposition');
|
|
79
|
+
});
|
|
80
|
+
|
|
43
81
|
it('falls back to process.env.CORS_ORIGIN when no origins option is given', async () => {
|
|
44
82
|
const ORIGINAL_ENV = process.env;
|
|
45
83
|
process.env = { ...ORIGINAL_ENV, CORS_ORIGIN: 'https://from-env.example' };
|
|
@@ -29,7 +29,11 @@ export function createCorsMiddleware(options: CorsMiddlewareOptions = {}) {
|
|
|
29
29
|
return callback(new Error('Not allowed by CORS: ' + origin));
|
|
30
30
|
},
|
|
31
31
|
credentials: true,
|
|
32
|
-
|
|
32
|
+
// PATCH belongs here as much as PUT: leaving it out lets the preflight
|
|
33
|
+
// succeed and then has the browser block the request it just approved,
|
|
34
|
+
// which surfaces in the UI as a bare "failed to save" with a 204 in the
|
|
35
|
+
// network tab and no failing request to point at.
|
|
36
|
+
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
33
37
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-correlation-id', 'x-session-id'],
|
|
34
38
|
exposedHeaders: options.exposedHeaders
|
|
35
39
|
});
|