@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-slack",
3
3
  "license": "MIT",
4
- "version": "5.0.0",
4
+ "version": "6.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -25,25 +25,24 @@
25
25
  "drizzle-orm": "^0.45.2",
26
26
  "ky": "^2.0.0",
27
27
  "zod": "^4.4.3",
28
- "@shipfox/api-auth-context": "5.0.0",
29
- "@shipfox/api-integration-core-dto": "5.0.0",
30
- "@shipfox/api-integration-slack-dto": "5.0.0",
31
- "@shipfox/api-workspaces": "5.0.0",
28
+ "@shipfox/api-auth-context": "6.0.0",
29
+ "@shipfox/api-integration-core-dto": "6.0.0",
30
+ "@shipfox/api-integration-slack-dto": "6.0.0",
31
+ "@shipfox/api-workspaces-dto": "6.0.0",
32
32
  "@shipfox/config": "1.2.2",
33
- "@shipfox/node-drizzle": "0.3.1",
34
- "@shipfox/node-fastify": "0.2.3",
33
+ "@shipfox/inter-module": "0.2.0",
34
+ "@shipfox/node-drizzle": "0.3.2",
35
+ "@shipfox/node-fastify": "0.2.4",
35
36
  "@shipfox/node-opentelemetry": "0.5.2",
36
37
  "@shipfox/node-postgres": "0.4.2"
37
38
  },
38
- "peerDependencies": {
39
- "@shipfox/api-secrets": "5.0.0"
40
- },
41
39
  "devDependencies": {
42
40
  "@types/pg": "^8.15.5",
43
41
  "drizzle-kit": "^0.31.10",
44
42
  "fastify": "^5.3.3",
45
43
  "fishery": "^2.4.0",
46
44
  "@shipfox/biome": "1.8.2",
45
+ "@shipfox/depcruise": "1.0.2",
47
46
  "@shipfox/swc": "1.2.6",
48
47
  "@shipfox/ts-config": "1.3.8",
49
48
  "@shipfox/typescript": "1.1.7",
@@ -53,6 +52,7 @@
53
52
  "build": "shipfox-swc",
54
53
  "check": "shipfox-biome-check",
55
54
  "check:fix": "shipfox-biome-check --write",
55
+ "depcruise": "shipfox-depcruise",
56
56
  "test": "shipfox-vitest-run",
57
57
  "test:watch": "shipfox-vitest-watch",
58
58
  "type": "shipfox-tsc-check",
@@ -10,6 +10,7 @@ const deleteSlackInstallationByConnectionIdMock = vi.mocked(deleteSlackInstallat
10
10
  describe('disconnectSlackInstallation', () => {
11
11
  beforeEach(() => {
12
12
  deleteSlackInstallationByConnectionIdMock.mockClear();
13
+ deleteSlackInstallationByConnectionIdMock.mockResolvedValue(true);
13
14
  });
14
15
 
15
16
  it('deletes stored tokens before deleting connection records', async () => {
@@ -46,4 +47,50 @@ describe('disconnectSlackInstallation', () => {
46
47
  deleteConnection.mock.invocationCallOrder[0] ?? 0,
47
48
  );
48
49
  });
50
+
51
+ it('keeps connection records when secret deletion fails', async () => {
52
+ const transaction = vi.fn();
53
+ const deleteConnection = vi.fn();
54
+
55
+ const run = disconnectSlackInstallation({
56
+ connectionId: 'connection-1',
57
+ getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
58
+ deleteSecrets: vi.fn(() => Promise.reject(new Error('secret store unavailable'))),
59
+ transaction,
60
+ deleteConnection,
61
+ });
62
+
63
+ await expect(run).rejects.toThrow('secret store unavailable');
64
+ expect(transaction).not.toHaveBeenCalled();
65
+ expect(deleteSlackInstallationByConnectionIdMock).not.toHaveBeenCalled();
66
+ expect(deleteConnection).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it('retries idempotent secret deletion after the records transaction fails', async () => {
70
+ const tx = Symbol('tx');
71
+ const deleteSecrets = vi.fn(() => Promise.resolve(0));
72
+ const deleteConnection = vi.fn(() => Promise.resolve(true));
73
+ const transaction = vi
74
+ .fn()
75
+ .mockImplementationOnce(async (fn: (value: symbol) => Promise<unknown>) => {
76
+ await fn(tx);
77
+ throw new Error('database commit failed');
78
+ })
79
+ .mockImplementationOnce(async (fn: (value: symbol) => Promise<unknown>) => await fn(tx));
80
+ const params = {
81
+ connectionId: 'connection-1',
82
+ getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
83
+ deleteSecrets,
84
+ transaction,
85
+ deleteConnection,
86
+ };
87
+
88
+ const firstAttempt = disconnectSlackInstallation(params);
89
+ await expect(firstAttempt).rejects.toThrow('database commit failed');
90
+ await disconnectSlackInstallation(params);
91
+
92
+ expect(deleteSecrets).toHaveBeenCalledTimes(2);
93
+ expect(deleteSlackInstallationByConnectionIdMock).toHaveBeenCalledTimes(2);
94
+ expect(deleteConnection).toHaveBeenCalledTimes(2);
95
+ });
49
96
  });
@@ -3,11 +3,11 @@ import {verifySlackSignature} from './signature.js';
3
3
 
4
4
  const secret = 'test-signing-secret';
5
5
  const timestamp = '1721300000';
6
- const rawBody = '{"type":"event_callback"}';
6
+ const rawBody = Buffer.from('{"type":"event_callback"}');
7
7
  const now = Number(timestamp) * 1000;
8
8
 
9
9
  function signature(body = rawBody): string {
10
- return `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${body}`).digest('hex')}`;
10
+ return `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:`).update(body).digest('hex')}`;
11
11
  }
12
12
 
13
13
  describe('verifySlackSignature', () => {
@@ -24,7 +24,7 @@ describe('verifySlackSignature', () => {
24
24
  });
25
25
 
26
26
  it.each([
27
- ['a tampered body', {rawBody: '{"type":"tampered"}'}],
27
+ ['a tampered body', {rawBody: Buffer.from('{"type":"tampered"}')}],
28
28
  ['a missing signature', {signature: undefined}],
29
29
  ['an empty signing secret', {signingSecret: ''}],
30
30
  ['a missing timestamp', {timestamp: undefined}],
@@ -6,11 +6,25 @@ export interface VerifySlackSignatureParams {
6
6
  signingSecret: string;
7
7
  signature: string | undefined;
8
8
  timestamp: string | undefined;
9
- rawBody: string;
9
+ rawBody: Uint8Array;
10
10
  now?: number | undefined;
11
11
  replayWindowMs?: number | undefined;
12
12
  }
13
13
 
14
+ export function isSlackTimestampWithinReplayWindow(
15
+ timestamp: string,
16
+ now = Date.now(),
17
+ replayWindowMs = 300_000,
18
+ ): boolean {
19
+ if (!unixTimestampPattern.test(timestamp)) return false;
20
+
21
+ const timestampSeconds = Number(timestamp);
22
+ if (!Number.isSafeInteger(timestampSeconds)) return false;
23
+ const timestampMilliseconds = timestampSeconds * 1000;
24
+ if (!Number.isSafeInteger(timestampMilliseconds)) return false;
25
+ return Math.abs(now - timestampMilliseconds) <= replayWindowMs;
26
+ }
27
+
14
28
  export function verifySlackSignature({
15
29
  signingSecret,
16
30
  signature,
@@ -19,18 +33,14 @@ export function verifySlackSignature({
19
33
  now = Date.now(),
20
34
  replayWindowMs = 300_000,
21
35
  }: VerifySlackSignatureParams): boolean {
22
- if (!signingSecret || !signature || !timestamp || !unixTimestampPattern.test(timestamp)) {
36
+ if (!signingSecret || !signature || !timestamp) {
23
37
  return false;
24
38
  }
25
-
26
- const timestampSeconds = Number(timestamp);
27
- if (!Number.isSafeInteger(timestampSeconds)) return false;
28
- const timestampMilliseconds = timestampSeconds * 1000;
29
- if (!Number.isSafeInteger(timestampMilliseconds)) return false;
30
- if (Math.abs(now - timestampMilliseconds) > replayWindowMs) return false;
39
+ if (!isSlackTimestampWithinReplayWindow(timestamp, now, replayWindowMs)) return false;
31
40
 
32
41
  const expected = `v0=${createHmac('sha256', signingSecret)
33
- .update(`v0:${timestamp}:${rawBody}`)
42
+ .update(`v0:${timestamp}:`)
43
+ .update(rawBody)
34
44
  .digest('hex')}`;
35
45
  const expectedBuffer = Buffer.from(expected, 'utf8');
36
46
  const receivedBuffer = Buffer.from(signature, 'utf8');
@@ -9,8 +9,8 @@ import {
9
9
 
10
10
  let slackSecrets: SlackSecretsStore;
11
11
 
12
- beforeAll(async () => {
13
- slackSecrets = await import('@shipfox/api-secrets');
12
+ beforeEach(() => {
13
+ slackSecrets = createInMemorySecretsStore();
14
14
  });
15
15
 
16
16
  function createConnectionContext() {
@@ -161,3 +161,17 @@ describe('createSlackTokenStore', () => {
161
161
  await expect(result).rejects.toBeInstanceOf(SlackConnectionNotFoundError);
162
162
  });
163
163
  });
164
+
165
+ function createInMemorySecretsStore(): SlackSecretsStore {
166
+ const values = new Map<string, string>();
167
+ const id = (params: {workspaceId: string; namespace: string; key: string}) =>
168
+ `${params.workspaceId}\0${params.namespace}\0${params.key}`;
169
+ return {
170
+ getSecret: async (params) => values.get(id(params)) ?? null,
171
+ setSecrets: async (params) => {
172
+ await Promise.resolve();
173
+ for (const [key, value] of Object.entries(params.values))
174
+ values.set(id({...params, key}), value);
175
+ },
176
+ };
177
+ }
@@ -0,0 +1,106 @@
1
+ import {createHmac, randomUUID} from 'node:crypto';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {createStoredWebhookRequest} from '@shipfox/api-integration-core-dto';
4
+ import {db} from '#db/db.js';
5
+ import {upsertSlackInstallation} from '#db/installations.js';
6
+ import {slackInstallations} from '#db/schema/installations.js';
7
+ import {createSlackWebhookProcessor} from './webhook-processor.js';
8
+
9
+ const signingSecret = 'test-signing-secret';
10
+
11
+ function fakeConnection(): IntegrationConnection {
12
+ return {
13
+ id: randomUUID(),
14
+ workspaceId: randomUUID(),
15
+ provider: 'slack',
16
+ externalAccountId: 'T1',
17
+ slug: 'slack_acme',
18
+ displayName: 'Slack Acme',
19
+ lifecycleStatus: 'active',
20
+ createdAt: new Date(),
21
+ updatedAt: new Date(),
22
+ };
23
+ }
24
+
25
+ function signedEventRequest(input: {
26
+ receivedAt: string;
27
+ timestamp?: number;
28
+ }): ReturnType<typeof createStoredWebhookRequest> {
29
+ const body = Buffer.from(
30
+ JSON.stringify({
31
+ type: 'event_callback',
32
+ team_id: 'T1',
33
+ api_app_id: 'A1',
34
+ event: {type: 'app_mention', channel: 'C1', user: 'U1', text: 'hello', ts: '1.0'},
35
+ event_id: 'Ev-delayed',
36
+ event_time: 1_721_300_000,
37
+ }),
38
+ );
39
+ const timestamp = `${input.timestamp ?? Math.floor(new Date(input.receivedAt).getTime() / 1000)}`;
40
+ const signature = `v0=${createHmac('sha256', signingSecret)
41
+ .update(`v0:${timestamp}:`)
42
+ .update(body)
43
+ .digest('hex')}`;
44
+ return createStoredWebhookRequest({
45
+ requestId: randomUUID(),
46
+ routeId: 'slack.event',
47
+ receivedAt: input.receivedAt,
48
+ rawQueryString: '',
49
+ headers: {
50
+ 'content-type': 'application/json',
51
+ 'x-slack-request-timestamp': timestamp,
52
+ 'x-slack-signature': signature,
53
+ },
54
+ body,
55
+ });
56
+ }
57
+
58
+ describe('Slack webhook processor', () => {
59
+ beforeEach(async () => {
60
+ await db().delete(slackInstallations);
61
+ });
62
+
63
+ it('accepts an event delayed after a valid receipt', async () => {
64
+ const connection = fakeConnection();
65
+ const receivedAt = new Date(Date.now() - 10 * 60_000).toISOString();
66
+ await upsertSlackInstallation({
67
+ connectionId: connection.id,
68
+ teamId: 'T1',
69
+ teamName: 'Acme',
70
+ appId: 'A1',
71
+ botUserId: 'UBOT',
72
+ scopes: [],
73
+ status: 'installed',
74
+ });
75
+ const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
76
+ const processor = createSlackWebhookProcessor({
77
+ coreDb: db,
78
+ claimWebhookDelivery: vi.fn(() => Promise.resolve({claimed: true})),
79
+ publishIntegrationEventReceived,
80
+ recordDeliveryOnly: vi.fn(() => Promise.resolve()),
81
+ getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
82
+ });
83
+
84
+ const result = await processor.process(signedEventRequest({receivedAt}));
85
+
86
+ expect(result).toMatchObject({outcome: 'processed', deliveryId: 'Ev-delayed'});
87
+ expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it('discards an event stale at receipt', async () => {
91
+ const receivedAt = new Date().toISOString();
92
+ const processor = createSlackWebhookProcessor({
93
+ coreDb: db,
94
+ claimWebhookDelivery: vi.fn(() => Promise.resolve({claimed: true})),
95
+ publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
96
+ recordDeliveryOnly: vi.fn(() => Promise.resolve()),
97
+ getIntegrationConnectionById: vi.fn(),
98
+ });
99
+
100
+ const result = await processor.process(
101
+ signedEventRequest({receivedAt, timestamp: Math.floor(Date.now() / 1000) - 301}),
102
+ );
103
+
104
+ expect(result).toMatchObject({outcome: 'discarded', reason: 'stale_at_receipt'});
105
+ });
106
+ });
@@ -0,0 +1,152 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import type {
3
+ ClaimWebhookDeliveryFn,
4
+ GetIntegrationConnectionByIdFn,
5
+ PublishIntegrationEventReceivedFn,
6
+ RecordDeliveryOnlyFn,
7
+ StoredWebhookRequest,
8
+ WebhookProcessingResult,
9
+ } from '@shipfox/api-integration-core-dto';
10
+ import {decodeWebhookBody} from '@shipfox/api-integration-core-dto';
11
+ import {
12
+ slackEventsRequestSchema,
13
+ slackSlashCommandSchema,
14
+ } from '@shipfox/api-integration-slack-dto';
15
+ import {logger} from '@shipfox/node-opentelemetry';
16
+ import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
17
+ import {config} from '#config.js';
18
+ import {isSlackTimestampWithinReplayWindow, verifySlackSignature} from '#core/signature.js';
19
+ import {handleSlackCommand, handleSlackEvent, type SlackWebhookOutcome} from '#core/webhook.js';
20
+
21
+ const SIGNATURE_HEADER = 'x-slack-signature';
22
+ const TIMESTAMP_HEADER = 'x-slack-request-timestamp';
23
+
24
+ export interface CreateSlackWebhookProcessorOptions {
25
+ coreDb: () => NodePgDatabase<Record<string, unknown>>;
26
+ claimWebhookDelivery: ClaimWebhookDeliveryFn;
27
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
28
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
29
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
30
+ }
31
+
32
+ export type SlackWebhookProcessingResult = WebhookProcessingResult;
33
+
34
+ export interface SlackWebhookProcessor {
35
+ process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;
36
+ }
37
+
38
+ export function createSlackWebhookProcessor(
39
+ options: CreateSlackWebhookProcessorOptions,
40
+ ): SlackWebhookProcessor {
41
+ return {process: (request) => processSlackWebhookRequest(options, request)};
42
+ }
43
+
44
+ function processSlackWebhookRequest(
45
+ options: CreateSlackWebhookProcessorOptions,
46
+ request: StoredWebhookRequest,
47
+ ): Promise<WebhookProcessingResult> {
48
+ if (request.route_id !== 'slack.event' && request.route_id !== 'slack.command') {
49
+ throw new Error(`Slack processor cannot process ${request.route_id} requests`);
50
+ }
51
+
52
+ const signature = request.headers[SIGNATURE_HEADER];
53
+ const timestamp = request.headers[TIMESTAMP_HEADER];
54
+ if (!signature || !timestamp) {
55
+ return Promise.resolve({outcome: 'discarded', reason: 'missing_required_input'});
56
+ }
57
+
58
+ const rawBody = decodeWebhookBody(request.body);
59
+ const receiptTime = new Date(request.received_at).getTime();
60
+ if (!isSlackTimestampWithinReplayWindow(timestamp, receiptTime)) {
61
+ return Promise.resolve({outcome: 'discarded', reason: 'stale_at_receipt'});
62
+ }
63
+ if (
64
+ !verifySlackSignature({
65
+ signingSecret: config.SLACK_SIGNING_SECRET,
66
+ signature,
67
+ timestamp,
68
+ rawBody,
69
+ now: receiptTime,
70
+ })
71
+ ) {
72
+ return Promise.resolve({outcome: 'discarded', reason: 'invalid_signature'});
73
+ }
74
+
75
+ return request.route_id === 'slack.event'
76
+ ? processSlackEvent(options, rawBody)
77
+ : processSlackCommand(options, rawBody);
78
+ }
79
+
80
+ async function processSlackEvent(
81
+ options: CreateSlackWebhookProcessorOptions,
82
+ rawBody: Uint8Array,
83
+ ): Promise<WebhookProcessingResult> {
84
+ let rawPayload: unknown;
85
+ try {
86
+ rawPayload = JSON.parse(Buffer.from(rawBody).toString('utf8'));
87
+ } catch (error) {
88
+ logger().warn({err: error}, 'slack events payload JSON parse failed');
89
+ return {outcome: 'discarded', reason: 'malformed_payload'};
90
+ }
91
+
92
+ const event = slackEventsRequestSchema.safeParse(rawPayload);
93
+ if (!event.success) {
94
+ logger().warn({issues: event.error.issues}, 'slack events envelope failed schema validation');
95
+ return {outcome: 'discarded', reason: 'unsupported_event'};
96
+ }
97
+ const envelope = event.data;
98
+ if (envelope.type === 'url_verification') {
99
+ return {outcome: 'processed', challenge: envelope.challenge};
100
+ }
101
+
102
+ const result = await options.coreDb().transaction(async (tx) =>
103
+ handleSlackEvent({
104
+ tx,
105
+ deliveryId: envelope.event_id,
106
+ envelope,
107
+ claimWebhookDelivery: options.claimWebhookDelivery,
108
+ publishIntegrationEventReceived: options.publishIntegrationEventReceived,
109
+ recordDeliveryOnly: options.recordDeliveryOnly,
110
+ getIntegrationConnectionById: options.getIntegrationConnectionById,
111
+ }),
112
+ );
113
+ return toProcessingResult(result.outcome, envelope.event_id);
114
+ }
115
+
116
+ async function processSlackCommand(
117
+ options: CreateSlackWebhookProcessorOptions,
118
+ rawBody: Uint8Array,
119
+ ): Promise<WebhookProcessingResult> {
120
+ const command = slackSlashCommandSchema.safeParse(
121
+ Object.fromEntries(new URLSearchParams(Buffer.from(rawBody).toString('utf8'))),
122
+ );
123
+ if (!command.success) {
124
+ logger().warn({issues: command.error.issues}, 'slack command failed schema validation');
125
+ return {outcome: 'discarded', reason: 'unsupported_event'};
126
+ }
127
+
128
+ const result = await options.coreDb().transaction(async (tx) =>
129
+ handleSlackCommand({
130
+ tx,
131
+ deliveryId: command.data.trigger_id,
132
+ command: command.data,
133
+ publishIntegrationEventReceived: options.publishIntegrationEventReceived,
134
+ recordDeliveryOnly: options.recordDeliveryOnly,
135
+ getIntegrationConnectionById: options.getIntegrationConnectionById,
136
+ }),
137
+ );
138
+ return toProcessingResult(result.outcome, command.data.trigger_id);
139
+ }
140
+
141
+ function toProcessingResult(
142
+ outcome: SlackWebhookOutcome,
143
+ deliveryId: string,
144
+ ): WebhookProcessingResult {
145
+ if (outcome === 'published') return {outcome: 'processed', deliveryId};
146
+ if (outcome === 'duplicate') return {outcome: 'duplicate', deliveryId};
147
+ return {
148
+ outcome: 'discarded',
149
+ reason: outcome === 'unsupported-event' ? 'unsupported_event' : 'connection_unavailable',
150
+ deliveryId,
151
+ };
152
+ }
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,
@@ -147,12 +154,17 @@ export function createSlackIntegrationProvider(
147
154
  throw new Error('Slack webhook routes require every core persistence dependency');
148
155
  }
149
156
  const routes: RouteGroup[] = [];
157
+ const webhookProcessor =
158
+ options.routes && hasSlackWebhookRoutesOptions(options.routes)
159
+ ? createSlackWebhookProcessor(options.routes)
160
+ : undefined;
150
161
  if (options.routes && hasSlackInstallationRoutesOptions(options.routes)) {
151
162
  const {
152
163
  tokenStore,
153
164
  getExistingSlackConnection,
154
165
  connectSlackInstallation,
155
166
  disconnectSlackInstallation,
167
+ requireActiveWorkspaceMembership,
156
168
  } = options.routes;
157
169
  routes.push(
158
170
  createSlackIntegrationRoutes({
@@ -162,11 +174,12 @@ export function createSlackIntegrationProvider(
162
174
  getExistingSlackConnection,
163
175
  connectSlackInstallation,
164
176
  disconnectSlackInstallation,
177
+ ...(requireActiveWorkspaceMembership ? {requireActiveWorkspaceMembership} : {}),
165
178
  }),
166
179
  );
167
180
  }
168
- if (options.routes && hasSlackWebhookRoutesOptions(options.routes)) {
169
- routes.push(...createSlackWebhookRoutes(options.routes));
181
+ if (options.routes && hasSlackWebhookRoutesOptions(options.routes) && webhookProcessor) {
182
+ routes.push(...createSlackWebhookRoutes({...options.routes, processor: webhookProcessor}));
170
183
  }
171
184
 
172
185
  return {
@@ -180,6 +193,9 @@ export function createSlackIntegrationProvider(
180
193
  },
181
194
  ...options.cleanup,
182
195
  routes,
196
+ webhookProcessors: webhookProcessor
197
+ ? [{routeIds: ['slack.event', 'slack.command'] as const, processor: webhookProcessor}]
198
+ : undefined,
183
199
  };
184
200
  }
185
201
 
@@ -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});