@shipfox/api-integration-slack 4.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 (158) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +22 -0
  5. package/LICENSE +21 -0
  6. package/dist/api/client.d.ts +28 -0
  7. package/dist/api/client.d.ts.map +1 -0
  8. package/dist/api/client.js +163 -0
  9. package/dist/api/client.js.map +1 -0
  10. package/dist/config.d.ts +8 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +21 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/core/agent-tools-provider.d.ts +75 -0
  15. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  16. package/dist/core/agent-tools-provider.js +111 -0
  17. package/dist/core/agent-tools-provider.js.map +1 -0
  18. package/dist/core/agent-tools.d.ts +65 -0
  19. package/dist/core/agent-tools.d.ts.map +1 -0
  20. package/dist/core/agent-tools.js +177 -0
  21. package/dist/core/agent-tools.js.map +1 -0
  22. package/dist/core/disconnect.d.ts +18 -0
  23. package/dist/core/disconnect.d.ts.map +1 -0
  24. package/dist/core/disconnect.js +23 -0
  25. package/dist/core/disconnect.js.map +1 -0
  26. package/dist/core/errors.d.ts +44 -0
  27. package/dist/core/errors.d.ts.map +1 -0
  28. package/dist/core/errors.js +71 -0
  29. package/dist/core/errors.js.map +1 -0
  30. package/dist/core/install.d.ts +48 -0
  31. package/dist/core/install.d.ts.map +1 -0
  32. package/dist/core/install.js +88 -0
  33. package/dist/core/install.js.map +1 -0
  34. package/dist/core/scopes.d.ts +4 -0
  35. package/dist/core/scopes.d.ts.map +1 -0
  36. package/dist/core/scopes.js +24 -0
  37. package/dist/core/scopes.js.map +1 -0
  38. package/dist/core/signature.d.ts +10 -0
  39. package/dist/core/signature.d.ts.map +1 -0
  40. package/dist/core/signature.js +18 -0
  41. package/dist/core/signature.js.map +1 -0
  42. package/dist/core/state.d.ts +12 -0
  43. package/dist/core/state.d.ts.map +1 -0
  44. package/dist/core/state.js +54 -0
  45. package/dist/core/state.js.map +1 -0
  46. package/dist/core/tokens.d.ts +35 -0
  47. package/dist/core/tokens.d.ts.map +1 -0
  48. package/dist/core/tokens.js +51 -0
  49. package/dist/core/tokens.js.map +1 -0
  50. package/dist/core/webhook.d.ts +25 -0
  51. package/dist/core/webhook.d.ts.map +1 -0
  52. package/dist/core/webhook.js +160 -0
  53. package/dist/core/webhook.js.map +1 -0
  54. package/dist/db/db.d.ts +403 -0
  55. package/dist/db/db.d.ts.map +1 -0
  56. package/dist/db/db.js +18 -0
  57. package/dist/db/db.js.map +1 -0
  58. package/dist/db/installations.d.ts +40 -0
  59. package/dist/db/installations.d.ts.map +1 -0
  60. package/dist/db/installations.js +73 -0
  61. package/dist/db/installations.js.map +1 -0
  62. package/dist/db/migrations.d.ts +2 -0
  63. package/dist/db/migrations.d.ts.map +1 -0
  64. package/dist/db/migrations.js +5 -0
  65. package/dist/db/migrations.js.map +1 -0
  66. package/dist/db/schema/common.d.ts +2 -0
  67. package/dist/db/schema/common.d.ts.map +1 -0
  68. package/dist/db/schema/common.js +4 -0
  69. package/dist/db/schema/common.js.map +1 -0
  70. package/dist/db/schema/installations.d.ts +203 -0
  71. package/dist/db/schema/installations.d.ts.map +1 -0
  72. package/dist/db/schema/installations.js +42 -0
  73. package/dist/db/schema/installations.js.map +1 -0
  74. package/dist/index.d.ts +59 -0
  75. package/dist/index.d.ts.map +1 -0
  76. package/dist/index.js +73 -0
  77. package/dist/index.js.map +1 -0
  78. package/dist/presentation/dto/integrations.d.ts +2 -0
  79. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  80. package/dist/presentation/dto/integrations.js +3 -0
  81. package/dist/presentation/dto/integrations.js.map +1 -0
  82. package/dist/presentation/e2eRoutes/create-connection.d.ts +16 -0
  83. package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
  84. package/dist/presentation/e2eRoutes/create-connection.js +65 -0
  85. package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
  86. package/dist/presentation/e2eRoutes/index.d.ts +5 -0
  87. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  88. package/dist/presentation/e2eRoutes/index.js +11 -0
  89. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  90. package/dist/presentation/routes/errors.d.ts +2 -0
  91. package/dist/presentation/routes/errors.d.ts.map +1 -0
  92. package/dist/presentation/routes/errors.js +75 -0
  93. package/dist/presentation/routes/errors.js.map +1 -0
  94. package/dist/presentation/routes/install.d.ts +19 -0
  95. package/dist/presentation/routes/install.d.ts.map +1 -0
  96. package/dist/presentation/routes/install.js +98 -0
  97. package/dist/presentation/routes/install.js.map +1 -0
  98. package/dist/presentation/routes/webhooks.d.ts +16 -0
  99. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  100. package/dist/presentation/routes/webhooks.js +166 -0
  101. package/dist/presentation/routes/webhooks.js.map +1 -0
  102. package/dist/tsconfig.test.tsbuildinfo +1 -0
  103. package/drizzle/0000_calm_fantastic_four.sql +16 -0
  104. package/drizzle/meta/0000_snapshot.json +132 -0
  105. package/drizzle/meta/_journal.json +13 -0
  106. package/drizzle.config.ts +7 -0
  107. package/package.json +71 -0
  108. package/src/api/client.test.ts +239 -0
  109. package/src/api/client.ts +243 -0
  110. package/src/config.test.ts +32 -0
  111. package/src/config.ts +20 -0
  112. package/src/connection-external-url.test.ts +25 -0
  113. package/src/core/agent-tools-provider.test.ts +256 -0
  114. package/src/core/agent-tools-provider.ts +147 -0
  115. package/src/core/agent-tools.test.ts +90 -0
  116. package/src/core/agent-tools.ts +189 -0
  117. package/src/core/disconnect.ts +26 -0
  118. package/src/core/errors.ts +92 -0
  119. package/src/core/install.test.ts +155 -0
  120. package/src/core/install.ts +137 -0
  121. package/src/core/scopes.test.ts +22 -0
  122. package/src/core/scopes.ts +25 -0
  123. package/src/core/signature.test.ts +45 -0
  124. package/src/core/signature.ts +41 -0
  125. package/src/core/state.test.ts +48 -0
  126. package/src/core/state.ts +82 -0
  127. package/src/core/tokens.test.ts +163 -0
  128. package/src/core/tokens.ts +98 -0
  129. package/src/core/webhook.test.ts +269 -0
  130. package/src/core/webhook.ts +223 -0
  131. package/src/db/db.ts +16 -0
  132. package/src/db/installations.test.ts +93 -0
  133. package/src/db/installations.ts +141 -0
  134. package/src/db/migrations.ts +4 -0
  135. package/src/db/schema/common.ts +3 -0
  136. package/src/db/schema/installations.ts +44 -0
  137. package/src/index.test.ts +25 -0
  138. package/src/index.ts +196 -0
  139. package/src/presentation/dto/integrations.ts +1 -0
  140. package/src/presentation/e2eRoutes/create-connection.ts +82 -0
  141. package/src/presentation/e2eRoutes/index.test.ts +96 -0
  142. package/src/presentation/e2eRoutes/index.ts +11 -0
  143. package/src/presentation/routes/errors.ts +68 -0
  144. package/src/presentation/routes/install.test.ts +229 -0
  145. package/src/presentation/routes/install.ts +117 -0
  146. package/src/presentation/routes/webhooks.test.ts +266 -0
  147. package/src/presentation/routes/webhooks.ts +170 -0
  148. package/test/api-secrets.d.ts +26 -0
  149. package/test/env.ts +13 -0
  150. package/test/factories/index.ts +1 -0
  151. package/test/globalSetup.ts +23 -0
  152. package/test/index.ts +1 -0
  153. package/test/setup.ts +17 -0
  154. package/tsconfig.build.json +9 -0
  155. package/tsconfig.build.tsbuildinfo +1 -0
  156. package/tsconfig.json +3 -0
  157. package/tsconfig.test.json +9 -0
  158. package/vitest.config.ts +12 -0
@@ -0,0 +1,269 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import type {
3
+ GetIntegrationConnectionByIdFn,
4
+ IntegrationConnection,
5
+ } from '@shipfox/api-integration-core-dto';
6
+ import {db} from '#db/db.js';
7
+ import {upsertSlackInstallation} from '#db/installations.js';
8
+ import {slackInstallations} from '#db/schema/installations.js';
9
+ import {handleSlackCommand, handleSlackEvent} from './webhook.js';
10
+
11
+ function fakeConnection(overrides: Partial<IntegrationConnection> = {}): 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
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ function eventEnvelope(overrides: Record<string, unknown> = {}) {
27
+ return {
28
+ type: 'event_callback' as const,
29
+ team_id: 'T1',
30
+ api_app_id: 'A1',
31
+ event: {type: 'app_mention', channel: 'C1', user: 'U1', text: 'hello', ts: '1.0'},
32
+ event_id: 'Ev1',
33
+ event_time: 1_721_300_000,
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function command(overrides: Record<string, unknown> = {}) {
39
+ return {
40
+ token: 'shared-verification-token',
41
+ command: '/deploy',
42
+ team_id: 'T1',
43
+ channel_id: 'C1',
44
+ user_id: 'U1',
45
+ response_url: 'https://hooks.slack.com/commands/1',
46
+ trigger_id: '1337.42',
47
+ text: '',
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ async function seedInstallation(
53
+ connection: IntegrationConnection,
54
+ status: 'installed' | 'revoked' = 'installed',
55
+ ) {
56
+ await upsertSlackInstallation({
57
+ connectionId: connection.id,
58
+ teamId: 'T1',
59
+ teamName: 'Acme',
60
+ appId: 'A1',
61
+ botUserId: 'UBOT',
62
+ scopes: [],
63
+ status,
64
+ });
65
+ }
66
+
67
+ function handlers(connection = fakeConnection()) {
68
+ return {
69
+ connection,
70
+ publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
71
+ recordDeliveryOnly: vi.fn(() => Promise.resolve()),
72
+ getIntegrationConnectionById: vi.fn<GetIntegrationConnectionByIdFn>(() =>
73
+ Promise.resolve(connection),
74
+ ),
75
+ };
76
+ }
77
+
78
+ describe('Slack webhook handlers', () => {
79
+ beforeEach(async () => {
80
+ await db().delete(slackInstallations);
81
+ });
82
+
83
+ it('publishes a supported event with a flattened payload', async () => {
84
+ const deps = handlers();
85
+ await seedInstallation(deps.connection);
86
+
87
+ const result = await handleSlackEvent({
88
+ tx: db(),
89
+ deliveryId: 'Ev1',
90
+ envelope: eventEnvelope(),
91
+ ...deps,
92
+ });
93
+
94
+ expect(result.outcome).toBe('published');
95
+ expect(deps.publishIntegrationEventReceived).toHaveBeenCalledWith(
96
+ expect.objectContaining({
97
+ event: expect.objectContaining({
98
+ event: 'app_mention',
99
+ deliveryId: 'Ev1',
100
+ payload: expect.objectContaining({
101
+ type: 'app_mention',
102
+ channel: 'C1',
103
+ team_id: 'T1',
104
+ event_id: 'Ev1',
105
+ }),
106
+ }),
107
+ }),
108
+ );
109
+ });
110
+
111
+ it('reports a duplicate for a repeated Slack event delivery id', async () => {
112
+ const deps = handlers();
113
+ deps.publishIntegrationEventReceived.mockResolvedValue({published: false});
114
+ await seedInstallation(deps.connection);
115
+
116
+ const result = await handleSlackEvent({
117
+ tx: db(),
118
+ deliveryId: 'Ev-replayed',
119
+ envelope: eventEnvelope({event_id: 'Ev-replayed'}),
120
+ ...deps,
121
+ });
122
+
123
+ expect(result.outcome).toBe('duplicate');
124
+ expect(deps.publishIntegrationEventReceived).toHaveBeenCalledWith(
125
+ expect.objectContaining({event: expect.objectContaining({deliveryId: 'Ev-replayed'})}),
126
+ );
127
+ });
128
+
129
+ it.each([
130
+ ['a bot-authored event', {event: {type: 'message', bot_id: 'B1'}}],
131
+ ['an event from this installation bot', {event: {type: 'reaction_added', user: 'UBOT'}}],
132
+ [
133
+ 'a nested edited bot message',
134
+ {event: {type: 'message', subtype: 'message_changed', message: {bot_id: 'B1'}}},
135
+ ],
136
+ ])('records and drops %s', async (_description, override) => {
137
+ const deps = handlers();
138
+ await seedInstallation(deps.connection);
139
+
140
+ const result = await handleSlackEvent({
141
+ tx: db(),
142
+ deliveryId: randomUUID(),
143
+ envelope: eventEnvelope(override),
144
+ ...deps,
145
+ });
146
+
147
+ expect(result.outcome).toBe('self-message');
148
+ expect(deps.publishIntegrationEventReceived).not.toHaveBeenCalled();
149
+ expect(deps.recordDeliveryOnly).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ it('records an unknown team without resolving a connection', async () => {
153
+ const deps = handlers();
154
+
155
+ const result = await handleSlackEvent({
156
+ tx: db(),
157
+ deliveryId: 'Ev-missing',
158
+ envelope: eventEnvelope({team_id: 'T-missing'}),
159
+ ...deps,
160
+ });
161
+
162
+ expect(result.outcome).toBe('unknown-team');
163
+ expect(deps.getIntegrationConnectionById).not.toHaveBeenCalled();
164
+ expect(deps.recordDeliveryOnly).toHaveBeenCalledWith(
165
+ expect.objectContaining({provider: 'slack', deliveryId: 'Ev-missing'}),
166
+ );
167
+ });
168
+
169
+ it('records an inactive connection', async () => {
170
+ const deps = handlers(fakeConnection({lifecycleStatus: 'disabled'}));
171
+ await seedInstallation(deps.connection);
172
+
173
+ const result = await handleSlackEvent({
174
+ tx: db(),
175
+ deliveryId: 'Ev-disabled',
176
+ envelope: eventEnvelope(),
177
+ ...deps,
178
+ });
179
+
180
+ expect(result.outcome).toBe('inactive-connection');
181
+ expect(deps.publishIntegrationEventReceived).not.toHaveBeenCalled();
182
+ expect(deps.recordDeliveryOnly).toHaveBeenCalledTimes(1);
183
+ });
184
+
185
+ it('records a revoked installation without resolving a connection', async () => {
186
+ const deps = handlers();
187
+ await seedInstallation(deps.connection, 'revoked');
188
+
189
+ const result = await handleSlackEvent({
190
+ tx: db(),
191
+ deliveryId: 'Ev-revoked',
192
+ envelope: eventEnvelope(),
193
+ ...deps,
194
+ });
195
+
196
+ expect(result.outcome).toBe('revoked-installation');
197
+ expect(deps.getIntegrationConnectionById).not.toHaveBeenCalled();
198
+ expect(deps.recordDeliveryOnly).toHaveBeenCalledTimes(1);
199
+ });
200
+
201
+ it('records an installation whose connection is missing', async () => {
202
+ const deps = handlers();
203
+ deps.getIntegrationConnectionById.mockResolvedValue(undefined);
204
+ await seedInstallation(deps.connection);
205
+
206
+ const result = await handleSlackEvent({
207
+ tx: db(),
208
+ deliveryId: 'Ev-missing-connection',
209
+ envelope: eventEnvelope(),
210
+ ...deps,
211
+ });
212
+
213
+ expect(result.outcome).toBe('missing-connection');
214
+ expect(deps.recordDeliveryOnly).toHaveBeenCalledTimes(1);
215
+ });
216
+
217
+ it('drops unsupported event types after resolving the installation', async () => {
218
+ const deps = handlers();
219
+ await seedInstallation(deps.connection);
220
+
221
+ const result = await handleSlackEvent({
222
+ tx: db(),
223
+ deliveryId: 'Ev-unsupported',
224
+ envelope: eventEnvelope({event: {type: 'channel_created'}}),
225
+ ...deps,
226
+ });
227
+
228
+ expect(result.outcome).toBe('unsupported-event');
229
+ expect(deps.publishIntegrationEventReceived).not.toHaveBeenCalled();
230
+ expect(deps.recordDeliveryOnly).toHaveBeenCalledTimes(1);
231
+ });
232
+
233
+ it('publishes a command without its verification token', async () => {
234
+ const deps = handlers();
235
+ await seedInstallation(deps.connection);
236
+
237
+ const result = await handleSlackCommand({
238
+ tx: db(),
239
+ deliveryId: '1337.42',
240
+ command: command(),
241
+ ...deps,
242
+ });
243
+
244
+ expect(result.outcome).toBe('published');
245
+ expect(deps.publishIntegrationEventReceived).toHaveBeenCalledWith(
246
+ expect.objectContaining({
247
+ event: expect.objectContaining({
248
+ event: 'slash_command',
249
+ payload: expect.not.objectContaining({token: expect.anything()}),
250
+ }),
251
+ }),
252
+ );
253
+ });
254
+
255
+ it('reports a duplicate slash command delivery', async () => {
256
+ const deps = handlers();
257
+ deps.publishIntegrationEventReceived.mockResolvedValue({published: false});
258
+ await seedInstallation(deps.connection);
259
+
260
+ const result = await handleSlackCommand({
261
+ tx: db(),
262
+ deliveryId: '1337.42',
263
+ command: command(),
264
+ ...deps,
265
+ });
266
+
267
+ expect(result.outcome).toBe('duplicate');
268
+ });
269
+ });
@@ -0,0 +1,223 @@
1
+ import type {
2
+ GetIntegrationConnectionByIdFn,
3
+ IntegrationConnection,
4
+ IntegrationTx,
5
+ PublishIntegrationEventReceivedFn,
6
+ RecordDeliveryOnlyFn,
7
+ } from '@shipfox/api-integration-core-dto';
8
+ import {
9
+ SLACK_PROVIDER,
10
+ SLACK_SLASH_COMMAND_EVENT,
11
+ type SlackEventBaseEnvelopeDto,
12
+ type SlackSlashCommandDto,
13
+ slackEventEnvelopeSchema,
14
+ } from '@shipfox/api-integration-slack-dto';
15
+ import {logger} from '@shipfox/node-opentelemetry';
16
+ import {z} from 'zod';
17
+ import {getSlackInstallationByTeamId, type SlackInstallation} from '#db/installations.js';
18
+
19
+ const slackSelfAuthoredEventSchema = z
20
+ .object({
21
+ bot_id: z.string().optional(),
22
+ user: z.string().optional(),
23
+ message: z
24
+ .object({
25
+ bot_id: z.string().optional(),
26
+ user: z.string().optional(),
27
+ })
28
+ .passthrough()
29
+ .optional(),
30
+ })
31
+ .passthrough();
32
+
33
+ export type SlackWebhookOutcome =
34
+ | 'published'
35
+ | 'duplicate'
36
+ | 'unknown-team'
37
+ | 'revoked-installation'
38
+ | 'missing-connection'
39
+ | 'inactive-connection'
40
+ | 'unsupported-event'
41
+ | 'self-message';
42
+
43
+ interface SlackWebhookParams {
44
+ tx: IntegrationTx;
45
+ deliveryId: string;
46
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
47
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
48
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
49
+ }
50
+
51
+ export interface HandleSlackEventParams extends SlackWebhookParams {
52
+ envelope: SlackEventBaseEnvelopeDto;
53
+ }
54
+
55
+ export interface HandleSlackCommandParams extends SlackWebhookParams {
56
+ command: SlackSlashCommandDto;
57
+ }
58
+
59
+ type SlackConnectionResolution =
60
+ | {kind: 'ok'; connection: IntegrationConnection; installation: SlackInstallation}
61
+ | {
62
+ kind: 'drop';
63
+ outcome: Exclude<
64
+ SlackWebhookOutcome,
65
+ 'published' | 'duplicate' | 'unsupported-event' | 'self-message'
66
+ >;
67
+ };
68
+
69
+ export async function handleSlackEvent(
70
+ params: HandleSlackEventParams,
71
+ ): Promise<{outcome: SlackWebhookOutcome}> {
72
+ const resolution = await resolveSlackConnection({
73
+ ...params,
74
+ teamId: params.envelope.team_id,
75
+ });
76
+ if (resolution.kind === 'drop') return resolution;
77
+
78
+ if (isSelfAuthoredSlackEvent(params.envelope.event, resolution.installation.botUserId)) {
79
+ await recordSlackDeliveryOnly(params);
80
+ return {outcome: 'self-message'};
81
+ }
82
+
83
+ const supported = slackEventEnvelopeSchema.safeParse(params.envelope);
84
+ if (!supported.success) {
85
+ logger().info(
86
+ {
87
+ deliveryId: params.deliveryId,
88
+ teamId: params.envelope.team_id,
89
+ type: params.envelope.event.type,
90
+ },
91
+ 'slack webhook: unsupported event, dropping',
92
+ );
93
+ await recordSlackDeliveryOnly(params);
94
+ return {outcome: 'unsupported-event'};
95
+ }
96
+
97
+ const result = await params.publishIntegrationEventReceived({
98
+ tx: params.tx,
99
+ event: {
100
+ provider: SLACK_PROVIDER,
101
+ source: resolution.connection.slug,
102
+ event: supported.data.event.type,
103
+ workspaceId: resolution.connection.workspaceId,
104
+ connectionId: resolution.connection.id,
105
+ connectionName: resolution.connection.displayName,
106
+ deliveryId: params.deliveryId,
107
+ receivedAt: new Date().toISOString(),
108
+ payload: {
109
+ ...supported.data.event,
110
+ team_id: supported.data.team_id,
111
+ api_app_id: supported.data.api_app_id,
112
+ event_id: supported.data.event_id,
113
+ event_time: supported.data.event_time,
114
+ },
115
+ },
116
+ });
117
+
118
+ return {outcome: result.published ? 'published' : 'duplicate'};
119
+ }
120
+
121
+ export async function handleSlackCommand(
122
+ params: HandleSlackCommandParams,
123
+ ): Promise<{outcome: Exclude<SlackWebhookOutcome, 'unsupported-event' | 'self-message'>}> {
124
+ const resolution = await resolveSlackConnection({
125
+ ...params,
126
+ teamId: params.command.team_id,
127
+ });
128
+ if (resolution.kind === 'drop') return resolution;
129
+
130
+ const {token: _token, ...payload} = params.command;
131
+ const result = await params.publishIntegrationEventReceived({
132
+ tx: params.tx,
133
+ event: {
134
+ provider: SLACK_PROVIDER,
135
+ source: resolution.connection.slug,
136
+ event: SLACK_SLASH_COMMAND_EVENT,
137
+ workspaceId: resolution.connection.workspaceId,
138
+ connectionId: resolution.connection.id,
139
+ connectionName: resolution.connection.displayName,
140
+ deliveryId: params.deliveryId,
141
+ receivedAt: new Date().toISOString(),
142
+ payload,
143
+ },
144
+ });
145
+
146
+ return {outcome: result.published ? 'published' : 'duplicate'};
147
+ }
148
+
149
+ export function isSelfAuthoredSlackEvent(event: unknown, botUserId: string): boolean {
150
+ const parsed = slackSelfAuthoredEventSchema.safeParse(event);
151
+ if (!parsed.success) return false;
152
+ const nestedMessage = parsed.data.message;
153
+ return (
154
+ parsed.data.bot_id !== undefined ||
155
+ parsed.data.user === botUserId ||
156
+ nestedMessage?.bot_id !== undefined ||
157
+ nestedMessage?.user === botUserId
158
+ );
159
+ }
160
+
161
+ async function resolveSlackConnection(
162
+ params: SlackWebhookParams & {teamId: string},
163
+ ): Promise<SlackConnectionResolution> {
164
+ const installation = await getSlackInstallationByTeamId(params.teamId, {tx: params.tx});
165
+ if (!installation) {
166
+ logger().warn(
167
+ {deliveryId: params.deliveryId, teamId: params.teamId},
168
+ 'slack webhook: unknown team, dropping',
169
+ );
170
+ await recordSlackDeliveryOnly(params);
171
+ return {kind: 'drop', outcome: 'unknown-team'};
172
+ }
173
+
174
+ if (installation.status !== 'installed') {
175
+ logger().info(
176
+ {
177
+ deliveryId: params.deliveryId,
178
+ teamId: params.teamId,
179
+ connectionId: installation.connectionId,
180
+ },
181
+ 'slack webhook: installation is not installed, dropping',
182
+ );
183
+ await recordSlackDeliveryOnly(params);
184
+ return {kind: 'drop', outcome: 'revoked-installation'};
185
+ }
186
+
187
+ const connection = await params.getIntegrationConnectionById(installation.connectionId, {
188
+ tx: params.tx,
189
+ });
190
+ if (!connection) {
191
+ logger().warn(
192
+ {
193
+ deliveryId: params.deliveryId,
194
+ teamId: params.teamId,
195
+ connectionId: installation.connectionId,
196
+ },
197
+ 'slack webhook: installation has no connection, dropping',
198
+ );
199
+ await recordSlackDeliveryOnly(params);
200
+ return {kind: 'drop', outcome: 'missing-connection'};
201
+ }
202
+
203
+ if (connection.lifecycleStatus !== 'active') {
204
+ logger().info(
205
+ {deliveryId: params.deliveryId, teamId: params.teamId, connectionId: connection.id},
206
+ 'slack webhook: inactive connection, dropping',
207
+ );
208
+ await recordSlackDeliveryOnly(params);
209
+ return {kind: 'drop', outcome: 'inactive-connection'};
210
+ }
211
+
212
+ return {kind: 'ok', connection, installation};
213
+ }
214
+
215
+ async function recordSlackDeliveryOnly(
216
+ params: Pick<SlackWebhookParams, 'tx' | 'deliveryId' | 'recordDeliveryOnly'>,
217
+ ): Promise<void> {
218
+ await params.recordDeliveryOnly({
219
+ tx: params.tx,
220
+ provider: SLACK_PROVIDER,
221
+ deliveryId: params.deliveryId,
222
+ });
223
+ }
package/src/db/db.ts ADDED
@@ -0,0 +1,16 @@
1
+ import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
2
+ import {pgClient} from '@shipfox/node-postgres';
3
+ import {slackInstallations} from './schema/installations.js';
4
+
5
+ export const schema = {slackInstallations};
6
+
7
+ let database: NodePgDatabase<typeof schema> | undefined;
8
+
9
+ export function db() {
10
+ if (!database) database = drizzle(pgClient(), {schema});
11
+ return database;
12
+ }
13
+
14
+ export function closeDb(): void {
15
+ database = undefined;
16
+ }
@@ -0,0 +1,93 @@
1
+ import {
2
+ SlackConnectionAlreadyLinkedError,
3
+ SlackInstallationAlreadyLinkedError,
4
+ } from '#core/errors.js';
5
+ import {
6
+ getSlackInstallationByConnectionId,
7
+ getSlackInstallationByTeamId,
8
+ markSlackInstallationRevoked,
9
+ upsertSlackInstallation,
10
+ } from './installations.js';
11
+
12
+ function createInstallationInput(
13
+ overrides: Partial<Parameters<typeof upsertSlackInstallation>[0]> = {},
14
+ ) {
15
+ return {
16
+ connectionId: crypto.randomUUID(),
17
+ teamId: `T${crypto.randomUUID()}`,
18
+ teamName: 'Acme',
19
+ appId: 'A123',
20
+ botUserId: 'U123',
21
+ scopes: ['app_mentions:read'],
22
+ status: 'installed' as const,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ describe('slack installations', () => {
28
+ it('upserts and reads an installation by connection and team id', async () => {
29
+ const input = createInstallationInput();
30
+
31
+ const installation = await upsertSlackInstallation(input);
32
+ const byConnection = await getSlackInstallationByConnectionId(input.connectionId);
33
+ const byTeam = await getSlackInstallationByTeamId(input.teamId);
34
+
35
+ expect(byConnection).toEqual(installation);
36
+ expect(byTeam).toEqual(installation);
37
+ });
38
+
39
+ it('updates mutable metadata for an existing installation', async () => {
40
+ const input = createInstallationInput();
41
+ await upsertSlackInstallation(input);
42
+
43
+ const result = await upsertSlackInstallation({
44
+ ...input,
45
+ teamName: 'Acme renamed',
46
+ appId: 'A456',
47
+ botUserId: 'U456',
48
+ scopes: ['app_mentions:read', 'chat:write'],
49
+ });
50
+
51
+ expect(result).toMatchObject({
52
+ teamName: 'Acme renamed',
53
+ appId: 'A456',
54
+ botUserId: 'U456',
55
+ scopes: ['app_mentions:read', 'chat:write'],
56
+ });
57
+ });
58
+
59
+ it('refuses to claim a team already linked to another connection', async () => {
60
+ const first = createInstallationInput();
61
+ await upsertSlackInstallation(first);
62
+ const second = createInstallationInput({teamId: first.teamId});
63
+
64
+ const result = upsertSlackInstallation(second);
65
+
66
+ await expect(result).rejects.toBeInstanceOf(SlackInstallationAlreadyLinkedError);
67
+ await expect(getSlackInstallationByTeamId(first.teamId)).resolves.toMatchObject({
68
+ connectionId: first.connectionId,
69
+ });
70
+ });
71
+
72
+ it('refuses to repoint an existing connection to another team', async () => {
73
+ const first = createInstallationInput();
74
+ await upsertSlackInstallation(first);
75
+ const second = createInstallationInput({connectionId: first.connectionId});
76
+
77
+ const result = upsertSlackInstallation(second);
78
+
79
+ await expect(result).rejects.toBeInstanceOf(SlackConnectionAlreadyLinkedError);
80
+ await expect(getSlackInstallationByConnectionId(first.connectionId)).resolves.toMatchObject({
81
+ teamId: first.teamId,
82
+ });
83
+ });
84
+
85
+ it('marks an installation revoked by connection id', async () => {
86
+ const input = createInstallationInput();
87
+ await upsertSlackInstallation(input);
88
+
89
+ const result = await markSlackInstallationRevoked(input.connectionId);
90
+
91
+ expect(result?.status).toBe('revoked');
92
+ });
93
+ });