feat(sprint3): EconomyModule TetardCoin + TwitchModule EventSub — migration + 36 tests

This commit is contained in:
2026-03-17 07:10:45 +01:00
parent 1fce52f05c
commit 49b8aa1211
18 changed files with 4023 additions and 1 deletions

View File

@@ -16,3 +16,7 @@ SUPER_OAUTH_JWT_SECRET=
# Cookie signing
COOKIE_SECRET=
# Twitch EventSub webhook
TWITCH_WEBHOOK_SECRET=<secret EventSub>
TWITCH_CLIENT_ID=<app client id>

80
docs/economy-design.md Normal file
View File

@@ -0,0 +1,80 @@
# TetaRdPG — Economy Design : TetardCoin
> Sprint 3 — Step 1 output
> Date : 2026-03-17
---
## Taux de conversion
**10 Bits = 1 TetardCoin (TC)**
Justification : 100 Bits (cheer le plus courant sur Twitch) → 10 TC = 1 recharge endurance complète. Ni trop abondant (1:1 dévaluerait le TC immédiatement), ni trop rare (100:1 pénaliserait les petits cheers). Valeur implicite ~0,10 USD par TC, ancrée sur le cours Bits Twitch.
---
## Rewards Abonnés
| Tier | TC / mois |
|------|-----------|
| Prime | 30 TC |
| T1 | 50 TC |
| T2 | 120 TC |
| T3 | 350 TC |
---
## Rewards Bits — Seuils de Cheers
Base : 10 Bits = 1 TC + prime de seuil
| Seuil | TC crédité | Prime | Note |
|-------|-----------|-------|------|
| 100 Bits | 10 TC | 0 | Pas de prime — évite le split-cheering |
| 500 Bits | 55 TC | +5 TC | ~10% prime |
| 1 000 Bits | 115 TC | +15 TC | ~15% prime |
| 5 000 Bits | 575 TC | +75 TC | ~15% prime |
---
## Utilisations TC
| Usage | Coût TC | Description |
|-------|---------|-------------|
| Recharge endurance | 1 TC = +20 pts | Prime volume : 5 TC et 10 TC |
| Cosmétiques Twitch | 20 150 TC | Titres, cadres, skins limités — rotation mensuelle |
| Forge garantie | Max 12 TC | Supprime risque perte matériaux (20-40%) |
| Tickets PvP | 5 TC = +3 tickets | Plafond +10 tickets/jour |
| Artisanat accéléré | 1 TC = skip 15 min | Max 8 TC pour un craft de 2h |
---
## Sink Anti-Inflation
**Oui — sinks actifs**
Sinks primaires :
- Endurance (consommation quotidienne si actif)
- Forge garantie (usage situationnel fort)
- Cosmétiques (rotation crée FOMO)
- Artisanat accéléré (usage passif régulier)
Sink secondaire proposé :
- Création de guilde : 50 TC
- Upgrade guilde (3 niveaux) : 30 / 60 / 100 TC
---
## Différenciateur vs StreamElements / Streamlabs Points
StreamElements/Streamlabs = présence passive → points sans friction → aucune décision.
TetardCoin = engagement actif → arbitrages réels (forge vs endurance vs guilde) → économie avec tension.
**C'est la différence entre un programme de fidélité et un jeu.**
---
## Prochaines étapes → Step 2
Implémenter : entité TetardCoin (balance + historique), service de conversion Bits→TC, migrations DB, API endpoints (balance, earn, spend, history), tests invariants économiques.

3193
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,10 @@
"seed": "ts-node -r tsconfig-paths/register src/database/seed.ts",
"seed:monsters": "ts-node -r tsconfig-paths/register src/database/monsters-seed.ts",
"seed:items": "ts-node -r tsconfig-paths/register src/database/items-seed.ts",
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts"
"typeorm": "typeorm-ts-node-commonjs -d src/database/data-source.ts",
"test": "jest",
"test:watch": "jest --watch",
"migration:generate": "typeorm-ts-node-commonjs -d src/database/data-source.ts migration:generate"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
@@ -35,9 +38,29 @@
"@nestjs/testing": "^10.0.0",
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.14",
"@types/node": "^20.0.0",
"jest": "^29.7.0",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -10,6 +10,8 @@ import { ItemModule } from './item/item.module';
import { MaterialModule } from './material/material.module';
import { CraftModule } from './craft/craft.module';
import { ForgeModule } from './forge/forge.module';
import { EconomyModule } from './economy/economy.module';
import { TwitchModule } from './twitch/twitch.module';
import { HealthController } from './common/health.controller';
@Module({
@@ -43,6 +45,8 @@ import { HealthController } from './common/health.controller';
MaterialModule,
CraftModule,
ForgeModule,
EconomyModule,
TwitchModule,
],
controllers: [HealthController],
})

View File

@@ -0,0 +1,13 @@
import { ConversionService } from './conversion.service';
describe('ConversionService', () => {
const service = new ConversionService();
it('10 Bits = 1 TC', () => expect(service.bitsToTC(10)).toBe(1));
it('15 Bits = 1 TC (floor)', () => expect(service.bitsToTC(15)).toBe(1));
it('0 Bits = 0 TC', () => expect(service.bitsToTC(0)).toBe(0));
it('9 Bits = 0 TC', () => expect(service.bitsToTC(9)).toBe(0));
it('100 Bits = 10 TC', () => expect(service.bitsToTC(100)).toBe(10));
it('1000 Bits = 100 TC', () => expect(service.bitsToTC(1000)).toBe(100));
it('throws on negative bits', () => expect(() => service.bitsToTC(-1)).toThrow());
});

View File

@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
const BITS_PER_TC = 10;
@Injectable()
export class ConversionService {
bitsToTC(bits: number): number {
if (bits < 0) throw new Error('bits cannot be negative');
return Math.floor(bits / BITS_PER_TC);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TetardCoin } from './entities/tetard-coin.entity';
import { Transaction } from './entities/transaction.entity';
import { TetardCoinService } from './tetard-coin.service';
import { ConversionService } from './conversion.service';
@Module({
imports: [TypeOrmModule.forFeature([TetardCoin, Transaction])],
providers: [TetardCoinService, ConversionService],
exports: [TetardCoinService, ConversionService],
})
export class EconomyModule {}

View File

@@ -0,0 +1,17 @@
import { Entity, PrimaryGeneratedColumn, Column, UpdateDateColumn, Index } from 'typeorm';
@Entity('tetard_coins')
export class TetardCoin {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'varchar', length: 255, unique: true })
userId: string;
@Column({ type: 'int', default: 0 })
balance: number;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,20 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
@Entity('tc_transactions')
export class Transaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'varchar', length: 255 })
userId: string;
@Column({ type: 'int' })
amount: number;
@Column({ type: 'varchar', length: 100 })
reason: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@@ -0,0 +1,99 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { BadRequestException } from '@nestjs/common';
import { TetardCoinService } from './tetard-coin.service';
import { TetardCoin } from './entities/tetard-coin.entity';
import { Transaction } from './entities/transaction.entity';
const makeTcRepo = () => ({
findOne: jest.fn(),
create: jest.fn((dto) => ({ ...dto })),
save: jest.fn(async (entity) => entity),
});
const makeTxRepo = () => ({
create: jest.fn((dto) => ({ ...dto })),
save: jest.fn(async (entity) => entity),
});
describe('TetardCoinService', () => {
let service: TetardCoinService;
let tcRepo: ReturnType<typeof makeTcRepo>;
let txRepo: ReturnType<typeof makeTxRepo>;
beforeEach(async () => {
tcRepo = makeTcRepo();
txRepo = makeTxRepo();
const module: TestingModule = await Test.createTestingModule({
providers: [
TetardCoinService,
{ provide: getRepositoryToken(TetardCoin), useValue: tcRepo },
{ provide: getRepositoryToken(Transaction), useValue: txRepo },
],
}).compile();
service = module.get<TetardCoinService>(TetardCoinService);
});
describe('earn()', () => {
it('augmente la balance (nouvel utilisateur)', async () => {
tcRepo.findOne.mockResolvedValue(null);
expect(await service.earn('u1', 10)).toBe(10);
});
it('augmente la balance (utilisateur existant)', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 50 });
expect(await service.earn('u1', 30)).toBe(80);
});
it('crée une entrée transaction audit', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 0 });
await service.earn('u1', 20, 'subscription-t1');
expect(txRepo.save).toHaveBeenCalledTimes(1);
expect(txRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'u1', amount: 20, reason: 'subscription-t1' }),
);
});
it('throw si amount <= 0', async () => {
await expect(service.earn('u1', 0)).rejects.toThrow(BadRequestException);
await expect(service.earn('u1', -5)).rejects.toThrow(BadRequestException);
});
});
describe('spend()', () => {
it('diminue la balance du montant correct', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 100 });
expect(await service.spend('u1', 30, 'endurance')).toBe(70);
});
it('throw si balance insuffisante (invariant ≥ 0)', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 10 });
await expect(service.spend('u1', 15, 'guild')).rejects.toThrow(BadRequestException);
});
it('throw si balance = 0', async () => {
tcRepo.findOne.mockResolvedValue(null);
await expect(service.spend('u1', 1, 'cosmetic')).rejects.toThrow(BadRequestException);
});
it('crée une transaction négative (audit)', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 50 });
await service.spend('u1', 20, 'forge');
expect(txRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ amount: -20, reason: 'forge' }),
);
});
});
describe('getBalance()', () => {
it('retourne 0 si aucun record', async () => {
tcRepo.findOne.mockResolvedValue(null);
expect(await service.getBalance('u1')).toBe(0);
});
it('retourne la balance correcte', async () => {
tcRepo.findOne.mockResolvedValue({ userId: 'u1', balance: 42 });
expect(await service.getBalance('u1')).toBe(42);
});
});
});

View File

@@ -0,0 +1,52 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TetardCoin } from './entities/tetard-coin.entity';
import { Transaction } from './entities/transaction.entity';
@Injectable()
export class TetardCoinService {
constructor(
@InjectRepository(TetardCoin)
private readonly tcRepo: Repository<TetardCoin>,
@InjectRepository(Transaction)
private readonly txRepo: Repository<Transaction>,
) {}
async getBalance(userId: string): Promise<number> {
const record = await this.tcRepo.findOne({ where: { userId } });
return record?.balance ?? 0;
}
async earn(userId: string, amount: number, reason = 'earn'): Promise<number> {
if (amount <= 0) throw new BadRequestException('earn amount must be > 0');
let record = await this.tcRepo.findOne({ where: { userId } });
if (!record) {
record = this.tcRepo.create({ userId, balance: 0 });
}
record.balance += amount;
await this.tcRepo.save(record);
await this.txRepo.save(this.txRepo.create({ userId, amount, reason }));
return record.balance;
}
async spend(userId: string, amount: number, reason: string): Promise<number> {
if (amount <= 0) throw new BadRequestException('spend amount must be > 0');
const record = await this.tcRepo.findOne({ where: { userId } });
const current = record?.balance ?? 0;
// Invariant absolu : balance ne peut jamais être négative
if (current < amount) {
throw new BadRequestException(
`Insufficient balance: has ${current} TC, needs ${amount} TC`,
);
}
record!.balance -= amount;
await this.tcRepo.save(record!);
await this.txRepo.save(this.txRepo.create({ userId, amount: -amount, reason }));
return record!.balance;
}
}

View File

@@ -0,0 +1,10 @@
import { Entity, PrimaryColumn, CreateDateColumn } from 'typeorm';
@Entity('processed_events')
export class ProcessedEvent {
@PrimaryColumn({ type: 'varchar', length: 255 })
id: string;
@CreateDateColumn({ name: 'processed_at' })
processedAt: Date;
}

View File

@@ -0,0 +1,148 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { TwitchEventService } from './twitch-event.service';
import { ProcessedEvent } from './entities/processed-event.entity';
import { TetardCoinService } from '../economy/tetard-coin.service';
import { ConversionService } from '../economy/conversion.service';
const makeProcessedRepo = () => ({
findOne: jest.fn(),
create: jest.fn((dto) => ({ ...dto })),
save: jest.fn(async (entity) => entity),
});
const makeTcService = () => ({
earn: jest.fn(async () => 0),
});
describe('TwitchEventService', () => {
let service: TwitchEventService;
let processedRepo: ReturnType<typeof makeProcessedRepo>;
let tcService: ReturnType<typeof makeTcService>;
beforeEach(async () => {
processedRepo = makeProcessedRepo();
tcService = makeTcService();
const module: TestingModule = await Test.createTestingModule({
providers: [
TwitchEventService,
ConversionService,
{ provide: getRepositoryToken(ProcessedEvent), useValue: processedRepo },
{ provide: TetardCoinService, useValue: tcService },
],
}).compile();
service = module.get<TwitchEventService>(TwitchEventService);
});
// ─── Cheer tests ────────────────────────────────────────────────────────
describe('handleCheer()', () => {
it('cheer 100 Bits → earn 10 TC (taux 10:1, pas de prime)', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleCheer('msg-1', 'user1', 100);
expect(tcService.earn).toHaveBeenCalledWith('user1', 10, 'cheer:100bits');
});
it('cheer 500 Bits → earn 55 TC (50 base + 5 prime seuil)', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleCheer('msg-2', 'user1', 500);
expect(tcService.earn).toHaveBeenCalledWith('user1', 55, 'cheer:500bits');
});
it('cheer 1000 Bits → earn 115 TC (100 base + 15 prime seuil)', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleCheer('msg-3', 'user1', 1000);
expect(tcService.earn).toHaveBeenCalledWith('user1', 115, 'cheer:1000bits');
});
it('cheer 5000 Bits → earn 575 TC (500 base + 75 prime seuil)', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleCheer('msg-4', 'user1', 5000);
expect(tcService.earn).toHaveBeenCalledWith('user1', 575, 'cheer:5000bits');
});
});
// ─── Idempotence tests ───────────────────────────────────────────────────
describe('idempotence', () => {
it('event déjà traité → pas de double crédit (cheer)', async () => {
processedRepo.findOne.mockResolvedValue({ id: 'msg-dup', processedAt: new Date() });
await service.handleCheer('msg-dup', 'user1', 100);
expect(tcService.earn).not.toHaveBeenCalled();
// Must not insert again
expect(processedRepo.save).not.toHaveBeenCalled();
});
it('event déjà traité → pas de double crédit (subscription)', async () => {
processedRepo.findOne.mockResolvedValue({ id: 'msg-sub-dup', processedAt: new Date() });
await service.handleSubscription('msg-sub-dup', 'user1', '1000', false);
expect(tcService.earn).not.toHaveBeenCalled();
});
it('premier event → inséré dans ProcessedEvent', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleCheer('msg-new', 'user1', 100);
expect(processedRepo.save).toHaveBeenCalledTimes(1);
});
});
// ─── Subscription tests ──────────────────────────────────────────────────
describe('handleSubscription()', () => {
it('T1 subscribe → earn 50 TC', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleSubscription('msg-s1', 'user1', '1000', false);
expect(tcService.earn).toHaveBeenCalledWith('user1', 50, 'subscribe:tier1000');
});
it('T2 subscribe → earn 120 TC', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleSubscription('msg-s2', 'user1', '2000', false);
expect(tcService.earn).toHaveBeenCalledWith('user1', 120, 'subscribe:tier2000');
});
it('T3 subscribe → earn 350 TC', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleSubscription('msg-s3', 'user1', '3000', false);
expect(tcService.earn).toHaveBeenCalledWith('user1', 350, 'subscribe:tier3000');
});
it('Prime subscribe → earn 30 TC', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleSubscription('msg-sp', 'user1', 'prime', false);
expect(tcService.earn).toHaveBeenCalledWith('user1', 30, 'subscribe:tierprime');
});
it('gift sub → gifter earns TC (gifterUserId)', async () => {
processedRepo.findOne.mockResolvedValue(null);
await service.handleSubscription('msg-gift', 'recipient', '1000', true, 'gifter1');
expect(tcService.earn).toHaveBeenCalledWith('gifter1', 50, 'gift-sub:tier1000');
});
});
// ─── Threshold bonus unit tests ──────────────────────────────────────────
describe('getThresholdBonus()', () => {
it('< 500 Bits → bonus 0', () => {
expect(service.getThresholdBonus(100)).toBe(0);
expect(service.getThresholdBonus(499)).toBe(0);
});
it('500 Bits → bonus +5', () => {
expect(service.getThresholdBonus(500)).toBe(5);
expect(service.getThresholdBonus(999)).toBe(5);
});
it('1000 Bits → bonus +15', () => {
expect(service.getThresholdBonus(1000)).toBe(15);
expect(service.getThresholdBonus(4999)).toBe(15);
});
it('5000 Bits → bonus +75', () => {
expect(service.getThresholdBonus(5000)).toBe(75);
expect(service.getThresholdBonus(10000)).toBe(75);
});
});
});

View File

@@ -0,0 +1,96 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TetardCoinService } from '../economy/tetard-coin.service';
import { ConversionService } from '../economy/conversion.service';
import { ProcessedEvent } from './entities/processed-event.entity';
// Subscription tier rewards — economy-design.md
const SUB_TC: Record<string, number> = {
'1000': 50, // T1
'2000': 120, // T2
'3000': 350, // T3
prime: 30,
};
// Bits threshold bonuses — economy-design.md
const BITS_BONUS: Array<{ threshold: number; bonus: number }> = [
{ threshold: 5000, bonus: 75 },
{ threshold: 1000, bonus: 15 },
{ threshold: 500, bonus: 5 },
];
@Injectable()
export class TwitchEventService {
private readonly logger = new Logger(TwitchEventService.name);
constructor(
@InjectRepository(ProcessedEvent)
private readonly processedRepo: Repository<ProcessedEvent>,
private readonly tcService: TetardCoinService,
private readonly conversionService: ConversionService,
) {}
/**
* Returns true if already processed (idempotence guard).
* If not seen before, inserts and returns false.
*/
async isAlreadyProcessed(messageId: string): Promise<boolean> {
const existing = await this.processedRepo.findOne({ where: { id: messageId } });
if (existing) return true;
await this.processedRepo.save(this.processedRepo.create({ id: messageId }));
return false;
}
/**
* channel.cheer — convert bits to TC with threshold bonus, then earn.
*/
async handleCheer(messageId: string, userId: string, bits: number): Promise<void> {
if (await this.isAlreadyProcessed(messageId)) {
this.logger.log(`Idempotent skip — messageId=${messageId}`);
return;
}
const baseTC = this.conversionService.bitsToTC(bits);
const bonus = this.getThresholdBonus(bits);
const total = baseTC + bonus;
await this.tcService.earn(userId, total, `cheer:${bits}bits`);
this.logger.log(`Cheer processed: userId=${userId} bits=${bits} tc=${total} (base=${baseTC} bonus=${bonus})`);
}
/**
* channel.subscribe / channel.subscription.gift
* gifted: if true, gifter earns TC (senderUserId), recipient earns nothing.
*/
async handleSubscription(
messageId: string,
userId: string,
tier: string,
gifted = false,
gifterUserId?: string,
): Promise<void> {
if (await this.isAlreadyProcessed(messageId)) {
this.logger.log(`Idempotent skip — messageId=${messageId}`);
return;
}
const tc = SUB_TC[tier] ?? SUB_TC['1000'];
const earnUserId = gifted && gifterUserId ? gifterUserId : userId;
const reason = gifted ? `gift-sub:tier${tier}` : `subscribe:tier${tier}`;
await this.tcService.earn(earnUserId, tc, reason);
this.logger.log(`Subscription processed: userId=${earnUserId} tier=${tier} tc=${tc} gifted=${gifted}`);
}
/**
* Compute threshold bonus based on economy-design.md table.
*/
getThresholdBonus(bits: number): number {
for (const { threshold, bonus } of BITS_BONUS) {
if (bits >= threshold) return bonus;
}
return 0;
}
}

View File

@@ -0,0 +1,97 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ForbiddenException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac } from 'crypto';
import { TwitchWebhookController } from './twitch-webhook.controller';
import { TwitchEventService } from './twitch-event.service';
const SECRET = 'test-webhook-secret';
const makeEventService = () => ({
handleCheer: jest.fn(),
handleSubscription: jest.fn(),
});
const makeConfigService = () => ({
get: jest.fn((key: string) => (key === 'TWITCH_WEBHOOK_SECRET' ? SECRET : undefined)),
});
function buildRequest(messageId: string, timestamp: string, bodyStr: string) {
const rawBody = Buffer.from(bodyStr, 'utf8');
const hmac = `sha256=${createHmac('sha256', SECRET)
.update(`${messageId}${timestamp}${bodyStr}`)
.digest('hex')}`;
return { rawBody, signature: hmac };
}
describe('TwitchWebhookController — signature', () => {
let controller: TwitchWebhookController;
let eventService: ReturnType<typeof makeEventService>;
beforeEach(async () => {
eventService = makeEventService();
const module: TestingModule = await Test.createTestingModule({
controllers: [TwitchWebhookController],
providers: [
{ provide: TwitchEventService, useValue: eventService },
{ provide: ConfigService, useValue: makeConfigService() },
],
}).compile();
controller = module.get<TwitchWebhookController>(TwitchWebhookController);
});
it('signature valide → traite la requête sans erreur', async () => {
const msgId = 'valid-msg-id';
const ts = new Date().toISOString();
const bodyObj = {
subscription: { type: 'channel.cheer' },
event: { user_id: 'user1', bits_used: 100 },
};
const bodyStr = JSON.stringify(bodyObj);
const { rawBody, signature } = buildRequest(msgId, ts, bodyStr);
const req = { rawBody } as any;
await expect(
controller.handleWebhook(req, msgId, ts, signature, 'notification', bodyObj as any),
).resolves.toEqual({ status: 'ok' });
});
it('signature invalide → ForbiddenException (400/403)', async () => {
const msgId = 'invalid-msg-id';
const ts = new Date().toISOString();
const bodyObj = {
subscription: { type: 'channel.cheer' },
event: { user_id: 'user1', bits_used: 100 },
};
const bodyStr = JSON.stringify(bodyObj);
const rawBody = Buffer.from(bodyStr, 'utf8');
const badSignature = 'sha256=invalidsignaturevalue00000000000000000000000000000000000000000000';
const req = { rawBody } as any;
await expect(
controller.handleWebhook(req, msgId, ts, badSignature, 'notification', bodyObj as any),
).rejects.toThrow(ForbiddenException);
expect(eventService.handleCheer).not.toHaveBeenCalled();
});
it('signature manquante → ForbiddenException', async () => {
const msgId = 'no-sig-msg';
const ts = new Date().toISOString();
const bodyObj = { subscription: { type: 'channel.cheer' }, event: {} };
const bodyStr = JSON.stringify(bodyObj);
const rawBody = Buffer.from(bodyStr, 'utf8');
const req = { rawBody } as any;
// Pass empty string as signature — length mismatch → ForbiddenException
await expect(
controller.handleWebhook(req, msgId, ts, '', 'notification', bodyObj as any),
).rejects.toThrow(ForbiddenException);
});
});

View File

@@ -0,0 +1,123 @@
import {
Controller,
Post,
Headers,
Body,
RawBodyRequest,
Req,
HttpCode,
HttpStatus,
Logger,
ForbiddenException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual } from 'crypto';
import { Request } from 'express';
import { TwitchEventService } from './twitch-event.service';
@Controller('twitch')
export class TwitchWebhookController {
private readonly logger = new Logger(TwitchWebhookController.name);
constructor(
private readonly configService: ConfigService,
private readonly twitchEventService: TwitchEventService,
) {}
@Post('webhook')
@HttpCode(HttpStatus.OK)
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('twitch-eventsub-message-id') messageId: string,
@Headers('twitch-eventsub-message-timestamp') timestamp: string,
@Headers('twitch-eventsub-message-signature') signature: string,
@Headers('twitch-eventsub-message-type') messageType: string,
@Body() body: Record<string, unknown>,
): Promise<{ status: string }> {
// Validate HMAC-SHA256 signature — security: validate-then-verify pattern
this.verifySignature(req, messageId, timestamp, signature);
// Twitch webhook verification challenge
if (messageType === 'webhook_callback_verification') {
return { status: body['challenge'] as string };
}
if (messageType === 'revocation') {
this.logger.warn(`Subscription revoked for messageId=${messageId}`);
return { status: 'ok' };
}
if (messageType !== 'notification') {
return { status: 'ok' };
}
const subscription = body['subscription'] as Record<string, unknown>;
const event = body['event'] as Record<string, unknown>;
const eventType = (subscription?.['type'] as string) ?? '';
switch (eventType) {
case 'channel.cheer':
await this.handleCheer(messageId, event);
break;
case 'channel.subscribe':
await this.handleSubscribe(messageId, event);
break;
case 'channel.subscription.gift':
await this.handleGiftSub(messageId, event);
break;
default:
this.logger.debug(`Unhandled event type: ${eventType}`);
}
return { status: 'ok' };
}
// ─── Private helpers ──────────────────────────────────────────────────────
private verifySignature(
req: RawBodyRequest<Request>,
messageId: string,
timestamp: string,
signature: string,
): void {
const secret = this.configService.get<string>('TWITCH_WEBHOOK_SECRET');
if (!secret) throw new ForbiddenException('Webhook secret not configured');
const rawBody = req.rawBody;
if (!rawBody) throw new ForbiddenException('Raw body unavailable');
const hmacMessage = `${messageId}${timestamp}${rawBody.toString('utf8')}`;
const expected = `sha256=${createHmac('sha256', secret).update(hmacMessage).digest('hex')}`;
// timingSafeEqual prevents timing attacks
const sigBuf = Buffer.from(signature ?? '');
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
this.logger.warn(`Invalid signature for messageId=${messageId}`);
throw new ForbiddenException('Invalid webhook signature');
}
}
private async handleCheer(messageId: string, event: Record<string, unknown>): Promise<void> {
const userId = event['user_id'] as string;
const bits = Number(event['bits_used'] ?? 0);
await this.twitchEventService.handleCheer(messageId, userId, bits);
}
private async handleSubscribe(messageId: string, event: Record<string, unknown>): Promise<void> {
const userId = event['user_id'] as string;
const tier = event['tier'] as string;
await this.twitchEventService.handleSubscription(messageId, userId, tier, false);
}
private async handleGiftSub(messageId: string, event: Record<string, unknown>): Promise<void> {
const gifterUserId = event['user_id'] as string;
const tier = event['tier'] as string;
// For gifts, gifter earns TC
await this.twitchEventService.handleSubscription(messageId, gifterUserId, tier, true, gifterUserId);
}
}

View File

@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { EconomyModule } from '../economy/economy.module';
import { ProcessedEvent } from './entities/processed-event.entity';
import { TwitchEventService } from './twitch-event.service';
import { TwitchWebhookController } from './twitch-webhook.controller';
@Module({
imports: [
ConfigModule,
TypeOrmModule.forFeature([ProcessedEvent]),
EconomyModule,
],
controllers: [TwitchWebhookController],
providers: [TwitchEventService],
exports: [TwitchEventService],
})
export class TwitchModule {}