@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
@@ -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
+ }
@@ -4,7 +4,7 @@ import type {
4
4
  IntegrationConnection,
5
5
  } from '@shipfox/api-integration-core-dto';
6
6
  import {db} from '#db/db.js';
7
- import {upsertSlackInstallation} from '#db/installations.js';
7
+ import {getSlackInstallationByTeamId, upsertSlackInstallation} from '#db/installations.js';
8
8
  import {slackInstallations} from '#db/schema/installations.js';
9
9
  import {handleSlackCommand, handleSlackEvent} from './webhook.js';
10
10
 
@@ -30,7 +30,7 @@ function eventEnvelope(overrides: Record<string, unknown> = {}) {
30
30
  api_app_id: 'A1',
31
31
  event: {type: 'app_mention', channel: 'C1', user: 'U1', text: 'hello', ts: '1.0'},
32
32
  event_id: 'Ev1',
33
- event_time: 1_721_300_000,
33
+ event_time: Math.floor(Date.now() / 1000) + 60,
34
34
  ...overrides,
35
35
  };
36
36
  }
@@ -69,6 +69,7 @@ function handlers(connection = fakeConnection()) {
69
69
  connection,
70
70
  publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
71
71
  recordDeliveryOnly: vi.fn(() => Promise.resolve()),
72
+ claimWebhookDelivery: vi.fn(() => Promise.resolve({claimed: true})),
72
73
  getIntegrationConnectionById: vi.fn<GetIntegrationConnectionByIdFn>(() =>
73
74
  Promise.resolve(connection),
74
75
  ),
@@ -126,6 +127,134 @@ describe('Slack webhook handlers', () => {
126
127
  );
127
128
  });
128
129
 
130
+ it('revokes an installation when Slack uninstalls the app', async () => {
131
+ const deps = handlers();
132
+ await seedInstallation(deps.connection);
133
+
134
+ const result = await handleSlackEvent({
135
+ tx: db(),
136
+ deliveryId: 'Ev-uninstalled',
137
+ envelope: eventEnvelope({event: {type: 'app_uninstalled'}}),
138
+ ...deps,
139
+ });
140
+
141
+ const installation = await getSlackInstallationByTeamId('T1');
142
+ expect(result.outcome).toBe('revoked');
143
+ expect(installation?.status).toBe('revoked');
144
+ expect(deps.claimWebhookDelivery).toHaveBeenCalledTimes(1);
145
+ expect(deps.publishIntegrationEventReceived).not.toHaveBeenCalled();
146
+ expect(deps.getIntegrationConnectionById).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('revokes an installation when its bot token is revoked', async () => {
150
+ const deps = handlers();
151
+ await seedInstallation(deps.connection);
152
+
153
+ const result = await handleSlackEvent({
154
+ tx: db(),
155
+ deliveryId: 'Ev-bot-revoked',
156
+ envelope: eventEnvelope({event: {type: 'tokens_revoked', tokens: {bot: ['UBOT']}}}),
157
+ ...deps,
158
+ });
159
+
160
+ const installation = await getSlackInstallationByTeamId('T1');
161
+ expect(result.outcome).toBe('revoked');
162
+ expect(installation?.status).toBe('revoked');
163
+ });
164
+
165
+ it.each([
166
+ ['an OAuth token is revoked', {oauth: ['UOAUTH']}],
167
+ ['the revoked token set is missing', undefined],
168
+ ['the revoked bot token set is empty', {bot: []}],
169
+ ])('keeps an installation active when %s', async (_description, tokens) => {
170
+ const deps = handlers();
171
+ await seedInstallation(deps.connection);
172
+
173
+ const result = await handleSlackEvent({
174
+ tx: db(),
175
+ deliveryId: 'Ev-oauth-revoked',
176
+ envelope: eventEnvelope({event: {type: 'tokens_revoked', tokens}}),
177
+ ...deps,
178
+ });
179
+
180
+ const installation = await getSlackInstallationByTeamId('T1');
181
+ expect(result.outcome).toBe('unaffected-revocation');
182
+ expect(installation?.status).toBe('installed');
183
+ expect(deps.publishIntegrationEventReceived).not.toHaveBeenCalled();
184
+ expect(deps.claimWebhookDelivery).toHaveBeenCalledTimes(1);
185
+ });
186
+
187
+ it('records an unknown lifecycle-event team without resolving a connection', async () => {
188
+ const deps = handlers();
189
+
190
+ const result = await handleSlackEvent({
191
+ tx: db(),
192
+ deliveryId: 'Ev-uninstalled-missing',
193
+ envelope: eventEnvelope({
194
+ team_id: 'T-missing',
195
+ event: {type: 'app_uninstalled'},
196
+ }),
197
+ ...deps,
198
+ });
199
+
200
+ expect(result.outcome).toBe('unknown-team');
201
+ expect(deps.getIntegrationConnectionById).not.toHaveBeenCalled();
202
+ expect(deps.claimWebhookDelivery).toHaveBeenCalledTimes(1);
203
+ });
204
+
205
+ it('records an already revoked lifecycle-event installation without throwing', async () => {
206
+ const deps = handlers();
207
+ await seedInstallation(deps.connection, 'revoked');
208
+
209
+ const result = await handleSlackEvent({
210
+ tx: db(),
211
+ deliveryId: 'Ev-uninstalled-replay',
212
+ envelope: eventEnvelope({event: {type: 'app_uninstalled'}}),
213
+ ...deps,
214
+ });
215
+
216
+ expect(result.outcome).toBe('revoked-installation');
217
+ expect(deps.claimWebhookDelivery).toHaveBeenCalledTimes(1);
218
+ });
219
+
220
+ it('does not revoke an installation after a duplicate lifecycle delivery', async () => {
221
+ const deps = handlers();
222
+ deps.claimWebhookDelivery.mockResolvedValue({claimed: false});
223
+ await seedInstallation(deps.connection);
224
+
225
+ const result = await handleSlackEvent({
226
+ tx: db(),
227
+ deliveryId: 'Ev-uninstalled-duplicate',
228
+ envelope: eventEnvelope({event: {type: 'app_uninstalled'}}),
229
+ ...deps,
230
+ });
231
+
232
+ const installation = await getSlackInstallationByTeamId('T1');
233
+ expect(result.outcome).toBe('duplicate');
234
+ expect(installation?.status).toBe('installed');
235
+ expect(deps.getIntegrationConnectionById).not.toHaveBeenCalled();
236
+ });
237
+
238
+ it('does not revoke a reconnected installation for a delayed lifecycle event', async () => {
239
+ const deps = handlers();
240
+ await seedInstallation(deps.connection);
241
+ await seedInstallation(deps.connection);
242
+
243
+ const result = await handleSlackEvent({
244
+ tx: db(),
245
+ deliveryId: 'Ev-uninstalled-stale',
246
+ envelope: eventEnvelope({
247
+ event: {type: 'app_uninstalled'},
248
+ event_time: Math.floor(Date.now() / 1000) - 60,
249
+ }),
250
+ ...deps,
251
+ });
252
+
253
+ const installation = await getSlackInstallationByTeamId('T1');
254
+ expect(result.outcome).toBe('stale-lifecycle-event');
255
+ expect(installation?.status).toBe('installed');
256
+ });
257
+
129
258
  it.each([
130
259
  ['a bot-authored event', {event: {type: 'message', bot_id: 'B1'}}],
131
260
  ['an event from this installation bot', {event: {type: 'reaction_added', user: 'UBOT'}}],
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ClaimWebhookDeliveryFn,
2
3
  GetIntegrationConnectionByIdFn,
3
4
  IntegrationConnection,
4
5
  IntegrationTx,
@@ -6,15 +7,23 @@ import type {
6
7
  RecordDeliveryOnlyFn,
7
8
  } from '@shipfox/api-integration-core-dto';
8
9
  import {
10
+ SLACK_APP_UNINSTALLED_EVENT,
9
11
  SLACK_PROVIDER,
10
12
  SLACK_SLASH_COMMAND_EVENT,
11
13
  type SlackEventBaseEnvelopeDto,
14
+ type SlackLifecycleEventType,
12
15
  type SlackSlashCommandDto,
13
16
  slackEventEnvelopeSchema,
17
+ slackLifecycleEventTypes,
18
+ slackTokensRevokedEventSchema,
14
19
  } from '@shipfox/api-integration-slack-dto';
15
20
  import {logger} from '@shipfox/node-opentelemetry';
16
21
  import {z} from 'zod';
17
- import {getSlackInstallationByTeamId, type SlackInstallation} from '#db/installations.js';
22
+ import {
23
+ getSlackInstallationByTeamId,
24
+ markSlackInstallationRevoked,
25
+ type SlackInstallation,
26
+ } from '#db/installations.js';
18
27
 
19
28
  const slackSelfAuthoredEventSchema = z
20
29
  .object({
@@ -35,6 +44,9 @@ export type SlackWebhookOutcome =
35
44
  | 'duplicate'
36
45
  | 'unknown-team'
37
46
  | 'revoked-installation'
47
+ | 'revoked'
48
+ | 'unaffected-revocation'
49
+ | 'stale-lifecycle-event'
38
50
  | 'missing-connection'
39
51
  | 'inactive-connection'
40
52
  | 'unsupported-event'
@@ -50,6 +62,7 @@ interface SlackWebhookParams {
50
62
 
51
63
  export interface HandleSlackEventParams extends SlackWebhookParams {
52
64
  envelope: SlackEventBaseEnvelopeDto;
65
+ claimWebhookDelivery: ClaimWebhookDeliveryFn;
53
66
  }
54
67
 
55
68
  export interface HandleSlackCommandParams extends SlackWebhookParams {
@@ -60,15 +73,25 @@ type SlackConnectionResolution =
60
73
  | {kind: 'ok'; connection: IntegrationConnection; installation: SlackInstallation}
61
74
  | {
62
75
  kind: 'drop';
63
- outcome: Exclude<
64
- SlackWebhookOutcome,
65
- 'published' | 'duplicate' | 'unsupported-event' | 'self-message'
66
- >;
76
+ outcome: SlackConnectionDropOutcome;
67
77
  };
68
78
 
79
+ type SlackCommandOutcome = Exclude<
80
+ SlackWebhookOutcome,
81
+ | 'unsupported-event'
82
+ | 'self-message'
83
+ | 'revoked'
84
+ | 'unaffected-revocation'
85
+ | 'stale-lifecycle-event'
86
+ >;
87
+ type SlackConnectionDropOutcome = Exclude<SlackCommandOutcome, 'published' | 'duplicate'>;
88
+
69
89
  export async function handleSlackEvent(
70
90
  params: HandleSlackEventParams,
71
91
  ): Promise<{outcome: SlackWebhookOutcome}> {
92
+ const lifecycleType = asSlackLifecycleEventType(params.envelope.event.type);
93
+ if (lifecycleType) return handleSlackLifecycleEvent(params, lifecycleType);
94
+
72
95
  const resolution = await resolveSlackConnection({
73
96
  ...params,
74
97
  teamId: params.envelope.team_id,
@@ -120,7 +143,7 @@ export async function handleSlackEvent(
120
143
 
121
144
  export async function handleSlackCommand(
122
145
  params: HandleSlackCommandParams,
123
- ): Promise<{outcome: Exclude<SlackWebhookOutcome, 'unsupported-event' | 'self-message'>}> {
146
+ ): Promise<{outcome: SlackCommandOutcome}> {
124
147
  const resolution = await resolveSlackConnection({
125
148
  ...params,
126
149
  teamId: params.command.team_id,
@@ -158,6 +181,120 @@ export function isSelfAuthoredSlackEvent(event: unknown, botUserId: string): boo
158
181
  );
159
182
  }
160
183
 
184
+ function asSlackLifecycleEventType(eventType: string): SlackLifecycleEventType | undefined {
185
+ return slackLifecycleEventTypes.find((type) => type === eventType);
186
+ }
187
+
188
+ async function handleSlackLifecycleEvent(
189
+ params: HandleSlackEventParams,
190
+ lifecycleType: SlackLifecycleEventType,
191
+ ): Promise<{outcome: SlackWebhookOutcome}> {
192
+ const claim = await params.claimWebhookDelivery({
193
+ tx: params.tx,
194
+ provider: SLACK_PROVIDER,
195
+ deliveryId: params.deliveryId,
196
+ });
197
+ if (!claim.claimed) {
198
+ logger().info(
199
+ {deliveryId: params.deliveryId, teamId: params.envelope.team_id, lifecycleType},
200
+ 'slack lifecycle event: duplicate delivery, dropping',
201
+ );
202
+ return {outcome: 'duplicate'};
203
+ }
204
+
205
+ const installation = await getSlackInstallationByTeamId(params.envelope.team_id, {tx: params.tx});
206
+ if (!installation) {
207
+ logger().warn(
208
+ {deliveryId: params.deliveryId, teamId: params.envelope.team_id, lifecycleType},
209
+ 'slack lifecycle event: unknown team, dropping',
210
+ );
211
+ return {outcome: 'unknown-team'};
212
+ }
213
+
214
+ if (installation.status !== 'installed') {
215
+ logger().info(
216
+ {
217
+ deliveryId: params.deliveryId,
218
+ teamId: params.envelope.team_id,
219
+ connectionId: installation.connectionId,
220
+ lifecycleType,
221
+ },
222
+ 'slack lifecycle event: installation is not installed, dropping',
223
+ );
224
+ return {outcome: 'revoked-installation'};
225
+ }
226
+
227
+ if (
228
+ isSlackLifecycleEventOlderThanInstallation(params.envelope.event_time, installation.updatedAt)
229
+ ) {
230
+ logger().info(
231
+ {
232
+ deliveryId: params.deliveryId,
233
+ teamId: params.envelope.team_id,
234
+ connectionId: installation.connectionId,
235
+ },
236
+ 'slack lifecycle event: predates the current installation, dropping',
237
+ );
238
+ return {outcome: 'stale-lifecycle-event'};
239
+ }
240
+
241
+ const revokesInstallation =
242
+ lifecycleType === SLACK_APP_UNINSTALLED_EVENT ||
243
+ slackTokensRevokedAffectsBot(params.envelope.event, installation.botUserId);
244
+ if (!revokesInstallation) {
245
+ logger().info(
246
+ {
247
+ deliveryId: params.deliveryId,
248
+ teamId: params.envelope.team_id,
249
+ connectionId: installation.connectionId,
250
+ },
251
+ 'slack lifecycle event: token revocation does not affect installation bot, dropping',
252
+ );
253
+ return {outcome: 'unaffected-revocation'};
254
+ }
255
+
256
+ const revoked = await markSlackInstallationRevoked(installation.connectionId, {
257
+ tx: params.tx,
258
+ expectedGeneration: installation.generation,
259
+ });
260
+ if (!revoked) {
261
+ logger().info(
262
+ {
263
+ deliveryId: params.deliveryId,
264
+ teamId: params.envelope.team_id,
265
+ connectionId: installation.connectionId,
266
+ },
267
+ 'slack lifecycle event: installation changed before revocation, dropping',
268
+ );
269
+ return {outcome: 'stale-lifecycle-event'};
270
+ }
271
+
272
+ logger().info(
273
+ {
274
+ deliveryId: params.deliveryId,
275
+ teamId: params.envelope.team_id,
276
+ connectionId: installation.connectionId,
277
+ lifecycleType,
278
+ },
279
+ 'slack lifecycle event: installation revoked',
280
+ );
281
+ return {outcome: 'revoked'};
282
+ }
283
+
284
+ function slackTokensRevokedAffectsBot(event: unknown, botUserId: string): boolean {
285
+ const parsed = slackTokensRevokedEventSchema.safeParse(event);
286
+ // Only a named bot token proves this installation credential was revoked; OAuth-only and missing lists do not.
287
+ return parsed.success && (parsed.data.tokens?.bot?.includes(botUserId) ?? false);
288
+ }
289
+
290
+ function isSlackLifecycleEventOlderThanInstallation(
291
+ eventTime: number,
292
+ installationUpdatedAt: Date,
293
+ ): boolean {
294
+ // Slack event_time has second precision, so an event stamped in the installation second is not provably stale.
295
+ return eventTime < Math.floor(installationUpdatedAt.getTime() / 1000);
296
+ }
297
+
161
298
  async function resolveSlackConnection(
162
299
  params: SlackWebhookParams & {teamId: string},
163
300
  ): Promise<SlackConnectionResolution> {
@@ -90,4 +90,19 @@ describe('slack installations', () => {
90
90
 
91
91
  expect(result?.status).toBe('revoked');
92
92
  });
93
+
94
+ it('does not revoke an installation updated after the lifecycle event was read', async () => {
95
+ const input = createInstallationInput();
96
+ const installation = await upsertSlackInstallation(input);
97
+ await upsertSlackInstallation(input);
98
+
99
+ const result = await markSlackInstallationRevoked(input.connectionId, {
100
+ expectedGeneration: installation.generation,
101
+ });
102
+
103
+ expect(result).toBeUndefined();
104
+ await expect(getSlackInstallationByConnectionId(input.connectionId)).resolves.toMatchObject({
105
+ status: 'installed',
106
+ });
107
+ });
93
108
  });
@@ -1,5 +1,5 @@
1
1
  import {isUniqueViolation} from '@shipfox/node-drizzle';
2
- import {eq} from 'drizzle-orm';
2
+ import {and, eq, sql} from 'drizzle-orm';
3
3
  import {
4
4
  SlackConnectionAlreadyLinkedError,
5
5
  SlackInstallationAlreadyLinkedError,
@@ -18,6 +18,7 @@ export interface SlackInstallation {
18
18
  botUserId: string;
19
19
  scopes: string[];
20
20
  status: SlackInstallationStatus;
21
+ generation: number;
21
22
  tokenExpiresAt: Date | null;
22
23
  createdAt: Date;
23
24
  updatedAt: Date;
@@ -69,6 +70,7 @@ export async function upsertSlackInstallation(
69
70
  botUserId: params.botUserId,
70
71
  scopes: params.scopes,
71
72
  status: params.status,
73
+ generation: sql`${slackInstallations.generation} + 1`,
72
74
  tokenExpiresAt: params.tokenExpiresAt ?? null,
73
75
  updatedAt: now,
74
76
  },
@@ -128,13 +130,19 @@ export async function deleteSlackInstallationByConnectionId(
128
130
 
129
131
  export async function markSlackInstallationRevoked(
130
132
  connectionId: string,
131
- options: {tx?: unknown} = {},
133
+ options: {tx?: unknown; expectedGeneration?: number | undefined} = {},
132
134
  ): Promise<SlackInstallation | undefined> {
133
135
  const executor = (options.tx ?? db()) as SlackDb | SlackTx;
136
+ const expectedInstallation = options.expectedGeneration
137
+ ? and(
138
+ eq(slackInstallations.connectionId, connectionId),
139
+ eq(slackInstallations.generation, options.expectedGeneration),
140
+ )
141
+ : eq(slackInstallations.connectionId, connectionId);
134
142
  const [row] = await executor
135
143
  .update(slackInstallations)
136
144
  .set({status: 'revoked', updatedAt: new Date()})
137
- .where(eq(slackInstallations.connectionId, connectionId))
145
+ .where(expectedInstallation)
138
146
  .returning();
139
147
  if (!row) return undefined;
140
148
  return toSlackInstallation(row);
@@ -1,5 +1,5 @@
1
1
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
- import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
2
+ import {integer, jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
3
  import type {SlackInstallation} from '#db/installations.js';
4
4
  import {pgTable} from './common.js';
5
5
 
@@ -14,6 +14,7 @@ export const slackInstallations = pgTable(
14
14
  botUserId: text('bot_user_id').notNull(),
15
15
  scopes: jsonb('scopes').notNull().$type<string[]>(),
16
16
  status: text('status').notNull().$type<SlackInstallation['status']>(),
17
+ generation: integer('generation').notNull().default(1),
17
18
  tokenExpiresAt: timestamp('token_expires_at', {withTimezone: true}),
18
19
  createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
19
20
  updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
@@ -37,6 +38,7 @@ export function toSlackInstallation(row: SlackInstallationDb): SlackInstallation
37
38
  botUserId: row.botUserId,
38
39
  scopes: row.scopes,
39
40
  status: row.status,
41
+ generation: row.generation,
40
42
  tokenExpiresAt: row.tokenExpiresAt,
41
43
  createdAt: row.createdAt,
42
44
  updatedAt: row.updatedAt,
package/src/index.test.ts CHANGED
@@ -22,4 +22,15 @@ describe('createSlackIntegrationProvider', () => {
22
22
 
23
23
  expect(catalog).toBe(slackAgentToolCatalog);
24
24
  });
25
+
26
+ it('exposes explicit connection cleanup without requiring routes', () => {
27
+ const deleteConnectionRecords = vi.fn(() => Promise.resolve());
28
+ const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
29
+ const provider = createSlackIntegrationProvider({
30
+ cleanup: {deleteConnectionRecords, deleteConnectionSecrets},
31
+ });
32
+
33
+ expect(provider.deleteConnectionRecords).toBe(deleteConnectionRecords);
34
+ expect(provider.deleteConnectionSecrets).toBe(deleteConnectionSecrets);
35
+ });
25
36
  });