@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.
- package/README.md +354 -0
- package/contracts/credit-reservation-funding-state/schema/payloads/reservation.funded-v1.json +59 -0
- package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunded-v1.json +80 -0
- package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunding-v1.json +74 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.consumed-v1.json +33 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.forfeited-v1.json +41 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.funded-v1.json +31 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.locked-v1.json +42 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.purchased-v1.json +39 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.released-v1.json +61 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.released-v2.json +77 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.reserved-v1.json +60 -0
- package/contracts/credit-reservation-lock/schema/payloads/customer.handoff-v1.json +35 -0
- package/contracts/event-envelope/schema/envelope-v1.json +79 -0
- package/contracts/event-types-registry.json +541 -0
- package/contracts/identity/schema/payloads/intake.amended-v1.json +124 -0
- package/contracts/identity/schema/payloads/intake.captured-v2.json +114 -0
- package/contracts/identity/schema/payloads/person.updated-v1.json +99 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.attempt.exhausted-v1.json +36 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.callback.scheduled-v1.json +39 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.created-v1.json +50 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.reached-v1.json +39 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.stage.changed-v1.json +44 -0
- package/contracts/payment-flow/schema/payloads/payment.failed-v1.json +88 -0
- package/contracts/payment-flow/schema/payloads/payment.received-v1.json +69 -0
- package/contracts/refund-flow/schema/payloads/refund.completed-v1.json +75 -0
- package/contracts/refund-flow/schema/payloads/refund.initiated-v1.json +69 -0
- package/dist/config.d.ts +67 -0
- package/dist/config.js +81 -0
- package/dist/dispatcher-errors.d.ts +20 -0
- package/dist/dispatcher-errors.js +42 -0
- package/dist/dispatcher.d.ts +123 -0
- package/dist/dispatcher.js +171 -0
- package/dist/dlq.d.ts +173 -0
- package/dist/dlq.js +391 -0
- package/dist/fanout-drain.d.ts +11 -0
- package/dist/fanout-drain.js +31 -0
- package/dist/fanout.d.ts +144 -0
- package/dist/fanout.js +321 -0
- package/dist/inbox.d.ts +125 -0
- package/dist/inbox.js +120 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +70 -0
- package/dist/internal/id.d.ts +38 -0
- package/dist/internal/id.js +78 -0
- package/dist/internal/pg-search-path.d.ts +34 -0
- package/dist/internal/pg-search-path.js +55 -0
- package/dist/internal/resolve-contract-path.d.ts +41 -0
- package/dist/internal/resolve-contract-path.js +65 -0
- package/dist/observability.d.ts +24 -0
- package/dist/observability.js +37 -0
- package/dist/postgres-consumer.d.ts +175 -0
- package/dist/postgres-consumer.js +561 -0
- package/dist/postgres-transport.d.ts +70 -0
- package/dist/postgres-transport.js +144 -0
- package/dist/producer-db.d.ts +80 -0
- package/dist/producer-db.js +115 -0
- package/dist/registry.d.ts +94 -0
- package/dist/registry.js +99 -0
- package/dist/signature.d.ts +44 -0
- package/dist/signature.js +79 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.js +13 -0
- package/dist/validator.d.ts +60 -0
- package/dist/validator.js +171 -0
- package/package.json +48 -0
package/dist/fanout.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/inbox.d.ts
ADDED
|
@@ -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>;
|