@shipfox/api-integration-slack 4.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +59 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +3 -1
- package/dist/api/client.js.map +1 -1
- package/dist/core/signature.d.ts +2 -1
- package/dist/core/signature.d.ts.map +1 -1
- package/dist/core/signature.js +10 -6
- package/dist/core/signature.js.map +1 -1
- package/dist/core/webhook-processor.d.ts +15 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +127 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/core/webhook.d.ts +5 -3
- package/dist/core/webhook.d.ts.map +1 -1
- package/dist/core/webhook.js +101 -2
- package/dist/core/webhook.js.map +1 -1
- package/dist/db/db.d.ts +34 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +2 -0
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +4 -2
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +17 -0
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +3 -1
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +28 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -7
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/errors.d.ts.map +1 -1
- package/dist/presentation/routes/errors.js +22 -0
- package/dist/presentation/routes/errors.js.map +1 -1
- package/dist/presentation/routes/install.d.ts +6 -1
- package/dist/presentation/routes/install.d.ts.map +1 -1
- package/dist/presentation/routes/install.js +6 -4
- package/dist/presentation/routes/install.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +4 -1
- package/dist/presentation/routes/webhooks.d.ts.map +1 -1
- package/dist/presentation/routes/webhooks.js +94 -92
- package/dist/presentation/routes/webhooks.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_dark_tyrannus.sql +1 -0
- package/drizzle/meta/0001_snapshot.json +139 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +20 -30
- package/src/api/client.test.ts +24 -0
- package/src/api/client.ts +3 -1
- package/src/core/disconnect.test.ts +96 -0
- package/src/core/signature.test.ts +3 -3
- package/src/core/signature.ts +19 -9
- package/src/core/tokens.test.ts +16 -2
- package/src/core/webhook-processor.test.ts +106 -0
- package/src/core/webhook-processor.ts +152 -0
- package/src/core/webhook.test.ts +131 -2
- package/src/core/webhook.ts +143 -6
- package/src/db/installations.test.ts +15 -0
- package/src/db/installations.ts +11 -3
- package/src/db/schema/installations.ts +3 -1
- package/src/index.test.ts +11 -0
- package/src/index.ts +30 -3
- package/src/presentation/routes/errors.test.ts +25 -0
- package/src/presentation/routes/errors.ts +20 -0
- package/src/presentation/routes/install.test.ts +2 -4
- package/src/presentation/routes/install.ts +18 -3
- package/src/presentation/routes/webhooks.test.ts +46 -1
- package/src/presentation/routes/webhooks.ts +127 -97
- package/test/globalSetup.ts +0 -9
- package/tsconfig.build.tsbuildinfo +1 -1
- package/turbo.json +9 -0
- package/test/api-secrets.d.ts +0 -26
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "integrations_slack_installations" ADD COLUMN "generation" integer DEFAULT 1 NOT NULL;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "5acf9796-7da0-4134-b7f8-000a1a5a7de0",
|
|
3
|
+
"prevId": "62801d4c-2399-4306-8d37-9773d10cbb02",
|
|
4
|
+
"version": "7",
|
|
5
|
+
"dialect": "postgresql",
|
|
6
|
+
"tables": {
|
|
7
|
+
"public.integrations_slack_installations": {
|
|
8
|
+
"name": "integrations_slack_installations",
|
|
9
|
+
"schema": "",
|
|
10
|
+
"columns": {
|
|
11
|
+
"id": {
|
|
12
|
+
"name": "id",
|
|
13
|
+
"type": "uuid",
|
|
14
|
+
"primaryKey": true,
|
|
15
|
+
"notNull": true,
|
|
16
|
+
"default": "uuidv7()"
|
|
17
|
+
},
|
|
18
|
+
"connection_id": {
|
|
19
|
+
"name": "connection_id",
|
|
20
|
+
"type": "uuid",
|
|
21
|
+
"primaryKey": false,
|
|
22
|
+
"notNull": true
|
|
23
|
+
},
|
|
24
|
+
"team_id": {
|
|
25
|
+
"name": "team_id",
|
|
26
|
+
"type": "text",
|
|
27
|
+
"primaryKey": false,
|
|
28
|
+
"notNull": true
|
|
29
|
+
},
|
|
30
|
+
"team_name": {
|
|
31
|
+
"name": "team_name",
|
|
32
|
+
"type": "text",
|
|
33
|
+
"primaryKey": false,
|
|
34
|
+
"notNull": true
|
|
35
|
+
},
|
|
36
|
+
"app_id": {
|
|
37
|
+
"name": "app_id",
|
|
38
|
+
"type": "text",
|
|
39
|
+
"primaryKey": false,
|
|
40
|
+
"notNull": true
|
|
41
|
+
},
|
|
42
|
+
"bot_user_id": {
|
|
43
|
+
"name": "bot_user_id",
|
|
44
|
+
"type": "text",
|
|
45
|
+
"primaryKey": false,
|
|
46
|
+
"notNull": true
|
|
47
|
+
},
|
|
48
|
+
"scopes": {
|
|
49
|
+
"name": "scopes",
|
|
50
|
+
"type": "jsonb",
|
|
51
|
+
"primaryKey": false,
|
|
52
|
+
"notNull": true
|
|
53
|
+
},
|
|
54
|
+
"status": {
|
|
55
|
+
"name": "status",
|
|
56
|
+
"type": "text",
|
|
57
|
+
"primaryKey": false,
|
|
58
|
+
"notNull": true
|
|
59
|
+
},
|
|
60
|
+
"generation": {
|
|
61
|
+
"name": "generation",
|
|
62
|
+
"type": "integer",
|
|
63
|
+
"primaryKey": false,
|
|
64
|
+
"notNull": true,
|
|
65
|
+
"default": 1
|
|
66
|
+
},
|
|
67
|
+
"token_expires_at": {
|
|
68
|
+
"name": "token_expires_at",
|
|
69
|
+
"type": "timestamp with time zone",
|
|
70
|
+
"primaryKey": false,
|
|
71
|
+
"notNull": false
|
|
72
|
+
},
|
|
73
|
+
"created_at": {
|
|
74
|
+
"name": "created_at",
|
|
75
|
+
"type": "timestamp with time zone",
|
|
76
|
+
"primaryKey": false,
|
|
77
|
+
"notNull": true,
|
|
78
|
+
"default": "now()"
|
|
79
|
+
},
|
|
80
|
+
"updated_at": {
|
|
81
|
+
"name": "updated_at",
|
|
82
|
+
"type": "timestamp with time zone",
|
|
83
|
+
"primaryKey": false,
|
|
84
|
+
"notNull": true,
|
|
85
|
+
"default": "now()"
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"indexes": {
|
|
89
|
+
"integrations_slack_installations_connection_unique": {
|
|
90
|
+
"name": "integrations_slack_installations_connection_unique",
|
|
91
|
+
"columns": [
|
|
92
|
+
{
|
|
93
|
+
"expression": "connection_id",
|
|
94
|
+
"isExpression": false,
|
|
95
|
+
"asc": true,
|
|
96
|
+
"nulls": "last"
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
"isUnique": true,
|
|
100
|
+
"concurrently": false,
|
|
101
|
+
"method": "btree",
|
|
102
|
+
"with": {}
|
|
103
|
+
},
|
|
104
|
+
"integrations_slack_installations_team_unique": {
|
|
105
|
+
"name": "integrations_slack_installations_team_unique",
|
|
106
|
+
"columns": [
|
|
107
|
+
{
|
|
108
|
+
"expression": "team_id",
|
|
109
|
+
"isExpression": false,
|
|
110
|
+
"asc": true,
|
|
111
|
+
"nulls": "last"
|
|
112
|
+
}
|
|
113
|
+
],
|
|
114
|
+
"isUnique": true,
|
|
115
|
+
"concurrently": false,
|
|
116
|
+
"method": "btree",
|
|
117
|
+
"with": {}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
"foreignKeys": {},
|
|
121
|
+
"compositePrimaryKeys": {},
|
|
122
|
+
"uniqueConstraints": {},
|
|
123
|
+
"policies": {},
|
|
124
|
+
"checkConstraints": {},
|
|
125
|
+
"isRLSEnabled": false
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"enums": {},
|
|
129
|
+
"schemas": {},
|
|
130
|
+
"sequences": {},
|
|
131
|
+
"roles": {},
|
|
132
|
+
"policies": {},
|
|
133
|
+
"views": {},
|
|
134
|
+
"_meta": {
|
|
135
|
+
"columns": {},
|
|
136
|
+
"schemas": {},
|
|
137
|
+
"tables": {}
|
|
138
|
+
}
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-integration-slack",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "6.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -13,56 +13,46 @@
|
|
|
13
13
|
"types": "dist/index.d.ts",
|
|
14
14
|
"imports": {
|
|
15
15
|
"#test/*": "./test/*",
|
|
16
|
-
"#*":
|
|
17
|
-
"workspace-source": "./src/*",
|
|
18
|
-
"development": "./src/*",
|
|
19
|
-
"default": "./dist/*"
|
|
20
|
-
}
|
|
16
|
+
"#*": "./dist/*"
|
|
21
17
|
},
|
|
22
18
|
"exports": {
|
|
23
19
|
".": {
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"default": "./src/index.ts"
|
|
27
|
-
},
|
|
28
|
-
"default": {
|
|
29
|
-
"types": "./dist/index.d.ts",
|
|
30
|
-
"default": "./dist/index.js"
|
|
31
|
-
}
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
32
22
|
}
|
|
33
23
|
},
|
|
34
24
|
"dependencies": {
|
|
35
25
|
"drizzle-orm": "^0.45.2",
|
|
36
26
|
"ky": "^2.0.0",
|
|
37
27
|
"zod": "^4.4.3",
|
|
38
|
-
"@shipfox/api-auth-context": "
|
|
39
|
-
"@shipfox/api-integration-core-dto": "
|
|
40
|
-
"@shipfox/
|
|
41
|
-
"@shipfox/api-
|
|
42
|
-
"@shipfox/
|
|
43
|
-
"@shipfox/
|
|
44
|
-
"@shipfox/node-
|
|
45
|
-
"@shipfox/node-
|
|
46
|
-
"@shipfox/node-
|
|
47
|
-
|
|
48
|
-
"peerDependencies": {
|
|
49
|
-
"@shipfox/api-secrets": "4.0.0"
|
|
28
|
+
"@shipfox/api-auth-context": "6.0.0",
|
|
29
|
+
"@shipfox/api-integration-core-dto": "6.0.0",
|
|
30
|
+
"@shipfox/api-integration-slack-dto": "6.0.0",
|
|
31
|
+
"@shipfox/api-workspaces-dto": "6.0.0",
|
|
32
|
+
"@shipfox/config": "1.2.2",
|
|
33
|
+
"@shipfox/inter-module": "0.2.0",
|
|
34
|
+
"@shipfox/node-drizzle": "0.3.2",
|
|
35
|
+
"@shipfox/node-fastify": "0.2.4",
|
|
36
|
+
"@shipfox/node-opentelemetry": "0.5.2",
|
|
37
|
+
"@shipfox/node-postgres": "0.4.2"
|
|
50
38
|
},
|
|
51
39
|
"devDependencies": {
|
|
52
40
|
"@types/pg": "^8.15.5",
|
|
53
41
|
"drizzle-kit": "^0.31.10",
|
|
54
42
|
"fastify": "^5.3.3",
|
|
55
43
|
"fishery": "^2.4.0",
|
|
56
|
-
"@shipfox/
|
|
57
|
-
"@shipfox/
|
|
44
|
+
"@shipfox/biome": "1.8.2",
|
|
45
|
+
"@shipfox/depcruise": "1.0.2",
|
|
46
|
+
"@shipfox/swc": "1.2.6",
|
|
58
47
|
"@shipfox/ts-config": "1.3.8",
|
|
59
|
-
"@shipfox/typescript": "1.1.
|
|
60
|
-
"@shipfox/vitest": "1.2.
|
|
48
|
+
"@shipfox/typescript": "1.1.7",
|
|
49
|
+
"@shipfox/vitest": "1.2.3"
|
|
61
50
|
},
|
|
62
51
|
"scripts": {
|
|
63
52
|
"build": "shipfox-swc",
|
|
64
53
|
"check": "shipfox-biome-check",
|
|
65
54
|
"check:fix": "shipfox-biome-check --write",
|
|
55
|
+
"depcruise": "shipfox-depcruise",
|
|
66
56
|
"test": "shipfox-vitest-run",
|
|
67
57
|
"test:watch": "shipfox-vitest-watch",
|
|
68
58
|
"type": "shipfox-tsc-check",
|
package/src/api/client.test.ts
CHANGED
|
@@ -217,6 +217,30 @@ describe('createSlackApiClient', () => {
|
|
|
217
217
|
expect(options.body.has('latest')).toBe(false);
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
it('normalizes a trailing slash in the Slack API base URL', async () => {
|
|
221
|
+
vi.stubEnv('SLACK_API_BASE_URL', 'http://127.0.0.1:0/');
|
|
222
|
+
vi.resetModules();
|
|
223
|
+
mocks.post.mockReturnValue(resolves({ok: true}));
|
|
224
|
+
try {
|
|
225
|
+
const {createSlackApiClient: createClient} = await import('./client.js');
|
|
226
|
+
|
|
227
|
+
await createClient().callMethod({
|
|
228
|
+
method: 'conversations.replies',
|
|
229
|
+
token: 'xoxb-secret',
|
|
230
|
+
arguments: {channel: 'C123', ts: '1721300000.000001'},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(mocks.post).toHaveBeenCalledWith('http://127.0.0.1:0/conversations.replies', {
|
|
234
|
+
headers: {authorization: 'Bearer xoxb-secret'},
|
|
235
|
+
body: new URLSearchParams({channel: 'C123', ts: '1721300000.000001'}),
|
|
236
|
+
timeout: 10_000,
|
|
237
|
+
});
|
|
238
|
+
} finally {
|
|
239
|
+
vi.unstubAllEnvs();
|
|
240
|
+
vi.resetModules();
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
220
244
|
it.each([
|
|
221
245
|
[httpError(429, {'retry-after': '17'}), 'rate-limited', 17],
|
|
222
246
|
[httpError(413), 'content-too-large', undefined],
|
package/src/api/client.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from '#core/errors.js';
|
|
9
9
|
|
|
10
10
|
const SLACK_API_TIMEOUT_MS = 10_000;
|
|
11
|
+
const trailingSlashesPattern = /\/+$/;
|
|
11
12
|
|
|
12
13
|
export interface SlackAuthorization {
|
|
13
14
|
accessToken: string;
|
|
@@ -232,7 +233,8 @@ function slackMethodArguments(input: Record<string, unknown>): URLSearchParams {
|
|
|
232
233
|
}
|
|
233
234
|
|
|
234
235
|
function slackApiUrl(path: string): string {
|
|
235
|
-
|
|
236
|
+
const baseUrl = config.SLACK_API_BASE_URL.replace(trailingSlashesPattern, '');
|
|
237
|
+
return new URL(path, `${baseUrl}/`).toString();
|
|
236
238
|
}
|
|
237
239
|
|
|
238
240
|
function retryAfterSeconds(headers: Headers): number | undefined {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {disconnectSlackInstallation} from './disconnect.js';
|
|
2
|
+
|
|
3
|
+
vi.mock('#db/installations.js', () => ({
|
|
4
|
+
deleteSlackInstallationByConnectionId: vi.fn(() => Promise.resolve(true)),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
const {deleteSlackInstallationByConnectionId} = await import('#db/installations.js');
|
|
8
|
+
const deleteSlackInstallationByConnectionIdMock = vi.mocked(deleteSlackInstallationByConnectionId);
|
|
9
|
+
|
|
10
|
+
describe('disconnectSlackInstallation', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
deleteSlackInstallationByConnectionIdMock.mockClear();
|
|
13
|
+
deleteSlackInstallationByConnectionIdMock.mockResolvedValue(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('deletes stored tokens before deleting connection records', async () => {
|
|
17
|
+
const tx = Symbol('tx');
|
|
18
|
+
const calls: string[] = [];
|
|
19
|
+
const deleteSecrets = vi.fn(() => {
|
|
20
|
+
calls.push('secrets');
|
|
21
|
+
return Promise.resolve(1);
|
|
22
|
+
});
|
|
23
|
+
const deleteConnection = vi.fn(() => {
|
|
24
|
+
calls.push('connection');
|
|
25
|
+
return Promise.resolve(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await disconnectSlackInstallation({
|
|
29
|
+
connectionId: 'connection-1',
|
|
30
|
+
getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
|
|
31
|
+
deleteSecrets,
|
|
32
|
+
transaction: async (fn) => await fn(tx),
|
|
33
|
+
deleteConnection,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(deleteSecrets).toHaveBeenCalledWith({
|
|
37
|
+
workspaceId: 'workspace-1',
|
|
38
|
+
namespace: 'system/integrations/slack/connection-1',
|
|
39
|
+
});
|
|
40
|
+
expect(deleteSlackInstallationByConnectionIdMock).toHaveBeenCalledWith('connection-1', {tx});
|
|
41
|
+
expect(deleteConnection).toHaveBeenCalledWith({connectionId: 'connection-1'}, {tx});
|
|
42
|
+
expect(calls).toEqual(['secrets', 'connection']);
|
|
43
|
+
expect(deleteSecrets.mock.invocationCallOrder[0]).toBeLessThan(
|
|
44
|
+
deleteSlackInstallationByConnectionIdMock.mock.invocationCallOrder[0] ?? 0,
|
|
45
|
+
);
|
|
46
|
+
expect(deleteSlackInstallationByConnectionIdMock.mock.invocationCallOrder[0]).toBeLessThan(
|
|
47
|
+
deleteConnection.mock.invocationCallOrder[0] ?? 0,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('keeps connection records when secret deletion fails', async () => {
|
|
52
|
+
const transaction = vi.fn();
|
|
53
|
+
const deleteConnection = vi.fn();
|
|
54
|
+
|
|
55
|
+
const run = disconnectSlackInstallation({
|
|
56
|
+
connectionId: 'connection-1',
|
|
57
|
+
getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
|
|
58
|
+
deleteSecrets: vi.fn(() => Promise.reject(new Error('secret store unavailable'))),
|
|
59
|
+
transaction,
|
|
60
|
+
deleteConnection,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await expect(run).rejects.toThrow('secret store unavailable');
|
|
64
|
+
expect(transaction).not.toHaveBeenCalled();
|
|
65
|
+
expect(deleteSlackInstallationByConnectionIdMock).not.toHaveBeenCalled();
|
|
66
|
+
expect(deleteConnection).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('retries idempotent secret deletion after the records transaction fails', async () => {
|
|
70
|
+
const tx = Symbol('tx');
|
|
71
|
+
const deleteSecrets = vi.fn(() => Promise.resolve(0));
|
|
72
|
+
const deleteConnection = vi.fn(() => Promise.resolve(true));
|
|
73
|
+
const transaction = vi
|
|
74
|
+
.fn()
|
|
75
|
+
.mockImplementationOnce(async (fn: (value: symbol) => Promise<unknown>) => {
|
|
76
|
+
await fn(tx);
|
|
77
|
+
throw new Error('database commit failed');
|
|
78
|
+
})
|
|
79
|
+
.mockImplementationOnce(async (fn: (value: symbol) => Promise<unknown>) => await fn(tx));
|
|
80
|
+
const params = {
|
|
81
|
+
connectionId: 'connection-1',
|
|
82
|
+
getConnection: vi.fn(() => Promise.resolve({workspaceId: 'workspace-1'})),
|
|
83
|
+
deleteSecrets,
|
|
84
|
+
transaction,
|
|
85
|
+
deleteConnection,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const firstAttempt = disconnectSlackInstallation(params);
|
|
89
|
+
await expect(firstAttempt).rejects.toThrow('database commit failed');
|
|
90
|
+
await disconnectSlackInstallation(params);
|
|
91
|
+
|
|
92
|
+
expect(deleteSecrets).toHaveBeenCalledTimes(2);
|
|
93
|
+
expect(deleteSlackInstallationByConnectionIdMock).toHaveBeenCalledTimes(2);
|
|
94
|
+
expect(deleteConnection).toHaveBeenCalledTimes(2);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -3,11 +3,11 @@ import {verifySlackSignature} from './signature.js';
|
|
|
3
3
|
|
|
4
4
|
const secret = 'test-signing-secret';
|
|
5
5
|
const timestamp = '1721300000';
|
|
6
|
-
const rawBody = '{"type":"event_callback"}';
|
|
6
|
+
const rawBody = Buffer.from('{"type":"event_callback"}');
|
|
7
7
|
const now = Number(timestamp) * 1000;
|
|
8
8
|
|
|
9
9
|
function signature(body = rawBody): string {
|
|
10
|
-
return `v0=${createHmac('sha256', secret).update(`v0:${timestamp}
|
|
10
|
+
return `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:`).update(body).digest('hex')}`;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
describe('verifySlackSignature', () => {
|
|
@@ -24,7 +24,7 @@ describe('verifySlackSignature', () => {
|
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
it.each([
|
|
27
|
-
['a tampered body', {rawBody: '{"type":"tampered"}'}],
|
|
27
|
+
['a tampered body', {rawBody: Buffer.from('{"type":"tampered"}')}],
|
|
28
28
|
['a missing signature', {signature: undefined}],
|
|
29
29
|
['an empty signing secret', {signingSecret: ''}],
|
|
30
30
|
['a missing timestamp', {timestamp: undefined}],
|
package/src/core/signature.ts
CHANGED
|
@@ -6,11 +6,25 @@ export interface VerifySlackSignatureParams {
|
|
|
6
6
|
signingSecret: string;
|
|
7
7
|
signature: string | undefined;
|
|
8
8
|
timestamp: string | undefined;
|
|
9
|
-
rawBody:
|
|
9
|
+
rawBody: Uint8Array;
|
|
10
10
|
now?: number | undefined;
|
|
11
11
|
replayWindowMs?: number | undefined;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export function isSlackTimestampWithinReplayWindow(
|
|
15
|
+
timestamp: string,
|
|
16
|
+
now = Date.now(),
|
|
17
|
+
replayWindowMs = 300_000,
|
|
18
|
+
): boolean {
|
|
19
|
+
if (!unixTimestampPattern.test(timestamp)) return false;
|
|
20
|
+
|
|
21
|
+
const timestampSeconds = Number(timestamp);
|
|
22
|
+
if (!Number.isSafeInteger(timestampSeconds)) return false;
|
|
23
|
+
const timestampMilliseconds = timestampSeconds * 1000;
|
|
24
|
+
if (!Number.isSafeInteger(timestampMilliseconds)) return false;
|
|
25
|
+
return Math.abs(now - timestampMilliseconds) <= replayWindowMs;
|
|
26
|
+
}
|
|
27
|
+
|
|
14
28
|
export function verifySlackSignature({
|
|
15
29
|
signingSecret,
|
|
16
30
|
signature,
|
|
@@ -19,18 +33,14 @@ export function verifySlackSignature({
|
|
|
19
33
|
now = Date.now(),
|
|
20
34
|
replayWindowMs = 300_000,
|
|
21
35
|
}: VerifySlackSignatureParams): boolean {
|
|
22
|
-
if (!signingSecret || !signature || !timestamp
|
|
36
|
+
if (!signingSecret || !signature || !timestamp) {
|
|
23
37
|
return false;
|
|
24
38
|
}
|
|
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;
|
|
39
|
+
if (!isSlackTimestampWithinReplayWindow(timestamp, now, replayWindowMs)) return false;
|
|
31
40
|
|
|
32
41
|
const expected = `v0=${createHmac('sha256', signingSecret)
|
|
33
|
-
.update(`v0:${timestamp}
|
|
42
|
+
.update(`v0:${timestamp}:`)
|
|
43
|
+
.update(rawBody)
|
|
34
44
|
.digest('hex')}`;
|
|
35
45
|
const expectedBuffer = Buffer.from(expected, 'utf8');
|
|
36
46
|
const receivedBuffer = Buffer.from(signature, 'utf8');
|
package/src/core/tokens.test.ts
CHANGED
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
|
|
10
10
|
let slackSecrets: SlackSecretsStore;
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
slackSecrets =
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
slackSecrets = createInMemorySecretsStore();
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
function createConnectionContext() {
|
|
@@ -161,3 +161,17 @@ describe('createSlackTokenStore', () => {
|
|
|
161
161
|
await expect(result).rejects.toBeInstanceOf(SlackConnectionNotFoundError);
|
|
162
162
|
});
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
function createInMemorySecretsStore(): SlackSecretsStore {
|
|
166
|
+
const values = new Map<string, string>();
|
|
167
|
+
const id = (params: {workspaceId: string; namespace: string; key: string}) =>
|
|
168
|
+
`${params.workspaceId}\0${params.namespace}\0${params.key}`;
|
|
169
|
+
return {
|
|
170
|
+
getSecret: async (params) => values.get(id(params)) ?? null,
|
|
171
|
+
setSecrets: async (params) => {
|
|
172
|
+
await Promise.resolve();
|
|
173
|
+
for (const [key, value] of Object.entries(params.values))
|
|
174
|
+
values.set(id({...params, key}), value);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {createHmac, randomUUID} from 'node:crypto';
|
|
2
|
+
import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
|
|
3
|
+
import {createStoredWebhookRequest} from '@shipfox/api-integration-core-dto';
|
|
4
|
+
import {db} from '#db/db.js';
|
|
5
|
+
import {upsertSlackInstallation} from '#db/installations.js';
|
|
6
|
+
import {slackInstallations} from '#db/schema/installations.js';
|
|
7
|
+
import {createSlackWebhookProcessor} from './webhook-processor.js';
|
|
8
|
+
|
|
9
|
+
const signingSecret = 'test-signing-secret';
|
|
10
|
+
|
|
11
|
+
function fakeConnection(): IntegrationConnection {
|
|
12
|
+
return {
|
|
13
|
+
id: randomUUID(),
|
|
14
|
+
workspaceId: randomUUID(),
|
|
15
|
+
provider: 'slack',
|
|
16
|
+
externalAccountId: 'T1',
|
|
17
|
+
slug: 'slack_acme',
|
|
18
|
+
displayName: 'Slack Acme',
|
|
19
|
+
lifecycleStatus: 'active',
|
|
20
|
+
createdAt: new Date(),
|
|
21
|
+
updatedAt: new Date(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function signedEventRequest(input: {
|
|
26
|
+
receivedAt: string;
|
|
27
|
+
timestamp?: number;
|
|
28
|
+
}): ReturnType<typeof createStoredWebhookRequest> {
|
|
29
|
+
const body = Buffer.from(
|
|
30
|
+
JSON.stringify({
|
|
31
|
+
type: 'event_callback',
|
|
32
|
+
team_id: 'T1',
|
|
33
|
+
api_app_id: 'A1',
|
|
34
|
+
event: {type: 'app_mention', channel: 'C1', user: 'U1', text: 'hello', ts: '1.0'},
|
|
35
|
+
event_id: 'Ev-delayed',
|
|
36
|
+
event_time: 1_721_300_000,
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
const timestamp = `${input.timestamp ?? Math.floor(new Date(input.receivedAt).getTime() / 1000)}`;
|
|
40
|
+
const signature = `v0=${createHmac('sha256', signingSecret)
|
|
41
|
+
.update(`v0:${timestamp}:`)
|
|
42
|
+
.update(body)
|
|
43
|
+
.digest('hex')}`;
|
|
44
|
+
return createStoredWebhookRequest({
|
|
45
|
+
requestId: randomUUID(),
|
|
46
|
+
routeId: 'slack.event',
|
|
47
|
+
receivedAt: input.receivedAt,
|
|
48
|
+
rawQueryString: '',
|
|
49
|
+
headers: {
|
|
50
|
+
'content-type': 'application/json',
|
|
51
|
+
'x-slack-request-timestamp': timestamp,
|
|
52
|
+
'x-slack-signature': signature,
|
|
53
|
+
},
|
|
54
|
+
body,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('Slack webhook processor', () => {
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
await db().delete(slackInstallations);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('accepts an event delayed after a valid receipt', async () => {
|
|
64
|
+
const connection = fakeConnection();
|
|
65
|
+
const receivedAt = new Date(Date.now() - 10 * 60_000).toISOString();
|
|
66
|
+
await upsertSlackInstallation({
|
|
67
|
+
connectionId: connection.id,
|
|
68
|
+
teamId: 'T1',
|
|
69
|
+
teamName: 'Acme',
|
|
70
|
+
appId: 'A1',
|
|
71
|
+
botUserId: 'UBOT',
|
|
72
|
+
scopes: [],
|
|
73
|
+
status: 'installed',
|
|
74
|
+
});
|
|
75
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
76
|
+
const processor = createSlackWebhookProcessor({
|
|
77
|
+
coreDb: db,
|
|
78
|
+
claimWebhookDelivery: vi.fn(() => Promise.resolve({claimed: true})),
|
|
79
|
+
publishIntegrationEventReceived,
|
|
80
|
+
recordDeliveryOnly: vi.fn(() => Promise.resolve()),
|
|
81
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const result = await processor.process(signedEventRequest({receivedAt}));
|
|
85
|
+
|
|
86
|
+
expect(result).toMatchObject({outcome: 'processed', deliveryId: 'Ev-delayed'});
|
|
87
|
+
expect(publishIntegrationEventReceived).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('discards an event stale at receipt', async () => {
|
|
91
|
+
const receivedAt = new Date().toISOString();
|
|
92
|
+
const processor = createSlackWebhookProcessor({
|
|
93
|
+
coreDb: db,
|
|
94
|
+
claimWebhookDelivery: vi.fn(() => Promise.resolve({claimed: true})),
|
|
95
|
+
publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
|
|
96
|
+
recordDeliveryOnly: vi.fn(() => Promise.resolve()),
|
|
97
|
+
getIntegrationConnectionById: vi.fn(),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const result = await processor.process(
|
|
101
|
+
signedEventRequest({receivedAt, timestamp: Math.floor(Date.now() / 1000) - 301}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(result).toMatchObject({outcome: 'discarded', reason: 'stale_at_receipt'});
|
|
105
|
+
});
|
|
106
|
+
});
|