@voyant-travel/webhook-delivery 0.1.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 ADDED
@@ -0,0 +1,10 @@
1
+ # @voyant-travel/webhook-delivery
2
+
3
+ Node-host outbound webhook delivery behind the Framework's selected-event
4
+ enqueue boundary. The package owns subscription fan-out, visibility policy,
5
+ HMAC signing, persisted attempts, idempotency, bounded retries, terminal
6
+ dead-letter state, and delivery audit outcomes.
7
+
8
+ The audit tables intentionally contain only redacted, bounded excerpts. The
9
+ original event payload remains at the host enqueue boundary and is never used
10
+ as an audit-storage surrogate.
@@ -0,0 +1,14 @@
1
+ import type { EventEnvelope } from "@voyant-travel/core";
2
+ export interface ExternalWebhookEventContract {
3
+ eventId: string;
4
+ eventType: string;
5
+ eventVersion: string;
6
+ payloadSchema: Readonly<Record<string, unknown>>;
7
+ }
8
+ export interface WebhookSubscriptionEventInput {
9
+ events?: readonly string[];
10
+ }
11
+ export declare function assertWebhookSubscriptionCreateEvents(input: WebhookSubscriptionEventInput, contracts: readonly ExternalWebhookEventContract[]): void;
12
+ export declare function assertWebhookSubscriptionUpdateEvents(input: WebhookSubscriptionEventInput, contracts: readonly ExternalWebhookEventContract[]): void;
13
+ export declare function prepareExternalWebhookEvent(event: EventEnvelope, contract: ExternalWebhookEventContract): EventEnvelope;
14
+ //# sourceMappingURL=contracts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../src/contracts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAKxD,MAAM,WAAW,4BAA4B;IAC3C,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,MAAM,CAAA;IACpB,aAAa,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;CACjD;AAED,MAAM,WAAW,6BAA6B;IAC5C,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC3B;AAED,wBAAgB,qCAAqC,CACnD,KAAK,EAAE,6BAA6B,EACpC,SAAS,EAAE,SAAS,4BAA4B,EAAE,GACjD,IAAI,CAEN;AAED,wBAAgB,qCAAqC,CACnD,KAAK,EAAE,6BAA6B,EACpC,SAAS,EAAE,SAAS,4BAA4B,EAAE,GACjD,IAAI,CAGN;AAED,wBAAgB,2BAA2B,CACzC,KAAK,EAAE,aAAa,EACpB,QAAQ,EAAE,4BAA4B,GACrC,aAAa,CAsBf"}
@@ -0,0 +1,112 @@
1
+ import { isExternalWebhookPayloadSchema } from "@voyant-travel/core/project";
2
+ const REDACTION_MARKER = "[REDACTED]";
3
+ export function assertWebhookSubscriptionCreateEvents(input, contracts) {
4
+ assertSelectedExternalEvents(input.events, contracts, "create");
5
+ }
6
+ export function assertWebhookSubscriptionUpdateEvents(input, contracts) {
7
+ if (input.events === undefined)
8
+ return;
9
+ assertSelectedExternalEvents(input.events, contracts, "update");
10
+ }
11
+ export function prepareExternalWebhookEvent(event, contract) {
12
+ if (!isExternalWebhookPayloadSchema(contract.payloadSchema)) {
13
+ throw new Error(`External webhook contract "${contract.eventId}" requires an object payload schema with explicit properties.`);
14
+ }
15
+ if (event.name !== contract.eventType) {
16
+ throw new Error(`External webhook contract "${contract.eventId}" governs "${contract.eventType}", not "${event.name}".`);
17
+ }
18
+ const metadata = event.metadata ?? {};
19
+ assertMetadata(metadata, "graphEventId", contract.eventId);
20
+ assertMetadata(metadata, "graphEventVersion", contract.eventVersion);
21
+ return {
22
+ name: event.name,
23
+ data: projectValue(event.data, contract.payloadSchema, "$.data"),
24
+ emittedAt: event.emittedAt,
25
+ metadata: externalMetadata(metadata, contract),
26
+ };
27
+ }
28
+ function assertSelectedExternalEvents(events, contracts, operation) {
29
+ if (!events || events.length === 0) {
30
+ throw new Error(`Webhook subscription ${operation} requires at least one event.`);
31
+ }
32
+ const selected = new Set(contracts.map(({ eventType }) => eventType));
33
+ const unknown = [...new Set(events.filter((eventType) => !selected.has(eventType)))].sort();
34
+ if (unknown.length > 0) {
35
+ throw new Error(`Webhook subscription ${operation} requested events outside the selected external catalog: ${unknown.join(", ")}.`);
36
+ }
37
+ }
38
+ function projectValue(value, schema, path) {
39
+ if (schema.writeOnly === true || schema["x-voyant-redact"] === true)
40
+ return REDACTION_MARKER;
41
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
42
+ throw new Error(`External webhook payload ${path} is not an allowed enum value.`);
43
+ }
44
+ const type = schema.type;
45
+ if (type === "object")
46
+ return projectObject(value, schema, path);
47
+ if (type === "array")
48
+ return projectArray(value, schema, path);
49
+ if (type === "string" && typeof value !== "string")
50
+ return invalidType(path, "string");
51
+ if (type === "number" && (typeof value !== "number" || !Number.isFinite(value))) {
52
+ return invalidType(path, "number");
53
+ }
54
+ if (type === "integer" && (typeof value !== "number" || !Number.isInteger(value))) {
55
+ return invalidType(path, "integer");
56
+ }
57
+ if (type === "boolean" && typeof value !== "boolean")
58
+ return invalidType(path, "boolean");
59
+ if (type === "null" && value !== null)
60
+ return invalidType(path, "null");
61
+ return value;
62
+ }
63
+ function projectObject(value, schema, path) {
64
+ if (!isRecord(value))
65
+ return invalidType(path, "object");
66
+ const properties = isRecord(schema.properties) ? schema.properties : {};
67
+ const required = new Set(Array.isArray(schema.required)
68
+ ? schema.required.filter((entry) => typeof entry === "string")
69
+ : []);
70
+ const output = {};
71
+ for (const [key, propertySchema] of Object.entries(properties)) {
72
+ if (!isRecord(propertySchema))
73
+ continue;
74
+ if (!(key in value)) {
75
+ if (required.has(key))
76
+ throw new Error(`External webhook payload ${path}.${key} is required.`);
77
+ continue;
78
+ }
79
+ output[key] = projectValue(value[key], propertySchema, `${path}.${key}`);
80
+ }
81
+ return output;
82
+ }
83
+ function projectArray(value, schema, path) {
84
+ if (!Array.isArray(value))
85
+ return invalidType(path, "array");
86
+ if (!isRecord(schema.items))
87
+ return [];
88
+ return value.map((entry, index) => projectValue(entry, schema.items, `${path}[${index}]`));
89
+ }
90
+ function externalMetadata(metadata, contract) {
91
+ return {
92
+ ...(typeof metadata.eventId === "string" ? { eventId: metadata.eventId } : {}),
93
+ ...(typeof metadata.correlationId === "string"
94
+ ? { correlationId: metadata.correlationId }
95
+ : {}),
96
+ ...(typeof metadata.causationId === "string" ? { causationId: metadata.causationId } : {}),
97
+ graphEventId: contract.eventId,
98
+ graphEventVersion: contract.eventVersion,
99
+ };
100
+ }
101
+ function assertMetadata(metadata, key, expected) {
102
+ const actual = metadata[key];
103
+ if (actual !== undefined && actual !== expected) {
104
+ throw new Error(`External webhook metadata ${key} does not match selected contract "${expected}".`);
105
+ }
106
+ }
107
+ function invalidType(path, expected) {
108
+ throw new Error(`External webhook payload ${path} must be ${expected}.`);
109
+ }
110
+ function isRecord(value) {
111
+ return value !== null && typeof value === "object" && !Array.isArray(value);
112
+ }
@@ -0,0 +1,10 @@
1
+ export type { ExternalWebhookEventContract, WebhookSubscriptionEventInput, } from "./contracts.js";
2
+ export { assertWebhookSubscriptionCreateEvents, assertWebhookSubscriptionUpdateEvents, prepareExternalWebhookEvent, } from "./contracts.js";
3
+ export { hashWebhookPayload, redactWebhookHeaders, signWebhookPayload, webhookBodyExcerpt, } from "./security.js";
4
+ export type { CreateSelectedExternalWebhookQueueOptions } from "./selected-queue.js";
5
+ export { createSelectedExternalWebhookQueue, externalContractFromEventMetadata, } from "./selected-queue.js";
6
+ export type { WebhookSubscriptionMutationStore, WebhookSubscriptionService, } from "./subscriptions.js";
7
+ export { createWebhookSubscriptionService } from "./subscriptions.js";
8
+ export type { CompleteWebhookAttemptInput, CreateWebhookDeliveryWorkerOptions, EnqueuedWebhookAttempt, EnqueueWebhookAttemptInput, SelectedExternalWebhookQueue, WebhookDeliveryAuditEvent, WebhookDeliveryOutcome, WebhookDeliveryStore, WebhookDeliveryWorker, WebhookEnqueueOutcome, WebhookRetryOptions, WebhookSubscription, } from "./types.js";
9
+ export { createWebhookDeliveryWorker } from "./worker.js";
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,4BAA4B,EAC5B,6BAA6B,GAC9B,MAAM,gBAAgB,CAAA;AACvB,OAAO,EACL,qCAAqC,EACrC,qCAAqC,EACrC,2BAA2B,GAC5B,MAAM,gBAAgB,CAAA;AACvB,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,kBAAkB,EAClB,kBAAkB,GACnB,MAAM,eAAe,CAAA;AACtB,YAAY,EAAE,yCAAyC,EAAE,MAAM,qBAAqB,CAAA;AACpF,OAAO,EACL,kCAAkC,EAClC,iCAAiC,GAClC,MAAM,qBAAqB,CAAA;AAC5B,YAAY,EACV,gCAAgC,EAChC,0BAA0B,GAC3B,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,gCAAgC,EAAE,MAAM,oBAAoB,CAAA;AACrE,YAAY,EACV,2BAA2B,EAC3B,kCAAkC,EAClC,sBAAsB,EACtB,0BAA0B,EAC1B,4BAA4B,EAC5B,yBAAyB,EACzB,sBAAsB,EACtB,oBAAoB,EACpB,qBAAqB,EACrB,qBAAqB,EACrB,mBAAmB,EACnB,mBAAmB,GACpB,MAAM,YAAY,CAAA;AACnB,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { assertWebhookSubscriptionCreateEvents, assertWebhookSubscriptionUpdateEvents, prepareExternalWebhookEvent, } from "./contracts.js";
2
+ export { hashWebhookPayload, redactWebhookHeaders, signWebhookPayload, webhookBodyExcerpt, } from "./security.js";
3
+ export { createSelectedExternalWebhookQueue, externalContractFromEventMetadata, } from "./selected-queue.js";
4
+ export { createWebhookSubscriptionService } from "./subscriptions.js";
5
+ export { createWebhookDeliveryWorker } from "./worker.js";
@@ -0,0 +1,20 @@
1
+ import type { EventEnvelope } from "@voyant-travel/core";
2
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
3
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
+ import type { ExternalWebhookEventContract } from "./contracts.js";
5
+ import { type WebhookSubscriptionService } from "./subscriptions.js";
6
+ import type { SelectedExternalWebhookQueue, WebhookDeliveryStore, WebhookEnqueueOutcome } from "./types.js";
7
+ export interface EnqueuePostgresWebhookEventOptions {
8
+ queue?: SelectedExternalWebhookQueue;
9
+ }
10
+ /** Persist one graph-selected event through the neutral Postgres delivery queue. */
11
+ export declare function enqueuePostgresWebhookEvent(db: AnyDrizzleDb, event: EventEnvelope, options?: EnqueuePostgresWebhookEventOptions): Promise<WebhookEnqueueOutcome[]>;
12
+ /**
13
+ * Postgres implementation for the Node delivery host. Transaction-scoped
14
+ * advisory locks make `(idempotencyKey, attemptNumber)` atomic even though the
15
+ * legacy audit table has an index rather than a unique constraint.
16
+ */
17
+ export declare function createPostgresWebhookDeliveryStore(db: PostgresJsDatabase): WebhookDeliveryStore;
18
+ /** The only package-owned Postgres mutation boundary for webhook subscriptions. */
19
+ export declare function createPostgresWebhookSubscriptionService(db: PostgresJsDatabase, contracts: readonly ExternalWebhookEventContract[]): WebhookSubscriptionService;
20
+ //# sourceMappingURL=postgres-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres-store.d.ts","sourceRoot":"","sources":["../src/postgres-store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AACxD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAYrD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,gBAAgB,CAAA;AAKlE,OAAO,EAEL,KAAK,0BAA0B,EAChC,MAAM,oBAAoB,CAAA;AAC3B,OAAO,KAAK,EAIV,4BAA4B,EAC5B,oBAAoB,EACpB,qBAAqB,EACtB,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,kCAAkC;IACjD,KAAK,CAAC,EAAE,4BAA4B,CAAA;CACrC;AAED,oFAAoF;AACpF,wBAAsB,2BAA2B,CAC/C,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,aAAa,EACpB,OAAO,GAAE,kCAAuC,GAC/C,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAQlC;AAED;;;;GAIG;AACH,wBAAgB,kCAAkC,CAAC,EAAE,EAAE,kBAAkB,GAAG,oBAAoB,CA+K/F;AAkCD,mFAAmF;AACnF,wBAAgB,wCAAwC,CACtD,EAAE,EAAE,kBAAkB,EACtB,SAAS,EAAE,SAAS,4BAA4B,EAAE,GACjD,0BAA0B,CAuB5B"}
@@ -0,0 +1,216 @@
1
+ import { newId } from "@voyant-travel/db/lib/typeid";
2
+ import { infraWebhookDeliveriesTable, infraWebhookDeliverySelectSchema, infraWebhookSubscriptionInsertSchema, infraWebhookSubscriptionSelectSchema, infraWebhookSubscriptionsTable, infraWebhookSubscriptionUpdateSchema, } from "@voyant-travel/db/schema/infra";
3
+ import { and, arrayContains, asc, eq, isNull, lte, or, sql } from "drizzle-orm";
4
+ import { createSelectedExternalWebhookQueue, externalContractFromEventMetadata, } from "./selected-queue.js";
5
+ import { createWebhookSubscriptionService, } from "./subscriptions.js";
6
+ /** Persist one graph-selected event through the neutral Postgres delivery queue. */
7
+ export async function enqueuePostgresWebhookEvent(db, event, options = {}) {
8
+ const queue = options.queue ??
9
+ createSelectedExternalWebhookQueue({
10
+ contracts: [externalContractFromEventMetadata(event)],
11
+ store: createPostgresWebhookDeliveryStore(db),
12
+ });
13
+ return queue.enqueue(event);
14
+ }
15
+ /**
16
+ * Postgres implementation for the Node delivery host. Transaction-scoped
17
+ * advisory locks make `(idempotencyKey, attemptNumber)` atomic even though the
18
+ * legacy audit table has an index rather than a unique constraint.
19
+ */
20
+ export function createPostgresWebhookDeliveryStore(db) {
21
+ return {
22
+ async listActiveSubscriptions(eventName) {
23
+ const rows = await db
24
+ .select()
25
+ .from(infraWebhookSubscriptionsTable)
26
+ .where(and(eq(infraWebhookSubscriptionsTable.active, true), arrayContains(infraWebhookSubscriptionsTable.events, [eventName])));
27
+ return rows.map(toWebhookSubscription);
28
+ },
29
+ async getSubscription(id) {
30
+ const rows = await db
31
+ .select()
32
+ .from(infraWebhookSubscriptionsTable)
33
+ .where(eq(infraWebhookSubscriptionsTable.id, id))
34
+ .limit(1);
35
+ return rows[0] ? toWebhookSubscription(rows[0]) : null;
36
+ },
37
+ async listReadyAttemptIds(now, staleBefore, limit) {
38
+ const rows = await db
39
+ .select({ id: infraWebhookDeliveriesTable.id })
40
+ .from(infraWebhookDeliveriesTable)
41
+ .where(or(and(eq(infraWebhookDeliveriesTable.status, "pending"), or(isNull(infraWebhookDeliveriesTable.scheduledFor), lte(infraWebhookDeliveriesTable.scheduledFor, now))), and(eq(infraWebhookDeliveriesTable.status, "in_flight"), lte(infraWebhookDeliveriesTable.startedAt, staleBefore))))
42
+ .orderBy(asc(infraWebhookDeliveriesTable.scheduledFor), asc(infraWebhookDeliveriesTable.id))
43
+ .limit(limit);
44
+ return rows.map(({ id }) => id);
45
+ },
46
+ async enqueueAttempt(input) {
47
+ return db.transaction(async (tx) => {
48
+ const lockKey = `webhook-delivery:${input.idempotencyKey}:${input.attemptNumber}`;
49
+ await tx.execute(sql `SELECT pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
50
+ const existing = await tx
51
+ .select()
52
+ .from(infraWebhookDeliveriesTable)
53
+ .where(and(eq(infraWebhookDeliveriesTable.idempotencyKey, input.idempotencyKey), eq(infraWebhookDeliveriesTable.attemptNumber, input.attemptNumber)))
54
+ .limit(1);
55
+ if (existing[0]) {
56
+ return {
57
+ attempt: infraWebhookDeliverySelectSchema.parse(existing[0]),
58
+ created: false,
59
+ };
60
+ }
61
+ const inserted = await tx
62
+ .insert(infraWebhookDeliveriesTable)
63
+ .values(pendingAttemptValues(input))
64
+ .returning();
65
+ const row = inserted[0];
66
+ if (!row)
67
+ throw new Error("Webhook attempt insert returned no row");
68
+ return {
69
+ attempt: infraWebhookDeliverySelectSchema.parse(row),
70
+ created: true,
71
+ };
72
+ });
73
+ },
74
+ async claimAttempt(id, now, staleBefore) {
75
+ const rows = await db
76
+ .update(infraWebhookDeliveriesTable)
77
+ .set({ status: "in_flight", startedAt: now, updatedAt: now })
78
+ .where(and(eq(infraWebhookDeliveriesTable.id, id), or(eq(infraWebhookDeliveriesTable.status, "pending"), and(eq(infraWebhookDeliveriesTable.status, "in_flight"), lte(infraWebhookDeliveriesTable.startedAt, staleBefore))), or(isNull(infraWebhookDeliveriesTable.scheduledFor), lte(infraWebhookDeliveriesTable.scheduledFor, now))))
79
+ .returning();
80
+ return rows[0] ? infraWebhookDeliverySelectSchema.parse(rows[0]) : null;
81
+ },
82
+ async completeAttempt(input) {
83
+ const rows = await db
84
+ .update(infraWebhookDeliveriesTable)
85
+ .set({
86
+ status: input.status,
87
+ responseStatus: input.responseStatus,
88
+ responseHeaders: input.responseHeaders,
89
+ responseBodyExcerpt: input.responseBodyExcerpt,
90
+ errorClass: input.errorClass,
91
+ errorMessage: input.errorMessage,
92
+ finishedAt: input.finishedAt,
93
+ durationMs: input.durationMs,
94
+ updatedAt: input.finishedAt,
95
+ })
96
+ .where(and(eq(infraWebhookDeliveriesTable.id, input.id), eq(infraWebhookDeliveriesTable.status, "in_flight")))
97
+ .returning();
98
+ const row = rows[0];
99
+ if (!row)
100
+ throw new Error(`Webhook attempt ${input.id} was not in flight`);
101
+ return infraWebhookDeliverySelectSchema.parse(row);
102
+ },
103
+ async completeAndEnqueueRetry(completion, retry) {
104
+ return db.transaction(async (tx) => {
105
+ const completedRows = await tx
106
+ .update(infraWebhookDeliveriesTable)
107
+ .set(completedAttemptValues(completion))
108
+ .where(and(eq(infraWebhookDeliveriesTable.id, completion.id), eq(infraWebhookDeliveriesTable.status, "in_flight")))
109
+ .returning();
110
+ const completed = completedRows[0];
111
+ if (!completed)
112
+ throw new Error(`Webhook attempt ${completion.id} was not in flight`);
113
+ const retryRows = await tx
114
+ .insert(infraWebhookDeliveriesTable)
115
+ .values(pendingAttemptValues(retry))
116
+ .returning();
117
+ const retryRow = retryRows[0];
118
+ if (!retryRow) {
119
+ throw new Error(`Webhook retry ${retry.idempotencyKey}:${retry.attemptNumber} returned no row`);
120
+ }
121
+ return {
122
+ completed: infraWebhookDeliverySelectSchema.parse(completed),
123
+ retry: infraWebhookDeliverySelectSchema.parse(retryRow),
124
+ };
125
+ });
126
+ },
127
+ async recordSubscriptionOutcome(subscriptionId, succeeded, at) {
128
+ await db
129
+ .update(infraWebhookSubscriptionsTable)
130
+ .set({
131
+ lastDeliveryAt: at,
132
+ failureCount: succeeded ? 0 : sql `${infraWebhookSubscriptionsTable.failureCount} + 1`,
133
+ updatedAt: at,
134
+ })
135
+ .where(eq(infraWebhookSubscriptionsTable.id, subscriptionId));
136
+ },
137
+ };
138
+ }
139
+ function toWebhookSubscription(row) {
140
+ return {
141
+ id: row.id,
142
+ url: row.url,
143
+ secret: row.secret,
144
+ headers: row.headers ?? null,
145
+ maxRetries: row.maxRetries,
146
+ active: row.active,
147
+ };
148
+ }
149
+ function completedAttemptValues(input) {
150
+ return {
151
+ status: input.status,
152
+ responseStatus: input.responseStatus,
153
+ responseHeaders: input.responseHeaders,
154
+ responseBodyExcerpt: input.responseBodyExcerpt,
155
+ errorClass: input.errorClass,
156
+ errorMessage: input.errorMessage,
157
+ finishedAt: input.finishedAt,
158
+ durationMs: input.durationMs,
159
+ updatedAt: input.finishedAt,
160
+ };
161
+ }
162
+ /** The only package-owned Postgres mutation boundary for webhook subscriptions. */
163
+ export function createPostgresWebhookSubscriptionService(db, contracts) {
164
+ return createWebhookSubscriptionService({
165
+ contracts,
166
+ store: {
167
+ async create(input) {
168
+ const values = infraWebhookSubscriptionInsertSchema.parse(input);
169
+ const rows = await db
170
+ .insert(infraWebhookSubscriptionsTable)
171
+ .values({ id: newId("webhook_subscriptions"), ...values })
172
+ .returning();
173
+ return requireSubscription(rows[0], "create");
174
+ },
175
+ async update(id, input) {
176
+ const values = infraWebhookSubscriptionUpdateSchema.parse(input);
177
+ const rows = await db
178
+ .update(infraWebhookSubscriptionsTable)
179
+ .set({ ...values, updatedAt: new Date() })
180
+ .where(eq(infraWebhookSubscriptionsTable.id, id))
181
+ .returning();
182
+ return requireSubscription(rows[0], `update "${id}"`);
183
+ },
184
+ },
185
+ });
186
+ }
187
+ function requireSubscription(row, operation) {
188
+ if (!row)
189
+ throw new Error(`Webhook subscription ${operation} returned no row.`);
190
+ return infraWebhookSubscriptionSelectSchema.parse(row);
191
+ }
192
+ function pendingAttemptValues(input) {
193
+ return {
194
+ id: newId("webhook_deliveries"),
195
+ sourceModule: input.sourceModule,
196
+ sourceEvent: input.sourceEvent,
197
+ sourceEntityModule: input.sourceEntityModule,
198
+ sourceEntityId: input.sourceEntityId,
199
+ subscriptionId: input.subscriptionId,
200
+ targetUrl: input.targetUrl,
201
+ targetKind: "subscription",
202
+ targetRef: input.subscriptionId,
203
+ requestMethod: input.requestMethod,
204
+ requestHeaders: input.requestHeaders,
205
+ requestBodyHash: input.requestBodyHash,
206
+ requestBodyExcerpt: input.requestBodyExcerpt,
207
+ requestPayload: input.requestPayload,
208
+ deliveryContract: input.deliveryContract,
209
+ attemptNumber: input.attemptNumber,
210
+ parentDeliveryId: input.parentDeliveryId,
211
+ idempotencyKey: input.idempotencyKey,
212
+ status: "pending",
213
+ scheduledFor: input.scheduledFor,
214
+ startedAt: null,
215
+ };
216
+ }
@@ -0,0 +1,5 @@
1
+ export declare function signWebhookPayload(secret: string, timestamp: string, body: string): string;
2
+ export declare function hashWebhookPayload(body: string): string;
3
+ export declare function redactWebhookHeaders(headers: Record<string, string> | undefined): Record<string, string> | null;
4
+ export declare function webhookBodyExcerpt(body: unknown, maxBytes?: number): string | null;
5
+ //# sourceMappingURL=security.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"security.d.ts","sourceRoot":"","sources":["../src/security.ts"],"names":[],"mappings":"AAsDA,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1F;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,GAC1C,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAQ/B;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,SAAwB,GAAG,MAAM,GAAG,IAAI,CAoBjG"}
@@ -0,0 +1,111 @@
1
+ import { createHash, createHmac } from "node:crypto";
2
+ const DEFAULT_EXCERPT_BYTES = 4 * 1024;
3
+ const REDACTION_MARKER = "[REDACTED]";
4
+ const REDACTED_HEADERS = new Set([
5
+ "authorization",
6
+ "proxy-authorization",
7
+ "cookie",
8
+ "set-cookie",
9
+ "x-api-key",
10
+ "x-api-token",
11
+ "x-auth-token",
12
+ "x-access-token",
13
+ "x-voyant-signature",
14
+ "api-key",
15
+ "apikey",
16
+ ]);
17
+ const REDACTED_BODY_KEYS = new Set([
18
+ "password",
19
+ "secret",
20
+ "token",
21
+ "accesstoken",
22
+ "refreshtoken",
23
+ "apikey",
24
+ "apitoken",
25
+ "authorization",
26
+ "email",
27
+ "phone",
28
+ "phonenumber",
29
+ "mobile",
30
+ "ssn",
31
+ "passport",
32
+ "passportnumber",
33
+ "documentnumber",
34
+ "nationalid",
35
+ "taxid",
36
+ "dob",
37
+ "dateofbirth",
38
+ "birthdate",
39
+ "cardnumber",
40
+ "pan",
41
+ "cvv",
42
+ "cvc",
43
+ "iban",
44
+ "bic",
45
+ "accountnumber",
46
+ "firstname",
47
+ "lastname",
48
+ "fullname",
49
+ "middlename",
50
+ ]);
51
+ const EMAIL_PATTERN = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi;
52
+ const PHONE_PATTERN = /\+?\d[\d\s().-]{6,}\d/g;
53
+ export function signWebhookPayload(secret, timestamp, body) {
54
+ return `sha256=${createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex")}`;
55
+ }
56
+ export function hashWebhookPayload(body) {
57
+ return createHash("sha256").update(body).digest("hex");
58
+ }
59
+ export function redactWebhookHeaders(headers) {
60
+ if (!headers)
61
+ return null;
62
+ return Object.fromEntries(Object.entries(headers).map(([name, value]) => [
63
+ name,
64
+ REDACTED_HEADERS.has(name.toLowerCase()) ? REDACTION_MARKER : value,
65
+ ]));
66
+ }
67
+ export function webhookBodyExcerpt(body, maxBytes = DEFAULT_EXCERPT_BYTES) {
68
+ if (body == null)
69
+ return null;
70
+ let text;
71
+ if (typeof body === "string") {
72
+ try {
73
+ text = JSON.stringify(redactValue(JSON.parse(body)));
74
+ }
75
+ catch {
76
+ text = redactString(body);
77
+ }
78
+ }
79
+ else {
80
+ try {
81
+ text = JSON.stringify(redactValue(body));
82
+ }
83
+ catch {
84
+ text = "[unserializable]";
85
+ }
86
+ }
87
+ if (Buffer.byteLength(text, "utf8") <= maxBytes)
88
+ return text;
89
+ return `${Buffer.from(text, "utf8")
90
+ .subarray(0, Math.max(0, maxBytes - 3))
91
+ .toString("utf8")}...`;
92
+ }
93
+ function redactValue(value) {
94
+ if (value == null)
95
+ return value;
96
+ if (typeof value === "string")
97
+ return redactString(value);
98
+ if (typeof value !== "object")
99
+ return value;
100
+ if (Array.isArray(value))
101
+ return value.map(redactValue);
102
+ const output = {};
103
+ for (const [key, item] of Object.entries(value)) {
104
+ const normalized = key.toLowerCase().replace(/[_-]/g, "");
105
+ output[key] = REDACTED_BODY_KEYS.has(normalized) ? REDACTION_MARKER : redactValue(item);
106
+ }
107
+ return output;
108
+ }
109
+ function redactString(value) {
110
+ return value.replace(EMAIL_PATTERN, REDACTION_MARKER).replace(PHONE_PATTERN, REDACTION_MARKER);
111
+ }
@@ -0,0 +1,11 @@
1
+ import type { EventEnvelope } from "@voyant-travel/core";
2
+ import { type ExternalWebhookEventContract } from "./contracts.js";
3
+ import type { SelectedExternalWebhookQueue, WebhookDeliveryStore } from "./types.js";
4
+ export interface CreateSelectedExternalWebhookQueueOptions {
5
+ contracts: readonly ExternalWebhookEventContract[];
6
+ store: WebhookDeliveryStore;
7
+ now?: () => Date;
8
+ }
9
+ export declare function createSelectedExternalWebhookQueue(options: CreateSelectedExternalWebhookQueueOptions): SelectedExternalWebhookQueue;
10
+ export declare function externalContractFromEventMetadata(event: EventEnvelope): ExternalWebhookEventContract;
11
+ //# sourceMappingURL=selected-queue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"selected-queue.d.ts","sourceRoot":"","sources":["../src/selected-queue.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AAGxD,OAAO,EAAE,KAAK,4BAA4B,EAA+B,MAAM,gBAAgB,CAAA;AAE/F,OAAO,KAAK,EACV,4BAA4B,EAC5B,oBAAoB,EAErB,MAAM,YAAY,CAAA;AAEnB,MAAM,WAAW,yCAAyC;IACxD,SAAS,EAAE,SAAS,4BAA4B,EAAE,CAAA;IAClD,KAAK,EAAE,oBAAoB,CAAA;IAC3B,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;CACjB;AAED,wBAAgB,kCAAkC,CAChD,OAAO,EAAE,yCAAyC,GACjD,4BAA4B,CA0D9B;AAED,wBAAgB,iCAAiC,CAC/C,KAAK,EAAE,aAAa,GACnB,4BAA4B,CAU9B"}
@@ -0,0 +1,102 @@
1
+ import { isExternalWebhookPayloadSchema } from "@voyant-travel/core/project";
2
+ import { prepareExternalWebhookEvent } from "./contracts.js";
3
+ import { hashWebhookPayload, redactWebhookHeaders, webhookBodyExcerpt } from "./security.js";
4
+ export function createSelectedExternalWebhookQueue(options) {
5
+ const contracts = contractCatalog(options.contracts);
6
+ const now = options.now ?? (() => new Date());
7
+ return {
8
+ async enqueue(input) {
9
+ const eventId = requireEventId(input);
10
+ const contract = contracts.get(input.name);
11
+ if (!contract) {
12
+ throw new Error(`Event "${input.name}" is not in the selected external catalog.`);
13
+ }
14
+ const event = prepareExternalWebhookEvent(input, contract);
15
+ const body = JSON.stringify(event);
16
+ const subscriptions = await options.store.listActiveSubscriptions(event.name);
17
+ return Promise.all(subscriptions.map(async (subscription) => {
18
+ const idempotencyKey = `graph-webhook:${eventId}:${subscription.id}`;
19
+ const enqueued = await options.store.enqueueAttempt({
20
+ sourceModule: stringMetadata(input, "graphEventSourceModule") ?? "graph-outbound-webhooks",
21
+ sourceEvent: event.name,
22
+ ...sourceEntity(event.data),
23
+ subscriptionId: subscription.id,
24
+ targetUrl: subscription.url,
25
+ requestMethod: "POST",
26
+ requestHeaders: redactWebhookHeaders({
27
+ ...(subscription.headers ?? {}),
28
+ "content-type": "application/json",
29
+ "idempotency-key": idempotencyKey,
30
+ "x-voyant-event": event.name,
31
+ "x-voyant-event-id": eventId,
32
+ "x-voyant-event-contract": contract.eventId,
33
+ "x-voyant-event-version": contract.eventVersion,
34
+ }) ?? {},
35
+ requestBodyHash: hashWebhookPayload(body),
36
+ requestBodyExcerpt: webhookBodyExcerpt(body),
37
+ requestPayload: event,
38
+ deliveryContract: contract,
39
+ attemptNumber: 1,
40
+ parentDeliveryId: null,
41
+ idempotencyKey,
42
+ scheduledFor: now(),
43
+ });
44
+ return {
45
+ status: enqueued.created
46
+ ? "pending"
47
+ : isTerminal(enqueued.attempt.status)
48
+ ? "already_completed"
49
+ : "already_pending",
50
+ subscriptionId: subscription.id,
51
+ delivery: enqueued.attempt,
52
+ };
53
+ }));
54
+ },
55
+ };
56
+ }
57
+ export function externalContractFromEventMetadata(event) {
58
+ const eventId = stringMetadata(event, "graphEventId");
59
+ const eventVersion = stringMetadata(event, "graphEventVersion");
60
+ const payloadSchema = event.metadata?.graphEventPayloadSchema;
61
+ if (!eventId || !eventVersion || !isExternalWebhookPayloadSchema(payloadSchema)) {
62
+ throw new Error(`External webhook event "${event.name}" is missing its selected contract metadata.`);
63
+ }
64
+ return { eventId, eventType: event.name, eventVersion, payloadSchema };
65
+ }
66
+ function contractCatalog(contracts) {
67
+ const catalog = new Map();
68
+ for (const contract of contracts) {
69
+ if (catalog.has(contract.eventType)) {
70
+ throw new Error(`Duplicate external webhook event contract "${contract.eventType}".`);
71
+ }
72
+ catalog.set(contract.eventType, contract);
73
+ }
74
+ return catalog;
75
+ }
76
+ function requireEventId(event) {
77
+ const eventId = event.metadata?.eventId;
78
+ if (typeof eventId !== "string" || eventId.trim().length === 0) {
79
+ throw new Error(`Webhook event "${event.name}" requires metadata.eventId.`);
80
+ }
81
+ return eventId;
82
+ }
83
+ function stringMetadata(event, key) {
84
+ const value = event.metadata?.[key];
85
+ return typeof value === "string" && value.length > 0 ? value : null;
86
+ }
87
+ function sourceEntity(data) {
88
+ if (!isRecord(data))
89
+ return { sourceEntityModule: null, sourceEntityId: null };
90
+ const module = data.entityModule ?? data.entity_module;
91
+ const id = data.entityId ?? data.entity_id;
92
+ return {
93
+ sourceEntityModule: typeof module === "string" ? module : null,
94
+ sourceEntityId: typeof id === "string" ? id : null,
95
+ };
96
+ }
97
+ function isTerminal(status) {
98
+ return status === "succeeded" || status === "abandoned";
99
+ }
100
+ function isRecord(value) {
101
+ return value !== null && typeof value === "object" && !Array.isArray(value);
102
+ }
@@ -0,0 +1,15 @@
1
+ import type { InfraWebhookSubscription, NewInfraWebhookSubscription, UpdateInfraWebhookSubscription } from "@voyant-travel/db/schema/infra";
2
+ import { type ExternalWebhookEventContract } from "./contracts.js";
3
+ export interface WebhookSubscriptionMutationStore {
4
+ create(input: NewInfraWebhookSubscription): Promise<InfraWebhookSubscription>;
5
+ update(id: string, input: UpdateInfraWebhookSubscription): Promise<InfraWebhookSubscription>;
6
+ }
7
+ export interface WebhookSubscriptionService {
8
+ create(input: NewInfraWebhookSubscription): Promise<InfraWebhookSubscription>;
9
+ update(id: string, input: UpdateInfraWebhookSubscription): Promise<InfraWebhookSubscription>;
10
+ }
11
+ export declare function createWebhookSubscriptionService(options: {
12
+ contracts: readonly ExternalWebhookEventContract[];
13
+ store: WebhookSubscriptionMutationStore;
14
+ }): WebhookSubscriptionService;
15
+ //# sourceMappingURL=subscriptions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscriptions.d.ts","sourceRoot":"","sources":["../src/subscriptions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,wBAAwB,EACxB,2BAA2B,EAC3B,8BAA8B,EAC/B,MAAM,gCAAgC,CAAA;AAEvC,OAAO,EAGL,KAAK,4BAA4B,EAClC,MAAM,gBAAgB,CAAA;AAEvB,MAAM,WAAW,gCAAgC;IAC/C,MAAM,CAAC,KAAK,EAAE,2BAA2B,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAA;IAC7E,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,8BAA8B,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAA;CAC7F;AAED,MAAM,WAAW,0BAA0B;IACzC,MAAM,CAAC,KAAK,EAAE,2BAA2B,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAA;IAC7E,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,8BAA8B,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAAA;CAC7F;AAED,wBAAgB,gCAAgC,CAAC,OAAO,EAAE;IACxD,SAAS,EAAE,SAAS,4BAA4B,EAAE,CAAA;IAClD,KAAK,EAAE,gCAAgC,CAAA;CACxC,GAAG,0BAA0B,CAW7B"}
@@ -0,0 +1,13 @@
1
+ import { assertWebhookSubscriptionCreateEvents, assertWebhookSubscriptionUpdateEvents, } from "./contracts.js";
2
+ export function createWebhookSubscriptionService(options) {
3
+ return {
4
+ async create(input) {
5
+ assertWebhookSubscriptionCreateEvents(input, options.contracts);
6
+ return options.store.create(input);
7
+ },
8
+ async update(id, input) {
9
+ assertWebhookSubscriptionUpdateEvents(input, options.contracts);
10
+ return options.store.update(id, input);
11
+ },
12
+ };
13
+ }
@@ -0,0 +1,104 @@
1
+ import type { EventEnvelope } from "@voyant-travel/core";
2
+ import type { InfraWebhookDelivery } from "@voyant-travel/db/schema/infra";
3
+ import type { ExternalWebhookEventContract } from "./contracts.js";
4
+ export interface WebhookSubscription {
5
+ id: string;
6
+ url: string;
7
+ secret: string;
8
+ headers: Record<string, string> | null;
9
+ maxRetries: number;
10
+ active: boolean;
11
+ }
12
+ export interface EnqueueWebhookAttemptInput {
13
+ sourceModule: string;
14
+ sourceEvent: string;
15
+ sourceEntityModule: string | null;
16
+ sourceEntityId: string | null;
17
+ subscriptionId: string;
18
+ targetUrl: string;
19
+ requestMethod: string;
20
+ requestHeaders: Record<string, string>;
21
+ requestBodyHash: string;
22
+ requestBodyExcerpt: string | null;
23
+ requestPayload: EventEnvelope;
24
+ deliveryContract: ExternalWebhookEventContract;
25
+ attemptNumber: number;
26
+ parentDeliveryId: string | null;
27
+ idempotencyKey: string;
28
+ scheduledFor: Date;
29
+ }
30
+ export interface CompleteWebhookAttemptInput {
31
+ id: string;
32
+ status: "succeeded" | "failed" | "abandoned";
33
+ responseStatus: number | null;
34
+ responseHeaders: Record<string, string> | null;
35
+ responseBodyExcerpt: string | null;
36
+ errorClass: InfraWebhookDelivery["errorClass"];
37
+ errorMessage: string | null;
38
+ finishedAt: Date;
39
+ durationMs: number;
40
+ }
41
+ export interface EnqueuedWebhookAttempt {
42
+ attempt: InfraWebhookDelivery;
43
+ created: boolean;
44
+ }
45
+ export interface WebhookDeliveryStore {
46
+ listActiveSubscriptions(eventName: string): Promise<WebhookSubscription[]>;
47
+ getSubscription(id: string): Promise<WebhookSubscription | null>;
48
+ enqueueAttempt(input: EnqueueWebhookAttemptInput): Promise<EnqueuedWebhookAttempt>;
49
+ listReadyAttemptIds(now: Date, staleBefore: Date, limit: number): Promise<string[]>;
50
+ claimAttempt(id: string, now: Date, staleBefore: Date): Promise<InfraWebhookDelivery | null>;
51
+ completeAttempt(input: CompleteWebhookAttemptInput): Promise<InfraWebhookDelivery>;
52
+ completeAndEnqueueRetry(completion: CompleteWebhookAttemptInput, retry: EnqueueWebhookAttemptInput): Promise<{
53
+ completed: InfraWebhookDelivery;
54
+ retry: InfraWebhookDelivery;
55
+ }>;
56
+ recordSubscriptionOutcome(subscriptionId: string, succeeded: boolean, at: Date): Promise<void>;
57
+ }
58
+ export type WebhookEnqueueOutcome = {
59
+ status: "pending" | "already_pending" | "already_completed";
60
+ subscriptionId: string;
61
+ delivery: InfraWebhookDelivery;
62
+ };
63
+ export type WebhookDeliveryOutcome = {
64
+ status: "succeeded" | "dead_lettered" | "retry_scheduled";
65
+ subscriptionId: string | null;
66
+ delivery: InfraWebhookDelivery;
67
+ nextAttempt?: InfraWebhookDelivery;
68
+ } | {
69
+ status: "idle";
70
+ };
71
+ export interface WebhookDeliveryAuditEvent {
72
+ eventId: string | null;
73
+ eventName: string;
74
+ contractId?: string;
75
+ contractVersion?: string;
76
+ subscriptionId: string | null;
77
+ outcome: Exclude<WebhookDeliveryOutcome["status"], "idle">;
78
+ deliveryId: string;
79
+ attemptNumber: number;
80
+ reason?: string;
81
+ }
82
+ export interface WebhookRetryOptions {
83
+ baseDelayMs?: number;
84
+ maxDelayMs?: number;
85
+ requestTimeoutMs?: number;
86
+ claimTimeoutMs?: number;
87
+ }
88
+ export interface CreateWebhookDeliveryWorkerOptions {
89
+ store: WebhookDeliveryStore;
90
+ fetch?: typeof globalThis.fetch;
91
+ now?: () => Date;
92
+ retry?: WebhookRetryOptions;
93
+ onAudit?: (event: WebhookDeliveryAuditEvent) => void | Promise<void>;
94
+ }
95
+ export interface WebhookDeliveryWorker {
96
+ runNext(): Promise<WebhookDeliveryOutcome>;
97
+ drain(options?: {
98
+ limit?: number;
99
+ }): Promise<WebhookDeliveryOutcome[]>;
100
+ }
101
+ export interface SelectedExternalWebhookQueue {
102
+ enqueue(event: EventEnvelope): Promise<WebhookEnqueueOutcome[]>;
103
+ }
104
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import type { CreateWebhookDeliveryWorkerOptions, WebhookDeliveryWorker } from "./types.js";
2
+ export declare function createWebhookDeliveryWorker(options: CreateWebhookDeliveryWorkerOptions): WebhookDeliveryWorker;
3
+ //# sourceMappingURL=worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAEV,kCAAkC,EAIlC,qBAAqB,EAEtB,MAAM,YAAY,CAAA;AASnB,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,kCAAkC,GAC1C,qBAAqB,CAkJvB"}
package/dist/worker.js ADDED
@@ -0,0 +1,317 @@
1
+ import { isExternalWebhookPayloadSchema } from "@voyant-travel/core/project";
2
+ import { hashWebhookPayload, redactWebhookHeaders, signWebhookPayload, webhookBodyExcerpt, } from "./security.js";
3
+ const DEFAULT_BASE_DELAY_MS = 1_000;
4
+ const DEFAULT_MAX_DELAY_MS = 60_000;
5
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
6
+ const DEFAULT_CLAIM_TIMEOUT_MS = 60_000;
7
+ const MAX_RESPONSE_BODY_BYTES = 4 * 1024;
8
+ const MAX_ERROR_MESSAGE_LENGTH = 1_000;
9
+ export function createWebhookDeliveryWorker(options) {
10
+ const fetchImpl = options.fetch ?? globalThis.fetch;
11
+ const now = options.now ?? (() => new Date());
12
+ const baseDelayMs = positiveInteger(options.retry?.baseDelayMs, DEFAULT_BASE_DELAY_MS);
13
+ const maxDelayMs = positiveInteger(options.retry?.maxDelayMs, DEFAULT_MAX_DELAY_MS);
14
+ const requestTimeoutMs = positiveInteger(options.retry?.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS);
15
+ const claimTimeoutMs = positiveInteger(options.retry?.claimTimeoutMs, DEFAULT_CLAIM_TIMEOUT_MS);
16
+ return {
17
+ async runNext() {
18
+ const claimedAt = now();
19
+ const staleBefore = new Date(claimedAt.getTime() - claimTimeoutMs);
20
+ const ids = await options.store.listReadyAttemptIds(claimedAt, staleBefore, 1);
21
+ for (const id of ids) {
22
+ const delivery = await options.store.claimAttempt(id, claimedAt, staleBefore);
23
+ if (delivery)
24
+ return execute(delivery, claimedAt);
25
+ }
26
+ return { status: "idle" };
27
+ },
28
+ async drain(input = {}) {
29
+ const outcomes = [];
30
+ const limit = positiveInteger(input.limit, 100);
31
+ while (outcomes.length < limit) {
32
+ const outcome = await this.runNext();
33
+ if (outcome.status === "idle")
34
+ break;
35
+ outcomes.push(outcome);
36
+ }
37
+ return outcomes;
38
+ },
39
+ };
40
+ async function execute(delivery, startedAt) {
41
+ const hydrated = hydrateAttempt(delivery);
42
+ if (!hydrated.ok)
43
+ return abandon(delivery, startedAt, hydrated.reason);
44
+ const subscription = await options.store.getSubscription(delivery.subscriptionId ?? "");
45
+ if (!subscription?.active) {
46
+ return abandon(delivery, startedAt, "webhook subscription is unavailable or inactive");
47
+ }
48
+ const body = JSON.stringify(hydrated.event);
49
+ if (hashWebhookPayload(body) !== delivery.requestBodyHash) {
50
+ return abandon(delivery, startedAt, "persisted webhook payload hash does not match");
51
+ }
52
+ const timestamp = Math.floor(startedAt.getTime() / 1_000).toString();
53
+ const requestHeaders = signedHeaders(subscription, hydrated.event, hydrated.contract, delivery.idempotencyKey ?? delivery.id, timestamp, body);
54
+ const result = await dispatch(fetchImpl, delivery.targetUrl, requestHeaders, body, requestTimeoutMs);
55
+ const finishedAt = now();
56
+ const completion = completionInput(delivery, result, startedAt, finishedAt);
57
+ if (result.succeeded) {
58
+ const completed = await options.store.completeAttempt({ ...completion, status: "succeeded" });
59
+ await options.store.recordSubscriptionOutcome(subscription.id, true, finishedAt);
60
+ const outcome = {
61
+ status: "succeeded",
62
+ subscriptionId: subscription.id,
63
+ delivery: completed,
64
+ };
65
+ await audit(options, hydrated.event, hydrated.contract, outcome);
66
+ return outcome;
67
+ }
68
+ const mayRetry = result.retryable && delivery.attemptNumber <= subscription.maxRetries;
69
+ if (mayRetry) {
70
+ const retry = retryInput(delivery, hydrated.event, hydrated.contract, subscription, body, finishedAt, baseDelayMs, maxDelayMs);
71
+ const persisted = await options.store.completeAndEnqueueRetry({ ...completion, status: "failed" }, retry);
72
+ const outcome = {
73
+ status: "retry_scheduled",
74
+ subscriptionId: subscription.id,
75
+ delivery: persisted.completed,
76
+ nextAttempt: persisted.retry,
77
+ };
78
+ await audit(options, hydrated.event, hydrated.contract, outcome);
79
+ return outcome;
80
+ }
81
+ const completed = await options.store.completeAttempt({ ...completion, status: "abandoned" });
82
+ await options.store.recordSubscriptionOutcome(subscription.id, false, finishedAt);
83
+ const outcome = {
84
+ status: "dead_lettered",
85
+ subscriptionId: subscription.id,
86
+ delivery: completed,
87
+ };
88
+ await audit(options, hydrated.event, hydrated.contract, outcome);
89
+ return outcome;
90
+ }
91
+ async function abandon(delivery, startedAt, reason) {
92
+ const finishedAt = now();
93
+ const completed = await options.store.completeAttempt({
94
+ id: delivery.id,
95
+ status: "abandoned",
96
+ responseStatus: null,
97
+ responseHeaders: null,
98
+ responseBodyExcerpt: null,
99
+ errorClass: "adapter_error",
100
+ errorMessage: boundedMessage(reason),
101
+ finishedAt,
102
+ durationMs: Math.max(0, finishedAt.getTime() - startedAt.getTime()),
103
+ });
104
+ if (delivery.subscriptionId) {
105
+ await options.store.recordSubscriptionOutcome(delivery.subscriptionId, false, finishedAt);
106
+ }
107
+ const outcome = {
108
+ status: "dead_lettered",
109
+ subscriptionId: delivery.subscriptionId,
110
+ delivery: completed,
111
+ };
112
+ await auditFromDelivery(options, delivery, outcome, reason);
113
+ return outcome;
114
+ }
115
+ }
116
+ function hydrateAttempt(delivery) {
117
+ if (!isEventEnvelope(delivery.requestPayload)) {
118
+ return { ok: false, reason: "pending webhook delivery has no complete request payload" };
119
+ }
120
+ if (!isExternalContract(delivery.deliveryContract)) {
121
+ return { ok: false, reason: "pending webhook delivery has no valid contract snapshot" };
122
+ }
123
+ if (delivery.requestPayload.name !== delivery.deliveryContract.eventType) {
124
+ return { ok: false, reason: "persisted webhook payload and contract event types differ" };
125
+ }
126
+ return { ok: true, event: delivery.requestPayload, contract: delivery.deliveryContract };
127
+ }
128
+ function isEventEnvelope(value) {
129
+ return (isRecord(value) &&
130
+ typeof value.name === "string" &&
131
+ "data" in value &&
132
+ typeof value.emittedAt === "string");
133
+ }
134
+ function isExternalContract(value) {
135
+ return (isRecord(value) &&
136
+ typeof value.eventId === "string" &&
137
+ typeof value.eventType === "string" &&
138
+ typeof value.eventVersion === "string" &&
139
+ isExternalWebhookPayloadSchema(value.payloadSchema));
140
+ }
141
+ async function dispatch(fetchImpl, url, headers, body, timeoutMs) {
142
+ assertWebhookUrl(url);
143
+ const controller = new AbortController();
144
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
145
+ try {
146
+ const response = await fetchImpl(url, {
147
+ method: "POST",
148
+ headers,
149
+ body,
150
+ signal: controller.signal,
151
+ });
152
+ const responseBody = await readBoundedResponseBody(response, MAX_RESPONSE_BODY_BYTES);
153
+ const responseHeaders = Object.fromEntries(response.headers.entries());
154
+ if (response.ok) {
155
+ return {
156
+ succeeded: true,
157
+ retryable: false,
158
+ responseStatus: response.status,
159
+ responseHeaders,
160
+ responseBody,
161
+ errorClass: null,
162
+ errorMessage: null,
163
+ };
164
+ }
165
+ const rateLimited = response.status === 429;
166
+ return {
167
+ succeeded: false,
168
+ retryable: rateLimited || response.status === 408 || response.status >= 500,
169
+ responseStatus: response.status,
170
+ responseHeaders,
171
+ responseBody,
172
+ errorClass: rateLimited ? "rate_limited" : response.status >= 500 ? "5xx" : "4xx",
173
+ errorMessage: boundedMessage(`HTTP ${response.status}`),
174
+ };
175
+ }
176
+ catch (error) {
177
+ return {
178
+ succeeded: false,
179
+ retryable: true,
180
+ responseStatus: null,
181
+ errorClass: controller.signal.aborted ? "timeout" : "network",
182
+ errorMessage: boundedMessage(error instanceof Error ? error.message : String(error)),
183
+ };
184
+ }
185
+ finally {
186
+ clearTimeout(timeout);
187
+ }
188
+ }
189
+ function retryInput(delivery, event, contract, subscription, body, now, baseDelayMs, maxDelayMs) {
190
+ const nextAttempt = delivery.attemptNumber + 1;
191
+ return {
192
+ sourceModule: delivery.sourceModule,
193
+ sourceEvent: delivery.sourceEvent,
194
+ sourceEntityModule: delivery.sourceEntityModule,
195
+ sourceEntityId: delivery.sourceEntityId,
196
+ subscriptionId: subscription.id,
197
+ targetUrl: delivery.targetUrl,
198
+ requestMethod: "POST",
199
+ requestHeaders: delivery.requestHeaders ?? {},
200
+ requestBodyHash: hashWebhookPayload(body),
201
+ requestBodyExcerpt: webhookBodyExcerpt(body),
202
+ requestPayload: event,
203
+ deliveryContract: contract,
204
+ attemptNumber: nextAttempt,
205
+ parentDeliveryId: delivery.id,
206
+ idempotencyKey: delivery.idempotencyKey ?? delivery.id,
207
+ scheduledFor: new Date(now.getTime() + retryDelay(delivery.attemptNumber, baseDelayMs, maxDelayMs)),
208
+ };
209
+ }
210
+ function completionInput(delivery, result, startedAt, finishedAt) {
211
+ return {
212
+ id: delivery.id,
213
+ responseStatus: result.responseStatus,
214
+ responseHeaders: redactWebhookHeaders(result.responseHeaders),
215
+ responseBodyExcerpt: webhookBodyExcerpt(result.responseBody),
216
+ errorClass: result.errorClass,
217
+ errorMessage: result.errorMessage,
218
+ finishedAt,
219
+ durationMs: Math.max(0, finishedAt.getTime() - startedAt.getTime()),
220
+ };
221
+ }
222
+ function signedHeaders(subscription, event, contract, idempotencyKey, timestamp, body) {
223
+ const eventId = stringMetadata(event, "eventId");
224
+ if (!eventId)
225
+ throw new Error(`Persisted webhook event "${event.name}" has no event id.`);
226
+ return {
227
+ ...(subscription.headers ?? {}),
228
+ "content-type": "application/json",
229
+ "idempotency-key": idempotencyKey,
230
+ "x-voyant-event": event.name,
231
+ "x-voyant-event-id": eventId,
232
+ "x-voyant-event-contract": contract.eventId,
233
+ "x-voyant-event-version": contract.eventVersion,
234
+ "x-voyant-timestamp": timestamp,
235
+ "x-voyant-signature": signWebhookPayload(subscription.secret, timestamp, body),
236
+ };
237
+ }
238
+ async function audit(options, event, contract, outcome) {
239
+ if (!options.onAudit)
240
+ return;
241
+ await options.onAudit({
242
+ eventId: stringMetadata(event, "eventId"),
243
+ eventName: event.name,
244
+ contractId: contract.eventId,
245
+ contractVersion: contract.eventVersion,
246
+ subscriptionId: outcome.subscriptionId,
247
+ outcome: outcome.status,
248
+ deliveryId: outcome.delivery.id,
249
+ attemptNumber: outcome.delivery.attemptNumber,
250
+ });
251
+ }
252
+ async function auditFromDelivery(options, delivery, outcome, reason) {
253
+ if (!options.onAudit)
254
+ return;
255
+ const payload = isEventEnvelope(delivery.requestPayload) ? delivery.requestPayload : null;
256
+ const contract = isExternalContract(delivery.deliveryContract) ? delivery.deliveryContract : null;
257
+ const auditEvent = {
258
+ eventId: payload ? stringMetadata(payload, "eventId") : null,
259
+ eventName: payload?.name ?? delivery.sourceEvent,
260
+ ...(contract ? { contractId: contract.eventId, contractVersion: contract.eventVersion } : {}),
261
+ subscriptionId: delivery.subscriptionId,
262
+ outcome: outcome.status,
263
+ deliveryId: outcome.delivery.id,
264
+ attemptNumber: outcome.delivery.attemptNumber,
265
+ reason,
266
+ };
267
+ await options.onAudit(auditEvent);
268
+ }
269
+ async function readBoundedResponseBody(response, maxBytes) {
270
+ const reader = response.body?.getReader();
271
+ if (!reader)
272
+ return "";
273
+ const buffer = new Uint8Array(maxBytes);
274
+ let offset = 0;
275
+ let complete = false;
276
+ try {
277
+ while (offset < maxBytes) {
278
+ const chunk = await reader.read();
279
+ if (chunk.done) {
280
+ complete = true;
281
+ break;
282
+ }
283
+ const length = Math.min(maxBytes - offset, chunk.value.byteLength);
284
+ buffer.set(chunk.value.subarray(0, length), offset);
285
+ offset += length;
286
+ if (length < chunk.value.byteLength)
287
+ break;
288
+ }
289
+ }
290
+ finally {
291
+ if (!complete)
292
+ await reader.cancel().catch(() => undefined);
293
+ }
294
+ return new TextDecoder().decode(buffer.subarray(0, offset));
295
+ }
296
+ function retryDelay(attemptNumber, baseDelayMs, maxDelayMs) {
297
+ return Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attemptNumber - 1));
298
+ }
299
+ function positiveInteger(value, fallback) {
300
+ return value !== undefined && Number.isInteger(value) && value > 0 ? value : fallback;
301
+ }
302
+ function boundedMessage(message) {
303
+ return message.slice(0, MAX_ERROR_MESSAGE_LENGTH);
304
+ }
305
+ function assertWebhookUrl(value) {
306
+ const url = new URL(value);
307
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
308
+ throw new Error(`Webhook URL must use HTTP(S): ${value}`);
309
+ }
310
+ }
311
+ function stringMetadata(event, key) {
312
+ const value = event.metadata?.[key];
313
+ return typeof value === "string" && value.length > 0 ? value : null;
314
+ }
315
+ function isRecord(value) {
316
+ return value !== null && typeof value === "object" && !Array.isArray(value);
317
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@voyant-travel/webhook-delivery",
3
+ "version": "0.1.0",
4
+ "description": "Durable, policy-aware outbound webhook delivery for Voyant Node deployments.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./postgres": "./src/postgres-store.ts"
11
+ },
12
+ "scripts": {
13
+ "typecheck": "tsc -p tsconfig.typecheck.json",
14
+ "lint": "biome check src/ tests/",
15
+ "test": "vitest run",
16
+ "build": "tsc -p tsconfig.build.json",
17
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
18
+ "prepack": "pnpm --filter @voyant-travel/webhook-delivery... run build"
19
+ },
20
+ "dependencies": {
21
+ "@voyant-travel/core": "workspace:^",
22
+ "@voyant-travel/db": "workspace:^",
23
+ "drizzle-orm": "catalog:"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "catalog:",
27
+ "@voyant-travel/voyant-typescript-config": "workspace:^",
28
+ "typescript": "catalog:",
29
+ "vitest": "catalog:"
30
+ },
31
+ "files": [
32
+ "dist"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "main": "./dist/index.js",
37
+ "types": "./dist/index.d.ts",
38
+ "exports": {
39
+ ".": {
40
+ "types": "./dist/index.d.ts",
41
+ "import": "./dist/index.js",
42
+ "default": "./dist/index.js"
43
+ },
44
+ "./postgres": {
45
+ "types": "./dist/postgres-store.d.ts",
46
+ "import": "./dist/postgres-store.js",
47
+ "default": "./dist/postgres-store.js"
48
+ }
49
+ }
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "https://github.com/voyant-travel/voyant.git",
54
+ "directory": "packages/webhook-delivery"
55
+ }
56
+ }