@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.
Files changed (48) hide show
  1. package/.env.example +5 -0
  2. package/dist/account/deletion-executor-repository.d.ts +17 -0
  3. package/dist/account/deletion-executor-repository.js +41 -0
  4. package/dist/account/deletion-executor-repository.test.d.ts +1 -0
  5. package/dist/account/deletion-executor-repository.test.js +89 -0
  6. package/dist/account/deletion-repository.d.ts +19 -0
  7. package/dist/account/deletion-repository.js +62 -0
  8. package/dist/account/deletion-repository.test.d.ts +1 -0
  9. package/dist/account/deletion-repository.test.js +85 -0
  10. package/dist/account/export-repository.d.ts +21 -0
  11. package/dist/account/export-repository.js +77 -0
  12. package/dist/account/export-repository.test.d.ts +1 -0
  13. package/dist/account/export-repository.test.js +109 -0
  14. package/dist/account/index.d.ts +4 -0
  15. package/dist/account/index.js +3 -0
  16. package/dist/account/repository-types.d.ts +38 -0
  17. package/dist/account/repository-types.js +4 -0
  18. package/dist/auth/auth-route-handler.test.d.ts +1 -0
  19. package/dist/auth/auth-route-handler.test.js +191 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +2 -0
  22. package/dist/metering/emitter.d.ts +2 -2
  23. package/dist/metering/emitter.js +4 -4
  24. package/dist/metering/emitter.test.js +7 -7
  25. package/dist/metering/metering.test.js +73 -73
  26. package/dist/metering/wal.d.ts +6 -4
  27. package/dist/metering/wal.js +14 -10
  28. package/dist/metering/wal.test.js +21 -0
  29. package/dist/security/redirect-allowlist.js +20 -1
  30. package/dist/security/redirect-allowlist.test.js +34 -0
  31. package/package.json +1 -1
  32. package/src/account/deletion-executor-repository.test.ts +109 -0
  33. package/src/account/deletion-executor-repository.ts +58 -0
  34. package/src/account/deletion-repository.test.ts +103 -0
  35. package/src/account/deletion-repository.ts +82 -0
  36. package/src/account/export-repository.test.ts +135 -0
  37. package/src/account/export-repository.ts +101 -0
  38. package/src/account/index.ts +14 -0
  39. package/src/account/repository-types.ts +46 -0
  40. package/src/auth/auth-route-handler.test.ts +243 -0
  41. package/src/index.ts +3 -0
  42. package/src/metering/emitter.test.ts +7 -7
  43. package/src/metering/emitter.ts +5 -5
  44. package/src/metering/metering.test.ts +75 -73
  45. package/src/metering/wal.test.ts +26 -0
  46. package/src/metering/wal.ts +14 -10
  47. package/src/security/redirect-allowlist.test.ts +41 -0
  48. 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(makeEvent({ tenant: "t-1", capability: "voice", provider: "deepgram", timestamp: pastWindow + 20 }));
410
- emitter.emit(
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());
@@ -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", () => {
@@ -40,19 +40,23 @@ export class MeterWAL {
40
40
  }
41
41
 
42
42
  /**
43
- * Append an event to the WAL. appendFileSync is atomic on POSIX (O_APPEND),
44
- * so no mutex is needed here. Returns the event with a generated ID.
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
- const eventWithId = {
48
- ...event,
49
- id: event.id ?? crypto.randomUUID(),
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
- const line = `${JSON.stringify(eventWithId)}\n`;
53
- appendFileSync(this.walPath, line, { encoding: "utf8", flag: "a" });
55
+ const line = `${JSON.stringify(eventWithId)}\n`;
56
+ appendFileSync(this.walPath, line, { encoding: "utf8", flag: "a" });
54
57
 
55
- return eventWithId;
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" ? ["https://example.com"] : []),
26
+ ...(process.env.NODE_ENV !== "production" ? parseExtraOrigins() : []),
9
27
  ];
10
28
  }
11
29