@voyant-travel/webhook-delivery 0.3.3 → 0.4.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/dist/.tsbuildinfo +1 -1
- package/dist/app-envelope.d.ts +41 -0
- package/dist/app-envelope.js +54 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -1
- package/dist/postgres-store.js +2 -1
- package/dist/security.d.ts +19 -0
- package/dist/security.js +54 -0
- package/dist/selected-queue.js +22 -3
- package/dist/types.d.ts +9 -1
- package/dist/worker.js +61 -16
- package/package.json +3 -3
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { EventEnvelope } from "@voyant-travel/core";
|
|
2
|
+
import type { ExternalWebhookEventContract } from "./contracts.js";
|
|
3
|
+
export interface AppWebhookDeliveryEnvelope {
|
|
4
|
+
schema: "voyant.app-webhook.delivery.v1";
|
|
5
|
+
deliveryId: string;
|
|
6
|
+
installationId: string;
|
|
7
|
+
appId: string;
|
|
8
|
+
event: {
|
|
9
|
+
type: string;
|
|
10
|
+
schemaVersion: string;
|
|
11
|
+
occurredAt: string;
|
|
12
|
+
deliveredAt: string;
|
|
13
|
+
};
|
|
14
|
+
attempt: {
|
|
15
|
+
number: number;
|
|
16
|
+
maxRetries: number;
|
|
17
|
+
idempotencyKey: string;
|
|
18
|
+
originalDeliveryId: string | null;
|
|
19
|
+
parentDeliveryId: string | null;
|
|
20
|
+
};
|
|
21
|
+
subject: {
|
|
22
|
+
module: string | null;
|
|
23
|
+
id: string | null;
|
|
24
|
+
};
|
|
25
|
+
payload: unknown;
|
|
26
|
+
metadata: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
export declare function createAppWebhookDeliveryEnvelope(input: {
|
|
29
|
+
deliveryId: string;
|
|
30
|
+
installationId: string;
|
|
31
|
+
appId: string;
|
|
32
|
+
event: EventEnvelope;
|
|
33
|
+
contract: ExternalWebhookEventContract;
|
|
34
|
+
deliveredAt: Date;
|
|
35
|
+
attemptNumber: number;
|
|
36
|
+
maxRetries: number;
|
|
37
|
+
idempotencyKey: string;
|
|
38
|
+
originalDeliveryId?: string | null;
|
|
39
|
+
parentDeliveryId?: string | null;
|
|
40
|
+
}): AppWebhookDeliveryEnvelope;
|
|
41
|
+
export declare function isAppWebhookDeliveryEnvelope(value: unknown): value is AppWebhookDeliveryEnvelope;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function createAppWebhookDeliveryEnvelope(input) {
|
|
2
|
+
const subject = sourceEntity(input.event.data);
|
|
3
|
+
return {
|
|
4
|
+
schema: "voyant.app-webhook.delivery.v1",
|
|
5
|
+
deliveryId: input.deliveryId,
|
|
6
|
+
installationId: input.installationId,
|
|
7
|
+
appId: input.appId,
|
|
8
|
+
event: {
|
|
9
|
+
type: input.event.name,
|
|
10
|
+
schemaVersion: input.contract.eventVersion,
|
|
11
|
+
occurredAt: input.event.emittedAt,
|
|
12
|
+
deliveredAt: input.deliveredAt.toISOString(),
|
|
13
|
+
},
|
|
14
|
+
attempt: {
|
|
15
|
+
number: input.attemptNumber,
|
|
16
|
+
maxRetries: input.maxRetries,
|
|
17
|
+
idempotencyKey: input.idempotencyKey,
|
|
18
|
+
originalDeliveryId: input.originalDeliveryId ?? null,
|
|
19
|
+
parentDeliveryId: input.parentDeliveryId ?? null,
|
|
20
|
+
},
|
|
21
|
+
subject,
|
|
22
|
+
payload: input.event.data,
|
|
23
|
+
metadata: input.event.metadata ?? {},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function isAppWebhookDeliveryEnvelope(value) {
|
|
27
|
+
return (isRecord(value) &&
|
|
28
|
+
value.schema === "voyant.app-webhook.delivery.v1" &&
|
|
29
|
+
typeof value.deliveryId === "string" &&
|
|
30
|
+
typeof value.installationId === "string" &&
|
|
31
|
+
typeof value.appId === "string" &&
|
|
32
|
+
isRecord(value.event) &&
|
|
33
|
+
typeof value.event.type === "string" &&
|
|
34
|
+
typeof value.event.schemaVersion === "string" &&
|
|
35
|
+
typeof value.event.occurredAt === "string" &&
|
|
36
|
+
typeof value.event.deliveredAt === "string" &&
|
|
37
|
+
isRecord(value.attempt) &&
|
|
38
|
+
typeof value.attempt.number === "number" &&
|
|
39
|
+
typeof value.attempt.idempotencyKey === "string" &&
|
|
40
|
+
"payload" in value);
|
|
41
|
+
}
|
|
42
|
+
function sourceEntity(data) {
|
|
43
|
+
if (!isRecord(data))
|
|
44
|
+
return { module: null, id: null };
|
|
45
|
+
const module = data.entityModule ?? data.entity_module;
|
|
46
|
+
const id = data.entityId ?? data.entity_id;
|
|
47
|
+
return {
|
|
48
|
+
module: typeof module === "string" ? module : null,
|
|
49
|
+
id: typeof id === "string" ? id : null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function isRecord(value) {
|
|
53
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
54
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
export type { AppWebhookDeliveryEnvelope } from "./app-envelope.js";
|
|
2
|
+
export { createAppWebhookDeliveryEnvelope, isAppWebhookDeliveryEnvelope } from "./app-envelope.js";
|
|
1
3
|
export type { ExternalWebhookEventContract, WebhookSubscriptionEventInput, } from "./contracts.js";
|
|
2
4
|
export { assertWebhookSubscriptionCreateEvents, assertWebhookSubscriptionUpdateEvents, prepareExternalWebhookEvent, } from "./contracts.js";
|
|
3
5
|
export type { OutboundWebhookDeliveryEnqueuer, OutboundWebhookEnqueueProvider, ResolveOutboundWebhookDeliveryEnqueuerOptions, } from "./provider.js";
|
|
4
6
|
export { resolveOutboundWebhookDeliveryEnqueuer } from "./provider.js";
|
|
5
|
-
export {
|
|
7
|
+
export type { WebhookSigningKey } from "./security.js";
|
|
8
|
+
export { assertOutboundWebhookEndpointUrl, hashWebhookPayload, redactWebhookHeaders, signWebhookPayload, verifyWebhookPayloadSignature, webhookBodyExcerpt, } from "./security.js";
|
|
6
9
|
export type { CreateSelectedExternalWebhookQueueOptions } from "./selected-queue.js";
|
|
7
10
|
export { createSelectedExternalWebhookQueue, externalContractFromEventMetadata, } from "./selected-queue.js";
|
|
8
11
|
export type { WebhookSubscriptionMutationStore, WebhookSubscriptionService, } from "./subscriptions.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
export { createAppWebhookDeliveryEnvelope, isAppWebhookDeliveryEnvelope } from "./app-envelope.js";
|
|
1
2
|
export { assertWebhookSubscriptionCreateEvents, assertWebhookSubscriptionUpdateEvents, prepareExternalWebhookEvent, } from "./contracts.js";
|
|
2
3
|
export { resolveOutboundWebhookDeliveryEnqueuer } from "./provider.js";
|
|
3
|
-
export { hashWebhookPayload, redactWebhookHeaders, signWebhookPayload, webhookBodyExcerpt, } from "./security.js";
|
|
4
|
+
export { assertOutboundWebhookEndpointUrl, hashWebhookPayload, redactWebhookHeaders, signWebhookPayload, verifyWebhookPayloadSignature, webhookBodyExcerpt, } from "./security.js";
|
|
4
5
|
export { createSelectedExternalWebhookQueue, externalContractFromEventMetadata, } from "./selected-queue.js";
|
|
5
6
|
export { createWebhookSubscriptionService } from "./subscriptions.js";
|
|
6
7
|
export { createWebhookDeliveryWorker } from "./worker.js";
|
package/dist/postgres-store.js
CHANGED
|
@@ -151,6 +151,7 @@ function toWebhookSubscription(row) {
|
|
|
151
151
|
id: row.id,
|
|
152
152
|
url: row.url,
|
|
153
153
|
secret: row.secret,
|
|
154
|
+
keyId: null,
|
|
154
155
|
headers: row.headers ?? null,
|
|
155
156
|
maxRetries: row.maxRetries,
|
|
156
157
|
active: row.active,
|
|
@@ -201,7 +202,7 @@ function requireSubscription(row, operation) {
|
|
|
201
202
|
}
|
|
202
203
|
function pendingAttemptValues(input) {
|
|
203
204
|
return {
|
|
204
|
-
id: newId("webhook_deliveries"),
|
|
205
|
+
id: input.id ?? newId("webhook_deliveries"),
|
|
205
206
|
sourceModule: input.sourceModule,
|
|
206
207
|
sourceEvent: input.sourceEvent,
|
|
207
208
|
sourceEntityModule: input.sourceEntityModule,
|
package/dist/security.d.ts
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
export declare function signWebhookPayload(secret: string, timestamp: string, body: string): string;
|
|
2
|
+
export interface WebhookSigningKey {
|
|
3
|
+
id: string;
|
|
4
|
+
secret: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function verifyWebhookPayloadSignature(input: {
|
|
7
|
+
body: string;
|
|
8
|
+
timestamp: string;
|
|
9
|
+
signature: string;
|
|
10
|
+
keys: readonly WebhookSigningKey[];
|
|
11
|
+
now?: Date;
|
|
12
|
+
toleranceSeconds?: number;
|
|
13
|
+
}): {
|
|
14
|
+
ok: true;
|
|
15
|
+
keyId: string;
|
|
16
|
+
} | {
|
|
17
|
+
ok: false;
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
2
20
|
export declare function hashWebhookPayload(body: string): string;
|
|
21
|
+
export declare function assertOutboundWebhookEndpointUrl(value: string): void;
|
|
3
22
|
export declare function redactWebhookHeaders(headers: Record<string, string> | undefined): Record<string, string> | null;
|
|
4
23
|
export declare function webhookBodyExcerpt(body: unknown, maxBytes?: number): string | null;
|
package/dist/security.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { isIP } from "node:net";
|
|
2
3
|
const DEFAULT_EXCERPT_BYTES = 4 * 1024;
|
|
3
4
|
const REDACTION_MARKER = "[REDACTED]";
|
|
4
5
|
const REDACTED_HEADERS = new Set([
|
|
@@ -53,9 +54,45 @@ const PHONE_PATTERN = /\+?\d[\d\s().-]{6,}\d/g;
|
|
|
53
54
|
export function signWebhookPayload(secret, timestamp, body) {
|
|
54
55
|
return `sha256=${createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex")}`;
|
|
55
56
|
}
|
|
57
|
+
export function verifyWebhookPayloadSignature(input) {
|
|
58
|
+
const timestampSeconds = Number(input.timestamp);
|
|
59
|
+
if (!Number.isInteger(timestampSeconds))
|
|
60
|
+
return { ok: false, reason: "invalid_timestamp" };
|
|
61
|
+
const nowSeconds = Math.floor((input.now ?? new Date()).getTime() / 1_000);
|
|
62
|
+
const tolerance = input.toleranceSeconds ?? 300;
|
|
63
|
+
if (Math.abs(nowSeconds - timestampSeconds) > tolerance) {
|
|
64
|
+
return { ok: false, reason: "timestamp_outside_tolerance" };
|
|
65
|
+
}
|
|
66
|
+
for (const key of input.keys) {
|
|
67
|
+
if (signWebhookPayload(key.secret, input.timestamp, input.body) === input.signature) {
|
|
68
|
+
return { ok: true, keyId: key.id };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { ok: false, reason: "signature_mismatch" };
|
|
72
|
+
}
|
|
56
73
|
export function hashWebhookPayload(body) {
|
|
57
74
|
return createHash("sha256").update(body).digest("hex");
|
|
58
75
|
}
|
|
76
|
+
export function assertOutboundWebhookEndpointUrl(value) {
|
|
77
|
+
const url = new URL(value);
|
|
78
|
+
if (url.protocol !== "https:") {
|
|
79
|
+
throw new Error(`Webhook URL must use HTTPS: ${value}`);
|
|
80
|
+
}
|
|
81
|
+
const hostname = url.hostname.toLowerCase();
|
|
82
|
+
const address = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
83
|
+
if (hostname === "localhost" ||
|
|
84
|
+
hostname.endsWith(".localhost") ||
|
|
85
|
+
hostname === "metadata.google.internal") {
|
|
86
|
+
throw new Error(`Webhook URL host is not allowed: ${hostname}`);
|
|
87
|
+
}
|
|
88
|
+
const ipVersion = isIP(address);
|
|
89
|
+
if (ipVersion === 4 && isPrivateIpv4(address)) {
|
|
90
|
+
throw new Error(`Webhook URL IP is not allowed: ${address}`);
|
|
91
|
+
}
|
|
92
|
+
if (ipVersion === 6 && isPrivateIpv6(address)) {
|
|
93
|
+
throw new Error(`Webhook URL IP is not allowed: ${address}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
59
96
|
export function redactWebhookHeaders(headers) {
|
|
60
97
|
if (!headers)
|
|
61
98
|
return null;
|
|
@@ -109,3 +146,20 @@ function redactValue(value) {
|
|
|
109
146
|
function redactString(value) {
|
|
110
147
|
return value.replace(EMAIL_PATTERN, REDACTION_MARKER).replace(PHONE_PATTERN, REDACTION_MARKER);
|
|
111
148
|
}
|
|
149
|
+
function isPrivateIpv4(value) {
|
|
150
|
+
const [a = 0, b = 0] = value.split(".").map((part) => Number(part));
|
|
151
|
+
return (a === 10 ||
|
|
152
|
+
a === 127 ||
|
|
153
|
+
(a === 169 && b === 254) ||
|
|
154
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
155
|
+
(a === 192 && b === 168) ||
|
|
156
|
+
a === 0);
|
|
157
|
+
}
|
|
158
|
+
function isPrivateIpv6(value) {
|
|
159
|
+
const normalized = value.toLowerCase();
|
|
160
|
+
return (normalized === "::1" ||
|
|
161
|
+
normalized === "::" ||
|
|
162
|
+
normalized.startsWith("fc") ||
|
|
163
|
+
normalized.startsWith("fd") ||
|
|
164
|
+
normalized.startsWith("fe80:"));
|
|
165
|
+
}
|
package/dist/selected-queue.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { isExternalWebhookPayloadSchema } from "@voyant-travel/core/project";
|
|
2
|
+
import { newId } from "@voyant-travel/db/lib/typeid";
|
|
3
|
+
import { createAppWebhookDeliveryEnvelope } from "./app-envelope.js";
|
|
2
4
|
import { prepareExternalWebhookEvent } from "./contracts.js";
|
|
3
5
|
import { hashWebhookPayload, redactWebhookHeaders, webhookBodyExcerpt } from "./security.js";
|
|
4
6
|
export function createSelectedExternalWebhookQueue(options) {
|
|
@@ -12,11 +14,28 @@ export function createSelectedExternalWebhookQueue(options) {
|
|
|
12
14
|
throw new Error(`Event "${input.name}" is not in the selected external catalog.`);
|
|
13
15
|
}
|
|
14
16
|
const event = prepareExternalWebhookEvent(input, contract);
|
|
15
|
-
const body = JSON.stringify(event);
|
|
16
17
|
const subscriptions = await options.store.listActiveSubscriptions(event.name);
|
|
17
|
-
return Promise.all(subscriptions
|
|
18
|
+
return Promise.all(subscriptions
|
|
19
|
+
.filter((subscription) => !subscription.app || subscription.app.eventVersion === contract.eventVersion)
|
|
20
|
+
.map(async (subscription) => {
|
|
21
|
+
const deliveryId = newId("webhook_deliveries");
|
|
18
22
|
const idempotencyKey = `graph-webhook:${eventId}:${subscription.id}`;
|
|
23
|
+
const payload = subscription.app
|
|
24
|
+
? createAppWebhookDeliveryEnvelope({
|
|
25
|
+
deliveryId,
|
|
26
|
+
installationId: subscription.app.installationId,
|
|
27
|
+
appId: subscription.app.appId,
|
|
28
|
+
event,
|
|
29
|
+
contract,
|
|
30
|
+
deliveredAt: now(),
|
|
31
|
+
attemptNumber: 1,
|
|
32
|
+
maxRetries: subscription.maxRetries,
|
|
33
|
+
idempotencyKey,
|
|
34
|
+
})
|
|
35
|
+
: event;
|
|
36
|
+
const body = JSON.stringify(payload);
|
|
19
37
|
const enqueued = await options.store.enqueueAttempt({
|
|
38
|
+
id: deliveryId,
|
|
20
39
|
sourceModule: stringMetadata(input, "graphEventSourceModule") ?? "graph-outbound-webhooks",
|
|
21
40
|
sourceEvent: event.name,
|
|
22
41
|
...sourceEntity(event.data),
|
|
@@ -34,7 +53,7 @@ export function createSelectedExternalWebhookQueue(options) {
|
|
|
34
53
|
}) ?? {},
|
|
35
54
|
requestBodyHash: hashWebhookPayload(body),
|
|
36
55
|
requestBodyExcerpt: webhookBodyExcerpt(body),
|
|
37
|
-
requestPayload:
|
|
56
|
+
requestPayload: payload,
|
|
38
57
|
deliveryContract: contract,
|
|
39
58
|
attemptNumber: 1,
|
|
40
59
|
parentDeliveryId: null,
|
package/dist/types.d.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import type { EventEnvelope } from "@voyant-travel/core";
|
|
2
2
|
import type { InfraWebhookDelivery } from "@voyant-travel/db/schema/infra";
|
|
3
|
+
import type { AppWebhookDeliveryEnvelope } from "./app-envelope.js";
|
|
3
4
|
import type { ExternalWebhookEventContract } from "./contracts.js";
|
|
4
5
|
export interface WebhookSubscription {
|
|
5
6
|
id: string;
|
|
6
7
|
url: string;
|
|
7
8
|
secret: string;
|
|
9
|
+
keyId?: string | null;
|
|
8
10
|
headers: Record<string, string> | null;
|
|
9
11
|
maxRetries: number;
|
|
10
12
|
active: boolean;
|
|
13
|
+
app?: {
|
|
14
|
+
installationId: string;
|
|
15
|
+
appId: string;
|
|
16
|
+
eventVersion: string;
|
|
17
|
+
};
|
|
11
18
|
}
|
|
12
19
|
export interface EnqueueWebhookAttemptInput {
|
|
20
|
+
id?: string;
|
|
13
21
|
sourceModule: string;
|
|
14
22
|
sourceEvent: string;
|
|
15
23
|
sourceEntityModule: string | null;
|
|
@@ -20,7 +28,7 @@ export interface EnqueueWebhookAttemptInput {
|
|
|
20
28
|
requestHeaders: Record<string, string>;
|
|
21
29
|
requestBodyHash: string;
|
|
22
30
|
requestBodyExcerpt: string | null;
|
|
23
|
-
requestPayload: EventEnvelope;
|
|
31
|
+
requestPayload: EventEnvelope | AppWebhookDeliveryEnvelope;
|
|
24
32
|
deliveryContract: ExternalWebhookEventContract;
|
|
25
33
|
attemptNumber: number;
|
|
26
34
|
parentDeliveryId: string | null;
|
package/dist/worker.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { isExternalWebhookPayloadSchema } from "@voyant-travel/core/project";
|
|
2
|
-
import {
|
|
2
|
+
import { newId } from "@voyant-travel/db/lib/typeid";
|
|
3
|
+
import { createAppWebhookDeliveryEnvelope, isAppWebhookDeliveryEnvelope, } from "./app-envelope.js";
|
|
4
|
+
import { assertOutboundWebhookEndpointUrl, hashWebhookPayload, redactWebhookHeaders, signWebhookPayload, webhookBodyExcerpt, } from "./security.js";
|
|
3
5
|
const DEFAULT_BASE_DELAY_MS = 1_000;
|
|
4
6
|
const DEFAULT_MAX_DELAY_MS = 60_000;
|
|
5
7
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -45,7 +47,7 @@ export function createWebhookDeliveryWorker(options) {
|
|
|
45
47
|
if (!subscription?.active) {
|
|
46
48
|
return abandon(delivery, startedAt, "webhook subscription is unavailable or inactive");
|
|
47
49
|
}
|
|
48
|
-
const body = JSON.stringify(hydrated.
|
|
50
|
+
const body = JSON.stringify(hydrated.payload);
|
|
49
51
|
if (hashWebhookPayload(body) !== delivery.requestBodyHash) {
|
|
50
52
|
return abandon(delivery, startedAt, "persisted webhook payload hash does not match");
|
|
51
53
|
}
|
|
@@ -67,7 +69,7 @@ export function createWebhookDeliveryWorker(options) {
|
|
|
67
69
|
}
|
|
68
70
|
const mayRetry = result.retryable && delivery.attemptNumber <= subscription.maxRetries;
|
|
69
71
|
if (mayRetry) {
|
|
70
|
-
const retry = retryInput(delivery, hydrated.
|
|
72
|
+
const retry = retryInput(delivery, hydrated.payload, hydrated.contract, subscription, finishedAt, baseDelayMs, maxDelayMs);
|
|
71
73
|
const persisted = await options.store.completeAndEnqueueRetry({ ...completion, status: "failed" }, retry);
|
|
72
74
|
const outcome = {
|
|
73
75
|
status: "retry_scheduled",
|
|
@@ -114,16 +116,32 @@ export function createWebhookDeliveryWorker(options) {
|
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
118
|
function hydrateAttempt(delivery) {
|
|
117
|
-
if (!isEventEnvelope(delivery.requestPayload)) {
|
|
118
|
-
return { ok: false, reason: "pending webhook delivery has no complete request payload" };
|
|
119
|
-
}
|
|
120
119
|
if (!isExternalContract(delivery.deliveryContract)) {
|
|
121
120
|
return { ok: false, reason: "pending webhook delivery has no valid contract snapshot" };
|
|
122
121
|
}
|
|
122
|
+
if (isAppWebhookDeliveryEnvelope(delivery.requestPayload)) {
|
|
123
|
+
if (delivery.requestPayload.event.type !== delivery.deliveryContract.eventType) {
|
|
124
|
+
return { ok: false, reason: "persisted webhook payload and contract event types differ" };
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
event: eventFromAppEnvelope(delivery.requestPayload),
|
|
129
|
+
payload: delivery.requestPayload,
|
|
130
|
+
contract: delivery.deliveryContract,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (!isEventEnvelope(delivery.requestPayload)) {
|
|
134
|
+
return { ok: false, reason: "pending webhook delivery has no complete request payload" };
|
|
135
|
+
}
|
|
123
136
|
if (delivery.requestPayload.name !== delivery.deliveryContract.eventType) {
|
|
124
137
|
return { ok: false, reason: "persisted webhook payload and contract event types differ" };
|
|
125
138
|
}
|
|
126
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
event: delivery.requestPayload,
|
|
142
|
+
payload: delivery.requestPayload,
|
|
143
|
+
contract: delivery.deliveryContract,
|
|
144
|
+
};
|
|
127
145
|
}
|
|
128
146
|
function isEventEnvelope(value) {
|
|
129
147
|
return (isRecord(value) &&
|
|
@@ -138,8 +156,16 @@ function isExternalContract(value) {
|
|
|
138
156
|
typeof value.eventVersion === "string" &&
|
|
139
157
|
isExternalWebhookPayloadSchema(value.payloadSchema));
|
|
140
158
|
}
|
|
159
|
+
function eventFromAppEnvelope(envelope) {
|
|
160
|
+
return {
|
|
161
|
+
name: envelope.event.type,
|
|
162
|
+
data: envelope.payload,
|
|
163
|
+
emittedAt: envelope.event.occurredAt,
|
|
164
|
+
metadata: envelope.metadata,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
141
167
|
async function dispatch(fetchImpl, url, headers, body, timeoutMs) {
|
|
142
|
-
|
|
168
|
+
assertOutboundWebhookEndpointUrl(url);
|
|
143
169
|
const controller = new AbortController();
|
|
144
170
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
145
171
|
try {
|
|
@@ -186,9 +212,27 @@ async function dispatch(fetchImpl, url, headers, body, timeoutMs) {
|
|
|
186
212
|
clearTimeout(timeout);
|
|
187
213
|
}
|
|
188
214
|
}
|
|
189
|
-
function retryInput(delivery,
|
|
215
|
+
function retryInput(delivery, payload, contract, subscription, now, baseDelayMs, maxDelayMs) {
|
|
190
216
|
const nextAttempt = delivery.attemptNumber + 1;
|
|
217
|
+
const deliveryId = newId("webhook_deliveries");
|
|
218
|
+
const retryPayload = isAppWebhookDeliveryEnvelope(payload)
|
|
219
|
+
? createAppWebhookDeliveryEnvelope({
|
|
220
|
+
deliveryId,
|
|
221
|
+
installationId: payload.installationId,
|
|
222
|
+
appId: payload.appId,
|
|
223
|
+
event: eventFromAppEnvelope(payload),
|
|
224
|
+
contract,
|
|
225
|
+
deliveredAt: now,
|
|
226
|
+
attemptNumber: nextAttempt,
|
|
227
|
+
maxRetries: subscription.maxRetries,
|
|
228
|
+
idempotencyKey: delivery.idempotencyKey ?? delivery.id,
|
|
229
|
+
originalDeliveryId: payload.attempt.originalDeliveryId ?? payload.deliveryId,
|
|
230
|
+
parentDeliveryId: delivery.id,
|
|
231
|
+
})
|
|
232
|
+
: payload;
|
|
233
|
+
const body = JSON.stringify(retryPayload);
|
|
191
234
|
return {
|
|
235
|
+
id: deliveryId,
|
|
192
236
|
sourceModule: delivery.sourceModule,
|
|
193
237
|
sourceEvent: delivery.sourceEvent,
|
|
194
238
|
sourceEntityModule: delivery.sourceEntityModule,
|
|
@@ -199,7 +243,7 @@ function retryInput(delivery, event, contract, subscription, body, now, baseDela
|
|
|
199
243
|
requestHeaders: delivery.requestHeaders ?? {},
|
|
200
244
|
requestBodyHash: hashWebhookPayload(body),
|
|
201
245
|
requestBodyExcerpt: webhookBodyExcerpt(body),
|
|
202
|
-
requestPayload:
|
|
246
|
+
requestPayload: retryPayload,
|
|
203
247
|
deliveryContract: contract,
|
|
204
248
|
attemptNumber: nextAttempt,
|
|
205
249
|
parentDeliveryId: delivery.id,
|
|
@@ -232,6 +276,13 @@ function signedHeaders(subscription, event, contract, idempotencyKey, timestamp,
|
|
|
232
276
|
"x-voyant-event-contract": contract.eventId,
|
|
233
277
|
"x-voyant-event-version": contract.eventVersion,
|
|
234
278
|
"x-voyant-timestamp": timestamp,
|
|
279
|
+
...(subscription.keyId ? { "x-voyant-key-id": subscription.keyId } : {}),
|
|
280
|
+
...(subscription.app
|
|
281
|
+
? {
|
|
282
|
+
"x-voyant-app-id": subscription.app.appId,
|
|
283
|
+
"x-voyant-installation-id": subscription.app.installationId,
|
|
284
|
+
}
|
|
285
|
+
: {}),
|
|
235
286
|
"x-voyant-signature": signWebhookPayload(subscription.secret, timestamp, body),
|
|
236
287
|
};
|
|
237
288
|
}
|
|
@@ -302,12 +353,6 @@ function positiveInteger(value, fallback) {
|
|
|
302
353
|
function boundedMessage(message) {
|
|
303
354
|
return message.slice(0, MAX_ERROR_MESSAGE_LENGTH);
|
|
304
355
|
}
|
|
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
356
|
function stringMetadata(event, key) {
|
|
312
357
|
const value = event.metadata?.[key];
|
|
313
358
|
return typeof value === "string" && value.length > 0 ? value : null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyant-travel/webhook-delivery",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Durable, policy-aware outbound webhook delivery for Voyant Node deployments.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"drizzle-orm": "^0.45.2",
|
|
22
|
-
"@voyant-travel/core": "^0.
|
|
23
|
-
"@voyant-travel/db": "^0.114.
|
|
22
|
+
"@voyant-travel/core": "^0.125.0",
|
|
23
|
+
"@voyant-travel/db": "^0.114.10"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/node": "^25.5.2",
|