@shipfox/api-integration-webhook 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +22 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +5 -0
- package/dist/constants.js.map +1 -1
- package/dist/core/webhook-processor.d.ts +13 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +110 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/inbound.d.ts +2 -0
- package/dist/presentation/routes/inbound.d.ts.map +1 -1
- package/dist/presentation/routes/inbound.js +72 -42
- package/dist/presentation/routes/inbound.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +8 -6
- package/src/constants.ts +6 -0
- package/src/core/webhook-processor.test.ts +87 -0
- package/src/core/webhook-processor.ts +128 -0
- package/src/index.ts +9 -0
- package/src/presentation/routes/inbound.test.ts +8 -3
- package/src/presentation/routes/inbound.ts +94 -44
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-integration-webhook",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "6.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -22,19 +22,20 @@
|
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@fastify/formbody": "^8.0.2",
|
|
26
25
|
"fastify-plugin": "^5.1.0",
|
|
27
26
|
"zod": "^4.4.3",
|
|
28
|
-
"@shipfox/api-auth-context": "
|
|
29
|
-
"@shipfox/api-integration-core-dto": "
|
|
30
|
-
"@shipfox/api-integration-webhook-dto": "
|
|
27
|
+
"@shipfox/api-auth-context": "6.0.0",
|
|
28
|
+
"@shipfox/api-integration-core-dto": "6.0.0",
|
|
29
|
+
"@shipfox/api-integration-webhook-dto": "6.0.0",
|
|
31
30
|
"@shipfox/config": "1.2.2",
|
|
32
|
-
"@shipfox/node-fastify": "0.2.
|
|
31
|
+
"@shipfox/node-fastify": "0.2.4",
|
|
32
|
+
"@shipfox/node-opentelemetry": "0.5.2"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"fastify": "^5.3.3",
|
|
36
36
|
"fastify-type-provider-zod": "^6.0.0",
|
|
37
37
|
"@shipfox/biome": "1.8.2",
|
|
38
|
+
"@shipfox/depcruise": "1.0.2",
|
|
38
39
|
"@shipfox/swc": "1.2.6",
|
|
39
40
|
"@shipfox/ts-config": "1.3.8",
|
|
40
41
|
"@shipfox/typescript": "1.1.7",
|
|
@@ -44,6 +45,7 @@
|
|
|
44
45
|
"build": "shipfox-swc",
|
|
45
46
|
"check": "shipfox-biome-check",
|
|
46
47
|
"check:fix": "shipfox-biome-check --write",
|
|
48
|
+
"depcruise": "shipfox-depcruise",
|
|
47
49
|
"test": "shipfox-vitest-run",
|
|
48
50
|
"test:watch": "shipfox-vitest-watch",
|
|
49
51
|
"type": "shipfox-tsc-check",
|
package/src/constants.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export const WEBHOOK_INBOUND_BODY_LIMIT = 1 * 1024 * 1024;
|
|
2
2
|
|
|
3
|
+
export const WEBHOOK_ACCEPTED_CONTENT_TYPES = [
|
|
4
|
+
'application/json',
|
|
5
|
+
'application/x-www-form-urlencoded',
|
|
6
|
+
'text/plain',
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
3
9
|
export const WEBHOOK_FORWARDED_HEADERS = [
|
|
4
10
|
'content-type',
|
|
5
11
|
'user-agent',
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
createStoredWebhookRequest,
|
|
4
|
+
type IntegrationConnection,
|
|
5
|
+
} from '@shipfox/api-integration-core-dto';
|
|
6
|
+
import {createGenericWebhookProcessor} from './webhook-processor.js';
|
|
7
|
+
|
|
8
|
+
function fakeConnection(overrides: Partial<IntegrationConnection> = {}): IntegrationConnection {
|
|
9
|
+
const now = new Date();
|
|
10
|
+
return {
|
|
11
|
+
id: randomUUID(),
|
|
12
|
+
workspaceId: randomUUID(),
|
|
13
|
+
provider: 'webhook',
|
|
14
|
+
externalAccountId: 'stripe-prod',
|
|
15
|
+
slug: 'stripe_prod',
|
|
16
|
+
displayName: 'Stripe Production',
|
|
17
|
+
lifecycleStatus: 'active',
|
|
18
|
+
createdAt: now,
|
|
19
|
+
updatedAt: now,
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function genericWebhookRequest(connectionId: string) {
|
|
25
|
+
return createStoredWebhookRequest({
|
|
26
|
+
requestId: randomUUID(),
|
|
27
|
+
routeId: 'webhook.connection',
|
|
28
|
+
receivedAt: '2026-07-20T10:30:00.123Z',
|
|
29
|
+
rawQueryString: 'mode=test&tag=one&tag=two',
|
|
30
|
+
headers: {
|
|
31
|
+
'content-type': 'application/json',
|
|
32
|
+
'x-delivery-id': 'delivery-1',
|
|
33
|
+
authorization: '[redacted]',
|
|
34
|
+
},
|
|
35
|
+
body: new TextEncoder().encode('{"ok":true}'),
|
|
36
|
+
connectionId,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('GenericWebhookProcessor', () => {
|
|
41
|
+
it.each([
|
|
42
|
+
['a missing connection', undefined],
|
|
43
|
+
['an inactive connection', fakeConnection({lifecycleStatus: 'disabled'})],
|
|
44
|
+
])('discards queued delivery for %s', async (_description, connection) => {
|
|
45
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
46
|
+
const processor = createGenericWebhookProcessor({
|
|
47
|
+
coreDb: () => ({transaction: (callback) => callback({})}),
|
|
48
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
|
|
49
|
+
publishIntegrationEventReceived,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const result = await processor.process(genericWebhookRequest(randomUUID()));
|
|
53
|
+
|
|
54
|
+
expect(result).toEqual({outcome: 'discarded', reason: 'connection_unavailable'});
|
|
55
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('publishes the compatible generic event envelope', async () => {
|
|
59
|
+
const connection = fakeConnection();
|
|
60
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
61
|
+
const processor = createGenericWebhookProcessor({
|
|
62
|
+
coreDb: () => ({transaction: (callback) => callback({})}),
|
|
63
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
|
|
64
|
+
publishIntegrationEventReceived,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = await processor.process(genericWebhookRequest(connection.id));
|
|
68
|
+
|
|
69
|
+
expect(result).toEqual({outcome: 'processed', deliveryId: 'delivery-1'});
|
|
70
|
+
expect(publishIntegrationEventReceived).toHaveBeenCalledWith({
|
|
71
|
+
tx: {},
|
|
72
|
+
event: expect.objectContaining({
|
|
73
|
+
deliveryId: 'delivery-1',
|
|
74
|
+
payload: {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: {
|
|
77
|
+
'content-type': 'application/json',
|
|
78
|
+
'x-delivery-id': 'delivery-1',
|
|
79
|
+
authorization: '[redacted]',
|
|
80
|
+
},
|
|
81
|
+
query: {mode: 'test', tag: ['one', 'two']},
|
|
82
|
+
body: {ok: true},
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {
|
|
3
|
+
decodeWebhookBody,
|
|
4
|
+
type GetIntegrationConnectionByIdFn,
|
|
5
|
+
type IntegrationConnection,
|
|
6
|
+
type IntegrationTx,
|
|
7
|
+
type PublishIntegrationEventReceivedFn,
|
|
8
|
+
type StoredWebhookRequest,
|
|
9
|
+
type WebhookProcessingResult,
|
|
10
|
+
} from '@shipfox/api-integration-core-dto';
|
|
11
|
+
import {WEBHOOK_PROVIDER, WEBHOOK_RECEIVED_EVENT} from '@shipfox/api-integration-webhook-dto';
|
|
12
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
13
|
+
import {redactHeaders, WEBHOOK_ACCEPTED_CONTENT_TYPES} from '#constants.js';
|
|
14
|
+
|
|
15
|
+
const [JSON_CONTENT_TYPE, FORM_CONTENT_TYPE, TEXT_CONTENT_TYPE] = WEBHOOK_ACCEPTED_CONTENT_TYPES;
|
|
16
|
+
|
|
17
|
+
export interface CreateGenericWebhookProcessorOptions {
|
|
18
|
+
coreDb: () => {
|
|
19
|
+
transaction<T>(callback: (tx: IntegrationTx) => Promise<T>): Promise<T>;
|
|
20
|
+
};
|
|
21
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
22
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GenericWebhookProcessor {
|
|
26
|
+
process(
|
|
27
|
+
request: StoredWebhookRequest,
|
|
28
|
+
connection?: IntegrationConnection | undefined,
|
|
29
|
+
): Promise<WebhookProcessingResult>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createGenericWebhookProcessor(
|
|
33
|
+
options: CreateGenericWebhookProcessorOptions,
|
|
34
|
+
): GenericWebhookProcessor {
|
|
35
|
+
return {
|
|
36
|
+
process: (request, connection) => processWebhookRequest(options, request, connection),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function processWebhookRequest(
|
|
41
|
+
options: CreateGenericWebhookProcessorOptions,
|
|
42
|
+
request: StoredWebhookRequest,
|
|
43
|
+
knownConnection?: IntegrationConnection | undefined,
|
|
44
|
+
): Promise<WebhookProcessingResult> {
|
|
45
|
+
if (request.route_id !== 'webhook.connection') {
|
|
46
|
+
throw new Error(`Generic webhook processor cannot process ${request.route_id} requests`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const connection =
|
|
50
|
+
knownConnection ??
|
|
51
|
+
(await options.getIntegrationConnectionById(request.path_parameters.connection_id));
|
|
52
|
+
if (
|
|
53
|
+
!connection ||
|
|
54
|
+
connection.provider !== WEBHOOK_PROVIDER ||
|
|
55
|
+
connection.lifecycleStatus !== 'active'
|
|
56
|
+
) {
|
|
57
|
+
return {outcome: 'discarded', reason: 'connection_unavailable'};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const deliveryId = request.headers['x-delivery-id'] ?? request.request_id;
|
|
61
|
+
const body = parseWebhookBody(request);
|
|
62
|
+
if (!body.success) {
|
|
63
|
+
logger().warn({deliveryId, err: body.error}, 'generic webhook payload parsing failed');
|
|
64
|
+
return {outcome: 'discarded', reason: 'malformed_payload', deliveryId};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await options.coreDb().transaction(async (tx) => {
|
|
68
|
+
await options.publishIntegrationEventReceived({
|
|
69
|
+
tx,
|
|
70
|
+
event: {
|
|
71
|
+
provider: WEBHOOK_PROVIDER,
|
|
72
|
+
source: connection.slug,
|
|
73
|
+
event: WEBHOOK_RECEIVED_EVENT,
|
|
74
|
+
workspaceId: connection.workspaceId,
|
|
75
|
+
connectionId: connection.id,
|
|
76
|
+
connectionName: connection.displayName,
|
|
77
|
+
deliveryId,
|
|
78
|
+
receivedAt: request.received_at,
|
|
79
|
+
payload: {
|
|
80
|
+
method: request.method,
|
|
81
|
+
headers: redactHeaders(request.headers),
|
|
82
|
+
query: parseWebhookQuery(request.raw_query_string),
|
|
83
|
+
body: body.value,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {outcome: 'processed', deliveryId};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseWebhookQuery(rawQueryString: string): Record<string, string | string[]> {
|
|
93
|
+
const query: Record<string, string | string[]> = {};
|
|
94
|
+
|
|
95
|
+
for (const [name, value] of new URLSearchParams(rawQueryString)) {
|
|
96
|
+
const existingValue = query[name];
|
|
97
|
+
if (existingValue === undefined) {
|
|
98
|
+
query[name] = value;
|
|
99
|
+
} else if (Array.isArray(existingValue)) {
|
|
100
|
+
existingValue.push(value);
|
|
101
|
+
} else {
|
|
102
|
+
query[name] = [existingValue, value];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return query;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseWebhookBody(
|
|
110
|
+
request: StoredWebhookRequest,
|
|
111
|
+
): {success: true; value: unknown} | {success: false; error?: unknown} {
|
|
112
|
+
const body = Buffer.from(decodeWebhookBody(request.body));
|
|
113
|
+
const contentType = request.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase();
|
|
114
|
+
|
|
115
|
+
if (contentType === JSON_CONTENT_TYPE) {
|
|
116
|
+
try {
|
|
117
|
+
return {success: true, value: JSON.parse(body.toString('utf8'))};
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return {success: false, error};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (contentType === FORM_CONTENT_TYPE) {
|
|
123
|
+
return {success: true, value: Object.fromEntries(new URLSearchParams(body.toString('utf8')))};
|
|
124
|
+
}
|
|
125
|
+
if (contentType === TEXT_CONTENT_TYPE) return {success: true, value: body.toString('utf8')};
|
|
126
|
+
|
|
127
|
+
return {success: false};
|
|
128
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,10 +9,16 @@ import type {
|
|
|
9
9
|
} from '@shipfox/api-integration-core-dto';
|
|
10
10
|
import {WEBHOOK_PROVIDER} from '@shipfox/api-integration-webhook-dto';
|
|
11
11
|
import {config} from '#config.js';
|
|
12
|
+
import {createGenericWebhookProcessor} from '#core/webhook-processor.js';
|
|
12
13
|
import {createWebhookConnectionRoutes} from '#presentation/routes/connections.js';
|
|
13
14
|
import {createWebhookInboundRoutes} from '#presentation/routes/inbound.js';
|
|
14
15
|
|
|
15
16
|
export {redactHeaders, WEBHOOK_FORWARDED_HEADERS, WEBHOOK_INBOUND_BODY_LIMIT} from '#constants.js';
|
|
17
|
+
export type {
|
|
18
|
+
CreateGenericWebhookProcessorOptions,
|
|
19
|
+
GenericWebhookProcessor,
|
|
20
|
+
} from '#core/webhook-processor.js';
|
|
21
|
+
export {createGenericWebhookProcessor} from '#core/webhook-processor.js';
|
|
16
22
|
|
|
17
23
|
export interface CreateWebhookIntegrationProviderOptions {
|
|
18
24
|
coreDb: () => {
|
|
@@ -29,6 +35,7 @@ export interface CreateWebhookIntegrationProviderOptions {
|
|
|
29
35
|
|
|
30
36
|
export function createWebhookIntegrationProvider(options: CreateWebhookIntegrationProviderOptions) {
|
|
31
37
|
const baseUrl = options.baseUrl ?? config.WEBHOOK_PUBLIC_URL;
|
|
38
|
+
const webhookProcessor = createGenericWebhookProcessor(options);
|
|
32
39
|
return {
|
|
33
40
|
provider: WEBHOOK_PROVIDER,
|
|
34
41
|
displayName: 'Webhook',
|
|
@@ -46,7 +53,9 @@ export function createWebhookIntegrationProvider(options: CreateWebhookIntegrati
|
|
|
46
53
|
coreDb: options.coreDb,
|
|
47
54
|
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
48
55
|
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
56
|
+
processor: webhookProcessor,
|
|
49
57
|
}),
|
|
50
58
|
],
|
|
59
|
+
webhookProcessors: [{routeIds: ['webhook.connection'] as const, processor: webhookProcessor}],
|
|
51
60
|
};
|
|
52
61
|
}
|
|
@@ -54,11 +54,14 @@ describe('webhook inbound route', () => {
|
|
|
54
54
|
|
|
55
55
|
it('publishes a JSON delivery envelope', async () => {
|
|
56
56
|
const connection = fakeConnection();
|
|
57
|
-
const {app, publishIntegrationEventReceived} =
|
|
57
|
+
const {app, publishIntegrationEventReceived, getIntegrationConnectionById} =
|
|
58
|
+
await createTestApp({
|
|
59
|
+
connection,
|
|
60
|
+
});
|
|
58
61
|
|
|
59
62
|
const res = await app.inject({
|
|
60
63
|
method: 'POST',
|
|
61
|
-
url: `/webhook/${connection.id}?mode=test`,
|
|
64
|
+
url: `/webhook/${connection.id}?mode=test&tag=one&tag=two`,
|
|
62
65
|
headers: {
|
|
63
66
|
'content-type': 'application/json',
|
|
64
67
|
'x-delivery-id': 'evt-1',
|
|
@@ -80,7 +83,7 @@ describe('webhook inbound route', () => {
|
|
|
80
83
|
deliveryId: 'evt-1',
|
|
81
84
|
payload: {
|
|
82
85
|
method: 'POST',
|
|
83
|
-
query: {mode: 'test'},
|
|
86
|
+
query: {mode: 'test', tag: ['one', 'two']},
|
|
84
87
|
body: {ok: true},
|
|
85
88
|
headers: {
|
|
86
89
|
'content-type': 'application/json',
|
|
@@ -89,6 +92,7 @@ describe('webhook inbound route', () => {
|
|
|
89
92
|
},
|
|
90
93
|
},
|
|
91
94
|
});
|
|
95
|
+
expect(getIntegrationConnectionById).toHaveBeenCalledTimes(1);
|
|
92
96
|
});
|
|
93
97
|
|
|
94
98
|
it('publishes a form delivery envelope', async () => {
|
|
@@ -187,6 +191,7 @@ describe('webhook inbound route', () => {
|
|
|
187
191
|
expect(res.statusCode).toBe(202);
|
|
188
192
|
const deliveryId = publishIntegrationEventReceived.mock.calls[0]?.[0].event.deliveryId;
|
|
189
193
|
expect(deliveryId).toMatch(UUID_RE);
|
|
194
|
+
expect(res.json().delivery_id).toBe(deliveryId);
|
|
190
195
|
});
|
|
191
196
|
|
|
192
197
|
it('rejects an overlong delivery ID header without publishing', async () => {
|
|
@@ -1,36 +1,39 @@
|
|
|
1
|
-
import type {Buffer} from 'node:buffer';
|
|
2
1
|
import {randomUUID} from 'node:crypto';
|
|
3
|
-
import formBodyPlugin from '@fastify/formbody';
|
|
4
2
|
import type {
|
|
5
3
|
GetIntegrationConnectionByIdFn,
|
|
6
4
|
IntegrationTx,
|
|
7
5
|
PublishIntegrationEventReceivedFn,
|
|
8
6
|
} from '@shipfox/api-integration-core-dto';
|
|
9
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createStoredWebhookRequest,
|
|
9
|
+
type StoredWebhookRequest,
|
|
10
|
+
WEBHOOK_MAX_RAW_BODY_BYTES,
|
|
11
|
+
} from '@shipfox/api-integration-core-dto';
|
|
12
|
+
import {WEBHOOK_PROVIDER} from '@shipfox/api-integration-webhook-dto';
|
|
10
13
|
import {ClientError, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
|
|
11
14
|
import type {FastifyPluginAsync} from 'fastify';
|
|
12
15
|
import fp from 'fastify-plugin';
|
|
13
16
|
import {z} from 'zod';
|
|
14
|
-
import {
|
|
17
|
+
import {WEBHOOK_ACCEPTED_CONTENT_TYPES, WEBHOOK_INBOUND_BODY_LIMIT} from '#constants.js';
|
|
18
|
+
import {
|
|
19
|
+
createGenericWebhookProcessor,
|
|
20
|
+
type GenericWebhookProcessor,
|
|
21
|
+
} from '#core/webhook-processor.js';
|
|
15
22
|
|
|
16
23
|
const MAX_DELIVERY_ID_HEADER_LENGTH = 200;
|
|
17
|
-
const acceptedContentTypes = new Set(
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
(_request, body: Buffer, done) => {
|
|
31
|
-
done(null, Object.fromEntries(new URLSearchParams(body.toString('utf8'))));
|
|
32
|
-
},
|
|
33
|
-
);
|
|
24
|
+
const acceptedContentTypes = new Set<string>(WEBHOOK_ACCEPTED_CONTENT_TYPES);
|
|
25
|
+
const rawWebhookBodyPlugin: FastifyPluginAsync = fp((app) => {
|
|
26
|
+
app.removeAllContentTypeParsers();
|
|
27
|
+
for (const contentType of acceptedContentTypes) {
|
|
28
|
+
app.addContentTypeParser(
|
|
29
|
+
contentType,
|
|
30
|
+
{parseAs: 'buffer', bodyLimit: WEBHOOK_INBOUND_BODY_LIMIT},
|
|
31
|
+
(_request, body, done) => {
|
|
32
|
+
done(null, body);
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return Promise.resolve();
|
|
34
37
|
});
|
|
35
38
|
|
|
36
39
|
export interface CreateWebhookInboundRoutesOptions {
|
|
@@ -39,6 +42,7 @@ export interface CreateWebhookInboundRoutesOptions {
|
|
|
39
42
|
};
|
|
40
43
|
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
41
44
|
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
45
|
+
processor?: GenericWebhookProcessor | undefined;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
const inboundParamsSchema = z.object({
|
|
@@ -64,7 +68,12 @@ function deliveryIdFor(header: string | string[] | undefined): string {
|
|
|
64
68
|
return value;
|
|
65
69
|
}
|
|
66
70
|
|
|
71
|
+
function hasDeliveryId(header: string | string[] | undefined): boolean {
|
|
72
|
+
return Boolean(Array.isArray(header) ? header[0] : header);
|
|
73
|
+
}
|
|
74
|
+
|
|
67
75
|
export function createWebhookInboundRoutes(options: CreateWebhookInboundRoutesOptions): RouteGroup {
|
|
76
|
+
const processor = options.processor ?? createGenericWebhookProcessor(options);
|
|
68
77
|
const inboundRoute = defineRoute({
|
|
69
78
|
method: 'POST',
|
|
70
79
|
path: '/:connectionId',
|
|
@@ -94,29 +103,25 @@ export function createWebhookInboundRoutes(options: CreateWebhookInboundRoutesOp
|
|
|
94
103
|
throw new ClientError('Webhook connection not found', 'not-found', {status: 404});
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
await
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
connectionId: connection.id,
|
|
108
|
-
connectionName: connection.displayName,
|
|
109
|
-
deliveryId,
|
|
110
|
-
receivedAt,
|
|
111
|
-
payload: {
|
|
112
|
-
method: request.method,
|
|
113
|
-
headers: redactHeaders(request.headers),
|
|
114
|
-
query: request.query,
|
|
115
|
-
body: request.body,
|
|
116
|
-
},
|
|
106
|
+
const deliveryHeader = request.headers['x-delivery-id'];
|
|
107
|
+
const deliveryId = deliveryIdFor(deliveryHeader);
|
|
108
|
+
const result = await processor.process(
|
|
109
|
+
createGenericStoredWebhookRequest(
|
|
110
|
+
{
|
|
111
|
+
body: request.body,
|
|
112
|
+
connectionId,
|
|
113
|
+
headers: request.headers,
|
|
114
|
+
rawQueryString: request.raw.url?.split('?')[1] ?? '',
|
|
115
|
+
requestId: hasDeliveryId(deliveryHeader) ? randomUUID() : deliveryId,
|
|
117
116
|
},
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
new Date().toISOString(),
|
|
118
|
+
),
|
|
119
|
+
connection,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
if (result.outcome === 'discarded' && result.reason === 'malformed_payload') {
|
|
123
|
+
throw new ClientError('Malformed webhook payload', 'malformed-payload', {status: 400});
|
|
124
|
+
}
|
|
120
125
|
|
|
121
126
|
reply.code(202);
|
|
122
127
|
return {delivery_id: deliveryId};
|
|
@@ -126,7 +131,52 @@ export function createWebhookInboundRoutes(options: CreateWebhookInboundRoutesOp
|
|
|
126
131
|
return {
|
|
127
132
|
prefix: '/webhook',
|
|
128
133
|
auth: [],
|
|
129
|
-
plugins: [
|
|
134
|
+
plugins: [rawWebhookBodyPlugin],
|
|
130
135
|
routes: [inboundRoute],
|
|
131
136
|
};
|
|
132
137
|
}
|
|
138
|
+
|
|
139
|
+
function createGenericStoredWebhookRequest(
|
|
140
|
+
input: {
|
|
141
|
+
body: unknown;
|
|
142
|
+
connectionId: string;
|
|
143
|
+
headers: Record<string, string | string[] | undefined>;
|
|
144
|
+
rawQueryString: string;
|
|
145
|
+
requestId: string;
|
|
146
|
+
},
|
|
147
|
+
receivedAt: string,
|
|
148
|
+
): StoredWebhookRequest {
|
|
149
|
+
if (!(input.body instanceof Uint8Array)) {
|
|
150
|
+
throw new ClientError('Expected raw webhook body', 'invalid-webhook-request', {status: 400});
|
|
151
|
+
}
|
|
152
|
+
if (input.body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {
|
|
153
|
+
throw new ClientError('Webhook request body is too large', 'body-too-large', {status: 413});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
return createStoredWebhookRequest({
|
|
158
|
+
requestId: input.requestId,
|
|
159
|
+
routeId: 'webhook.connection',
|
|
160
|
+
receivedAt,
|
|
161
|
+
rawQueryString: input.rawQueryString,
|
|
162
|
+
headers: webhookHeaders(input.headers),
|
|
163
|
+
body: input.body,
|
|
164
|
+
connectionId: input.connectionId,
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {
|
|
168
|
+
cause: error,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function webhookHeaders(
|
|
174
|
+
headers: Record<string, string | string[] | undefined>,
|
|
175
|
+
): Record<string, string> {
|
|
176
|
+
return Object.fromEntries(
|
|
177
|
+
Object.entries(headers).flatMap(([name, value]) => {
|
|
178
|
+
if (value === undefined) return [];
|
|
179
|
+
return [[name.toLowerCase(), Array.isArray(value) ? value.join(', ') : value]];
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
}
|