@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
@@ -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());
@@ -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. appendFileSync is atomic on POSIX (O_APPEND),
17
- * so no mutex is needed here. Returns the event with a generated ID.
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).
@@ -35,17 +35,21 @@ export class MeterWAL {
35
35
  }
36
36
  }
37
37
  /**
38
- * Append an event to the WAL. appendFileSync is atomic on POSIX (O_APPEND),
39
- * so no mutex is needed here. Returns the event with a generated ID.
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
- const eventWithId = {
43
- ...event,
44
- id: event.id ?? crypto.randomUUID(),
45
- };
46
- const line = `${JSON.stringify(eventWithId)}\n`;
47
- appendFileSync(this.walPath, line, { encoding: "utf8", flag: "a" });
48
- return eventWithId;
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" ? ["https://example.com"] : []),
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",