@wopr-network/platform-core 1.0.4 → 1.0.6
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/.env.example +5 -0
- package/dist/account/deletion-executor-repository.d.ts +17 -0
- package/dist/account/deletion-executor-repository.js +41 -0
- package/dist/account/deletion-executor-repository.test.d.ts +1 -0
- package/dist/account/deletion-executor-repository.test.js +89 -0
- package/dist/account/deletion-repository.d.ts +19 -0
- package/dist/account/deletion-repository.js +62 -0
- package/dist/account/deletion-repository.test.d.ts +1 -0
- package/dist/account/deletion-repository.test.js +85 -0
- package/dist/account/export-repository.d.ts +21 -0
- package/dist/account/export-repository.js +77 -0
- package/dist/account/export-repository.test.d.ts +1 -0
- package/dist/account/export-repository.test.js +109 -0
- package/dist/account/index.d.ts +4 -0
- package/dist/account/index.js +3 -0
- package/dist/account/repository-types.d.ts +38 -0
- package/dist/account/repository-types.js +4 -0
- package/dist/auth/auth-route-handler.test.d.ts +1 -0
- package/dist/auth/auth-route-handler.test.js +191 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/metering/emitter.d.ts +2 -2
- package/dist/metering/emitter.js +4 -4
- package/dist/metering/emitter.test.js +7 -7
- package/dist/metering/metering.test.js +73 -73
- package/dist/metering/wal.d.ts +6 -4
- package/dist/metering/wal.js +14 -10
- package/dist/metering/wal.test.js +21 -0
- package/dist/security/redirect-allowlist.js +20 -1
- package/dist/security/redirect-allowlist.test.js +34 -0
- package/package.json +1 -1
- package/src/account/deletion-executor-repository.test.ts +109 -0
- package/src/account/deletion-executor-repository.ts +58 -0
- package/src/account/deletion-repository.test.ts +103 -0
- package/src/account/deletion-repository.ts +82 -0
- package/src/account/export-repository.test.ts +135 -0
- package/src/account/export-repository.ts +101 -0
- package/src/account/index.ts +14 -0
- package/src/account/repository-types.ts +46 -0
- package/src/auth/auth-route-handler.test.ts +243 -0
- package/src/index.ts +3 -0
- package/src/metering/emitter.test.ts +7 -7
- package/src/metering/emitter.ts +5 -5
- package/src/metering/metering.test.ts +75 -73
- package/src/metering/wal.test.ts +26 -0
- package/src/metering/wal.ts +14 -10
- package/src/security/redirect-allowlist.test.ts +41 -0
- package/src/security/redirect-allowlist.ts +19 -1
|
@@ -117,14 +117,14 @@ describe("MeterEmitter", () => {
|
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
119
|
it("buffers events without writing until flush", async () => {
|
|
120
|
-
emitter.emit(makeEvent());
|
|
120
|
+
await emitter.emit(makeEvent());
|
|
121
121
|
expect(emitter.pending).toBe(1);
|
|
122
122
|
const rows = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents))[0];
|
|
123
123
|
expect(rows?.cnt).toBe(0);
|
|
124
124
|
});
|
|
125
125
|
it("flush writes buffered events to the database", async () => {
|
|
126
|
-
emitter.emit(makeEvent());
|
|
127
|
-
emitter.emit(makeEvent({ tenant: "tenant-2" }));
|
|
126
|
+
await emitter.emit(makeEvent());
|
|
127
|
+
await emitter.emit(makeEvent({ tenant: "tenant-2" }));
|
|
128
128
|
const flushed = await emitter.flush();
|
|
129
129
|
expect(flushed).toBe(2);
|
|
130
130
|
expect(emitter.pending).toBe(0);
|
|
@@ -142,7 +142,7 @@ describe("MeterEmitter", () => {
|
|
|
142
142
|
sessionId: "sess-123",
|
|
143
143
|
duration: 5000,
|
|
144
144
|
});
|
|
145
|
-
emitter.emit(event);
|
|
145
|
+
await emitter.emit(event);
|
|
146
146
|
await emitter.flush();
|
|
147
147
|
const rows = await emitter.queryEvents("t-abc");
|
|
148
148
|
expect(rows).toHaveLength(1);
|
|
@@ -156,15 +156,15 @@ describe("MeterEmitter", () => {
|
|
|
156
156
|
expect(rows[0].duration).toBe(5000);
|
|
157
157
|
});
|
|
158
158
|
it("handles null optional fields", async () => {
|
|
159
|
-
emitter.emit(makeEvent({ sessionId: undefined, duration: undefined }));
|
|
159
|
+
await emitter.emit(makeEvent({ sessionId: undefined, duration: undefined }));
|
|
160
160
|
await emitter.flush();
|
|
161
161
|
const rows = await emitter.queryEvents("tenant-1");
|
|
162
162
|
expect(rows[0].session_id).toBeNull();
|
|
163
163
|
expect(rows[0].duration).toBeNull();
|
|
164
164
|
});
|
|
165
165
|
it("generates unique IDs for each event", async () => {
|
|
166
|
-
emitter.emit(makeEvent());
|
|
167
|
-
emitter.emit(makeEvent());
|
|
166
|
+
await emitter.emit(makeEvent());
|
|
167
|
+
await emitter.emit(makeEvent());
|
|
168
168
|
await emitter.flush();
|
|
169
169
|
const rows = await emitter.queryEvents("tenant-1");
|
|
170
170
|
expect(rows).toHaveLength(2);
|
|
@@ -172,25 +172,25 @@ describe("MeterEmitter", () => {
|
|
|
172
172
|
});
|
|
173
173
|
it("auto-flushes when batch size is reached", async () => {
|
|
174
174
|
const smallBatch = makeEmitter(db, { flushIntervalMs: 60_000, batchSize: 3 });
|
|
175
|
-
smallBatch.emit(makeEvent());
|
|
176
|
-
smallBatch.emit(makeEvent());
|
|
175
|
+
await smallBatch.emit(makeEvent());
|
|
176
|
+
await smallBatch.emit(makeEvent());
|
|
177
177
|
// Third event triggers auto-flush.
|
|
178
|
-
smallBatch.emit(makeEvent());
|
|
178
|
+
await smallBatch.emit(makeEvent());
|
|
179
179
|
const rows = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents))[0];
|
|
180
180
|
expect(rows?.cnt).toBe(3);
|
|
181
181
|
expect(smallBatch.pending).toBe(0);
|
|
182
182
|
smallBatch.close();
|
|
183
183
|
});
|
|
184
184
|
it("close flushes remaining events", async () => {
|
|
185
|
-
emitter.emit(makeEvent());
|
|
186
|
-
emitter.emit(makeEvent());
|
|
185
|
+
await emitter.emit(makeEvent());
|
|
186
|
+
await emitter.emit(makeEvent());
|
|
187
187
|
emitter.close();
|
|
188
188
|
const rows = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents))[0];
|
|
189
189
|
expect(rows?.cnt).toBe(2);
|
|
190
190
|
});
|
|
191
191
|
it("ignores events after close", async () => {
|
|
192
192
|
emitter.close();
|
|
193
|
-
emitter.emit(makeEvent());
|
|
193
|
+
await emitter.emit(makeEvent());
|
|
194
194
|
expect(emitter.pending).toBe(0);
|
|
195
195
|
});
|
|
196
196
|
it("re-adds events to buffer on flush failure", async () => {
|
|
@@ -202,8 +202,8 @@ describe("MeterEmitter", () => {
|
|
|
202
202
|
walPath: TEST_WAL_PATH,
|
|
203
203
|
dlqPath: TEST_DLQ_PATH,
|
|
204
204
|
});
|
|
205
|
-
localEmitter.emit(makeEvent());
|
|
206
|
-
localEmitter.emit(makeEvent());
|
|
205
|
+
await localEmitter.emit(makeEvent());
|
|
206
|
+
await localEmitter.emit(makeEvent());
|
|
207
207
|
expect(localEmitter.pending).toBe(2);
|
|
208
208
|
await localPool.close();
|
|
209
209
|
// Should not throw even though db is closed.
|
|
@@ -214,9 +214,9 @@ describe("MeterEmitter", () => {
|
|
|
214
214
|
localEmitter.close();
|
|
215
215
|
});
|
|
216
216
|
it("queryEvents returns events for a specific tenant", async () => {
|
|
217
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
218
|
-
emitter.emit(makeEvent({ tenant: "t-2" }));
|
|
219
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
217
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
218
|
+
await emitter.emit(makeEvent({ tenant: "t-2" }));
|
|
219
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
220
220
|
await emitter.flush();
|
|
221
221
|
const t1Events = await emitter.queryEvents("t-1");
|
|
222
222
|
expect(t1Events).toHaveLength(2);
|
|
@@ -248,9 +248,9 @@ describe("MeterEmitter - concurrent multi-provider sessions", () => {
|
|
|
248
248
|
});
|
|
249
249
|
it("groups multiple providers under one sessionId", async () => {
|
|
250
250
|
const sessionId = "voice-session-1";
|
|
251
|
-
emitter.emit(makeEvent({ capability: "stt", provider: "deepgram", sessionId }));
|
|
252
|
-
emitter.emit(makeEvent({ capability: "chat", provider: "openai", sessionId }));
|
|
253
|
-
emitter.emit(makeEvent({ capability: "tts", provider: "elevenlabs", sessionId }));
|
|
251
|
+
await emitter.emit(makeEvent({ capability: "stt", provider: "deepgram", sessionId }));
|
|
252
|
+
await emitter.emit(makeEvent({ capability: "chat", provider: "openai", sessionId }));
|
|
253
|
+
await emitter.emit(makeEvent({ capability: "tts", provider: "elevenlabs", sessionId }));
|
|
254
254
|
await emitter.flush();
|
|
255
255
|
const rows = await db.select().from(meterEvents).where(eq(meterEvents.sessionId, sessionId));
|
|
256
256
|
expect(rows).toHaveLength(3);
|
|
@@ -260,9 +260,9 @@ describe("MeterEmitter - concurrent multi-provider sessions", () => {
|
|
|
260
260
|
expect(providers).toEqual(["deepgram", "elevenlabs", "openai"]);
|
|
261
261
|
});
|
|
262
262
|
it("handles events from different sessions simultaneously", async () => {
|
|
263
|
-
emitter.emit(makeEvent({ sessionId: "sess-a", capability: "stt" }));
|
|
264
|
-
emitter.emit(makeEvent({ sessionId: "sess-b", capability: "stt" }));
|
|
265
|
-
emitter.emit(makeEvent({ sessionId: "sess-a", capability: "tts" }));
|
|
263
|
+
await emitter.emit(makeEvent({ sessionId: "sess-a", capability: "stt" }));
|
|
264
|
+
await emitter.emit(makeEvent({ sessionId: "sess-b", capability: "stt" }));
|
|
265
|
+
await emitter.emit(makeEvent({ sessionId: "sess-a", capability: "tts" }));
|
|
266
266
|
await emitter.flush();
|
|
267
267
|
const sessA = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents).where(eq(meterEvents.sessionId, "sess-a")))[0];
|
|
268
268
|
const sessB = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents).where(eq(meterEvents.sessionId, "sess-b")))[0];
|
|
@@ -296,13 +296,13 @@ describe("MeterAggregator", () => {
|
|
|
296
296
|
it("aggregates events from completed windows", async () => {
|
|
297
297
|
// Insert events in a past window.
|
|
298
298
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
299
|
-
emitter.emit(makeEvent({
|
|
299
|
+
await emitter.emit(makeEvent({
|
|
300
300
|
tenant: "t-1",
|
|
301
301
|
cost: Credit.fromDollars(0.01),
|
|
302
302
|
charge: Credit.fromDollars(0.02),
|
|
303
303
|
timestamp: pastWindow + 100,
|
|
304
304
|
}));
|
|
305
|
-
emitter.emit(makeEvent({
|
|
305
|
+
await emitter.emit(makeEvent({
|
|
306
306
|
tenant: "t-1",
|
|
307
307
|
cost: Credit.fromDollars(0.03),
|
|
308
308
|
charge: Credit.fromDollars(0.06),
|
|
@@ -319,9 +319,9 @@ describe("MeterAggregator", () => {
|
|
|
319
319
|
});
|
|
320
320
|
it("groups by tenant, capability, and provider", async () => {
|
|
321
321
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
322
|
-
emitter.emit(makeEvent({ tenant: "t-1", capability: "embeddings", provider: "openai", timestamp: pastWindow + 10 }));
|
|
323
|
-
emitter.emit(makeEvent({ tenant: "t-1", capability: "voice", provider: "deepgram", timestamp: pastWindow + 20 }));
|
|
324
|
-
emitter.emit(makeEvent({ tenant: "t-2", capability: "embeddings", provider: "openai", timestamp: pastWindow + 30 }));
|
|
322
|
+
await emitter.emit(makeEvent({ tenant: "t-1", capability: "embeddings", provider: "openai", timestamp: pastWindow + 10 }));
|
|
323
|
+
await emitter.emit(makeEvent({ tenant: "t-1", capability: "voice", provider: "deepgram", timestamp: pastWindow + 20 }));
|
|
324
|
+
await emitter.emit(makeEvent({ tenant: "t-2", capability: "embeddings", provider: "openai", timestamp: pastWindow + 30 }));
|
|
325
325
|
await emitter.flush();
|
|
326
326
|
const count = await aggregator.aggregate();
|
|
327
327
|
expect(count).toBe(3); // Three distinct groups.
|
|
@@ -332,7 +332,7 @@ describe("MeterAggregator", () => {
|
|
|
332
332
|
});
|
|
333
333
|
it("does not aggregate the current (incomplete) window", async () => {
|
|
334
334
|
// Insert an event in the *current* window.
|
|
335
|
-
emitter.emit(makeEvent({ timestamp: Date.now() }));
|
|
335
|
+
await emitter.emit(makeEvent({ timestamp: Date.now() }));
|
|
336
336
|
await emitter.flush();
|
|
337
337
|
const count = await aggregator.aggregate();
|
|
338
338
|
// If there are no events in prior windows, nothing to aggregate.
|
|
@@ -343,7 +343,7 @@ describe("MeterAggregator", () => {
|
|
|
343
343
|
});
|
|
344
344
|
it("is idempotent - does not double-aggregate", async () => {
|
|
345
345
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
346
|
-
emitter.emit(makeEvent({
|
|
346
|
+
await emitter.emit(makeEvent({
|
|
347
347
|
tenant: "t-1",
|
|
348
348
|
cost: Credit.fromDollars(0.01),
|
|
349
349
|
charge: Credit.fromDollars(0.02),
|
|
@@ -358,13 +358,13 @@ describe("MeterAggregator", () => {
|
|
|
358
358
|
});
|
|
359
359
|
it("aggregates duration for session-based capabilities", async () => {
|
|
360
360
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
361
|
-
emitter.emit(makeEvent({
|
|
361
|
+
await emitter.emit(makeEvent({
|
|
362
362
|
tenant: "t-1",
|
|
363
363
|
capability: "voice",
|
|
364
364
|
duration: 3000,
|
|
365
365
|
timestamp: pastWindow + 10,
|
|
366
366
|
}));
|
|
367
|
-
emitter.emit(makeEvent({
|
|
367
|
+
await emitter.emit(makeEvent({
|
|
368
368
|
tenant: "t-1",
|
|
369
369
|
capability: "voice",
|
|
370
370
|
duration: 5000,
|
|
@@ -382,13 +382,13 @@ describe("MeterAggregator", () => {
|
|
|
382
382
|
});
|
|
383
383
|
it("getTenantTotal returns aggregate totals", async () => {
|
|
384
384
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
385
|
-
emitter.emit(makeEvent({
|
|
385
|
+
await emitter.emit(makeEvent({
|
|
386
386
|
tenant: "t-1",
|
|
387
387
|
cost: Credit.fromDollars(0.01),
|
|
388
388
|
charge: Credit.fromDollars(0.02),
|
|
389
389
|
timestamp: pastWindow + 10,
|
|
390
390
|
}));
|
|
391
|
-
emitter.emit(makeEvent({
|
|
391
|
+
await emitter.emit(makeEvent({
|
|
392
392
|
tenant: "t-1",
|
|
393
393
|
cost: Credit.fromDollars(0.05),
|
|
394
394
|
charge: Credit.fromDollars(0.1),
|
|
@@ -412,8 +412,8 @@ describe("MeterAggregator", () => {
|
|
|
412
412
|
const now = Date.now();
|
|
413
413
|
const twoWindowsAgo = Math.floor(now / WINDOW) * WINDOW - 2 * WINDOW;
|
|
414
414
|
const oneWindowAgo = Math.floor(now / WINDOW) * WINDOW - WINDOW;
|
|
415
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: twoWindowsAgo + 10 }));
|
|
416
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: oneWindowAgo + 10 }));
|
|
415
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: twoWindowsAgo + 10 }));
|
|
416
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: oneWindowAgo + 10 }));
|
|
417
417
|
await emitter.flush();
|
|
418
418
|
// Aggregate both windows.
|
|
419
419
|
await aggregator.aggregate(twoWindowsAgo + WINDOW + 1);
|
|
@@ -454,7 +454,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
454
454
|
const now = Date.now();
|
|
455
455
|
const threeWindowsAgo = Math.floor(now / WINDOW) * WINDOW - 3 * WINDOW;
|
|
456
456
|
// Place one event 3 windows ago; windows 2-ago and 1-ago are empty.
|
|
457
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: threeWindowsAgo + 10 }));
|
|
457
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: threeWindowsAgo + 10 }));
|
|
458
458
|
await emitter.flush();
|
|
459
459
|
await aggregator.aggregate(now);
|
|
460
460
|
// The event window should produce a real summary.
|
|
@@ -473,7 +473,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
473
473
|
it("handles single-event windows correctly", async () => {
|
|
474
474
|
const now = Date.now();
|
|
475
475
|
const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
|
|
476
|
-
emitter.emit(makeEvent({
|
|
476
|
+
await emitter.emit(makeEvent({
|
|
477
477
|
tenant: "t-1",
|
|
478
478
|
cost: Credit.fromDollars(0.123),
|
|
479
479
|
charge: Credit.fromDollars(0.456),
|
|
@@ -492,7 +492,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
492
492
|
const now = Date.now();
|
|
493
493
|
const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
|
|
494
494
|
// Event at the exact start of the window (timestamp === windowStart).
|
|
495
|
-
emitter.emit(makeEvent({
|
|
495
|
+
await emitter.emit(makeEvent({
|
|
496
496
|
tenant: "t-1",
|
|
497
497
|
cost: Credit.fromDollars(0.01),
|
|
498
498
|
charge: Credit.fromDollars(0.02),
|
|
@@ -511,7 +511,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
511
511
|
const twoWindowsAgo = Math.floor(now / WINDOW) * WINDOW - 2 * WINDOW;
|
|
512
512
|
const oneWindowAgo = twoWindowsAgo + WINDOW;
|
|
513
513
|
// Event at the exact boundary (end of window 2-ago = start of window 1-ago).
|
|
514
|
-
emitter.emit(makeEvent({
|
|
514
|
+
await emitter.emit(makeEvent({
|
|
515
515
|
tenant: "t-1",
|
|
516
516
|
cost: Credit.fromDollars(0.01),
|
|
517
517
|
charge: Credit.fromDollars(0.02),
|
|
@@ -529,14 +529,14 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
529
529
|
const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
|
|
530
530
|
const tenants = ["alpha", "beta", "gamma"];
|
|
531
531
|
for (const t of tenants) {
|
|
532
|
-
emitter.emit(makeEvent({
|
|
532
|
+
await emitter.emit(makeEvent({
|
|
533
533
|
tenant: t,
|
|
534
534
|
cost: Credit.fromDollars(0.01),
|
|
535
535
|
charge: Credit.fromDollars(0.02),
|
|
536
536
|
capability: "chat",
|
|
537
537
|
timestamp: pastWindow + 10,
|
|
538
538
|
}));
|
|
539
|
-
emitter.emit(makeEvent({
|
|
539
|
+
await emitter.emit(makeEvent({
|
|
540
540
|
tenant: t,
|
|
541
541
|
cost: Credit.fromDollars(0.03),
|
|
542
542
|
charge: Credit.fromDollars(0.06),
|
|
@@ -560,19 +560,19 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
560
560
|
const threeWindowsAgo = Math.floor(now / WINDOW) * WINDOW - 3 * WINDOW;
|
|
561
561
|
const twoWindowsAgo = threeWindowsAgo + WINDOW;
|
|
562
562
|
const oneWindowAgo = twoWindowsAgo + WINDOW;
|
|
563
|
-
emitter.emit(makeEvent({
|
|
563
|
+
await emitter.emit(makeEvent({
|
|
564
564
|
tenant: "t-1",
|
|
565
565
|
cost: Credit.fromDollars(0.01),
|
|
566
566
|
charge: Credit.fromDollars(0.02),
|
|
567
567
|
timestamp: threeWindowsAgo + 100,
|
|
568
568
|
}));
|
|
569
|
-
emitter.emit(makeEvent({
|
|
569
|
+
await emitter.emit(makeEvent({
|
|
570
570
|
tenant: "t-1",
|
|
571
571
|
cost: Credit.fromDollars(0.03),
|
|
572
572
|
charge: Credit.fromDollars(0.06),
|
|
573
573
|
timestamp: twoWindowsAgo + 100,
|
|
574
574
|
}));
|
|
575
|
-
emitter.emit(makeEvent({
|
|
575
|
+
await emitter.emit(makeEvent({
|
|
576
576
|
tenant: "t-1",
|
|
577
577
|
cost: Credit.fromDollars(0.05),
|
|
578
578
|
charge: Credit.fromDollars(0.1),
|
|
@@ -660,7 +660,7 @@ describe("MeterAggregator - billing accuracy", () => {
|
|
|
660
660
|
const expectedCostRaw = events.reduce((s, e) => s + e.cost.toRaw(), 0);
|
|
661
661
|
const expectedChargeRaw = events.reduce((s, e) => s + e.charge.toRaw(), 0);
|
|
662
662
|
for (const e of events)
|
|
663
|
-
emitter.emit(e);
|
|
663
|
+
await emitter.emit(e);
|
|
664
664
|
await emitter.flush();
|
|
665
665
|
await aggregator.aggregate(now);
|
|
666
666
|
const total = await aggregator.getTenantTotal("billing-test", 0);
|
|
@@ -672,21 +672,21 @@ describe("MeterAggregator - billing accuracy", () => {
|
|
|
672
672
|
const WINDOW = 60_000;
|
|
673
673
|
const now = Date.now();
|
|
674
674
|
const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
|
|
675
|
-
emitter.emit(makeEvent({
|
|
675
|
+
await emitter.emit(makeEvent({
|
|
676
676
|
tenant: "t-1",
|
|
677
677
|
cost: Credit.fromDollars(0.1),
|
|
678
678
|
charge: Credit.fromDollars(0.2),
|
|
679
679
|
capability: "chat",
|
|
680
680
|
timestamp: pastWindow + 10,
|
|
681
681
|
}));
|
|
682
|
-
emitter.emit(makeEvent({
|
|
682
|
+
await emitter.emit(makeEvent({
|
|
683
683
|
tenant: "t-1",
|
|
684
684
|
cost: Credit.fromDollars(0.05),
|
|
685
685
|
charge: Credit.fromDollars(0.1),
|
|
686
686
|
capability: "embeddings",
|
|
687
687
|
timestamp: pastWindow + 20,
|
|
688
688
|
}));
|
|
689
|
-
emitter.emit(makeEvent({
|
|
689
|
+
await emitter.emit(makeEvent({
|
|
690
690
|
tenant: "t-1",
|
|
691
691
|
cost: Credit.fromDollars(0.15),
|
|
692
692
|
charge: Credit.fromDollars(0.3),
|
|
@@ -728,7 +728,7 @@ describe("MeterEmitter - edge cases", () => {
|
|
|
728
728
|
});
|
|
729
729
|
it("handles large batch of events", async () => {
|
|
730
730
|
for (let i = 0; i < 200; i++) {
|
|
731
|
-
emitter.emit(makeEvent({
|
|
731
|
+
await emitter.emit(makeEvent({
|
|
732
732
|
tenant: "bulk-tenant",
|
|
733
733
|
cost: Credit.fromDollars(0.001),
|
|
734
734
|
charge: Credit.fromDollars(0.002),
|
|
@@ -740,7 +740,7 @@ describe("MeterEmitter - edge cases", () => {
|
|
|
740
740
|
expect(rows?.cnt).toBe(200);
|
|
741
741
|
});
|
|
742
742
|
it("handles zero-cost events", async () => {
|
|
743
|
-
emitter.emit(makeEvent({ tenant: "free-tier", cost: Credit.ZERO, charge: Credit.ZERO }));
|
|
743
|
+
await emitter.emit(makeEvent({ tenant: "free-tier", cost: Credit.ZERO, charge: Credit.ZERO }));
|
|
744
744
|
await emitter.flush();
|
|
745
745
|
const rows = await emitter.queryEvents("free-tier");
|
|
746
746
|
expect(rows).toHaveLength(1);
|
|
@@ -749,9 +749,9 @@ describe("MeterEmitter - edge cases", () => {
|
|
|
749
749
|
});
|
|
750
750
|
it("preserves event ordering within a tenant", async () => {
|
|
751
751
|
const base = 1700000000000;
|
|
752
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 300 }));
|
|
753
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 100 }));
|
|
754
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 200 }));
|
|
752
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 300 }));
|
|
753
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 100 }));
|
|
754
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 200 }));
|
|
755
755
|
await emitter.flush();
|
|
756
756
|
// queryEvents orders by timestamp DESC.
|
|
757
757
|
const rows = await emitter.queryEvents("t-1");
|
|
@@ -761,11 +761,11 @@ describe("MeterEmitter - edge cases", () => {
|
|
|
761
761
|
expect(rows[2].timestamp).toBe(base + 100);
|
|
762
762
|
});
|
|
763
763
|
it("handles multiple flushes without losing events", async () => {
|
|
764
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
764
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
765
765
|
await emitter.flush();
|
|
766
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
766
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
767
767
|
await emitter.flush();
|
|
768
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
768
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
769
769
|
await emitter.flush();
|
|
770
770
|
const rows = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents).where(eq(meterEvents.tenant, "t-1")))[0];
|
|
771
771
|
expect(rows?.cnt).toBe(3);
|
|
@@ -776,13 +776,13 @@ describe("append-only guarantee", () => {
|
|
|
776
776
|
it("meter_events table has no UPDATE or DELETE operations in emitter", async () => {
|
|
777
777
|
const { db, pool } = await createTestDb();
|
|
778
778
|
const emitter = makeEmitter(db, { flushIntervalMs: 60_000 });
|
|
779
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
779
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
780
780
|
await emitter.flush();
|
|
781
781
|
// Verify the event exists.
|
|
782
782
|
const before = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents))[0];
|
|
783
783
|
expect(before?.cnt).toBe(1);
|
|
784
784
|
// Emit more -- never replaces.
|
|
785
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
785
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
786
786
|
await emitter.flush();
|
|
787
787
|
const after = (await db.select({ cnt: sql `COUNT(*)` }).from(meterEvents))[0];
|
|
788
788
|
expect(after?.cnt).toBe(2);
|
|
@@ -840,7 +840,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
840
840
|
}
|
|
841
841
|
});
|
|
842
842
|
it("writes events to WAL before buffering", async () => {
|
|
843
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
843
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
844
844
|
// WAL should exist immediately.
|
|
845
845
|
expect(existsSync(TEST_WAL_PATH)).toBe(true);
|
|
846
846
|
const content = readFileSync(TEST_WAL_PATH, "utf8");
|
|
@@ -851,14 +851,14 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
851
851
|
expect(event.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
|
852
852
|
});
|
|
853
853
|
it("clears WAL after successful flush", async () => {
|
|
854
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
854
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
855
855
|
expect(existsSync(TEST_WAL_PATH)).toBe(true);
|
|
856
856
|
await emitter.flush();
|
|
857
857
|
// WAL should not exist after successful flush.
|
|
858
858
|
expect(existsSync(TEST_WAL_PATH)).toBe(false);
|
|
859
859
|
});
|
|
860
860
|
it("moves events to DLQ after max retries", async () => {
|
|
861
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
861
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
862
862
|
// Close the database to force flush failures.
|
|
863
863
|
pool.close();
|
|
864
864
|
// Trigger max retries.
|
|
@@ -929,7 +929,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
929
929
|
});
|
|
930
930
|
describe("generic usage fields (WOP-512)", () => {
|
|
931
931
|
it("persists usage, tier, and metadata fields", async () => {
|
|
932
|
-
emitter.emit(makeEvent({
|
|
932
|
+
await emitter.emit(makeEvent({
|
|
933
933
|
tenant: "t-1",
|
|
934
934
|
capability: "tts",
|
|
935
935
|
provider: "elevenlabs",
|
|
@@ -945,7 +945,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
945
945
|
expect(JSON.parse(rows[0].metadata)).toEqual({ voice: "adam", model: "eleven_v2" });
|
|
946
946
|
});
|
|
947
947
|
it("handles null usage/tier/metadata (backwards compatibility)", async () => {
|
|
948
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
948
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
949
949
|
await emitter.flush();
|
|
950
950
|
const rows = await emitter.queryEvents("t-1");
|
|
951
951
|
expect(rows[0].usage_units).toBeNull();
|
|
@@ -954,25 +954,25 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
954
954
|
expect(rows[0].metadata).toBeNull();
|
|
955
955
|
});
|
|
956
956
|
it("works with multiple capability types in the same flush", async () => {
|
|
957
|
-
emitter.emit(makeEvent({
|
|
957
|
+
await emitter.emit(makeEvent({
|
|
958
958
|
capability: "tts",
|
|
959
959
|
provider: "elevenlabs",
|
|
960
960
|
usage: { units: 500, unitType: "characters" },
|
|
961
961
|
tier: "branded",
|
|
962
962
|
}));
|
|
963
|
-
emitter.emit(makeEvent({
|
|
963
|
+
await emitter.emit(makeEvent({
|
|
964
964
|
capability: "chat-completions",
|
|
965
965
|
provider: "openrouter",
|
|
966
966
|
usage: { units: 1500, unitType: "tokens" },
|
|
967
967
|
tier: "branded",
|
|
968
968
|
}));
|
|
969
|
-
emitter.emit(makeEvent({
|
|
969
|
+
await emitter.emit(makeEvent({
|
|
970
970
|
capability: "transcription",
|
|
971
971
|
provider: "self-hosted-whisper",
|
|
972
972
|
usage: { units: 120, unitType: "seconds" },
|
|
973
973
|
tier: "wopr",
|
|
974
974
|
}));
|
|
975
|
-
emitter.emit(makeEvent({
|
|
975
|
+
await emitter.emit(makeEvent({
|
|
976
976
|
capability: "image-generation",
|
|
977
977
|
provider: "replicate",
|
|
978
978
|
usage: { units: 2, unitType: "images" },
|
|
@@ -986,7 +986,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
986
986
|
expect(unitTypes).toEqual(["characters", "images", "seconds", "tokens"]);
|
|
987
987
|
});
|
|
988
988
|
it("BYOK tier records zero cost/charge with tier='byok'", async () => {
|
|
989
|
-
emitter.emit(makeEvent({
|
|
989
|
+
await emitter.emit(makeEvent({
|
|
990
990
|
cost: Credit.ZERO,
|
|
991
991
|
charge: Credit.ZERO,
|
|
992
992
|
capability: "chat-completions",
|
|
@@ -1005,7 +1005,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1005
1005
|
const WINDOW = 60_000; // 1 minute
|
|
1006
1006
|
const aggregator = new MeterAggregator(new DrizzleUsageSummaryRepository(db), { windowMs: WINDOW });
|
|
1007
1007
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
1008
|
-
emitter.emit(makeEvent({
|
|
1008
|
+
await emitter.emit(makeEvent({
|
|
1009
1009
|
tenant: "t-1",
|
|
1010
1010
|
cost: Credit.fromDollars(0.01),
|
|
1011
1011
|
charge: Credit.fromDollars(0.02),
|
|
@@ -1013,7 +1013,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1013
1013
|
usage: { units: 100, unitType: "tokens" },
|
|
1014
1014
|
tier: "branded",
|
|
1015
1015
|
}));
|
|
1016
|
-
emitter.emit(makeEvent({
|
|
1016
|
+
await emitter.emit(makeEvent({
|
|
1017
1017
|
tenant: "t-1",
|
|
1018
1018
|
cost: Credit.fromDollars(0.03),
|
|
1019
1019
|
charge: Credit.fromDollars(0.06),
|
|
@@ -1034,7 +1034,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1034
1034
|
tier: "wopr",
|
|
1035
1035
|
metadata: { foo: "bar" },
|
|
1036
1036
|
});
|
|
1037
|
-
emitter.emit(event);
|
|
1037
|
+
await emitter.emit(event);
|
|
1038
1038
|
// WAL should persist new fields
|
|
1039
1039
|
const walContent = readFileSync(TEST_WAL_PATH, "utf8");
|
|
1040
1040
|
const walEvent = JSON.parse(walContent.trim());
|
package/dist/metering/wal.d.ts
CHANGED
|
@@ -13,14 +13,16 @@ export declare class MeterWAL {
|
|
|
13
13
|
constructor(walPath: string);
|
|
14
14
|
private ensureDir;
|
|
15
15
|
/**
|
|
16
|
-
* Append an event to the WAL.
|
|
17
|
-
*
|
|
16
|
+
* Append an event to the WAL. Mutex-guarded to prevent TOCTOU races
|
|
17
|
+
* with remove(), which does a read-filter-rewrite that would clobber
|
|
18
|
+
* concurrent appends. Uses appendFileSync inside the lock for
|
|
19
|
+
* fail-closed crash safety (POSIX O_APPEND atomic write).
|
|
18
20
|
*/
|
|
19
21
|
append(event: MeterEvent & {
|
|
20
22
|
id?: string;
|
|
21
|
-
}): MeterEvent & {
|
|
23
|
+
}): Promise<MeterEvent & {
|
|
22
24
|
id: string;
|
|
23
|
-
}
|
|
25
|
+
}>;
|
|
24
26
|
/**
|
|
25
27
|
* Read all events from the WAL. Returns events in the order they were written.
|
|
26
28
|
* Skips malformed lines (defensive against incomplete writes).
|
package/dist/metering/wal.js
CHANGED
|
@@ -35,17 +35,21 @@ export class MeterWAL {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
* Append an event to the WAL.
|
|
39
|
-
*
|
|
38
|
+
* Append an event to the WAL. Mutex-guarded to prevent TOCTOU races
|
|
39
|
+
* with remove(), which does a read-filter-rewrite that would clobber
|
|
40
|
+
* concurrent appends. Uses appendFileSync inside the lock for
|
|
41
|
+
* fail-closed crash safety (POSIX O_APPEND atomic write).
|
|
40
42
|
*/
|
|
41
|
-
append(event) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
async append(event) {
|
|
44
|
+
return this.withLock(() => {
|
|
45
|
+
const eventWithId = {
|
|
46
|
+
...event,
|
|
47
|
+
id: event.id ?? crypto.randomUUID(),
|
|
48
|
+
};
|
|
49
|
+
const line = `${JSON.stringify(eventWithId)}\n`;
|
|
50
|
+
appendFileSync(this.walPath, line, { encoding: "utf8", flag: "a" });
|
|
51
|
+
return eventWithId;
|
|
52
|
+
});
|
|
49
53
|
}
|
|
50
54
|
/**
|
|
51
55
|
* Read all events from the WAL. Returns events in the order they were written.
|
|
@@ -125,6 +125,27 @@ describe("MeterWAL", () => {
|
|
|
125
125
|
expect(tenants).not.toContain("t-2");
|
|
126
126
|
expect(events).toHaveLength(2);
|
|
127
127
|
});
|
|
128
|
+
it("concurrent appends and remove are serialized without data loss", async () => {
|
|
129
|
+
// Seed 2 events to remove
|
|
130
|
+
const e1 = await wal.append(makeEvent({ tenant: "seed-1" }));
|
|
131
|
+
const e2 = await wal.append(makeEvent({ tenant: "seed-2" }));
|
|
132
|
+
// Fire remove + 3 concurrent appends
|
|
133
|
+
const removePromise = wal.remove(new Set([e1.id, e2.id]));
|
|
134
|
+
const a1 = wal.append(makeEvent({ tenant: "new-1" }));
|
|
135
|
+
const a2 = wal.append(makeEvent({ tenant: "new-2" }));
|
|
136
|
+
const a3 = wal.append(makeEvent({ tenant: "new-3" }));
|
|
137
|
+
await Promise.all([removePromise, a1, a2, a3]);
|
|
138
|
+
const events = wal.readAll();
|
|
139
|
+
const tenants = events.map((e) => e.tenant);
|
|
140
|
+
// Seeded events must be gone
|
|
141
|
+
expect(tenants).not.toContain("seed-1");
|
|
142
|
+
expect(tenants).not.toContain("seed-2");
|
|
143
|
+
// All 3 new appends must survive
|
|
144
|
+
expect(tenants).toContain("new-1");
|
|
145
|
+
expect(tenants).toContain("new-2");
|
|
146
|
+
expect(tenants).toContain("new-3");
|
|
147
|
+
expect(events).toHaveLength(3);
|
|
148
|
+
});
|
|
128
149
|
});
|
|
129
150
|
describe("MeterDLQ", () => {
|
|
130
151
|
let dlq;
|
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
const STATIC_ORIGINS = ["https://app.wopr.bot", "https://wopr.network"];
|
|
2
|
+
function parseExtraOrigins() {
|
|
3
|
+
const raw = process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
|
|
4
|
+
if (!raw)
|
|
5
|
+
return [];
|
|
6
|
+
return raw
|
|
7
|
+
.split(",")
|
|
8
|
+
.map((s) => s.trim())
|
|
9
|
+
.filter(Boolean)
|
|
10
|
+
.filter((entry) => {
|
|
11
|
+
try {
|
|
12
|
+
new URL(entry);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
console.warn(`[redirect-allowlist] Malformed entry in EXTRA_ALLOWED_REDIRECT_ORIGINS, skipping: ${entry}`);
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
2
21
|
function getAllowedOrigins() {
|
|
3
22
|
return [
|
|
4
23
|
...STATIC_ORIGINS,
|
|
5
24
|
...(process.env.NODE_ENV !== "production" ? ["http://localhost:3000", "http://localhost:3001"] : []),
|
|
6
25
|
...(process.env.PLATFORM_UI_URL ? [process.env.PLATFORM_UI_URL] : []),
|
|
7
|
-
...(process.env.NODE_ENV !== "production" ?
|
|
26
|
+
...(process.env.NODE_ENV !== "production" ? parseExtraOrigins() : []),
|
|
8
27
|
];
|
|
9
28
|
}
|
|
10
29
|
/**
|
|
@@ -34,6 +34,40 @@ describe("assertSafeRedirectUrl", () => {
|
|
|
34
34
|
it("rejects empty string", () => {
|
|
35
35
|
expect(() => assertSafeRedirectUrl("")).toThrow("Invalid redirect URL");
|
|
36
36
|
});
|
|
37
|
+
it("rejects https://example.com", () => {
|
|
38
|
+
expect(() => assertSafeRedirectUrl("https://example.com/callback")).toThrow("Invalid redirect URL");
|
|
39
|
+
});
|
|
40
|
+
describe("EXTRA_ALLOWED_REDIRECT_ORIGINS env-driven entries", () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.resetModules();
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
delete process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
|
|
46
|
+
vi.resetModules();
|
|
47
|
+
});
|
|
48
|
+
it("allows origins listed in EXTRA_ALLOWED_REDIRECT_ORIGINS", async () => {
|
|
49
|
+
process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot";
|
|
50
|
+
const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
|
|
51
|
+
expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
it("allows multiple comma-separated origins", async () => {
|
|
54
|
+
process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot,https://preview.wopr.bot";
|
|
55
|
+
const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
|
|
56
|
+
expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
|
|
57
|
+
expect(() => assertSafe("https://preview.wopr.bot/dashboard")).not.toThrow();
|
|
58
|
+
});
|
|
59
|
+
it("ignores empty/whitespace entries in comma-separated list", async () => {
|
|
60
|
+
process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot, , ,";
|
|
61
|
+
const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
|
|
62
|
+
expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
|
|
63
|
+
expect(() => assertSafe("https://evil.com/phishing")).toThrow("Invalid redirect URL");
|
|
64
|
+
});
|
|
65
|
+
it("defaults to empty when env var is unset", async () => {
|
|
66
|
+
delete process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
|
|
67
|
+
const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
|
|
68
|
+
expect(() => assertSafe("https://random.example.org")).toThrow("Invalid redirect URL");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
37
71
|
describe("PLATFORM_UI_URL env-driven entry", () => {
|
|
38
72
|
beforeEach(() => {
|
|
39
73
|
process.env.PLATFORM_UI_URL = "https://platform.example.com";
|