@shipfox/api-integration-slack 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 +35 -0
- package/dist/core/signature.d.ts +2 -1
- package/dist/core/signature.d.ts.map +1 -1
- package/dist/core/signature.js +10 -6
- package/dist/core/signature.js.map +1 -1
- package/dist/core/webhook-processor.d.ts +15 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +127 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -6
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/errors.d.ts.map +1 -1
- package/dist/presentation/routes/errors.js +22 -0
- package/dist/presentation/routes/errors.js.map +1 -1
- package/dist/presentation/routes/install.d.ts +6 -1
- package/dist/presentation/routes/install.d.ts.map +1 -1
- package/dist/presentation/routes/install.js +6 -4
- package/dist/presentation/routes/install.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +2 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -1
- package/dist/presentation/routes/webhooks.js +94 -93
- package/dist/presentation/routes/webhooks.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/core/disconnect.test.ts +47 -0
- package/src/core/signature.test.ts +3 -3
- package/src/core/signature.ts +19 -9
- package/src/core/tokens.test.ts +16 -2
- package/src/core/webhook-processor.test.ts +106 -0
- package/src/core/webhook-processor.ts +152 -0
- package/src/index.ts +19 -3
- package/src/presentation/routes/errors.test.ts +25 -0
- package/src/presentation/routes/errors.ts +20 -0
- package/src/presentation/routes/install.test.ts +2 -4
- package/src/presentation/routes/install.ts +18 -3
- package/src/presentation/routes/webhooks.test.ts +18 -0
- package/src/presentation/routes/webhooks.ts +125 -98
- package/test/globalSetup.ts +0 -9
- package/tsconfig.build.tsbuildinfo +1 -1
- package/turbo.json +1 -1
- package/test/api-secrets.d.ts +0 -26
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
slackCallbackQuerySchema,
|
|
8
8
|
slackCallbackResponseSchema,
|
|
9
9
|
} from '@shipfox/api-integration-slack-dto';
|
|
10
|
-
import {requireWorkspaceMembership} from '@shipfox/api-workspaces';
|
|
11
10
|
import {defineRoute, type RouteGroup} from '@shipfox/node-fastify';
|
|
12
11
|
import type {SlackApiClient} from '#api/client.js';
|
|
13
12
|
import {config} from '#config.js';
|
|
@@ -33,6 +32,11 @@ export interface CreateSlackIntegrationRoutesOptions {
|
|
|
33
32
|
) => Promise<IntegrationConnection<'slack'>>;
|
|
34
33
|
disconnectSlackInstallation: (input: {connectionId: string}) => Promise<void>;
|
|
35
34
|
connectionCapabilities: IntegrationCapability[];
|
|
35
|
+
requireActiveWorkspaceMembership?: (input: {
|
|
36
|
+
workspaceId: string;
|
|
37
|
+
userId: string;
|
|
38
|
+
memberships: ReadonlyArray<import('@shipfox/api-auth-context').UserContextMembership>;
|
|
39
|
+
}) => Promise<unknown>;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export function createSlackIntegrationRoutes({
|
|
@@ -42,6 +46,7 @@ export function createSlackIntegrationRoutes({
|
|
|
42
46
|
connectSlackInstallation,
|
|
43
47
|
disconnectSlackInstallation,
|
|
44
48
|
connectionCapabilities,
|
|
49
|
+
requireActiveWorkspaceMembership,
|
|
45
50
|
}: CreateSlackIntegrationRoutesOptions): RouteGroup {
|
|
46
51
|
const installRoute = defineRoute({
|
|
47
52
|
method: 'POST',
|
|
@@ -86,7 +91,8 @@ export function createSlackIntegrationRoutes({
|
|
|
86
91
|
errorDescription: query.error_description,
|
|
87
92
|
sessionUserId: actor.userId,
|
|
88
93
|
sessionMemberships: actor.memberships,
|
|
89
|
-
requireWorkspaceMembership
|
|
94
|
+
requireWorkspaceMembership:
|
|
95
|
+
requireActiveWorkspaceMembership ?? unavailableWorkspaceMembershipCheck,
|
|
90
96
|
});
|
|
91
97
|
}
|
|
92
98
|
const connection = await handleSlackCallback({
|
|
@@ -96,7 +102,8 @@ export function createSlackIntegrationRoutes({
|
|
|
96
102
|
state: query.state,
|
|
97
103
|
sessionUserId: actor.userId,
|
|
98
104
|
sessionMemberships: actor.memberships,
|
|
99
|
-
requireWorkspaceMembership
|
|
105
|
+
requireWorkspaceMembership:
|
|
106
|
+
requireActiveWorkspaceMembership ?? unavailableWorkspaceMembershipCheck,
|
|
100
107
|
getExistingSlackConnection,
|
|
101
108
|
connectSlackInstallation,
|
|
102
109
|
disconnectSlackInstallation,
|
|
@@ -108,6 +115,14 @@ export function createSlackIntegrationRoutes({
|
|
|
108
115
|
return {prefix: '/integrations/slack', routes: [installRoute, callbackApiRoute]};
|
|
109
116
|
}
|
|
110
117
|
|
|
118
|
+
function unavailableWorkspaceMembershipCheck(_input: {
|
|
119
|
+
workspaceId: string;
|
|
120
|
+
userId: string;
|
|
121
|
+
memberships: ReadonlyArray<import('@shipfox/api-auth-context').UserContextMembership>;
|
|
122
|
+
}): Promise<never> {
|
|
123
|
+
return Promise.reject(new Error('Workspaces inter-module client is not configured'));
|
|
124
|
+
}
|
|
125
|
+
|
|
111
126
|
function isSlackOAuthErrorCallback(query: SlackCallbackQueryDto): query is SlackCallbackQueryDto & {
|
|
112
127
|
error: string;
|
|
113
128
|
error_description?: string | undefined;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {createHmac, randomUUID} from 'node:crypto';
|
|
2
2
|
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
3
|
+
import {WEBHOOK_MAX_RAW_BODY_BYTES} from '@shipfox/api-integration-core-dto';
|
|
3
4
|
import {closeApp, createApp} from '@shipfox/node-fastify';
|
|
4
5
|
import {db} from '#db/db.js';
|
|
5
6
|
import {getSlackInstallationByTeamId, upsertSlackInstallation} from '#db/installations.js';
|
|
@@ -161,6 +162,23 @@ describe('Slack webhook routes', () => {
|
|
|
161
162
|
expect(recordDeliveryOnly).not.toHaveBeenCalled();
|
|
162
163
|
});
|
|
163
164
|
|
|
165
|
+
it('rejects a body too large for the shared webhook request before processing it', async () => {
|
|
166
|
+
const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
|
|
167
|
+
const rawBody = 'a'.repeat(WEBHOOK_MAX_RAW_BODY_BYTES + 1);
|
|
168
|
+
|
|
169
|
+
const response = await app.inject({
|
|
170
|
+
method: 'POST',
|
|
171
|
+
url: '/webhooks/integrations/slack/events',
|
|
172
|
+
headers: slackHeaders(rawBody, 'application/json'),
|
|
173
|
+
payload: rawBody,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(response.statusCode).toBe(413);
|
|
177
|
+
expect(response.json()).toEqual({code: 'body-too-large'});
|
|
178
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
179
|
+
expect(recordDeliveryOnly).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
164
182
|
it('publishes a signed event delivery through the route transaction', async () => {
|
|
165
183
|
const {app, connection, publishIntegrationEventReceived} = await createTestApp();
|
|
166
184
|
await seedInstallation(connection);
|
|
@@ -1,20 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
2
|
import type {
|
|
3
3
|
ClaimWebhookDeliveryFn,
|
|
4
4
|
GetIntegrationConnectionByIdFn,
|
|
5
5
|
PublishIntegrationEventReceivedFn,
|
|
6
6
|
RecordDeliveryOnlyFn,
|
|
7
|
+
StoredWebhookRequest,
|
|
8
|
+
WebhookProcessingResult,
|
|
7
9
|
} from '@shipfox/api-integration-core-dto';
|
|
8
10
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from '@shipfox/api-integration-
|
|
12
|
-
import {
|
|
13
|
-
|
|
11
|
+
createStoredWebhookRequest,
|
|
12
|
+
WEBHOOK_MAX_RAW_BODY_BYTES,
|
|
13
|
+
} from '@shipfox/api-integration-core-dto';
|
|
14
|
+
import {
|
|
15
|
+
ClientError,
|
|
16
|
+
createRawBodyPlugin,
|
|
17
|
+
defineRoute,
|
|
18
|
+
type RouteGroup,
|
|
19
|
+
} from '@shipfox/node-fastify';
|
|
14
20
|
import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
|
|
15
|
-
import {
|
|
16
|
-
import {verifySlackSignature} from '#core/signature.js';
|
|
17
|
-
import {handleSlackCommand, handleSlackEvent} from '#core/webhook.js';
|
|
21
|
+
import {createSlackWebhookProcessor, type SlackWebhookProcessor} from '#core/webhook-processor.js';
|
|
18
22
|
|
|
19
23
|
export const SLACK_WEBHOOK_BODY_LIMIT = 1024 * 1024;
|
|
20
24
|
export const SLASH_COMMAND_ACK = {response_type: 'ephemeral', text: 'Working on it.'} as const;
|
|
@@ -34,9 +38,11 @@ export interface CreateSlackWebhookRoutesOptions {
|
|
|
34
38
|
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
35
39
|
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
36
40
|
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
41
|
+
processor?: SlackWebhookProcessor | undefined;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOptions): RouteGroup[] {
|
|
45
|
+
const processor = options.processor ?? createSlackWebhookProcessor(options);
|
|
40
46
|
const eventsRoute = defineRoute({
|
|
41
47
|
method: 'POST',
|
|
42
48
|
path: '/',
|
|
@@ -44,59 +50,25 @@ export function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOption
|
|
|
44
50
|
description: 'Slack Events API receiver.',
|
|
45
51
|
options: {bodyLimit: SLACK_WEBHOOK_BODY_LIMIT},
|
|
46
52
|
handler: async (request, reply) => {
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
53
|
+
const body = rawRequestBody(request.body);
|
|
54
|
+
if (!body) {
|
|
49
55
|
reply.code(400);
|
|
50
56
|
return {error: 'expected raw JSON body'};
|
|
51
57
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
);
|
|
65
|
-
reply.code(400);
|
|
66
|
-
return {error: 'malformed JSON'};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const parsed = slackEventsRequestSchema.safeParse(parsedJson);
|
|
70
|
-
if (!parsed.success) {
|
|
71
|
-
logger().warn(
|
|
72
|
-
{issues: parsed.error.issues},
|
|
73
|
-
'slack events envelope failed schema validation',
|
|
74
|
-
);
|
|
75
|
-
reply.code(200);
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
const eventRequest = parsed.data;
|
|
79
|
-
if (eventRequest.type === 'url_verification') {
|
|
80
|
-
reply.code(200);
|
|
81
|
-
return {challenge: eventRequest.challenge};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
await options.coreDb().transaction(async (tx) => {
|
|
85
|
-
await handleSlackEvent({
|
|
86
|
-
tx,
|
|
87
|
-
deliveryId: eventRequest.event_id,
|
|
88
|
-
envelope: eventRequest,
|
|
89
|
-
claimWebhookDelivery: options.claimWebhookDelivery,
|
|
90
|
-
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
91
|
-
recordDeliveryOnly: options.recordDeliveryOnly,
|
|
92
|
-
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
reply.code(200);
|
|
96
|
-
return null;
|
|
58
|
+
const result = await processor.process(
|
|
59
|
+
createSlackStoredWebhookRequest(
|
|
60
|
+
{
|
|
61
|
+
routeId: 'slack.event',
|
|
62
|
+
body,
|
|
63
|
+
headers: request.headers,
|
|
64
|
+
rawQueryString: rawQueryString(request),
|
|
65
|
+
},
|
|
66
|
+
new Date().toISOString(),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
return sendSlackEventResponse(reply, result);
|
|
97
70
|
},
|
|
98
71
|
});
|
|
99
|
-
|
|
100
72
|
const commandsRoute = defineRoute({
|
|
101
73
|
method: 'POST',
|
|
102
74
|
path: '/',
|
|
@@ -104,37 +76,23 @@ export function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOption
|
|
|
104
76
|
description: 'Slack slash-command receiver.',
|
|
105
77
|
options: {bodyLimit: SLACK_WEBHOOK_BODY_LIMIT},
|
|
106
78
|
handler: async (request, reply) => {
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
79
|
+
const body = rawRequestBody(request.body);
|
|
80
|
+
if (!body) {
|
|
109
81
|
reply.code(400);
|
|
110
82
|
return {error: 'expected raw form body'};
|
|
111
83
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
84
|
+
const result = await processor.process(
|
|
85
|
+
createSlackStoredWebhookRequest(
|
|
86
|
+
{
|
|
87
|
+
routeId: 'slack.command',
|
|
88
|
+
body,
|
|
89
|
+
headers: request.headers,
|
|
90
|
+
rawQueryString: rawQueryString(request),
|
|
91
|
+
},
|
|
92
|
+
new Date().toISOString(),
|
|
93
|
+
),
|
|
119
94
|
);
|
|
120
|
-
|
|
121
|
-
logger().warn({issues: command.error.issues}, 'slack command failed schema validation');
|
|
122
|
-
reply.code(200);
|
|
123
|
-
return SLASH_COMMAND_ACK;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
await options.coreDb().transaction(async (tx) => {
|
|
127
|
-
await handleSlackCommand({
|
|
128
|
-
tx,
|
|
129
|
-
deliveryId: command.data.trigger_id,
|
|
130
|
-
command: command.data,
|
|
131
|
-
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
132
|
-
recordDeliveryOnly: options.recordDeliveryOnly,
|
|
133
|
-
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
reply.code(200);
|
|
137
|
-
return SLASH_COMMAND_ACK;
|
|
95
|
+
return sendSlackCommandResponse(reply, result);
|
|
138
96
|
},
|
|
139
97
|
});
|
|
140
98
|
|
|
@@ -154,20 +112,89 @@ export function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOption
|
|
|
154
112
|
];
|
|
155
113
|
}
|
|
156
114
|
|
|
157
|
-
function rawRequestBody(body: unknown):
|
|
158
|
-
return
|
|
115
|
+
function rawRequestBody(body: unknown): Uint8Array | undefined {
|
|
116
|
+
return body instanceof Uint8Array ? body : undefined;
|
|
159
117
|
}
|
|
160
118
|
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
119
|
+
function rawQueryString(request: {raw: {url?: string | undefined}}): string {
|
|
120
|
+
return request.raw.url?.split('?')[1] ?? '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createSlackStoredWebhookRequest(
|
|
124
|
+
input: {
|
|
125
|
+
routeId: 'slack.event' | 'slack.command';
|
|
126
|
+
body: Uint8Array;
|
|
127
|
+
headers: Record<string, string | string[] | undefined>;
|
|
128
|
+
rawQueryString: string;
|
|
129
|
+
},
|
|
130
|
+
receivedAt: string,
|
|
131
|
+
): StoredWebhookRequest {
|
|
132
|
+
if (input.body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {
|
|
133
|
+
throw new ClientError('Webhook request body is too large', 'body-too-large', {status: 413});
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
return createStoredWebhookRequest({
|
|
137
|
+
requestId: randomUUID(),
|
|
138
|
+
routeId: input.routeId,
|
|
139
|
+
receivedAt,
|
|
140
|
+
rawQueryString: input.rawQueryString,
|
|
141
|
+
headers: slackWebhookHeaders(input.headers),
|
|
142
|
+
body: input.body,
|
|
143
|
+
});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {
|
|
146
|
+
cause: error,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function slackWebhookHeaders(headers: Record<string, string | string[] | undefined>) {
|
|
152
|
+
return Object.fromEntries(
|
|
153
|
+
['content-type', 'x-slack-signature', 'x-slack-request-timestamp'].flatMap((name) => {
|
|
154
|
+
const value = headers[name];
|
|
155
|
+
return typeof value === 'string' ? [[name, value]] : [];
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function sendSlackEventResponse(
|
|
161
|
+
reply: {code(statusCode: number): void},
|
|
162
|
+
result: WebhookProcessingResult,
|
|
163
|
+
) {
|
|
164
|
+
if (result.outcome === 'processed' && 'challenge' in result) {
|
|
165
|
+
reply.code(200);
|
|
166
|
+
return {challenge: result.challenge};
|
|
167
|
+
}
|
|
168
|
+
if (
|
|
169
|
+
result.outcome === 'discarded' &&
|
|
170
|
+
(result.reason === 'invalid_signature' ||
|
|
171
|
+
result.reason === 'missing_required_input' ||
|
|
172
|
+
result.reason === 'stale_at_receipt')
|
|
173
|
+
) {
|
|
174
|
+
reply.code(401);
|
|
175
|
+
return {error: 'invalid signature'};
|
|
176
|
+
}
|
|
177
|
+
if (result.outcome === 'discarded' && result.reason === 'malformed_payload') {
|
|
178
|
+
reply.code(400);
|
|
179
|
+
return {error: 'malformed JSON'};
|
|
180
|
+
}
|
|
181
|
+
reply.code(200);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sendSlackCommandResponse(
|
|
186
|
+
reply: {code(statusCode: number): void},
|
|
187
|
+
result: WebhookProcessingResult,
|
|
188
|
+
) {
|
|
189
|
+
if (
|
|
190
|
+
result.outcome === 'discarded' &&
|
|
191
|
+
(result.reason === 'invalid_signature' ||
|
|
192
|
+
result.reason === 'missing_required_input' ||
|
|
193
|
+
result.reason === 'stale_at_receipt')
|
|
194
|
+
) {
|
|
195
|
+
reply.code(401);
|
|
196
|
+
return {error: 'invalid signature'};
|
|
197
|
+
}
|
|
198
|
+
reply.code(200);
|
|
199
|
+
return SLASH_COMMAND_ACK;
|
|
173
200
|
}
|
package/test/globalSetup.ts
CHANGED
|
@@ -8,15 +8,6 @@ export async function setup() {
|
|
|
8
8
|
createPostgresClient();
|
|
9
9
|
|
|
10
10
|
await runMigrations(db(), migrationsPath, '__drizzle_migrations_integrations_slack');
|
|
11
|
-
const {secretsModule} = await import('@shipfox/api-secrets');
|
|
12
|
-
if (!secretsModule.database || Array.isArray(secretsModule.database)) {
|
|
13
|
-
throw new Error('Secrets module database is not configured');
|
|
14
|
-
}
|
|
15
|
-
await runMigrations(
|
|
16
|
-
secretsModule.database.db(),
|
|
17
|
-
secretsModule.database.migrationsPath,
|
|
18
|
-
'__drizzle_migrations_secrets',
|
|
19
|
-
);
|
|
20
11
|
|
|
21
12
|
closeDb();
|
|
22
13
|
await closePostgresClient();
|