@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,141 @@
1
+ import {isUniqueViolation} from '@shipfox/node-drizzle';
2
+ import {eq} from 'drizzle-orm';
3
+ import {
4
+ SlackConnectionAlreadyLinkedError,
5
+ SlackInstallationAlreadyLinkedError,
6
+ } from '#core/errors.js';
7
+ import {db} from './db.js';
8
+ import {slackInstallations, toSlackInstallation} from './schema/installations.js';
9
+
10
+ export type SlackInstallationStatus = 'installed' | 'revoked';
11
+
12
+ export interface SlackInstallation {
13
+ id: string;
14
+ connectionId: string;
15
+ teamId: string;
16
+ teamName: string;
17
+ appId: string;
18
+ botUserId: string;
19
+ scopes: string[];
20
+ status: SlackInstallationStatus;
21
+ tokenExpiresAt: Date | null;
22
+ createdAt: Date;
23
+ updatedAt: Date;
24
+ }
25
+
26
+ export interface UpsertSlackInstallationParams {
27
+ connectionId: string;
28
+ teamId: string;
29
+ teamName: string;
30
+ appId: string;
31
+ botUserId: string;
32
+ scopes: string[];
33
+ status: SlackInstallationStatus;
34
+ tokenExpiresAt?: Date | null | undefined;
35
+ }
36
+
37
+ type SlackDb = ReturnType<typeof db>;
38
+ type SlackTx = Parameters<Parameters<SlackDb['transaction']>[0]>[0];
39
+
40
+ export async function upsertSlackInstallation(
41
+ params: UpsertSlackInstallationParams,
42
+ options: {tx?: unknown} = {},
43
+ ): Promise<SlackInstallation> {
44
+ const executor = (options.tx ?? db()) as SlackDb | SlackTx;
45
+ const now = new Date();
46
+ let row: typeof slackInstallations.$inferSelect | undefined;
47
+
48
+ try {
49
+ [row] = await executor
50
+ .insert(slackInstallations)
51
+ .values({
52
+ connectionId: params.connectionId,
53
+ teamId: params.teamId,
54
+ teamName: params.teamName,
55
+ appId: params.appId,
56
+ botUserId: params.botUserId,
57
+ scopes: params.scopes,
58
+ status: params.status,
59
+ tokenExpiresAt: params.tokenExpiresAt ?? null,
60
+ })
61
+ .onConflictDoUpdate({
62
+ target: slackInstallations.teamId,
63
+ setWhere: eq(slackInstallations.connectionId, params.connectionId),
64
+ set: {
65
+ connectionId: params.connectionId,
66
+ teamId: params.teamId,
67
+ teamName: params.teamName,
68
+ appId: params.appId,
69
+ botUserId: params.botUserId,
70
+ scopes: params.scopes,
71
+ status: params.status,
72
+ tokenExpiresAt: params.tokenExpiresAt ?? null,
73
+ updatedAt: now,
74
+ },
75
+ })
76
+ .returning();
77
+ } catch (error) {
78
+ if (isUniqueViolation(error, 'integrations_slack_installations_connection_unique')) {
79
+ throw new SlackConnectionAlreadyLinkedError(params.connectionId);
80
+ }
81
+ throw error;
82
+ }
83
+
84
+ if (!row) throw new SlackInstallationAlreadyLinkedError(params.teamId);
85
+ return toSlackInstallation(row);
86
+ }
87
+
88
+ export async function getSlackInstallationByTeamId(
89
+ teamId: string,
90
+ options: {tx?: unknown} = {},
91
+ ): Promise<SlackInstallation | undefined> {
92
+ const executor = (options.tx ?? db()) as SlackDb | SlackTx;
93
+ const rows = await executor
94
+ .select()
95
+ .from(slackInstallations)
96
+ .where(eq(slackInstallations.teamId, teamId))
97
+ .limit(1);
98
+ const row = rows[0];
99
+ if (!row) return undefined;
100
+ return toSlackInstallation(row);
101
+ }
102
+
103
+ export async function getSlackInstallationByConnectionId(
104
+ connectionId: string,
105
+ options: {tx?: unknown} = {},
106
+ ): Promise<SlackInstallation | undefined> {
107
+ const executor = (options.tx ?? db()) as SlackDb | SlackTx;
108
+ const rows = await executor
109
+ .select()
110
+ .from(slackInstallations)
111
+ .where(eq(slackInstallations.connectionId, connectionId))
112
+ .limit(1);
113
+ const row = rows[0];
114
+ if (!row) return undefined;
115
+ return toSlackInstallation(row);
116
+ }
117
+
118
+ export async function deleteSlackInstallationByConnectionId(
119
+ connectionId: string,
120
+ options: {tx?: unknown} = {},
121
+ ): Promise<boolean> {
122
+ const executor = (options.tx ?? db()) as SlackDb | SlackTx;
123
+ const result = await executor
124
+ .delete(slackInstallations)
125
+ .where(eq(slackInstallations.connectionId, connectionId));
126
+ return (result.rowCount ?? 0) > 0;
127
+ }
128
+
129
+ export async function markSlackInstallationRevoked(
130
+ connectionId: string,
131
+ options: {tx?: unknown} = {},
132
+ ): Promise<SlackInstallation | undefined> {
133
+ const executor = (options.tx ?? db()) as SlackDb | SlackTx;
134
+ const [row] = await executor
135
+ .update(slackInstallations)
136
+ .set({status: 'revoked', updatedAt: new Date()})
137
+ .where(eq(slackInstallations.connectionId, connectionId))
138
+ .returning();
139
+ if (!row) return undefined;
140
+ return toSlackInstallation(row);
141
+ }
@@ -0,0 +1,4 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+
4
+ export const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../drizzle');
@@ -0,0 +1,3 @@
1
+ import {pgTableCreator} from 'drizzle-orm/pg-core';
2
+
3
+ export const pgTable = pgTableCreator((name) => `integrations_slack_${name}`);
@@ -0,0 +1,44 @@
1
+ import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
+ import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
+ import type {SlackInstallation} from '#db/installations.js';
4
+ import {pgTable} from './common.js';
5
+
6
+ export const slackInstallations = pgTable(
7
+ 'installations',
8
+ {
9
+ id: uuidv7PrimaryKey(),
10
+ connectionId: uuid('connection_id').notNull(),
11
+ teamId: text('team_id').notNull(),
12
+ teamName: text('team_name').notNull(),
13
+ appId: text('app_id').notNull(),
14
+ botUserId: text('bot_user_id').notNull(),
15
+ scopes: jsonb('scopes').notNull().$type<string[]>(),
16
+ status: text('status').notNull().$type<SlackInstallation['status']>(),
17
+ tokenExpiresAt: timestamp('token_expires_at', {withTimezone: true}),
18
+ createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
19
+ updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
20
+ },
21
+ (table) => [
22
+ uniqueIndex('integrations_slack_installations_connection_unique').on(table.connectionId),
23
+ uniqueIndex('integrations_slack_installations_team_unique').on(table.teamId),
24
+ ],
25
+ );
26
+
27
+ export type SlackInstallationDb = typeof slackInstallations.$inferSelect;
28
+ export type SlackInstallationCreateDb = typeof slackInstallations.$inferInsert;
29
+
30
+ export function toSlackInstallation(row: SlackInstallationDb): SlackInstallation {
31
+ return {
32
+ id: row.id,
33
+ connectionId: row.connectionId,
34
+ teamId: row.teamId,
35
+ teamName: row.teamName,
36
+ appId: row.appId,
37
+ botUserId: row.botUserId,
38
+ scopes: row.scopes,
39
+ status: row.status,
40
+ tokenExpiresAt: row.tokenExpiresAt,
41
+ createdAt: row.createdAt,
42
+ updatedAt: row.updatedAt,
43
+ };
44
+ }
@@ -0,0 +1,25 @@
1
+ import {createSlackIntegrationProvider, slackAgentToolCatalog} from '#index.js';
2
+
3
+ describe('createSlackIntegrationProvider', () => {
4
+ it('does not mount webhook routes without route options', () => {
5
+ const provider = createSlackIntegrationProvider();
6
+
7
+ expect(provider.routes).toEqual([]);
8
+ });
9
+
10
+ it('rejects incomplete webhook route options', () => {
11
+ expect(() => createSlackIntegrationProvider({routes: {}})).toThrow(
12
+ 'Slack webhook routes require every core persistence dependency',
13
+ );
14
+ });
15
+
16
+ it('exposes the Slack agent-tools adapter when token access is configured', () => {
17
+ const provider = createSlackIntegrationProvider({
18
+ agentTools: {tokenStore: {getAccessToken: async () => 'xoxb-token'}},
19
+ });
20
+
21
+ const catalog = provider.adapters.agent_tools?.catalog();
22
+
23
+ expect(catalog).toBe(slackAgentToolCatalog);
24
+ });
25
+ });
package/src/index.ts ADDED
@@ -0,0 +1,196 @@
1
+ import {SLACK_PROVIDER} from '@shipfox/api-integration-slack-dto';
2
+ import type {RouteGroup} from '@shipfox/node-fastify';
3
+ import {createSlackApiClient, type SlackApiClient} from '#api/client.js';
4
+ import {config} from '#config.js';
5
+ import {SlackAgentToolsProvider} from '#core/agent-tools-provider.js';
6
+ import type {SlackTokenStore} from '#core/tokens.js';
7
+ import {closeDb, db} from '#db/db.js';
8
+ import {getSlackInstallationByConnectionId} from '#db/installations.js';
9
+ import {migrationsPath} from '#db/migrations.js';
10
+ import {
11
+ type CreateSlackIntegrationRoutesOptions,
12
+ createSlackIntegrationRoutes,
13
+ } from '#presentation/routes/install.js';
14
+ import {
15
+ type CreateSlackWebhookRoutesOptions,
16
+ createSlackWebhookRoutes,
17
+ } from '#presentation/routes/webhooks.js';
18
+
19
+ type SlackInstallationRouteOptions = Omit<
20
+ CreateSlackIntegrationRoutesOptions,
21
+ 'slack' | 'connectionCapabilities'
22
+ >;
23
+ type SlackRouteOptions = Partial<SlackInstallationRouteOptions> &
24
+ Partial<CreateSlackWebhookRoutesOptions>;
25
+
26
+ export type {SlackProvider} from '@shipfox/api-integration-slack-dto';
27
+ export type {SlackApiClient, SlackAuthorization, SlackWebApiResponse} from '#api/client.js';
28
+ export {createSlackApiClient} from '#api/client.js';
29
+ export type {
30
+ SlackAgentToolCatalogEntry,
31
+ SlackAgentToolId,
32
+ SlackAgentToolRequiredScope,
33
+ } from '#core/agent-tools.js';
34
+ export {
35
+ SLACK_TOOL_METHODS,
36
+ slackAgentToolCatalog,
37
+ slackAgentToolSelectionCatalog,
38
+ } from '#core/agent-tools.js';
39
+ export type {
40
+ SlackAgentToolsProviderOptions,
41
+ SlackToolCallResult,
42
+ } from '#core/agent-tools-provider.js';
43
+ export {SlackAgentToolsProvider} from '#core/agent-tools-provider.js';
44
+ export {
45
+ type DisconnectSlackInstallationParams,
46
+ disconnectSlackInstallation,
47
+ } from '#core/disconnect.js';
48
+ export {
49
+ SlackAccessTokenUnavailableError,
50
+ type SlackAccessTokenUnavailableReason,
51
+ SlackAuthorizationScopeMismatchError,
52
+ SlackBotTokenMissingError,
53
+ SlackConnectionAlreadyLinkedError,
54
+ SlackConnectionNotFoundError,
55
+ SlackEnterpriseInstallUnsupportedError,
56
+ SlackInstallationAlreadyLinkedError,
57
+ SlackInstallStateActorMismatchError,
58
+ SlackInstallStateError,
59
+ SlackIntegrationProviderError,
60
+ SlackOAuthCallbackError,
61
+ } from '#core/errors.js';
62
+ export type {ConnectSlackInstallationInput, HandleSlackCallbackParams} from '#core/install.js';
63
+ export {handleSlackCallback, handleSlackOAuthCallbackError} from '#core/install.js';
64
+ export {
65
+ assertSlackAuthorizationScopes,
66
+ formatSlackBotScopes,
67
+ SLACK_BOT_SCOPES,
68
+ } from '#core/scopes.js';
69
+ export type {VerifySlackSignatureParams} from '#core/signature.js';
70
+ export {verifySlackSignature} from '#core/signature.js';
71
+ export type {SlackInstallStateClaims} from '#core/state.js';
72
+ export {signSlackInstallState, verifySlackInstallState} from '#core/state.js';
73
+ export type {
74
+ CreateSlackTokenStoreParams,
75
+ GetSlackAccessTokenParams,
76
+ SlackConnectionResolverResult,
77
+ SlackSecretsStore,
78
+ SlackTokenStore,
79
+ StoreSlackTokensParams,
80
+ } from '#core/tokens.js';
81
+ export {createSlackTokenStore, slackSecretsNamespace} from '#core/tokens.js';
82
+ export type {
83
+ HandleSlackCommandParams,
84
+ HandleSlackEventParams,
85
+ SlackWebhookOutcome,
86
+ } from '#core/webhook.js';
87
+ export {handleSlackCommand, handleSlackEvent, isSelfAuthoredSlackEvent} from '#core/webhook.js';
88
+ export type {
89
+ SlackInstallation,
90
+ SlackInstallationStatus,
91
+ UpsertSlackInstallationParams,
92
+ } from '#db/installations.js';
93
+ export {
94
+ deleteSlackInstallationByConnectionId,
95
+ getSlackInstallationByConnectionId,
96
+ getSlackInstallationByTeamId,
97
+ markSlackInstallationRevoked,
98
+ upsertSlackInstallation,
99
+ } from '#db/installations.js';
100
+ export {
101
+ type CreateSlackE2eRoutesOptions,
102
+ createSlackE2eRoutes,
103
+ } from '#presentation/e2eRoutes/index.js';
104
+ export {
105
+ type CreateSlackWebhookRoutesOptions,
106
+ createSlackWebhookRoutes,
107
+ SLACK_WEBHOOK_BODY_LIMIT,
108
+ SLASH_COMMAND_ACK,
109
+ } from '#presentation/routes/webhooks.js';
110
+ export {closeDb, config, db, migrationsPath};
111
+
112
+ export interface CreateSlackIntegrationProviderOptions {
113
+ slack?: SlackApiClient | undefined;
114
+ agentTools?: {tokenStore: Pick<SlackTokenStore, 'getAccessToken'>} | undefined;
115
+ getSlackInstallationByConnectionId?: typeof getSlackInstallationByConnectionId | undefined;
116
+ routes?: SlackRouteOptions | undefined;
117
+ }
118
+
119
+ export function createSlackIntegrationProvider(
120
+ options: CreateSlackIntegrationProviderOptions = {},
121
+ ) {
122
+ const slack = options.slack ?? createSlackApiClient();
123
+ const getInstallationByConnectionId =
124
+ options.getSlackInstallationByConnectionId ?? getSlackInstallationByConnectionId;
125
+ const adapters = options.agentTools
126
+ ? {
127
+ agent_tools: new SlackAgentToolsProvider({
128
+ slack,
129
+ tokenStore: options.agentTools.tokenStore,
130
+ }),
131
+ }
132
+ : {};
133
+ if (
134
+ options.routes &&
135
+ !hasSlackInstallationRoutesOptions(options.routes) &&
136
+ !hasSlackWebhookRoutesOptions(options.routes)
137
+ ) {
138
+ throw new Error('Slack webhook routes require every core persistence dependency');
139
+ }
140
+ const routes: RouteGroup[] = [];
141
+ if (options.routes && hasSlackInstallationRoutesOptions(options.routes)) {
142
+ const {
143
+ tokenStore,
144
+ getExistingSlackConnection,
145
+ connectSlackInstallation,
146
+ disconnectSlackInstallation,
147
+ } = options.routes;
148
+ routes.push(
149
+ createSlackIntegrationRoutes({
150
+ slack,
151
+ connectionCapabilities: adapters.agent_tools ? ['agent_tools'] : [],
152
+ tokenStore,
153
+ getExistingSlackConnection,
154
+ connectSlackInstallation,
155
+ disconnectSlackInstallation,
156
+ }),
157
+ );
158
+ }
159
+ if (options.routes && hasSlackWebhookRoutesOptions(options.routes)) {
160
+ routes.push(...createSlackWebhookRoutes(options.routes));
161
+ }
162
+
163
+ return {
164
+ provider: SLACK_PROVIDER,
165
+ displayName: 'Slack',
166
+ adapters,
167
+ async connectionExternalUrl(connection: {id: string}): Promise<string | undefined> {
168
+ const installation = await getInstallationByConnectionId(connection.id);
169
+ if (!installation) return undefined;
170
+ return `https://app.slack.com/client/${encodeURIComponent(installation.teamId)}`;
171
+ },
172
+ routes,
173
+ };
174
+ }
175
+
176
+ function hasSlackInstallationRoutesOptions(
177
+ routes: SlackRouteOptions,
178
+ ): routes is SlackInstallationRouteOptions {
179
+ return (
180
+ routes.tokenStore !== undefined &&
181
+ routes.getExistingSlackConnection !== undefined &&
182
+ routes.connectSlackInstallation !== undefined &&
183
+ routes.disconnectSlackInstallation !== undefined
184
+ );
185
+ }
186
+
187
+ function hasSlackWebhookRoutesOptions(
188
+ routes: SlackRouteOptions,
189
+ ): routes is CreateSlackWebhookRoutesOptions {
190
+ return (
191
+ routes.coreDb !== undefined &&
192
+ routes.publishIntegrationEventReceived !== undefined &&
193
+ routes.recordDeliveryOnly !== undefined &&
194
+ routes.getIntegrationConnectionById !== undefined
195
+ );
196
+ }
@@ -0,0 +1 @@
1
+ export {toIntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
@@ -0,0 +1,82 @@
1
+ import type {IntegrationCapability, IntegrationConnection} from '@shipfox/api-integration-core-dto';
2
+ import {
3
+ createE2eSlackConnectionBodySchema,
4
+ createE2eSlackConnectionResponseSchema,
5
+ } from '@shipfox/api-integration-slack-dto';
6
+ import {ClientError, defineRoute} from '@shipfox/node-fastify';
7
+ import {logger} from '@shipfox/node-opentelemetry';
8
+ import type {ConnectSlackInstallationInput} from '#core/install.js';
9
+ import type {SlackTokenStore} from '#core/tokens.js';
10
+ import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
11
+
12
+ export interface CreateE2eSlackConnectionRouteOptions {
13
+ tokenStore: Pick<SlackTokenStore, 'storeTokens'>;
14
+ getExistingSlackConnection: (input: {
15
+ teamId: string;
16
+ }) => Promise<IntegrationConnection<'slack'> | undefined>;
17
+ connectSlackInstallation: (
18
+ input: ConnectSlackInstallationInput,
19
+ ) => Promise<IntegrationConnection<'slack'>>;
20
+ disconnectSlackInstallation: (input: {connectionId: string}) => Promise<void>;
21
+ connectionCapabilities: IntegrationCapability[];
22
+ }
23
+
24
+ export function createE2eSlackConnectionRoute(options: CreateE2eSlackConnectionRouteOptions) {
25
+ return defineRoute({
26
+ method: 'POST',
27
+ path: '/slack-connections',
28
+ description: 'Create a synthetic Slack connection for E2E tests.',
29
+ schema: {
30
+ body: createE2eSlackConnectionBodySchema,
31
+ response: {201: createE2eSlackConnectionResponseSchema},
32
+ },
33
+ handler: async (request, reply) => {
34
+ const body = request.body;
35
+ const existing = await options.getExistingSlackConnection({teamId: body.team_id});
36
+ if (existing && existing.workspaceId !== body.workspace_id) {
37
+ throw new ClientError(
38
+ 'Slack team is already connected to another workspace',
39
+ 'slack-connection-workspace-mismatch',
40
+ {status: 409},
41
+ );
42
+ }
43
+ const connection =
44
+ existing ??
45
+ (await options.connectSlackInstallation({
46
+ workspaceId: body.workspace_id,
47
+ teamId: body.team_id,
48
+ teamName: body.team_name,
49
+ appId: body.app_id,
50
+ botUserId: body.bot_user_id,
51
+ scopes: body.scopes,
52
+ tokenExpiresAt: null,
53
+ displayName: `Slack ${body.team_name}`,
54
+ }));
55
+ try {
56
+ await options.tokenStore.storeTokens({
57
+ connectionId: connection.id,
58
+ botToken: body.bot_token,
59
+ });
60
+ } catch (error) {
61
+ if (!existing) await bestEffortDisconnectSlackInstallation(options, connection.id);
62
+ throw error;
63
+ }
64
+ reply.code(201);
65
+ return toIntegrationConnectionDto(connection, {capabilities: options.connectionCapabilities});
66
+ },
67
+ });
68
+ }
69
+
70
+ async function bestEffortDisconnectSlackInstallation(
71
+ options: CreateE2eSlackConnectionRouteOptions,
72
+ connectionId: string,
73
+ ): Promise<void> {
74
+ try {
75
+ await options.disconnectSlackInstallation({connectionId});
76
+ } catch (error) {
77
+ logger().warn(
78
+ {err: error, connectionId},
79
+ 'Slack E2E connection compensation failed after token storage rejection',
80
+ );
81
+ }
82
+ }
@@ -0,0 +1,96 @@
1
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
2
+ import {closeApp, createApp} from '@shipfox/node-fastify';
3
+ import {createSlackE2eRoutes} from './index.js';
4
+
5
+ function connection(overrides: Partial<IntegrationConnection<'slack'>> = {}) {
6
+ return {
7
+ id: '00000000-0000-4000-8000-000000000001',
8
+ workspaceId: '00000000-0000-4000-8000-000000000002',
9
+ provider: 'slack',
10
+ externalAccountId: 'T123',
11
+ slug: 'slack_acme',
12
+ displayName: 'Slack Acme',
13
+ lifecycleStatus: 'active',
14
+ createdAt: new Date(),
15
+ updatedAt: new Date(),
16
+ ...overrides,
17
+ } satisfies IntegrationConnection<'slack'>;
18
+ }
19
+
20
+ describe('Slack E2E routes', () => {
21
+ afterEach(async () => {
22
+ await closeApp();
23
+ });
24
+
25
+ it('creates a connection and stores its bot token without returning it', async () => {
26
+ const tokenStore = {storeTokens: vi.fn(() => Promise.resolve())};
27
+ const app = await createApp({
28
+ routes: [
29
+ createSlackE2eRoutes({
30
+ tokenStore,
31
+ getExistingSlackConnection: vi.fn(() => Promise.resolve(undefined)),
32
+ connectSlackInstallation: vi.fn(() => Promise.resolve(connection())),
33
+ disconnectSlackInstallation: vi.fn(() => Promise.resolve()),
34
+ connectionCapabilities: [],
35
+ }),
36
+ ],
37
+ swagger: false,
38
+ });
39
+
40
+ const res = await app.inject({
41
+ method: 'POST',
42
+ url: '/integrations/slack-connections',
43
+ payload: {
44
+ workspace_id: '00000000-0000-4000-8000-000000000002',
45
+ team_id: 'T123',
46
+ team_name: 'Acme',
47
+ app_id: 'A123',
48
+ bot_user_id: 'U123',
49
+ bot_token: 'xoxb-e2e-token',
50
+ },
51
+ });
52
+
53
+ expect(res.statusCode).toBe(201);
54
+ expect(res.json()).toMatchObject({provider: 'slack', capabilities: []});
55
+ expect(res.body).not.toContain('xoxb-e2e-token');
56
+ expect(tokenStore.storeTokens).toHaveBeenCalledWith({
57
+ connectionId: '00000000-0000-4000-8000-000000000001',
58
+ botToken: 'xoxb-e2e-token',
59
+ });
60
+ });
61
+
62
+ it('rejects a Slack team connected to another workspace', async () => {
63
+ const connectSlackInstallation = vi.fn(() => Promise.resolve(connection()));
64
+ const app = await createApp({
65
+ routes: [
66
+ createSlackE2eRoutes({
67
+ tokenStore: {storeTokens: vi.fn(() => Promise.resolve())},
68
+ getExistingSlackConnection: vi.fn(() =>
69
+ Promise.resolve(connection({workspaceId: '00000000-0000-4000-8000-000000000003'})),
70
+ ),
71
+ connectSlackInstallation,
72
+ disconnectSlackInstallation: vi.fn(() => Promise.resolve()),
73
+ connectionCapabilities: [],
74
+ }),
75
+ ],
76
+ swagger: false,
77
+ });
78
+
79
+ const res = await app.inject({
80
+ method: 'POST',
81
+ url: '/integrations/slack-connections',
82
+ payload: {
83
+ workspace_id: '00000000-0000-4000-8000-000000000002',
84
+ team_id: 'T123',
85
+ team_name: 'Acme',
86
+ app_id: 'A123',
87
+ bot_user_id: 'U123',
88
+ bot_token: 'xoxb-e2e-token',
89
+ },
90
+ });
91
+
92
+ expect(res.statusCode).toBe(409);
93
+ expect(res.json()).toMatchObject({code: 'slack-connection-workspace-mismatch'});
94
+ expect(connectSlackInstallation).not.toHaveBeenCalled();
95
+ });
96
+ });
@@ -0,0 +1,11 @@
1
+ import type {RouteGroup} from '@shipfox/node-fastify';
2
+ import {
3
+ type CreateE2eSlackConnectionRouteOptions,
4
+ createE2eSlackConnectionRoute,
5
+ } from './create-connection.js';
6
+
7
+ export type CreateSlackE2eRoutesOptions = CreateE2eSlackConnectionRouteOptions;
8
+
9
+ export function createSlackE2eRoutes(options: CreateSlackE2eRoutesOptions): RouteGroup {
10
+ return {prefix: '/integrations', routes: [createE2eSlackConnectionRoute(options)]};
11
+ }
@@ -0,0 +1,68 @@
1
+ import {
2
+ ConnectionSlugConflictError,
3
+ type IntegrationProviderErrorReason,
4
+ } from '@shipfox/api-integration-core-dto';
5
+ import {ClientError} from '@shipfox/node-fastify';
6
+ import {
7
+ SlackAuthorizationScopeMismatchError,
8
+ SlackConnectionAlreadyLinkedError,
9
+ SlackEnterpriseInstallUnsupportedError,
10
+ SlackInstallationAlreadyLinkedError,
11
+ SlackInstallStateActorMismatchError,
12
+ SlackInstallStateError,
13
+ SlackIntegrationProviderError,
14
+ SlackOAuthCallbackError,
15
+ SlackTokenRotationUnsupportedError,
16
+ } from '#core/errors.js';
17
+
18
+ function providerStatus(reason: IntegrationProviderErrorReason): number {
19
+ if (reason === 'rate-limited') return 429;
20
+ if (reason === 'timeout' || reason === 'provider-unavailable') return 503;
21
+ return 422;
22
+ }
23
+
24
+ export function slackRouteErrorHandler(error: unknown): never {
25
+ if (error instanceof SlackInstallStateError) {
26
+ throw new ClientError(error.message, 'invalid-slack-install-state', {status: 400});
27
+ }
28
+ if (error instanceof SlackInstallStateActorMismatchError) {
29
+ throw new ClientError(error.message, 'slack-install-state-actor-mismatch', {status: 403});
30
+ }
31
+ if (error instanceof SlackInstallationAlreadyLinkedError) {
32
+ throw new ClientError(error.message, 'slack-installation-already-linked', {status: 409});
33
+ }
34
+ if (error instanceof SlackConnectionAlreadyLinkedError) {
35
+ throw new ClientError(error.message, 'slack-connection-already-linked', {status: 409});
36
+ }
37
+ if (error instanceof SlackAuthorizationScopeMismatchError) {
38
+ throw new ClientError(error.message, 'slack-authorization-scope-mismatch', {
39
+ status: 422,
40
+ details: {missing_scopes: error.missingScopes},
41
+ });
42
+ }
43
+ if (error instanceof SlackEnterpriseInstallUnsupportedError) {
44
+ throw new ClientError(error.message, 'slack-enterprise-install-unsupported', {status: 422});
45
+ }
46
+ if (error instanceof SlackTokenRotationUnsupportedError) {
47
+ throw new ClientError(error.message, 'slack-token-rotation-unsupported', {status: 422});
48
+ }
49
+ if (error instanceof SlackOAuthCallbackError) {
50
+ throw new ClientError(error.message, 'slack-oauth-callback-error', {
51
+ status: 422,
52
+ details: {
53
+ error: error.providerError,
54
+ ...(error.providerDescription ? {error_description: error.providerDescription} : {}),
55
+ },
56
+ });
57
+ }
58
+ if (error instanceof ConnectionSlugConflictError) {
59
+ throw new ClientError(error.message, 'slug-conflict', {status: 409});
60
+ }
61
+ if (error instanceof SlackIntegrationProviderError) {
62
+ throw new ClientError(error.message, error.reason, {
63
+ details: {retry_after_seconds: error.retryAfterSeconds},
64
+ status: providerStatus(error.reason),
65
+ });
66
+ }
67
+ throw error;
68
+ }