@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.
Files changed (44) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +35 -0
  3. package/dist/core/signature.d.ts +2 -1
  4. package/dist/core/signature.d.ts.map +1 -1
  5. package/dist/core/signature.js +10 -6
  6. package/dist/core/signature.js.map +1 -1
  7. package/dist/core/webhook-processor.d.ts +15 -0
  8. package/dist/core/webhook-processor.d.ts.map +1 -0
  9. package/dist/core/webhook-processor.js +127 -0
  10. package/dist/core/webhook-processor.js.map +1 -0
  11. package/dist/index.d.ts +7 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +24 -6
  14. package/dist/index.js.map +1 -1
  15. package/dist/presentation/routes/errors.d.ts.map +1 -1
  16. package/dist/presentation/routes/errors.js +22 -0
  17. package/dist/presentation/routes/errors.js.map +1 -1
  18. package/dist/presentation/routes/install.d.ts +6 -1
  19. package/dist/presentation/routes/install.d.ts.map +1 -1
  20. package/dist/presentation/routes/install.js +6 -4
  21. package/dist/presentation/routes/install.js.map +1 -1
  22. package/dist/presentation/routes/webhooks.d.ts +2 -0
  23. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  24. package/dist/presentation/routes/webhooks.js +94 -93
  25. package/dist/presentation/routes/webhooks.js.map +1 -1
  26. package/dist/tsconfig.test.tsbuildinfo +1 -1
  27. package/package.json +10 -10
  28. package/src/core/disconnect.test.ts +47 -0
  29. package/src/core/signature.test.ts +3 -3
  30. package/src/core/signature.ts +19 -9
  31. package/src/core/tokens.test.ts +16 -2
  32. package/src/core/webhook-processor.test.ts +106 -0
  33. package/src/core/webhook-processor.ts +152 -0
  34. package/src/index.ts +19 -3
  35. package/src/presentation/routes/errors.test.ts +25 -0
  36. package/src/presentation/routes/errors.ts +20 -0
  37. package/src/presentation/routes/install.test.ts +2 -4
  38. package/src/presentation/routes/install.ts +18 -3
  39. package/src/presentation/routes/webhooks.test.ts +18 -0
  40. package/src/presentation/routes/webhooks.ts +125 -98
  41. package/test/globalSetup.ts +0 -9
  42. package/tsconfig.build.tsbuildinfo +1 -1
  43. package/turbo.json +1 -1
  44. 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 {Buffer} from 'node:buffer';
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
- slackEventsRequestSchema,
10
- slackSlashCommandSchema,
11
- } from '@shipfox/api-integration-slack-dto';
12
- import {createRawBodyPlugin, defineRoute, type RouteGroup} from '@shipfox/node-fastify';
13
- 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';
14
20
  import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
15
- import {config} from '#config.js';
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 rawBody = rawRequestBody(request.body);
48
- if (rawBody === undefined) {
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
- if (!hasValidSlackSignature(request.headers, rawBody)) {
53
- reply.code(401);
54
- return {error: 'invalid signature'};
55
- }
56
-
57
- let parsedJson: unknown;
58
- try {
59
- parsedJson = JSON.parse(rawBody);
60
- } catch (error) {
61
- logger().warn(
62
- {errorName: error instanceof Error ? error.name : 'unknown'},
63
- 'slack events payload JSON parse failed',
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 rawBody = rawRequestBody(request.body);
108
- if (rawBody === undefined) {
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
- if (!hasValidSlackSignature(request.headers, rawBody)) {
113
- reply.code(401);
114
- return {error: 'invalid signature'};
115
- }
116
-
117
- const command = slackSlashCommandSchema.safeParse(
118
- 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
+ ),
119
94
  );
120
- if (!command.success) {
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): string | undefined {
158
- return Buffer.isBuffer(body) ? body.toString('utf8') : undefined;
115
+ function rawRequestBody(body: unknown): Uint8Array | undefined {
116
+ return body instanceof Uint8Array ? body : undefined;
159
117
  }
160
118
 
161
- function hasValidSlackSignature(
162
- headers: Record<string, string | string[] | undefined>,
163
- rawBody: string,
164
- ): boolean {
165
- const signature = headers['x-slack-signature'];
166
- const timestamp = headers['x-slack-request-timestamp'];
167
- return verifySlackSignature({
168
- signingSecret: config.SLACK_SIGNING_SECRET,
169
- signature: typeof signature === 'string' ? signature : undefined,
170
- timestamp: typeof timestamp === 'string' ? timestamp : undefined,
171
- rawBody,
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
  }
@@ -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();