@shipfox/api-integration-slack 4.0.0 → 5.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 (46) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +24 -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/webhook.d.ts +5 -3
  7. package/dist/core/webhook.d.ts.map +1 -1
  8. package/dist/core/webhook.js +101 -2
  9. package/dist/core/webhook.js.map +1 -1
  10. package/dist/db/db.d.ts +34 -0
  11. package/dist/db/db.d.ts.map +1 -1
  12. package/dist/db/installations.d.ts +2 -0
  13. package/dist/db/installations.d.ts.map +1 -1
  14. package/dist/db/installations.js +4 -2
  15. package/dist/db/installations.js.map +1 -1
  16. package/dist/db/schema/installations.d.ts +17 -0
  17. package/dist/db/schema/installations.d.ts.map +1 -1
  18. package/dist/db/schema/installations.js +3 -1
  19. package/dist/db/schema/installations.js.map +1 -1
  20. package/dist/index.d.ts +21 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/presentation/routes/webhooks.d.ts +2 -1
  25. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  26. package/dist/presentation/routes/webhooks.js +1 -0
  27. package/dist/presentation/routes/webhooks.js.map +1 -1
  28. package/dist/tsconfig.test.tsbuildinfo +1 -1
  29. package/drizzle/0001_dark_tyrannus.sql +1 -0
  30. package/drizzle/meta/0001_snapshot.json +139 -0
  31. package/drizzle/meta/_journal.json +7 -0
  32. package/package.json +18 -28
  33. package/src/api/client.test.ts +24 -0
  34. package/src/api/client.ts +3 -1
  35. package/src/core/disconnect.test.ts +49 -0
  36. package/src/core/webhook.test.ts +131 -2
  37. package/src/core/webhook.ts +143 -6
  38. package/src/db/installations.test.ts +15 -0
  39. package/src/db/installations.ts +11 -3
  40. package/src/db/schema/installations.ts +3 -1
  41. package/src/index.test.ts +11 -0
  42. package/src/index.ts +11 -0
  43. package/src/presentation/routes/webhooks.test.ts +28 -1
  44. package/src/presentation/routes/webhooks.ts +3 -0
  45. package/tsconfig.build.tsbuildinfo +1 -1
  46. package/turbo.json +9 -0
@@ -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
  });
package/src/index.ts CHANGED
@@ -113,6 +113,15 @@ export interface CreateSlackIntegrationProviderOptions {
113
113
  slack?: SlackApiClient | undefined;
114
114
  agentTools?: {tokenStore: Pick<SlackTokenStore, 'getAccessToken'>} | undefined;
115
115
  getSlackInstallationByConnectionId?: typeof getSlackInstallationByConnectionId | undefined;
116
+ cleanup?:
117
+ | {
118
+ deleteConnectionRecords?: (
119
+ connection: {id: string},
120
+ options: {tx: unknown},
121
+ ) => Promise<void>;
122
+ deleteConnectionSecrets?: (connection: {id: string; workspaceId: string}) => Promise<void>;
123
+ }
124
+ | undefined;
116
125
  routes?: SlackRouteOptions | undefined;
117
126
  }
118
127
 
@@ -169,6 +178,7 @@ export function createSlackIntegrationProvider(
169
178
  if (!installation) return undefined;
170
179
  return `https://app.slack.com/client/${encodeURIComponent(installation.teamId)}`;
171
180
  },
181
+ ...options.cleanup,
172
182
  routes,
173
183
  };
174
184
  }
@@ -189,6 +199,7 @@ function hasSlackWebhookRoutesOptions(
189
199
  ): routes is CreateSlackWebhookRoutesOptions {
190
200
  return (
191
201
  routes.coreDb !== undefined &&
202
+ routes.claimWebhookDelivery !== undefined &&
192
203
  routes.publishIntegrationEventReceived !== undefined &&
193
204
  routes.recordDeliveryOnly !== undefined &&
194
205
  routes.getIntegrationConnectionById !== undefined
@@ -2,7 +2,7 @@ import {createHmac, randomUUID} from 'node:crypto';
2
2
  import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
3
  import {closeApp, createApp} from '@shipfox/node-fastify';
4
4
  import {db} from '#db/db.js';
5
- import {upsertSlackInstallation} from '#db/installations.js';
5
+ import {getSlackInstallationByTeamId, upsertSlackInstallation} from '#db/installations.js';
6
6
  import {slackInstallations} from '#db/schema/installations.js';
7
7
  import {createSlackWebhookRoutes, SLASH_COMMAND_ACK} from './webhooks.js';
8
8
 
@@ -46,10 +46,12 @@ async function createTestApp(connection = fakeConnection()): Promise<{
46
46
  }> {
47
47
  const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
48
48
  const recordDeliveryOnly = vi.fn(() => Promise.resolve());
49
+ const claimWebhookDelivery = vi.fn(() => Promise.resolve({claimed: true}));
49
50
  const getIntegrationConnectionById = vi.fn(() => Promise.resolve(connection));
50
51
  const app = await createApp({
51
52
  routes: createSlackWebhookRoutes({
52
53
  coreDb: db,
54
+ claimWebhookDelivery,
53
55
  publishIntegrationEventReceived,
54
56
  recordDeliveryOnly,
55
57
  getIntegrationConnectionById,
@@ -186,6 +188,31 @@ describe('Slack webhook routes', () => {
186
188
  );
187
189
  });
188
190
 
191
+ it('revokes an installation after a signed app-uninstalled event', async () => {
192
+ const {app, connection, publishIntegrationEventReceived} = await createTestApp();
193
+ await seedInstallation(connection);
194
+ const rawBody = JSON.stringify({
195
+ type: 'event_callback',
196
+ team_id: 'T1',
197
+ api_app_id: 'A1',
198
+ event: {type: 'app_uninstalled'},
199
+ event_id: 'Ev-route-uninstalled',
200
+ event_time: Math.floor(Date.now() / 1000) + 60,
201
+ });
202
+
203
+ const response = await app.inject({
204
+ method: 'POST',
205
+ url: '/webhooks/integrations/slack/events',
206
+ headers: slackHeaders(rawBody, 'application/json'),
207
+ payload: rawBody,
208
+ });
209
+
210
+ const installation = await getSlackInstallationByTeamId('T1');
211
+ expect(response.statusCode).toBe(200);
212
+ expect(installation?.status).toBe('revoked');
213
+ expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
214
+ });
215
+
189
216
  it('returns the fixed acknowledgement for a signed command that cannot resolve a team', async () => {
190
217
  const {app, publishIntegrationEventReceived, recordDeliveryOnly} = await createTestApp();
191
218
  const rawBody = new URLSearchParams({
@@ -1,5 +1,6 @@
1
1
  import {Buffer} from 'node:buffer';
2
2
  import type {
3
+ ClaimWebhookDeliveryFn,
3
4
  GetIntegrationConnectionByIdFn,
4
5
  PublishIntegrationEventReceivedFn,
5
6
  RecordDeliveryOnlyFn,
@@ -29,6 +30,7 @@ const slackFormRawBodyPlugin = createRawBodyPlugin({
29
30
 
30
31
  export interface CreateSlackWebhookRoutesOptions {
31
32
  coreDb: () => NodePgDatabase<Record<string, unknown>>;
33
+ claimWebhookDelivery: ClaimWebhookDeliveryFn;
32
34
  publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
33
35
  recordDeliveryOnly: RecordDeliveryOnlyFn;
34
36
  getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
@@ -84,6 +86,7 @@ export function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOption
84
86
  tx,
85
87
  deliveryId: eventRequest.event_id,
86
88
  envelope: eventRequest,
89
+ claimWebhookDelivery: options.claimWebhookDelivery,
87
90
  publishIntegrationEventReceived: options.publishIntegrationEventReceived,
88
91
  recordDeliveryOnly: options.recordDeliveryOnly,
89
92
  getIntegrationConnectionById: options.getIntegrationConnectionById,