@sguild/dispatcher 2.0.0

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.
Files changed (66) hide show
  1. package/README.md +354 -0
  2. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.funded-v1.json +59 -0
  3. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunded-v1.json +80 -0
  4. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunding-v1.json +74 -0
  5. package/contracts/credit-reservation-lock/schema/payloads/credit.consumed-v1.json +33 -0
  6. package/contracts/credit-reservation-lock/schema/payloads/credit.forfeited-v1.json +41 -0
  7. package/contracts/credit-reservation-lock/schema/payloads/credit.funded-v1.json +31 -0
  8. package/contracts/credit-reservation-lock/schema/payloads/credit.locked-v1.json +42 -0
  9. package/contracts/credit-reservation-lock/schema/payloads/credit.purchased-v1.json +39 -0
  10. package/contracts/credit-reservation-lock/schema/payloads/credit.released-v1.json +61 -0
  11. package/contracts/credit-reservation-lock/schema/payloads/credit.released-v2.json +77 -0
  12. package/contracts/credit-reservation-lock/schema/payloads/credit.reserved-v1.json +60 -0
  13. package/contracts/credit-reservation-lock/schema/payloads/customer.handoff-v1.json +35 -0
  14. package/contracts/event-envelope/schema/envelope-v1.json +79 -0
  15. package/contracts/event-types-registry.json +541 -0
  16. package/contracts/identity/schema/payloads/intake.amended-v1.json +124 -0
  17. package/contracts/identity/schema/payloads/intake.captured-v2.json +114 -0
  18. package/contracts/identity/schema/payloads/person.updated-v1.json +99 -0
  19. package/contracts/lead-lifecycle/schema/payloads/lead.attempt.exhausted-v1.json +36 -0
  20. package/contracts/lead-lifecycle/schema/payloads/lead.callback.scheduled-v1.json +39 -0
  21. package/contracts/lead-lifecycle/schema/payloads/lead.created-v1.json +50 -0
  22. package/contracts/lead-lifecycle/schema/payloads/lead.reached-v1.json +39 -0
  23. package/contracts/lead-lifecycle/schema/payloads/lead.stage.changed-v1.json +44 -0
  24. package/contracts/payment-flow/schema/payloads/payment.failed-v1.json +88 -0
  25. package/contracts/payment-flow/schema/payloads/payment.received-v1.json +69 -0
  26. package/contracts/refund-flow/schema/payloads/refund.completed-v1.json +75 -0
  27. package/contracts/refund-flow/schema/payloads/refund.initiated-v1.json +69 -0
  28. package/dist/config.d.ts +67 -0
  29. package/dist/config.js +81 -0
  30. package/dist/dispatcher-errors.d.ts +20 -0
  31. package/dist/dispatcher-errors.js +42 -0
  32. package/dist/dispatcher.d.ts +123 -0
  33. package/dist/dispatcher.js +171 -0
  34. package/dist/dlq.d.ts +173 -0
  35. package/dist/dlq.js +391 -0
  36. package/dist/fanout-drain.d.ts +11 -0
  37. package/dist/fanout-drain.js +31 -0
  38. package/dist/fanout.d.ts +144 -0
  39. package/dist/fanout.js +321 -0
  40. package/dist/inbox.d.ts +125 -0
  41. package/dist/inbox.js +120 -0
  42. package/dist/index.d.ts +36 -0
  43. package/dist/index.js +70 -0
  44. package/dist/internal/id.d.ts +38 -0
  45. package/dist/internal/id.js +78 -0
  46. package/dist/internal/pg-search-path.d.ts +34 -0
  47. package/dist/internal/pg-search-path.js +55 -0
  48. package/dist/internal/resolve-contract-path.d.ts +41 -0
  49. package/dist/internal/resolve-contract-path.js +65 -0
  50. package/dist/observability.d.ts +24 -0
  51. package/dist/observability.js +37 -0
  52. package/dist/postgres-consumer.d.ts +175 -0
  53. package/dist/postgres-consumer.js +561 -0
  54. package/dist/postgres-transport.d.ts +70 -0
  55. package/dist/postgres-transport.js +144 -0
  56. package/dist/producer-db.d.ts +80 -0
  57. package/dist/producer-db.js +115 -0
  58. package/dist/registry.d.ts +94 -0
  59. package/dist/registry.js +99 -0
  60. package/dist/signature.d.ts +44 -0
  61. package/dist/signature.js +79 -0
  62. package/dist/types.d.ts +107 -0
  63. package/dist/types.js +13 -0
  64. package/dist/validator.d.ts +60 -0
  65. package/dist/validator.js +171 -0
  66. package/package.json +48 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Platform-side dispatcher fanout worker per the 2026-05-12 topology
3
+ * decision (memos/2026-05-12-platform-cross-db-consumer-dsn-upstream).
4
+ *
5
+ * For each (event_type, consumer-domain) registered in the event-types
6
+ * registry, the fanout creates a webhook-dispatch handler that POSTs the
7
+ * envelope to the consumer-domain's configured URL with an HMAC-signed
8
+ * body. The existing ConsumerLoop machinery owns the cursor advancement,
9
+ * retry-with-backoff, dedup, and dead-letter logic; this module is a
10
+ * thin layer over it that swaps the in-process handler invocation for an
11
+ * HTTP POST.
12
+ *
13
+ * Multi-producer fanout: one ConsumerLoop per (producer-domain,
14
+ * consumer-domain) pair. Each loop reads from the producer's
15
+ * `dispatcher_event` table (per ADR-0009's per-domain table family) via
16
+ * a producer-specific Prisma client resolved through producer-db.ts. The
17
+ * fanout is centralized in Platform's worker rather than distributed
18
+ * across each consumer process, preserving topology B's "one place
19
+ * owns retry / dedup / DLQ" rationale even when the producer is a
20
+ * different domain's database.
21
+ *
22
+ * Consumer name pattern: "fanout:<consumer-domain>" (e.g., "fanout:sales").
23
+ * The dispatcher_cursor, dispatcher_dedup, and dispatcher_dead_letter
24
+ * rows live in the producer's database, so the same consumer name
25
+ * appearing in multiple producer DBs does not collide — each producer's
26
+ * cursor table tracks its own (consumer, event_type) pair independently.
27
+ *
28
+ * Routing is env-var-driven so the same code runs in dev, staging, and
29
+ * production with different DSNs / URLs:
30
+ *
31
+ * DISPATCHER_PRODUCER_DATABASE_URL_<PRODUCER> source DSN per producer
32
+ * (DATABASE_URL is used
33
+ * for the worker's own
34
+ * producer when applicable)
35
+ * DISPATCHER_CONSUMER_URL_<DOMAIN> destination webhook URL
36
+ * DISPATCHER_CONSUMER_SECRET_<DOMAIN> shared HMAC secret
37
+ *
38
+ * A producer with no DSN, or a consumer with no URL or secret, is logged
39
+ * at info level and excluded from the fanout. The loop appears
40
+ * automatically on the next process restart once the missing env arrives.
41
+ *
42
+ * Master gate: DISPATCHER_FANOUT_ENABLED=true must be set for the
43
+ * fanout to start. Absent the flag, startFanout returns a no-op stop
44
+ * function and logs that the fanout is disabled. Useful for local dev
45
+ * loops where Platform's web server should run without firing webhooks.
46
+ *
47
+ * HMAC signature contract: header `X-Sguild-Signature: sha256=<hex>` over
48
+ * the JSON-serialized envelope body, computed with the per-consumer
49
+ * shared secret. Consumers verify by recomputing the same digest and
50
+ * constant-time comparing. Compromised secret is rotated by updating
51
+ * both the Platform env and the consumer env in lockstep.
52
+ *
53
+ * Slice 4 of the Phase 2 dispatcher SDK; lands the topology B decision.
54
+ */
55
+ import { ConsumerLoop } from "./postgres-consumer";
56
+ import type { EventEnvelope, LiveProducerDomain } from "./types";
57
+ export { EVENT_ID_HEADER, EVENT_TYPE_HEADER, SIGNATURE_HEADER, signEnvelope, verifyEnvelopeSignature, } from "./signature";
58
+ export declare const FANOUT_CONSUMER_PREFIX = "fanout:";
59
+ /**
60
+ * Domains the fanout knows about as webhook destinations. Platform is
61
+ * now included so cross-domain producers (e.g., Sales emitting
62
+ * `role.assigned` for the lead role) can fan back to Platform's own
63
+ * webhook inbox — Platform's person_role projection consumes those
64
+ * events the same way Sales/Growth consume person.updated.
65
+ *
66
+ * Self-fanout is prevented separately by the `consumer === producer`
67
+ * check in buildFanoutLoops, so Platform producing person.updated still
68
+ * doesn't get re-delivered to its own inbox.
69
+ *
70
+ * `platform-warehouse` stays excluded — that's a synthetic consumer
71
+ * for the warehouse loader, not a webhook destination.
72
+ */
73
+ export declare const KNOWN_CONSUMER_DOMAINS: ReadonlyArray<LiveProducerDomain>;
74
+ export declare class WebhookDispatchError extends Error {
75
+ readonly consumer: string;
76
+ readonly url: string;
77
+ readonly statusCode: number | null;
78
+ constructor(consumer: string, url: string, statusCode: number | null, message: string);
79
+ }
80
+ export declare function consumerUrl(domain: LiveProducerDomain): string | null;
81
+ export declare function consumerSecret(domain: LiveProducerDomain): string | null;
82
+ /**
83
+ * POST an envelope to a single consumer-domain's inbox URL. Throws a
84
+ * WebhookDispatchError on non-2xx response or network failure; the
85
+ * ConsumerLoop's retry-with-backoff handles the rest.
86
+ *
87
+ * The envelope is JSON-serialized once; the same string is both signed
88
+ * and sent as the request body so the consumer's signature verification
89
+ * uses the exact bytes the signature was computed over.
90
+ */
91
+ export declare function postEnvelopeToConsumer(consumer: LiveProducerDomain, envelope: EventEnvelope<unknown>, fetchImpl?: typeof fetch): Promise<void>;
92
+ export type FanoutLoopHandle = {
93
+ /** Source producer-domain this loop polls from. Determines which DB's dispatcher_event the loop reads. */
94
+ producer: string;
95
+ /** Destination consumer-domain this loop POSTs envelopes to. */
96
+ consumer: LiveProducerDomain;
97
+ loop: ConsumerLoop;
98
+ };
99
+ /**
100
+ * Build the consumer-loop set for Platform's fanout. Reads the registry,
101
+ * groups events by (producer, consumer-domain), and for each producer
102
+ * resolves the producer's DSN via producer-db.ts so the loop's reads
103
+ * and LISTEN/NOTIFY wake-up target that producer's database. Returns an
104
+ * array of {producer, consumer, loop} ready to start.
105
+ *
106
+ * Exclusion logging at info level:
107
+ * - Producer DSN missing: every loop reading that producer is skipped.
108
+ * The boot log names the producer and the env var to provision
109
+ * (DATABASE_URL for self-producer, DISPATCHER_PRODUCER_DATABASE_URL_*
110
+ * for everything else).
111
+ * - Consumer URL or secret missing: every loop POSTing to that
112
+ * consumer is skipped.
113
+ *
114
+ * Skipped (producer, consumer) pairs pick up automatically on the next
115
+ * process restart once the missing env is provisioned.
116
+ */
117
+ export declare function buildFanoutLoops(options?: {
118
+ fetchImpl?: typeof fetch;
119
+ }): FanoutLoopHandle[];
120
+ /**
121
+ * Start all fanout loops. Returns a stop function the caller invokes
122
+ * on shutdown (SIGTERM via instrumentation.ts).
123
+ *
124
+ * Master gate: DISPATCHER_FANOUT_ENABLED=true is required for the
125
+ * fanout to start. Absent the flag, this is a no-op and the caller
126
+ * gets a stop function that resolves immediately. This lets dev and
127
+ * test environments run Platform without firing webhooks unless the
128
+ * developer explicitly opts in.
129
+ *
130
+ * Calling startFanout twice without an intervening stop throws.
131
+ */
132
+ export declare function startFanout(options?: {
133
+ fetchImpl?: typeof fetch;
134
+ }): Promise<() => Promise<void>>;
135
+ /**
136
+ * Test-only: get the running loops for introspection. Production code
137
+ * never calls this.
138
+ */
139
+ export declare function __getRunningLoopsForTests(): FanoutLoopHandle[];
140
+ /**
141
+ * Test-only: reset the running-loops state. Used between test fixtures
142
+ * to allow startFanout to be called again without a prior stop call.
143
+ */
144
+ export declare function __resetFanoutStateForTests(): void;
package/dist/fanout.js ADDED
@@ -0,0 +1,321 @@
1
+ "use strict";
2
+ /**
3
+ * Platform-side dispatcher fanout worker per the 2026-05-12 topology
4
+ * decision (memos/2026-05-12-platform-cross-db-consumer-dsn-upstream).
5
+ *
6
+ * For each (event_type, consumer-domain) registered in the event-types
7
+ * registry, the fanout creates a webhook-dispatch handler that POSTs the
8
+ * envelope to the consumer-domain's configured URL with an HMAC-signed
9
+ * body. The existing ConsumerLoop machinery owns the cursor advancement,
10
+ * retry-with-backoff, dedup, and dead-letter logic; this module is a
11
+ * thin layer over it that swaps the in-process handler invocation for an
12
+ * HTTP POST.
13
+ *
14
+ * Multi-producer fanout: one ConsumerLoop per (producer-domain,
15
+ * consumer-domain) pair. Each loop reads from the producer's
16
+ * `dispatcher_event` table (per ADR-0009's per-domain table family) via
17
+ * a producer-specific Prisma client resolved through producer-db.ts. The
18
+ * fanout is centralized in Platform's worker rather than distributed
19
+ * across each consumer process, preserving topology B's "one place
20
+ * owns retry / dedup / DLQ" rationale even when the producer is a
21
+ * different domain's database.
22
+ *
23
+ * Consumer name pattern: "fanout:<consumer-domain>" (e.g., "fanout:sales").
24
+ * The dispatcher_cursor, dispatcher_dedup, and dispatcher_dead_letter
25
+ * rows live in the producer's database, so the same consumer name
26
+ * appearing in multiple producer DBs does not collide — each producer's
27
+ * cursor table tracks its own (consumer, event_type) pair independently.
28
+ *
29
+ * Routing is env-var-driven so the same code runs in dev, staging, and
30
+ * production with different DSNs / URLs:
31
+ *
32
+ * DISPATCHER_PRODUCER_DATABASE_URL_<PRODUCER> source DSN per producer
33
+ * (DATABASE_URL is used
34
+ * for the worker's own
35
+ * producer when applicable)
36
+ * DISPATCHER_CONSUMER_URL_<DOMAIN> destination webhook URL
37
+ * DISPATCHER_CONSUMER_SECRET_<DOMAIN> shared HMAC secret
38
+ *
39
+ * A producer with no DSN, or a consumer with no URL or secret, is logged
40
+ * at info level and excluded from the fanout. The loop appears
41
+ * automatically on the next process restart once the missing env arrives.
42
+ *
43
+ * Master gate: DISPATCHER_FANOUT_ENABLED=true must be set for the
44
+ * fanout to start. Absent the flag, startFanout returns a no-op stop
45
+ * function and logs that the fanout is disabled. Useful for local dev
46
+ * loops where Platform's web server should run without firing webhooks.
47
+ *
48
+ * HMAC signature contract: header `X-Sguild-Signature: sha256=<hex>` over
49
+ * the JSON-serialized envelope body, computed with the per-consumer
50
+ * shared secret. Consumers verify by recomputing the same digest and
51
+ * constant-time comparing. Compromised secret is rotated by updating
52
+ * both the Platform env and the consumer env in lockstep.
53
+ *
54
+ * Slice 4 of the Phase 2 dispatcher SDK; lands the topology B decision.
55
+ */
56
+ Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.WebhookDispatchError = exports.KNOWN_CONSUMER_DOMAINS = exports.FANOUT_CONSUMER_PREFIX = exports.verifyEnvelopeSignature = exports.signEnvelope = exports.SIGNATURE_HEADER = exports.EVENT_TYPE_HEADER = exports.EVENT_ID_HEADER = void 0;
58
+ exports.consumerUrl = consumerUrl;
59
+ exports.consumerSecret = consumerSecret;
60
+ exports.postEnvelopeToConsumer = postEnvelopeToConsumer;
61
+ exports.buildFanoutLoops = buildFanoutLoops;
62
+ exports.startFanout = startFanout;
63
+ exports.__getRunningLoopsForTests = __getRunningLoopsForTests;
64
+ exports.__resetFanoutStateForTests = __resetFanoutStateForTests;
65
+ const postgres_consumer_1 = require("./postgres-consumer");
66
+ const producer_db_1 = require("./producer-db");
67
+ const registry_1 = require("./registry");
68
+ const signature_1 = require("./signature");
69
+ // Re-export the signature primitives at the fanout layer so existing
70
+ // consumers of these constants from fanout.ts continue to work. The
71
+ // canonical home is signature.ts; this is a soft-deprecation alias for
72
+ // the legacy import path.
73
+ var signature_2 = require("./signature");
74
+ Object.defineProperty(exports, "EVENT_ID_HEADER", { enumerable: true, get: function () { return signature_2.EVENT_ID_HEADER; } });
75
+ Object.defineProperty(exports, "EVENT_TYPE_HEADER", { enumerable: true, get: function () { return signature_2.EVENT_TYPE_HEADER; } });
76
+ Object.defineProperty(exports, "SIGNATURE_HEADER", { enumerable: true, get: function () { return signature_2.SIGNATURE_HEADER; } });
77
+ Object.defineProperty(exports, "signEnvelope", { enumerable: true, get: function () { return signature_2.signEnvelope; } });
78
+ Object.defineProperty(exports, "verifyEnvelopeSignature", { enumerable: true, get: function () { return signature_2.verifyEnvelopeSignature; } });
79
+ // =============================================================================
80
+ // Constants
81
+ // =============================================================================
82
+ exports.FANOUT_CONSUMER_PREFIX = "fanout:";
83
+ /**
84
+ * Domains the fanout knows about as webhook destinations. Platform is
85
+ * now included so cross-domain producers (e.g., Sales emitting
86
+ * `role.assigned` for the lead role) can fan back to Platform's own
87
+ * webhook inbox — Platform's person_role projection consumes those
88
+ * events the same way Sales/Growth consume person.updated.
89
+ *
90
+ * Self-fanout is prevented separately by the `consumer === producer`
91
+ * check in buildFanoutLoops, so Platform producing person.updated still
92
+ * doesn't get re-delivered to its own inbox.
93
+ *
94
+ * `platform-warehouse` stays excluded — that's a synthetic consumer
95
+ * for the warehouse loader, not a webhook destination.
96
+ */
97
+ exports.KNOWN_CONSUMER_DOMAINS = [
98
+ "platform",
99
+ "growth",
100
+ "sales",
101
+ "delivery",
102
+ "revenue",
103
+ "coaching",
104
+ ];
105
+ // =============================================================================
106
+ // Error types
107
+ // =============================================================================
108
+ class WebhookDispatchError extends Error {
109
+ consumer;
110
+ url;
111
+ statusCode;
112
+ constructor(consumer, url, statusCode, message) {
113
+ super(message);
114
+ this.consumer = consumer;
115
+ this.url = url;
116
+ this.statusCode = statusCode;
117
+ this.name = "WebhookDispatchError";
118
+ }
119
+ }
120
+ exports.WebhookDispatchError = WebhookDispatchError;
121
+ // =============================================================================
122
+ // Routing config
123
+ // =============================================================================
124
+ function consumerUrl(domain) {
125
+ const envName = `DISPATCHER_CONSUMER_URL_${domain.toUpperCase()}`;
126
+ return process.env[envName] || null;
127
+ }
128
+ function consumerSecret(domain) {
129
+ const envName = `DISPATCHER_CONSUMER_SECRET_${domain.toUpperCase()}`;
130
+ return process.env[envName] || null;
131
+ }
132
+ // =============================================================================
133
+ // Webhook dispatch
134
+ // =============================================================================
135
+ /**
136
+ * POST an envelope to a single consumer-domain's inbox URL. Throws a
137
+ * WebhookDispatchError on non-2xx response or network failure; the
138
+ * ConsumerLoop's retry-with-backoff handles the rest.
139
+ *
140
+ * The envelope is JSON-serialized once; the same string is both signed
141
+ * and sent as the request body so the consumer's signature verification
142
+ * uses the exact bytes the signature was computed over.
143
+ */
144
+ async function postEnvelopeToConsumer(consumer, envelope, fetchImpl = globalThis.fetch) {
145
+ const url = consumerUrl(consumer);
146
+ const secret = consumerSecret(consumer);
147
+ if (!url) {
148
+ throw new WebhookDispatchError(consumer, "<unset>", null, `Consumer URL not set; expected env DISPATCHER_CONSUMER_URL_${consumer.toUpperCase()}`);
149
+ }
150
+ if (!secret) {
151
+ throw new WebhookDispatchError(consumer, url, null, `Consumer HMAC secret not set; expected env DISPATCHER_CONSUMER_SECRET_${consumer.toUpperCase()}`);
152
+ }
153
+ const body = JSON.stringify(envelope);
154
+ const signature = `sha256=${(0, signature_1.signEnvelope)(body, secret)}`;
155
+ let response;
156
+ try {
157
+ response = await fetchImpl(url, {
158
+ method: "POST",
159
+ headers: {
160
+ "content-type": "application/json",
161
+ [signature_1.EVENT_ID_HEADER]: envelope.event_id,
162
+ [signature_1.EVENT_TYPE_HEADER]: envelope.event_type,
163
+ [signature_1.SIGNATURE_HEADER]: signature,
164
+ },
165
+ body,
166
+ });
167
+ }
168
+ catch (e) {
169
+ throw new WebhookDispatchError(consumer, url, null, `Network error POSTing to consumer: ${e instanceof Error ? e.message : String(e)}`);
170
+ }
171
+ if (!response.ok) {
172
+ let bodyText;
173
+ try {
174
+ bodyText = (await response.text()).slice(0, 500);
175
+ }
176
+ catch {
177
+ bodyText = "<unreadable response body>";
178
+ }
179
+ throw new WebhookDispatchError(consumer, url, response.status, `Consumer returned non-2xx: ${response.status} ${response.statusText}; body: ${bodyText}`);
180
+ }
181
+ }
182
+ /**
183
+ * Build the consumer-loop set for Platform's fanout. Reads the registry,
184
+ * groups events by (producer, consumer-domain), and for each producer
185
+ * resolves the producer's DSN via producer-db.ts so the loop's reads
186
+ * and LISTEN/NOTIFY wake-up target that producer's database. Returns an
187
+ * array of {producer, consumer, loop} ready to start.
188
+ *
189
+ * Exclusion logging at info level:
190
+ * - Producer DSN missing: every loop reading that producer is skipped.
191
+ * The boot log names the producer and the env var to provision
192
+ * (DATABASE_URL for self-producer, DISPATCHER_PRODUCER_DATABASE_URL_*
193
+ * for everything else).
194
+ * - Consumer URL or secret missing: every loop POSTing to that
195
+ * consumer is skipped.
196
+ *
197
+ * Skipped (producer, consumer) pairs pick up automatically on the next
198
+ * process restart once the missing env is provisioned.
199
+ */
200
+ function buildFanoutLoops(options = {}) {
201
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
202
+ const manifest = (0, registry_1.loadRegistry)();
203
+ // Group registrations by (producer, consumer). The outer key is the
204
+ // producer (because each producer has its own DSN and its own
205
+ // ConsumerLoop instance); the inner key is the consumer (because each
206
+ // consumer has its own webhook URL).
207
+ const byProducerThenConsumer = new Map();
208
+ for (const [eventType, entry] of Object.entries(manifest.events)) {
209
+ const producer = entry.producer;
210
+ for (const consumer of entry.consumers) {
211
+ if (consumer === "platform-warehouse")
212
+ continue;
213
+ if (consumer === producer)
214
+ continue; // no self-fanout
215
+ if (!exports.KNOWN_CONSUMER_DOMAINS.includes(consumer))
216
+ continue;
217
+ const domain = consumer;
218
+ let consumerMap = byProducerThenConsumer.get(producer);
219
+ if (!consumerMap) {
220
+ consumerMap = new Map();
221
+ byProducerThenConsumer.set(producer, consumerMap);
222
+ }
223
+ if (!consumerMap.has(domain)) {
224
+ consumerMap.set(domain, []);
225
+ }
226
+ consumerMap.get(domain).push({
227
+ eventType,
228
+ handler: (envelope) => postEnvelopeToConsumer(domain, envelope, fetchImpl),
229
+ });
230
+ }
231
+ }
232
+ const result = [];
233
+ for (const [producer, consumerMap] of byProducerThenConsumer.entries()) {
234
+ const producerDb = (0, producer_db_1.getProducerDb)(producer);
235
+ if (!producerDb) {
236
+ const envName = producer === "platform"
237
+ ? "DATABASE_URL"
238
+ : `DISPATCHER_PRODUCER_DATABASE_URL_${producer.toUpperCase()}`;
239
+ console.log(`[dispatcher.fanout] producer ${producer} excluded: DSN not set; expected env ${envName}; ${consumerMap.size} consumer loop(s) will start once the env is provisioned`);
240
+ continue;
241
+ }
242
+ for (const [domain, registrations] of consumerMap.entries()) {
243
+ const url = consumerUrl(domain);
244
+ const secret = consumerSecret(domain);
245
+ if (!url || !secret) {
246
+ console.log(`[dispatcher.fanout] consumer ${domain} from producer ${producer} excluded: ${!url ? "URL" : "secret"} env var not set; the loop will start once DISPATCHER_CONSUMER_URL_${domain.toUpperCase()} and DISPATCHER_CONSUMER_SECRET_${domain.toUpperCase()} are provisioned`);
247
+ continue;
248
+ }
249
+ const loop = new postgres_consumer_1.ConsumerLoop(registrations, {
250
+ consumer: `${exports.FANOUT_CONSUMER_PREFIX}${domain}`,
251
+ pool: producerDb.pool,
252
+ databaseUrl: producerDb.databaseUrl,
253
+ schema: producerDb.schema,
254
+ });
255
+ result.push({ producer, consumer: domain, loop });
256
+ }
257
+ }
258
+ return result;
259
+ }
260
+ // =============================================================================
261
+ // Bootstrap entry point
262
+ // =============================================================================
263
+ let runningLoops = [];
264
+ /**
265
+ * Start all fanout loops. Returns a stop function the caller invokes
266
+ * on shutdown (SIGTERM via instrumentation.ts).
267
+ *
268
+ * Master gate: DISPATCHER_FANOUT_ENABLED=true is required for the
269
+ * fanout to start. Absent the flag, this is a no-op and the caller
270
+ * gets a stop function that resolves immediately. This lets dev and
271
+ * test environments run Platform without firing webhooks unless the
272
+ * developer explicitly opts in.
273
+ *
274
+ * Calling startFanout twice without an intervening stop throws.
275
+ */
276
+ async function startFanout(options = {}) {
277
+ if (process.env.DISPATCHER_FANOUT_ENABLED !== "true") {
278
+ console.log("[dispatcher.fanout] disabled (DISPATCHER_FANOUT_ENABLED != 'true'); skipping bootstrap");
279
+ return async () => { };
280
+ }
281
+ if (runningLoops.length > 0) {
282
+ throw new Error("dispatcher.fanout.startFanout: already running; call the previous stop() before starting again");
283
+ }
284
+ const loops = buildFanoutLoops(options);
285
+ if (loops.length === 0) {
286
+ console.log("[dispatcher.fanout] no fanout loops to start (no Platform-produced events with provisioned consumer URLs)");
287
+ return async () => { };
288
+ }
289
+ runningLoops = loops;
290
+ const pairs = loops.map((l) => `${l.producer}->${l.consumer}`).join(", ");
291
+ console.log(`[dispatcher.fanout] starting fanout for ${loops.length} (producer, consumer) pair(s): ${pairs}`);
292
+ // Fire-and-forget each loop's start(). ConsumerLoop.start() returns
293
+ // a promise that only resolves when stop() is called and the
294
+ // in-flight batch finishes draining, so awaiting all of them would
295
+ // block forever. Track unhandled errors so a misbehaving loop crash
296
+ // is visible in logs rather than swallowed.
297
+ for (const { producer, consumer, loop } of loops) {
298
+ loop.start().catch((e) => {
299
+ console.error(`[dispatcher.fanout] ${producer}->${consumer} loop crashed: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
300
+ });
301
+ }
302
+ return async () => {
303
+ const current = runningLoops;
304
+ runningLoops = [];
305
+ await Promise.all(current.map((l) => l.loop.stop()));
306
+ };
307
+ }
308
+ /**
309
+ * Test-only: get the running loops for introspection. Production code
310
+ * never calls this.
311
+ */
312
+ function __getRunningLoopsForTests() {
313
+ return runningLoops;
314
+ }
315
+ /**
316
+ * Test-only: reset the running-loops state. Used between test fixtures
317
+ * to allow startFanout to be called again without a prior stop call.
318
+ */
319
+ function __resetFanoutStateForTests() {
320
+ runningLoops = [];
321
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Domain-side dispatcher inbox helpers.
3
+ *
4
+ * Per the 2026-05-12 topology decision (memos/2026-05-12-platform-cross-db-
5
+ * consumer-dsn-upstream): Platform's fanout worker POSTs events to each
6
+ * consumer domain's webhook endpoint. The endpoint pattern is roughly:
7
+ *
8
+ * POST /api/dispatcher/inbox
9
+ *
10
+ * with these headers from Platform's fanout:
11
+ *
12
+ * x-sguild-event-id: evt_<UUIDv7>
13
+ * x-sguild-event-type: <event_type>
14
+ * x-sguild-signature: sha256=<hex>
15
+ *
16
+ * and the JSON-serialized envelope as the request body. This module
17
+ * carries the two pieces every domain inbox needs:
18
+ *
19
+ * 1. `verifyInboxRequest(...)` — validates the HMAC signature against
20
+ * the raw request body using the domain's shared secret, returns the
21
+ * parsed envelope on success or a typed error on failure.
22
+ *
23
+ * 2. `dedupOnEventId(...)` — wraps an idempotency check in the domain's
24
+ * own database. Domains track which event_ids they have already
25
+ * processed; a duplicate hit returns early without re-running the
26
+ * handler. The dedup table is domain-owned, not Platform's.
27
+ *
28
+ * Domains import these helpers from `@/lib/dispatcher/inbox` once they
29
+ * vendor Platform's dispatcher SDK. The handler shape is the domain's
30
+ * own concern — this module does not own routing; it owns signature
31
+ * verification and the dedup contract.
32
+ *
33
+ * The reference route at `app/api/dispatcher/inbox/route.ts` in this
34
+ * repo shows the full assembly: verify, parse, dedup, dispatch, ack.
35
+ */
36
+ import type { EventEnvelope } from "./types";
37
+ export type InboxVerificationResult<TPayload = unknown> = {
38
+ ok: true;
39
+ envelope: EventEnvelope<TPayload>;
40
+ rawBody: string;
41
+ } | {
42
+ ok: false;
43
+ status: 401 | 400;
44
+ reason: string;
45
+ };
46
+ /**
47
+ * Verify a Platform-fanout-style POST request against the domain's
48
+ * shared HMAC secret. The signature is computed over the raw request
49
+ * body, so callers MUST pass the exact bytes received (not a re-
50
+ * stringified JSON object). The standard pattern in a Next.js route
51
+ * handler is:
52
+ *
53
+ * const rawBody = await req.text();
54
+ * const result = verifyInboxRequest(rawBody, req.headers, secret);
55
+ *
56
+ * Returns the parsed envelope on success. Failures map to the right
57
+ * HTTP status: 401 for signature mismatch, 400 for missing header or
58
+ * malformed body. The route handler returns that status verbatim.
59
+ *
60
+ * On 5xx-shaped failures (database errors during dedup, handler
61
+ * throws, etc.), the route handler returns 500 separately; Platform's
62
+ * fanout retries on 5xx and dead-letters on permanent failure. 4xx
63
+ * responses also retry once per Platform's ConsumerLoop retry budget
64
+ * (4xx may indicate a deploy-in-progress secret rotation that is
65
+ * transient).
66
+ */
67
+ export declare function verifyInboxRequest<TPayload = unknown>(rawBody: string, headers: Headers, secret: string): InboxVerificationResult<TPayload>;
68
+ /**
69
+ * Domain-side dedup helper. Domains track which event_ids they have
70
+ * already processed in their own table; Platform's fanout retries can
71
+ * deliver the same envelope twice, and the inbox must be idempotent.
72
+ *
73
+ * The dedup contract:
74
+ *
75
+ * 1. Before invoking the handler, attempt to INSERT (consumer-name,
76
+ * event_id) into the domain's dedup table.
77
+ * 2. On unique-constraint conflict (already processed), short-circuit
78
+ * and return 200; Platform's fanout treats this as success and
79
+ * advances its cursor.
80
+ * 3. On successful insert, invoke the handler. If the handler throws,
81
+ * the row stays (a future retry will see "already processed" and
82
+ * short-circuit, by design: the handler was attempted, even if it
83
+ * failed — operator inspects via the domain's lead_activity / audit
84
+ * log if a retry is needed; do NOT re-invoke on retry, that breaks
85
+ * at-most-once).
86
+ *
87
+ * The schema each domain owns:
88
+ *
89
+ * CREATE TABLE dispatcher_inbox_dedup (
90
+ * event_id TEXT PRIMARY KEY,
91
+ * event_type TEXT NOT NULL,
92
+ * processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
93
+ * );
94
+ *
95
+ * Domains may add columns (e.g., outcome_summary, handler_version) for
96
+ * audit. The PRIMARY KEY on event_id is the load-bearing piece.
97
+ *
98
+ * Why event_id alone is sufficient (no consumer column): each domain's
99
+ * dedup table is exclusive to that domain. Cross-consumer dedup is
100
+ * Platform's responsibility on the fanout side (dispatcher_dedup with
101
+ * consumer = "fanout:<domain>").
102
+ *
103
+ * This module does not ship an opinionated implementation; the domain's
104
+ * Prisma client and schema vary. The interface is documented above so
105
+ * each domain can drop in a 5-line repo function.
106
+ */
107
+ export type DedupOutcome = "first_time" | "already_processed";
108
+ export type DedupAttempt = (eventId: string, eventType: string) => Promise<DedupOutcome>;
109
+ /**
110
+ * Helper for the common case where the dedup INSERT throws on conflict
111
+ * with a recognizable Prisma error code. Wrap a Prisma insert and pass
112
+ * the throwing function in. The helper inspects the thrown error for
113
+ * Prisma's unique-violation code (P2002) and translates to
114
+ * `already_processed`; any other error rethrows.
115
+ *
116
+ * Usage:
117
+ *
118
+ * const outcome = await tryInsertOrDetectConflict(async () => {
119
+ * await prisma.dispatcherInboxDedup.create({
120
+ * data: { eventId, eventType }
121
+ * });
122
+ * });
123
+ * if (outcome === "already_processed") return new Response(null, { status: 200 });
124
+ */
125
+ export declare function tryInsertOrDetectConflict(insert: () => Promise<void>): Promise<DedupOutcome>;