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
184 lines
5.6 KiB
TypeScript
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();
|