@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,147 @@
1
+ import type {
2
+ AgentToolSession,
3
+ AgentToolsProvider,
4
+ IntegrationConnection,
5
+ OpenAgentToolsSessionInput,
6
+ } from '@shipfox/api-integration-core-dto';
7
+ import {logger} from '@shipfox/node-opentelemetry';
8
+ import type {SlackApiClient, SlackWebApiResponse} from '#api/client.js';
9
+ import {
10
+ SLACK_TOOL_METHODS,
11
+ type SlackAgentToolCatalogEntry,
12
+ type SlackAgentToolId,
13
+ type SlackAgentToolRequiredScope,
14
+ slackAgentToolCatalog,
15
+ slackAgentToolSelectionCatalog,
16
+ } from '#core/agent-tools.js';
17
+ import {SlackIntegrationProviderError} from '#core/errors.js';
18
+ import type {SlackTokenStore} from '#core/tokens.js';
19
+
20
+ type SlackIntegrationConnection = IntegrationConnection<'slack'>;
21
+
22
+ export type SlackToolCallResult = {
23
+ isError?: boolean | undefined;
24
+ content: readonly {type: 'text'; text: string}[];
25
+ structuredContent?: Record<string, unknown> | undefined;
26
+ };
27
+
28
+ export interface SlackAgentToolsProviderOptions {
29
+ slack: Pick<SlackApiClient, 'callMethod'>;
30
+ tokenStore: Pick<SlackTokenStore, 'getAccessToken'>;
31
+ }
32
+
33
+ export class SlackAgentToolsProvider
34
+ implements
35
+ AgentToolsProvider<
36
+ SlackIntegrationConnection,
37
+ SlackAgentToolRequiredScope,
38
+ unknown,
39
+ SlackToolCallResult
40
+ >
41
+ {
42
+ constructor(private readonly options: SlackAgentToolsProviderOptions) {}
43
+
44
+ catalog() {
45
+ return slackAgentToolCatalog;
46
+ }
47
+
48
+ selectionCatalog() {
49
+ return slackAgentToolSelectionCatalog;
50
+ }
51
+
52
+ async openSession(
53
+ input: OpenAgentToolsSessionInput<
54
+ SlackIntegrationConnection,
55
+ SlackAgentToolRequiredScope,
56
+ unknown
57
+ >,
58
+ ): Promise<AgentToolSession<SlackToolCallResult>> {
59
+ const token = await this.options.tokenStore.getAccessToken({connectionId: input.connection.id});
60
+
61
+ return {
62
+ call: async (call) => {
63
+ const tool = input.tools.find((candidate) => candidate.id === call.toolId);
64
+ if (!tool) return slackToolError(`Unknown Slack tool: ${call.toolId}`);
65
+ const method = slackToolMethod(tool.id);
66
+ if (!method) return slackToolError(`Unknown Slack tool method: ${tool.id}`);
67
+ const missingParameter = missingRequiredParameter(tool, call.arguments);
68
+ if (missingParameter) {
69
+ return slackToolError(`Missing required parameter: ${missingParameter}`);
70
+ }
71
+
72
+ let body: SlackWebApiResponse;
73
+ try {
74
+ body = await this.options.slack.callMethod({
75
+ method,
76
+ token,
77
+ arguments: call.arguments,
78
+ });
79
+ } catch (error) {
80
+ if (error instanceof SlackIntegrationProviderError) {
81
+ return slackToolError(error.message, {
82
+ code: error.reason,
83
+ retryAfterSeconds: error.retryAfterSeconds,
84
+ });
85
+ }
86
+ throw error;
87
+ }
88
+
89
+ if (body.ok) return slackToolResult(body);
90
+ const slackError = typeof body.error === 'string' ? body.error : 'Slack request failed';
91
+ if (isSlackAccessError(slackError)) {
92
+ logger().warn(
93
+ {connectionId: input.connection.id, slackError},
94
+ 'Slack API rejected integration credentials',
95
+ );
96
+ return slackToolError(slackError, {code: 'access-denied'});
97
+ }
98
+ if (slackError === 'ratelimited') {
99
+ return slackToolError(slackError, {code: 'rate-limited'});
100
+ }
101
+ return slackToolError(slackError);
102
+ },
103
+ close: () => Promise.resolve(),
104
+ };
105
+ }
106
+ }
107
+
108
+ function slackToolMethod(toolId: string): string | undefined {
109
+ return SLACK_TOOL_METHODS[toolId as SlackAgentToolId];
110
+ }
111
+
112
+ function missingRequiredParameter(
113
+ tool: SlackAgentToolCatalogEntry,
114
+ args: Record<string, unknown>,
115
+ ): string | undefined {
116
+ const required = tool.inputSchema.required;
117
+ if (!Array.isArray(required)) return undefined;
118
+ return required.find((parameter) => typeof parameter === 'string' && !(parameter in args));
119
+ }
120
+
121
+ function isSlackAccessError(error: string): boolean {
122
+ return error === 'invalid_auth' || error === 'token_revoked' || error === 'account_inactive';
123
+ }
124
+
125
+ function slackToolResult(body: SlackWebApiResponse): SlackToolCallResult {
126
+ return {
127
+ content: [{type: 'text', text: JSON.stringify(body)}],
128
+ structuredContent: body,
129
+ };
130
+ }
131
+
132
+ function slackToolError(
133
+ message: string,
134
+ options: {code?: string | undefined; retryAfterSeconds?: number | undefined} = {},
135
+ ): SlackToolCallResult {
136
+ const structuredContent = {
137
+ ...(options.code === undefined ? {} : {code: options.code}),
138
+ ...(options.retryAfterSeconds === undefined
139
+ ? {}
140
+ : {retryAfterSeconds: options.retryAfterSeconds}),
141
+ };
142
+ return {
143
+ isError: true,
144
+ content: [{type: 'text', text: message}],
145
+ ...(Object.keys(structuredContent).length === 0 ? {} : {structuredContent}),
146
+ };
147
+ }
@@ -0,0 +1,90 @@
1
+ import {
2
+ SLACK_TOOL_METHODS,
3
+ type SlackAgentToolId,
4
+ slackAgentToolCatalog,
5
+ slackAgentToolSelectionCatalog,
6
+ } from './agent-tools.js';
7
+
8
+ const expectedTools = [
9
+ {id: 'conversations_history', sensitivity: 'read', requiredScope: 'read'},
10
+ {id: 'conversations_replies', sensitivity: 'read', requiredScope: 'read'},
11
+ {id: 'conversations_list', sensitivity: 'read', requiredScope: 'read'},
12
+ {id: 'users_info', sensitivity: 'read', requiredScope: 'read'},
13
+ {id: 'chat_postMessage', sensitivity: 'write', requiredScope: 'write'},
14
+ {id: 'chat_update', sensitivity: 'write', requiredScope: 'write'},
15
+ {id: 'reactions_add', sensitivity: 'write', requiredScope: 'write'},
16
+ ] as const;
17
+
18
+ describe('slackAgentToolCatalog', () => {
19
+ it('defines the seven Slack tools with their access requirements', () => {
20
+ const tools = slackAgentToolCatalog.map(({id, sensitivity, requiredScope, sensitive}) => ({
21
+ id,
22
+ sensitivity,
23
+ requiredScope,
24
+ sensitive,
25
+ }));
26
+
27
+ expect(tools).toEqual(expectedTools.map((tool) => ({...tool, sensitive: false})));
28
+ });
29
+
30
+ it('documents every tool with an object input schema', () => {
31
+ const schemas = slackAgentToolCatalog.map(({description, inputSchema}) => ({
32
+ description,
33
+ type: inputSchema.type,
34
+ }));
35
+
36
+ expect(schemas).toHaveLength(7);
37
+ expect(
38
+ schemas.every(({description, type}) => description.length > 0 && type === 'object'),
39
+ ).toBe(true);
40
+ });
41
+
42
+ it('models Block Kit messages and Slack scalar parameters without narrowing valid calls', () => {
43
+ const postMessage = slackAgentToolCatalog.find(({id}) => id === 'chat_postMessage');
44
+ const listConversations = slackAgentToolCatalog.find(({id}) => id === 'conversations_list');
45
+ const history = slackAgentToolCatalog.find(({id}) => id === 'conversations_history');
46
+
47
+ expect(postMessage?.inputSchema).toMatchObject({
48
+ required: ['channel'],
49
+ properties: {
50
+ blocks: {type: 'array', items: {type: 'object'}},
51
+ thread_ts: {type: 'string'},
52
+ },
53
+ });
54
+ expect(listConversations?.inputSchema).toMatchObject({
55
+ properties: {types: {type: 'string'}, limit: {type: 'number'}},
56
+ });
57
+ expect(history?.inputSchema).toMatchObject({
58
+ properties: {oldest: {type: 'string'}, latest: {type: 'string'}},
59
+ });
60
+ });
61
+
62
+ it('maps every tool id to its dotted Slack Web API method', () => {
63
+ const methods = Object.fromEntries(
64
+ slackAgentToolCatalog.map(({id}) => [id, SLACK_TOOL_METHODS[id as SlackAgentToolId]]),
65
+ );
66
+
67
+ expect(methods).toEqual({
68
+ conversations_history: 'conversations.history',
69
+ conversations_replies: 'conversations.replies',
70
+ conversations_list: 'conversations.list',
71
+ users_info: 'users.info',
72
+ chat_postMessage: 'chat.postMessage',
73
+ chat_update: 'chat.update',
74
+ reactions_add: 'reactions.add',
75
+ });
76
+ });
77
+
78
+ it('exposes one standalone selector per tool with matching sensitivity', () => {
79
+ const selectors = slackAgentToolSelectionCatalog.selectors;
80
+
81
+ expect(selectors).toEqual(
82
+ expectedTools.map(({id, sensitivity}) => ({
83
+ token: id,
84
+ kind: 'standalone',
85
+ sensitivity,
86
+ sensitive: false,
87
+ })),
88
+ );
89
+ });
90
+ });
@@ -0,0 +1,189 @@
1
+ import type {
2
+ AgentToolCatalogEntry,
3
+ AgentToolJsonSchema,
4
+ AgentToolSelectionCatalog,
5
+ AgentToolSelector,
6
+ } from '@shipfox/api-integration-core-dto';
7
+
8
+ export type SlackAgentToolRequiredScope = 'read' | 'write';
9
+ export type SlackAgentToolCatalogEntry = AgentToolCatalogEntry<SlackAgentToolRequiredScope>;
10
+
11
+ interface SlackAgentToolCatalogInput {
12
+ id: string;
13
+ description: string;
14
+ sensitivity: 'read' | 'write';
15
+ sensitive: boolean;
16
+ requiredScope: SlackAgentToolRequiredScope;
17
+ inputSchema: AgentToolJsonSchema;
18
+ }
19
+
20
+ const channelSchema = stringSchema('Slack channel ID');
21
+ const cursorSchema = stringSchema('Next page cursor');
22
+ const limitSchema = numberSchema('Maximum number of results to return');
23
+
24
+ export const slackAgentToolCatalog = [
25
+ tool({
26
+ id: 'conversations_history',
27
+ description: 'List messages in a Slack channel or direct message.',
28
+ sensitivity: 'read',
29
+ sensitive: false,
30
+ requiredScope: 'read',
31
+ inputSchema: objectSchema(
32
+ {
33
+ channel: channelSchema,
34
+ cursor: cursorSchema,
35
+ limit: limitSchema,
36
+ oldest: stringSchema('Only messages after this Slack timestamp'),
37
+ latest: stringSchema('Only messages before this Slack timestamp'),
38
+ inclusive: booleanSchema('Include messages at the oldest or latest timestamp'),
39
+ },
40
+ ['channel'],
41
+ ),
42
+ }),
43
+ tool({
44
+ id: 'conversations_replies',
45
+ description: 'List replies in a Slack message thread.',
46
+ sensitivity: 'read',
47
+ sensitive: false,
48
+ requiredScope: 'read',
49
+ inputSchema: objectSchema(
50
+ {
51
+ channel: channelSchema,
52
+ ts: stringSchema('Slack timestamp of the parent message'),
53
+ cursor: cursorSchema,
54
+ limit: limitSchema,
55
+ },
56
+ ['channel', 'ts'],
57
+ ),
58
+ }),
59
+ tool({
60
+ id: 'conversations_list',
61
+ description: 'List Slack channels visible to the bot.',
62
+ sensitivity: 'read',
63
+ sensitive: false,
64
+ requiredScope: 'read',
65
+ inputSchema: objectSchema({
66
+ cursor: cursorSchema,
67
+ limit: limitSchema,
68
+ types: stringSchema('Comma-separated channel types, such as public_channel,private_channel'),
69
+ exclude_archived: booleanSchema('Exclude archived channels'),
70
+ }),
71
+ }),
72
+ tool({
73
+ id: 'users_info',
74
+ description: 'Look up a Slack user and profile by user ID.',
75
+ sensitivity: 'read',
76
+ sensitive: false,
77
+ requiredScope: 'read',
78
+ inputSchema: objectSchema({user: stringSchema('Slack user ID')}, ['user']),
79
+ }),
80
+ tool({
81
+ id: 'chat_postMessage',
82
+ description: 'Post a Slack message to a channel or thread. Provide text, blocks, or both.',
83
+ sensitivity: 'write',
84
+ sensitive: false,
85
+ requiredScope: 'write',
86
+ inputSchema: objectSchema(
87
+ {
88
+ channel: channelSchema,
89
+ text: stringSchema('Message text'),
90
+ thread_ts: stringSchema('Slack timestamp of the parent message when replying in a thread'),
91
+ blocks: arraySchema({type: 'object'}),
92
+ },
93
+ ['channel'],
94
+ ),
95
+ }),
96
+ tool({
97
+ id: 'chat_update',
98
+ description: 'Update a Slack message with text, blocks, or both.',
99
+ sensitivity: 'write',
100
+ sensitive: false,
101
+ requiredScope: 'write',
102
+ inputSchema: objectSchema(
103
+ {
104
+ channel: channelSchema,
105
+ ts: stringSchema('Slack timestamp of the message to update'),
106
+ text: stringSchema('Updated message text'),
107
+ blocks: arraySchema({type: 'object'}),
108
+ },
109
+ ['channel', 'ts'],
110
+ ),
111
+ }),
112
+ tool({
113
+ id: 'reactions_add',
114
+ description: 'Add an emoji reaction to a Slack message.',
115
+ sensitivity: 'write',
116
+ sensitive: false,
117
+ requiredScope: 'write',
118
+ inputSchema: objectSchema(
119
+ {
120
+ channel: channelSchema,
121
+ timestamp: stringSchema('Slack timestamp of the message'),
122
+ name: stringSchema('Emoji name without surrounding colons'),
123
+ },
124
+ ['channel', 'timestamp', 'name'],
125
+ ),
126
+ }),
127
+ ] as const satisfies readonly SlackAgentToolCatalogEntry[];
128
+
129
+ export type SlackAgentToolId = (typeof slackAgentToolCatalog)[number]['id'];
130
+
131
+ export const SLACK_TOOL_METHODS = {
132
+ conversations_history: 'conversations.history',
133
+ conversations_replies: 'conversations.replies',
134
+ conversations_list: 'conversations.list',
135
+ users_info: 'users.info',
136
+ chat_postMessage: 'chat.postMessage',
137
+ chat_update: 'chat.update',
138
+ reactions_add: 'reactions.add',
139
+ } as const satisfies Record<SlackAgentToolId, string>;
140
+
141
+ export const slackAgentToolSelectionCatalog =
142
+ buildSlackAgentToolSelectionCatalog(slackAgentToolCatalog);
143
+
144
+ function buildSlackAgentToolSelectionCatalog(
145
+ catalog: readonly SlackAgentToolCatalogEntry[],
146
+ ): AgentToolSelectionCatalog {
147
+ return {
148
+ selectors: catalog.map(
149
+ (entry): AgentToolSelector => ({
150
+ token: entry.id,
151
+ kind: 'standalone',
152
+ sensitivity: entry.sensitivity,
153
+ sensitive: entry.sensitive,
154
+ }),
155
+ ),
156
+ };
157
+ }
158
+
159
+ function tool<const Entry extends SlackAgentToolCatalogInput>(input: Entry): Entry {
160
+ return input;
161
+ }
162
+
163
+ function objectSchema(
164
+ properties: Record<string, AgentToolJsonSchema>,
165
+ required: string[] = [],
166
+ ): AgentToolJsonSchema {
167
+ return {
168
+ type: 'object',
169
+ additionalProperties: false,
170
+ properties,
171
+ ...(required.length > 0 ? {required} : {}),
172
+ };
173
+ }
174
+
175
+ function stringSchema(description?: string): AgentToolJsonSchema {
176
+ return {type: 'string', ...(description ? {description} : {})};
177
+ }
178
+
179
+ function numberSchema(description?: string): AgentToolJsonSchema {
180
+ return {type: 'number', ...(description ? {description} : {})};
181
+ }
182
+
183
+ function booleanSchema(description: string): AgentToolJsonSchema {
184
+ return {type: 'boolean', description};
185
+ }
186
+
187
+ function arraySchema(items: AgentToolJsonSchema): AgentToolJsonSchema {
188
+ return {type: 'array', items};
189
+ }
@@ -0,0 +1,26 @@
1
+ import {deleteSlackInstallationByConnectionId} from '#db/installations.js';
2
+ import {slackSecretsNamespace} from './tokens.js';
3
+
4
+ export interface DisconnectSlackInstallationParams<Tx = unknown> {
5
+ connectionId: string;
6
+ getConnection(connectionId: string): Promise<{workspaceId: string} | undefined>;
7
+ deleteSecrets(params: {workspaceId: string; namespace: string}): Promise<number>;
8
+ transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
9
+ deleteConnection(params: {connectionId: string}, options: {tx: Tx}): Promise<boolean>;
10
+ }
11
+
12
+ export async function disconnectSlackInstallation<Tx = unknown>(
13
+ params: DisconnectSlackInstallationParams<Tx>,
14
+ ): Promise<void> {
15
+ const connection = await params.getConnection(params.connectionId);
16
+ if (connection) {
17
+ await params.deleteSecrets({
18
+ workspaceId: connection.workspaceId,
19
+ namespace: slackSecretsNamespace(params.connectionId),
20
+ });
21
+ }
22
+ await params.transaction(async (tx) => {
23
+ await deleteSlackInstallationByConnectionId(params.connectionId, {tx});
24
+ await params.deleteConnection({connectionId: params.connectionId}, {tx});
25
+ });
26
+ }
@@ -0,0 +1,92 @@
1
+ import {IntegrationProviderError} from '@shipfox/api-integration-core-dto';
2
+
3
+ export class SlackIntegrationProviderError extends IntegrationProviderError {}
4
+
5
+ export class SlackInstallStateError extends Error {
6
+ constructor(message = 'Invalid Slack install state') {
7
+ super(message);
8
+ this.name = 'SlackInstallStateError';
9
+ }
10
+ }
11
+
12
+ export class SlackInstallStateActorMismatchError extends Error {
13
+ constructor() {
14
+ super('Slack install state was created by a different user');
15
+ this.name = 'SlackInstallStateActorMismatchError';
16
+ }
17
+ }
18
+
19
+ export class SlackOAuthCallbackError extends Error {
20
+ constructor(
21
+ public readonly providerError: string,
22
+ public readonly providerDescription: string | undefined,
23
+ ) {
24
+ super(providerDescription ?? `Slack OAuth callback failed: ${providerError}`);
25
+ this.name = 'SlackOAuthCallbackError';
26
+ }
27
+ }
28
+
29
+ export class SlackAuthorizationScopeMismatchError extends Error {
30
+ constructor(public readonly missingScopes: string[]) {
31
+ super(`Slack authorization is missing required scopes: ${missingScopes.join(', ')}`);
32
+ this.name = 'SlackAuthorizationScopeMismatchError';
33
+ }
34
+ }
35
+
36
+ export class SlackEnterpriseInstallUnsupportedError extends Error {
37
+ constructor() {
38
+ super('Slack Enterprise Grid installations are not supported');
39
+ this.name = 'SlackEnterpriseInstallUnsupportedError';
40
+ }
41
+ }
42
+
43
+ export class SlackTokenRotationUnsupportedError extends Error {
44
+ constructor() {
45
+ super('Slack token rotation is not supported');
46
+ this.name = 'SlackTokenRotationUnsupportedError';
47
+ }
48
+ }
49
+
50
+ export class SlackInstallationAlreadyLinkedError extends Error {
51
+ constructor(teamId: string) {
52
+ super(`Slack team is already linked to another Shipfox workspace: ${teamId}`);
53
+ this.name = 'SlackInstallationAlreadyLinkedError';
54
+ }
55
+ }
56
+
57
+ export class SlackConnectionAlreadyLinkedError extends Error {
58
+ constructor(connectionId: string) {
59
+ super(`Integration connection is already linked to another Slack team: ${connectionId}`);
60
+ this.name = 'SlackConnectionAlreadyLinkedError';
61
+ }
62
+ }
63
+
64
+ export class SlackConnectionNotFoundError extends Error {
65
+ constructor(connectionId: string) {
66
+ super(`Slack integration connection was not found: ${connectionId}`);
67
+ this.name = 'SlackConnectionNotFoundError';
68
+ }
69
+ }
70
+
71
+ export class SlackBotTokenMissingError extends Error {
72
+ constructor(connectionId: string) {
73
+ super(`Slack bot token is missing for connection: ${connectionId}`);
74
+ this.name = 'SlackBotTokenMissingError';
75
+ }
76
+ }
77
+
78
+ export type SlackAccessTokenUnavailableReason =
79
+ | 'installation-not-found'
80
+ | 'not-installed'
81
+ | 'expired';
82
+
83
+ export class SlackAccessTokenUnavailableError extends Error {
84
+ constructor(
85
+ public readonly connectionId: string,
86
+ public readonly reason: SlackAccessTokenUnavailableReason,
87
+ public readonly expiresAt?: Date,
88
+ ) {
89
+ super(`Slack access token is unavailable for connection: ${connectionId} (${reason})`);
90
+ this.name = 'SlackAccessTokenUnavailableError';
91
+ }
92
+ }
@@ -0,0 +1,155 @@
1
+ import type {UserContextMembership} from '@shipfox/api-auth-context';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import type {SlackApiClient} from '#api/client.js';
4
+ import type {ConnectSlackInstallationInput} from './install.js';
5
+ import {handleSlackCallback} from './install.js';
6
+ import {signSlackInstallState} from './state.js';
7
+
8
+ function authorization(overrides: Record<string, unknown> = {}) {
9
+ return {
10
+ accessToken: 'xoxb-token',
11
+ botUserId: 'U123',
12
+ appId: 'A123',
13
+ teamId: 'T123',
14
+ teamName: 'Acme',
15
+ scopes: [
16
+ 'app_mentions:read',
17
+ 'im:history',
18
+ 'chat:write',
19
+ 'channels:history',
20
+ 'groups:history',
21
+ 'channels:read',
22
+ 'groups:read',
23
+ 'users:read',
24
+ 'reactions:read',
25
+ 'reactions:write',
26
+ 'commands',
27
+ ],
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ function connection(overrides: Partial<IntegrationConnection<'slack'>> = {}) {
33
+ return {
34
+ id: '00000000-0000-4000-8000-000000000001',
35
+ workspaceId: '00000000-0000-4000-8000-000000000002',
36
+ provider: 'slack',
37
+ externalAccountId: 'T123',
38
+ slug: 'slack_acme',
39
+ displayName: 'Slack Acme',
40
+ lifecycleStatus: 'active',
41
+ createdAt: new Date(),
42
+ updatedAt: new Date(),
43
+ ...overrides,
44
+ } satisfies IntegrationConnection<'slack'>;
45
+ }
46
+
47
+ function callbackParams(
48
+ overrides: Partial<Parameters<typeof handleSlackCallback>[0]> = {},
49
+ ): Parameters<typeof handleSlackCallback>[0] {
50
+ const slack: SlackApiClient = {
51
+ exchangeAuthorizationCode: vi.fn(() => Promise.resolve(authorization())),
52
+ revokeToken: vi.fn(() => Promise.resolve()),
53
+ callMethod: vi.fn(() => Promise.resolve({ok: true})),
54
+ };
55
+ return {
56
+ slack,
57
+ tokenStore: {storeTokens: vi.fn(() => Promise.resolve())},
58
+ code: 'code',
59
+ state: signSlackInstallState({
60
+ workspaceId: '00000000-0000-4000-8000-000000000002',
61
+ userId: 'user-1',
62
+ }),
63
+ sessionUserId: 'user-1',
64
+ sessionMemberships: [
65
+ {workspaceId: '00000000-0000-4000-8000-000000000002', role: 'admin'},
66
+ ] satisfies UserContextMembership[],
67
+ requireWorkspaceMembership: vi.fn(() => Promise.resolve()),
68
+ getExistingSlackConnection: vi.fn(() => Promise.resolve(undefined)),
69
+ connectSlackInstallation: vi.fn(() => Promise.resolve(connection())),
70
+ disconnectSlackInstallation: vi.fn(() => Promise.resolve()),
71
+ ...overrides,
72
+ };
73
+ }
74
+
75
+ describe('handleSlackCallback', () => {
76
+ it('connects a Slack installation and stores its bot token', async () => {
77
+ const params = callbackParams();
78
+
79
+ const result = await handleSlackCallback(params);
80
+
81
+ expect(result).toMatchObject({provider: 'slack', externalAccountId: 'T123'});
82
+ expect(params.connectSlackInstallation).toHaveBeenCalledWith({
83
+ workspaceId: '00000000-0000-4000-8000-000000000002',
84
+ teamId: 'T123',
85
+ teamName: 'Acme',
86
+ appId: 'A123',
87
+ botUserId: 'U123',
88
+ scopes: authorization().scopes,
89
+ tokenExpiresAt: null,
90
+ displayName: 'Slack Acme',
91
+ } satisfies ConnectSlackInstallationInput);
92
+ expect(params.tokenStore.storeTokens).toHaveBeenCalledWith({
93
+ connectionId: '00000000-0000-4000-8000-000000000001',
94
+ botToken: 'xoxb-token',
95
+ editedBy: 'user-1',
96
+ });
97
+ });
98
+
99
+ it('revokes the minted token when scopes do not match', async () => {
100
+ const slack: SlackApiClient = {
101
+ exchangeAuthorizationCode: vi.fn(() =>
102
+ Promise.resolve(authorization({scopes: ['chat:write']})),
103
+ ),
104
+ revokeToken: vi.fn(() => Promise.resolve()),
105
+ callMethod: vi.fn(() => Promise.resolve({ok: true})),
106
+ };
107
+ const params = callbackParams({slack});
108
+
109
+ const result = handleSlackCallback(params);
110
+
111
+ await expect(result).rejects.toThrow('missing required scopes');
112
+ expect(slack.revokeToken).toHaveBeenCalledWith({token: 'xoxb-token'});
113
+ expect(params.connectSlackInstallation).not.toHaveBeenCalled();
114
+ });
115
+
116
+ it('revokes a cross-workspace installation attempt', async () => {
117
+ const params = callbackParams({
118
+ getExistingSlackConnection: vi.fn(() =>
119
+ Promise.resolve(connection({workspaceId: crypto.randomUUID()})),
120
+ ),
121
+ });
122
+
123
+ const result = handleSlackCallback(params);
124
+
125
+ await expect(result).rejects.toThrow('already linked');
126
+ expect(params.slack.revokeToken).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it('compensates a newly created connection when token storage fails', async () => {
130
+ const params = callbackParams({
131
+ tokenStore: {storeTokens: vi.fn(() => Promise.reject(new Error('store failed')))},
132
+ });
133
+
134
+ const result = handleSlackCallback(params);
135
+
136
+ await expect(result).rejects.toThrow('store failed');
137
+ expect(params.slack.revokeToken).toHaveBeenCalledWith({token: 'xoxb-token'});
138
+ expect(params.disconnectSlackInstallation).toHaveBeenCalledWith({
139
+ connectionId: connection().id,
140
+ });
141
+ });
142
+
143
+ it('refreshes an existing connection token without disconnecting it on storage failure', async () => {
144
+ const params = callbackParams({
145
+ getExistingSlackConnection: vi.fn(() => Promise.resolve(connection())),
146
+ tokenStore: {storeTokens: vi.fn(() => Promise.reject(new Error('store failed')))},
147
+ });
148
+
149
+ const result = handleSlackCallback(params);
150
+
151
+ await expect(result).rejects.toThrow('store failed');
152
+ expect(params.disconnectSlackInstallation).not.toHaveBeenCalled();
153
+ expect(params.slack.revokeToken).not.toHaveBeenCalled();
154
+ });
155
+ });