@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +24 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +3 -1
- package/dist/api/client.js.map +1 -1
- package/dist/core/webhook.d.ts +5 -3
- package/dist/core/webhook.d.ts.map +1 -1
- package/dist/core/webhook.js +101 -2
- package/dist/core/webhook.js.map +1 -1
- package/dist/db/db.d.ts +34 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +2 -0
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +4 -2
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +17 -0
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +3 -1
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +2 -1
- package/dist/presentation/routes/webhooks.d.ts.map +1 -1
- package/dist/presentation/routes/webhooks.js +1 -0
- package/dist/presentation/routes/webhooks.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_dark_tyrannus.sql +1 -0
- package/drizzle/meta/0001_snapshot.json +139 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +18 -28
- package/src/api/client.test.ts +24 -0
- package/src/api/client.ts +3 -1
- package/src/core/disconnect.test.ts +49 -0
- package/src/core/webhook.test.ts +131 -2
- package/src/core/webhook.ts +143 -6
- package/src/db/installations.test.ts +15 -0
- package/src/db/installations.ts +11 -3
- package/src/db/schema/installations.ts +3 -1
- package/src/index.test.ts +11 -0
- package/src/index.ts +11 -0
- package/src/presentation/routes/webhooks.test.ts +28 -1
- package/src/presentation/routes/webhooks.ts +3 -0
- package/tsconfig.build.tsbuildinfo +1 -1
- package/turbo.json +9 -0
package/src/core/webhook.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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:
|
|
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
|
});
|
package/src/db/installations.ts
CHANGED
|
@@ -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(
|
|
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,
|