feat(guild-events): add HMAC-signed webhook delivery and replay guards
This commit is contained in:
@@ -1,10 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createHmac, randomUUID } from 'crypto';
|
||||
import { FabricEventEnvelope } from './event-envelope';
|
||||
|
||||
@Injectable()
|
||||
export class EventsService {
|
||||
private readonly logger = new Logger(EventsService.name);
|
||||
private readonly sentEventIds = new Map<string, number>();
|
||||
|
||||
private cleanupSentCache(now: number): void {
|
||||
const ttlMs = 10 * 60 * 1000;
|
||||
for (const [eventId, ts] of this.sentEventIds.entries()) {
|
||||
if (now - ts > ttlMs) this.sentEventIds.delete(eventId);
|
||||
}
|
||||
}
|
||||
|
||||
private signWebhook(payload: string, timestamp: string, nonce: string): string {
|
||||
const secret = process.env.FABRIC_WEBHOOK_SECRET;
|
||||
if (!secret) return '';
|
||||
const canonical = ['POST', '/webhook/events', timestamp, nonce, payload].join('\n');
|
||||
return createHmac('sha256', secret).update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
buildEnvelope(input: {
|
||||
eventType: string;
|
||||
@@ -32,21 +47,39 @@ export class EventsService {
|
||||
data: Record<string, unknown>;
|
||||
}): Promise<FabricEventEnvelope> {
|
||||
const envelope = this.buildEnvelope(input);
|
||||
const now = Date.now();
|
||||
this.cleanupSentCache(now);
|
||||
|
||||
if (this.sentEventIds.has(envelope.event_id)) {
|
||||
this.logger.warn(`skip duplicate event_id: ${envelope.event_id}`);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
const webhookUrl = process.env.FABRIC_WEBHOOK_URL;
|
||||
if (!webhookUrl) {
|
||||
this.logger.log(`event(no-webhook): ${JSON.stringify(envelope)}`);
|
||||
this.sentEventIds.set(envelope.event_id, now);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
try {
|
||||
const timestamp = new Date(now).toISOString();
|
||||
const nonce = randomUUID();
|
||||
const payload = JSON.stringify(envelope);
|
||||
const signature = this.signWebhook(payload, timestamp, nonce);
|
||||
|
||||
await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-fabric-version': '1',
|
||||
'x-fabric-timestamp': timestamp,
|
||||
'x-fabric-nonce': nonce,
|
||||
'x-fabric-signature': signature,
|
||||
},
|
||||
body: JSON.stringify(envelope),
|
||||
body: payload,
|
||||
});
|
||||
this.sentEventIds.set(envelope.event_id, now);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`event delivery failed: ${message}`);
|
||||
|
||||
Reference in New Issue
Block a user