changemaker.lite/api/src/services/event-bus.service.ts
bunker-admin 0c2ffe754e Harden Stripe payment integration: 15 security fixes from audit
Addresses 11 original findings (1 critical, 3 high, 4 medium, 3 low)
plus 4 additional findings from security review:

- Mask secrets in PUT /settings response (was leaking decrypted keys)
- Add paymentCheckoutRateLimit (10/hr/IP) to all 5 checkout endpoints
- Implement durable audit logging to payment_audit_log table
- Pin Stripe API version to 2026-01-28.clover (SDK v20.3.1)
- Add charge.dispute.created/closed webhook handlers with DISPUTED status
- Restore tickets on dispute won, handle charge_refunded closure
- Guard against sentinel passthrough corrupting stored Stripe keys
- Wrap refund DB updates in try/catch with webhook reconciliation fallback
- Add $transaction for product maxPurchases race condition
- Remove dead Payment model lookup from handleChargeRefunded
- Cap donation amount at $100k in both schemas
- Add requirePaymentsEnabled middleware on all checkout routes
- Remove Stripe internal IDs from CSV exports
- Add Cache-Control: no-store on admin settings responses

Bunker Admin
2026-03-31 08:34:23 -06:00

184 lines
5.6 KiB
TypeScript

/**
* Platform EventBus — in-process pub/sub for decoupled service integration.
*
* Design:
* - Uses Node.js EventEmitter (single process, zero serialization overhead)
* - Typed events via PlatformEventMap (compile-time safety)
* - Wildcard subscriptions: subscribe('shift.*') catches all shift events
* - Error isolation: each listener wraps its handler in try-catch
* - Stats tracking: per-event and per-listener counters for observability
*
* Usage:
* // Publish (from any service)
* eventBus.publish('shift.signup.created', { shiftId, userName, ... });
*
* // Subscribe (from listeners registered at startup)
* eventBus.subscribe('shift.signup.created', async (payload) => { ... });
* eventBus.subscribe('shift.*', async (payload) => { ... }); // wildcard
*/
import { EventEmitter } from 'events';
import { logger } from '../utils/logger';
import type { PlatformEventMap, PlatformEventName, EventPayload } from '../types/events';
type EventHandler<E extends PlatformEventName> = (payload: EventPayload<E>) => void | Promise<void>;
interface ListenerRegistration {
name: string;
pattern: string;
handler: (event: string, payload: unknown) => void | Promise<void>;
}
interface EventStats {
published: number;
lastPublishedAt: Date | null;
}
class EventBus {
private emitter = new EventEmitter();
private listeners: ListenerRegistration[] = [];
private eventStats = new Map<string, EventStats>();
private listenerStats = new Map<string, { handled: number; errors: number }>();
constructor() {
// Allow many listeners (we'll have multiple per event)
this.emitter.setMaxListeners(100);
}
/**
* Publish a typed event. All matching subscribers are called asynchronously.
* This is fire-and-forget — errors in listeners do NOT propagate to the publisher.
*/
publish<E extends PlatformEventName>(event: E, payload: EventPayload<E>): void {
// Update stats
const stats = this.eventStats.get(event) ?? { published: 0, lastPublishedAt: null };
stats.published++;
stats.lastPublishedAt = new Date();
this.eventStats.set(event, stats);
// Emit to exact subscribers
this.emitter.emit(event, payload);
// Emit to wildcard subscribers
for (const reg of this.listeners) {
if (reg.pattern.endsWith('.*')) {
const prefix = reg.pattern.slice(0, -2);
if (event.startsWith(prefix + '.') && event !== reg.pattern) {
this.safeCall(reg.name, () => reg.handler(event, payload));
}
}
}
logger.debug(`EventBus: ${event}`, { event });
}
/**
* Subscribe to a specific event with a named listener.
* The name is used for stats tracking and debugging.
*/
subscribe<E extends PlatformEventName>(
event: E,
name: string,
handler: EventHandler<E>,
): void {
const wrappedHandler = (payload: EventPayload<E>) => {
this.safeCall(name, () => handler(payload));
};
this.emitter.on(event, wrappedHandler);
this.listeners.push({
name,
pattern: event,
handler: (_event: string, payload: unknown) => handler(payload as EventPayload<E>),
});
this.listenerStats.set(name, { handled: 0, errors: 0 });
}
/**
* Subscribe to all events matching a wildcard pattern (e.g., 'shift.*').
* Handler receives both the event name and payload.
*/
subscribePattern(
pattern: string,
name: string,
handler: (event: string, payload: unknown) => void | Promise<void>,
): void {
this.listeners.push({ name, pattern, handler });
this.listenerStats.set(name, { handled: 0, errors: 0 });
}
/**
* Call a handler with error isolation and stats tracking.
*/
private safeCall(listenerName: string, fn: () => void | Promise<void>): void {
const stats = this.listenerStats.get(listenerName);
try {
const result = fn();
if (result instanceof Promise) {
result
.then(() => {
if (stats) stats.handled++;
})
.catch((err) => {
if (stats) {
stats.handled++;
stats.errors++;
}
logger.debug(`EventBus listener "${listenerName}" error:`, err);
});
} else {
if (stats) stats.handled++;
}
} catch (err) {
if (stats) {
stats.handled++;
stats.errors++;
}
logger.debug(`EventBus listener "${listenerName}" sync error:`, err);
}
}
/**
* Get stats for observability dashboard.
*/
getStats(): {
totalEventsPublished: number;
eventCounts: Record<string, { published: number; lastPublishedAt: string | null }>;
listenerCounts: Record<string, { handled: number; errors: number }>;
registeredListeners: { name: string; pattern: string }[];
} {
let total = 0;
const eventCounts: Record<string, { published: number; lastPublishedAt: string | null }> = {};
for (const [name, stats] of this.eventStats) {
total += stats.published;
eventCounts[name] = {
published: stats.published,
lastPublishedAt: stats.lastPublishedAt?.toISOString() ?? null,
};
}
const listenerCounts: Record<string, { handled: number; errors: number }> = {};
for (const [name, stats] of this.listenerStats) {
listenerCounts[name] = { ...stats };
}
return {
totalEventsPublished: total,
eventCounts,
listenerCounts,
registeredListeners: this.listeners.map(l => ({ name: l.name, pattern: l.pattern })),
};
}
/**
* Remove all listeners (for testing or shutdown).
*/
removeAllListeners(): void {
this.emitter.removeAllListeners();
this.listeners = [];
}
}
export const eventBus = new EventBus();