@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
+ "use strict";
2
+ /**
3
+ * Postgres-queue transport for the dispatcher SDK.
4
+ *
5
+ * Implements the publish-side path of the bus dispatcher per ADR-0009
6
+ * §"Decision". The producer-side transactional guarantee is the
7
+ * load-bearing primitive: producers call `dispatcher.publish` inside a
8
+ * Prisma transaction, the SDK inserts the event row in the SAME
9
+ * transaction (via the `tx` argument), and either both the domain write
10
+ * and the event emit commit or both roll back. No outbox pattern, no
11
+ * staging table, no second state machine to debug; this is the property
12
+ * that breaks the tie among the four bus options ADR-0009 considered.
13
+ *
14
+ * Phase 2 Slice 2 (this file): publish path. Slice 3 lands the consumer
15
+ * polling worker plus LISTEN/NOTIFY listener; Slice 4 wires this into
16
+ * `dispatcher.ts`'s public surface (replacing the
17
+ * DispatcherNotImplementedError stubs); Slice 5 ships the per-consumer
18
+ * DLQ read API.
19
+ *
20
+ * Usage shape producers will adopt at their state-transition call sites:
21
+ *
22
+ * await prisma.$transaction(async (tx) => {
23
+ * // ... domain write that produces the event ...
24
+ * await tx.creditReservation.update({ where: { id }, data: { state: "locked" } });
25
+ *
26
+ * // ... emit the event in the SAME transaction ...
27
+ * await dispatcher.publish({
28
+ * event_type: "credit.locked",
29
+ * payload: { credit_reservation_id: id, ... },
30
+ * subject: personId,
31
+ * actor: "system:revenue",
32
+ * }, { tx });
33
+ * });
34
+ *
35
+ * Without the `tx` option, publish runs in its own transaction off the
36
+ * default Prisma client. That's the right shape for emit paths that have
37
+ * no domain write to coordinate with (e.g., a periodic job that emits a
38
+ * status event); it preserves the at-most-once-insert semantics but
39
+ * without coupling to a domain write.
40
+ */
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.EVENT_ID_PREFIX = void 0;
43
+ exports.publishToPostgres = publishToPostgres;
44
+ const id_1 = require("./internal/id");
45
+ const config_1 = require("./config");
46
+ const dispatcher_errors_1 = require("./dispatcher-errors");
47
+ const observability_1 = require("./observability");
48
+ const registry_1 = require("./registry");
49
+ const validator_1 = require("./validator");
50
+ /**
51
+ * ID prefix for envelope event_id values per envelope contract §4.1
52
+ * and ADR-0002. `evt_<UUID v7 canonical>` is the canonical shape.
53
+ */
54
+ exports.EVENT_ID_PREFIX = "evt_";
55
+ /**
56
+ * Publish an event to the Postgres-queue transport. Returns the canonical
57
+ * event_id so the producer can correlate retries per envelope contract
58
+ * §10.6 (producers SHALL reuse the same event_id on retry so consumer
59
+ * dedup works).
60
+ *
61
+ * Validation order: registry lookup → schema_version resolution →
62
+ * envelope construction → envelope JSON Schema validation → payload
63
+ * validation → INSERT. A failure at any step throws before the row
64
+ * lands, so the dispatcher_event table never carries unschema'd or
65
+ * unregistered events.
66
+ */
67
+ async function publishToPostgres(emit, options = {}) {
68
+ // 1. Registry lookup.
69
+ const registration = (0, registry_1.getEventTypeRegistration)(emit.event_type);
70
+ if (!registration) {
71
+ throw new dispatcher_errors_1.UnregisteredEventTypeError(emit.event_type);
72
+ }
73
+ // 2. Schema_version resolution. Caller-supplied wins; fall back to
74
+ // the active version registered for this event_type.
75
+ const targetVersion = emit.schema_version ?? (0, registry_1.getActiveSchemaVersion)(emit.event_type)?.version ?? null;
76
+ if (targetVersion === null) {
77
+ throw new dispatcher_errors_1.UnregisteredSchemaVersionError(emit.event_type, null);
78
+ }
79
+ const versionEntry = registration.schema_versions.find((v) => v.version === targetVersion);
80
+ if (!versionEntry) {
81
+ throw new dispatcher_errors_1.UnregisteredSchemaVersionError(emit.event_type, targetVersion);
82
+ }
83
+ // 3. Envelope construction. Auto-populate the SDK-controlled fields
84
+ // per envelope contract §10.2; producer supplies the rest.
85
+ const config = (0, config_1.getDispatcherConfig)();
86
+ const envelope = {
87
+ event_id: `${exports.EVENT_ID_PREFIX}${(0, id_1.uuidv7)()}`,
88
+ event_type: emit.event_type,
89
+ occurred_at: new Date().toISOString(),
90
+ tenant_id: config.tenantId,
91
+ producer: config.producer,
92
+ schema_version: targetVersion,
93
+ payload: emit.payload,
94
+ ...(emit.subject ? { subject: emit.subject } : {}),
95
+ ...(emit.actor ? { actor: emit.actor } : {}),
96
+ ...(emit.correlation_id ? { correlation_id: emit.correlation_id } : {}),
97
+ };
98
+ // 4. Envelope JSON Schema validation. Catches shape errors at the
99
+ // SDK boundary, before the row hits the table.
100
+ (0, validator_1.validateEnvelope)(envelope);
101
+ // 5. Payload JSON Schema validation. Per ADR-0009 action item 8:
102
+ // every emit validates against the registered schema for the
103
+ // (event_type, schema_version) tuple. Skip if the registry has no
104
+ // payload_schema recorded; the registry-hygiene story per the
105
+ // Phase 0 wrap is that authorship belongs to the owning domain
106
+ // and pending entries are not yet validate-blocking.
107
+ if (versionEntry.payload_schema !== null) {
108
+ (0, validator_1.validatePayload)(emit.event_type, targetVersion, emit.payload);
109
+ }
110
+ // 6. INSERT into dispatcher_event using the caller's tx if supplied,
111
+ // otherwise the Prisma client injected at startup via
112
+ // configureDispatcher({ prismaClient }). The same-transaction insert
113
+ // is the producer-transactional-guarantee primitive ADR-0009 chose
114
+ // Postgres for. The SDK does not import any domain's generated client
115
+ // directly; the consuming domain injects its own (@prisma/client is a
116
+ // peer dependency).
117
+ const db = options.tx ?? config.prismaClient;
118
+ if (!db) {
119
+ throw new Error("dispatcher.publish: no Prisma client available for the insert. Either pass " +
120
+ "{ tx } to publish() with an interactive-transaction client, or call " +
121
+ "configureDispatcher({ producer, tenantId, prismaClient }) at process startup " +
122
+ "so the publish path has a default client.");
123
+ }
124
+ await db.dispatcherEvent.create({
125
+ data: {
126
+ id: envelope.event_id,
127
+ eventType: envelope.event_type,
128
+ schemaVersion: envelope.schema_version,
129
+ tenantId: envelope.tenant_id,
130
+ producer: envelope.producer,
131
+ subject: envelope.subject ?? null,
132
+ actor: envelope.actor ?? "",
133
+ occurredAt: new Date(envelope.occurred_at),
134
+ payload: envelope.payload,
135
+ ...(envelope.correlation_id ? { correlationId: envelope.correlation_id } : {}),
136
+ },
137
+ });
138
+ (0, observability_1.recordDispatcherIncrement)("dispatcher.publish.count", {
139
+ event_type: envelope.event_type,
140
+ producer: envelope.producer,
141
+ schema_version: String(envelope.schema_version),
142
+ });
143
+ return { event_id: envelope.event_id };
144
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Producer-DB resolution for the fanout worker.
3
+ *
4
+ * Per ADR-0009 §"Per-domain table family," each producer-domain owns its
5
+ * own `dispatcher_event` / `dispatcher_cursor` / `dispatcher_dedup` /
6
+ * `dispatcher_dead_letter` tables in the producer's database. Cross-DB
7
+ * transactions are not supported, so the producer-side write to
8
+ * `dispatcher_event` happens in the producer's DB and any reader of that
9
+ * stream needs a connection there.
10
+ *
11
+ * Topology B (per 2026-05-12-platform-cross-db-consumer-dsn-upstream):
12
+ * Platform runs one fanout worker that polls every producer's
13
+ * `dispatcher_event` and POSTs envelopes to the registered consumer
14
+ * webhook inboxes. Centralizing here means the cross-DB read happens in
15
+ * exactly one place (the worker), not in N consumer processes. This file
16
+ * resolves "producer name" -> "pg.Pool pointed at that producer's DB"
17
+ * for the worker.
18
+ *
19
+ * Why pg.Pool instead of PrismaClient: Platform's Prisma client is
20
+ * generated against Platform's schema.prisma, which has no
21
+ * `multiSchema` or `@@schema` declarations and so always qualifies
22
+ * dispatcher_X queries with `public.`. Producer DBs (Growth, Sales,
23
+ * etc.) put their dispatcher tables in domain-specific schemas (Sales'
24
+ * `sales` schema, Growth's `growth` schema via `?schema=` URL param).
25
+ * Platform's `public.`-qualified queries fail there. Raw pg.Pool +
26
+ * unqualified SQL + the connection's search_path (derived from the DSN's
27
+ * `?schema=<domain>` query param by pgConfigWithSearchPath) resolves the
28
+ * schema correctly per producer with zero code per producer.
29
+ *
30
+ * Env var convention:
31
+ *
32
+ * - For the worker's own producer (`platform`): DATABASE_URL.
33
+ * - For every other producer: `DISPATCHER_PRODUCER_DATABASE_URL_<PRODUCER>`
34
+ * e.g., DISPATCHER_PRODUCER_DATABASE_URL_GROWTH points at Growth's
35
+ * `sguild-domains` Supabase project pooler URL with
36
+ * `?schema=growth` so unqualified `dispatcher_cursor` queries
37
+ * resolve to `growth.dispatcher_cursor`.
38
+ *
39
+ * A missing DSN for a producer logs a warning and excludes that
40
+ * producer from the fanout boot loop (sibling pattern to a missing
41
+ * consumer URL excluding that consumer-domain). Operators see the gap in
42
+ * the boot log and provision the env var; the cursor for any
43
+ * (consumer, event_type) reading from that producer stays put until the
44
+ * env arrives.
45
+ *
46
+ * Lifecycle: pools are cached by producer name for process lifetime.
47
+ * fanout-worker.ts's SIGTERM handler closes the ConsumerLoops which
48
+ * close pools they own; pools handed out here are NOT owned by the
49
+ * loops (multiple loops can share a pool against the same producer DB).
50
+ * The cached pools rely on Node process exit to drain.
51
+ */
52
+ import { Pool } from "pg";
53
+ /**
54
+ * Resolve a producer name to its DSN. Returns undefined if the env var
55
+ * is not set; in that case the fanout boot loop logs and skips this
56
+ * producer.
57
+ */
58
+ export declare function resolveProducerDsn(producer: string): string | undefined;
59
+ /**
60
+ * Resolve a producer name to a pg.Pool + databaseUrl pointed at that
61
+ * producer's DB. Returns undefined if the DSN is missing (the caller
62
+ * logs and skips).
63
+ *
64
+ * Pools are cached by producer name so multiple loops reading the same
65
+ * producer share a single pool. Pool size defaults to pg's default
66
+ * (10 connections); for the dispatcher fanout that's well within
67
+ * typical Supabase pooler limits.
68
+ */
69
+ export declare function getProducerDb(producer: string): {
70
+ pool: Pool;
71
+ databaseUrl: string;
72
+ schema: string | null;
73
+ } | undefined;
74
+ /**
75
+ * Test-only seam: clear the cache so each test boots the resolver from
76
+ * scratch. Production code MUST NOT call this; it would leak the
77
+ * existing pool connections (the old pools stay open and the new ones
78
+ * start from zero).
79
+ */
80
+ export declare function __resetProducerDbCacheForTests(): void;
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ /**
3
+ * Producer-DB resolution for the fanout worker.
4
+ *
5
+ * Per ADR-0009 §"Per-domain table family," each producer-domain owns its
6
+ * own `dispatcher_event` / `dispatcher_cursor` / `dispatcher_dedup` /
7
+ * `dispatcher_dead_letter` tables in the producer's database. Cross-DB
8
+ * transactions are not supported, so the producer-side write to
9
+ * `dispatcher_event` happens in the producer's DB and any reader of that
10
+ * stream needs a connection there.
11
+ *
12
+ * Topology B (per 2026-05-12-platform-cross-db-consumer-dsn-upstream):
13
+ * Platform runs one fanout worker that polls every producer's
14
+ * `dispatcher_event` and POSTs envelopes to the registered consumer
15
+ * webhook inboxes. Centralizing here means the cross-DB read happens in
16
+ * exactly one place (the worker), not in N consumer processes. This file
17
+ * resolves "producer name" -> "pg.Pool pointed at that producer's DB"
18
+ * for the worker.
19
+ *
20
+ * Why pg.Pool instead of PrismaClient: Platform's Prisma client is
21
+ * generated against Platform's schema.prisma, which has no
22
+ * `multiSchema` or `@@schema` declarations and so always qualifies
23
+ * dispatcher_X queries with `public.`. Producer DBs (Growth, Sales,
24
+ * etc.) put their dispatcher tables in domain-specific schemas (Sales'
25
+ * `sales` schema, Growth's `growth` schema via `?schema=` URL param).
26
+ * Platform's `public.`-qualified queries fail there. Raw pg.Pool +
27
+ * unqualified SQL + the connection's search_path (derived from the DSN's
28
+ * `?schema=<domain>` query param by pgConfigWithSearchPath) resolves the
29
+ * schema correctly per producer with zero code per producer.
30
+ *
31
+ * Env var convention:
32
+ *
33
+ * - For the worker's own producer (`platform`): DATABASE_URL.
34
+ * - For every other producer: `DISPATCHER_PRODUCER_DATABASE_URL_<PRODUCER>`
35
+ * e.g., DISPATCHER_PRODUCER_DATABASE_URL_GROWTH points at Growth's
36
+ * `sguild-domains` Supabase project pooler URL with
37
+ * `?schema=growth` so unqualified `dispatcher_cursor` queries
38
+ * resolve to `growth.dispatcher_cursor`.
39
+ *
40
+ * A missing DSN for a producer logs a warning and excludes that
41
+ * producer from the fanout boot loop (sibling pattern to a missing
42
+ * consumer URL excluding that consumer-domain). Operators see the gap in
43
+ * the boot log and provision the env var; the cursor for any
44
+ * (consumer, event_type) reading from that producer stays put until the
45
+ * env arrives.
46
+ *
47
+ * Lifecycle: pools are cached by producer name for process lifetime.
48
+ * fanout-worker.ts's SIGTERM handler closes the ConsumerLoops which
49
+ * close pools they own; pools handed out here are NOT owned by the
50
+ * loops (multiple loops can share a pool against the same producer DB).
51
+ * The cached pools rely on Node process exit to drain.
52
+ */
53
+ Object.defineProperty(exports, "__esModule", { value: true });
54
+ exports.resolveProducerDsn = resolveProducerDsn;
55
+ exports.getProducerDb = getProducerDb;
56
+ exports.__resetProducerDbCacheForTests = __resetProducerDbCacheForTests;
57
+ const pg_1 = require("pg");
58
+ const pg_search_path_1 = require("./internal/pg-search-path");
59
+ /**
60
+ * Cache of producer name -> { pool, databaseUrl, schema }. Keyed by producer
61
+ * name for fast lookup at boot. The databaseUrl is also kept so the
62
+ * ConsumerLoop can wire its LISTEN/NOTIFY pg.Client against the same
63
+ * producer's DSN. The schema is the parsed `?schema=` query param value
64
+ * (null if absent or `public`); ConsumerLoop schema-qualifies its SQL
65
+ * with this value so queries work even when pgbouncer's transaction-mode
66
+ * pooler drops the libpq `options` startup parameter.
67
+ */
68
+ const cache = new Map();
69
+ /**
70
+ * Resolve a producer name to its DSN. Returns undefined if the env var
71
+ * is not set; in that case the fanout boot loop logs and skips this
72
+ * producer.
73
+ */
74
+ function resolveProducerDsn(producer) {
75
+ if (producer === "platform") {
76
+ return process.env.DATABASE_URL;
77
+ }
78
+ const envName = `DISPATCHER_PRODUCER_DATABASE_URL_${producer.toUpperCase()}`;
79
+ return process.env[envName];
80
+ }
81
+ /**
82
+ * Resolve a producer name to a pg.Pool + databaseUrl pointed at that
83
+ * producer's DB. Returns undefined if the DSN is missing (the caller
84
+ * logs and skips).
85
+ *
86
+ * Pools are cached by producer name so multiple loops reading the same
87
+ * producer share a single pool. Pool size defaults to pg's default
88
+ * (10 connections); for the dispatcher fanout that's well within
89
+ * typical Supabase pooler limits.
90
+ */
91
+ function getProducerDb(producer) {
92
+ const cached = cache.get(producer);
93
+ if (cached)
94
+ return cached;
95
+ const dsn = resolveProducerDsn(producer);
96
+ if (!dsn)
97
+ return undefined;
98
+ const config = (0, pg_search_path_1.pgConfigWithSearchPath)(dsn);
99
+ const pool = new pg_1.Pool(config);
100
+ pool.on("error", (error) => {
101
+ console.warn(`[dispatcher.producer-db] ${producer} pool error: ${error.message}`);
102
+ });
103
+ const entry = { pool, databaseUrl: dsn, schema: config.schema };
104
+ cache.set(producer, entry);
105
+ return entry;
106
+ }
107
+ /**
108
+ * Test-only seam: clear the cache so each test boots the resolver from
109
+ * scratch. Production code MUST NOT call this; it would leak the
110
+ * existing pool connections (the old pools stay open and the new ones
111
+ * start from zero).
112
+ */
113
+ function __resetProducerDbCacheForTests() {
114
+ cache.clear();
115
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Event-type registry loader and query.
3
+ *
4
+ * Reads the canonical registry manifest at
5
+ * coordination/contracts/event-types-registry.json (path resolved relative to
6
+ * the platform repo's working directory; the registry is checked into the
7
+ * coordination repo and the platform repo references it via its known
8
+ * filesystem location during local development; CI gets a copy via the same
9
+ * mechanism).
10
+ *
11
+ * The registry is the single source of truth for which (event_type,
12
+ * schema_version) pairs are valid. The dispatcher reads it at startup to drive
13
+ * validation; producers register new event_types here before first use per
14
+ * §10.4 of the envelope contract.
15
+ */
16
+ import type { ProducerDomain } from "./types";
17
+ export type SchemaVersionStatus = "active" | "deprecated" | "sunset";
18
+ export type SchemaVersionEntry = {
19
+ version: number;
20
+ /** Path to the JSON Schema for this payload version, relative to the coordination repo root. May be null if the schema has not yet been authored. */
21
+ payload_schema: string | null;
22
+ status: SchemaVersionStatus;
23
+ /** YYYY-MM-DD when this version was registered. */
24
+ registered_at: string;
25
+ notes?: string;
26
+ };
27
+ export type EventTypeRegistration = {
28
+ event_type: string;
29
+ producer: ProducerDomain;
30
+ /**
31
+ * The closed-set domains that subscribe, plus the synthetic "platform-warehouse"
32
+ * value for the warehouse loader. Today this is informational; once CI
33
+ * validation lands as a Phase 0 deliverable, it gates emit-side smoke tests
34
+ * against unregistered consumers.
35
+ */
36
+ consumers: Array<ProducerDomain | "platform-warehouse">;
37
+ /** Path to the contract that owns the producer surface. May be null for events whose owning contract has not been written yet. */
38
+ owning_contract: string | null;
39
+ schema_versions: SchemaVersionEntry[];
40
+ };
41
+ export type RegistryManifest = {
42
+ registry_version: string;
43
+ registry_date: string;
44
+ registry_owner: string;
45
+ registry_notes: string;
46
+ events: Record<string, Omit<EventTypeRegistration, "event_type">>;
47
+ };
48
+ /**
49
+ * Location of the registry manifest inside the bundled contracts tree,
50
+ * rooted at `contracts/` exactly as the coordination repo stores it. The
51
+ * `@sguild/dispatcher` package ships this file bundled, so the default
52
+ * needs no sibling coordination checkout. `resolveContractPath` knows the
53
+ * module-relative and cwd-relative layouts this can live at (including the
54
+ * sibling coordination repo) and tries them in order.
55
+ */
56
+ export declare const DEFAULT_REGISTRY_PATH = "contracts/event-types-registry.json";
57
+ /**
58
+ * Load and cache the registry manifest from the filesystem. Called once per
59
+ * process at startup; subsequent calls return the cached value.
60
+ *
61
+ * Resolution order: an explicit `path` argument (resolved against the
62
+ * process cwd; absolute paths pass through unchanged, which is how the test
63
+ * fixtures load) wins; otherwise the bundled copy shipped inside the
64
+ * package; otherwise the cwd-relative sibling coordination repo. See
65
+ * `resolveContractPath` for the full waterfall.
66
+ *
67
+ * The registry is read from disk synchronously at first use. A future
68
+ * version may switch to an async loader if the registry grows large or
69
+ * moves to a network-fetched location.
70
+ */
71
+ export declare function loadRegistry(path?: string): RegistryManifest;
72
+ /**
73
+ * Look up an event_type's registration. Returns null if the event_type is
74
+ * not registered; producers calling `dispatcher.publish` against an
75
+ * unregistered event_type will see a clear error rather than a successful
76
+ * emit that consumers cannot decode.
77
+ */
78
+ export declare function getEventTypeRegistration(event_type: string, manifest?: RegistryManifest): EventTypeRegistration | null;
79
+ /**
80
+ * Look up the active schema_version for an event_type. Returns null if the
81
+ * event_type is not registered or has no active version (every version is
82
+ * deprecated or sunset, which is itself a registry hygiene problem).
83
+ *
84
+ * Producers emitting without an explicit `schema_version` get the active
85
+ * version returned here. During a transition window where multiple versions
86
+ * are simultaneously active, producers MUST supply the version explicitly.
87
+ */
88
+ export declare function getActiveSchemaVersion(event_type: string, manifest?: RegistryManifest): SchemaVersionEntry | null;
89
+ /**
90
+ * Reset the cached manifest. Test-only; production code does not call this.
91
+ * Exported so test files can reload the registry between fixtures without
92
+ * spinning up a fresh process.
93
+ */
94
+ export declare function __resetRegistryCacheForTests(): void;
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ /**
3
+ * Event-type registry loader and query.
4
+ *
5
+ * Reads the canonical registry manifest at
6
+ * coordination/contracts/event-types-registry.json (path resolved relative to
7
+ * the platform repo's working directory; the registry is checked into the
8
+ * coordination repo and the platform repo references it via its known
9
+ * filesystem location during local development; CI gets a copy via the same
10
+ * mechanism).
11
+ *
12
+ * The registry is the single source of truth for which (event_type,
13
+ * schema_version) pairs are valid. The dispatcher reads it at startup to drive
14
+ * validation; producers register new event_types here before first use per
15
+ * §10.4 of the envelope contract.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.DEFAULT_REGISTRY_PATH = void 0;
19
+ exports.loadRegistry = loadRegistry;
20
+ exports.getEventTypeRegistration = getEventTypeRegistration;
21
+ exports.getActiveSchemaVersion = getActiveSchemaVersion;
22
+ exports.__resetRegistryCacheForTests = __resetRegistryCacheForTests;
23
+ const node_fs_1 = require("node:fs");
24
+ const resolve_contract_path_1 = require("./internal/resolve-contract-path");
25
+ /**
26
+ * Location of the registry manifest inside the bundled contracts tree,
27
+ * rooted at `contracts/` exactly as the coordination repo stores it. The
28
+ * `@sguild/dispatcher` package ships this file bundled, so the default
29
+ * needs no sibling coordination checkout. `resolveContractPath` knows the
30
+ * module-relative and cwd-relative layouts this can live at (including the
31
+ * sibling coordination repo) and tries them in order.
32
+ */
33
+ exports.DEFAULT_REGISTRY_PATH = "contracts/event-types-registry.json";
34
+ let cachedManifest = null;
35
+ /**
36
+ * Load and cache the registry manifest from the filesystem. Called once per
37
+ * process at startup; subsequent calls return the cached value.
38
+ *
39
+ * Resolution order: an explicit `path` argument (resolved against the
40
+ * process cwd; absolute paths pass through unchanged, which is how the test
41
+ * fixtures load) wins; otherwise the bundled copy shipped inside the
42
+ * package; otherwise the cwd-relative sibling coordination repo. See
43
+ * `resolveContractPath` for the full waterfall.
44
+ *
45
+ * The registry is read from disk synchronously at first use. A future
46
+ * version may switch to an async loader if the registry grows large or
47
+ * moves to a network-fetched location.
48
+ */
49
+ function loadRegistry(path) {
50
+ if (cachedManifest)
51
+ return cachedManifest;
52
+ const resolvedPath = (0, resolve_contract_path_1.resolveContractPath)(__dirname, exports.DEFAULT_REGISTRY_PATH, {
53
+ priorityPaths: path ? [path] : [],
54
+ });
55
+ const raw = (0, node_fs_1.readFileSync)(resolvedPath, "utf8");
56
+ cachedManifest = JSON.parse(raw);
57
+ return cachedManifest;
58
+ }
59
+ /**
60
+ * Look up an event_type's registration. Returns null if the event_type is
61
+ * not registered; producers calling `dispatcher.publish` against an
62
+ * unregistered event_type will see a clear error rather than a successful
63
+ * emit that consumers cannot decode.
64
+ */
65
+ function getEventTypeRegistration(event_type, manifest = loadRegistry()) {
66
+ const entry = manifest.events[event_type];
67
+ if (!entry)
68
+ return null;
69
+ return { event_type, ...entry };
70
+ }
71
+ /**
72
+ * Look up the active schema_version for an event_type. Returns null if the
73
+ * event_type is not registered or has no active version (every version is
74
+ * deprecated or sunset, which is itself a registry hygiene problem).
75
+ *
76
+ * Producers emitting without an explicit `schema_version` get the active
77
+ * version returned here. During a transition window where multiple versions
78
+ * are simultaneously active, producers MUST supply the version explicitly.
79
+ */
80
+ function getActiveSchemaVersion(event_type, manifest = loadRegistry()) {
81
+ const reg = getEventTypeRegistration(event_type, manifest);
82
+ if (!reg)
83
+ return null;
84
+ const active = reg.schema_versions.filter((v) => v.status === "active");
85
+ if (active.length === 0)
86
+ return null;
87
+ if (active.length > 1) {
88
+ throw new Error(`Multiple active schema_versions for event_type '${event_type}'; producers must supply schema_version explicitly during the transition window.`);
89
+ }
90
+ return active[0];
91
+ }
92
+ /**
93
+ * Reset the cached manifest. Test-only; production code does not call this.
94
+ * Exported so test files can reload the registry between fixtures without
95
+ * spinning up a fresh process.
96
+ */
97
+ function __resetRegistryCacheForTests() {
98
+ cachedManifest = null;
99
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Sign/verify primitives shared between the Platform-side fanout worker
3
+ * (producer of signatures) and domain-side inbox handlers (verifiers).
4
+ *
5
+ * Extracted out of fanout.ts so domains can vendor a minimal three-file
6
+ * surface (signature.ts + inbox.ts + types.ts) without pulling in the
7
+ * polling-consumer machinery, registry loader, transport implementation,
8
+ * Prisma client wiring, or any of the rest of the SDK. This is the
9
+ * canonical low-dependency module domains depend on for HMAC.
10
+ *
11
+ * No imports from prisma, the registry, the dispatcher singleton, or
12
+ * any other dispatcher file. Only node:crypto. Keep it that way.
13
+ *
14
+ * Contract: Platform's fanout signs the JSON-serialized envelope body
15
+ * with the per-consumer HMAC secret and sends `X-Sguild-Signature:
16
+ * sha256=<hex>`. The receiving inbox recomputes the same digest from
17
+ * the raw request body and constant-time compares.
18
+ */
19
+ /** Header carrying the HMAC signature. Sha256 hex digest, prefixed by algorithm. */
20
+ export declare const SIGNATURE_HEADER = "x-sguild-signature";
21
+ /** Header carrying the event_id for log correlation on the consumer side. */
22
+ export declare const EVENT_ID_HEADER = "x-sguild-event-id";
23
+ /** Header carrying the event_type for log correlation. */
24
+ export declare const EVENT_TYPE_HEADER = "x-sguild-event-type";
25
+ /**
26
+ * Compute the HMAC-SHA256 signature for an envelope body. Output is a
27
+ * hex string; the header value is `sha256=<output>`. Body is the exact
28
+ * JSON-serialized envelope the consumer will receive; sender and
29
+ * receiver MUST serialize identically (no whitespace differences,
30
+ * key-ordering differences, etc.) or the signature will mismatch. The
31
+ * recommended pattern: sender produces `JSON.stringify(envelope)` and
32
+ * the receiver verifies against the raw request body before parsing.
33
+ */
34
+ export declare function signEnvelope(body: string, secret: string): string;
35
+ /**
36
+ * Verify a signature against a body. Constant-time comparison to
37
+ * prevent timing attacks. Accepts the signature with or without the
38
+ * `sha256=` prefix; consumers should pass the header verbatim and this
39
+ * function normalizes.
40
+ *
41
+ * Returns true on match, false on mismatch or any parse/length error.
42
+ * Never throws.
43
+ */
44
+ export declare function verifyEnvelopeSignature(body: string, secret: string, signatureHeaderValue: string): boolean;
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ /**
3
+ * Sign/verify primitives shared between the Platform-side fanout worker
4
+ * (producer of signatures) and domain-side inbox handlers (verifiers).
5
+ *
6
+ * Extracted out of fanout.ts so domains can vendor a minimal three-file
7
+ * surface (signature.ts + inbox.ts + types.ts) without pulling in the
8
+ * polling-consumer machinery, registry loader, transport implementation,
9
+ * Prisma client wiring, or any of the rest of the SDK. This is the
10
+ * canonical low-dependency module domains depend on for HMAC.
11
+ *
12
+ * No imports from prisma, the registry, the dispatcher singleton, or
13
+ * any other dispatcher file. Only node:crypto. Keep it that way.
14
+ *
15
+ * Contract: Platform's fanout signs the JSON-serialized envelope body
16
+ * with the per-consumer HMAC secret and sends `X-Sguild-Signature:
17
+ * sha256=<hex>`. The receiving inbox recomputes the same digest from
18
+ * the raw request body and constant-time compares.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.EVENT_TYPE_HEADER = exports.EVENT_ID_HEADER = exports.SIGNATURE_HEADER = void 0;
22
+ exports.signEnvelope = signEnvelope;
23
+ exports.verifyEnvelopeSignature = verifyEnvelopeSignature;
24
+ const node_crypto_1 = require("node:crypto");
25
+ /** Header carrying the HMAC signature. Sha256 hex digest, prefixed by algorithm. */
26
+ exports.SIGNATURE_HEADER = "x-sguild-signature";
27
+ /** Header carrying the event_id for log correlation on the consumer side. */
28
+ exports.EVENT_ID_HEADER = "x-sguild-event-id";
29
+ /** Header carrying the event_type for log correlation. */
30
+ exports.EVENT_TYPE_HEADER = "x-sguild-event-type";
31
+ /**
32
+ * Compute the HMAC-SHA256 signature for an envelope body. Output is a
33
+ * hex string; the header value is `sha256=<output>`. Body is the exact
34
+ * JSON-serialized envelope the consumer will receive; sender and
35
+ * receiver MUST serialize identically (no whitespace differences,
36
+ * key-ordering differences, etc.) or the signature will mismatch. The
37
+ * recommended pattern: sender produces `JSON.stringify(envelope)` and
38
+ * the receiver verifies against the raw request body before parsing.
39
+ */
40
+ function signEnvelope(body, secret) {
41
+ return (0, node_crypto_1.createHmac)("sha256", secret).update(body, "utf8").digest("hex");
42
+ }
43
+ function hexToBytes(hex) {
44
+ if (hex.length % 2 !== 0) {
45
+ throw new Error("invalid hex length");
46
+ }
47
+ const bytes = new Uint8Array(hex.length / 2);
48
+ for (let i = 0; i < bytes.length; i += 1) {
49
+ const byte = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
50
+ if (Number.isNaN(byte)) {
51
+ throw new Error("invalid hex byte");
52
+ }
53
+ bytes[i] = byte;
54
+ }
55
+ return bytes;
56
+ }
57
+ /**
58
+ * Verify a signature against a body. Constant-time comparison to
59
+ * prevent timing attacks. Accepts the signature with or without the
60
+ * `sha256=` prefix; consumers should pass the header verbatim and this
61
+ * function normalizes.
62
+ *
63
+ * Returns true on match, false on mismatch or any parse/length error.
64
+ * Never throws.
65
+ */
66
+ function verifyEnvelopeSignature(body, secret, signatureHeaderValue) {
67
+ const provided = signatureHeaderValue.startsWith("sha256=")
68
+ ? signatureHeaderValue.slice("sha256=".length)
69
+ : signatureHeaderValue;
70
+ const expected = signEnvelope(body, secret);
71
+ if (provided.length !== expected.length)
72
+ return false;
73
+ try {
74
+ return (0, node_crypto_1.timingSafeEqual)(hexToBytes(provided), hexToBytes(expected));
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }