@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
|
@@ -143,7 +143,7 @@ describe("MeterEmitter", () => {
|
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
it("buffers events without writing until flush", async () => {
|
|
146
|
-
emitter.emit(makeEvent());
|
|
146
|
+
await emitter.emit(makeEvent());
|
|
147
147
|
expect(emitter.pending).toBe(1);
|
|
148
148
|
|
|
149
149
|
const rows = (await db.select({ cnt: sql<number>`COUNT(*)` }).from(meterEvents))[0];
|
|
@@ -151,8 +151,8 @@ describe("MeterEmitter", () => {
|
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
it("flush writes buffered events to the database", async () => {
|
|
154
|
-
emitter.emit(makeEvent());
|
|
155
|
-
emitter.emit(makeEvent({ tenant: "tenant-2" }));
|
|
154
|
+
await emitter.emit(makeEvent());
|
|
155
|
+
await emitter.emit(makeEvent({ tenant: "tenant-2" }));
|
|
156
156
|
|
|
157
157
|
const flushed = await emitter.flush();
|
|
158
158
|
expect(flushed).toBe(2);
|
|
@@ -174,7 +174,7 @@ describe("MeterEmitter", () => {
|
|
|
174
174
|
duration: 5000,
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
-
emitter.emit(event);
|
|
177
|
+
await emitter.emit(event);
|
|
178
178
|
await emitter.flush();
|
|
179
179
|
|
|
180
180
|
const rows = await emitter.queryEvents("t-abc");
|
|
@@ -190,7 +190,7 @@ describe("MeterEmitter", () => {
|
|
|
190
190
|
});
|
|
191
191
|
|
|
192
192
|
it("handles null optional fields", async () => {
|
|
193
|
-
emitter.emit(makeEvent({ sessionId: undefined, duration: undefined }));
|
|
193
|
+
await emitter.emit(makeEvent({ sessionId: undefined, duration: undefined }));
|
|
194
194
|
await emitter.flush();
|
|
195
195
|
|
|
196
196
|
const rows = await emitter.queryEvents("tenant-1");
|
|
@@ -199,8 +199,8 @@ describe("MeterEmitter", () => {
|
|
|
199
199
|
});
|
|
200
200
|
|
|
201
201
|
it("generates unique IDs for each event", async () => {
|
|
202
|
-
emitter.emit(makeEvent());
|
|
203
|
-
emitter.emit(makeEvent());
|
|
202
|
+
await emitter.emit(makeEvent());
|
|
203
|
+
await emitter.emit(makeEvent());
|
|
204
204
|
await emitter.flush();
|
|
205
205
|
|
|
206
206
|
const rows = await emitter.queryEvents("tenant-1");
|
|
@@ -210,10 +210,10 @@ describe("MeterEmitter", () => {
|
|
|
210
210
|
|
|
211
211
|
it("auto-flushes when batch size is reached", async () => {
|
|
212
212
|
const smallBatch = makeEmitter(db, { flushIntervalMs: 60_000, batchSize: 3 });
|
|
213
|
-
smallBatch.emit(makeEvent());
|
|
214
|
-
smallBatch.emit(makeEvent());
|
|
213
|
+
await smallBatch.emit(makeEvent());
|
|
214
|
+
await smallBatch.emit(makeEvent());
|
|
215
215
|
// Third event triggers auto-flush.
|
|
216
|
-
smallBatch.emit(makeEvent());
|
|
216
|
+
await smallBatch.emit(makeEvent());
|
|
217
217
|
|
|
218
218
|
const rows = (await db.select({ cnt: sql<number>`COUNT(*)` }).from(meterEvents))[0];
|
|
219
219
|
expect(rows?.cnt).toBe(3);
|
|
@@ -222,8 +222,8 @@ describe("MeterEmitter", () => {
|
|
|
222
222
|
});
|
|
223
223
|
|
|
224
224
|
it("close flushes remaining events", async () => {
|
|
225
|
-
emitter.emit(makeEvent());
|
|
226
|
-
emitter.emit(makeEvent());
|
|
225
|
+
await emitter.emit(makeEvent());
|
|
226
|
+
await emitter.emit(makeEvent());
|
|
227
227
|
emitter.close();
|
|
228
228
|
|
|
229
229
|
const rows = (await db.select({ cnt: sql<number>`COUNT(*)` }).from(meterEvents))[0];
|
|
@@ -232,7 +232,7 @@ describe("MeterEmitter", () => {
|
|
|
232
232
|
|
|
233
233
|
it("ignores events after close", async () => {
|
|
234
234
|
emitter.close();
|
|
235
|
-
emitter.emit(makeEvent());
|
|
235
|
+
await emitter.emit(makeEvent());
|
|
236
236
|
expect(emitter.pending).toBe(0);
|
|
237
237
|
});
|
|
238
238
|
|
|
@@ -246,8 +246,8 @@ describe("MeterEmitter", () => {
|
|
|
246
246
|
dlqPath: TEST_DLQ_PATH,
|
|
247
247
|
});
|
|
248
248
|
|
|
249
|
-
localEmitter.emit(makeEvent());
|
|
250
|
-
localEmitter.emit(makeEvent());
|
|
249
|
+
await localEmitter.emit(makeEvent());
|
|
250
|
+
await localEmitter.emit(makeEvent());
|
|
251
251
|
expect(localEmitter.pending).toBe(2);
|
|
252
252
|
|
|
253
253
|
await localPool.close();
|
|
@@ -261,9 +261,9 @@ describe("MeterEmitter", () => {
|
|
|
261
261
|
});
|
|
262
262
|
|
|
263
263
|
it("queryEvents returns events for a specific tenant", async () => {
|
|
264
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
265
|
-
emitter.emit(makeEvent({ tenant: "t-2" }));
|
|
266
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
264
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
265
|
+
await emitter.emit(makeEvent({ tenant: "t-2" }));
|
|
266
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
267
267
|
await emitter.flush();
|
|
268
268
|
|
|
269
269
|
const t1Events = await emitter.queryEvents("t-1");
|
|
@@ -306,9 +306,9 @@ describe("MeterEmitter - concurrent multi-provider sessions", () => {
|
|
|
306
306
|
it("groups multiple providers under one sessionId", async () => {
|
|
307
307
|
const sessionId = "voice-session-1";
|
|
308
308
|
|
|
309
|
-
emitter.emit(makeEvent({ capability: "stt", provider: "deepgram", sessionId }));
|
|
310
|
-
emitter.emit(makeEvent({ capability: "chat", provider: "openai", sessionId }));
|
|
311
|
-
emitter.emit(makeEvent({ capability: "tts", provider: "elevenlabs", sessionId }));
|
|
309
|
+
await emitter.emit(makeEvent({ capability: "stt", provider: "deepgram", sessionId }));
|
|
310
|
+
await emitter.emit(makeEvent({ capability: "chat", provider: "openai", sessionId }));
|
|
311
|
+
await emitter.emit(makeEvent({ capability: "tts", provider: "elevenlabs", sessionId }));
|
|
312
312
|
await emitter.flush();
|
|
313
313
|
|
|
314
314
|
const rows = await db.select().from(meterEvents).where(eq(meterEvents.sessionId, sessionId));
|
|
@@ -321,9 +321,9 @@ describe("MeterEmitter - concurrent multi-provider sessions", () => {
|
|
|
321
321
|
});
|
|
322
322
|
|
|
323
323
|
it("handles events from different sessions simultaneously", async () => {
|
|
324
|
-
emitter.emit(makeEvent({ sessionId: "sess-a", capability: "stt" }));
|
|
325
|
-
emitter.emit(makeEvent({ sessionId: "sess-b", capability: "stt" }));
|
|
326
|
-
emitter.emit(makeEvent({ sessionId: "sess-a", capability: "tts" }));
|
|
324
|
+
await emitter.emit(makeEvent({ sessionId: "sess-a", capability: "stt" }));
|
|
325
|
+
await emitter.emit(makeEvent({ sessionId: "sess-b", capability: "stt" }));
|
|
326
|
+
await emitter.emit(makeEvent({ sessionId: "sess-a", capability: "tts" }));
|
|
327
327
|
await emitter.flush();
|
|
328
328
|
|
|
329
329
|
const sessA = (
|
|
@@ -372,7 +372,7 @@ describe("MeterAggregator", () => {
|
|
|
372
372
|
// Insert events in a past window.
|
|
373
373
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
374
374
|
|
|
375
|
-
emitter.emit(
|
|
375
|
+
await emitter.emit(
|
|
376
376
|
makeEvent({
|
|
377
377
|
tenant: "t-1",
|
|
378
378
|
cost: Credit.fromDollars(0.01),
|
|
@@ -380,7 +380,7 @@ describe("MeterAggregator", () => {
|
|
|
380
380
|
timestamp: pastWindow + 100,
|
|
381
381
|
}),
|
|
382
382
|
);
|
|
383
|
-
emitter.emit(
|
|
383
|
+
await emitter.emit(
|
|
384
384
|
makeEvent({
|
|
385
385
|
tenant: "t-1",
|
|
386
386
|
cost: Credit.fromDollars(0.03),
|
|
@@ -403,11 +403,13 @@ describe("MeterAggregator", () => {
|
|
|
403
403
|
it("groups by tenant, capability, and provider", async () => {
|
|
404
404
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
405
405
|
|
|
406
|
-
emitter.emit(
|
|
406
|
+
await emitter.emit(
|
|
407
407
|
makeEvent({ tenant: "t-1", capability: "embeddings", provider: "openai", timestamp: pastWindow + 10 }),
|
|
408
408
|
);
|
|
409
|
-
emitter.emit(
|
|
410
|
-
|
|
409
|
+
await emitter.emit(
|
|
410
|
+
makeEvent({ tenant: "t-1", capability: "voice", provider: "deepgram", timestamp: pastWindow + 20 }),
|
|
411
|
+
);
|
|
412
|
+
await emitter.emit(
|
|
411
413
|
makeEvent({ tenant: "t-2", capability: "embeddings", provider: "openai", timestamp: pastWindow + 30 }),
|
|
412
414
|
);
|
|
413
415
|
await emitter.flush();
|
|
@@ -424,7 +426,7 @@ describe("MeterAggregator", () => {
|
|
|
424
426
|
|
|
425
427
|
it("does not aggregate the current (incomplete) window", async () => {
|
|
426
428
|
// Insert an event in the *current* window.
|
|
427
|
-
emitter.emit(makeEvent({ timestamp: Date.now() }));
|
|
429
|
+
await emitter.emit(makeEvent({ timestamp: Date.now() }));
|
|
428
430
|
await emitter.flush();
|
|
429
431
|
|
|
430
432
|
const count = await aggregator.aggregate();
|
|
@@ -438,7 +440,7 @@ describe("MeterAggregator", () => {
|
|
|
438
440
|
it("is idempotent - does not double-aggregate", async () => {
|
|
439
441
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
440
442
|
|
|
441
|
-
emitter.emit(
|
|
443
|
+
await emitter.emit(
|
|
442
444
|
makeEvent({
|
|
443
445
|
tenant: "t-1",
|
|
444
446
|
cost: Credit.fromDollars(0.01),
|
|
@@ -459,7 +461,7 @@ describe("MeterAggregator", () => {
|
|
|
459
461
|
it("aggregates duration for session-based capabilities", async () => {
|
|
460
462
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
461
463
|
|
|
462
|
-
emitter.emit(
|
|
464
|
+
await emitter.emit(
|
|
463
465
|
makeEvent({
|
|
464
466
|
tenant: "t-1",
|
|
465
467
|
capability: "voice",
|
|
@@ -467,7 +469,7 @@ describe("MeterAggregator", () => {
|
|
|
467
469
|
timestamp: pastWindow + 10,
|
|
468
470
|
}),
|
|
469
471
|
);
|
|
470
|
-
emitter.emit(
|
|
472
|
+
await emitter.emit(
|
|
471
473
|
makeEvent({
|
|
472
474
|
tenant: "t-1",
|
|
473
475
|
capability: "voice",
|
|
@@ -493,7 +495,7 @@ describe("MeterAggregator", () => {
|
|
|
493
495
|
it("getTenantTotal returns aggregate totals", async () => {
|
|
494
496
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
495
497
|
|
|
496
|
-
emitter.emit(
|
|
498
|
+
await emitter.emit(
|
|
497
499
|
makeEvent({
|
|
498
500
|
tenant: "t-1",
|
|
499
501
|
cost: Credit.fromDollars(0.01),
|
|
@@ -501,7 +503,7 @@ describe("MeterAggregator", () => {
|
|
|
501
503
|
timestamp: pastWindow + 10,
|
|
502
504
|
}),
|
|
503
505
|
);
|
|
504
|
-
emitter.emit(
|
|
506
|
+
await emitter.emit(
|
|
505
507
|
makeEvent({
|
|
506
508
|
tenant: "t-1",
|
|
507
509
|
cost: Credit.fromDollars(0.05),
|
|
@@ -532,8 +534,8 @@ describe("MeterAggregator", () => {
|
|
|
532
534
|
const twoWindowsAgo = Math.floor(now / WINDOW) * WINDOW - 2 * WINDOW;
|
|
533
535
|
const oneWindowAgo = Math.floor(now / WINDOW) * WINDOW - WINDOW;
|
|
534
536
|
|
|
535
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: twoWindowsAgo + 10 }));
|
|
536
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: oneWindowAgo + 10 }));
|
|
537
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: twoWindowsAgo + 10 }));
|
|
538
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: oneWindowAgo + 10 }));
|
|
537
539
|
await emitter.flush();
|
|
538
540
|
|
|
539
541
|
// Aggregate both windows.
|
|
@@ -586,7 +588,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
586
588
|
const threeWindowsAgo = Math.floor(now / WINDOW) * WINDOW - 3 * WINDOW;
|
|
587
589
|
|
|
588
590
|
// Place one event 3 windows ago; windows 2-ago and 1-ago are empty.
|
|
589
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: threeWindowsAgo + 10 }));
|
|
591
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: threeWindowsAgo + 10 }));
|
|
590
592
|
await emitter.flush();
|
|
591
593
|
|
|
592
594
|
await aggregator.aggregate(now);
|
|
@@ -613,7 +615,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
613
615
|
const now = Date.now();
|
|
614
616
|
const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
|
|
615
617
|
|
|
616
|
-
emitter.emit(
|
|
618
|
+
await emitter.emit(
|
|
617
619
|
makeEvent({
|
|
618
620
|
tenant: "t-1",
|
|
619
621
|
cost: Credit.fromDollars(0.123),
|
|
@@ -638,7 +640,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
638
640
|
const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
|
|
639
641
|
|
|
640
642
|
// Event at the exact start of the window (timestamp === windowStart).
|
|
641
|
-
emitter.emit(
|
|
643
|
+
await emitter.emit(
|
|
642
644
|
makeEvent({
|
|
643
645
|
tenant: "t-1",
|
|
644
646
|
cost: Credit.fromDollars(0.01),
|
|
@@ -663,7 +665,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
663
665
|
const oneWindowAgo = twoWindowsAgo + WINDOW;
|
|
664
666
|
|
|
665
667
|
// Event at the exact boundary (end of window 2-ago = start of window 1-ago).
|
|
666
|
-
emitter.emit(
|
|
668
|
+
await emitter.emit(
|
|
667
669
|
makeEvent({
|
|
668
670
|
tenant: "t-1",
|
|
669
671
|
cost: Credit.fromDollars(0.01),
|
|
@@ -687,7 +689,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
687
689
|
|
|
688
690
|
const tenants = ["alpha", "beta", "gamma"];
|
|
689
691
|
for (const t of tenants) {
|
|
690
|
-
emitter.emit(
|
|
692
|
+
await emitter.emit(
|
|
691
693
|
makeEvent({
|
|
692
694
|
tenant: t,
|
|
693
695
|
cost: Credit.fromDollars(0.01),
|
|
@@ -696,7 +698,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
696
698
|
timestamp: pastWindow + 10,
|
|
697
699
|
}),
|
|
698
700
|
);
|
|
699
|
-
emitter.emit(
|
|
701
|
+
await emitter.emit(
|
|
700
702
|
makeEvent({
|
|
701
703
|
tenant: t,
|
|
702
704
|
cost: Credit.fromDollars(0.03),
|
|
@@ -726,7 +728,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
726
728
|
const twoWindowsAgo = threeWindowsAgo + WINDOW;
|
|
727
729
|
const oneWindowAgo = twoWindowsAgo + WINDOW;
|
|
728
730
|
|
|
729
|
-
emitter.emit(
|
|
731
|
+
await emitter.emit(
|
|
730
732
|
makeEvent({
|
|
731
733
|
tenant: "t-1",
|
|
732
734
|
cost: Credit.fromDollars(0.01),
|
|
@@ -734,7 +736,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
734
736
|
timestamp: threeWindowsAgo + 100,
|
|
735
737
|
}),
|
|
736
738
|
);
|
|
737
|
-
emitter.emit(
|
|
739
|
+
await emitter.emit(
|
|
738
740
|
makeEvent({
|
|
739
741
|
tenant: "t-1",
|
|
740
742
|
cost: Credit.fromDollars(0.03),
|
|
@@ -742,7 +744,7 @@ describe("MeterAggregator - edge cases", () => {
|
|
|
742
744
|
timestamp: twoWindowsAgo + 100,
|
|
743
745
|
}),
|
|
744
746
|
);
|
|
745
|
-
emitter.emit(
|
|
747
|
+
await emitter.emit(
|
|
746
748
|
makeEvent({
|
|
747
749
|
tenant: "t-1",
|
|
748
750
|
cost: Credit.fromDollars(0.05),
|
|
@@ -845,7 +847,7 @@ describe("MeterAggregator - billing accuracy", () => {
|
|
|
845
847
|
const expectedCostRaw = events.reduce((s, e) => s + e.cost.toRaw(), 0);
|
|
846
848
|
const expectedChargeRaw = events.reduce((s, e) => s + e.charge.toRaw(), 0);
|
|
847
849
|
|
|
848
|
-
for (const e of events) emitter.emit(e);
|
|
850
|
+
for (const e of events) await emitter.emit(e);
|
|
849
851
|
await emitter.flush();
|
|
850
852
|
await aggregator.aggregate(now);
|
|
851
853
|
|
|
@@ -860,7 +862,7 @@ describe("MeterAggregator - billing accuracy", () => {
|
|
|
860
862
|
const now = Date.now();
|
|
861
863
|
const pastWindow = Math.floor(now / WINDOW) * WINDOW - WINDOW;
|
|
862
864
|
|
|
863
|
-
emitter.emit(
|
|
865
|
+
await emitter.emit(
|
|
864
866
|
makeEvent({
|
|
865
867
|
tenant: "t-1",
|
|
866
868
|
cost: Credit.fromDollars(0.1),
|
|
@@ -869,7 +871,7 @@ describe("MeterAggregator - billing accuracy", () => {
|
|
|
869
871
|
timestamp: pastWindow + 10,
|
|
870
872
|
}),
|
|
871
873
|
);
|
|
872
|
-
emitter.emit(
|
|
874
|
+
await emitter.emit(
|
|
873
875
|
makeEvent({
|
|
874
876
|
tenant: "t-1",
|
|
875
877
|
cost: Credit.fromDollars(0.05),
|
|
@@ -878,7 +880,7 @@ describe("MeterAggregator - billing accuracy", () => {
|
|
|
878
880
|
timestamp: pastWindow + 20,
|
|
879
881
|
}),
|
|
880
882
|
);
|
|
881
|
-
emitter.emit(
|
|
883
|
+
await emitter.emit(
|
|
882
884
|
makeEvent({
|
|
883
885
|
tenant: "t-1",
|
|
884
886
|
cost: Credit.fromDollars(0.15),
|
|
@@ -929,7 +931,7 @@ describe("MeterEmitter - edge cases", () => {
|
|
|
929
931
|
|
|
930
932
|
it("handles large batch of events", async () => {
|
|
931
933
|
for (let i = 0; i < 200; i++) {
|
|
932
|
-
emitter.emit(
|
|
934
|
+
await emitter.emit(
|
|
933
935
|
makeEvent({
|
|
934
936
|
tenant: "bulk-tenant",
|
|
935
937
|
cost: Credit.fromDollars(0.001),
|
|
@@ -947,7 +949,7 @@ describe("MeterEmitter - edge cases", () => {
|
|
|
947
949
|
});
|
|
948
950
|
|
|
949
951
|
it("handles zero-cost events", async () => {
|
|
950
|
-
emitter.emit(makeEvent({ tenant: "free-tier", cost: Credit.ZERO, charge: Credit.ZERO }));
|
|
952
|
+
await emitter.emit(makeEvent({ tenant: "free-tier", cost: Credit.ZERO, charge: Credit.ZERO }));
|
|
951
953
|
await emitter.flush();
|
|
952
954
|
|
|
953
955
|
const rows = await emitter.queryEvents("free-tier");
|
|
@@ -958,9 +960,9 @@ describe("MeterEmitter - edge cases", () => {
|
|
|
958
960
|
|
|
959
961
|
it("preserves event ordering within a tenant", async () => {
|
|
960
962
|
const base = 1700000000000;
|
|
961
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 300 }));
|
|
962
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 100 }));
|
|
963
|
-
emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 200 }));
|
|
963
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 300 }));
|
|
964
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 100 }));
|
|
965
|
+
await emitter.emit(makeEvent({ tenant: "t-1", timestamp: base + 200 }));
|
|
964
966
|
await emitter.flush();
|
|
965
967
|
|
|
966
968
|
// queryEvents orders by timestamp DESC.
|
|
@@ -972,11 +974,11 @@ describe("MeterEmitter - edge cases", () => {
|
|
|
972
974
|
});
|
|
973
975
|
|
|
974
976
|
it("handles multiple flushes without losing events", async () => {
|
|
975
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
977
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
976
978
|
await emitter.flush();
|
|
977
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
979
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
978
980
|
await emitter.flush();
|
|
979
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
981
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
980
982
|
await emitter.flush();
|
|
981
983
|
|
|
982
984
|
const rows = (
|
|
@@ -993,7 +995,7 @@ describe("append-only guarantee", () => {
|
|
|
993
995
|
const { db, pool } = await createTestDb();
|
|
994
996
|
const emitter = makeEmitter(db, { flushIntervalMs: 60_000 });
|
|
995
997
|
|
|
996
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
998
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
997
999
|
await emitter.flush();
|
|
998
1000
|
|
|
999
1001
|
// Verify the event exists.
|
|
@@ -1001,7 +1003,7 @@ describe("append-only guarantee", () => {
|
|
|
1001
1003
|
expect(before?.cnt).toBe(1);
|
|
1002
1004
|
|
|
1003
1005
|
// Emit more -- never replaces.
|
|
1004
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
1006
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
1005
1007
|
await emitter.flush();
|
|
1006
1008
|
|
|
1007
1009
|
const after = (await db.select({ cnt: sql<number>`COUNT(*)` }).from(meterEvents))[0];
|
|
@@ -1064,7 +1066,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1064
1066
|
});
|
|
1065
1067
|
|
|
1066
1068
|
it("writes events to WAL before buffering", async () => {
|
|
1067
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
1069
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
1068
1070
|
|
|
1069
1071
|
// WAL should exist immediately.
|
|
1070
1072
|
expect(existsSync(TEST_WAL_PATH)).toBe(true);
|
|
@@ -1079,7 +1081,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1079
1081
|
});
|
|
1080
1082
|
|
|
1081
1083
|
it("clears WAL after successful flush", async () => {
|
|
1082
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
1084
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
1083
1085
|
expect(existsSync(TEST_WAL_PATH)).toBe(true);
|
|
1084
1086
|
|
|
1085
1087
|
await emitter.flush();
|
|
@@ -1089,7 +1091,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1089
1091
|
});
|
|
1090
1092
|
|
|
1091
1093
|
it("moves events to DLQ after max retries", async () => {
|
|
1092
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
1094
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
1093
1095
|
|
|
1094
1096
|
// Close the database to force flush failures.
|
|
1095
1097
|
pool.close();
|
|
@@ -1179,7 +1181,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1179
1181
|
|
|
1180
1182
|
describe("generic usage fields (WOP-512)", () => {
|
|
1181
1183
|
it("persists usage, tier, and metadata fields", async () => {
|
|
1182
|
-
emitter.emit(
|
|
1184
|
+
await emitter.emit(
|
|
1183
1185
|
makeEvent({
|
|
1184
1186
|
tenant: "t-1",
|
|
1185
1187
|
capability: "tts",
|
|
@@ -1198,7 +1200,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1198
1200
|
});
|
|
1199
1201
|
|
|
1200
1202
|
it("handles null usage/tier/metadata (backwards compatibility)", async () => {
|
|
1201
|
-
emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
1203
|
+
await emitter.emit(makeEvent({ tenant: "t-1" }));
|
|
1202
1204
|
await emitter.flush();
|
|
1203
1205
|
const rows = await emitter.queryEvents("t-1");
|
|
1204
1206
|
expect(rows[0].usage_units).toBeNull();
|
|
@@ -1208,7 +1210,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1208
1210
|
});
|
|
1209
1211
|
|
|
1210
1212
|
it("works with multiple capability types in the same flush", async () => {
|
|
1211
|
-
emitter.emit(
|
|
1213
|
+
await emitter.emit(
|
|
1212
1214
|
makeEvent({
|
|
1213
1215
|
capability: "tts",
|
|
1214
1216
|
provider: "elevenlabs",
|
|
@@ -1216,7 +1218,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1216
1218
|
tier: "branded",
|
|
1217
1219
|
}),
|
|
1218
1220
|
);
|
|
1219
|
-
emitter.emit(
|
|
1221
|
+
await emitter.emit(
|
|
1220
1222
|
makeEvent({
|
|
1221
1223
|
capability: "chat-completions",
|
|
1222
1224
|
provider: "openrouter",
|
|
@@ -1224,7 +1226,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1224
1226
|
tier: "branded",
|
|
1225
1227
|
}),
|
|
1226
1228
|
);
|
|
1227
|
-
emitter.emit(
|
|
1229
|
+
await emitter.emit(
|
|
1228
1230
|
makeEvent({
|
|
1229
1231
|
capability: "transcription",
|
|
1230
1232
|
provider: "self-hosted-whisper",
|
|
@@ -1232,7 +1234,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1232
1234
|
tier: "wopr",
|
|
1233
1235
|
}),
|
|
1234
1236
|
);
|
|
1235
|
-
emitter.emit(
|
|
1237
|
+
await emitter.emit(
|
|
1236
1238
|
makeEvent({
|
|
1237
1239
|
capability: "image-generation",
|
|
1238
1240
|
provider: "replicate",
|
|
@@ -1249,7 +1251,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1249
1251
|
});
|
|
1250
1252
|
|
|
1251
1253
|
it("BYOK tier records zero cost/charge with tier='byok'", async () => {
|
|
1252
|
-
emitter.emit(
|
|
1254
|
+
await emitter.emit(
|
|
1253
1255
|
makeEvent({
|
|
1254
1256
|
cost: Credit.ZERO,
|
|
1255
1257
|
charge: Credit.ZERO,
|
|
@@ -1271,7 +1273,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1271
1273
|
const WINDOW = 60_000; // 1 minute
|
|
1272
1274
|
const aggregator = new MeterAggregator(new DrizzleUsageSummaryRepository(db), { windowMs: WINDOW });
|
|
1273
1275
|
const pastWindow = Math.floor(Date.now() / WINDOW) * WINDOW - WINDOW;
|
|
1274
|
-
emitter.emit(
|
|
1276
|
+
await emitter.emit(
|
|
1275
1277
|
makeEvent({
|
|
1276
1278
|
tenant: "t-1",
|
|
1277
1279
|
cost: Credit.fromDollars(0.01),
|
|
@@ -1281,7 +1283,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1281
1283
|
tier: "branded",
|
|
1282
1284
|
}),
|
|
1283
1285
|
);
|
|
1284
|
-
emitter.emit(
|
|
1286
|
+
await emitter.emit(
|
|
1285
1287
|
makeEvent({
|
|
1286
1288
|
tenant: "t-1",
|
|
1287
1289
|
cost: Credit.fromDollars(0.03),
|
|
@@ -1305,7 +1307,7 @@ describe("MeterEmitter - fail-closed policy", () => {
|
|
|
1305
1307
|
tier: "wopr",
|
|
1306
1308
|
metadata: { foo: "bar" },
|
|
1307
1309
|
});
|
|
1308
|
-
emitter.emit(event);
|
|
1310
|
+
await emitter.emit(event);
|
|
1309
1311
|
// WAL should persist new fields
|
|
1310
1312
|
const walContent = readFileSync(TEST_WAL_PATH, "utf8");
|
|
1311
1313
|
const walEvent = JSON.parse(walContent.trim());
|
package/src/metering/wal.test.ts
CHANGED
|
@@ -161,6 +161,32 @@ describe("MeterWAL", () => {
|
|
|
161
161
|
expect(tenants).not.toContain("t-2");
|
|
162
162
|
expect(events).toHaveLength(2);
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
it("concurrent appends and remove are serialized without data loss", async () => {
|
|
166
|
+
// Seed 2 events to remove
|
|
167
|
+
const e1 = await wal.append(makeEvent({ tenant: "seed-1" }));
|
|
168
|
+
const e2 = await wal.append(makeEvent({ tenant: "seed-2" }));
|
|
169
|
+
|
|
170
|
+
// Fire remove + 3 concurrent appends
|
|
171
|
+
const removePromise = wal.remove(new Set([e1.id, e2.id]));
|
|
172
|
+
const a1 = wal.append(makeEvent({ tenant: "new-1" }));
|
|
173
|
+
const a2 = wal.append(makeEvent({ tenant: "new-2" }));
|
|
174
|
+
const a3 = wal.append(makeEvent({ tenant: "new-3" }));
|
|
175
|
+
|
|
176
|
+
await Promise.all([removePromise, a1, a2, a3]);
|
|
177
|
+
|
|
178
|
+
const events = wal.readAll();
|
|
179
|
+
const tenants = events.map((e) => e.tenant);
|
|
180
|
+
|
|
181
|
+
// Seeded events must be gone
|
|
182
|
+
expect(tenants).not.toContain("seed-1");
|
|
183
|
+
expect(tenants).not.toContain("seed-2");
|
|
184
|
+
// All 3 new appends must survive
|
|
185
|
+
expect(tenants).toContain("new-1");
|
|
186
|
+
expect(tenants).toContain("new-2");
|
|
187
|
+
expect(tenants).toContain("new-3");
|
|
188
|
+
expect(events).toHaveLength(3);
|
|
189
|
+
});
|
|
164
190
|
});
|
|
165
191
|
|
|
166
192
|
describe("MeterDLQ", () => {
|
package/src/metering/wal.ts
CHANGED
|
@@ -40,19 +40,23 @@ export class MeterWAL {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Append an event to the WAL.
|
|
44
|
-
*
|
|
43
|
+
* Append an event to the WAL. Mutex-guarded to prevent TOCTOU races
|
|
44
|
+
* with remove(), which does a read-filter-rewrite that would clobber
|
|
45
|
+
* concurrent appends. Uses appendFileSync inside the lock for
|
|
46
|
+
* fail-closed crash safety (POSIX O_APPEND atomic write).
|
|
45
47
|
*/
|
|
46
|
-
append(event: MeterEvent & { id?: string }): MeterEvent & { id: string } {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
async append(event: MeterEvent & { id?: string }): Promise<MeterEvent & { id: string }> {
|
|
49
|
+
return this.withLock(() => {
|
|
50
|
+
const eventWithId = {
|
|
51
|
+
...event,
|
|
52
|
+
id: event.id ?? crypto.randomUUID(),
|
|
53
|
+
};
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
const line = `${JSON.stringify(eventWithId)}\n`;
|
|
56
|
+
appendFileSync(this.walPath, line, { encoding: "utf8", flag: "a" });
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
return eventWithId;
|
|
59
|
+
});
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
/**
|
|
@@ -46,6 +46,47 @@ describe("assertSafeRedirectUrl", () => {
|
|
|
46
46
|
expect(() => assertSafeRedirectUrl("")).toThrow("Invalid redirect URL");
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
it("rejects https://example.com", () => {
|
|
50
|
+
expect(() => assertSafeRedirectUrl("https://example.com/callback")).toThrow("Invalid redirect URL");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("EXTRA_ALLOWED_REDIRECT_ORIGINS env-driven entries", () => {
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
vi.resetModules();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
delete process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
|
|
60
|
+
vi.resetModules();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("allows origins listed in EXTRA_ALLOWED_REDIRECT_ORIGINS", async () => {
|
|
64
|
+
process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot";
|
|
65
|
+
const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
|
|
66
|
+
expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("allows multiple comma-separated origins", async () => {
|
|
70
|
+
process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot,https://preview.wopr.bot";
|
|
71
|
+
const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
|
|
72
|
+
expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
|
|
73
|
+
expect(() => assertSafe("https://preview.wopr.bot/dashboard")).not.toThrow();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("ignores empty/whitespace entries in comma-separated list", async () => {
|
|
77
|
+
process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS = "https://staging.wopr.bot, , ,";
|
|
78
|
+
const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
|
|
79
|
+
expect(() => assertSafe("https://staging.wopr.bot/billing")).not.toThrow();
|
|
80
|
+
expect(() => assertSafe("https://evil.com/phishing")).toThrow("Invalid redirect URL");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("defaults to empty when env var is unset", async () => {
|
|
84
|
+
delete process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
|
|
85
|
+
const { assertSafeRedirectUrl: assertSafe } = await import("./redirect-allowlist.js");
|
|
86
|
+
expect(() => assertSafe("https://random.example.org")).toThrow("Invalid redirect URL");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
49
90
|
describe("PLATFORM_UI_URL env-driven entry", () => {
|
|
50
91
|
beforeEach(() => {
|
|
51
92
|
process.env.PLATFORM_UI_URL = "https://platform.example.com";
|
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
const STATIC_ORIGINS: string[] = ["https://app.wopr.bot", "https://wopr.network"];
|
|
2
2
|
|
|
3
|
+
function parseExtraOrigins(): string[] {
|
|
4
|
+
const raw = process.env.EXTRA_ALLOWED_REDIRECT_ORIGINS;
|
|
5
|
+
if (!raw) 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
|
+
} catch {
|
|
15
|
+
console.warn(`[redirect-allowlist] Malformed entry in EXTRA_ALLOWED_REDIRECT_ORIGINS, skipping: ${entry}`);
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
3
21
|
function getAllowedOrigins(): string[] {
|
|
4
22
|
return [
|
|
5
23
|
...STATIC_ORIGINS,
|
|
6
24
|
...(process.env.NODE_ENV !== "production" ? ["http://localhost:3000", "http://localhost:3001"] : []),
|
|
7
25
|
...(process.env.PLATFORM_UI_URL ? [process.env.PLATFORM_UI_URL] : []),
|
|
8
|
-
...(process.env.NODE_ENV !== "production" ?
|
|
26
|
+
...(process.env.NODE_ENV !== "production" ? parseExtraOrigins() : []),
|
|
9
27
|
];
|
|
10
28
|
}
|
|
11
29
|
|