@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,137 @@
1
+ import type {UserContextMembership} from '@shipfox/api-auth-context';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {logger} from '@shipfox/node-opentelemetry';
4
+ import type {SlackApiClient} from '#api/client.js';
5
+ import type {SlackTokenStore} from '#core/tokens.js';
6
+ import {
7
+ SlackInstallationAlreadyLinkedError,
8
+ SlackInstallStateActorMismatchError,
9
+ SlackOAuthCallbackError,
10
+ } from './errors.js';
11
+ import {assertSlackAuthorizationScopes} from './scopes.js';
12
+ import {verifySlackInstallState} from './state.js';
13
+
14
+ export interface ConnectSlackInstallationInput {
15
+ workspaceId: string;
16
+ teamId: string;
17
+ teamName: string;
18
+ appId: string;
19
+ botUserId: string;
20
+ scopes: string[];
21
+ tokenExpiresAt: Date | null;
22
+ displayName: string;
23
+ }
24
+
25
+ export interface HandleSlackCallbackParams {
26
+ slack: SlackApiClient;
27
+ tokenStore: Pick<SlackTokenStore, 'storeTokens'>;
28
+ code: string;
29
+ state: string;
30
+ sessionUserId: string;
31
+ sessionMemberships: ReadonlyArray<UserContextMembership>;
32
+ requireWorkspaceMembership: (params: {
33
+ workspaceId: string;
34
+ userId: string;
35
+ memberships: ReadonlyArray<UserContextMembership>;
36
+ }) => Promise<unknown>;
37
+ getExistingSlackConnection: (input: {
38
+ teamId: string;
39
+ }) => Promise<IntegrationConnection<'slack'> | undefined>;
40
+ connectSlackInstallation: (
41
+ input: ConnectSlackInstallationInput,
42
+ ) => Promise<IntegrationConnection<'slack'>>;
43
+ disconnectSlackInstallation: (input: {connectionId: string}) => Promise<void>;
44
+ }
45
+
46
+ export async function handleSlackCallback(
47
+ params: HandleSlackCallbackParams,
48
+ ): Promise<IntegrationConnection<'slack'>> {
49
+ const claims = verifySlackInstallState(params.state);
50
+ if (claims.userId !== params.sessionUserId) throw new SlackInstallStateActorMismatchError();
51
+ await params.requireWorkspaceMembership({
52
+ workspaceId: claims.workspaceId,
53
+ userId: claims.userId,
54
+ memberships: params.sessionMemberships,
55
+ });
56
+
57
+ const authorization = await params.slack.exchangeAuthorizationCode({code: params.code});
58
+ let connectedConnectionId: string | undefined;
59
+ let shouldDisconnectConnection = false;
60
+ let shouldRevokeToken = false;
61
+ try {
62
+ const existing = await params.getExistingSlackConnection({teamId: authorization.teamId});
63
+ if (existing && existing.workspaceId !== claims.workspaceId) {
64
+ throw new SlackInstallationAlreadyLinkedError(authorization.teamId);
65
+ }
66
+ shouldRevokeToken = !existing;
67
+ assertSlackAuthorizationScopes(authorization.scopes);
68
+ const connection = await params.connectSlackInstallation({
69
+ workspaceId: claims.workspaceId,
70
+ teamId: authorization.teamId,
71
+ teamName: authorization.teamName,
72
+ appId: authorization.appId,
73
+ botUserId: authorization.botUserId,
74
+ scopes: authorization.scopes,
75
+ tokenExpiresAt: null,
76
+ displayName: `Slack ${authorization.teamName}`,
77
+ });
78
+ connectedConnectionId = connection.id;
79
+ shouldDisconnectConnection = !existing;
80
+ await params.tokenStore.storeTokens({
81
+ connectionId: connection.id,
82
+ botToken: authorization.accessToken,
83
+ editedBy: claims.userId,
84
+ });
85
+ return connection;
86
+ } catch (error) {
87
+ if (shouldRevokeToken) await bestEffortRevokeToken(params.slack, authorization.accessToken);
88
+ if (connectedConnectionId && shouldDisconnectConnection) {
89
+ await bestEffortDisconnectSlackInstallation(params, connectedConnectionId);
90
+ }
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ export async function handleSlackOAuthCallbackError(params: {
96
+ state: string;
97
+ error: string;
98
+ errorDescription?: string | undefined;
99
+ sessionUserId: string;
100
+ sessionMemberships: ReadonlyArray<UserContextMembership>;
101
+ requireWorkspaceMembership: (input: {
102
+ workspaceId: string;
103
+ userId: string;
104
+ memberships: ReadonlyArray<UserContextMembership>;
105
+ }) => Promise<unknown>;
106
+ }): Promise<never> {
107
+ const claims = verifySlackInstallState(params.state);
108
+ if (claims.userId !== params.sessionUserId) throw new SlackInstallStateActorMismatchError();
109
+ await params.requireWorkspaceMembership({
110
+ workspaceId: claims.workspaceId,
111
+ userId: claims.userId,
112
+ memberships: params.sessionMemberships,
113
+ });
114
+ throw new SlackOAuthCallbackError(params.error, params.errorDescription);
115
+ }
116
+
117
+ async function bestEffortRevokeToken(slack: SlackApiClient, token: string): Promise<void> {
118
+ try {
119
+ await slack.revokeToken({token});
120
+ } catch (error) {
121
+ logger().warn({err: error}, 'Slack OAuth token revocation failed');
122
+ }
123
+ }
124
+
125
+ async function bestEffortDisconnectSlackInstallation(
126
+ params: HandleSlackCallbackParams,
127
+ connectionId: string,
128
+ ): Promise<void> {
129
+ try {
130
+ await params.disconnectSlackInstallation({connectionId});
131
+ } catch (error) {
132
+ logger().warn(
133
+ {err: error, connectionId},
134
+ 'Slack connect compensation failed after token storage rejection',
135
+ );
136
+ }
137
+ }
@@ -0,0 +1,22 @@
1
+ import {SlackAuthorizationScopeMismatchError} from './errors.js';
2
+ import {assertSlackAuthorizationScopes, formatSlackBotScopes, SLACK_BOT_SCOPES} from './scopes.js';
3
+
4
+ describe('Slack bot scopes', () => {
5
+ it('formats and accepts the complete required scope set', () => {
6
+ const result = () => assertSlackAuthorizationScopes([...SLACK_BOT_SCOPES]);
7
+
8
+ expect(formatSlackBotScopes()).toBe(SLACK_BOT_SCOPES.join(','));
9
+ expect(result).not.toThrow();
10
+ });
11
+
12
+ it('reports every missing scope', () => {
13
+ const result = () => assertSlackAuthorizationScopes(['chat:write']);
14
+
15
+ expect(result).toThrow(SlackAuthorizationScopeMismatchError);
16
+ try {
17
+ result();
18
+ } catch (error) {
19
+ expect(error).toMatchObject({missingScopes: expect.arrayContaining(['app_mentions:read'])});
20
+ }
21
+ });
22
+ });
@@ -0,0 +1,25 @@
1
+ import {SlackAuthorizationScopeMismatchError} from './errors.js';
2
+
3
+ export const SLACK_BOT_SCOPES = Object.freeze([
4
+ 'app_mentions:read',
5
+ 'im:history',
6
+ 'chat:write',
7
+ 'channels:history',
8
+ 'groups:history',
9
+ 'channels:read',
10
+ 'groups:read',
11
+ 'users:read',
12
+ 'reactions:read',
13
+ 'reactions:write',
14
+ 'commands',
15
+ ]);
16
+
17
+ export function formatSlackBotScopes(scopes = SLACK_BOT_SCOPES): string {
18
+ return scopes.join(',');
19
+ }
20
+
21
+ export function assertSlackAuthorizationScopes(scopes: string[]): void {
22
+ const grantedScopes = new Set(scopes);
23
+ const missingScopes = SLACK_BOT_SCOPES.filter((scope) => !grantedScopes.has(scope));
24
+ if (missingScopes.length > 0) throw new SlackAuthorizationScopeMismatchError(missingScopes);
25
+ }
@@ -0,0 +1,45 @@
1
+ import {createHmac} from 'node:crypto';
2
+ import {verifySlackSignature} from './signature.js';
3
+
4
+ const secret = 'test-signing-secret';
5
+ const timestamp = '1721300000';
6
+ const rawBody = '{"type":"event_callback"}';
7
+ const now = Number(timestamp) * 1000;
8
+
9
+ function signature(body = rawBody): string {
10
+ return `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${body}`).digest('hex')}`;
11
+ }
12
+
13
+ describe('verifySlackSignature', () => {
14
+ it('accepts a valid current Slack v0 signature', () => {
15
+ const result = verifySlackSignature({
16
+ signingSecret: secret,
17
+ signature: signature(),
18
+ timestamp,
19
+ rawBody,
20
+ now,
21
+ });
22
+
23
+ expect(result).toBe(true);
24
+ });
25
+
26
+ it.each([
27
+ ['a tampered body', {rawBody: '{"type":"tampered"}'}],
28
+ ['a missing signature', {signature: undefined}],
29
+ ['an empty signing secret', {signingSecret: ''}],
30
+ ['a missing timestamp', {timestamp: undefined}],
31
+ ['a malformed timestamp', {timestamp: 'not-a-timestamp'}],
32
+ ['a stale timestamp', {now: now + 300_001}],
33
+ ])('rejects %s', (_description, overrides) => {
34
+ const result = verifySlackSignature({
35
+ signingSecret: secret,
36
+ signature: signature(),
37
+ timestamp,
38
+ rawBody,
39
+ now,
40
+ ...overrides,
41
+ });
42
+
43
+ expect(result).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,41 @@
1
+ import {createHmac, timingSafeEqual} from 'node:crypto';
2
+
3
+ const unixTimestampPattern = /^\d+$/;
4
+
5
+ export interface VerifySlackSignatureParams {
6
+ signingSecret: string;
7
+ signature: string | undefined;
8
+ timestamp: string | undefined;
9
+ rawBody: string;
10
+ now?: number | undefined;
11
+ replayWindowMs?: number | undefined;
12
+ }
13
+
14
+ export function verifySlackSignature({
15
+ signingSecret,
16
+ signature,
17
+ timestamp,
18
+ rawBody,
19
+ now = Date.now(),
20
+ replayWindowMs = 300_000,
21
+ }: VerifySlackSignatureParams): boolean {
22
+ if (!signingSecret || !signature || !timestamp || !unixTimestampPattern.test(timestamp)) {
23
+ return false;
24
+ }
25
+
26
+ const timestampSeconds = Number(timestamp);
27
+ if (!Number.isSafeInteger(timestampSeconds)) return false;
28
+ const timestampMilliseconds = timestampSeconds * 1000;
29
+ if (!Number.isSafeInteger(timestampMilliseconds)) return false;
30
+ if (Math.abs(now - timestampMilliseconds) > replayWindowMs) return false;
31
+
32
+ const expected = `v0=${createHmac('sha256', signingSecret)
33
+ .update(`v0:${timestamp}:${rawBody}`)
34
+ .digest('hex')}`;
35
+ const expectedBuffer = Buffer.from(expected, 'utf8');
36
+ const receivedBuffer = Buffer.from(signature, 'utf8');
37
+ return (
38
+ expectedBuffer.length === receivedBuffer.length &&
39
+ timingSafeEqual(expectedBuffer, receivedBuffer)
40
+ );
41
+ }
@@ -0,0 +1,48 @@
1
+ import {createHmac} from 'node:crypto';
2
+ import {SlackInstallStateError} from './errors.js';
3
+ import {signSlackInstallState, verifySlackInstallState} from './state.js';
4
+
5
+ describe('Slack install state', () => {
6
+ it('round-trips signed workspace and user claims', () => {
7
+ const state = signSlackInstallState({
8
+ workspaceId: 'workspace-1',
9
+ userId: 'user-1',
10
+ nonce: 'nonce-1',
11
+ now: new Date('2026-07-07T12:00:00.000Z'),
12
+ });
13
+
14
+ const result = verifySlackInstallState(state, new Date('2026-07-07T12:05:00.000Z'));
15
+
16
+ expect(result).toEqual({workspaceId: 'workspace-1', userId: 'user-1'});
17
+ });
18
+
19
+ it.each(['tampered signature', 'expired state'])('rejects a %s', (kind) => {
20
+ const state = signSlackInstallState({
21
+ workspaceId: 'workspace-1',
22
+ userId: 'user-1',
23
+ nonce: 'nonce-1',
24
+ now: new Date('2026-07-07T12:00:00.000Z'),
25
+ });
26
+ const [payload] = state.split('.');
27
+ const input = kind === 'tampered signature' ? `${payload}.tampered` : state;
28
+ const now =
29
+ kind === 'expired state'
30
+ ? new Date('2026-07-07T12:31:00.000Z')
31
+ : new Date('2026-07-07T12:05:00.000Z');
32
+
33
+ const result = () => verifySlackInstallState(input, now);
34
+
35
+ expect(result).toThrow(SlackInstallStateError);
36
+ });
37
+
38
+ it('rejects a validly signed malformed payload', () => {
39
+ const payload = Buffer.from('not-json').toString('base64url');
40
+ const signature = createHmac('sha256', 'test-client-secret')
41
+ .update(payload)
42
+ .digest('base64url');
43
+
44
+ const result = () => verifySlackInstallState(`${payload}.${signature}`);
45
+
46
+ expect(result).toThrow('Invalid Slack install state payload');
47
+ });
48
+ });
@@ -0,0 +1,82 @@
1
+ import {createHmac, randomUUID, timingSafeEqual} from 'node:crypto';
2
+ import {config} from '#config.js';
3
+ import {SlackInstallStateError} from './errors.js';
4
+
5
+ const STATE_TTL_SECONDS = 30 * 60;
6
+
7
+ interface SlackInstallStatePayload {
8
+ workspaceId: string;
9
+ userId: string;
10
+ nonce: string;
11
+ expiresAt: number;
12
+ }
13
+
14
+ export interface SlackInstallStateClaims {
15
+ workspaceId: string;
16
+ userId: string;
17
+ }
18
+
19
+ export function signSlackInstallState(params: {
20
+ workspaceId: string;
21
+ userId: string;
22
+ nonce?: string | undefined;
23
+ now?: Date | undefined;
24
+ }): string {
25
+ const now = params.now ?? new Date();
26
+ const payload: SlackInstallStatePayload = {
27
+ workspaceId: params.workspaceId,
28
+ userId: params.userId,
29
+ nonce: params.nonce ?? randomUUID(),
30
+ expiresAt: Math.floor(now.getTime() / 1000) + STATE_TTL_SECONDS,
31
+ };
32
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
33
+ return `${encodedPayload}.${sign(encodedPayload)}`;
34
+ }
35
+
36
+ export function verifySlackInstallState(
37
+ state: string,
38
+ now: Date = new Date(),
39
+ ): SlackInstallStateClaims {
40
+ const [encodedPayload, signature, extra] = state.split('.');
41
+ if (!encodedPayload || !signature || extra !== undefined) {
42
+ throw new SlackInstallStateError('Invalid Slack install state');
43
+ }
44
+ if (!constantTimeEqual(signature, sign(encodedPayload))) {
45
+ throw new SlackInstallStateError('Invalid Slack install state signature');
46
+ }
47
+ const payload = parsePayload(encodedPayload);
48
+ if (payload.expiresAt < Math.floor(now.getTime() / 1000)) {
49
+ throw new SlackInstallStateError('Expired Slack install state');
50
+ }
51
+ return {workspaceId: payload.workspaceId, userId: payload.userId};
52
+ }
53
+
54
+ function sign(encodedPayload: string): string {
55
+ return createHmac('sha256', config.SLACK_OAUTH_CLIENT_SECRET)
56
+ .update(encodedPayload)
57
+ .digest('base64url');
58
+ }
59
+
60
+ function constantTimeEqual(a: string, b: string): boolean {
61
+ const left = Buffer.from(a);
62
+ const right = Buffer.from(b);
63
+ if (left.length !== right.length) return false;
64
+ return timingSafeEqual(left, right);
65
+ }
66
+
67
+ function parsePayload(encodedPayload: string): SlackInstallStatePayload {
68
+ try {
69
+ const parsed = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
70
+ if (
71
+ typeof parsed.workspaceId !== 'string' ||
72
+ typeof parsed.userId !== 'string' ||
73
+ typeof parsed.nonce !== 'string' ||
74
+ typeof parsed.expiresAt !== 'number'
75
+ ) {
76
+ throw new Error('Invalid payload shape');
77
+ }
78
+ return parsed;
79
+ } catch (_error) {
80
+ throw new SlackInstallStateError('Invalid Slack install state payload');
81
+ }
82
+ }
@@ -0,0 +1,163 @@
1
+ import {SlackBotTokenMissingError, SlackConnectionNotFoundError} from '#core/errors.js';
2
+ import {upsertSlackInstallation} from '#db/installations.js';
3
+ import {
4
+ createSlackTokenStore,
5
+ type SlackConnectionResolverResult,
6
+ type SlackSecretsStore,
7
+ slackSecretsNamespace,
8
+ } from './tokens.js';
9
+
10
+ let slackSecrets: SlackSecretsStore;
11
+
12
+ beforeAll(async () => {
13
+ slackSecrets = await import('@shipfox/api-secrets');
14
+ });
15
+
16
+ function createConnectionContext() {
17
+ const workspaceId = crypto.randomUUID();
18
+ const connectionId = crypto.randomUUID();
19
+ const resolveConnection = vi
20
+ .fn<(connectionId: string) => Promise<SlackConnectionResolverResult | undefined>>()
21
+ .mockResolvedValue({workspaceId});
22
+ const getSecret = vi.fn<SlackSecretsStore['getSecret']>((params) =>
23
+ slackSecrets.getSecret(params),
24
+ );
25
+ const secrets = {getSecret, setSecrets: slackSecrets.setSecrets};
26
+ const store = createSlackTokenStore({resolveConnection, secrets});
27
+
28
+ return {workspaceId, connectionId, getSecret, resolveConnection, store};
29
+ }
30
+
31
+ async function arrangeSlackInstallation({
32
+ connectionId,
33
+ status = 'installed',
34
+ tokenExpiresAt = null,
35
+ }: {
36
+ connectionId: string;
37
+ status?: 'installed' | 'revoked';
38
+ tokenExpiresAt?: Date | null;
39
+ }): Promise<void> {
40
+ await upsertSlackInstallation({
41
+ connectionId,
42
+ teamId: `T${crypto.randomUUID()}`,
43
+ teamName: 'Acme',
44
+ appId: 'A123',
45
+ botUserId: 'U123',
46
+ scopes: ['app_mentions:read'],
47
+ status,
48
+ tokenExpiresAt,
49
+ });
50
+ }
51
+
52
+ describe('createSlackTokenStore', () => {
53
+ afterEach(() => {
54
+ vi.useRealTimers();
55
+ });
56
+
57
+ it('stores and reads a bot token in the Slack system namespace', async () => {
58
+ const {workspaceId, connectionId, store} = createConnectionContext();
59
+
60
+ await store.storeTokens({
61
+ connectionId,
62
+ botToken: 'xoxb-test-token',
63
+ editedBy: crypto.randomUUID(),
64
+ });
65
+ await arrangeSlackInstallation({connectionId});
66
+
67
+ const result = await store.getAccessToken({connectionId});
68
+
69
+ await expect(
70
+ slackSecrets.getSecret({
71
+ workspaceId,
72
+ namespace: slackSecretsNamespace(connectionId),
73
+ key: 'BOT_TOKEN',
74
+ }),
75
+ ).resolves.toBe('xoxb-test-token');
76
+ expect(result).toBe('xoxb-test-token');
77
+ });
78
+
79
+ it('reads an installed bot token with a future expiry', async () => {
80
+ const {connectionId, store} = createConnectionContext();
81
+ await store.storeTokens({connectionId, botToken: 'xoxb-test-token'});
82
+ await arrangeSlackInstallation({
83
+ connectionId,
84
+ tokenExpiresAt: new Date(Date.now() + 60_000),
85
+ });
86
+
87
+ const result = await store.getAccessToken({connectionId});
88
+
89
+ expect(result).toBe('xoxb-test-token');
90
+ });
91
+
92
+ it('rejects a token whose expiry is exactly now', async () => {
93
+ vi.useFakeTimers();
94
+ const now = new Date('2026-07-18T15:00:00.000Z');
95
+ vi.setSystemTime(now);
96
+ const {connectionId, getSecret, store} = createConnectionContext();
97
+ await arrangeSlackInstallation({connectionId, tokenExpiresAt: now});
98
+
99
+ const result = store.getAccessToken({connectionId});
100
+
101
+ await expect(result).rejects.toMatchObject({
102
+ reason: 'expired',
103
+ expiresAt: now,
104
+ });
105
+ expect(getSecret).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it('rejects a token whose expiry is in the past', async () => {
109
+ const {connectionId, getSecret, store} = createConnectionContext();
110
+ await arrangeSlackInstallation({
111
+ connectionId,
112
+ tokenExpiresAt: new Date(Date.now() - 60_000),
113
+ });
114
+
115
+ const result = store.getAccessToken({connectionId});
116
+
117
+ await expect(result).rejects.toMatchObject({
118
+ reason: 'expired',
119
+ });
120
+ expect(getSecret).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('rejects a revoked installation before reading the bot token', async () => {
124
+ const {connectionId, getSecret, store} = createConnectionContext();
125
+ await arrangeSlackInstallation({connectionId, status: 'revoked'});
126
+
127
+ const result = store.getAccessToken({connectionId});
128
+
129
+ await expect(result).rejects.toMatchObject({
130
+ reason: 'not-installed',
131
+ });
132
+ expect(getSecret).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it('rejects a missing installation before reading the bot token', async () => {
136
+ const {connectionId, getSecret, store} = createConnectionContext();
137
+
138
+ const result = store.getAccessToken({connectionId});
139
+
140
+ await expect(result).rejects.toMatchObject({
141
+ reason: 'installation-not-found',
142
+ });
143
+ expect(getSecret).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it('throws a typed error when the bot token is missing', async () => {
147
+ const {connectionId, store} = createConnectionContext();
148
+ await arrangeSlackInstallation({connectionId});
149
+
150
+ const result = store.getAccessToken({connectionId});
151
+
152
+ await expect(result).rejects.toBeInstanceOf(SlackBotTokenMissingError);
153
+ });
154
+
155
+ it('throws a typed error when the connection cannot be resolved', async () => {
156
+ const {connectionId, resolveConnection, store} = createConnectionContext();
157
+ resolveConnection.mockResolvedValue(undefined);
158
+
159
+ const result = store.getAccessToken({connectionId});
160
+
161
+ await expect(result).rejects.toBeInstanceOf(SlackConnectionNotFoundError);
162
+ });
163
+ });
@@ -0,0 +1,98 @@
1
+ import {
2
+ SlackAccessTokenUnavailableError,
3
+ SlackBotTokenMissingError,
4
+ SlackConnectionNotFoundError,
5
+ } from '#core/errors.js';
6
+ import {getSlackInstallationByConnectionId, type SlackInstallation} from '#db/installations.js';
7
+
8
+ const BOT_TOKEN_KEY = 'BOT_TOKEN';
9
+
10
+ export interface SlackConnectionResolverResult {
11
+ workspaceId: string;
12
+ }
13
+
14
+ export interface SlackSecretsStore {
15
+ getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;
16
+ setSecrets(params: {
17
+ workspaceId: string;
18
+ namespace: string;
19
+ values: Record<string, string>;
20
+ editedBy?: string | null | undefined;
21
+ }): Promise<void>;
22
+ }
23
+
24
+ export interface CreateSlackTokenStoreParams {
25
+ resolveConnection(connectionId: string): Promise<SlackConnectionResolverResult | undefined>;
26
+ secrets: SlackSecretsStore;
27
+ }
28
+
29
+ export interface StoreSlackTokensParams {
30
+ connectionId: string;
31
+ botToken: string;
32
+ editedBy?: string | null | undefined;
33
+ }
34
+
35
+ export interface GetSlackAccessTokenParams {
36
+ connectionId: string;
37
+ }
38
+
39
+ export interface SlackTokenStore {
40
+ storeTokens(params: StoreSlackTokensParams): Promise<void>;
41
+ getAccessToken(params: GetSlackAccessTokenParams): Promise<string>;
42
+ }
43
+
44
+ export function slackSecretsNamespace(connectionId: string): string {
45
+ return `system/integrations/slack/${connectionId}`;
46
+ }
47
+
48
+ function assertSlackInstallationServesToken(
49
+ connectionId: string,
50
+ installation: SlackInstallation | undefined,
51
+ ): asserts installation is SlackInstallation {
52
+ if (!installation) {
53
+ throw new SlackAccessTokenUnavailableError(connectionId, 'installation-not-found');
54
+ }
55
+ if (installation.status !== 'installed') {
56
+ throw new SlackAccessTokenUnavailableError(connectionId, 'not-installed');
57
+ }
58
+ if (installation.tokenExpiresAt !== null && installation.tokenExpiresAt.getTime() <= Date.now()) {
59
+ throw new SlackAccessTokenUnavailableError(
60
+ connectionId,
61
+ 'expired',
62
+ installation.tokenExpiresAt,
63
+ );
64
+ }
65
+ }
66
+
67
+ export function createSlackTokenStore(params: CreateSlackTokenStoreParams): SlackTokenStore {
68
+ async function resolveWorkspaceId(connectionId: string): Promise<string> {
69
+ const connection = await params.resolveConnection(connectionId);
70
+ if (!connection) throw new SlackConnectionNotFoundError(connectionId);
71
+ return connection.workspaceId;
72
+ }
73
+
74
+ return {
75
+ async storeTokens(input) {
76
+ const workspaceId = await resolveWorkspaceId(input.connectionId);
77
+ await params.secrets.setSecrets({
78
+ workspaceId,
79
+ namespace: slackSecretsNamespace(input.connectionId),
80
+ values: {[BOT_TOKEN_KEY]: input.botToken},
81
+ editedBy: input.editedBy,
82
+ });
83
+ },
84
+
85
+ async getAccessToken(input) {
86
+ const workspaceId = await resolveWorkspaceId(input.connectionId);
87
+ const installation = await getSlackInstallationByConnectionId(input.connectionId);
88
+ assertSlackInstallationServesToken(input.connectionId, installation);
89
+ const token = await params.secrets.getSecret({
90
+ workspaceId,
91
+ namespace: slackSecretsNamespace(input.connectionId),
92
+ key: BOT_TOKEN_KEY,
93
+ });
94
+ if (!token) throw new SlackBotTokenMissingError(input.connectionId);
95
+ return token;
96
+ },
97
+ };
98
+ }