@shipfox/api-integration-slack 4.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 +59 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +3 -1
- package/dist/api/client.js.map +1 -1
- 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/core/webhook.d.ts +5 -3
- package/dist/core/webhook.d.ts.map +1 -1
- package/dist/core/webhook.js +101 -2
- package/dist/core/webhook.js.map +1 -1
- package/dist/db/db.d.ts +34 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +2 -0
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +4 -2
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +17 -0
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +3 -1
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +28 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -7
- 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 +4 -1
- package/dist/presentation/routes/webhooks.d.ts.map +1 -1
- package/dist/presentation/routes/webhooks.js +94 -92
- package/dist/presentation/routes/webhooks.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_dark_tyrannus.sql +1 -0
- package/drizzle/meta/0001_snapshot.json +139 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +20 -30
- package/src/api/client.test.ts +24 -0
- package/src/api/client.ts +3 -1
- package/src/core/disconnect.test.ts +96 -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/core/webhook.test.ts +131 -2
- package/src/core/webhook.ts +143 -6
- package/src/db/installations.test.ts +15 -0
- package/src/db/installations.ts +11 -3
- package/src/db/schema/installations.ts +3 -1
- package/src/index.test.ts +11 -0
- package/src/index.ts +30 -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 +46 -1
- package/src/presentation/routes/webhooks.ts +127 -97
- package/test/globalSetup.ts +0 -9
- package/tsconfig.build.tsbuildinfo +1 -1
- package/turbo.json +9 -0
- package/test/api-secrets.d.ts +0 -26
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {createSlackApiClient, type SlackApiClient} from '#api/client.js';
|
|
|
4
4
|
import {config} from '#config.js';
|
|
5
5
|
import {SlackAgentToolsProvider} from '#core/agent-tools-provider.js';
|
|
6
6
|
import type {SlackTokenStore} from '#core/tokens.js';
|
|
7
|
+
import {createSlackWebhookProcessor} from '#core/webhook-processor.js';
|
|
7
8
|
import {closeDb, db} from '#db/db.js';
|
|
8
9
|
import {getSlackInstallationByConnectionId} from '#db/installations.js';
|
|
9
10
|
import {migrationsPath} from '#db/migrations.js';
|
|
@@ -67,7 +68,7 @@ export {
|
|
|
67
68
|
SLACK_BOT_SCOPES,
|
|
68
69
|
} from '#core/scopes.js';
|
|
69
70
|
export type {VerifySlackSignatureParams} from '#core/signature.js';
|
|
70
|
-
export {verifySlackSignature} from '#core/signature.js';
|
|
71
|
+
export {isSlackTimestampWithinReplayWindow, verifySlackSignature} from '#core/signature.js';
|
|
71
72
|
export type {SlackInstallStateClaims} from '#core/state.js';
|
|
72
73
|
export {signSlackInstallState, verifySlackInstallState} from '#core/state.js';
|
|
73
74
|
export type {
|
|
@@ -85,6 +86,12 @@ export type {
|
|
|
85
86
|
SlackWebhookOutcome,
|
|
86
87
|
} from '#core/webhook.js';
|
|
87
88
|
export {handleSlackCommand, handleSlackEvent, isSelfAuthoredSlackEvent} from '#core/webhook.js';
|
|
89
|
+
export type {
|
|
90
|
+
CreateSlackWebhookProcessorOptions,
|
|
91
|
+
SlackWebhookProcessingResult,
|
|
92
|
+
SlackWebhookProcessor,
|
|
93
|
+
} from '#core/webhook-processor.js';
|
|
94
|
+
export {createSlackWebhookProcessor} from '#core/webhook-processor.js';
|
|
88
95
|
export type {
|
|
89
96
|
SlackInstallation,
|
|
90
97
|
SlackInstallationStatus,
|
|
@@ -113,6 +120,15 @@ export interface CreateSlackIntegrationProviderOptions {
|
|
|
113
120
|
slack?: SlackApiClient | undefined;
|
|
114
121
|
agentTools?: {tokenStore: Pick<SlackTokenStore, 'getAccessToken'>} | undefined;
|
|
115
122
|
getSlackInstallationByConnectionId?: typeof getSlackInstallationByConnectionId | undefined;
|
|
123
|
+
cleanup?:
|
|
124
|
+
| {
|
|
125
|
+
deleteConnectionRecords?: (
|
|
126
|
+
connection: {id: string},
|
|
127
|
+
options: {tx: unknown},
|
|
128
|
+
) => Promise<void>;
|
|
129
|
+
deleteConnectionSecrets?: (connection: {id: string; workspaceId: string}) => Promise<void>;
|
|
130
|
+
}
|
|
131
|
+
| undefined;
|
|
116
132
|
routes?: SlackRouteOptions | undefined;
|
|
117
133
|
}
|
|
118
134
|
|
|
@@ -138,12 +154,17 @@ export function createSlackIntegrationProvider(
|
|
|
138
154
|
throw new Error('Slack webhook routes require every core persistence dependency');
|
|
139
155
|
}
|
|
140
156
|
const routes: RouteGroup[] = [];
|
|
157
|
+
const webhookProcessor =
|
|
158
|
+
options.routes && hasSlackWebhookRoutesOptions(options.routes)
|
|
159
|
+
? createSlackWebhookProcessor(options.routes)
|
|
160
|
+
: undefined;
|
|
141
161
|
if (options.routes && hasSlackInstallationRoutesOptions(options.routes)) {
|
|
142
162
|
const {
|
|
143
163
|
tokenStore,
|
|
144
164
|
getExistingSlackConnection,
|
|
145
165
|
connectSlackInstallation,
|
|
146
166
|
disconnectSlackInstallation,
|
|
167
|
+
requireActiveWorkspaceMembership,
|
|
147
168
|
} = options.routes;
|
|
148
169
|
routes.push(
|
|
149
170
|
createSlackIntegrationRoutes({
|
|
@@ -153,11 +174,12 @@ export function createSlackIntegrationProvider(
|
|
|
153
174
|
getExistingSlackConnection,
|
|
154
175
|
connectSlackInstallation,
|
|
155
176
|
disconnectSlackInstallation,
|
|
177
|
+
...(requireActiveWorkspaceMembership ? {requireActiveWorkspaceMembership} : {}),
|
|
156
178
|
}),
|
|
157
179
|
);
|
|
158
180
|
}
|
|
159
|
-
if (options.routes && hasSlackWebhookRoutesOptions(options.routes)) {
|
|
160
|
-
routes.push(...createSlackWebhookRoutes(options.routes));
|
|
181
|
+
if (options.routes && hasSlackWebhookRoutesOptions(options.routes) && webhookProcessor) {
|
|
182
|
+
routes.push(...createSlackWebhookRoutes({...options.routes, processor: webhookProcessor}));
|
|
161
183
|
}
|
|
162
184
|
|
|
163
185
|
return {
|
|
@@ -169,7 +191,11 @@ export function createSlackIntegrationProvider(
|
|
|
169
191
|
if (!installation) return undefined;
|
|
170
192
|
return `https://app.slack.com/client/${encodeURIComponent(installation.teamId)}`;
|
|
171
193
|
},
|
|
194
|
+
...options.cleanup,
|
|
172
195
|
routes,
|
|
196
|
+
webhookProcessors: webhookProcessor
|
|
197
|
+
? [{routeIds: ['slack.event', 'slack.command'] as const, processor: webhookProcessor}]
|
|
198
|
+
: undefined,
|
|
173
199
|
};
|
|
174
200
|
}
|
|
175
201
|
|
|
@@ -189,6 +215,7 @@ function hasSlackWebhookRoutesOptions(
|
|
|
189
215
|
): routes is CreateSlackWebhookRoutesOptions {
|
|
190
216
|
return (
|
|
191
217
|
routes.coreDb !== undefined &&
|
|
218
|
+
routes.claimWebhookDelivery !== undefined &&
|
|
192
219
|
routes.publishIntegrationEventReceived !== undefined &&
|
|
193
220
|
routes.recordDeliveryOnly !== undefined &&
|
|
194
221
|
routes.getIntegrationConnectionById !== undefined
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
|
|
2
|
+
import {createInterModuleKnownError} from '@shipfox/inter-module';
|
|
3
|
+
import {ClientError} from '@shipfox/node-fastify';
|
|
4
|
+
import {slackRouteErrorHandler} from './errors.js';
|
|
5
|
+
|
|
6
|
+
describe('slackRouteErrorHandler workspace access errors', () => {
|
|
7
|
+
it.each([
|
|
8
|
+
['workspace-not-found', 'not-found', 404],
|
|
9
|
+
['membership-required', 'forbidden', 403],
|
|
10
|
+
['workspace-inactive', 'workspace-inactive', 403],
|
|
11
|
+
] as const)('translates %s into %s', (error, code, status) => {
|
|
12
|
+
const knownError = createInterModuleKnownError(
|
|
13
|
+
workspacesInterModuleContract.methods.requireActiveMembership,
|
|
14
|
+
error,
|
|
15
|
+
{workspaceId: crypto.randomUUID()},
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(() => slackRouteErrorHandler(knownError)).toThrow(ClientError);
|
|
19
|
+
try {
|
|
20
|
+
slackRouteErrorHandler(knownError);
|
|
21
|
+
} catch (result) {
|
|
22
|
+
expect(result).toMatchObject({code, status});
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
ConnectionSlugConflictError,
|
|
3
3
|
type IntegrationProviderErrorReason,
|
|
4
4
|
} from '@shipfox/api-integration-core-dto';
|
|
5
|
+
import {workspacesInterModuleContract} from '@shipfox/api-workspaces-dto/inter-module';
|
|
6
|
+
import {isInterModuleKnownError} from '@shipfox/inter-module';
|
|
5
7
|
import {ClientError} from '@shipfox/node-fastify';
|
|
6
8
|
import {
|
|
7
9
|
SlackAuthorizationScopeMismatchError,
|
|
@@ -22,6 +24,24 @@ function providerStatus(reason: IntegrationProviderErrorReason): number {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export function slackRouteErrorHandler(error: unknown): never {
|
|
27
|
+
if (
|
|
28
|
+
isInterModuleKnownError(workspacesInterModuleContract.methods.requireActiveMembership, error)
|
|
29
|
+
) {
|
|
30
|
+
if (error.code === 'workspace-not-found')
|
|
31
|
+
throw new ClientError('Workspace not found', 'not-found', {
|
|
32
|
+
status: 404,
|
|
33
|
+
details: {workspace_id: error.details.workspaceId},
|
|
34
|
+
});
|
|
35
|
+
if (error.code === 'membership-required')
|
|
36
|
+
throw new ClientError('Workspace membership required', 'forbidden', {
|
|
37
|
+
status: 403,
|
|
38
|
+
details: {workspace_id: error.details.workspaceId},
|
|
39
|
+
});
|
|
40
|
+
throw new ClientError('Workspace is inactive', 'workspace-inactive', {
|
|
41
|
+
status: 403,
|
|
42
|
+
details: {workspace_id: error.details.workspaceId},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
25
45
|
if (error instanceof SlackInstallStateError) {
|
|
26
46
|
throw new ClientError(error.message, 'invalid-slack-install-state', {status: 400});
|
|
27
47
|
}
|
|
@@ -14,10 +14,7 @@ import {verifySlackInstallState} from '#core/state.js';
|
|
|
14
14
|
import type {SlackTokenStore} from '#core/tokens.js';
|
|
15
15
|
import {createSlackIntegrationProvider} from '#index.js';
|
|
16
16
|
|
|
17
|
-
vi.
|
|
18
|
-
requireWorkspaceMembership: vi.fn(() => Promise.resolve()),
|
|
19
|
-
}));
|
|
20
|
-
|
|
17
|
+
const requireWorkspaceMembershipMock = vi.fn(() => Promise.resolve());
|
|
21
18
|
let authenticatedMemberships: UserContextMembership[] = [];
|
|
22
19
|
|
|
23
20
|
const fakeUserAuth: AuthMethod = {
|
|
@@ -105,6 +102,7 @@ async function createTestApp(
|
|
|
105
102
|
Promise.resolve(connection({workspaceId: input.workspaceId})),
|
|
106
103
|
),
|
|
107
104
|
disconnectSlackInstallation: vi.fn(() => Promise.resolve()),
|
|
105
|
+
requireActiveWorkspaceMembership: requireWorkspaceMembershipMock,
|
|
108
106
|
},
|
|
109
107
|
});
|
|
110
108
|
const app = await createApp({auth: [fakeUserAuth], routes: provider.routes, swagger: false});
|
|
@@ -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,8 +1,9 @@
|
|
|
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
|
-
import {upsertSlackInstallation} from '#db/installations.js';
|
|
6
|
+
import {getSlackInstallationByTeamId, upsertSlackInstallation} from '#db/installations.js';
|
|
6
7
|
import {slackInstallations} from '#db/schema/installations.js';
|
|
7
8
|
import {createSlackWebhookRoutes, SLASH_COMMAND_ACK} from './webhooks.js';
|
|
8
9
|
|
|
@@ -46,10 +47,12 @@ async function createTestApp(connection = fakeConnection()): Promise<{
|
|
|
46
47
|
}> {
|
|
47
48
|
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
48
49
|
const recordDeliveryOnly = vi.fn(() => Promise.resolve());
|
|
50
|
+
const claimWebhookDelivery = vi.fn(() => Promise.resolve({claimed: true}));
|
|
49
51
|
const getIntegrationConnectionById = vi.fn(() => Promise.resolve(connection));
|
|
50
52
|
const app = await createApp({
|
|
51
53
|
routes: createSlackWebhookRoutes({
|
|
52
54
|
coreDb: db,
|
|
55
|
+
claimWebhookDelivery,
|
|
53
56
|
publishIntegrationEventReceived,
|
|
54
57
|
recordDeliveryOnly,
|
|
55
58
|
getIntegrationConnectionById,
|
|
@@ -159,6 +162,23 @@ describe('Slack webhook routes', () => {
|
|
|
159
162
|
expect(recordDeliveryOnly).not.toHaveBeenCalled();
|
|
160
163
|
});
|
|
161
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
|
+
|
|
162
182
|
it('publishes a signed event delivery through the route transaction', async () => {
|
|
163
183
|
const {app, connection, publishIntegrationEventReceived} = await createTestApp();
|
|
164
184
|
await seedInstallation(connection);
|
|
@@ -186,6 +206,31 @@ describe('Slack webhook routes', () => {
|
|
|
186
206
|
);
|
|
187
207
|
});
|
|
188
208
|
|
|
209
|
+
it('revokes an installation after a signed app-uninstalled event', async () => {
|
|
210
|
+
const {app, connection, publishIntegrationEventReceived} = await createTestApp();
|
|
211
|
+
await seedInstallation(connection);
|
|
212
|
+
const rawBody = JSON.stringify({
|
|
213
|
+
type: 'event_callback',
|
|
214
|
+
team_id: 'T1',
|
|
215
|
+
api_app_id: 'A1',
|
|
216
|
+
event: {type: 'app_uninstalled'},
|
|
217
|
+
event_id: 'Ev-route-uninstalled',
|
|
218
|
+
event_time: Math.floor(Date.now() / 1000) + 60,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const response = await app.inject({
|
|
222
|
+
method: 'POST',
|
|
223
|
+
url: '/webhooks/integrations/slack/events',
|
|
224
|
+
headers: slackHeaders(rawBody, 'application/json'),
|
|
225
|
+
payload: rawBody,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const installation = await getSlackInstallationByTeamId('T1');
|
|
229
|
+
expect(response.statusCode).toBe(200);
|
|
230
|
+
expect(installation?.status).toBe('revoked');
|
|
231
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
|
|
189
234
|
it('returns the fixed acknowledgement for a signed command that cannot resolve a team', async () => {
|
|
190
235
|
const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
|
|
191
236
|
const rawBody = new URLSearchParams({
|
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
2
|
import type {
|
|
3
|
+
ClaimWebhookDeliveryFn,
|
|
3
4
|
GetIntegrationConnectionByIdFn,
|
|
4
5
|
PublishIntegrationEventReceivedFn,
|
|
5
6
|
RecordDeliveryOnlyFn,
|
|
7
|
+
StoredWebhookRequest,
|
|
8
|
+
WebhookProcessingResult,
|
|
6
9
|
} from '@shipfox/api-integration-core-dto';
|
|
7
10
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} from '@shipfox/api-integration-
|
|
11
|
-
import {
|
|
12
|
-
|
|
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';
|
|
13
20
|
import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
|
|
14
|
-
import {
|
|
15
|
-
import {verifySlackSignature} from '#core/signature.js';
|
|
16
|
-
import {handleSlackCommand, handleSlackEvent} from '#core/webhook.js';
|
|
21
|
+
import {createSlackWebhookProcessor, type SlackWebhookProcessor} from '#core/webhook-processor.js';
|
|
17
22
|
|
|
18
23
|
export const SLACK_WEBHOOK_BODY_LIMIT = 1024 * 1024;
|
|
19
24
|
export const SLASH_COMMAND_ACK = {response_type: 'ephemeral', text: 'Working on it.'} as const;
|
|
@@ -29,12 +34,15 @@ const slackFormRawBodyPlugin = createRawBodyPlugin({
|
|
|
29
34
|
|
|
30
35
|
export interface CreateSlackWebhookRoutesOptions {
|
|
31
36
|
coreDb: () => NodePgDatabase<Record<string, unknown>>;
|
|
37
|
+
claimWebhookDelivery: ClaimWebhookDeliveryFn;
|
|
32
38
|
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
33
39
|
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
34
40
|
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
41
|
+
processor?: SlackWebhookProcessor | undefined;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
export function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOptions): RouteGroup[] {
|
|
45
|
+
const processor = options.processor ?? createSlackWebhookProcessor(options);
|
|
38
46
|
const eventsRoute = defineRoute({
|
|
39
47
|
method: 'POST',
|
|
40
48
|
path: '/',
|
|
@@ -42,58 +50,25 @@ export function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOption
|
|
|
42
50
|
description: 'Slack Events API receiver.',
|
|
43
51
|
options: {bodyLimit: SLACK_WEBHOOK_BODY_LIMIT},
|
|
44
52
|
handler: async (request, reply) => {
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
53
|
+
const body = rawRequestBody(request.body);
|
|
54
|
+
if (!body) {
|
|
47
55
|
reply.code(400);
|
|
48
56
|
return {error: 'expected raw JSON body'};
|
|
49
57
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
63
|
-
reply.code(400);
|
|
64
|
-
return {error: 'malformed JSON'};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const parsed = slackEventsRequestSchema.safeParse(parsedJson);
|
|
68
|
-
if (!parsed.success) {
|
|
69
|
-
logger().warn(
|
|
70
|
-
{issues: parsed.error.issues},
|
|
71
|
-
'slack events envelope failed schema validation',
|
|
72
|
-
);
|
|
73
|
-
reply.code(200);
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
const eventRequest = parsed.data;
|
|
77
|
-
if (eventRequest.type === 'url_verification') {
|
|
78
|
-
reply.code(200);
|
|
79
|
-
return {challenge: eventRequest.challenge};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
await options.coreDb().transaction(async (tx) => {
|
|
83
|
-
await handleSlackEvent({
|
|
84
|
-
tx,
|
|
85
|
-
deliveryId: eventRequest.event_id,
|
|
86
|
-
envelope: eventRequest,
|
|
87
|
-
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
88
|
-
recordDeliveryOnly: options.recordDeliveryOnly,
|
|
89
|
-
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
reply.code(200);
|
|
93
|
-
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);
|
|
94
70
|
},
|
|
95
71
|
});
|
|
96
|
-
|
|
97
72
|
const commandsRoute = defineRoute({
|
|
98
73
|
method: 'POST',
|
|
99
74
|
path: '/',
|
|
@@ -101,37 +76,23 @@ export function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOption
|
|
|
101
76
|
description: 'Slack slash-command receiver.',
|
|
102
77
|
options: {bodyLimit: SLACK_WEBHOOK_BODY_LIMIT},
|
|
103
78
|
handler: async (request, reply) => {
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
79
|
+
const body = rawRequestBody(request.body);
|
|
80
|
+
if (!body) {
|
|
106
81
|
reply.code(400);
|
|
107
82
|
return {error: 'expected raw form body'};
|
|
108
83
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
+
),
|
|
116
94
|
);
|
|
117
|
-
|
|
118
|
-
logger().warn({issues: command.error.issues}, 'slack command failed schema validation');
|
|
119
|
-
reply.code(200);
|
|
120
|
-
return SLASH_COMMAND_ACK;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
await options.coreDb().transaction(async (tx) => {
|
|
124
|
-
await handleSlackCommand({
|
|
125
|
-
tx,
|
|
126
|
-
deliveryId: command.data.trigger_id,
|
|
127
|
-
command: command.data,
|
|
128
|
-
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
129
|
-
recordDeliveryOnly: options.recordDeliveryOnly,
|
|
130
|
-
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
reply.code(200);
|
|
134
|
-
return SLASH_COMMAND_ACK;
|
|
95
|
+
return sendSlackCommandResponse(reply, result);
|
|
135
96
|
},
|
|
136
97
|
});
|
|
137
98
|
|
|
@@ -151,20 +112,89 @@ export function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOption
|
|
|
151
112
|
];
|
|
152
113
|
}
|
|
153
114
|
|
|
154
|
-
function rawRequestBody(body: unknown):
|
|
155
|
-
return
|
|
115
|
+
function rawRequestBody(body: unknown): Uint8Array | undefined {
|
|
116
|
+
return body instanceof Uint8Array ? body : undefined;
|
|
156
117
|
}
|
|
157
118
|
|
|
158
|
-
function
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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;
|
|
170
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();
|