@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.
Files changed (72) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +59 -0
  3. package/dist/api/client.d.ts.map +1 -1
  4. package/dist/api/client.js +3 -1
  5. package/dist/api/client.js.map +1 -1
  6. package/dist/core/signature.d.ts +2 -1
  7. package/dist/core/signature.d.ts.map +1 -1
  8. package/dist/core/signature.js +10 -6
  9. package/dist/core/signature.js.map +1 -1
  10. package/dist/core/webhook-processor.d.ts +15 -0
  11. package/dist/core/webhook-processor.d.ts.map +1 -0
  12. package/dist/core/webhook-processor.js +127 -0
  13. package/dist/core/webhook-processor.js.map +1 -0
  14. package/dist/core/webhook.d.ts +5 -3
  15. package/dist/core/webhook.d.ts.map +1 -1
  16. package/dist/core/webhook.js +101 -2
  17. package/dist/core/webhook.js.map +1 -1
  18. package/dist/db/db.d.ts +34 -0
  19. package/dist/db/db.d.ts.map +1 -1
  20. package/dist/db/installations.d.ts +2 -0
  21. package/dist/db/installations.d.ts.map +1 -1
  22. package/dist/db/installations.js +4 -2
  23. package/dist/db/installations.js.map +1 -1
  24. package/dist/db/schema/installations.d.ts +17 -0
  25. package/dist/db/schema/installations.d.ts.map +1 -1
  26. package/dist/db/schema/installations.js +3 -1
  27. package/dist/db/schema/installations.js.map +1 -1
  28. package/dist/index.d.ts +28 -2
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +26 -7
  31. package/dist/index.js.map +1 -1
  32. package/dist/presentation/routes/errors.d.ts.map +1 -1
  33. package/dist/presentation/routes/errors.js +22 -0
  34. package/dist/presentation/routes/errors.js.map +1 -1
  35. package/dist/presentation/routes/install.d.ts +6 -1
  36. package/dist/presentation/routes/install.d.ts.map +1 -1
  37. package/dist/presentation/routes/install.js +6 -4
  38. package/dist/presentation/routes/install.js.map +1 -1
  39. package/dist/presentation/routes/webhooks.d.ts +4 -1
  40. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  41. package/dist/presentation/routes/webhooks.js +94 -92
  42. package/dist/presentation/routes/webhooks.js.map +1 -1
  43. package/dist/tsconfig.test.tsbuildinfo +1 -1
  44. package/drizzle/0001_dark_tyrannus.sql +1 -0
  45. package/drizzle/meta/0001_snapshot.json +139 -0
  46. package/drizzle/meta/_journal.json +7 -0
  47. package/package.json +20 -30
  48. package/src/api/client.test.ts +24 -0
  49. package/src/api/client.ts +3 -1
  50. package/src/core/disconnect.test.ts +96 -0
  51. package/src/core/signature.test.ts +3 -3
  52. package/src/core/signature.ts +19 -9
  53. package/src/core/tokens.test.ts +16 -2
  54. package/src/core/webhook-processor.test.ts +106 -0
  55. package/src/core/webhook-processor.ts +152 -0
  56. package/src/core/webhook.test.ts +131 -2
  57. package/src/core/webhook.ts +143 -6
  58. package/src/db/installations.test.ts +15 -0
  59. package/src/db/installations.ts +11 -3
  60. package/src/db/schema/installations.ts +3 -1
  61. package/src/index.test.ts +11 -0
  62. package/src/index.ts +30 -3
  63. package/src/presentation/routes/errors.test.ts +25 -0
  64. package/src/presentation/routes/errors.ts +20 -0
  65. package/src/presentation/routes/install.test.ts +2 -4
  66. package/src/presentation/routes/install.ts +18 -3
  67. package/src/presentation/routes/webhooks.test.ts +46 -1
  68. package/src/presentation/routes/webhooks.ts +127 -97
  69. package/test/globalSetup.ts +0 -9
  70. package/tsconfig.build.tsbuildinfo +1 -1
  71. package/turbo.json +9 -0
  72. 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.mock('@shipfox/api-workspaces', () => ({
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 {Buffer} from 'node:buffer';
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
- slackEventsRequestSchema,
9
- slackSlashCommandSchema,
10
- } from '@shipfox/api-integration-slack-dto';
11
- import {createRawBodyPlugin, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
12
- import {logger} from '@shipfox/node-opentelemetry';
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 {config} from '#config.js';
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 rawBody = rawRequestBody(request.body);
46
- if (rawBody === undefined) {
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
- if (!hasValidSlackSignature(request.headers, rawBody)) {
51
- reply.code(401);
52
- return {error: 'invalid signature'};
53
- }
54
-
55
- let parsedJson: unknown;
56
- try {
57
- parsedJson = JSON.parse(rawBody);
58
- } catch (error) {
59
- logger().warn(
60
- {errorName: error instanceof Error ? error.name : 'unknown'},
61
- 'slack events payload JSON parse failed',
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 rawBody = rawRequestBody(request.body);
105
- if (rawBody === undefined) {
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
- if (!hasValidSlackSignature(request.headers, rawBody)) {
110
- reply.code(401);
111
- return {error: 'invalid signature'};
112
- }
113
-
114
- const command = slackSlashCommandSchema.safeParse(
115
- Object.fromEntries(new URLSearchParams(rawBody)),
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
- if (!command.success) {
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): string | undefined {
155
- return Buffer.isBuffer(body) ? body.toString('utf8') : undefined;
115
+ function rawRequestBody(body: unknown): Uint8Array | undefined {
116
+ return body instanceof Uint8Array ? body : undefined;
156
117
  }
157
118
 
158
- function hasValidSlackSignature(
159
- headers: Record<string, string | string[] | undefined>,
160
- rawBody: string,
161
- ): boolean {
162
- const signature = headers['x-slack-signature'];
163
- const timestamp = headers['x-slack-request-timestamp'];
164
- return verifySlackSignature({
165
- signingSecret: config.SLACK_SIGNING_SECRET,
166
- signature: typeof signature === 'string' ? signature : undefined,
167
- timestamp: typeof timestamp === 'string' ? timestamp : undefined,
168
- rawBody,
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
  }
@@ -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();