@tumbaland/backend-core 1.25.0 → 1.26.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 (59) hide show
  1. package/dist/entitlements/client.d.ts.map +1 -1
  2. package/dist/entitlements/client.js +17 -1
  3. package/dist/entitlements/client.js.map +1 -1
  4. package/dist/entitlements/index.d.ts +3 -2
  5. package/dist/entitlements/index.d.ts.map +1 -1
  6. package/dist/entitlements/index.js +5 -4
  7. package/dist/entitlements/index.js.map +1 -1
  8. package/dist/entitlements/middleware.d.ts.map +1 -1
  9. package/dist/entitlements/middleware.js +8 -19
  10. package/dist/entitlements/middleware.js.map +1 -1
  11. package/dist/entitlements/reconcile.d.ts +74 -0
  12. package/dist/entitlements/reconcile.d.ts.map +1 -0
  13. package/dist/entitlements/reconcile.js +297 -0
  14. package/dist/entitlements/reconcile.js.map +1 -0
  15. package/dist/entitlements/types.d.ts +0 -8
  16. package/dist/entitlements/types.d.ts.map +1 -1
  17. package/dist/entitlements/usage.d.ts +1 -1
  18. package/dist/entitlements/usage.d.ts.map +1 -1
  19. package/dist/entitlements/usage.js +11 -20
  20. package/dist/entitlements/usage.js.map +1 -1
  21. package/dist/groups/client.d.ts +27 -0
  22. package/dist/groups/client.d.ts.map +1 -0
  23. package/dist/groups/client.js +153 -0
  24. package/dist/groups/client.js.map +1 -0
  25. package/dist/groups/index.d.ts +5 -0
  26. package/dist/groups/index.d.ts.map +1 -0
  27. package/dist/groups/index.js +10 -0
  28. package/dist/groups/index.js.map +1 -0
  29. package/dist/groups/subject.d.ts +30 -0
  30. package/dist/groups/subject.d.ts.map +1 -0
  31. package/dist/groups/subject.js +52 -0
  32. package/dist/groups/subject.js.map +1 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -0
  36. package/dist/index.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/entitlements/client.test.ts +30 -0
  39. package/src/entitlements/client.ts +18 -1
  40. package/src/entitlements/index.ts +4 -2
  41. package/src/entitlements/middleware.test.ts +9 -30
  42. package/src/entitlements/middleware.ts +8 -19
  43. package/src/entitlements/reconcile.test.ts +333 -0
  44. package/src/entitlements/reconcile.ts +384 -0
  45. package/src/entitlements/types.ts +0 -9
  46. package/src/entitlements/usage.test.ts +27 -31
  47. package/src/entitlements/usage.ts +12 -22
  48. package/src/groups/client.test.ts +215 -0
  49. package/src/groups/client.ts +182 -0
  50. package/src/groups/index.ts +5 -0
  51. package/src/groups/subject.test.ts +85 -0
  52. package/src/groups/subject.ts +50 -0
  53. package/src/index.ts +3 -0
  54. package/dist/entitlements/mode.d.ts +0 -10
  55. package/dist/entitlements/mode.d.ts.map +0 -1
  56. package/dist/entitlements/mode.js +0 -32
  57. package/dist/entitlements/mode.js.map +0 -1
  58. package/src/entitlements/mode.test.ts +0 -50
  59. package/src/entitlements/mode.ts +0 -28
@@ -0,0 +1,384 @@
1
+ import mongoose from 'mongoose';
2
+ import logger from '../logging/logger';
3
+ import { MeterKey } from './definitions';
4
+ import { UsageMeter, currentPeriod } from './UsageMeter';
5
+
6
+ /**
7
+ * Rebuild the cumulative usage meters from the data they describe.
8
+ *
9
+ * **Why this can exist at all:** every meter reconciled here is *derived*.
10
+ * `storageBytes` is the sum of photo sizes, `trackedTickers` the count of
11
+ * active symbols, `seats` the members of the groups a subject owns — in each
12
+ * case the underlying collection is the record and the counter is only a cache
13
+ * of it, kept hot because a quota check cannot afford the aggregation. So the
14
+ * counter can always be recomputed, and reconciliation is cache invalidation.
15
+ *
16
+ * `aiJobs` is deliberately absent, and its absence is the same rule read the
17
+ * other way: nothing records that a GPU job happened except the counter itself,
18
+ * so there is no truth to recompute it from. That is precisely why
19
+ * `consumeQuota` rolls its increment back on rejection rather than leaving it —
20
+ * a leaked increment on that meter is permanent, where here it is temporary.
21
+ *
22
+ * **On racing with live writes:** a `consumeQuota` landing between the
23
+ * aggregation and the write is lost. That is tolerable for exactly the reason
24
+ * above — the next run re-derives the same number from source and converges —
25
+ * so this is scheduled at a quiet hour rather than defended with a lock.
26
+ *
27
+ * Reads collections owned by other services through the raw driver rather than
28
+ * mongoose models. They are not this library's documents to model, every
29
+ * service shares one database, and going through the driver avoids registering
30
+ * a second model for a schema that already exists elsewhere.
31
+ */
32
+
33
+ /** Meters whose value can be recomputed from source data. See the note above. */
34
+ export const RECONCILED_METERS: readonly MeterKey[] = ['storageBytes', 'trackedTickers', 'seats'];
35
+
36
+ export interface ReconcileOptions {
37
+ /** Write the corrections. Default false — the caller must opt in to mutating. */
38
+ apply?: boolean;
39
+ /** Restrict to a single subject, for a targeted repair. */
40
+ subject?: string;
41
+ }
42
+
43
+ export interface MeterChange {
44
+ subjectId: string;
45
+ meter: MeterKey;
46
+ /** `null` when no counter existed yet. */
47
+ from: number | null;
48
+ to: number;
49
+ }
50
+
51
+ export interface OrphanedMeter {
52
+ subjectId: string;
53
+ meter: string;
54
+ count: number;
55
+ }
56
+
57
+ export interface ReconcileReport {
58
+ applied: boolean;
59
+ scannedUsers: number;
60
+ /** Subjects with any attributable usage, before any `subject` filter. */
61
+ subjects: number;
62
+ expected: Record<string, Record<MeterKey, number>>;
63
+ changes: MeterChange[];
64
+ written: number;
65
+ orphanedMeters: OrphanedMeter[];
66
+ warnings: string[];
67
+ }
68
+
69
+ const normalizeEmail = (value: unknown): string | undefined =>
70
+ typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined;
71
+
72
+ interface MemberRow {
73
+ email?: string;
74
+ role?: string;
75
+ }
76
+
77
+ interface GroupRow {
78
+ _id: unknown;
79
+ name?: string;
80
+ createdBy?: string;
81
+ members?: MemberRow[];
82
+ deletedAt?: Date | null;
83
+ }
84
+
85
+ interface OwnedRow {
86
+ _id: unknown;
87
+ userId?: string;
88
+ groupId?: string;
89
+ }
90
+
91
+ /**
92
+ * Whoever currently holds the `owner` role — that is what group-service
93
+ * authorizes against, and `updateMemberRole` can move it while `createdBy`
94
+ * stays as first written. Several owners resolve to the lowest email so that a
95
+ * re-run bills the same person twice rather than two people once.
96
+ */
97
+ function groupOwner(group: GroupRow, warnings: string[]): string | undefined {
98
+ const owners = (group.members ?? [])
99
+ .filter(m => m.role === 'owner')
100
+ .map(m => normalizeEmail(m.email))
101
+ .filter((e): e is string => Boolean(e))
102
+ .sort();
103
+
104
+ if (owners.length === 1) return owners[0];
105
+
106
+ if (owners.length === 0) {
107
+ const fallback = normalizeEmail(group.createdBy);
108
+ warnings.push(
109
+ `group ${group._id} ("${group.name}") has no member with role 'owner'; billing to createdBy=${fallback ?? 'MISSING'}`
110
+ );
111
+ return fallback;
112
+ }
113
+
114
+ warnings.push(`group ${group._id} ("${group.name}") has ${owners.length} owners (${owners.join(', ')}); billing to ${owners[0]}`);
115
+ return owners[0];
116
+ }
117
+
118
+ interface Bucket {
119
+ storageBytes: number;
120
+ trackedTickers: Set<string>;
121
+ seats: Set<string>;
122
+ }
123
+
124
+ async function computeExpected(): Promise<{
125
+ expected: Map<string, Bucket>;
126
+ warnings: string[];
127
+ scannedUsers: number;
128
+ }> {
129
+ const db = mongoose.connection.db;
130
+ if (!db) throw new Error('Cannot reconcile usage meters: no active database connection');
131
+
132
+ const warnings: string[] = [];
133
+
134
+ // Album.userId / TrackedTicker.userId hold `req.user.id`, which is the
135
+ // auth-service User._id as a string, while a meter's subjectId is an email.
136
+ // Nothing personal can be attributed without coming back through this map.
137
+ const users = await db.collection('users').find({}, { projection: { email: 1 } }).toArray();
138
+ const userIdToEmail = new Map<string, string>();
139
+ for (const user of users) {
140
+ const email = normalizeEmail(user.email);
141
+ if (email) userIdToEmail.set(String(user._id), email);
142
+ }
143
+
144
+ const groups = (await db.collection('groups').find({}).toArray()) as unknown as GroupRow[];
145
+ const groupsById = new Map<string, { doc: GroupRow; owner?: string; deleted: boolean }>();
146
+ for (const group of groups) {
147
+ groupsById.set(String(group._id), {
148
+ doc: group,
149
+ owner: groupOwner(group, warnings),
150
+ deleted: group.deletedAt !== null && group.deletedAt !== undefined
151
+ });
152
+ }
153
+
154
+ const expected = new Map<string, Bucket>();
155
+ const bucket = (email: string): Bucket => {
156
+ let existing = expected.get(email);
157
+ if (!existing) {
158
+ existing = { storageBytes: 0, trackedTickers: new Set(), seats: new Set() };
159
+ expected.set(email, existing);
160
+ }
161
+ return existing;
162
+ };
163
+ // Every known user gets a bucket, so a user whose real usage is zero can
164
+ // still correct a stale non-zero counter back down.
165
+ for (const email of userIdToEmail.values()) bucket(email);
166
+
167
+ /**
168
+ * Group rows bill to the group owner: Family sells a pooled allowance, and
169
+ * charging the uploader would spend their personal free tier on it instead.
170
+ */
171
+ const subjectFor = (row: OwnedRow, label: string): string | undefined => {
172
+ const groupId = row.groupId ? String(row.groupId) : undefined;
173
+ if (groupId) {
174
+ const group = groupsById.get(groupId);
175
+ if (group?.owner) return group.owner;
176
+ // The group is gone but its rows are not. The uploader is the only
177
+ // identity left, and dropping the bytes would understate real storage.
178
+ const uploader = userIdToEmail.get(String(row.userId));
179
+ warnings.push(`${label} ${row._id} references missing group ${groupId}; billing to uploader=${uploader ?? 'UNATTRIBUTED'}`);
180
+ return uploader;
181
+ }
182
+ const owner = userIdToEmail.get(String(row.userId));
183
+ if (!owner) warnings.push(`${label} ${row._id} has userId=${row.userId}, which matches no user; UNATTRIBUTED`);
184
+ return owner;
185
+ };
186
+
187
+ // --- storageBytes ---------------------------------------------------------
188
+ // Summed from the photos themselves rather than from the denormalized
189
+ // Album.totalSize: that counter is what a drifting $inc would have corrupted,
190
+ // so reconciling against it would re-import the drift instead of fixing it.
191
+ const photoTotals = await db
192
+ .collection('photos')
193
+ .aggregate<{ _id: unknown; totalSize: number; photoCount: number }>(
194
+ [{ $group: { _id: '$albumId', totalSize: { $sum: '$size' }, photoCount: { $sum: 1 } } }],
195
+ { allowDiskUse: true }
196
+ )
197
+ .toArray();
198
+ const sizeByAlbum = new Map(photoTotals.map(row => [String(row._id), row.totalSize || 0]));
199
+
200
+ const albums = (await db
201
+ .collection('albums')
202
+ .find({}, { projection: { userId: 1, groupId: 1, totalSize: 1, name: 1 } })
203
+ .toArray()) as unknown as (OwnedRow & { totalSize?: number; name?: string })[];
204
+ const albumIds = new Set(albums.map(a => String(a._id)));
205
+
206
+ for (const album of albums) {
207
+ const realSize = sizeByAlbum.get(String(album._id)) ?? 0;
208
+ if ((album.totalSize ?? 0) !== realSize) {
209
+ warnings.push(
210
+ `album ${album._id} ("${album.name}") totalSize=${album.totalSize} but photos sum to ${realSize}; ` +
211
+ `metering the photo sum. The album counter is left alone — it is album-service's to maintain, ` +
212
+ `and silently rewriting another service's denormalized field would hide the bug rather than surface it.`
213
+ );
214
+ }
215
+ const subject = subjectFor(album, 'album');
216
+ if (subject) bucket(subject).storageBytes += realSize;
217
+ }
218
+
219
+ for (const row of photoTotals) {
220
+ if (!albumIds.has(String(row._id))) {
221
+ warnings.push(`${row.photoCount} photo(s) totalling ${row.totalSize} B reference missing album ${row._id}; UNATTRIBUTED`);
222
+ }
223
+ }
224
+
225
+ // --- trackedTickers -------------------------------------------------------
226
+ // Distinct symbols, not rows: the daily Alpha Vantage spend this limit
227
+ // recovers is one call per distinct active symbol, however many rows hold it.
228
+ const tickers = (await db
229
+ .collection('trackedtickers')
230
+ .find({ isActive: true }, { projection: { userId: 1, groupId: 1, ticker: 1 } })
231
+ .toArray()) as unknown as (OwnedRow & { ticker?: string })[];
232
+
233
+ for (const ticker of tickers) {
234
+ const subject = subjectFor(ticker, 'trackedTicker');
235
+ if (subject && ticker.ticker) bucket(subject).trackedTickers.add(String(ticker.ticker).toUpperCase());
236
+ }
237
+
238
+ // --- seats ----------------------------------------------------------------
239
+ // Distinct people across every live group the subject owns. Soft-deleted
240
+ // groups are skipped: unlike storage and tickers they cost nothing and grant
241
+ // nobody access, so charging for them would charge for nothing.
242
+ for (const { doc, owner, deleted } of groupsById.values()) {
243
+ if (deleted || !owner) continue;
244
+ const seats = bucket(owner).seats;
245
+ for (const member of doc.members ?? []) {
246
+ const email = normalizeEmail(member.email);
247
+ if (email) seats.add(email);
248
+ }
249
+ }
250
+
251
+ return { expected, warnings, scannedUsers: users.length };
252
+ }
253
+
254
+ /**
255
+ * Recompute every derived meter and, with `apply`, write back the ones that
256
+ * disagree.
257
+ *
258
+ * Idempotent: it computes absolute values and writes only differences, so a
259
+ * second run in a row is a no-op.
260
+ */
261
+ export async function reconcileUsageMeters(options: ReconcileOptions = {}): Promise<ReconcileReport> {
262
+ const { apply = false, subject: only } = options;
263
+ const onlySubject = normalizeEmail(only);
264
+
265
+ const { expected, warnings, scannedUsers } = await computeExpected();
266
+
267
+ const current = await UsageMeter.find({ meter: { $in: RECONCILED_METERS }, period: 'ALL' }).lean();
268
+ const currentByKey = new Map(current.map(doc => [`${normalizeEmail(doc.subjectId)}::${doc.meter}`, doc]));
269
+
270
+ const subjects = Array.from(expected.keys())
271
+ .filter(email => !onlySubject || email === onlySubject)
272
+ .sort();
273
+
274
+ const changes: MeterChange[] = [];
275
+ const ops = [];
276
+
277
+ for (const subjectId of subjects) {
278
+ const counts = expected.get(subjectId)!;
279
+ const resolved: Record<MeterKey, number> = {
280
+ storageBytes: counts.storageBytes,
281
+ trackedTickers: counts.trackedTickers.size,
282
+ seats: counts.seats.size
283
+ } as Record<MeterKey, number>;
284
+
285
+ for (const meter of RECONCILED_METERS) {
286
+ const to = resolved[meter];
287
+ const existing = currentByKey.get(`${subjectId}::${meter}`);
288
+ const from = existing ? existing.count : null;
289
+
290
+ if (from === to) continue;
291
+ // Never create a row just to say zero — absence already reads as zero
292
+ // everywhere. An *existing* row must still be corrected down to zero, or
293
+ // a stale count outlives the data it was counting.
294
+ if (from === null && to === 0) continue;
295
+
296
+ changes.push({ subjectId, meter, from, to });
297
+ ops.push({
298
+ updateOne: {
299
+ filter: { subjectId, meter, period: currentPeriod(meter) },
300
+ update: {
301
+ $set: { count: to, updatedAt: new Date() },
302
+ $setOnInsert: { subjectId, meter, period: currentPeriod(meter), createdAt: new Date() }
303
+ },
304
+ upsert: true
305
+ }
306
+ });
307
+ }
308
+ }
309
+
310
+ // Meters for subjects the scan no longer recognizes — a removed user, or a
311
+ // subjectId written before an ownership change. Reported, never deleted: a
312
+ // reconciliation that silently drops billing state is worse than the drift.
313
+ const orphanedMeters: OrphanedMeter[] = current
314
+ .filter(doc => !expected.has(normalizeEmail(doc.subjectId) ?? ''))
315
+ .map(doc => ({ subjectId: doc.subjectId, meter: doc.meter, count: doc.count }));
316
+
317
+ let written = 0;
318
+ if (apply && ops.length > 0) {
319
+ const result = await UsageMeter.bulkWrite(ops);
320
+ written = result.modifiedCount + result.upsertedCount;
321
+ }
322
+
323
+ const expectedRecord: Record<string, Record<MeterKey, number>> = {};
324
+ for (const subjectId of subjects) {
325
+ const counts = expected.get(subjectId)!;
326
+ expectedRecord[subjectId] = {
327
+ storageBytes: counts.storageBytes,
328
+ trackedTickers: counts.trackedTickers.size,
329
+ seats: counts.seats.size
330
+ } as Record<MeterKey, number>;
331
+ }
332
+
333
+ return {
334
+ applied: apply,
335
+ scannedUsers,
336
+ subjects: expected.size,
337
+ expected: expectedRecord,
338
+ changes,
339
+ written,
340
+ orphanedMeters,
341
+ warnings
342
+ };
343
+ }
344
+
345
+ /**
346
+ * Run a reconciliation and log the outcome. The scheduled entry point.
347
+ *
348
+ * Never throws: this runs from a cron callback with no caller to catch it, and
349
+ * an unhandled rejection there takes the process down over a housekeeping job.
350
+ */
351
+ export async function runUsageReconciliation(): Promise<ReconcileReport | null> {
352
+ const startedAt = Date.now();
353
+ try {
354
+ const report = await reconcileUsageMeters({ apply: true });
355
+
356
+ // A correction is drift that already happened — the counter had gone wrong
357
+ // and users were being metered against a wrong number until this ran.
358
+ // Logged at `warn` so a persistent leak in consumeQuota/releaseQuota is
359
+ // visible as a recurring signal rather than buried in an info line.
360
+ const level = report.changes.length > 0 ? 'warn' : 'info';
361
+ logger[level]('entitlement.reconciled', {
362
+ event: 'entitlement.reconciled',
363
+ subjects: report.subjects,
364
+ corrected: report.changes.length,
365
+ written: report.written,
366
+ orphaned: report.orphanedMeters.length,
367
+ warnings: report.warnings.length,
368
+ durationMs: Date.now() - startedAt,
369
+ changes: report.changes.slice(0, 20)
370
+ });
371
+
372
+ for (const warning of report.warnings.slice(0, 20)) {
373
+ logger.warn('entitlement.reconcile_warning', { event: 'entitlement.reconcile_warning', detail: warning });
374
+ }
375
+
376
+ return report;
377
+ } catch (err) {
378
+ logger.error('Usage meter reconciliation failed', {
379
+ error: (err as Error)?.message,
380
+ durationMs: Date.now() - startedAt
381
+ });
382
+ return null;
383
+ }
384
+ }
@@ -19,12 +19,3 @@ export interface Entitlements {
19
19
  */
20
20
  stale?: boolean;
21
21
  }
22
-
23
- /**
24
- * `off` — no metering, no blocking. `observe` — record usage and log what
25
- * would have been blocked, block nothing. `enforce` — block.
26
- *
27
- * Observe is the default because the configured limits start as guesses; two
28
- * weeks of `entitlement.would_block` volume is what turns them into numbers.
29
- */
30
- export type EntitlementsMode = 'off' | 'observe' | 'enforce';
@@ -50,7 +50,7 @@ afterAll(async () => {
50
50
  });
51
51
 
52
52
  beforeEach(async () => {
53
- process.env = { ...ORIGINAL_ENV, ENTITLEMENTS_MODE: 'enforce' };
53
+ process.env = { ...ORIGINAL_ENV };
54
54
  await UsageMeter.deleteMany({});
55
55
  // The unique index is what makes the concurrent upserts below collapse onto
56
56
  // one document instead of racing into duplicates.
@@ -136,32 +136,34 @@ describe('consumeQuota', () => {
136
136
  });
137
137
  });
138
138
 
139
- describe('modes', () => {
140
- it('records nothing and blocks nothing when off', async () => {
141
- process.env.ENTITLEMENTS_MODE = 'off';
139
+ describe('rejection logging', () => {
140
+ it('logs the block alongside throwing, so an operator sees what a user saw', async () => {
142
141
  await setUsage('user@example.com', 'aiJobs', 5);
143
142
 
144
143
  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);
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
+ );
148
151
  });
149
152
 
150
- it('keeps the increment and logs would_block instead of rejecting when observing', async () => {
151
- process.env.ENTITLEMENTS_MODE = 'observe';
153
+ it('flags a block that came from stale fallback limits rather than the real plan', async () => {
152
154
  await setUsage('user@example.com', 'aiJobs', 5);
153
155
 
154
156
  await expect(
155
- consumeQuota({ subjectId: 'user@example.com', meter: 'aiJobs', entitlements: entitlements(), route: '/ai/dispatch' })
156
- ).resolves.toBeUndefined();
157
+ consumeQuota({
158
+ subjectId: 'user@example.com',
159
+ meter: 'aiJobs',
160
+ entitlements: entitlements({ stale: true })
161
+ })
162
+ ).rejects.toMatchObject({ statusCode: 402 });
157
163
 
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
- );
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 }));
165
167
  });
166
168
  });
167
169
 
@@ -175,28 +177,22 @@ describe('checkQuota', () => {
175
177
  expect(await getUsage('user@example.com', 'storageBytes')).toBe(400);
176
178
  });
177
179
 
178
- it('logs would_block instead of rejecting when observing', async () => {
179
- process.env.ENTITLEMENTS_MODE = 'observe';
180
+ it('rejects, and writes nothing, when the requested amount would overrun', async () => {
180
181
  await setUsage('user@example.com', 'storageBytes', 400);
181
182
 
182
183
  await expect(
183
184
  checkQuota({ subjectId: 'user@example.com', meter: 'storageBytes', amount: 601, entitlements: entitlements() })
184
- ).resolves.toBeUndefined();
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);
185
190
  expect(logger.info).toHaveBeenCalledWith(
186
- 'entitlement.would_block',
191
+ 'entitlement.blocked',
187
192
  expect.objectContaining({ meter: 'storageBytes', used: 400, limit: 1000 })
188
193
  );
189
194
  });
190
195
 
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
196
  it('skips the usage read entirely for an unlimited meter', async () => {
201
197
  const unlimited = entitlements({ limits: normalizeLimits({ meters: { storageBytes: UNLIMITED } }, 'free') });
202
198
 
@@ -3,7 +3,6 @@ import { PaymentRequiredError } from '../errors/HttpError';
3
3
  import { UsageMeter, currentPeriod } from './UsageMeter';
4
4
  import { MeterKey, METER_KEYS, UNLIMITED, fitsWithin } from './definitions';
5
5
  import { Entitlements } from './types';
6
- import { getEntitlementsMode } from './mode';
7
6
 
8
7
  export interface QuotaRequest {
9
8
  /** Who the usage is billed to — see `IUsageMeter.subjectId`. */
@@ -12,7 +11,7 @@ export interface QuotaRequest {
12
11
  /** How much this request wants. Bytes for `storageBytes`, otherwise a count. */
13
12
  amount?: number;
14
13
  entitlements: Entitlements;
15
- /** Attached to the observe-mode log line so a would-block can be traced to a route. */
14
+ /** Attached to the rejection log so a 402 can be traced back to a route. */
16
15
  route?: string;
17
16
  }
18
17
 
@@ -52,9 +51,15 @@ function quotaError(req: QuotaRequest, used: number, limit: number): PaymentRequ
52
51
  });
53
52
  }
54
53
 
55
- function logWouldBlock(req: QuotaRequest, used: number, limit: number): void {
56
- logger.info('entitlement.would_block', {
57
- event: 'entitlement.would_block',
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',
58
63
  meter: req.meter,
59
64
  subjectId: req.subjectId,
60
65
  amount: req.amount ?? 1,
@@ -75,19 +80,13 @@ function logWouldBlock(req: QuotaRequest, used: number, limit: number): void {
75
80
  * the underlying data. Use `consumeQuota` where the count is the only record.
76
81
  */
77
82
  export async function checkQuota(req: QuotaRequest): Promise<void> {
78
- const mode = getEntitlementsMode();
79
- if (mode === 'off') return;
80
-
81
83
  const limit = req.entitlements.limits.meters[req.meter] ?? UNLIMITED;
82
84
  if (limit === UNLIMITED) return;
83
85
 
84
86
  const used = await getUsage(req.subjectId, req.meter);
85
87
  if (fitsWithin(limit, used, req.amount ?? 1)) return;
86
88
 
87
- if (mode === 'observe') {
88
- logWouldBlock(req, used, limit);
89
- return;
90
- }
89
+ logBlocked(req, used, limit);
91
90
  throw quotaError(req, used, limit);
92
91
  }
93
92
 
@@ -106,9 +105,6 @@ export async function checkQuota(req: QuotaRequest): Promise<void> {
106
105
  * who cannot upload despite being well under their limit.
107
106
  */
108
107
  export async function consumeQuota(req: QuotaRequest): Promise<void> {
109
- const mode = getEntitlementsMode();
110
- if (mode === 'off') return;
111
-
112
108
  const amount = req.amount ?? 1;
113
109
  const limit = req.entitlements.limits.meters[req.meter] ?? UNLIMITED;
114
110
  const period = currentPeriod(req.meter);
@@ -124,13 +120,7 @@ export async function consumeQuota(req: QuotaRequest): Promise<void> {
124
120
 
125
121
  const usedBefore = usage.count - amount;
126
122
 
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
-
123
+ logBlocked(req, usedBefore, limit);
134
124
  await releaseQuota(subjectId, req.meter, amount);
135
125
  throw quotaError(req, usedBefore, limit);
136
126
  }