@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.
Files changed (73) hide show
  1. package/dist/entitlements/UsageMeter.d.ts +30 -0
  2. package/dist/entitlements/UsageMeter.d.ts.map +1 -0
  3. package/dist/entitlements/UsageMeter.js +27 -0
  4. package/dist/entitlements/UsageMeter.js.map +1 -0
  5. package/dist/entitlements/client.d.ts +17 -0
  6. package/dist/entitlements/client.d.ts.map +1 -0
  7. package/dist/entitlements/client.js +142 -0
  8. package/dist/entitlements/client.js.map +1 -0
  9. package/dist/entitlements/definitions.d.ts +124 -0
  10. package/dist/entitlements/definitions.d.ts.map +1 -0
  11. package/dist/entitlements/definitions.js +210 -0
  12. package/dist/entitlements/definitions.js.map +1 -0
  13. package/dist/entitlements/index.d.ts +12 -0
  14. package/dist/entitlements/index.d.ts.map +1 -0
  15. package/dist/entitlements/index.js +43 -0
  16. package/dist/entitlements/index.js.map +1 -0
  17. package/dist/entitlements/middleware.d.ts +54 -0
  18. package/dist/entitlements/middleware.d.ts.map +1 -0
  19. package/dist/entitlements/middleware.js +109 -0
  20. package/dist/entitlements/middleware.js.map +1 -0
  21. package/dist/entitlements/mode.d.ts +10 -0
  22. package/dist/entitlements/mode.d.ts.map +1 -0
  23. package/dist/entitlements/mode.js +32 -0
  24. package/dist/entitlements/mode.js.map +1 -0
  25. package/dist/entitlements/types.d.ts +29 -0
  26. package/dist/entitlements/types.d.ts.map +1 -0
  27. package/dist/entitlements/types.js +3 -0
  28. package/dist/entitlements/types.js.map +1 -0
  29. package/dist/entitlements/usage.d.ts +56 -0
  30. package/dist/entitlements/usage.d.ts.map +1 -0
  31. package/dist/entitlements/usage.js +159 -0
  32. package/dist/entitlements/usage.js.map +1 -0
  33. package/dist/errors/HttpError.d.ts +30 -1
  34. package/dist/errors/HttpError.d.ts.map +1 -1
  35. package/dist/errors/HttpError.js +20 -2
  36. package/dist/errors/HttpError.js.map +1 -1
  37. package/dist/index.d.ts +4 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +26 -2
  40. package/dist/index.js.map +1 -1
  41. package/dist/middleware/corsMiddleware.d.ts.map +1 -1
  42. package/dist/middleware/corsMiddleware.js +5 -1
  43. package/dist/middleware/corsMiddleware.js.map +1 -1
  44. package/dist/middleware/errorHandler.d.ts.map +1 -1
  45. package/dist/middleware/errorHandler.js +4 -1
  46. package/dist/middleware/errorHandler.js.map +1 -1
  47. package/dist/middleware/internalServiceAuth.d.ts +44 -0
  48. package/dist/middleware/internalServiceAuth.d.ts.map +1 -0
  49. package/dist/middleware/internalServiceAuth.js +86 -0
  50. package/dist/middleware/internalServiceAuth.js.map +1 -0
  51. package/package.json +2 -1
  52. package/src/entitlements/UsageMeter.ts +49 -0
  53. package/src/entitlements/client.test.ts +170 -0
  54. package/src/entitlements/client.ts +162 -0
  55. package/src/entitlements/definitions.test.ts +161 -0
  56. package/src/entitlements/definitions.ts +261 -0
  57. package/src/entitlements/index.ts +40 -0
  58. package/src/entitlements/middleware.test.ts +179 -0
  59. package/src/entitlements/middleware.ts +136 -0
  60. package/src/entitlements/mode.test.ts +50 -0
  61. package/src/entitlements/mode.ts +28 -0
  62. package/src/entitlements/types.ts +30 -0
  63. package/src/entitlements/usage.test.ts +271 -0
  64. package/src/entitlements/usage.ts +189 -0
  65. package/src/errors/HttpError.test.ts +39 -1
  66. package/src/errors/HttpError.ts +35 -1
  67. package/src/index.ts +13 -1
  68. package/src/middleware/corsMiddleware.test.ts +38 -0
  69. package/src/middleware/corsMiddleware.ts +5 -1
  70. package/src/middleware/errorHandler.test.ts +43 -1
  71. package/src/middleware/errorHandler.ts +6 -1
  72. package/src/middleware/internalServiceAuth.test.ts +173 -0
  73. 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([
@@ -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
- methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
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
  });