@fonderie/events 5.0.0 → 5.0.2

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.
package/README.md CHANGED
@@ -34,7 +34,7 @@ subscribe to exactly the events you care about.
34
34
  You've shipped this plumbing before — auth, teams, billing, messaging —
35
35
  and the next project will ask for it again. Fonderie packages it once:
36
36
  plain TypeScript modules for
37
- [`@fonderie/core`](https://github.com/fonderie-js/sdk/tree/main/packages/core),
37
+ [`@fonderie/core`](https://github.com/fonderiejs/sdk/tree/main/packages/core),
38
38
  PostgreSQL-backed, self-hosted, MIT. No external control plane, no
39
39
  per-seat anything. Register the modules you need; skip the ones you don't.
40
40
 
@@ -42,7 +42,7 @@ per-seat anything. Register the modules you need; skip the ones you don't.
42
42
  producers and consumers stay decoupled — swap transports, keep handlers.
43
43
 
44
44
  Browse the whole set at
45
- [fonderie-js/sdk](https://github.com/fonderie-js/sdk) · follow
45
+ [fonderiejs/sdk](https://github.com/fonderiejs/sdk) · follow
46
46
  [@fonderiejs](https://x.com/fonderiejs)
47
47
 
48
48
  ## License
package/brain/outcomes.md CHANGED
@@ -29,6 +29,7 @@ type TEXT NOT NULL
29
29
  payload JSONB NOT NULL DEFAULT '{}'
30
30
  meta JSONB NOT NULL DEFAULT '{}'
31
31
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
32
+ hmac TEXT
32
33
  ```
33
34
 
34
35
  Raw SQL ships in `node_modules/@fonderie/events/dist/migrations/sql/` — read it there if you must; never download tarballs.
@@ -17,6 +17,7 @@ new EventsModule(config: IEventsConfig): EventsModule
17
17
  .name: "@fonderie/events"
18
18
  .bus: EventBus
19
19
  .install(_app: IFonderieApp): void
20
+ .checkReadiness(): IReadinessProblem[]
20
21
 
21
22
  interface IEventsConfig {
22
23
  transport: EventTransportConfig;
@@ -28,6 +29,7 @@ type EventTransportConfig = {
28
29
  maxRetries?: number;
29
30
  batchSize?: number;
30
31
  pollInterval?: number;
32
+ integrityKey?: string;
31
33
  } | IEventTransport;
32
34
 
33
35
  new MemoryTransport(): MemoryTransport
@@ -54,10 +56,56 @@ interface IPGTransportConfig {
54
56
  maxRetries?: number;
55
57
  batchSize?: number;
56
58
  pollInterval?: number;
59
+ integrityKey?: string;
57
60
  }
58
61
 
59
62
  function matchesPattern(pattern: string, eventType: string): boolean
60
63
 
64
+ function computeEventHmac(key: string, event: IHashableEvent): string
65
+
66
+ function verifyEventChain(store: IStoreAdapter, key: string): Promise<IIntegrityReport>
67
+
68
+ function canonicalize(value: unknown): string
69
+
70
+ function startIntegrityCheck(store: IStoreAdapter, key: string, options?: IIntegrityCheckOptions): IIntegrityCheckHandle
71
+
72
+ interface IIntegrityCheckOptions {
73
+ intervalMs?: number;
74
+ onResult?: (report: IIntegrityReport) => void;
75
+ onTamper?: (report: IIntegrityReport) => void;
76
+ }
77
+
78
+ interface IIntegrityCheckHandle {
79
+ stop: () => void;
80
+ }
81
+
82
+ interface IHashableEvent {
83
+ id: string;
84
+ type: string;
85
+ payload: unknown;
86
+ meta: unknown;
87
+ }
88
+
89
+ interface IIntegrityReport {
90
+ ok: boolean;
91
+ checked: number;
92
+ unprotected: number;
93
+ tampered: string[];
94
+ }
95
+
96
+ function purgeEvents(store: IStoreAdapter, { olderThanDays }: IPurgeEventsOptions): Promise<number>
97
+
98
+ function startEventRetention(store: IStoreAdapter, options: IRetentionScheduleOptions): { stop: () => void; }
99
+
100
+ interface IPurgeEventsOptions {
101
+ olderThanDays: number;
102
+ }
103
+
104
+ interface IRetentionScheduleOptions extends IPurgeEventsOptions {
105
+ intervalMs?: number;
106
+ onPurge?: (deleted: number) => void;
107
+ }
108
+
61
109
  interface IEventMeta {
62
110
  id: string;
63
111
  type: string;
package/dist/index.cjs CHANGED
@@ -35,7 +35,13 @@ __export(index_exports, {
35
35
  MemoryTransport: () => MemoryTransport,
36
36
  NOTIFICATION_EVENT: () => NOTIFICATION_EVENT,
37
37
  PGTransport: () => PGTransport,
38
- matchesPattern: () => matchesPattern
38
+ canonicalize: () => canonicalize,
39
+ computeEventHmac: () => computeEventHmac,
40
+ matchesPattern: () => matchesPattern,
41
+ purgeEvents: () => purgeEvents,
42
+ startEventRetention: () => startEventRetention,
43
+ startIntegrityCheck: () => startIntegrityCheck,
44
+ verifyEventChain: () => verifyEventChain
39
45
  });
40
46
  module.exports = __toCommonJS(index_exports);
41
47
 
@@ -80,6 +86,46 @@ function matchesPattern(pattern, eventType) {
80
86
  return regex.test(eventType);
81
87
  }
82
88
 
89
+ // src/integrity.ts
90
+ var import_node_crypto2 = require("crypto");
91
+ var import_core = require("@fonderie/core");
92
+ function canonicalize(value) {
93
+ return JSON.stringify(sortKeys(value));
94
+ }
95
+ function sortKeys(value) {
96
+ if (Array.isArray(value)) return value.map(sortKeys);
97
+ if (value && typeof value === "object") {
98
+ const out = {};
99
+ for (const k of Object.keys(value).sort()) {
100
+ out[k] = sortKeys(value[k]);
101
+ }
102
+ return out;
103
+ }
104
+ return value;
105
+ }
106
+ function computeEventHmac(key, event) {
107
+ return (0, import_node_crypto2.createHmac)("sha256", key).update(event.id).update("\n").update(event.type).update("\n").update(canonicalize(event.payload)).update("\n").update(canonicalize(event.meta)).digest("hex");
108
+ }
109
+ async function verifyEventChain(store, key) {
110
+ const rows = await store.query(
111
+ `SELECT id, type, payload, meta, hmac FROM fonderie_events ORDER BY created_at, id`
112
+ );
113
+ const report = { ok: true, checked: 0, unprotected: 0, tampered: [] };
114
+ for (const row of rows) {
115
+ if (row.hmac === null) {
116
+ report.unprotected += 1;
117
+ continue;
118
+ }
119
+ report.checked += 1;
120
+ const expected = computeEventHmac(key, row);
121
+ if (!(0, import_core.constantTimeEqual)(expected, row.hmac)) {
122
+ report.ok = false;
123
+ report.tampered.push(row.id);
124
+ }
125
+ }
126
+ return report;
127
+ }
128
+
83
129
  // src/transports/pg.ts
84
130
  var PGTransport = class {
85
131
  constructor(config) {
@@ -87,6 +133,7 @@ var PGTransport = class {
87
133
  this.maxRetries = config.maxRetries ?? 3;
88
134
  this.batchSize = config.batchSize ?? 10;
89
135
  this.pollInterval = config.pollInterval ?? 1e3;
136
+ this.integrityKey = config.integrityKey;
90
137
  }
91
138
  config;
92
139
  subscriptions = [];
@@ -97,15 +144,17 @@ var PGTransport = class {
97
144
  maxRetries;
98
145
  batchSize;
99
146
  pollInterval;
147
+ integrityKey;
100
148
  // ── Public API ──────────────────────────────────────────────────
101
149
  subscribe(pattern, handler, consumer) {
102
150
  this.subscriptions.push({ pattern, handler, consumer });
103
151
  }
104
152
  async publish(type, payload, meta) {
153
+ const hmac = this.integrityKey ? computeEventHmac(this.integrityKey, { id: meta.id, type, payload, meta }) : null;
105
154
  await this.store.query(
106
- `INSERT INTO fonderie_events (id, type, payload, meta)
107
- VALUES ($1, $2, $3, $4)`,
108
- [meta.id, type, JSON.stringify(payload), JSON.stringify(meta)]
155
+ `INSERT INTO fonderie_events (id, type, payload, meta, hmac)
156
+ VALUES ($1, $2, $3, $4, $5)`,
157
+ [meta.id, type, JSON.stringify(payload), JSON.stringify(meta), hmac]
109
158
  );
110
159
  const consumers = this.matchingConsumers(type);
111
160
  if (consumers.length > 0) {
@@ -240,7 +289,8 @@ function resolveTransport(config) {
240
289
  connectionUrl: config.connectionUrl,
241
290
  ...config.maxRetries !== void 0 ? { maxRetries: config.maxRetries } : {},
242
291
  ...config.batchSize !== void 0 ? { batchSize: config.batchSize } : {},
243
- ...config.pollInterval !== void 0 ? { pollInterval: config.pollInterval } : {}
292
+ ...config.pollInterval !== void 0 ? { pollInterval: config.pollInterval } : {},
293
+ ...config.integrityKey !== void 0 ? { integrityKey: config.integrityKey } : {}
244
294
  });
245
295
  }
246
296
  return config;
@@ -248,12 +298,28 @@ function resolveTransport(config) {
248
298
  var EventsModule = class {
249
299
  name = "@fonderie/events";
250
300
  bus;
301
+ config;
251
302
  constructor(config) {
303
+ this.config = config;
252
304
  this.bus = new EventBus(resolveTransport(config.transport));
253
305
  }
254
306
  install(_app) {
255
307
  this.bus.start().catch((err) => console.error("[events] failed to start transport", err));
256
308
  }
309
+ // The event log doubles as the audit trail. Without an integrityKey it is
310
+ // append-only but not tamper-evident, so a compromised DB write could alter
311
+ // history undetectably — a finding worth surfacing (not fatal).
312
+ checkReadiness() {
313
+ const t = this.config.transport;
314
+ if ("type" in t && t.type === "pg" && !t.integrityKey) {
315
+ return [{
316
+ module: this.name,
317
+ severity: "warning",
318
+ message: "no integrityKey \u2014 the event/audit log is not tamper-evident; set one to enable per-event HMACs"
319
+ }];
320
+ }
321
+ return [];
322
+ }
257
323
  };
258
324
 
259
325
  // src/transports/memory.ts
@@ -272,6 +338,74 @@ var MemoryTransport = class {
272
338
  }
273
339
  };
274
340
 
341
+ // src/integrity-job.ts
342
+ var DAY_MS = 24 * 60 * 60 * 1e3;
343
+ function startIntegrityCheck(store, key, options = {}) {
344
+ const intervalMs = options.intervalMs ?? DAY_MS;
345
+ const onTamper = options.onTamper ?? defaultTamperHandler;
346
+ let stopped = false;
347
+ const run = async () => {
348
+ if (stopped) return;
349
+ try {
350
+ const report = await verifyEventChain(store, key);
351
+ options.onResult?.(report);
352
+ if (!report.ok) onTamper(report);
353
+ } catch (err) {
354
+ console.error("[events] integrity check failed to run:", err);
355
+ }
356
+ };
357
+ const timer = setInterval(run, intervalMs);
358
+ if (typeof timer.unref === "function") {
359
+ timer.unref();
360
+ }
361
+ void run();
362
+ return {
363
+ stop: () => {
364
+ stopped = true;
365
+ clearInterval(timer);
366
+ }
367
+ };
368
+ }
369
+ function defaultTamperHandler(report) {
370
+ console.error(
371
+ `[events] AUDIT LOG INTEGRITY FAILURE \u2014 ${report.tampered.length} tampered row(s) out of ${report.checked} checked: ${report.tampered.join(", ")}`
372
+ );
373
+ }
374
+
375
+ // src/retention.ts
376
+ async function purgeEvents(store, { olderThanDays }) {
377
+ if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
378
+ throw new Error("[events] purgeEvents: olderThanDays must be a non-negative number");
379
+ }
380
+ const rows = await store.query(
381
+ `DELETE FROM fonderie_events
382
+ WHERE created_at < now() - make_interval(days => $1)
383
+ RETURNING id`,
384
+ [olderThanDays]
385
+ );
386
+ return rows.length;
387
+ }
388
+ function startEventRetention(store, options) {
389
+ const intervalMs = options.intervalMs ?? 24 * 60 * 60 * 1e3;
390
+ let stopped = false;
391
+ const run = async () => {
392
+ if (stopped) return;
393
+ try {
394
+ const deleted = await purgeEvents(store, { olderThanDays: options.olderThanDays });
395
+ options.onPurge?.(deleted);
396
+ } catch (err) {
397
+ console.error("[events] scheduled retention purge failed:", err);
398
+ }
399
+ };
400
+ const timer = setInterval(run, intervalMs);
401
+ if (typeof timer.unref === "function") timer.unref();
402
+ void run();
403
+ return { stop: () => {
404
+ stopped = true;
405
+ clearInterval(timer);
406
+ } };
407
+ }
408
+
275
409
  // src/index.ts
276
410
  var NOTIFICATION_EVENT = "fonderie.notification.send";
277
411
  // Annotate the CommonJS export names for ESM import in node:
@@ -281,6 +415,12 @@ var NOTIFICATION_EVENT = "fonderie.notification.send";
281
415
  MemoryTransport,
282
416
  NOTIFICATION_EVENT,
283
417
  PGTransport,
284
- matchesPattern
418
+ canonicalize,
419
+ computeEventHmac,
420
+ matchesPattern,
421
+ purgeEvents,
422
+ startEventRetention,
423
+ startIntegrityCheck,
424
+ verifyEventChain
285
425
  });
286
426
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/bus.ts","../src/transports/pg.ts","../src/transports/pattern.ts","../src/module.ts","../src/transports/memory.ts"],"sourcesContent":["export { EventBus } from './bus';\nexport { EventsModule } from './module';\nexport type { IEventsConfig, EventTransportConfig } from './module';\n\nexport { MemoryTransport, PGTransport } from './transports';\nexport type { IEventTransport, IPGTransportConfig } from './transports';\n\nexport { matchesPattern } from './transports/pattern';\n\nexport type { IEventMeta, IEventHandler, IEventRecord, IConsumerRecord } from './types';\n\n// ── Typed event keys ─────────────────────────────────────────────\n// Each domain package re-exports its own EVENT_KEYS.\n// Consumers alias on import:\n// import { EVENT_KEYS as AUTH_EVENT_KEYS } from '@fonderie/auth'\n\nexport const NOTIFICATION_EVENT = 'fonderie.notification.send' as const;\nexport type NotificationEvent = typeof NOTIFICATION_EVENT;\n","import { randomUUID } from 'node:crypto';\n\nimport type { IEventTransport } from './transports/types';\nimport type { IEventMeta, IEventHandler } from './types';\n\nexport class EventBus {\n\tconstructor(private transport: IEventTransport) {}\n\n\tasync emit<T = unknown>(type: string, payload: T, opts?: { requestId?: string }): Promise<void> {\n\t\tconst meta: IEventMeta = {\n\t\t\tid: randomUUID(),\n\t\t\ttype,\n\t\t\temittedAt: new Date().toISOString(),\n\t\t\tattempts: 0,\n\t\t\t...(opts?.requestId !== undefined ? { requestId: opts.requestId } : {}),\n\t\t};\n\t\tawait this.transport.publish(type, payload, meta);\n\t}\n\n\t// consumer identifies the logical subscriber for per-consumer delivery tracking.\n\t// Defaults to the pattern string — stable and predictable for single-subscriber patterns.\n\ton<T = unknown>(type: string, handler: IEventHandler<T>, consumer: string = type): void {\n\t\tthis.transport.subscribe(type, handler as IEventHandler, consumer);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tawait this.transport.start();\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tawait this.transport.stop();\n\t}\n}\n","import pg from 'pg';\n\nimport { PGAdapter } from '@fonderie/store';\nimport type { IStoreAdapter } from '@fonderie/store';\nimport type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler, IEventRecord } from '../types';\nimport { matchesPattern } from './pattern';\n\nexport interface IPGTransportConfig {\n\tconnectionUrl: string;\n\tmaxRetries?: number; // default 3\n\tbatchSize?: number; // default 10 rows claimed per consumer per poll cycle\n\tpollInterval?: number; // default 1000ms fallback poll when no NOTIFY arrives\n}\n\ninterface Subscription {\n\tpattern: string;\n\thandler: IEventHandler;\n\tconsumer: string;\n}\n\nexport class PGTransport implements IEventTransport {\n\tprivate subscriptions: Subscription[] = [];\n\tprivate listenClient: pg.Client | null = null;\n\tprivate store!: IStoreAdapter;\n\tprivate running = false;\n\tprivate wakeResolvers: Array<() => void> = [];\n\n\tprivate readonly maxRetries: number;\n\tprivate readonly batchSize: number;\n\tprivate readonly pollInterval: number;\n\n\tconstructor(private config: IPGTransportConfig) {\n\t\tthis.maxRetries = config.maxRetries ?? 3;\n\t\tthis.batchSize = config.batchSize ?? 10;\n\t\tthis.pollInterval = config.pollInterval ?? 1_000;\n\t}\n\n\t// ── Public API ──────────────────────────────────────────────────\n\n\tsubscribe(pattern: string, handler: IEventHandler, consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler, consumer });\n\t}\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_events (id, type, payload, meta)\n\t\t\t VALUES ($1, $2, $3, $4)`,\n\t\t\t[meta.id, type, JSON.stringify(payload), JSON.stringify(meta)],\n\t\t);\n\n\t\tconst consumers = this.matchingConsumers(type);\n\t\tif (consumers.length > 0) {\n\t\t\tawait this.store.query(\n\t\t\t\t`INSERT INTO fonderie_event_consumers (event_id, consumer, status, attempts)\n\t\t\t\t SELECT $1, unnest($2::text[]), 'pending', 0\n\t\t\t\t ON CONFLICT (event_id, consumer) DO NOTHING`,\n\t\t\t\t[meta.id, consumers],\n\t\t\t);\n\t\t}\n\n\t\t// NOTIFY carries no payload — it is a wake signal only\n\t\tawait this.store.query(`SELECT pg_notify('fonderie_events', '')`);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tthis.running = true;\n\t\tthis.store = new PGAdapter(this.config.connectionUrl);\n\n\t\t// Reset any rows left in 'processing' by a crashed instance\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_event_consumers SET status = 'failed' WHERE status = 'processing'`,\n\t\t);\n\n\t\tthis.listenClient = new pg.Client(this.config.connectionUrl);\n\t\tawait this.listenClient.connect();\n\t\tawait this.listenClient.query('LISTEN fonderie_events');\n\n\t\tthis.listenClient.on('notification', () => this.wake());\n\t\tthis.listenClient.on('error', (err) =>\n\t\t\tconsole.error('[events:pg] listen client error:', err.message),\n\t\t);\n\n\t\tthis.runPollLoop().catch((err) => console.error('[events:pg] poll loop crashed:', err));\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tthis.running = false;\n\t\tthis.wake();\n\t\tawait this.listenClient?.end();\n\t\tthis.listenClient = null;\n\t}\n\n\t// ── Poll loop ───────────────────────────────────────────────────\n\n\tprivate async runPollLoop(): Promise<void> {\n\t\twhile (this.running) {\n\t\t\ttry {\n\t\t\t\tconst hadWork = await this.pollAllConsumers();\n\t\t\t\tif (!hadWork) await this.sleep();\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error('[events:pg] poll error:', err);\n\t\t\t\tawait this.sleep();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async pollAllConsumers(): Promise<boolean> {\n\t\tconst consumers = [...new Set(this.subscriptions.map((s) => s.consumer))];\n\t\tconst results = await Promise.all(consumers.map((c) => this.pollConsumer(c)));\n\t\treturn results.some((n) => n > 0);\n\t}\n\n\tprivate async pollConsumer(consumer: string): Promise<number> {\n\t\tconst claimed = await this.store.query<{ event_id: string }>(\n\t\t\t`UPDATE fonderie_event_consumers c\n\t\t\t SET status = 'processing', attempts = c.attempts + 1\n\t\t\t FROM (\n\t\t\t SELECT event_id\n\t\t\t FROM fonderie_event_consumers\n\t\t\t WHERE consumer = $1\n\t\t\t AND status IN ('pending', 'failed')\n\t\t\t AND attempts < $2\n\t\t\t ORDER BY event_id\n\t\t\t LIMIT $3\n\t\t\t FOR UPDATE SKIP LOCKED\n\t\t\t ) AS locked\n\t\t\t WHERE c.event_id = locked.event_id\n\t\t\t AND c.consumer = $1\n\t\t\t RETURNING c.event_id`,\n\t\t\t[consumer, this.maxRetries, this.batchSize],\n\t\t);\n\n\t\tawait Promise.all(claimed.map((row) => this.processConsumerEvent(consumer, row.event_id)));\n\t\treturn claimed.length;\n\t}\n\n\t// ── Event processing ────────────────────────────────────────────\n\n\tprivate async processConsumerEvent(consumer: string, eventId: string): Promise<void> {\n\t\tconst [event] = await this.store.query<IEventRecord>(\n\t\t\t`SELECT type, payload, meta FROM fonderie_events WHERE id = $1`,\n\t\t\t[eventId],\n\t\t);\n\t\tif (!event) return;\n\n\t\tconst handlers = this.subscriptions\n\t\t\t.filter((s) => s.consumer === consumer && matchesPattern(s.pattern, event.type))\n\t\t\t.map((s) => s.handler);\n\n\t\ttry {\n\t\t\tawait Promise.all(handlers.map((h) => h(event.payload, event.meta)));\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = 'processed', processed_at = now()\n\t\t\t\t WHERE event_id = $1 AND consumer = $2`,\n\t\t\t\t[eventId, consumer],\n\t\t\t);\n\t\t} catch (err) {\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = CASE WHEN attempts >= $1 THEN 'dead' ELSE 'failed' END,\n\t\t\t\t error = $2\n\t\t\t\t WHERE event_id = $3 AND consumer = $4`,\n\t\t\t\t[this.maxRetries, err instanceof Error ? err.message : String(err), eventId, consumer],\n\t\t\t);\n\t\t}\n\t}\n\n\t// ── Helpers ─────────────────────────────────────────────────────\n\n\tprivate matchingConsumers(eventType: string): string[] {\n\t\tconst seen = new Set<string>();\n\t\tfor (const sub of this.subscriptions) {\n\t\t\tif (matchesPattern(sub.pattern, eventType)) seen.add(sub.consumer);\n\t\t}\n\t\treturn [...seen];\n\t}\n\n\tprivate sleep(): Promise<void> {\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tlet timer: ReturnType<typeof setTimeout>;\n\t\t\tconst wake = () => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tresolve();\n\t\t\t};\n\t\t\ttimer = setTimeout(() => {\n\t\t\t\tconst idx = this.wakeResolvers.indexOf(wake);\n\t\t\t\tif (idx !== -1) this.wakeResolvers.splice(idx, 1);\n\t\t\t\tresolve();\n\t\t\t}, this.pollInterval);\n\t\t\tthis.wakeResolvers.push(wake);\n\t\t});\n\t}\n\n\tprivate wake(): void {\n\t\tthis.wakeResolvers.shift()?.();\n\t}\n}\n","// Glob matching for event topic patterns.\n// '*' alone matches everything. Otherwise '*' is a wildcard for any\n// characters including dots, so 'sport.*' matches 'sport.event.created'.\nexport function matchesPattern(pattern: string, eventType: string): boolean {\n\tif (pattern === '*') return true;\n\tconst regex = new RegExp('^' + pattern.replace(/\\./g, '\\\\.').replace(/\\*/g, '.*') + '$');\n\treturn regex.test(eventType);\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\n\nimport { EventBus } from './bus';\nimport { PGTransport } from './transports/pg';\nimport type { IEventTransport } from './transports/types';\n\nexport type EventTransportConfig =\n\t| {\n\t\t\ttype: 'pg';\n\t\t\tconnectionUrl: string;\n\t\t\tmaxRetries?: number;\n\t\t\tbatchSize?: number;\n\t\t\tpollInterval?: number;\n\t }\n\t| IEventTransport;\n\nexport interface IEventsConfig {\n\ttransport: EventTransportConfig;\n}\n\nfunction resolveTransport(config: EventTransportConfig): IEventTransport {\n\tif ('type' in config && config.type === 'pg') {\n\t\treturn new PGTransport({\n\t\t\tconnectionUrl: config.connectionUrl,\n\t\t\t...(config.maxRetries !== undefined ? { maxRetries: config.maxRetries } : {}),\n\t\t\t...(config.batchSize !== undefined ? { batchSize: config.batchSize } : {}),\n\t\t\t...(config.pollInterval !== undefined ? { pollInterval: config.pollInterval } : {}),\n\t\t});\n\t}\n\n\treturn config as IEventTransport;\n}\n\nexport class EventsModule implements IFonderieModule {\n\treadonly name = '@fonderie/events';\n\treadonly bus: EventBus;\n\n\tconstructor(config: IEventsConfig) {\n\t\tthis.bus = new EventBus(resolveTransport(config.transport));\n\t}\n\n\tinstall(_app: IFonderieApp): void {\n\t\tthis.bus.start().catch((err) => console.error('[events] failed to start transport', err));\n\t}\n}\n","import type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler } from '../types';\nimport { matchesPattern } from './pattern';\n\nexport class MemoryTransport implements IEventTransport {\n\tprivate subscriptions: Array<{ pattern: string; handler: IEventHandler }> = [];\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tconst matching = this.subscriptions.filter((s) => matchesPattern(s.pattern, type));\n\t\tawait Promise.all(matching.map((s) => s.handler(payload, meta)));\n\t}\n\n\tsubscribe(pattern: string, handler: IEventHandler, _consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler });\n\t}\n\n\tasync start(): Promise<void> {}\n\tasync stop(): Promise<void> {}\n}\n"],"mappings":"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","names":["pg"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/bus.ts","../src/transports/pg.ts","../src/transports/pattern.ts","../src/integrity.ts","../src/module.ts","../src/transports/memory.ts","../src/integrity-job.ts","../src/retention.ts"],"sourcesContent":["export { EventBus } from './bus';\nexport { EventsModule } from './module';\nexport type { IEventsConfig, EventTransportConfig } from './module';\n\nexport { MemoryTransport, PGTransport } from './transports';\nexport type { IEventTransport, IPGTransportConfig } from './transports';\n\nexport { matchesPattern } from './transports/pattern';\n\n// Audit-log tamper-evidence\nexport { computeEventHmac, verifyEventChain, canonicalize } from './integrity';\nexport { startIntegrityCheck } from './integrity-job';\nexport type { IIntegrityCheckOptions, IIntegrityCheckHandle } from './integrity-job';\nexport type { IHashableEvent, IIntegrityReport } from './integrity';\n\n// Retention / disposal\nexport { purgeEvents, startEventRetention } from './retention';\nexport type { IPurgeEventsOptions, IRetentionScheduleOptions } from './retention';\n\nexport type { IEventMeta, IEventHandler, IEventRecord, IConsumerRecord } from './types';\n\n// ── Typed event keys ─────────────────────────────────────────────\n// Each domain package re-exports its own EVENT_KEYS.\n// Consumers alias on import:\n// import { EVENT_KEYS as AUTH_EVENT_KEYS } from '@fonderie/auth'\n\nexport const NOTIFICATION_EVENT = 'fonderie.notification.send' as const;\nexport type NotificationEvent = typeof NOTIFICATION_EVENT;\n","import { randomUUID } from 'node:crypto';\n\nimport type { IEventTransport } from './transports/types';\nimport type { IEventMeta, IEventHandler } from './types';\n\nexport class EventBus {\n\tconstructor(private transport: IEventTransport) {}\n\n\tasync emit<T = unknown>(type: string, payload: T, opts?: { requestId?: string }): Promise<void> {\n\t\tconst meta: IEventMeta = {\n\t\t\tid: randomUUID(),\n\t\t\ttype,\n\t\t\temittedAt: new Date().toISOString(),\n\t\t\tattempts: 0,\n\t\t\t...(opts?.requestId !== undefined ? { requestId: opts.requestId } : {}),\n\t\t};\n\t\tawait this.transport.publish(type, payload, meta);\n\t}\n\n\t// consumer identifies the logical subscriber for per-consumer delivery tracking.\n\t// Defaults to the pattern string — stable and predictable for single-subscriber patterns.\n\ton<T = unknown>(type: string, handler: IEventHandler<T>, consumer: string = type): void {\n\t\tthis.transport.subscribe(type, handler as IEventHandler, consumer);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tawait this.transport.start();\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tawait this.transport.stop();\n\t}\n}\n","import pg from 'pg';\n\nimport { PGAdapter } from '@fonderie/store';\nimport type { IStoreAdapter } from '@fonderie/store';\nimport type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler, IEventRecord } from '../types';\nimport { matchesPattern } from './pattern';\nimport { computeEventHmac } from '../integrity';\n\nexport interface IPGTransportConfig {\n\tconnectionUrl: string;\n\tmaxRetries?: number; // default 3\n\tbatchSize?: number; // default 10 rows claimed per consumer per poll cycle\n\tpollInterval?: number; // default 1000ms fallback poll when no NOTIFY arrives\n\t// When set, every event is stored with a keyed HMAC over its immutable\n\t// content, making the audit log tamper-evident. Unset → no HMAC (unchanged\n\t// behaviour). Verify later with `verifyEventChain(store, integrityKey)`.\n\tintegrityKey?: string;\n}\n\ninterface Subscription {\n\tpattern: string;\n\thandler: IEventHandler;\n\tconsumer: string;\n}\n\nexport class PGTransport implements IEventTransport {\n\tprivate subscriptions: Subscription[] = [];\n\tprivate listenClient: pg.Client | null = null;\n\tprivate store!: IStoreAdapter;\n\tprivate running = false;\n\tprivate wakeResolvers: Array<() => void> = [];\n\n\tprivate readonly maxRetries: number;\n\tprivate readonly batchSize: number;\n\tprivate readonly pollInterval: number;\n\tprivate readonly integrityKey: string | undefined;\n\n\tconstructor(private config: IPGTransportConfig) {\n\t\tthis.maxRetries = config.maxRetries ?? 3;\n\t\tthis.batchSize = config.batchSize ?? 10;\n\t\tthis.pollInterval = config.pollInterval ?? 1_000;\n\t\tthis.integrityKey = config.integrityKey;\n\t}\n\n\t// ── Public API ──────────────────────────────────────────────────\n\n\tsubscribe(pattern: string, handler: IEventHandler, consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler, consumer });\n\t}\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tconst hmac = this.integrityKey\n\t\t\t? computeEventHmac(this.integrityKey, { id: meta.id, type, payload, meta })\n\t\t\t: null;\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_events (id, type, payload, meta, hmac)\n\t\t\t VALUES ($1, $2, $3, $4, $5)`,\n\t\t\t[meta.id, type, JSON.stringify(payload), JSON.stringify(meta), hmac],\n\t\t);\n\n\t\tconst consumers = this.matchingConsumers(type);\n\t\tif (consumers.length > 0) {\n\t\t\tawait this.store.query(\n\t\t\t\t`INSERT INTO fonderie_event_consumers (event_id, consumer, status, attempts)\n\t\t\t\t SELECT $1, unnest($2::text[]), 'pending', 0\n\t\t\t\t ON CONFLICT (event_id, consumer) DO NOTHING`,\n\t\t\t\t[meta.id, consumers],\n\t\t\t);\n\t\t}\n\n\t\t// NOTIFY carries no payload — it is a wake signal only\n\t\tawait this.store.query(`SELECT pg_notify('fonderie_events', '')`);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tthis.running = true;\n\t\tthis.store = new PGAdapter(this.config.connectionUrl);\n\n\t\t// Reset any rows left in 'processing' by a crashed instance\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_event_consumers SET status = 'failed' WHERE status = 'processing'`,\n\t\t);\n\n\t\tthis.listenClient = new pg.Client(this.config.connectionUrl);\n\t\tawait this.listenClient.connect();\n\t\tawait this.listenClient.query('LISTEN fonderie_events');\n\n\t\tthis.listenClient.on('notification', () => this.wake());\n\t\tthis.listenClient.on('error', (err) =>\n\t\t\tconsole.error('[events:pg] listen client error:', err.message),\n\t\t);\n\n\t\tthis.runPollLoop().catch((err) => console.error('[events:pg] poll loop crashed:', err));\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tthis.running = false;\n\t\tthis.wake();\n\t\tawait this.listenClient?.end();\n\t\tthis.listenClient = null;\n\t}\n\n\t// ── Poll loop ───────────────────────────────────────────────────\n\n\tprivate async runPollLoop(): Promise<void> {\n\t\twhile (this.running) {\n\t\t\ttry {\n\t\t\t\tconst hadWork = await this.pollAllConsumers();\n\t\t\t\tif (!hadWork) await this.sleep();\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error('[events:pg] poll error:', err);\n\t\t\t\tawait this.sleep();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async pollAllConsumers(): Promise<boolean> {\n\t\tconst consumers = [...new Set(this.subscriptions.map((s) => s.consumer))];\n\t\tconst results = await Promise.all(consumers.map((c) => this.pollConsumer(c)));\n\t\treturn results.some((n) => n > 0);\n\t}\n\n\tprivate async pollConsumer(consumer: string): Promise<number> {\n\t\tconst claimed = await this.store.query<{ event_id: string }>(\n\t\t\t`UPDATE fonderie_event_consumers c\n\t\t\t SET status = 'processing', attempts = c.attempts + 1\n\t\t\t FROM (\n\t\t\t SELECT event_id\n\t\t\t FROM fonderie_event_consumers\n\t\t\t WHERE consumer = $1\n\t\t\t AND status IN ('pending', 'failed')\n\t\t\t AND attempts < $2\n\t\t\t ORDER BY event_id\n\t\t\t LIMIT $3\n\t\t\t FOR UPDATE SKIP LOCKED\n\t\t\t ) AS locked\n\t\t\t WHERE c.event_id = locked.event_id\n\t\t\t AND c.consumer = $1\n\t\t\t RETURNING c.event_id`,\n\t\t\t[consumer, this.maxRetries, this.batchSize],\n\t\t);\n\n\t\tawait Promise.all(claimed.map((row) => this.processConsumerEvent(consumer, row.event_id)));\n\t\treturn claimed.length;\n\t}\n\n\t// ── Event processing ────────────────────────────────────────────\n\n\tprivate async processConsumerEvent(consumer: string, eventId: string): Promise<void> {\n\t\tconst [event] = await this.store.query<IEventRecord>(\n\t\t\t`SELECT type, payload, meta FROM fonderie_events WHERE id = $1`,\n\t\t\t[eventId],\n\t\t);\n\t\tif (!event) return;\n\n\t\tconst handlers = this.subscriptions\n\t\t\t.filter((s) => s.consumer === consumer && matchesPattern(s.pattern, event.type))\n\t\t\t.map((s) => s.handler);\n\n\t\ttry {\n\t\t\tawait Promise.all(handlers.map((h) => h(event.payload, event.meta)));\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = 'processed', processed_at = now()\n\t\t\t\t WHERE event_id = $1 AND consumer = $2`,\n\t\t\t\t[eventId, consumer],\n\t\t\t);\n\t\t} catch (err) {\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = CASE WHEN attempts >= $1 THEN 'dead' ELSE 'failed' END,\n\t\t\t\t error = $2\n\t\t\t\t WHERE event_id = $3 AND consumer = $4`,\n\t\t\t\t[this.maxRetries, err instanceof Error ? err.message : String(err), eventId, consumer],\n\t\t\t);\n\t\t}\n\t}\n\n\t// ── Helpers ─────────────────────────────────────────────────────\n\n\tprivate matchingConsumers(eventType: string): string[] {\n\t\tconst seen = new Set<string>();\n\t\tfor (const sub of this.subscriptions) {\n\t\t\tif (matchesPattern(sub.pattern, eventType)) seen.add(sub.consumer);\n\t\t}\n\t\treturn [...seen];\n\t}\n\n\tprivate sleep(): Promise<void> {\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tlet timer: ReturnType<typeof setTimeout>;\n\t\t\tconst wake = () => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tresolve();\n\t\t\t};\n\t\t\ttimer = setTimeout(() => {\n\t\t\t\tconst idx = this.wakeResolvers.indexOf(wake);\n\t\t\t\tif (idx !== -1) this.wakeResolvers.splice(idx, 1);\n\t\t\t\tresolve();\n\t\t\t}, this.pollInterval);\n\t\t\tthis.wakeResolvers.push(wake);\n\t\t});\n\t}\n\n\tprivate wake(): void {\n\t\tthis.wakeResolvers.shift()?.();\n\t}\n}\n","// Glob matching for event topic patterns.\n// '*' alone matches everything. Otherwise '*' is a wildcard for any\n// characters including dots, so 'sport.*' matches 'sport.event.created'.\nexport function matchesPattern(pattern: string, eventType: string): boolean {\n\tif (pattern === '*') return true;\n\tconst regex = new RegExp('^' + pattern.replace(/\\./g, '\\\\.').replace(/\\*/g, '.*') + '$');\n\treturn regex.test(eventType);\n}\n","import { createHmac } from 'node:crypto';\n\nimport { constantTimeEqual } from '@fonderie/core';\n\nimport type { IStoreAdapter } from '@fonderie/store';\n\n// Tamper-evidence for the append-only event log. Each row carries an HMAC-SHA256\n// over its immutable content, keyed by a server-held secret. An auditor (or a\n// scheduled job) re-derives every HMAC and compares: any modified or forged row\n// fails, because rewriting it without the key can't produce a matching HMAC.\n//\n// Scope: this detects *content* tampering and forged rows. It does not by itself\n// prove no whole row was deleted — that is the job of append-only grants,\n// restricted DB permissions, and backups. Kept deliberately keyed-per-row (not a\n// prev-hash chain) so publishing stays lock-free on the hot event-bus path.\n\n// Deterministic JSON: recursively sort object keys so the same logical value\n// always serialises identically, regardless of insertion order or a JSONB\n// round-trip through Postgres.\nexport function canonicalize(value: unknown): string {\n\treturn JSON.stringify(sortKeys(value));\n}\n\nfunction sortKeys(value: unknown): unknown {\n\tif (Array.isArray(value)) return value.map(sortKeys);\n\tif (value && typeof value === 'object') {\n\t\tconst out: Record<string, unknown> = {};\n\t\tfor (const k of Object.keys(value as Record<string, unknown>).sort()) {\n\t\t\tout[k] = sortKeys((value as Record<string, unknown>)[k]);\n\t\t}\n\t\treturn out;\n\t}\n\treturn value;\n}\n\nexport interface IHashableEvent {\n\tid: string;\n\ttype: string;\n\tpayload: unknown;\n\tmeta: unknown;\n}\n\n// The HMAC over an event's immutable fields. Field separators (\\n) are safe\n// because they can't appear unescaped inside a JSON string or a UUID/type.\nexport function computeEventHmac(key: string, event: IHashableEvent): string {\n\treturn createHmac('sha256', key)\n\t\t.update(event.id)\n\t\t.update('\\n')\n\t\t.update(event.type)\n\t\t.update('\\n')\n\t\t.update(canonicalize(event.payload))\n\t\t.update('\\n')\n\t\t.update(canonicalize(event.meta))\n\t\t.digest('hex');\n}\n\nexport interface IIntegrityReport {\n\t// True when every HMAC-carrying row verified.\n\tok: boolean;\n\t// Rows that carried an HMAC and were checked.\n\tchecked: number;\n\t// Rows with no HMAC (published before integrity was enabled) — skipped.\n\tunprotected: number;\n\t// Ids of rows whose stored HMAC did not match a fresh computation.\n\ttampered: string[];\n}\n\ninterface IRawEventRow {\n\tid: string;\n\ttype: string;\n\tpayload: unknown;\n\tmeta: unknown;\n\thmac: string | null;\n}\n\n// Walk the whole event log and re-verify every HMAC-carrying row. Intended for a\n// scheduled integrity job or an on-demand audit endpoint.\nexport async function verifyEventChain(store: IStoreAdapter, key: string): Promise<IIntegrityReport> {\n\tconst rows = await store.query<IRawEventRow>(\n\t\t`SELECT id, type, payload, meta, hmac FROM fonderie_events ORDER BY created_at, id`,\n\t);\n\n\tconst report: IIntegrityReport = { ok: true, checked: 0, unprotected: 0, tampered: [] };\n\n\tfor (const row of rows) {\n\t\tif (row.hmac === null) {\n\t\t\treport.unprotected += 1;\n\t\t\tcontinue;\n\t\t}\n\t\treport.checked += 1;\n\t\tconst expected = computeEventHmac(key, row);\n\t\tif (!constantTimeEqual(expected, row.hmac)) {\n\t\t\treport.ok = false;\n\t\t\treport.tampered.push(row.id);\n\t\t}\n\t}\n\n\treturn report;\n}\n","import type { IFonderieModule, IFonderieApp, IReadinessProblem } from '@fonderie/core';\n\nimport { EventBus } from './bus';\nimport { PGTransport } from './transports/pg';\nimport type { IEventTransport } from './transports/types';\n\nexport type EventTransportConfig =\n\t| {\n\t\t\ttype: 'pg';\n\t\t\tconnectionUrl: string;\n\t\t\tmaxRetries?: number;\n\t\t\tbatchSize?: number;\n\t\t\tpollInterval?: number;\n\t\t\t// Enables tamper-evident audit logging (keyed HMAC per event).\n\t\t\tintegrityKey?: string;\n\t }\n\t| IEventTransport;\n\nexport interface IEventsConfig {\n\ttransport: EventTransportConfig;\n}\n\nfunction resolveTransport(config: EventTransportConfig): IEventTransport {\n\tif ('type' in config && config.type === 'pg') {\n\t\treturn new PGTransport({\n\t\t\tconnectionUrl: config.connectionUrl,\n\t\t\t...(config.maxRetries !== undefined ? { maxRetries: config.maxRetries } : {}),\n\t\t\t...(config.batchSize !== undefined ? { batchSize: config.batchSize } : {}),\n\t\t\t...(config.pollInterval !== undefined ? { pollInterval: config.pollInterval } : {}),\n\t\t\t...(config.integrityKey !== undefined ? { integrityKey: config.integrityKey } : {}),\n\t\t});\n\t}\n\n\treturn config as IEventTransport;\n}\n\nexport class EventsModule implements IFonderieModule {\n\treadonly name = '@fonderie/events';\n\treadonly bus: EventBus;\n\tprivate readonly config: IEventsConfig;\n\n\tconstructor(config: IEventsConfig) {\n\t\tthis.config = config;\n\t\tthis.bus = new EventBus(resolveTransport(config.transport));\n\t}\n\n\tinstall(_app: IFonderieApp): void {\n\t\tthis.bus.start().catch((err) => console.error('[events] failed to start transport', err));\n\t}\n\n\t// The event log doubles as the audit trail. Without an integrityKey it is\n\t// append-only but not tamper-evident, so a compromised DB write could alter\n\t// history undetectably — a finding worth surfacing (not fatal).\n\tcheckReadiness(): IReadinessProblem[] {\n\t\tconst t = this.config.transport;\n\t\tif ('type' in t && t.type === 'pg' && !t.integrityKey) {\n\t\t\treturn [{\n\t\t\t\tmodule: this.name,\n\t\t\t\tseverity: 'warning',\n\t\t\t\tmessage: 'no integrityKey — the event/audit log is not tamper-evident; set one to enable per-event HMACs',\n\t\t\t}];\n\t\t}\n\t\treturn [];\n\t}\n}\n","import type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler } from '../types';\nimport { matchesPattern } from './pattern';\n\nexport class MemoryTransport implements IEventTransport {\n\tprivate subscriptions: Array<{ pattern: string; handler: IEventHandler }> = [];\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tconst matching = this.subscriptions.filter((s) => matchesPattern(s.pattern, type));\n\t\tawait Promise.all(matching.map((s) => s.handler(payload, meta)));\n\t}\n\n\tsubscribe(pattern: string, handler: IEventHandler, _consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler });\n\t}\n\n\tasync start(): Promise<void> {}\n\tasync stop(): Promise<void> {}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport { verifyEventChain, type IIntegrityReport } from './integrity';\n\n// Scheduled tamper-detection for the audit/event log (SOC 2 CC7.2). Runs\n// `verifyEventChain` on an interval; if any HMAC-carrying row fails, it fires\n// `onTamper` — wire that to your alerting. Runs once immediately, then every\n// `intervalMs`. Non-blocking (the timer is unref'd). Call `.stop()` to cancel.\n\nexport interface IIntegrityCheckOptions {\n\t// Default 24h.\n\tintervalMs?: number;\n\t// Called after every run (ok or not) — e.g. to record a heartbeat.\n\tonResult?: (report: IIntegrityReport) => void;\n\t// Called only when the log failed verification. Defaults to a loud\n\t// console.error naming the tampered rows — override to page/alert.\n\tonTamper?: (report: IIntegrityReport) => void;\n}\n\nexport interface IIntegrityCheckHandle {\n\tstop: () => void;\n}\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\n\nexport function startIntegrityCheck(\n\tstore: IStoreAdapter,\n\tkey: string,\n\toptions: IIntegrityCheckOptions = {},\n): IIntegrityCheckHandle {\n\tconst intervalMs = options.intervalMs ?? DAY_MS;\n\tconst onTamper = options.onTamper ?? defaultTamperHandler;\n\tlet stopped = false;\n\n\tconst run = async (): Promise<void> => {\n\t\tif (stopped) return;\n\t\ttry {\n\t\t\tconst report = await verifyEventChain(store, key);\n\t\t\toptions.onResult?.(report);\n\t\t\tif (!report.ok) onTamper(report);\n\t\t} catch (err) {\n\t\t\tconsole.error('[events] integrity check failed to run:', err);\n\t\t}\n\t};\n\n\tconst timer = setInterval(run, intervalMs);\n\tif (typeof (timer as { unref?: () => void }).unref === 'function') {\n\t\t(timer as { unref: () => void }).unref();\n\t}\n\tvoid run(); // fire once immediately\n\n\treturn {\n\t\tstop: () => {\n\t\t\tstopped = true;\n\t\t\tclearInterval(timer);\n\t\t},\n\t};\n}\n\nfunction defaultTamperHandler(report: IIntegrityReport): void {\n\tconsole.error(\n\t\t`[events] AUDIT LOG INTEGRITY FAILURE — ${report.tampered.length} tampered row(s) ` +\n\t\t\t`out of ${report.checked} checked: ${report.tampered.join(', ')}`,\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\n// Retention for the append-only event/audit log. Events accumulate forever by\n// default; a retention policy disposes of them once they age past the window.\n// Call from a scheduled job. Per-consumer delivery rows are removed by\n// ON DELETE CASCADE. Note: purging is disposal, not tampering — it removes whole\n// aged rows wholesale and does not touch the HMAC of anything it keeps.\n\nexport interface IPurgeEventsOptions {\n\t// Delete events whose created_at is older than this many days.\n\tolderThanDays: number;\n}\n\nexport async function purgeEvents(\n\tstore: IStoreAdapter,\n\t{ olderThanDays }: IPurgeEventsOptions,\n): Promise<number> {\n\tif (!Number.isFinite(olderThanDays) || olderThanDays < 0) {\n\t\tthrow new Error('[events] purgeEvents: olderThanDays must be a non-negative number');\n\t}\n\tconst rows = await store.query<{ id: string }>(\n\t\t`DELETE FROM fonderie_events\n\t\t WHERE created_at < now() - make_interval(days => $1)\n\t\t RETURNING id`,\n\t\t[olderThanDays],\n\t);\n\treturn rows.length;\n}\n\n// Scheduled disposal (SOC 2 C1/P4). Runs purgeEvents on an interval so aged\n// audit/event rows don't accumulate past the policy window. Runs once\n// immediately, then every intervalMs. Non-blocking (timer unref'd). .stop() cancels.\nexport interface IRetentionScheduleOptions extends IPurgeEventsOptions {\n\tintervalMs?: number; // default 24h\n\tonPurge?: (deleted: number) => void;\n}\n\nexport function startEventRetention(\n\tstore: IStoreAdapter,\n\toptions: IRetentionScheduleOptions,\n): { stop: () => void } {\n\tconst intervalMs = options.intervalMs ?? 24 * 60 * 60 * 1000;\n\tlet stopped = false;\n\tconst run = async () => {\n\t\tif (stopped) return;\n\t\ttry {\n\t\t\tconst deleted = await purgeEvents(store, { olderThanDays: options.olderThanDays });\n\t\t\toptions.onPurge?.(deleted);\n\t\t} catch (err) {\n\t\t\tconsole.error('[events] scheduled retention purge failed:', err);\n\t\t}\n\t};\n\tconst timer = setInterval(run, intervalMs);\n\tif (typeof (timer as { unref?: () => void }).unref === 'function') (timer as { unref: () => void }).unref();\n\tvoid run();\n\treturn { stop: () => { stopped = true; clearInterval(timer); } };\n}\n"],"mappings":"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","names":["import_node_crypto","pg"]}
package/dist/index.d.cts CHANGED
@@ -1,4 +1,5 @@
1
- import { IFonderieModule, IFonderieApp } from '@fonderie/core';
1
+ import { IFonderieModule, IFonderieApp, IReadinessProblem } from '@fonderie/core';
2
+ import { IStoreAdapter } from '@fonderie/store';
2
3
 
3
4
  interface IEventMeta {
4
5
  id: string;
@@ -48,6 +49,7 @@ type EventTransportConfig = {
48
49
  maxRetries?: number;
49
50
  batchSize?: number;
50
51
  pollInterval?: number;
52
+ integrityKey?: string;
51
53
  } | IEventTransport;
52
54
  interface IEventsConfig {
53
55
  transport: EventTransportConfig;
@@ -55,8 +57,10 @@ interface IEventsConfig {
55
57
  declare class EventsModule implements IFonderieModule {
56
58
  readonly name = "@fonderie/events";
57
59
  readonly bus: EventBus;
60
+ private readonly config;
58
61
  constructor(config: IEventsConfig);
59
62
  install(_app: IFonderieApp): void;
63
+ checkReadiness(): IReadinessProblem[];
60
64
  }
61
65
 
62
66
  declare class MemoryTransport implements IEventTransport {
@@ -72,6 +76,7 @@ interface IPGTransportConfig {
72
76
  maxRetries?: number;
73
77
  batchSize?: number;
74
78
  pollInterval?: number;
79
+ integrityKey?: string;
75
80
  }
76
81
  declare class PGTransport implements IEventTransport {
77
82
  private config;
@@ -83,6 +88,7 @@ declare class PGTransport implements IEventTransport {
83
88
  private readonly maxRetries;
84
89
  private readonly batchSize;
85
90
  private readonly pollInterval;
91
+ private readonly integrityKey;
86
92
  constructor(config: IPGTransportConfig);
87
93
  subscribe(pattern: string, handler: IEventHandler, consumer: string): void;
88
94
  publish(type: string, payload: unknown, meta: IEventMeta): Promise<void>;
@@ -99,7 +105,45 @@ declare class PGTransport implements IEventTransport {
99
105
 
100
106
  declare function matchesPattern(pattern: string, eventType: string): boolean;
101
107
 
108
+ declare function canonicalize(value: unknown): string;
109
+ interface IHashableEvent {
110
+ id: string;
111
+ type: string;
112
+ payload: unknown;
113
+ meta: unknown;
114
+ }
115
+ declare function computeEventHmac(key: string, event: IHashableEvent): string;
116
+ interface IIntegrityReport {
117
+ ok: boolean;
118
+ checked: number;
119
+ unprotected: number;
120
+ tampered: string[];
121
+ }
122
+ declare function verifyEventChain(store: IStoreAdapter, key: string): Promise<IIntegrityReport>;
123
+
124
+ interface IIntegrityCheckOptions {
125
+ intervalMs?: number;
126
+ onResult?: (report: IIntegrityReport) => void;
127
+ onTamper?: (report: IIntegrityReport) => void;
128
+ }
129
+ interface IIntegrityCheckHandle {
130
+ stop: () => void;
131
+ }
132
+ declare function startIntegrityCheck(store: IStoreAdapter, key: string, options?: IIntegrityCheckOptions): IIntegrityCheckHandle;
133
+
134
+ interface IPurgeEventsOptions {
135
+ olderThanDays: number;
136
+ }
137
+ declare function purgeEvents(store: IStoreAdapter, { olderThanDays }: IPurgeEventsOptions): Promise<number>;
138
+ interface IRetentionScheduleOptions extends IPurgeEventsOptions {
139
+ intervalMs?: number;
140
+ onPurge?: (deleted: number) => void;
141
+ }
142
+ declare function startEventRetention(store: IStoreAdapter, options: IRetentionScheduleOptions): {
143
+ stop: () => void;
144
+ };
145
+
102
146
  declare const NOTIFICATION_EVENT: "fonderie.notification.send";
103
147
  type NotificationEvent = typeof NOTIFICATION_EVENT;
104
148
 
105
- export { EventBus, type EventTransportConfig, EventsModule, type IConsumerRecord, type IEventHandler, type IEventMeta, type IEventRecord, type IEventTransport, type IEventsConfig, type IPGTransportConfig, MemoryTransport, NOTIFICATION_EVENT, type NotificationEvent, PGTransport, matchesPattern };
149
+ export { EventBus, type EventTransportConfig, EventsModule, type IConsumerRecord, type IEventHandler, type IEventMeta, type IEventRecord, type IEventTransport, type IEventsConfig, type IHashableEvent, type IIntegrityCheckHandle, type IIntegrityCheckOptions, type IIntegrityReport, type IPGTransportConfig, type IPurgeEventsOptions, type IRetentionScheduleOptions, MemoryTransport, NOTIFICATION_EVENT, type NotificationEvent, PGTransport, canonicalize, computeEventHmac, matchesPattern, purgeEvents, startEventRetention, startIntegrityCheck, verifyEventChain };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { IFonderieModule, IFonderieApp } from '@fonderie/core';
1
+ import { IFonderieModule, IFonderieApp, IReadinessProblem } from '@fonderie/core';
2
+ import { IStoreAdapter } from '@fonderie/store';
2
3
 
3
4
  interface IEventMeta {
4
5
  id: string;
@@ -48,6 +49,7 @@ type EventTransportConfig = {
48
49
  maxRetries?: number;
49
50
  batchSize?: number;
50
51
  pollInterval?: number;
52
+ integrityKey?: string;
51
53
  } | IEventTransport;
52
54
  interface IEventsConfig {
53
55
  transport: EventTransportConfig;
@@ -55,8 +57,10 @@ interface IEventsConfig {
55
57
  declare class EventsModule implements IFonderieModule {
56
58
  readonly name = "@fonderie/events";
57
59
  readonly bus: EventBus;
60
+ private readonly config;
58
61
  constructor(config: IEventsConfig);
59
62
  install(_app: IFonderieApp): void;
63
+ checkReadiness(): IReadinessProblem[];
60
64
  }
61
65
 
62
66
  declare class MemoryTransport implements IEventTransport {
@@ -72,6 +76,7 @@ interface IPGTransportConfig {
72
76
  maxRetries?: number;
73
77
  batchSize?: number;
74
78
  pollInterval?: number;
79
+ integrityKey?: string;
75
80
  }
76
81
  declare class PGTransport implements IEventTransport {
77
82
  private config;
@@ -83,6 +88,7 @@ declare class PGTransport implements IEventTransport {
83
88
  private readonly maxRetries;
84
89
  private readonly batchSize;
85
90
  private readonly pollInterval;
91
+ private readonly integrityKey;
86
92
  constructor(config: IPGTransportConfig);
87
93
  subscribe(pattern: string, handler: IEventHandler, consumer: string): void;
88
94
  publish(type: string, payload: unknown, meta: IEventMeta): Promise<void>;
@@ -99,7 +105,45 @@ declare class PGTransport implements IEventTransport {
99
105
 
100
106
  declare function matchesPattern(pattern: string, eventType: string): boolean;
101
107
 
108
+ declare function canonicalize(value: unknown): string;
109
+ interface IHashableEvent {
110
+ id: string;
111
+ type: string;
112
+ payload: unknown;
113
+ meta: unknown;
114
+ }
115
+ declare function computeEventHmac(key: string, event: IHashableEvent): string;
116
+ interface IIntegrityReport {
117
+ ok: boolean;
118
+ checked: number;
119
+ unprotected: number;
120
+ tampered: string[];
121
+ }
122
+ declare function verifyEventChain(store: IStoreAdapter, key: string): Promise<IIntegrityReport>;
123
+
124
+ interface IIntegrityCheckOptions {
125
+ intervalMs?: number;
126
+ onResult?: (report: IIntegrityReport) => void;
127
+ onTamper?: (report: IIntegrityReport) => void;
128
+ }
129
+ interface IIntegrityCheckHandle {
130
+ stop: () => void;
131
+ }
132
+ declare function startIntegrityCheck(store: IStoreAdapter, key: string, options?: IIntegrityCheckOptions): IIntegrityCheckHandle;
133
+
134
+ interface IPurgeEventsOptions {
135
+ olderThanDays: number;
136
+ }
137
+ declare function purgeEvents(store: IStoreAdapter, { olderThanDays }: IPurgeEventsOptions): Promise<number>;
138
+ interface IRetentionScheduleOptions extends IPurgeEventsOptions {
139
+ intervalMs?: number;
140
+ onPurge?: (deleted: number) => void;
141
+ }
142
+ declare function startEventRetention(store: IStoreAdapter, options: IRetentionScheduleOptions): {
143
+ stop: () => void;
144
+ };
145
+
102
146
  declare const NOTIFICATION_EVENT: "fonderie.notification.send";
103
147
  type NotificationEvent = typeof NOTIFICATION_EVENT;
104
148
 
105
- export { EventBus, type EventTransportConfig, EventsModule, type IConsumerRecord, type IEventHandler, type IEventMeta, type IEventRecord, type IEventTransport, type IEventsConfig, type IPGTransportConfig, MemoryTransport, NOTIFICATION_EVENT, type NotificationEvent, PGTransport, matchesPattern };
149
+ export { EventBus, type EventTransportConfig, EventsModule, type IConsumerRecord, type IEventHandler, type IEventMeta, type IEventRecord, type IEventTransport, type IEventsConfig, type IHashableEvent, type IIntegrityCheckHandle, type IIntegrityCheckOptions, type IIntegrityReport, type IPGTransportConfig, type IPurgeEventsOptions, type IRetentionScheduleOptions, MemoryTransport, NOTIFICATION_EVENT, type NotificationEvent, PGTransport, canonicalize, computeEventHmac, matchesPattern, purgeEvents, startEventRetention, startIntegrityCheck, verifyEventChain };
package/dist/index.js CHANGED
@@ -39,6 +39,46 @@ function matchesPattern(pattern, eventType) {
39
39
  return regex.test(eventType);
40
40
  }
41
41
 
42
+ // src/integrity.ts
43
+ import { createHmac } from "crypto";
44
+ import { constantTimeEqual } from "@fonderie/core";
45
+ function canonicalize(value) {
46
+ return JSON.stringify(sortKeys(value));
47
+ }
48
+ function sortKeys(value) {
49
+ if (Array.isArray(value)) return value.map(sortKeys);
50
+ if (value && typeof value === "object") {
51
+ const out = {};
52
+ for (const k of Object.keys(value).sort()) {
53
+ out[k] = sortKeys(value[k]);
54
+ }
55
+ return out;
56
+ }
57
+ return value;
58
+ }
59
+ function computeEventHmac(key, event) {
60
+ return createHmac("sha256", key).update(event.id).update("\n").update(event.type).update("\n").update(canonicalize(event.payload)).update("\n").update(canonicalize(event.meta)).digest("hex");
61
+ }
62
+ async function verifyEventChain(store, key) {
63
+ const rows = await store.query(
64
+ `SELECT id, type, payload, meta, hmac FROM fonderie_events ORDER BY created_at, id`
65
+ );
66
+ const report = { ok: true, checked: 0, unprotected: 0, tampered: [] };
67
+ for (const row of rows) {
68
+ if (row.hmac === null) {
69
+ report.unprotected += 1;
70
+ continue;
71
+ }
72
+ report.checked += 1;
73
+ const expected = computeEventHmac(key, row);
74
+ if (!constantTimeEqual(expected, row.hmac)) {
75
+ report.ok = false;
76
+ report.tampered.push(row.id);
77
+ }
78
+ }
79
+ return report;
80
+ }
81
+
42
82
  // src/transports/pg.ts
43
83
  var PGTransport = class {
44
84
  constructor(config) {
@@ -46,6 +86,7 @@ var PGTransport = class {
46
86
  this.maxRetries = config.maxRetries ?? 3;
47
87
  this.batchSize = config.batchSize ?? 10;
48
88
  this.pollInterval = config.pollInterval ?? 1e3;
89
+ this.integrityKey = config.integrityKey;
49
90
  }
50
91
  config;
51
92
  subscriptions = [];
@@ -56,15 +97,17 @@ var PGTransport = class {
56
97
  maxRetries;
57
98
  batchSize;
58
99
  pollInterval;
100
+ integrityKey;
59
101
  // ── Public API ──────────────────────────────────────────────────
60
102
  subscribe(pattern, handler, consumer) {
61
103
  this.subscriptions.push({ pattern, handler, consumer });
62
104
  }
63
105
  async publish(type, payload, meta) {
106
+ const hmac = this.integrityKey ? computeEventHmac(this.integrityKey, { id: meta.id, type, payload, meta }) : null;
64
107
  await this.store.query(
65
- `INSERT INTO fonderie_events (id, type, payload, meta)
66
- VALUES ($1, $2, $3, $4)`,
67
- [meta.id, type, JSON.stringify(payload), JSON.stringify(meta)]
108
+ `INSERT INTO fonderie_events (id, type, payload, meta, hmac)
109
+ VALUES ($1, $2, $3, $4, $5)`,
110
+ [meta.id, type, JSON.stringify(payload), JSON.stringify(meta), hmac]
68
111
  );
69
112
  const consumers = this.matchingConsumers(type);
70
113
  if (consumers.length > 0) {
@@ -199,7 +242,8 @@ function resolveTransport(config) {
199
242
  connectionUrl: config.connectionUrl,
200
243
  ...config.maxRetries !== void 0 ? { maxRetries: config.maxRetries } : {},
201
244
  ...config.batchSize !== void 0 ? { batchSize: config.batchSize } : {},
202
- ...config.pollInterval !== void 0 ? { pollInterval: config.pollInterval } : {}
245
+ ...config.pollInterval !== void 0 ? { pollInterval: config.pollInterval } : {},
246
+ ...config.integrityKey !== void 0 ? { integrityKey: config.integrityKey } : {}
203
247
  });
204
248
  }
205
249
  return config;
@@ -207,12 +251,28 @@ function resolveTransport(config) {
207
251
  var EventsModule = class {
208
252
  name = "@fonderie/events";
209
253
  bus;
254
+ config;
210
255
  constructor(config) {
256
+ this.config = config;
211
257
  this.bus = new EventBus(resolveTransport(config.transport));
212
258
  }
213
259
  install(_app) {
214
260
  this.bus.start().catch((err) => console.error("[events] failed to start transport", err));
215
261
  }
262
+ // The event log doubles as the audit trail. Without an integrityKey it is
263
+ // append-only but not tamper-evident, so a compromised DB write could alter
264
+ // history undetectably — a finding worth surfacing (not fatal).
265
+ checkReadiness() {
266
+ const t = this.config.transport;
267
+ if ("type" in t && t.type === "pg" && !t.integrityKey) {
268
+ return [{
269
+ module: this.name,
270
+ severity: "warning",
271
+ message: "no integrityKey \u2014 the event/audit log is not tamper-evident; set one to enable per-event HMACs"
272
+ }];
273
+ }
274
+ return [];
275
+ }
216
276
  };
217
277
 
218
278
  // src/transports/memory.ts
@@ -231,6 +291,74 @@ var MemoryTransport = class {
231
291
  }
232
292
  };
233
293
 
294
+ // src/integrity-job.ts
295
+ var DAY_MS = 24 * 60 * 60 * 1e3;
296
+ function startIntegrityCheck(store, key, options = {}) {
297
+ const intervalMs = options.intervalMs ?? DAY_MS;
298
+ const onTamper = options.onTamper ?? defaultTamperHandler;
299
+ let stopped = false;
300
+ const run = async () => {
301
+ if (stopped) return;
302
+ try {
303
+ const report = await verifyEventChain(store, key);
304
+ options.onResult?.(report);
305
+ if (!report.ok) onTamper(report);
306
+ } catch (err) {
307
+ console.error("[events] integrity check failed to run:", err);
308
+ }
309
+ };
310
+ const timer = setInterval(run, intervalMs);
311
+ if (typeof timer.unref === "function") {
312
+ timer.unref();
313
+ }
314
+ void run();
315
+ return {
316
+ stop: () => {
317
+ stopped = true;
318
+ clearInterval(timer);
319
+ }
320
+ };
321
+ }
322
+ function defaultTamperHandler(report) {
323
+ console.error(
324
+ `[events] AUDIT LOG INTEGRITY FAILURE \u2014 ${report.tampered.length} tampered row(s) out of ${report.checked} checked: ${report.tampered.join(", ")}`
325
+ );
326
+ }
327
+
328
+ // src/retention.ts
329
+ async function purgeEvents(store, { olderThanDays }) {
330
+ if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
331
+ throw new Error("[events] purgeEvents: olderThanDays must be a non-negative number");
332
+ }
333
+ const rows = await store.query(
334
+ `DELETE FROM fonderie_events
335
+ WHERE created_at < now() - make_interval(days => $1)
336
+ RETURNING id`,
337
+ [olderThanDays]
338
+ );
339
+ return rows.length;
340
+ }
341
+ function startEventRetention(store, options) {
342
+ const intervalMs = options.intervalMs ?? 24 * 60 * 60 * 1e3;
343
+ let stopped = false;
344
+ const run = async () => {
345
+ if (stopped) return;
346
+ try {
347
+ const deleted = await purgeEvents(store, { olderThanDays: options.olderThanDays });
348
+ options.onPurge?.(deleted);
349
+ } catch (err) {
350
+ console.error("[events] scheduled retention purge failed:", err);
351
+ }
352
+ };
353
+ const timer = setInterval(run, intervalMs);
354
+ if (typeof timer.unref === "function") timer.unref();
355
+ void run();
356
+ return { stop: () => {
357
+ stopped = true;
358
+ clearInterval(timer);
359
+ } };
360
+ }
361
+
234
362
  // src/index.ts
235
363
  var NOTIFICATION_EVENT = "fonderie.notification.send";
236
364
  export {
@@ -239,6 +367,12 @@ export {
239
367
  MemoryTransport,
240
368
  NOTIFICATION_EVENT,
241
369
  PGTransport,
242
- matchesPattern
370
+ canonicalize,
371
+ computeEventHmac,
372
+ matchesPattern,
373
+ purgeEvents,
374
+ startEventRetention,
375
+ startIntegrityCheck,
376
+ verifyEventChain
243
377
  };
244
378
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/bus.ts","../src/transports/pg.ts","../src/transports/pattern.ts","../src/module.ts","../src/transports/memory.ts","../src/index.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\n\nimport type { IEventTransport } from './transports/types';\nimport type { IEventMeta, IEventHandler } from './types';\n\nexport class EventBus {\n\tconstructor(private transport: IEventTransport) {}\n\n\tasync emit<T = unknown>(type: string, payload: T, opts?: { requestId?: string }): Promise<void> {\n\t\tconst meta: IEventMeta = {\n\t\t\tid: randomUUID(),\n\t\t\ttype,\n\t\t\temittedAt: new Date().toISOString(),\n\t\t\tattempts: 0,\n\t\t\t...(opts?.requestId !== undefined ? { requestId: opts.requestId } : {}),\n\t\t};\n\t\tawait this.transport.publish(type, payload, meta);\n\t}\n\n\t// consumer identifies the logical subscriber for per-consumer delivery tracking.\n\t// Defaults to the pattern string — stable and predictable for single-subscriber patterns.\n\ton<T = unknown>(type: string, handler: IEventHandler<T>, consumer: string = type): void {\n\t\tthis.transport.subscribe(type, handler as IEventHandler, consumer);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tawait this.transport.start();\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tawait this.transport.stop();\n\t}\n}\n","import pg from 'pg';\n\nimport { PGAdapter } from '@fonderie/store';\nimport type { IStoreAdapter } from '@fonderie/store';\nimport type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler, IEventRecord } from '../types';\nimport { matchesPattern } from './pattern';\n\nexport interface IPGTransportConfig {\n\tconnectionUrl: string;\n\tmaxRetries?: number; // default 3\n\tbatchSize?: number; // default 10 rows claimed per consumer per poll cycle\n\tpollInterval?: number; // default 1000ms fallback poll when no NOTIFY arrives\n}\n\ninterface Subscription {\n\tpattern: string;\n\thandler: IEventHandler;\n\tconsumer: string;\n}\n\nexport class PGTransport implements IEventTransport {\n\tprivate subscriptions: Subscription[] = [];\n\tprivate listenClient: pg.Client | null = null;\n\tprivate store!: IStoreAdapter;\n\tprivate running = false;\n\tprivate wakeResolvers: Array<() => void> = [];\n\n\tprivate readonly maxRetries: number;\n\tprivate readonly batchSize: number;\n\tprivate readonly pollInterval: number;\n\n\tconstructor(private config: IPGTransportConfig) {\n\t\tthis.maxRetries = config.maxRetries ?? 3;\n\t\tthis.batchSize = config.batchSize ?? 10;\n\t\tthis.pollInterval = config.pollInterval ?? 1_000;\n\t}\n\n\t// ── Public API ──────────────────────────────────────────────────\n\n\tsubscribe(pattern: string, handler: IEventHandler, consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler, consumer });\n\t}\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_events (id, type, payload, meta)\n\t\t\t VALUES ($1, $2, $3, $4)`,\n\t\t\t[meta.id, type, JSON.stringify(payload), JSON.stringify(meta)],\n\t\t);\n\n\t\tconst consumers = this.matchingConsumers(type);\n\t\tif (consumers.length > 0) {\n\t\t\tawait this.store.query(\n\t\t\t\t`INSERT INTO fonderie_event_consumers (event_id, consumer, status, attempts)\n\t\t\t\t SELECT $1, unnest($2::text[]), 'pending', 0\n\t\t\t\t ON CONFLICT (event_id, consumer) DO NOTHING`,\n\t\t\t\t[meta.id, consumers],\n\t\t\t);\n\t\t}\n\n\t\t// NOTIFY carries no payload — it is a wake signal only\n\t\tawait this.store.query(`SELECT pg_notify('fonderie_events', '')`);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tthis.running = true;\n\t\tthis.store = new PGAdapter(this.config.connectionUrl);\n\n\t\t// Reset any rows left in 'processing' by a crashed instance\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_event_consumers SET status = 'failed' WHERE status = 'processing'`,\n\t\t);\n\n\t\tthis.listenClient = new pg.Client(this.config.connectionUrl);\n\t\tawait this.listenClient.connect();\n\t\tawait this.listenClient.query('LISTEN fonderie_events');\n\n\t\tthis.listenClient.on('notification', () => this.wake());\n\t\tthis.listenClient.on('error', (err) =>\n\t\t\tconsole.error('[events:pg] listen client error:', err.message),\n\t\t);\n\n\t\tthis.runPollLoop().catch((err) => console.error('[events:pg] poll loop crashed:', err));\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tthis.running = false;\n\t\tthis.wake();\n\t\tawait this.listenClient?.end();\n\t\tthis.listenClient = null;\n\t}\n\n\t// ── Poll loop ───────────────────────────────────────────────────\n\n\tprivate async runPollLoop(): Promise<void> {\n\t\twhile (this.running) {\n\t\t\ttry {\n\t\t\t\tconst hadWork = await this.pollAllConsumers();\n\t\t\t\tif (!hadWork) await this.sleep();\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error('[events:pg] poll error:', err);\n\t\t\t\tawait this.sleep();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async pollAllConsumers(): Promise<boolean> {\n\t\tconst consumers = [...new Set(this.subscriptions.map((s) => s.consumer))];\n\t\tconst results = await Promise.all(consumers.map((c) => this.pollConsumer(c)));\n\t\treturn results.some((n) => n > 0);\n\t}\n\n\tprivate async pollConsumer(consumer: string): Promise<number> {\n\t\tconst claimed = await this.store.query<{ event_id: string }>(\n\t\t\t`UPDATE fonderie_event_consumers c\n\t\t\t SET status = 'processing', attempts = c.attempts + 1\n\t\t\t FROM (\n\t\t\t SELECT event_id\n\t\t\t FROM fonderie_event_consumers\n\t\t\t WHERE consumer = $1\n\t\t\t AND status IN ('pending', 'failed')\n\t\t\t AND attempts < $2\n\t\t\t ORDER BY event_id\n\t\t\t LIMIT $3\n\t\t\t FOR UPDATE SKIP LOCKED\n\t\t\t ) AS locked\n\t\t\t WHERE c.event_id = locked.event_id\n\t\t\t AND c.consumer = $1\n\t\t\t RETURNING c.event_id`,\n\t\t\t[consumer, this.maxRetries, this.batchSize],\n\t\t);\n\n\t\tawait Promise.all(claimed.map((row) => this.processConsumerEvent(consumer, row.event_id)));\n\t\treturn claimed.length;\n\t}\n\n\t// ── Event processing ────────────────────────────────────────────\n\n\tprivate async processConsumerEvent(consumer: string, eventId: string): Promise<void> {\n\t\tconst [event] = await this.store.query<IEventRecord>(\n\t\t\t`SELECT type, payload, meta FROM fonderie_events WHERE id = $1`,\n\t\t\t[eventId],\n\t\t);\n\t\tif (!event) return;\n\n\t\tconst handlers = this.subscriptions\n\t\t\t.filter((s) => s.consumer === consumer && matchesPattern(s.pattern, event.type))\n\t\t\t.map((s) => s.handler);\n\n\t\ttry {\n\t\t\tawait Promise.all(handlers.map((h) => h(event.payload, event.meta)));\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = 'processed', processed_at = now()\n\t\t\t\t WHERE event_id = $1 AND consumer = $2`,\n\t\t\t\t[eventId, consumer],\n\t\t\t);\n\t\t} catch (err) {\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = CASE WHEN attempts >= $1 THEN 'dead' ELSE 'failed' END,\n\t\t\t\t error = $2\n\t\t\t\t WHERE event_id = $3 AND consumer = $4`,\n\t\t\t\t[this.maxRetries, err instanceof Error ? err.message : String(err), eventId, consumer],\n\t\t\t);\n\t\t}\n\t}\n\n\t// ── Helpers ─────────────────────────────────────────────────────\n\n\tprivate matchingConsumers(eventType: string): string[] {\n\t\tconst seen = new Set<string>();\n\t\tfor (const sub of this.subscriptions) {\n\t\t\tif (matchesPattern(sub.pattern, eventType)) seen.add(sub.consumer);\n\t\t}\n\t\treturn [...seen];\n\t}\n\n\tprivate sleep(): Promise<void> {\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tlet timer: ReturnType<typeof setTimeout>;\n\t\t\tconst wake = () => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tresolve();\n\t\t\t};\n\t\t\ttimer = setTimeout(() => {\n\t\t\t\tconst idx = this.wakeResolvers.indexOf(wake);\n\t\t\t\tif (idx !== -1) this.wakeResolvers.splice(idx, 1);\n\t\t\t\tresolve();\n\t\t\t}, this.pollInterval);\n\t\t\tthis.wakeResolvers.push(wake);\n\t\t});\n\t}\n\n\tprivate wake(): void {\n\t\tthis.wakeResolvers.shift()?.();\n\t}\n}\n","// Glob matching for event topic patterns.\n// '*' alone matches everything. Otherwise '*' is a wildcard for any\n// characters including dots, so 'sport.*' matches 'sport.event.created'.\nexport function matchesPattern(pattern: string, eventType: string): boolean {\n\tif (pattern === '*') return true;\n\tconst regex = new RegExp('^' + pattern.replace(/\\./g, '\\\\.').replace(/\\*/g, '.*') + '$');\n\treturn regex.test(eventType);\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\n\nimport { EventBus } from './bus';\nimport { PGTransport } from './transports/pg';\nimport type { IEventTransport } from './transports/types';\n\nexport type EventTransportConfig =\n\t| {\n\t\t\ttype: 'pg';\n\t\t\tconnectionUrl: string;\n\t\t\tmaxRetries?: number;\n\t\t\tbatchSize?: number;\n\t\t\tpollInterval?: number;\n\t }\n\t| IEventTransport;\n\nexport interface IEventsConfig {\n\ttransport: EventTransportConfig;\n}\n\nfunction resolveTransport(config: EventTransportConfig): IEventTransport {\n\tif ('type' in config && config.type === 'pg') {\n\t\treturn new PGTransport({\n\t\t\tconnectionUrl: config.connectionUrl,\n\t\t\t...(config.maxRetries !== undefined ? { maxRetries: config.maxRetries } : {}),\n\t\t\t...(config.batchSize !== undefined ? { batchSize: config.batchSize } : {}),\n\t\t\t...(config.pollInterval !== undefined ? { pollInterval: config.pollInterval } : {}),\n\t\t});\n\t}\n\n\treturn config as IEventTransport;\n}\n\nexport class EventsModule implements IFonderieModule {\n\treadonly name = '@fonderie/events';\n\treadonly bus: EventBus;\n\n\tconstructor(config: IEventsConfig) {\n\t\tthis.bus = new EventBus(resolveTransport(config.transport));\n\t}\n\n\tinstall(_app: IFonderieApp): void {\n\t\tthis.bus.start().catch((err) => console.error('[events] failed to start transport', err));\n\t}\n}\n","import type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler } from '../types';\nimport { matchesPattern } from './pattern';\n\nexport class MemoryTransport implements IEventTransport {\n\tprivate subscriptions: Array<{ pattern: string; handler: IEventHandler }> = [];\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tconst matching = this.subscriptions.filter((s) => matchesPattern(s.pattern, type));\n\t\tawait Promise.all(matching.map((s) => s.handler(payload, meta)));\n\t}\n\n\tsubscribe(pattern: string, handler: IEventHandler, _consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler });\n\t}\n\n\tasync start(): Promise<void> {}\n\tasync stop(): Promise<void> {}\n}\n","export { EventBus } from './bus';\nexport { EventsModule } from './module';\nexport type { IEventsConfig, EventTransportConfig } from './module';\n\nexport { MemoryTransport, PGTransport } from './transports';\nexport type { IEventTransport, IPGTransportConfig } from './transports';\n\nexport { matchesPattern } from './transports/pattern';\n\nexport type { IEventMeta, IEventHandler, IEventRecord, IConsumerRecord } from './types';\n\n// ── Typed event keys ─────────────────────────────────────────────\n// Each domain package re-exports its own EVENT_KEYS.\n// Consumers alias on import:\n// import { EVENT_KEYS as AUTH_EVENT_KEYS } from '@fonderie/auth'\n\nexport const NOTIFICATION_EVENT = 'fonderie.notification.send' as const;\nexport type NotificationEvent = typeof NOTIFICATION_EVENT;\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/bus.ts","../src/transports/pg.ts","../src/transports/pattern.ts","../src/integrity.ts","../src/module.ts","../src/transports/memory.ts","../src/integrity-job.ts","../src/retention.ts","../src/index.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\n\nimport type { IEventTransport } from './transports/types';\nimport type { IEventMeta, IEventHandler } from './types';\n\nexport class EventBus {\n\tconstructor(private transport: IEventTransport) {}\n\n\tasync emit<T = unknown>(type: string, payload: T, opts?: { requestId?: string }): Promise<void> {\n\t\tconst meta: IEventMeta = {\n\t\t\tid: randomUUID(),\n\t\t\ttype,\n\t\t\temittedAt: new Date().toISOString(),\n\t\t\tattempts: 0,\n\t\t\t...(opts?.requestId !== undefined ? { requestId: opts.requestId } : {}),\n\t\t};\n\t\tawait this.transport.publish(type, payload, meta);\n\t}\n\n\t// consumer identifies the logical subscriber for per-consumer delivery tracking.\n\t// Defaults to the pattern string — stable and predictable for single-subscriber patterns.\n\ton<T = unknown>(type: string, handler: IEventHandler<T>, consumer: string = type): void {\n\t\tthis.transport.subscribe(type, handler as IEventHandler, consumer);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tawait this.transport.start();\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tawait this.transport.stop();\n\t}\n}\n","import pg from 'pg';\n\nimport { PGAdapter } from '@fonderie/store';\nimport type { IStoreAdapter } from '@fonderie/store';\nimport type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler, IEventRecord } from '../types';\nimport { matchesPattern } from './pattern';\nimport { computeEventHmac } from '../integrity';\n\nexport interface IPGTransportConfig {\n\tconnectionUrl: string;\n\tmaxRetries?: number; // default 3\n\tbatchSize?: number; // default 10 rows claimed per consumer per poll cycle\n\tpollInterval?: number; // default 1000ms fallback poll when no NOTIFY arrives\n\t// When set, every event is stored with a keyed HMAC over its immutable\n\t// content, making the audit log tamper-evident. Unset → no HMAC (unchanged\n\t// behaviour). Verify later with `verifyEventChain(store, integrityKey)`.\n\tintegrityKey?: string;\n}\n\ninterface Subscription {\n\tpattern: string;\n\thandler: IEventHandler;\n\tconsumer: string;\n}\n\nexport class PGTransport implements IEventTransport {\n\tprivate subscriptions: Subscription[] = [];\n\tprivate listenClient: pg.Client | null = null;\n\tprivate store!: IStoreAdapter;\n\tprivate running = false;\n\tprivate wakeResolvers: Array<() => void> = [];\n\n\tprivate readonly maxRetries: number;\n\tprivate readonly batchSize: number;\n\tprivate readonly pollInterval: number;\n\tprivate readonly integrityKey: string | undefined;\n\n\tconstructor(private config: IPGTransportConfig) {\n\t\tthis.maxRetries = config.maxRetries ?? 3;\n\t\tthis.batchSize = config.batchSize ?? 10;\n\t\tthis.pollInterval = config.pollInterval ?? 1_000;\n\t\tthis.integrityKey = config.integrityKey;\n\t}\n\n\t// ── Public API ──────────────────────────────────────────────────\n\n\tsubscribe(pattern: string, handler: IEventHandler, consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler, consumer });\n\t}\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tconst hmac = this.integrityKey\n\t\t\t? computeEventHmac(this.integrityKey, { id: meta.id, type, payload, meta })\n\t\t\t: null;\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_events (id, type, payload, meta, hmac)\n\t\t\t VALUES ($1, $2, $3, $4, $5)`,\n\t\t\t[meta.id, type, JSON.stringify(payload), JSON.stringify(meta), hmac],\n\t\t);\n\n\t\tconst consumers = this.matchingConsumers(type);\n\t\tif (consumers.length > 0) {\n\t\t\tawait this.store.query(\n\t\t\t\t`INSERT INTO fonderie_event_consumers (event_id, consumer, status, attempts)\n\t\t\t\t SELECT $1, unnest($2::text[]), 'pending', 0\n\t\t\t\t ON CONFLICT (event_id, consumer) DO NOTHING`,\n\t\t\t\t[meta.id, consumers],\n\t\t\t);\n\t\t}\n\n\t\t// NOTIFY carries no payload — it is a wake signal only\n\t\tawait this.store.query(`SELECT pg_notify('fonderie_events', '')`);\n\t}\n\n\tasync start(): Promise<void> {\n\t\tthis.running = true;\n\t\tthis.store = new PGAdapter(this.config.connectionUrl);\n\n\t\t// Reset any rows left in 'processing' by a crashed instance\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_event_consumers SET status = 'failed' WHERE status = 'processing'`,\n\t\t);\n\n\t\tthis.listenClient = new pg.Client(this.config.connectionUrl);\n\t\tawait this.listenClient.connect();\n\t\tawait this.listenClient.query('LISTEN fonderie_events');\n\n\t\tthis.listenClient.on('notification', () => this.wake());\n\t\tthis.listenClient.on('error', (err) =>\n\t\t\tconsole.error('[events:pg] listen client error:', err.message),\n\t\t);\n\n\t\tthis.runPollLoop().catch((err) => console.error('[events:pg] poll loop crashed:', err));\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tthis.running = false;\n\t\tthis.wake();\n\t\tawait this.listenClient?.end();\n\t\tthis.listenClient = null;\n\t}\n\n\t// ── Poll loop ───────────────────────────────────────────────────\n\n\tprivate async runPollLoop(): Promise<void> {\n\t\twhile (this.running) {\n\t\t\ttry {\n\t\t\t\tconst hadWork = await this.pollAllConsumers();\n\t\t\t\tif (!hadWork) await this.sleep();\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error('[events:pg] poll error:', err);\n\t\t\t\tawait this.sleep();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate async pollAllConsumers(): Promise<boolean> {\n\t\tconst consumers = [...new Set(this.subscriptions.map((s) => s.consumer))];\n\t\tconst results = await Promise.all(consumers.map((c) => this.pollConsumer(c)));\n\t\treturn results.some((n) => n > 0);\n\t}\n\n\tprivate async pollConsumer(consumer: string): Promise<number> {\n\t\tconst claimed = await this.store.query<{ event_id: string }>(\n\t\t\t`UPDATE fonderie_event_consumers c\n\t\t\t SET status = 'processing', attempts = c.attempts + 1\n\t\t\t FROM (\n\t\t\t SELECT event_id\n\t\t\t FROM fonderie_event_consumers\n\t\t\t WHERE consumer = $1\n\t\t\t AND status IN ('pending', 'failed')\n\t\t\t AND attempts < $2\n\t\t\t ORDER BY event_id\n\t\t\t LIMIT $3\n\t\t\t FOR UPDATE SKIP LOCKED\n\t\t\t ) AS locked\n\t\t\t WHERE c.event_id = locked.event_id\n\t\t\t AND c.consumer = $1\n\t\t\t RETURNING c.event_id`,\n\t\t\t[consumer, this.maxRetries, this.batchSize],\n\t\t);\n\n\t\tawait Promise.all(claimed.map((row) => this.processConsumerEvent(consumer, row.event_id)));\n\t\treturn claimed.length;\n\t}\n\n\t// ── Event processing ────────────────────────────────────────────\n\n\tprivate async processConsumerEvent(consumer: string, eventId: string): Promise<void> {\n\t\tconst [event] = await this.store.query<IEventRecord>(\n\t\t\t`SELECT type, payload, meta FROM fonderie_events WHERE id = $1`,\n\t\t\t[eventId],\n\t\t);\n\t\tif (!event) return;\n\n\t\tconst handlers = this.subscriptions\n\t\t\t.filter((s) => s.consumer === consumer && matchesPattern(s.pattern, event.type))\n\t\t\t.map((s) => s.handler);\n\n\t\ttry {\n\t\t\tawait Promise.all(handlers.map((h) => h(event.payload, event.meta)));\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = 'processed', processed_at = now()\n\t\t\t\t WHERE event_id = $1 AND consumer = $2`,\n\t\t\t\t[eventId, consumer],\n\t\t\t);\n\t\t} catch (err) {\n\t\t\tawait this.store.query(\n\t\t\t\t`UPDATE fonderie_event_consumers\n\t\t\t\t SET status = CASE WHEN attempts >= $1 THEN 'dead' ELSE 'failed' END,\n\t\t\t\t error = $2\n\t\t\t\t WHERE event_id = $3 AND consumer = $4`,\n\t\t\t\t[this.maxRetries, err instanceof Error ? err.message : String(err), eventId, consumer],\n\t\t\t);\n\t\t}\n\t}\n\n\t// ── Helpers ─────────────────────────────────────────────────────\n\n\tprivate matchingConsumers(eventType: string): string[] {\n\t\tconst seen = new Set<string>();\n\t\tfor (const sub of this.subscriptions) {\n\t\t\tif (matchesPattern(sub.pattern, eventType)) seen.add(sub.consumer);\n\t\t}\n\t\treturn [...seen];\n\t}\n\n\tprivate sleep(): Promise<void> {\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tlet timer: ReturnType<typeof setTimeout>;\n\t\t\tconst wake = () => {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\tresolve();\n\t\t\t};\n\t\t\ttimer = setTimeout(() => {\n\t\t\t\tconst idx = this.wakeResolvers.indexOf(wake);\n\t\t\t\tif (idx !== -1) this.wakeResolvers.splice(idx, 1);\n\t\t\t\tresolve();\n\t\t\t}, this.pollInterval);\n\t\t\tthis.wakeResolvers.push(wake);\n\t\t});\n\t}\n\n\tprivate wake(): void {\n\t\tthis.wakeResolvers.shift()?.();\n\t}\n}\n","// Glob matching for event topic patterns.\n// '*' alone matches everything. Otherwise '*' is a wildcard for any\n// characters including dots, so 'sport.*' matches 'sport.event.created'.\nexport function matchesPattern(pattern: string, eventType: string): boolean {\n\tif (pattern === '*') return true;\n\tconst regex = new RegExp('^' + pattern.replace(/\\./g, '\\\\.').replace(/\\*/g, '.*') + '$');\n\treturn regex.test(eventType);\n}\n","import { createHmac } from 'node:crypto';\n\nimport { constantTimeEqual } from '@fonderie/core';\n\nimport type { IStoreAdapter } from '@fonderie/store';\n\n// Tamper-evidence for the append-only event log. Each row carries an HMAC-SHA256\n// over its immutable content, keyed by a server-held secret. An auditor (or a\n// scheduled job) re-derives every HMAC and compares: any modified or forged row\n// fails, because rewriting it without the key can't produce a matching HMAC.\n//\n// Scope: this detects *content* tampering and forged rows. It does not by itself\n// prove no whole row was deleted — that is the job of append-only grants,\n// restricted DB permissions, and backups. Kept deliberately keyed-per-row (not a\n// prev-hash chain) so publishing stays lock-free on the hot event-bus path.\n\n// Deterministic JSON: recursively sort object keys so the same logical value\n// always serialises identically, regardless of insertion order or a JSONB\n// round-trip through Postgres.\nexport function canonicalize(value: unknown): string {\n\treturn JSON.stringify(sortKeys(value));\n}\n\nfunction sortKeys(value: unknown): unknown {\n\tif (Array.isArray(value)) return value.map(sortKeys);\n\tif (value && typeof value === 'object') {\n\t\tconst out: Record<string, unknown> = {};\n\t\tfor (const k of Object.keys(value as Record<string, unknown>).sort()) {\n\t\t\tout[k] = sortKeys((value as Record<string, unknown>)[k]);\n\t\t}\n\t\treturn out;\n\t}\n\treturn value;\n}\n\nexport interface IHashableEvent {\n\tid: string;\n\ttype: string;\n\tpayload: unknown;\n\tmeta: unknown;\n}\n\n// The HMAC over an event's immutable fields. Field separators (\\n) are safe\n// because they can't appear unescaped inside a JSON string or a UUID/type.\nexport function computeEventHmac(key: string, event: IHashableEvent): string {\n\treturn createHmac('sha256', key)\n\t\t.update(event.id)\n\t\t.update('\\n')\n\t\t.update(event.type)\n\t\t.update('\\n')\n\t\t.update(canonicalize(event.payload))\n\t\t.update('\\n')\n\t\t.update(canonicalize(event.meta))\n\t\t.digest('hex');\n}\n\nexport interface IIntegrityReport {\n\t// True when every HMAC-carrying row verified.\n\tok: boolean;\n\t// Rows that carried an HMAC and were checked.\n\tchecked: number;\n\t// Rows with no HMAC (published before integrity was enabled) — skipped.\n\tunprotected: number;\n\t// Ids of rows whose stored HMAC did not match a fresh computation.\n\ttampered: string[];\n}\n\ninterface IRawEventRow {\n\tid: string;\n\ttype: string;\n\tpayload: unknown;\n\tmeta: unknown;\n\thmac: string | null;\n}\n\n// Walk the whole event log and re-verify every HMAC-carrying row. Intended for a\n// scheduled integrity job or an on-demand audit endpoint.\nexport async function verifyEventChain(store: IStoreAdapter, key: string): Promise<IIntegrityReport> {\n\tconst rows = await store.query<IRawEventRow>(\n\t\t`SELECT id, type, payload, meta, hmac FROM fonderie_events ORDER BY created_at, id`,\n\t);\n\n\tconst report: IIntegrityReport = { ok: true, checked: 0, unprotected: 0, tampered: [] };\n\n\tfor (const row of rows) {\n\t\tif (row.hmac === null) {\n\t\t\treport.unprotected += 1;\n\t\t\tcontinue;\n\t\t}\n\t\treport.checked += 1;\n\t\tconst expected = computeEventHmac(key, row);\n\t\tif (!constantTimeEqual(expected, row.hmac)) {\n\t\t\treport.ok = false;\n\t\t\treport.tampered.push(row.id);\n\t\t}\n\t}\n\n\treturn report;\n}\n","import type { IFonderieModule, IFonderieApp, IReadinessProblem } from '@fonderie/core';\n\nimport { EventBus } from './bus';\nimport { PGTransport } from './transports/pg';\nimport type { IEventTransport } from './transports/types';\n\nexport type EventTransportConfig =\n\t| {\n\t\t\ttype: 'pg';\n\t\t\tconnectionUrl: string;\n\t\t\tmaxRetries?: number;\n\t\t\tbatchSize?: number;\n\t\t\tpollInterval?: number;\n\t\t\t// Enables tamper-evident audit logging (keyed HMAC per event).\n\t\t\tintegrityKey?: string;\n\t }\n\t| IEventTransport;\n\nexport interface IEventsConfig {\n\ttransport: EventTransportConfig;\n}\n\nfunction resolveTransport(config: EventTransportConfig): IEventTransport {\n\tif ('type' in config && config.type === 'pg') {\n\t\treturn new PGTransport({\n\t\t\tconnectionUrl: config.connectionUrl,\n\t\t\t...(config.maxRetries !== undefined ? { maxRetries: config.maxRetries } : {}),\n\t\t\t...(config.batchSize !== undefined ? { batchSize: config.batchSize } : {}),\n\t\t\t...(config.pollInterval !== undefined ? { pollInterval: config.pollInterval } : {}),\n\t\t\t...(config.integrityKey !== undefined ? { integrityKey: config.integrityKey } : {}),\n\t\t});\n\t}\n\n\treturn config as IEventTransport;\n}\n\nexport class EventsModule implements IFonderieModule {\n\treadonly name = '@fonderie/events';\n\treadonly bus: EventBus;\n\tprivate readonly config: IEventsConfig;\n\n\tconstructor(config: IEventsConfig) {\n\t\tthis.config = config;\n\t\tthis.bus = new EventBus(resolveTransport(config.transport));\n\t}\n\n\tinstall(_app: IFonderieApp): void {\n\t\tthis.bus.start().catch((err) => console.error('[events] failed to start transport', err));\n\t}\n\n\t// The event log doubles as the audit trail. Without an integrityKey it is\n\t// append-only but not tamper-evident, so a compromised DB write could alter\n\t// history undetectably — a finding worth surfacing (not fatal).\n\tcheckReadiness(): IReadinessProblem[] {\n\t\tconst t = this.config.transport;\n\t\tif ('type' in t && t.type === 'pg' && !t.integrityKey) {\n\t\t\treturn [{\n\t\t\t\tmodule: this.name,\n\t\t\t\tseverity: 'warning',\n\t\t\t\tmessage: 'no integrityKey — the event/audit log is not tamper-evident; set one to enable per-event HMACs',\n\t\t\t}];\n\t\t}\n\t\treturn [];\n\t}\n}\n","import type { IEventTransport } from './types';\nimport type { IEventMeta, IEventHandler } from '../types';\nimport { matchesPattern } from './pattern';\n\nexport class MemoryTransport implements IEventTransport {\n\tprivate subscriptions: Array<{ pattern: string; handler: IEventHandler }> = [];\n\n\tasync publish(type: string, payload: unknown, meta: IEventMeta): Promise<void> {\n\t\tconst matching = this.subscriptions.filter((s) => matchesPattern(s.pattern, type));\n\t\tawait Promise.all(matching.map((s) => s.handler(payload, meta)));\n\t}\n\n\tsubscribe(pattern: string, handler: IEventHandler, _consumer: string): void {\n\t\tthis.subscriptions.push({ pattern, handler });\n\t}\n\n\tasync start(): Promise<void> {}\n\tasync stop(): Promise<void> {}\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport { verifyEventChain, type IIntegrityReport } from './integrity';\n\n// Scheduled tamper-detection for the audit/event log (SOC 2 CC7.2). Runs\n// `verifyEventChain` on an interval; if any HMAC-carrying row fails, it fires\n// `onTamper` — wire that to your alerting. Runs once immediately, then every\n// `intervalMs`. Non-blocking (the timer is unref'd). Call `.stop()` to cancel.\n\nexport interface IIntegrityCheckOptions {\n\t// Default 24h.\n\tintervalMs?: number;\n\t// Called after every run (ok or not) — e.g. to record a heartbeat.\n\tonResult?: (report: IIntegrityReport) => void;\n\t// Called only when the log failed verification. Defaults to a loud\n\t// console.error naming the tampered rows — override to page/alert.\n\tonTamper?: (report: IIntegrityReport) => void;\n}\n\nexport interface IIntegrityCheckHandle {\n\tstop: () => void;\n}\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\n\nexport function startIntegrityCheck(\n\tstore: IStoreAdapter,\n\tkey: string,\n\toptions: IIntegrityCheckOptions = {},\n): IIntegrityCheckHandle {\n\tconst intervalMs = options.intervalMs ?? DAY_MS;\n\tconst onTamper = options.onTamper ?? defaultTamperHandler;\n\tlet stopped = false;\n\n\tconst run = async (): Promise<void> => {\n\t\tif (stopped) return;\n\t\ttry {\n\t\t\tconst report = await verifyEventChain(store, key);\n\t\t\toptions.onResult?.(report);\n\t\t\tif (!report.ok) onTamper(report);\n\t\t} catch (err) {\n\t\t\tconsole.error('[events] integrity check failed to run:', err);\n\t\t}\n\t};\n\n\tconst timer = setInterval(run, intervalMs);\n\tif (typeof (timer as { unref?: () => void }).unref === 'function') {\n\t\t(timer as { unref: () => void }).unref();\n\t}\n\tvoid run(); // fire once immediately\n\n\treturn {\n\t\tstop: () => {\n\t\t\tstopped = true;\n\t\t\tclearInterval(timer);\n\t\t},\n\t};\n}\n\nfunction defaultTamperHandler(report: IIntegrityReport): void {\n\tconsole.error(\n\t\t`[events] AUDIT LOG INTEGRITY FAILURE — ${report.tampered.length} tampered row(s) ` +\n\t\t\t`out of ${report.checked} checked: ${report.tampered.join(', ')}`,\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\n// Retention for the append-only event/audit log. Events accumulate forever by\n// default; a retention policy disposes of them once they age past the window.\n// Call from a scheduled job. Per-consumer delivery rows are removed by\n// ON DELETE CASCADE. Note: purging is disposal, not tampering — it removes whole\n// aged rows wholesale and does not touch the HMAC of anything it keeps.\n\nexport interface IPurgeEventsOptions {\n\t// Delete events whose created_at is older than this many days.\n\tolderThanDays: number;\n}\n\nexport async function purgeEvents(\n\tstore: IStoreAdapter,\n\t{ olderThanDays }: IPurgeEventsOptions,\n): Promise<number> {\n\tif (!Number.isFinite(olderThanDays) || olderThanDays < 0) {\n\t\tthrow new Error('[events] purgeEvents: olderThanDays must be a non-negative number');\n\t}\n\tconst rows = await store.query<{ id: string }>(\n\t\t`DELETE FROM fonderie_events\n\t\t WHERE created_at < now() - make_interval(days => $1)\n\t\t RETURNING id`,\n\t\t[olderThanDays],\n\t);\n\treturn rows.length;\n}\n\n// Scheduled disposal (SOC 2 C1/P4). Runs purgeEvents on an interval so aged\n// audit/event rows don't accumulate past the policy window. Runs once\n// immediately, then every intervalMs. Non-blocking (timer unref'd). .stop() cancels.\nexport interface IRetentionScheduleOptions extends IPurgeEventsOptions {\n\tintervalMs?: number; // default 24h\n\tonPurge?: (deleted: number) => void;\n}\n\nexport function startEventRetention(\n\tstore: IStoreAdapter,\n\toptions: IRetentionScheduleOptions,\n): { stop: () => void } {\n\tconst intervalMs = options.intervalMs ?? 24 * 60 * 60 * 1000;\n\tlet stopped = false;\n\tconst run = async () => {\n\t\tif (stopped) return;\n\t\ttry {\n\t\t\tconst deleted = await purgeEvents(store, { olderThanDays: options.olderThanDays });\n\t\t\toptions.onPurge?.(deleted);\n\t\t} catch (err) {\n\t\t\tconsole.error('[events] scheduled retention purge failed:', err);\n\t\t}\n\t};\n\tconst timer = setInterval(run, intervalMs);\n\tif (typeof (timer as { unref?: () => void }).unref === 'function') (timer as { unref: () => void }).unref();\n\tvoid run();\n\treturn { stop: () => { stopped = true; clearInterval(timer); } };\n}\n","export { EventBus } from './bus';\nexport { EventsModule } from './module';\nexport type { IEventsConfig, EventTransportConfig } from './module';\n\nexport { MemoryTransport, PGTransport } from './transports';\nexport type { IEventTransport, IPGTransportConfig } from './transports';\n\nexport { matchesPattern } from './transports/pattern';\n\n// Audit-log tamper-evidence\nexport { computeEventHmac, verifyEventChain, canonicalize } from './integrity';\nexport { startIntegrityCheck } from './integrity-job';\nexport type { IIntegrityCheckOptions, IIntegrityCheckHandle } from './integrity-job';\nexport type { IHashableEvent, IIntegrityReport } from './integrity';\n\n// Retention / disposal\nexport { purgeEvents, startEventRetention } from './retention';\nexport type { IPurgeEventsOptions, IRetentionScheduleOptions } from './retention';\n\nexport type { IEventMeta, IEventHandler, IEventRecord, IConsumerRecord } from './types';\n\n// ── Typed event keys ─────────────────────────────────────────────\n// Each domain package re-exports its own EVENT_KEYS.\n// Consumers alias on import:\n// import { EVENT_KEYS as AUTH_EVENT_KEYS } from '@fonderie/auth'\n\nexport const NOTIFICATION_EVENT = 'fonderie.notification.send' as const;\nexport type NotificationEvent = typeof NOTIFICATION_EVENT;\n"],"mappings":"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","names":[]}
@@ -0,0 +1,4 @@
1
+ -- Tamper-evidence: per-row keyed HMAC over each event's immutable content.
2
+ -- NULL for rows written before integrity was enabled (verification skips them).
3
+ ALTER TABLE fonderie_events
4
+ ADD COLUMN IF NOT EXISTS hmac TEXT;
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@fonderie/events",
3
- "version": "5.0.0",
4
- "description": "Event bus for @fonderie-js — memory and PostgreSQL transports built-in, adapter interface for Redis/Kafka/RabbitMQ.",
3
+ "version": "5.0.2",
4
+ "description": "Event bus for @fonderiejs — memory and PostgreSQL transports built-in, adapter interface for Redis/Kafka/RabbitMQ.",
5
5
  "keywords": [
6
- "fonderie-js",
6
+ "fonderiejs",
7
7
  "events",
8
8
  "event-bus",
9
9
  "pubsub",
@@ -39,7 +39,7 @@
39
39
  "check": "biome check --write src"
40
40
  },
41
41
  "peerDependencies": {
42
- "@fonderie/core": "^0.5.0",
42
+ "@fonderie/core": "^0.7.0",
43
43
  "@fonderie/store": "^0.2.0",
44
44
  "pg": "^8.0.0"
45
45
  },
@@ -54,11 +54,11 @@
54
54
  "devDependencies": {
55
55
  "@fonderie/core": "../core",
56
56
  "@fonderie/store": "../store",
57
- "@types/node": "^25.6.0",
58
- "@types/pg": "^8.11.10",
59
- "pg": "^8.13.3",
57
+ "@types/node": "^26.4.0",
58
+ "@types/pg": "^8.21.0",
59
+ "pg": "^8.23.0",
60
60
  "tsup": "^8.5.1",
61
- "tsx": "^4.21.0",
61
+ "tsx": "^4.23.12",
62
62
  "typescript": "^6.0.3"
63
63
  },
64
64
  "publishConfig": {
@@ -72,11 +72,11 @@
72
72
  ],
73
73
  "repository": {
74
74
  "type": "git",
75
- "url": "git+https://github.com/fonderiejs/sdk.git",
75
+ "url": "git+https://github.com/fonderiejs/fonderie.git",
76
76
  "directory": "packages/events"
77
77
  },
78
- "homepage": "https://github.com/fonderiejs/sdk/tree/main/packages/events#readme",
78
+ "homepage": "https://github.com/fonderiejs/fonderie/tree/main/packages/events#readme",
79
79
  "bugs": {
80
- "url": "https://github.com/fonderiejs/sdk/issues"
80
+ "url": "https://github.com/fonderiejs/fonderie/issues"
81
81
  }
82
82
  }