@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
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
$ shipfox-swc
|
|
2
|
-
Successfully compiled:
|
|
2
|
+
Successfully compiled: 25 files with swc (476.32ms)
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# @shipfox/api-integration-slack
|
|
2
2
|
|
|
3
|
+
## 6.0.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 40a6e0f: Adds shared processing for signed Slack event and command webhook deliveries.
|
|
8
|
+
- f262539: Adds a composed webhook processor and optional provider-neutral delivery source for hosted API runtimes.
|
|
9
|
+
- a42b575: Exposes Secrets through its inter-module contract and migrates Agent, integrations, and Workflows consumers.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 3bb4e26: Fixes composed webhook processing and exposes Slack URL-verification responses through the shared contract.
|
|
14
|
+
- f73da5d: Enforces bounded API context imports and routes inter-module consumers through producer contracts.
|
|
15
|
+
- 326f4c0: Exposes Workspaces inter-module operations and moves Auth and OAuth providers onto injected clients.
|
|
16
|
+
- 1820feb: Adds Slack Settings installation and callback recovery while returning stable workspace access errors.
|
|
17
|
+
- Updated dependencies [0bb82a4]
|
|
18
|
+
- Updated dependencies [7ac43a4]
|
|
19
|
+
- Updated dependencies [f262539]
|
|
20
|
+
- Updated dependencies [3bb4e26]
|
|
21
|
+
- Updated dependencies [1b79cda]
|
|
22
|
+
- Updated dependencies [c2db8c3]
|
|
23
|
+
- Updated dependencies [8bdc149]
|
|
24
|
+
- Updated dependencies [b00ed29]
|
|
25
|
+
- Updated dependencies [8aa7cd3]
|
|
26
|
+
- Updated dependencies [326f4c0]
|
|
27
|
+
- Updated dependencies [4a91956]
|
|
28
|
+
- Updated dependencies [81f9544]
|
|
29
|
+
- Updated dependencies [4604a06]
|
|
30
|
+
- @shipfox/api-integration-core-dto@6.0.0
|
|
31
|
+
- @shipfox/node-drizzle@0.3.2
|
|
32
|
+
- @shipfox/api-workspaces-dto@6.0.0
|
|
33
|
+
- @shipfox/api-auth-context@6.0.0
|
|
34
|
+
- @shipfox/node-fastify@0.2.4
|
|
35
|
+
- @shipfox/inter-module@0.2.0
|
|
36
|
+
- @shipfox/api-integration-slack-dto@6.0.0
|
|
37
|
+
|
|
38
|
+
## 5.0.0
|
|
39
|
+
|
|
40
|
+
### Minor Changes
|
|
41
|
+
|
|
42
|
+
- 2875241: Adds deduplicated Slack installation revocation for app uninstall and bot token-revocation events.
|
|
43
|
+
- fb70438: Cascades provider installation and token deletion when removing a connection.
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- bb037af: Resolves workspace packages from source during development while published consumers continue to use compiled output.
|
|
48
|
+
- Updated dependencies [2875241]
|
|
49
|
+
- Updated dependencies [bb037af]
|
|
50
|
+
- Updated dependencies [fb70438]
|
|
51
|
+
- @shipfox/api-integration-slack-dto@5.0.0
|
|
52
|
+
- @shipfox/api-integration-core-dto@5.0.0
|
|
53
|
+
- @shipfox/api-auth-context@5.0.0
|
|
54
|
+
- @shipfox/api-secrets@5.0.0
|
|
55
|
+
- @shipfox/api-workspaces@5.0.0
|
|
56
|
+
- @shipfox/config@1.2.2
|
|
57
|
+
- @shipfox/node-drizzle@0.3.1
|
|
58
|
+
- @shipfox/node-fastify@0.2.3
|
|
59
|
+
- @shipfox/node-opentelemetry@0.5.2
|
|
60
|
+
- @shipfox/node-postgres@0.4.2
|
|
61
|
+
|
|
3
62
|
## 4.0.0
|
|
4
63
|
|
|
5
64
|
### Minor Changes
|
package/dist/api/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,cAAc;IAC7B,yBAAyB,CAAC,KAAK,EAAE;QAAC,IAAI,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAC9E,WAAW,CAAC,KAAK,EAAE;QAAC,KAAK,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,UAAU,CAAC,KAAK,EAAE;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;QACd,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACpC,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;CAClC;AAeD,wBAAgB,oBAAoB,IAAI,cAAc,CAwCrD"}
|
package/dist/api/client.js
CHANGED
|
@@ -3,6 +3,7 @@ import ky, { HTTPError, TimeoutError } from 'ky';
|
|
|
3
3
|
import { config } from '#config.js';
|
|
4
4
|
import { SlackEnterpriseInstallUnsupportedError, SlackIntegrationProviderError, SlackTokenRotationUnsupportedError } from '#core/errors.js';
|
|
5
5
|
const SLACK_API_TIMEOUT_MS = 10_000;
|
|
6
|
+
const trailingSlashesPattern = /\/+$/;
|
|
6
7
|
export function createSlackApiClient() {
|
|
7
8
|
return {
|
|
8
9
|
async exchangeAuthorizationCode (input) {
|
|
@@ -151,7 +152,8 @@ function slackMethodArguments(input) {
|
|
|
151
152
|
return body;
|
|
152
153
|
}
|
|
153
154
|
function slackApiUrl(path) {
|
|
154
|
-
|
|
155
|
+
const baseUrl = config.SLACK_API_BASE_URL.replace(trailingSlashesPattern, '');
|
|
156
|
+
return new URL(path, `${baseUrl}/`).toString();
|
|
155
157
|
}
|
|
156
158
|
function retryAfterSeconds(headers) {
|
|
157
159
|
const retryAfter = headers.get('retry-after');
|
package/dist/api/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/api/client.ts"],"sourcesContent":["import {logger} from '@shipfox/node-opentelemetry';\nimport ky, {HTTPError, TimeoutError} from 'ky';\nimport {config} from '#config.js';\nimport {\n SlackEnterpriseInstallUnsupportedError,\n SlackIntegrationProviderError,\n SlackTokenRotationUnsupportedError,\n} from '#core/errors.js';\n\nconst SLACK_API_TIMEOUT_MS = 10_000;\n\nexport interface SlackAuthorization {\n accessToken: string;\n botUserId: string;\n appId: string;\n teamId: string;\n teamName: string;\n scopes: string[];\n}\n\nexport interface SlackWebApiResponse {\n ok: boolean;\n error?: string | undefined;\n [key: string]: unknown;\n}\n\nexport interface SlackApiClient {\n exchangeAuthorizationCode(input: {code: string}): Promise<SlackAuthorization>;\n revokeToken(input: {token: string}): Promise<void>;\n callMethod(input: {\n method: string;\n token: string;\n arguments: Record<string, unknown>;\n }): Promise<SlackWebApiResponse>;\n}\n\ninterface SlackOAuthAccessResponse {\n ok?: unknown;\n error?: unknown;\n access_token?: unknown;\n bot_user_id?: unknown;\n app_id?: unknown;\n team?: {id?: unknown; name?: unknown} | null | undefined;\n scope?: unknown;\n is_enterprise_install?: unknown;\n expires_in?: unknown;\n refresh_token?: unknown;\n}\n\nexport function createSlackApiClient(): SlackApiClient {\n return {\n async exchangeAuthorizationCode(input) {\n const body = await mapSlackError('exchange-authorization-code', () =>\n ky\n .post(slackApiUrl('oauth.v2.access'), {\n body: new URLSearchParams({\n client_id: config.SLACK_OAUTH_CLIENT_ID,\n client_secret: config.SLACK_OAUTH_CLIENT_SECRET,\n code: input.code,\n redirect_uri: config.SLACK_OAUTH_REDIRECT_URL,\n }),\n timeout: SLACK_API_TIMEOUT_MS,\n })\n .json<SlackOAuthAccessResponse>(),\n );\n return parseOAuthAccess(body);\n },\n\n async revokeToken(input) {\n await mapSlackError('revoke-token', async () => {\n await ky.post(slackApiUrl('auth.revoke'), {\n headers: {authorization: `Bearer ${input.token}`},\n timeout: SLACK_API_TIMEOUT_MS,\n });\n });\n },\n\n async callMethod(input) {\n return await mapSlackWebApiError(input.method, () =>\n ky\n .post(slackApiUrl(input.method), {\n headers: {authorization: `Bearer ${input.token}`},\n body: slackMethodArguments(input.arguments),\n timeout: SLACK_API_TIMEOUT_MS,\n })\n .json<SlackWebApiResponse>(),\n );\n },\n };\n}\n\nfunction parseOAuthAccess(body: SlackOAuthAccessResponse): SlackAuthorization {\n if (body.ok !== true) {\n throw new SlackIntegrationProviderError(\n slackOAuthErrorReason(body.error),\n 'Slack authorization request failed',\n );\n }\n if (body.is_enterprise_install === true || body.team == null) {\n throw new SlackEnterpriseInstallUnsupportedError();\n }\n // The bot-token store has no refresh path, so a rotating (expiring) token must never be persisted.\n // Slack omits both fields when rotation is off, so presence of either signals rotation.\n // The rejected token self-expires, matching the Enterprise Grid rejection path.\n if (body.expires_in !== undefined || body.refresh_token !== undefined) {\n throw new SlackTokenRotationUnsupportedError();\n }\n const {access_token: accessToken, bot_user_id: botUserId, app_id: appId, team, scope} = body;\n if (\n typeof accessToken !== 'string' ||\n typeof botUserId !== 'string' ||\n typeof appId !== 'string' ||\n typeof team.id !== 'string' ||\n typeof team.name !== 'string' ||\n typeof scope !== 'string'\n ) {\n throw new SlackIntegrationProviderError(\n 'malformed-provider-response',\n 'Slack authorization response did not include the required installation details',\n );\n }\n return {\n accessToken,\n botUserId,\n appId,\n teamId: team.id,\n teamName: team.name,\n scopes: scope\n .split(',')\n .map((value) => value.trim())\n .filter(Boolean),\n };\n}\n\nfunction slackOAuthErrorReason(error: unknown) {\n if (error === 'service_unavailable' || error === 'internal_error') return 'provider-unavailable';\n if (error === 'ratelimited') return 'rate-limited';\n return 'access-denied';\n}\n\nasync function mapSlackError<T>(operation: string, request: () => Promise<T>): Promise<T> {\n try {\n return await request();\n } catch (error) {\n if (error instanceof SlackIntegrationProviderError) throw error;\n if (error instanceof HTTPError) {\n const {status, statusText, headers} = error.response;\n logger().warn({operation, status, statusText}, 'Slack API request rejected');\n if (status === 429) {\n throw new SlackIntegrationProviderError(\n 'rate-limited',\n 'Slack request was rate limited',\n retryAfterSeconds(headers),\n );\n }\n if (status >= 500) {\n throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');\n }\n throw new SlackIntegrationProviderError('access-denied', 'Slack request was rejected');\n }\n if (error instanceof TimeoutError) {\n logger().warn({operation}, 'Slack API request timed out');\n throw new SlackIntegrationProviderError('timeout', 'Slack request timed out');\n }\n logger().warn(\n {operation, errName: error instanceof Error ? error.name : typeof error},\n 'Slack API request failed',\n );\n throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');\n }\n}\n\nasync function mapSlackWebApiError<T>(method: string, request: () => Promise<T>): Promise<T> {\n try {\n return await request();\n } catch (error) {\n if (error instanceof SlackIntegrationProviderError) throw error;\n if (error instanceof HTTPError) {\n const {status, statusText, headers} = error.response;\n logger().warn(\n {operation: 'call-method', method, status, statusText},\n 'Slack API request rejected',\n );\n if (status === 429) {\n throw new SlackIntegrationProviderError(\n 'rate-limited',\n 'Slack request was rate limited',\n retryAfterSeconds(headers),\n );\n }\n if (status === 413) {\n throw new SlackIntegrationProviderError(\n 'content-too-large',\n 'Slack request content was too large',\n );\n }\n if (status >= 500) {\n throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');\n }\n throw new SlackIntegrationProviderError(\n 'malformed-provider-response',\n 'Slack request was rejected',\n );\n }\n if (error instanceof TimeoutError) {\n logger().warn({operation: 'call-method', method}, 'Slack API request timed out');\n throw new SlackIntegrationProviderError('timeout', 'Slack request timed out');\n }\n logger().warn(\n {\n operation: 'call-method',\n method,\n errName: error instanceof Error ? error.name : typeof error,\n },\n 'Slack API request failed',\n );\n throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');\n }\n}\n\nfunction slackMethodArguments(input: Record<string, unknown>): URLSearchParams {\n const body = new URLSearchParams();\n for (const [key, value] of Object.entries(input)) {\n if (value === null || value === undefined) continue;\n const serialized =\n typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n ? String(value)\n : JSON.stringify(value);\n if (serialized !== undefined) body.set(key, serialized);\n }\n return body;\n}\n\nfunction slackApiUrl(path: string): string {\n return new URL(path, `${config.SLACK_API_BASE_URL}/`).toString();\n}\n\nfunction retryAfterSeconds(headers: Headers): number | undefined {\n const retryAfter = headers.get('retry-after');\n if (!retryAfter) return undefined;\n const parsed = Number.parseInt(retryAfter, 10);\n return Number.isNaN(parsed) ? undefined : parsed;\n}\n"],"names":["logger","ky","HTTPError","TimeoutError","config","SlackEnterpriseInstallUnsupportedError","SlackIntegrationProviderError","SlackTokenRotationUnsupportedError","SLACK_API_TIMEOUT_MS","createSlackApiClient","exchangeAuthorizationCode","input","body","mapSlackError","post","slackApiUrl","URLSearchParams","client_id","SLACK_OAUTH_CLIENT_ID","client_secret","SLACK_OAUTH_CLIENT_SECRET","code","redirect_uri","SLACK_OAUTH_REDIRECT_URL","timeout","json","parseOAuthAccess","revokeToken","headers","authorization","token","callMethod","mapSlackWebApiError","method","slackMethodArguments","arguments","ok","slackOAuthErrorReason","error","is_enterprise_install","team","expires_in","undefined","refresh_token","access_token","accessToken","bot_user_id","botUserId","app_id","appId","scope","id","name","teamId","teamName","scopes","split","map","value","trim","filter","Boolean","operation","request","status","statusText","response","warn","retryAfterSeconds","errName","Error","key","Object","entries","serialized","String","JSON","stringify","set","path","URL","SLACK_API_BASE_URL","toString","retryAfter","get","parsed","Number","parseInt","isNaN"],"mappings":"AAAA,SAAQA,MAAM,QAAO,8BAA8B;AACnD,OAAOC,MAAKC,SAAS,EAAEC,YAAY,QAAO,KAAK;AAC/C,SAAQC,MAAM,QAAO,aAAa;AAClC,SACEC,sCAAsC,EACtCC,6BAA6B,EAC7BC,kCAAkC,QAC7B,kBAAkB;AAEzB,MAAMC,uBAAuB;AAwC7B,OAAO,SAASC;IACd,OAAO;QACL,MAAMC,2BAA0BC,KAAK;YACnC,MAAMC,OAAO,MAAMC,cAAc,+BAA+B,IAC9DZ,GACGa,IAAI,CAACC,YAAY,oBAAoB;oBACpCH,MAAM,IAAII,gBAAgB;wBACxBC,WAAWb,OAAOc,qBAAqB;wBACvCC,eAAef,OAAOgB,yBAAyB;wBAC/CC,MAAMV,MAAMU,IAAI;wBAChBC,cAAclB,OAAOmB,wBAAwB;oBAC/C;oBACAC,SAAShB;gBACX,GACCiB,IAAI;YAET,OAAOC,iBAAiBd;QAC1B;QAEA,MAAMe,aAAYhB,KAAK;YACrB,MAAME,cAAc,gBAAgB;gBAClC,MAAMZ,GAAGa,IAAI,CAACC,YAAY,gBAAgB;oBACxCa,SAAS;wBAACC,eAAe,CAAC,OAAO,EAAElB,MAAMmB,KAAK,EAAE;oBAAA;oBAChDN,SAAShB;gBACX;YACF;QACF;QAEA,MAAMuB,YAAWpB,KAAK;YACpB,OAAO,MAAMqB,oBAAoBrB,MAAMsB,MAAM,EAAE,IAC7ChC,GACGa,IAAI,CAACC,YAAYJ,MAAMsB,MAAM,GAAG;oBAC/BL,SAAS;wBAACC,eAAe,CAAC,OAAO,EAAElB,MAAMmB,KAAK,EAAE;oBAAA;oBAChDlB,MAAMsB,qBAAqBvB,MAAMwB,SAAS;oBAC1CX,SAAShB;gBACX,GACCiB,IAAI;QAEX;IACF;AACF;AAEA,SAASC,iBAAiBd,IAA8B;IACtD,IAAIA,KAAKwB,EAAE,KAAK,MAAM;QACpB,MAAM,IAAI9B,8BACR+B,sBAAsBzB,KAAK0B,KAAK,GAChC;IAEJ;IACA,IAAI1B,KAAK2B,qBAAqB,KAAK,QAAQ3B,KAAK4B,IAAI,IAAI,MAAM;QAC5D,MAAM,IAAInC;IACZ;IACA,mGAAmG;IACnG,wFAAwF;IACxF,gFAAgF;IAChF,IAAIO,KAAK6B,UAAU,KAAKC,aAAa9B,KAAK+B,aAAa,KAAKD,WAAW;QACrE,MAAM,IAAInC;IACZ;IACA,MAAM,EAACqC,cAAcC,WAAW,EAAEC,aAAaC,SAAS,EAAEC,QAAQC,KAAK,EAAET,IAAI,EAAEU,KAAK,EAAC,GAAGtC;IACxF,IACE,OAAOiC,gBAAgB,YACvB,OAAOE,cAAc,YACrB,OAAOE,UAAU,YACjB,OAAOT,KAAKW,EAAE,KAAK,YACnB,OAAOX,KAAKY,IAAI,KAAK,YACrB,OAAOF,UAAU,UACjB;QACA,MAAM,IAAI5C,8BACR,+BACA;IAEJ;IACA,OAAO;QACLuC;QACAE;QACAE;QACAI,QAAQb,KAAKW,EAAE;QACfG,UAAUd,KAAKY,IAAI;QACnBG,QAAQL,MACLM,KAAK,CAAC,KACNC,GAAG,CAAC,CAACC,QAAUA,MAAMC,IAAI,IACzBC,MAAM,CAACC;IACZ;AACF;AAEA,SAASxB,sBAAsBC,KAAc;IAC3C,IAAIA,UAAU,yBAAyBA,UAAU,kBAAkB,OAAO;IAC1E,IAAIA,UAAU,eAAe,OAAO;IACpC,OAAO;AACT;AAEA,eAAezB,cAAiBiD,SAAiB,EAAEC,OAAyB;IAC1E,IAAI;QACF,OAAO,MAAMA;IACf,EAAE,OAAOzB,OAAO;QACd,IAAIA,iBAAiBhC,+BAA+B,MAAMgC;QAC1D,IAAIA,iBAAiBpC,WAAW;YAC9B,MAAM,EAAC8D,MAAM,EAAEC,UAAU,EAAErC,OAAO,EAAC,GAAGU,MAAM4B,QAAQ;YACpDlE,SAASmE,IAAI,CAAC;gBAACL;gBAAWE;gBAAQC;YAAU,GAAG;YAC/C,IAAID,WAAW,KAAK;gBAClB,MAAM,IAAI1D,8BACR,gBACA,kCACA8D,kBAAkBxC;YAEtB;YACA,IAAIoC,UAAU,KAAK;gBACjB,MAAM,IAAI1D,8BAA8B,wBAAwB;YAClE;YACA,MAAM,IAAIA,8BAA8B,iBAAiB;QAC3D;QACA,IAAIgC,iBAAiBnC,cAAc;YACjCH,SAASmE,IAAI,CAAC;gBAACL;YAAS,GAAG;YAC3B,MAAM,IAAIxD,8BAA8B,WAAW;QACrD;QACAN,SAASmE,IAAI,CACX;YAACL;YAAWO,SAAS/B,iBAAiBgC,QAAQhC,MAAMc,IAAI,GAAG,OAAOd;QAAK,GACvE;QAEF,MAAM,IAAIhC,8BAA8B,wBAAwB;IAClE;AACF;AAEA,eAAe0B,oBAAuBC,MAAc,EAAE8B,OAAyB;IAC7E,IAAI;QACF,OAAO,MAAMA;IACf,EAAE,OAAOzB,OAAO;QACd,IAAIA,iBAAiBhC,+BAA+B,MAAMgC;QAC1D,IAAIA,iBAAiBpC,WAAW;YAC9B,MAAM,EAAC8D,MAAM,EAAEC,UAAU,EAAErC,OAAO,EAAC,GAAGU,MAAM4B,QAAQ;YACpDlE,SAASmE,IAAI,CACX;gBAACL,WAAW;gBAAe7B;gBAAQ+B;gBAAQC;YAAU,GACrD;YAEF,IAAID,WAAW,KAAK;gBAClB,MAAM,IAAI1D,8BACR,gBACA,kCACA8D,kBAAkBxC;YAEtB;YACA,IAAIoC,WAAW,KAAK;gBAClB,MAAM,IAAI1D,8BACR,qBACA;YAEJ;YACA,IAAI0D,UAAU,KAAK;gBACjB,MAAM,IAAI1D,8BAA8B,wBAAwB;YAClE;YACA,MAAM,IAAIA,8BACR,+BACA;QAEJ;QACA,IAAIgC,iBAAiBnC,cAAc;YACjCH,SAASmE,IAAI,CAAC;gBAACL,WAAW;gBAAe7B;YAAM,GAAG;YAClD,MAAM,IAAI3B,8BAA8B,WAAW;QACrD;QACAN,SAASmE,IAAI,CACX;YACEL,WAAW;YACX7B;YACAoC,SAAS/B,iBAAiBgC,QAAQhC,MAAMc,IAAI,GAAG,OAAOd;QACxD,GACA;QAEF,MAAM,IAAIhC,8BAA8B,wBAAwB;IAClE;AACF;AAEA,SAAS4B,qBAAqBvB,KAA8B;IAC1D,MAAMC,OAAO,IAAII;IACjB,KAAK,MAAM,CAACuD,KAAKb,MAAM,IAAIc,OAAOC,OAAO,CAAC9D,OAAQ;QAChD,IAAI+C,UAAU,QAAQA,UAAUhB,WAAW;QAC3C,MAAMgC,aACJ,OAAOhB,UAAU,YAAY,OAAOA,UAAU,YAAY,OAAOA,UAAU,YACvEiB,OAAOjB,SACPkB,KAAKC,SAAS,CAACnB;QACrB,IAAIgB,eAAehC,WAAW9B,KAAKkE,GAAG,CAACP,KAAKG;IAC9C;IACA,OAAO9D;AACT;AAEA,SAASG,YAAYgE,IAAY;IAC/B,OAAO,IAAIC,IAAID,MAAM,GAAG3E,OAAO6E,kBAAkB,CAAC,CAAC,CAAC,EAAEC,QAAQ;AAChE;AAEA,SAASd,kBAAkBxC,OAAgB;IACzC,MAAMuD,aAAavD,QAAQwD,GAAG,CAAC;IAC/B,IAAI,CAACD,YAAY,OAAOzC;IACxB,MAAM2C,SAASC,OAAOC,QAAQ,CAACJ,YAAY;IAC3C,OAAOG,OAAOE,KAAK,CAACH,UAAU3C,YAAY2C;AAC5C"}
|
|
1
|
+
{"version":3,"sources":["../../src/api/client.ts"],"sourcesContent":["import {logger} from '@shipfox/node-opentelemetry';\nimport ky, {HTTPError, TimeoutError} from 'ky';\nimport {config} from '#config.js';\nimport {\n SlackEnterpriseInstallUnsupportedError,\n SlackIntegrationProviderError,\n SlackTokenRotationUnsupportedError,\n} from '#core/errors.js';\n\nconst SLACK_API_TIMEOUT_MS = 10_000;\nconst trailingSlashesPattern = /\\/+$/;\n\nexport interface SlackAuthorization {\n accessToken: string;\n botUserId: string;\n appId: string;\n teamId: string;\n teamName: string;\n scopes: string[];\n}\n\nexport interface SlackWebApiResponse {\n ok: boolean;\n error?: string | undefined;\n [key: string]: unknown;\n}\n\nexport interface SlackApiClient {\n exchangeAuthorizationCode(input: {code: string}): Promise<SlackAuthorization>;\n revokeToken(input: {token: string}): Promise<void>;\n callMethod(input: {\n method: string;\n token: string;\n arguments: Record<string, unknown>;\n }): Promise<SlackWebApiResponse>;\n}\n\ninterface SlackOAuthAccessResponse {\n ok?: unknown;\n error?: unknown;\n access_token?: unknown;\n bot_user_id?: unknown;\n app_id?: unknown;\n team?: {id?: unknown; name?: unknown} | null | undefined;\n scope?: unknown;\n is_enterprise_install?: unknown;\n expires_in?: unknown;\n refresh_token?: unknown;\n}\n\nexport function createSlackApiClient(): SlackApiClient {\n return {\n async exchangeAuthorizationCode(input) {\n const body = await mapSlackError('exchange-authorization-code', () =>\n ky\n .post(slackApiUrl('oauth.v2.access'), {\n body: new URLSearchParams({\n client_id: config.SLACK_OAUTH_CLIENT_ID,\n client_secret: config.SLACK_OAUTH_CLIENT_SECRET,\n code: input.code,\n redirect_uri: config.SLACK_OAUTH_REDIRECT_URL,\n }),\n timeout: SLACK_API_TIMEOUT_MS,\n })\n .json<SlackOAuthAccessResponse>(),\n );\n return parseOAuthAccess(body);\n },\n\n async revokeToken(input) {\n await mapSlackError('revoke-token', async () => {\n await ky.post(slackApiUrl('auth.revoke'), {\n headers: {authorization: `Bearer ${input.token}`},\n timeout: SLACK_API_TIMEOUT_MS,\n });\n });\n },\n\n async callMethod(input) {\n return await mapSlackWebApiError(input.method, () =>\n ky\n .post(slackApiUrl(input.method), {\n headers: {authorization: `Bearer ${input.token}`},\n body: slackMethodArguments(input.arguments),\n timeout: SLACK_API_TIMEOUT_MS,\n })\n .json<SlackWebApiResponse>(),\n );\n },\n };\n}\n\nfunction parseOAuthAccess(body: SlackOAuthAccessResponse): SlackAuthorization {\n if (body.ok !== true) {\n throw new SlackIntegrationProviderError(\n slackOAuthErrorReason(body.error),\n 'Slack authorization request failed',\n );\n }\n if (body.is_enterprise_install === true || body.team == null) {\n throw new SlackEnterpriseInstallUnsupportedError();\n }\n // The bot-token store has no refresh path, so a rotating (expiring) token must never be persisted.\n // Slack omits both fields when rotation is off, so presence of either signals rotation.\n // The rejected token self-expires, matching the Enterprise Grid rejection path.\n if (body.expires_in !== undefined || body.refresh_token !== undefined) {\n throw new SlackTokenRotationUnsupportedError();\n }\n const {access_token: accessToken, bot_user_id: botUserId, app_id: appId, team, scope} = body;\n if (\n typeof accessToken !== 'string' ||\n typeof botUserId !== 'string' ||\n typeof appId !== 'string' ||\n typeof team.id !== 'string' ||\n typeof team.name !== 'string' ||\n typeof scope !== 'string'\n ) {\n throw new SlackIntegrationProviderError(\n 'malformed-provider-response',\n 'Slack authorization response did not include the required installation details',\n );\n }\n return {\n accessToken,\n botUserId,\n appId,\n teamId: team.id,\n teamName: team.name,\n scopes: scope\n .split(',')\n .map((value) => value.trim())\n .filter(Boolean),\n };\n}\n\nfunction slackOAuthErrorReason(error: unknown) {\n if (error === 'service_unavailable' || error === 'internal_error') return 'provider-unavailable';\n if (error === 'ratelimited') return 'rate-limited';\n return 'access-denied';\n}\n\nasync function mapSlackError<T>(operation: string, request: () => Promise<T>): Promise<T> {\n try {\n return await request();\n } catch (error) {\n if (error instanceof SlackIntegrationProviderError) throw error;\n if (error instanceof HTTPError) {\n const {status, statusText, headers} = error.response;\n logger().warn({operation, status, statusText}, 'Slack API request rejected');\n if (status === 429) {\n throw new SlackIntegrationProviderError(\n 'rate-limited',\n 'Slack request was rate limited',\n retryAfterSeconds(headers),\n );\n }\n if (status >= 500) {\n throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');\n }\n throw new SlackIntegrationProviderError('access-denied', 'Slack request was rejected');\n }\n if (error instanceof TimeoutError) {\n logger().warn({operation}, 'Slack API request timed out');\n throw new SlackIntegrationProviderError('timeout', 'Slack request timed out');\n }\n logger().warn(\n {operation, errName: error instanceof Error ? error.name : typeof error},\n 'Slack API request failed',\n );\n throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');\n }\n}\n\nasync function mapSlackWebApiError<T>(method: string, request: () => Promise<T>): Promise<T> {\n try {\n return await request();\n } catch (error) {\n if (error instanceof SlackIntegrationProviderError) throw error;\n if (error instanceof HTTPError) {\n const {status, statusText, headers} = error.response;\n logger().warn(\n {operation: 'call-method', method, status, statusText},\n 'Slack API request rejected',\n );\n if (status === 429) {\n throw new SlackIntegrationProviderError(\n 'rate-limited',\n 'Slack request was rate limited',\n retryAfterSeconds(headers),\n );\n }\n if (status === 413) {\n throw new SlackIntegrationProviderError(\n 'content-too-large',\n 'Slack request content was too large',\n );\n }\n if (status >= 500) {\n throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');\n }\n throw new SlackIntegrationProviderError(\n 'malformed-provider-response',\n 'Slack request was rejected',\n );\n }\n if (error instanceof TimeoutError) {\n logger().warn({operation: 'call-method', method}, 'Slack API request timed out');\n throw new SlackIntegrationProviderError('timeout', 'Slack request timed out');\n }\n logger().warn(\n {\n operation: 'call-method',\n method,\n errName: error instanceof Error ? error.name : typeof error,\n },\n 'Slack API request failed',\n );\n throw new SlackIntegrationProviderError('provider-unavailable', 'Slack request failed');\n }\n}\n\nfunction slackMethodArguments(input: Record<string, unknown>): URLSearchParams {\n const body = new URLSearchParams();\n for (const [key, value] of Object.entries(input)) {\n if (value === null || value === undefined) continue;\n const serialized =\n typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'\n ? String(value)\n : JSON.stringify(value);\n if (serialized !== undefined) body.set(key, serialized);\n }\n return body;\n}\n\nfunction slackApiUrl(path: string): string {\n const baseUrl = config.SLACK_API_BASE_URL.replace(trailingSlashesPattern, '');\n return new URL(path, `${baseUrl}/`).toString();\n}\n\nfunction retryAfterSeconds(headers: Headers): number | undefined {\n const retryAfter = headers.get('retry-after');\n if (!retryAfter) return undefined;\n const parsed = Number.parseInt(retryAfter, 10);\n return Number.isNaN(parsed) ? undefined : parsed;\n}\n"],"names":["logger","ky","HTTPError","TimeoutError","config","SlackEnterpriseInstallUnsupportedError","SlackIntegrationProviderError","SlackTokenRotationUnsupportedError","SLACK_API_TIMEOUT_MS","trailingSlashesPattern","createSlackApiClient","exchangeAuthorizationCode","input","body","mapSlackError","post","slackApiUrl","URLSearchParams","client_id","SLACK_OAUTH_CLIENT_ID","client_secret","SLACK_OAUTH_CLIENT_SECRET","code","redirect_uri","SLACK_OAUTH_REDIRECT_URL","timeout","json","parseOAuthAccess","revokeToken","headers","authorization","token","callMethod","mapSlackWebApiError","method","slackMethodArguments","arguments","ok","slackOAuthErrorReason","error","is_enterprise_install","team","expires_in","undefined","refresh_token","access_token","accessToken","bot_user_id","botUserId","app_id","appId","scope","id","name","teamId","teamName","scopes","split","map","value","trim","filter","Boolean","operation","request","status","statusText","response","warn","retryAfterSeconds","errName","Error","key","Object","entries","serialized","String","JSON","stringify","set","path","baseUrl","SLACK_API_BASE_URL","replace","URL","toString","retryAfter","get","parsed","Number","parseInt","isNaN"],"mappings":"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"}
|
package/dist/core/signature.d.ts
CHANGED
|
@@ -2,9 +2,10 @@ export interface VerifySlackSignatureParams {
|
|
|
2
2
|
signingSecret: string;
|
|
3
3
|
signature: string | undefined;
|
|
4
4
|
timestamp: string | undefined;
|
|
5
|
-
rawBody:
|
|
5
|
+
rawBody: Uint8Array;
|
|
6
6
|
now?: number | undefined;
|
|
7
7
|
replayWindowMs?: number | undefined;
|
|
8
8
|
}
|
|
9
|
+
export declare function isSlackTimestampWithinReplayWindow(timestamp: string, now?: number, replayWindowMs?: number): boolean;
|
|
9
10
|
export declare function verifySlackSignature({ signingSecret, signature, timestamp, rawBody, now, replayWindowMs, }: VerifySlackSignatureParams): boolean;
|
|
10
11
|
//# sourceMappingURL=signature.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"signature.d.ts","sourceRoot":"","sources":["../../src/core/signature.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,0BAA0B;IACzC,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"signature.d.ts","sourceRoot":"","sources":["../../src/core/signature.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,0BAA0B;IACzC,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,OAAO,EAAE,UAAU,CAAC;IACpB,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACrC;AAED,wBAAgB,kCAAkC,CAChD,SAAS,EAAE,MAAM,EACjB,GAAG,SAAa,EAChB,cAAc,SAAU,GACvB,OAAO,CAQT;AAED,wBAAgB,oBAAoB,CAAC,EACnC,aAAa,EACb,SAAS,EACT,SAAS,EACT,OAAO,EACP,GAAgB,EAChB,cAAwB,GACzB,EAAE,0BAA0B,GAAG,OAAO,CAgBtC"}
|
package/dist/core/signature.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
const unixTimestampPattern = /^\d+$/;
|
|
3
|
-
export function
|
|
4
|
-
if (!
|
|
5
|
-
return false;
|
|
6
|
-
}
|
|
3
|
+
export function isSlackTimestampWithinReplayWindow(timestamp, now = Date.now(), replayWindowMs = 300_000) {
|
|
4
|
+
if (!unixTimestampPattern.test(timestamp)) return false;
|
|
7
5
|
const timestampSeconds = Number(timestamp);
|
|
8
6
|
if (!Number.isSafeInteger(timestampSeconds)) return false;
|
|
9
7
|
const timestampMilliseconds = timestampSeconds * 1000;
|
|
10
8
|
if (!Number.isSafeInteger(timestampMilliseconds)) return false;
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
return Math.abs(now - timestampMilliseconds) <= replayWindowMs;
|
|
10
|
+
}
|
|
11
|
+
export function verifySlackSignature({ signingSecret, signature, timestamp, rawBody, now = Date.now(), replayWindowMs = 300_000 }) {
|
|
12
|
+
if (!signingSecret || !signature || !timestamp) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
if (!isSlackTimestampWithinReplayWindow(timestamp, now, replayWindowMs)) return false;
|
|
16
|
+
const expected = `v0=${createHmac('sha256', signingSecret).update(`v0:${timestamp}:`).update(rawBody).digest('hex')}`;
|
|
13
17
|
const expectedBuffer = Buffer.from(expected, 'utf8');
|
|
14
18
|
const receivedBuffer = Buffer.from(signature, 'utf8');
|
|
15
19
|
return expectedBuffer.length === receivedBuffer.length && timingSafeEqual(expectedBuffer, receivedBuffer);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/core/signature.ts"],"sourcesContent":["import {createHmac, timingSafeEqual} from 'node:crypto';\n\nconst unixTimestampPattern = /^\\d+$/;\n\nexport interface VerifySlackSignatureParams {\n signingSecret: string;\n signature: string | undefined;\n timestamp: string | undefined;\n rawBody:
|
|
1
|
+
{"version":3,"sources":["../../src/core/signature.ts"],"sourcesContent":["import {createHmac, timingSafeEqual} from 'node:crypto';\n\nconst unixTimestampPattern = /^\\d+$/;\n\nexport interface VerifySlackSignatureParams {\n signingSecret: string;\n signature: string | undefined;\n timestamp: string | undefined;\n rawBody: Uint8Array;\n now?: number | undefined;\n replayWindowMs?: number | undefined;\n}\n\nexport function isSlackTimestampWithinReplayWindow(\n timestamp: string,\n now = Date.now(),\n replayWindowMs = 300_000,\n): boolean {\n if (!unixTimestampPattern.test(timestamp)) return false;\n\n const timestampSeconds = Number(timestamp);\n if (!Number.isSafeInteger(timestampSeconds)) return false;\n const timestampMilliseconds = timestampSeconds * 1000;\n if (!Number.isSafeInteger(timestampMilliseconds)) return false;\n return Math.abs(now - timestampMilliseconds) <= replayWindowMs;\n}\n\nexport function verifySlackSignature({\n signingSecret,\n signature,\n timestamp,\n rawBody,\n now = Date.now(),\n replayWindowMs = 300_000,\n}: VerifySlackSignatureParams): boolean {\n if (!signingSecret || !signature || !timestamp) {\n return false;\n }\n if (!isSlackTimestampWithinReplayWindow(timestamp, now, replayWindowMs)) return false;\n\n const expected = `v0=${createHmac('sha256', signingSecret)\n .update(`v0:${timestamp}:`)\n .update(rawBody)\n .digest('hex')}`;\n const expectedBuffer = Buffer.from(expected, 'utf8');\n const receivedBuffer = Buffer.from(signature, 'utf8');\n return (\n expectedBuffer.length === receivedBuffer.length &&\n timingSafeEqual(expectedBuffer, receivedBuffer)\n );\n}\n"],"names":["createHmac","timingSafeEqual","unixTimestampPattern","isSlackTimestampWithinReplayWindow","timestamp","now","Date","replayWindowMs","test","timestampSeconds","Number","isSafeInteger","timestampMilliseconds","Math","abs","verifySlackSignature","signingSecret","signature","rawBody","expected","update","digest","expectedBuffer","Buffer","from","receivedBuffer","length"],"mappings":"AAAA,SAAQA,UAAU,EAAEC,eAAe,QAAO,cAAc;AAExD,MAAMC,uBAAuB;AAW7B,OAAO,SAASC,mCACdC,SAAiB,EACjBC,MAAMC,KAAKD,GAAG,EAAE,EAChBE,iBAAiB,OAAO;IAExB,IAAI,CAACL,qBAAqBM,IAAI,CAACJ,YAAY,OAAO;IAElD,MAAMK,mBAAmBC,OAAON;IAChC,IAAI,CAACM,OAAOC,aAAa,CAACF,mBAAmB,OAAO;IACpD,MAAMG,wBAAwBH,mBAAmB;IACjD,IAAI,CAACC,OAAOC,aAAa,CAACC,wBAAwB,OAAO;IACzD,OAAOC,KAAKC,GAAG,CAACT,MAAMO,0BAA0BL;AAClD;AAEA,OAAO,SAASQ,qBAAqB,EACnCC,aAAa,EACbC,SAAS,EACTb,SAAS,EACTc,OAAO,EACPb,MAAMC,KAAKD,GAAG,EAAE,EAChBE,iBAAiB,OAAO,EACG;IAC3B,IAAI,CAACS,iBAAiB,CAACC,aAAa,CAACb,WAAW;QAC9C,OAAO;IACT;IACA,IAAI,CAACD,mCAAmCC,WAAWC,KAAKE,iBAAiB,OAAO;IAEhF,MAAMY,WAAW,CAAC,GAAG,EAAEnB,WAAW,UAAUgB,eACzCI,MAAM,CAAC,CAAC,GAAG,EAAEhB,UAAU,CAAC,CAAC,EACzBgB,MAAM,CAACF,SACPG,MAAM,CAAC,QAAQ;IAClB,MAAMC,iBAAiBC,OAAOC,IAAI,CAACL,UAAU;IAC7C,MAAMM,iBAAiBF,OAAOC,IAAI,CAACP,WAAW;IAC9C,OACEK,eAAeI,MAAM,KAAKD,eAAeC,MAAM,IAC/CzB,gBAAgBqB,gBAAgBG;AAEpC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ClaimWebhookDeliveryFn, GetIntegrationConnectionByIdFn, PublishIntegrationEventReceivedFn, RecordDeliveryOnlyFn, StoredWebhookRequest, WebhookProcessingResult } from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
3
|
+
export interface CreateSlackWebhookProcessorOptions {
|
|
4
|
+
coreDb: () => NodePgDatabase<Record<string, unknown>>;
|
|
5
|
+
claimWebhookDelivery: ClaimWebhookDeliveryFn;
|
|
6
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
7
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
8
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
9
|
+
}
|
|
10
|
+
export type SlackWebhookProcessingResult = WebhookProcessingResult;
|
|
11
|
+
export interface SlackWebhookProcessor {
|
|
12
|
+
process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;
|
|
13
|
+
}
|
|
14
|
+
export declare function createSlackWebhookProcessor(options: CreateSlackWebhookProcessorOptions): SlackWebhookProcessor;
|
|
15
|
+
//# sourceMappingURL=webhook-processor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-processor.d.ts","sourceRoot":"","sources":["../../src/core/webhook-processor.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,sBAAsB,EACtB,8BAA8B,EAC9B,iCAAiC,EACjC,oBAAoB,EACpB,oBAAoB,EACpB,uBAAuB,EACxB,MAAM,mCAAmC,CAAC;AAO3C,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAQ9D,MAAM,WAAW,kCAAkC;IACjD,MAAM,EAAE,MAAM,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,oBAAoB,EAAE,sBAAsB,CAAC;IAC7C,+BAA+B,EAAE,iCAAiC,CAAC;IACnE,kBAAkB,EAAE,oBAAoB,CAAC;IACzC,4BAA4B,EAAE,8BAA8B,CAAC;CAC9D;AAED,MAAM,MAAM,4BAA4B,GAAG,uBAAuB,CAAC;AAEnE,MAAM,WAAW,qBAAqB;IACpC,OAAO,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;CAC1E;AAED,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,kCAAkC,GAC1C,qBAAqB,CAEvB"}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { decodeWebhookBody } from '@shipfox/api-integration-core-dto';
|
|
3
|
+
import { slackEventsRequestSchema, slackSlashCommandSchema } from '@shipfox/api-integration-slack-dto';
|
|
4
|
+
import { logger } from '@shipfox/node-opentelemetry';
|
|
5
|
+
import { config } from '#config.js';
|
|
6
|
+
import { isSlackTimestampWithinReplayWindow, verifySlackSignature } from '#core/signature.js';
|
|
7
|
+
import { handleSlackCommand, handleSlackEvent } from '#core/webhook.js';
|
|
8
|
+
const SIGNATURE_HEADER = 'x-slack-signature';
|
|
9
|
+
const TIMESTAMP_HEADER = 'x-slack-request-timestamp';
|
|
10
|
+
export function createSlackWebhookProcessor(options) {
|
|
11
|
+
return {
|
|
12
|
+
process: (request)=>processSlackWebhookRequest(options, request)
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function processSlackWebhookRequest(options, request) {
|
|
16
|
+
if (request.route_id !== 'slack.event' && request.route_id !== 'slack.command') {
|
|
17
|
+
throw new Error(`Slack processor cannot process ${request.route_id} requests`);
|
|
18
|
+
}
|
|
19
|
+
const signature = request.headers[SIGNATURE_HEADER];
|
|
20
|
+
const timestamp = request.headers[TIMESTAMP_HEADER];
|
|
21
|
+
if (!signature || !timestamp) {
|
|
22
|
+
return Promise.resolve({
|
|
23
|
+
outcome: 'discarded',
|
|
24
|
+
reason: 'missing_required_input'
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const rawBody = decodeWebhookBody(request.body);
|
|
28
|
+
const receiptTime = new Date(request.received_at).getTime();
|
|
29
|
+
if (!isSlackTimestampWithinReplayWindow(timestamp, receiptTime)) {
|
|
30
|
+
return Promise.resolve({
|
|
31
|
+
outcome: 'discarded',
|
|
32
|
+
reason: 'stale_at_receipt'
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (!verifySlackSignature({
|
|
36
|
+
signingSecret: config.SLACK_SIGNING_SECRET,
|
|
37
|
+
signature,
|
|
38
|
+
timestamp,
|
|
39
|
+
rawBody,
|
|
40
|
+
now: receiptTime
|
|
41
|
+
})) {
|
|
42
|
+
return Promise.resolve({
|
|
43
|
+
outcome: 'discarded',
|
|
44
|
+
reason: 'invalid_signature'
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return request.route_id === 'slack.event' ? processSlackEvent(options, rawBody) : processSlackCommand(options, rawBody);
|
|
48
|
+
}
|
|
49
|
+
async function processSlackEvent(options, rawBody) {
|
|
50
|
+
let rawPayload;
|
|
51
|
+
try {
|
|
52
|
+
rawPayload = JSON.parse(Buffer.from(rawBody).toString('utf8'));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
logger().warn({
|
|
55
|
+
err: error
|
|
56
|
+
}, 'slack events payload JSON parse failed');
|
|
57
|
+
return {
|
|
58
|
+
outcome: 'discarded',
|
|
59
|
+
reason: 'malformed_payload'
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const event = slackEventsRequestSchema.safeParse(rawPayload);
|
|
63
|
+
if (!event.success) {
|
|
64
|
+
logger().warn({
|
|
65
|
+
issues: event.error.issues
|
|
66
|
+
}, 'slack events envelope failed schema validation');
|
|
67
|
+
return {
|
|
68
|
+
outcome: 'discarded',
|
|
69
|
+
reason: 'unsupported_event'
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const envelope = event.data;
|
|
73
|
+
if (envelope.type === 'url_verification') {
|
|
74
|
+
return {
|
|
75
|
+
outcome: 'processed',
|
|
76
|
+
challenge: envelope.challenge
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const result = await options.coreDb().transaction(async (tx)=>handleSlackEvent({
|
|
80
|
+
tx,
|
|
81
|
+
deliveryId: envelope.event_id,
|
|
82
|
+
envelope,
|
|
83
|
+
claimWebhookDelivery: options.claimWebhookDelivery,
|
|
84
|
+
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
85
|
+
recordDeliveryOnly: options.recordDeliveryOnly,
|
|
86
|
+
getIntegrationConnectionById: options.getIntegrationConnectionById
|
|
87
|
+
}));
|
|
88
|
+
return toProcessingResult(result.outcome, envelope.event_id);
|
|
89
|
+
}
|
|
90
|
+
async function processSlackCommand(options, rawBody) {
|
|
91
|
+
const command = slackSlashCommandSchema.safeParse(Object.fromEntries(new URLSearchParams(Buffer.from(rawBody).toString('utf8'))));
|
|
92
|
+
if (!command.success) {
|
|
93
|
+
logger().warn({
|
|
94
|
+
issues: command.error.issues
|
|
95
|
+
}, 'slack command failed schema validation');
|
|
96
|
+
return {
|
|
97
|
+
outcome: 'discarded',
|
|
98
|
+
reason: 'unsupported_event'
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const result = await options.coreDb().transaction(async (tx)=>handleSlackCommand({
|
|
102
|
+
tx,
|
|
103
|
+
deliveryId: command.data.trigger_id,
|
|
104
|
+
command: command.data,
|
|
105
|
+
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
106
|
+
recordDeliveryOnly: options.recordDeliveryOnly,
|
|
107
|
+
getIntegrationConnectionById: options.getIntegrationConnectionById
|
|
108
|
+
}));
|
|
109
|
+
return toProcessingResult(result.outcome, command.data.trigger_id);
|
|
110
|
+
}
|
|
111
|
+
function toProcessingResult(outcome, deliveryId) {
|
|
112
|
+
if (outcome === 'published') return {
|
|
113
|
+
outcome: 'processed',
|
|
114
|
+
deliveryId
|
|
115
|
+
};
|
|
116
|
+
if (outcome === 'duplicate') return {
|
|
117
|
+
outcome: 'duplicate',
|
|
118
|
+
deliveryId
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
outcome: 'discarded',
|
|
122
|
+
reason: outcome === 'unsupported-event' ? 'unsupported_event' : 'connection_unavailable',
|
|
123
|
+
deliveryId
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
//# sourceMappingURL=webhook-processor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/webhook-processor.ts"],"sourcesContent":["import {Buffer} from 'node:buffer';\nimport type {\n ClaimWebhookDeliveryFn,\n GetIntegrationConnectionByIdFn,\n PublishIntegrationEventReceivedFn,\n RecordDeliveryOnlyFn,\n StoredWebhookRequest,\n WebhookProcessingResult,\n} from '@shipfox/api-integration-core-dto';\nimport {decodeWebhookBody} from '@shipfox/api-integration-core-dto';\nimport {\n slackEventsRequestSchema,\n slackSlashCommandSchema,\n} from '@shipfox/api-integration-slack-dto';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport type {NodePgDatabase} from 'drizzle-orm/node-postgres';\nimport {config} from '#config.js';\nimport {isSlackTimestampWithinReplayWindow, verifySlackSignature} from '#core/signature.js';\nimport {handleSlackCommand, handleSlackEvent, type SlackWebhookOutcome} from '#core/webhook.js';\n\nconst SIGNATURE_HEADER = 'x-slack-signature';\nconst TIMESTAMP_HEADER = 'x-slack-request-timestamp';\n\nexport interface CreateSlackWebhookProcessorOptions {\n coreDb: () => NodePgDatabase<Record<string, unknown>>;\n claimWebhookDelivery: ClaimWebhookDeliveryFn;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n getIntegrationConnectionById: GetIntegrationConnectionByIdFn;\n}\n\nexport type SlackWebhookProcessingResult = WebhookProcessingResult;\n\nexport interface SlackWebhookProcessor {\n process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;\n}\n\nexport function createSlackWebhookProcessor(\n options: CreateSlackWebhookProcessorOptions,\n): SlackWebhookProcessor {\n return {process: (request) => processSlackWebhookRequest(options, request)};\n}\n\nfunction processSlackWebhookRequest(\n options: CreateSlackWebhookProcessorOptions,\n request: StoredWebhookRequest,\n): Promise<WebhookProcessingResult> {\n if (request.route_id !== 'slack.event' && request.route_id !== 'slack.command') {\n throw new Error(`Slack processor cannot process ${request.route_id} requests`);\n }\n\n const signature = request.headers[SIGNATURE_HEADER];\n const timestamp = request.headers[TIMESTAMP_HEADER];\n if (!signature || !timestamp) {\n return Promise.resolve({outcome: 'discarded', reason: 'missing_required_input'});\n }\n\n const rawBody = decodeWebhookBody(request.body);\n const receiptTime = new Date(request.received_at).getTime();\n if (!isSlackTimestampWithinReplayWindow(timestamp, receiptTime)) {\n return Promise.resolve({outcome: 'discarded', reason: 'stale_at_receipt'});\n }\n if (\n !verifySlackSignature({\n signingSecret: config.SLACK_SIGNING_SECRET,\n signature,\n timestamp,\n rawBody,\n now: receiptTime,\n })\n ) {\n return Promise.resolve({outcome: 'discarded', reason: 'invalid_signature'});\n }\n\n return request.route_id === 'slack.event'\n ? processSlackEvent(options, rawBody)\n : processSlackCommand(options, rawBody);\n}\n\nasync function processSlackEvent(\n options: CreateSlackWebhookProcessorOptions,\n rawBody: Uint8Array,\n): Promise<WebhookProcessingResult> {\n let rawPayload: unknown;\n try {\n rawPayload = JSON.parse(Buffer.from(rawBody).toString('utf8'));\n } catch (error) {\n logger().warn({err: error}, 'slack events payload JSON parse failed');\n return {outcome: 'discarded', reason: 'malformed_payload'};\n }\n\n const event = slackEventsRequestSchema.safeParse(rawPayload);\n if (!event.success) {\n logger().warn({issues: event.error.issues}, 'slack events envelope failed schema validation');\n return {outcome: 'discarded', reason: 'unsupported_event'};\n }\n const envelope = event.data;\n if (envelope.type === 'url_verification') {\n return {outcome: 'processed', challenge: envelope.challenge};\n }\n\n const result = await options.coreDb().transaction(async (tx) =>\n handleSlackEvent({\n tx,\n deliveryId: envelope.event_id,\n envelope,\n claimWebhookDelivery: options.claimWebhookDelivery,\n publishIntegrationEventReceived: options.publishIntegrationEventReceived,\n recordDeliveryOnly: options.recordDeliveryOnly,\n getIntegrationConnectionById: options.getIntegrationConnectionById,\n }),\n );\n return toProcessingResult(result.outcome, envelope.event_id);\n}\n\nasync function processSlackCommand(\n options: CreateSlackWebhookProcessorOptions,\n rawBody: Uint8Array,\n): Promise<WebhookProcessingResult> {\n const command = slackSlashCommandSchema.safeParse(\n Object.fromEntries(new URLSearchParams(Buffer.from(rawBody).toString('utf8'))),\n );\n if (!command.success) {\n logger().warn({issues: command.error.issues}, 'slack command failed schema validation');\n return {outcome: 'discarded', reason: 'unsupported_event'};\n }\n\n const result = await options.coreDb().transaction(async (tx) =>\n handleSlackCommand({\n tx,\n deliveryId: command.data.trigger_id,\n command: command.data,\n publishIntegrationEventReceived: options.publishIntegrationEventReceived,\n recordDeliveryOnly: options.recordDeliveryOnly,\n getIntegrationConnectionById: options.getIntegrationConnectionById,\n }),\n );\n return toProcessingResult(result.outcome, command.data.trigger_id);\n}\n\nfunction toProcessingResult(\n outcome: SlackWebhookOutcome,\n deliveryId: string,\n): WebhookProcessingResult {\n if (outcome === 'published') return {outcome: 'processed', deliveryId};\n if (outcome === 'duplicate') return {outcome: 'duplicate', deliveryId};\n return {\n outcome: 'discarded',\n reason: outcome === 'unsupported-event' ? 'unsupported_event' : 'connection_unavailable',\n deliveryId,\n };\n}\n"],"names":["Buffer","decodeWebhookBody","slackEventsRequestSchema","slackSlashCommandSchema","logger","config","isSlackTimestampWithinReplayWindow","verifySlackSignature","handleSlackCommand","handleSlackEvent","SIGNATURE_HEADER","TIMESTAMP_HEADER","createSlackWebhookProcessor","options","process","request","processSlackWebhookRequest","route_id","Error","signature","headers","timestamp","Promise","resolve","outcome","reason","rawBody","body","receiptTime","Date","received_at","getTime","signingSecret","SLACK_SIGNING_SECRET","now","processSlackEvent","processSlackCommand","rawPayload","JSON","parse","from","toString","error","warn","err","event","safeParse","success","issues","envelope","data","type","challenge","result","coreDb","transaction","tx","deliveryId","event_id","claimWebhookDelivery","publishIntegrationEventReceived","recordDeliveryOnly","getIntegrationConnectionById","toProcessingResult","command","Object","fromEntries","URLSearchParams","trigger_id"],"mappings":"AAAA,SAAQA,MAAM,QAAO,cAAc;AASnC,SAAQC,iBAAiB,QAAO,oCAAoC;AACpE,SACEC,wBAAwB,EACxBC,uBAAuB,QAClB,qCAAqC;AAC5C,SAAQC,MAAM,QAAO,8BAA8B;AAEnD,SAAQC,MAAM,QAAO,aAAa;AAClC,SAAQC,kCAAkC,EAAEC,oBAAoB,QAAO,qBAAqB;AAC5F,SAAQC,kBAAkB,EAAEC,gBAAgB,QAAiC,mBAAmB;AAEhG,MAAMC,mBAAmB;AACzB,MAAMC,mBAAmB;AAgBzB,OAAO,SAASC,4BACdC,OAA2C;IAE3C,OAAO;QAACC,SAAS,CAACC,UAAYC,2BAA2BH,SAASE;IAAQ;AAC5E;AAEA,SAASC,2BACPH,OAA2C,EAC3CE,OAA6B;IAE7B,IAAIA,QAAQE,QAAQ,KAAK,iBAAiBF,QAAQE,QAAQ,KAAK,iBAAiB;QAC9E,MAAM,IAAIC,MAAM,CAAC,+BAA+B,EAAEH,QAAQE,QAAQ,CAAC,SAAS,CAAC;IAC/E;IAEA,MAAME,YAAYJ,QAAQK,OAAO,CAACV,iBAAiB;IACnD,MAAMW,YAAYN,QAAQK,OAAO,CAACT,iBAAiB;IACnD,IAAI,CAACQ,aAAa,CAACE,WAAW;QAC5B,OAAOC,QAAQC,OAAO,CAAC;YAACC,SAAS;YAAaC,QAAQ;QAAwB;IAChF;IAEA,MAAMC,UAAUzB,kBAAkBc,QAAQY,IAAI;IAC9C,MAAMC,cAAc,IAAIC,KAAKd,QAAQe,WAAW,EAAEC,OAAO;IACzD,IAAI,CAACzB,mCAAmCe,WAAWO,cAAc;QAC/D,OAAON,QAAQC,OAAO,CAAC;YAACC,SAAS;YAAaC,QAAQ;QAAkB;IAC1E;IACA,IACE,CAAClB,qBAAqB;QACpByB,eAAe3B,OAAO4B,oBAAoB;QAC1Cd;QACAE;QACAK;QACAQ,KAAKN;IACP,IACA;QACA,OAAON,QAAQC,OAAO,CAAC;YAACC,SAAS;YAAaC,QAAQ;QAAmB;IAC3E;IAEA,OAAOV,QAAQE,QAAQ,KAAK,gBACxBkB,kBAAkBtB,SAASa,WAC3BU,oBAAoBvB,SAASa;AACnC;AAEA,eAAeS,kBACbtB,OAA2C,EAC3Ca,OAAmB;IAEnB,IAAIW;IACJ,IAAI;QACFA,aAAaC,KAAKC,KAAK,CAACvC,OAAOwC,IAAI,CAACd,SAASe,QAAQ,CAAC;IACxD,EAAE,OAAOC,OAAO;QACdtC,SAASuC,IAAI,CAAC;YAACC,KAAKF;QAAK,GAAG;QAC5B,OAAO;YAAClB,SAAS;YAAaC,QAAQ;QAAmB;IAC3D;IAEA,MAAMoB,QAAQ3C,yBAAyB4C,SAAS,CAACT;IACjD,IAAI,CAACQ,MAAME,OAAO,EAAE;QAClB3C,SAASuC,IAAI,CAAC;YAACK,QAAQH,MAAMH,KAAK,CAACM,MAAM;QAAA,GAAG;QAC5C,OAAO;YAACxB,SAAS;YAAaC,QAAQ;QAAmB;IAC3D;IACA,MAAMwB,WAAWJ,MAAMK,IAAI;IAC3B,IAAID,SAASE,IAAI,KAAK,oBAAoB;QACxC,OAAO;YAAC3B,SAAS;YAAa4B,WAAWH,SAASG,SAAS;QAAA;IAC7D;IAEA,MAAMC,SAAS,MAAMxC,QAAQyC,MAAM,GAAGC,WAAW,CAAC,OAAOC,KACvD/C,iBAAiB;YACf+C;YACAC,YAAYR,SAASS,QAAQ;YAC7BT;YACAU,sBAAsB9C,QAAQ8C,oBAAoB;YAClDC,iCAAiC/C,QAAQ+C,+BAA+B;YACxEC,oBAAoBhD,QAAQgD,kBAAkB;YAC9CC,8BAA8BjD,QAAQiD,4BAA4B;QACpE;IAEF,OAAOC,mBAAmBV,OAAO7B,OAAO,EAAEyB,SAASS,QAAQ;AAC7D;AAEA,eAAetB,oBACbvB,OAA2C,EAC3Ca,OAAmB;IAEnB,MAAMsC,UAAU7D,wBAAwB2C,SAAS,CAC/CmB,OAAOC,WAAW,CAAC,IAAIC,gBAAgBnE,OAAOwC,IAAI,CAACd,SAASe,QAAQ,CAAC;IAEvE,IAAI,CAACuB,QAAQjB,OAAO,EAAE;QACpB3C,SAASuC,IAAI,CAAC;YAACK,QAAQgB,QAAQtB,KAAK,CAACM,MAAM;QAAA,GAAG;QAC9C,OAAO;YAACxB,SAAS;YAAaC,QAAQ;QAAmB;IAC3D;IAEA,MAAM4B,SAAS,MAAMxC,QAAQyC,MAAM,GAAGC,WAAW,CAAC,OAAOC,KACvDhD,mBAAmB;YACjBgD;YACAC,YAAYO,QAAQd,IAAI,CAACkB,UAAU;YACnCJ,SAASA,QAAQd,IAAI;YACrBU,iCAAiC/C,QAAQ+C,+BAA+B;YACxEC,oBAAoBhD,QAAQgD,kBAAkB;YAC9CC,8BAA8BjD,QAAQiD,4BAA4B;QACpE;IAEF,OAAOC,mBAAmBV,OAAO7B,OAAO,EAAEwC,QAAQd,IAAI,CAACkB,UAAU;AACnE;AAEA,SAASL,mBACPvC,OAA4B,EAC5BiC,UAAkB;IAElB,IAAIjC,YAAY,aAAa,OAAO;QAACA,SAAS;QAAaiC;IAAU;IACrE,IAAIjC,YAAY,aAAa,OAAO;QAACA,SAAS;QAAaiC;IAAU;IACrE,OAAO;QACLjC,SAAS;QACTC,QAAQD,YAAY,sBAAsB,sBAAsB;QAChEiC;IACF;AACF"}
|
package/dist/core/webhook.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { GetIntegrationConnectionByIdFn, IntegrationTx, PublishIntegrationEventReceivedFn, RecordDeliveryOnlyFn } from '@shipfox/api-integration-core-dto';
|
|
1
|
+
import type { ClaimWebhookDeliveryFn, GetIntegrationConnectionByIdFn, IntegrationTx, PublishIntegrationEventReceivedFn, RecordDeliveryOnlyFn } from '@shipfox/api-integration-core-dto';
|
|
2
2
|
import { type SlackEventBaseEnvelopeDto, type SlackSlashCommandDto } from '@shipfox/api-integration-slack-dto';
|
|
3
|
-
export type SlackWebhookOutcome = 'published' | 'duplicate' | 'unknown-team' | 'revoked-installation' | 'missing-connection' | 'inactive-connection' | 'unsupported-event' | 'self-message';
|
|
3
|
+
export type SlackWebhookOutcome = 'published' | 'duplicate' | 'unknown-team' | 'revoked-installation' | 'revoked' | 'unaffected-revocation' | 'stale-lifecycle-event' | 'missing-connection' | 'inactive-connection' | 'unsupported-event' | 'self-message';
|
|
4
4
|
interface SlackWebhookParams {
|
|
5
5
|
tx: IntegrationTx;
|
|
6
6
|
deliveryId: string;
|
|
@@ -10,15 +10,17 @@ interface SlackWebhookParams {
|
|
|
10
10
|
}
|
|
11
11
|
export interface HandleSlackEventParams extends SlackWebhookParams {
|
|
12
12
|
envelope: SlackEventBaseEnvelopeDto;
|
|
13
|
+
claimWebhookDelivery: ClaimWebhookDeliveryFn;
|
|
13
14
|
}
|
|
14
15
|
export interface HandleSlackCommandParams extends SlackWebhookParams {
|
|
15
16
|
command: SlackSlashCommandDto;
|
|
16
17
|
}
|
|
18
|
+
type SlackCommandOutcome = Exclude<SlackWebhookOutcome, 'unsupported-event' | 'self-message' | 'revoked' | 'unaffected-revocation' | 'stale-lifecycle-event'>;
|
|
17
19
|
export declare function handleSlackEvent(params: HandleSlackEventParams): Promise<{
|
|
18
20
|
outcome: SlackWebhookOutcome;
|
|
19
21
|
}>;
|
|
20
22
|
export declare function handleSlackCommand(params: HandleSlackCommandParams): Promise<{
|
|
21
|
-
outcome:
|
|
23
|
+
outcome: SlackCommandOutcome;
|
|
22
24
|
}>;
|
|
23
25
|
export declare function isSelfAuthoredSlackEvent(event: unknown, botUserId: string): boolean;
|
|
24
26
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../../src/core/webhook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,8BAA8B,EAE9B,aAAa,EACb,iCAAiC,EACjC,oBAAoB,EACrB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,
|
|
1
|
+
{"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../../src/core/webhook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,sBAAsB,EACtB,8BAA8B,EAE9B,aAAa,EACb,iCAAiC,EACjC,oBAAoB,EACrB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAIL,KAAK,yBAAyB,EAE9B,KAAK,oBAAoB,EAI1B,MAAM,oCAAoC,CAAC;AAuB5C,MAAM,MAAM,mBAAmB,GAC3B,WAAW,GACX,WAAW,GACX,cAAc,GACd,sBAAsB,GACtB,SAAS,GACT,uBAAuB,GACvB,uBAAuB,GACvB,oBAAoB,GACpB,qBAAqB,GACrB,mBAAmB,GACnB,cAAc,CAAC;AAEnB,UAAU,kBAAkB;IAC1B,EAAE,EAAE,aAAa,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,+BAA+B,EAAE,iCAAiC,CAAC;IACnE,kBAAkB,EAAE,oBAAoB,CAAC;IACzC,4BAA4B,EAAE,8BAA8B,CAAC;CAC9D;AAED,MAAM,WAAW,sBAAuB,SAAQ,kBAAkB;IAChE,QAAQ,EAAE,yBAAyB,CAAC;IACpC,oBAAoB,EAAE,sBAAsB,CAAC;CAC9C;AAED,MAAM,WAAW,wBAAyB,SAAQ,kBAAkB;IAClE,OAAO,EAAE,oBAAoB,CAAC;CAC/B;AASD,KAAK,mBAAmB,GAAG,OAAO,CAChC,mBAAmB,EACjB,mBAAmB,GACnB,cAAc,GACd,SAAS,GACT,uBAAuB,GACvB,uBAAuB,CAC1B,CAAC;AAGF,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,sBAAsB,GAC7B,OAAO,CAAC;IAAC,OAAO,EAAE,mBAAmB,CAAA;CAAC,CAAC,CAmDzC;AAED,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,wBAAwB,GAC/B,OAAO,CAAC;IAAC,OAAO,EAAE,mBAAmB,CAAA;CAAC,CAAC,CAwBzC;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAUnF"}
|
package/dist/core/webhook.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { SLACK_PROVIDER, SLACK_SLASH_COMMAND_EVENT, slackEventEnvelopeSchema } from '@shipfox/api-integration-slack-dto';
|
|
1
|
+
import { SLACK_APP_UNINSTALLED_EVENT, SLACK_PROVIDER, SLACK_SLASH_COMMAND_EVENT, slackEventEnvelopeSchema, slackLifecycleEventTypes, slackTokensRevokedEventSchema } from '@shipfox/api-integration-slack-dto';
|
|
2
2
|
import { logger } from '@shipfox/node-opentelemetry';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { getSlackInstallationByTeamId } from '#db/installations.js';
|
|
4
|
+
import { getSlackInstallationByTeamId, markSlackInstallationRevoked } from '#db/installations.js';
|
|
5
5
|
const slackSelfAuthoredEventSchema = z.object({
|
|
6
6
|
bot_id: z.string().optional(),
|
|
7
7
|
user: z.string().optional(),
|
|
@@ -11,6 +11,8 @@ const slackSelfAuthoredEventSchema = z.object({
|
|
|
11
11
|
}).passthrough().optional()
|
|
12
12
|
}).passthrough();
|
|
13
13
|
export async function handleSlackEvent(params) {
|
|
14
|
+
const lifecycleType = asSlackLifecycleEventType(params.envelope.event.type);
|
|
15
|
+
if (lifecycleType) return handleSlackLifecycleEvent(params, lifecycleType);
|
|
14
16
|
const resolution = await resolveSlackConnection({
|
|
15
17
|
...params,
|
|
16
18
|
teamId: params.envelope.team_id
|
|
@@ -89,6 +91,103 @@ export function isSelfAuthoredSlackEvent(event, botUserId) {
|
|
|
89
91
|
const nestedMessage = parsed.data.message;
|
|
90
92
|
return parsed.data.bot_id !== undefined || parsed.data.user === botUserId || nestedMessage?.bot_id !== undefined || nestedMessage?.user === botUserId;
|
|
91
93
|
}
|
|
94
|
+
function asSlackLifecycleEventType(eventType) {
|
|
95
|
+
return slackLifecycleEventTypes.find((type)=>type === eventType);
|
|
96
|
+
}
|
|
97
|
+
async function handleSlackLifecycleEvent(params, lifecycleType) {
|
|
98
|
+
const claim = await params.claimWebhookDelivery({
|
|
99
|
+
tx: params.tx,
|
|
100
|
+
provider: SLACK_PROVIDER,
|
|
101
|
+
deliveryId: params.deliveryId
|
|
102
|
+
});
|
|
103
|
+
if (!claim.claimed) {
|
|
104
|
+
logger().info({
|
|
105
|
+
deliveryId: params.deliveryId,
|
|
106
|
+
teamId: params.envelope.team_id,
|
|
107
|
+
lifecycleType
|
|
108
|
+
}, 'slack lifecycle event: duplicate delivery, dropping');
|
|
109
|
+
return {
|
|
110
|
+
outcome: 'duplicate'
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const installation = await getSlackInstallationByTeamId(params.envelope.team_id, {
|
|
114
|
+
tx: params.tx
|
|
115
|
+
});
|
|
116
|
+
if (!installation) {
|
|
117
|
+
logger().warn({
|
|
118
|
+
deliveryId: params.deliveryId,
|
|
119
|
+
teamId: params.envelope.team_id,
|
|
120
|
+
lifecycleType
|
|
121
|
+
}, 'slack lifecycle event: unknown team, dropping');
|
|
122
|
+
return {
|
|
123
|
+
outcome: 'unknown-team'
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (installation.status !== 'installed') {
|
|
127
|
+
logger().info({
|
|
128
|
+
deliveryId: params.deliveryId,
|
|
129
|
+
teamId: params.envelope.team_id,
|
|
130
|
+
connectionId: installation.connectionId,
|
|
131
|
+
lifecycleType
|
|
132
|
+
}, 'slack lifecycle event: installation is not installed, dropping');
|
|
133
|
+
return {
|
|
134
|
+
outcome: 'revoked-installation'
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (isSlackLifecycleEventOlderThanInstallation(params.envelope.event_time, installation.updatedAt)) {
|
|
138
|
+
logger().info({
|
|
139
|
+
deliveryId: params.deliveryId,
|
|
140
|
+
teamId: params.envelope.team_id,
|
|
141
|
+
connectionId: installation.connectionId
|
|
142
|
+
}, 'slack lifecycle event: predates the current installation, dropping');
|
|
143
|
+
return {
|
|
144
|
+
outcome: 'stale-lifecycle-event'
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const revokesInstallation = lifecycleType === SLACK_APP_UNINSTALLED_EVENT || slackTokensRevokedAffectsBot(params.envelope.event, installation.botUserId);
|
|
148
|
+
if (!revokesInstallation) {
|
|
149
|
+
logger().info({
|
|
150
|
+
deliveryId: params.deliveryId,
|
|
151
|
+
teamId: params.envelope.team_id,
|
|
152
|
+
connectionId: installation.connectionId
|
|
153
|
+
}, 'slack lifecycle event: token revocation does not affect installation bot, dropping');
|
|
154
|
+
return {
|
|
155
|
+
outcome: 'unaffected-revocation'
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const revoked = await markSlackInstallationRevoked(installation.connectionId, {
|
|
159
|
+
tx: params.tx,
|
|
160
|
+
expectedGeneration: installation.generation
|
|
161
|
+
});
|
|
162
|
+
if (!revoked) {
|
|
163
|
+
logger().info({
|
|
164
|
+
deliveryId: params.deliveryId,
|
|
165
|
+
teamId: params.envelope.team_id,
|
|
166
|
+
connectionId: installation.connectionId
|
|
167
|
+
}, 'slack lifecycle event: installation changed before revocation, dropping');
|
|
168
|
+
return {
|
|
169
|
+
outcome: 'stale-lifecycle-event'
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
logger().info({
|
|
173
|
+
deliveryId: params.deliveryId,
|
|
174
|
+
teamId: params.envelope.team_id,
|
|
175
|
+
connectionId: installation.connectionId,
|
|
176
|
+
lifecycleType
|
|
177
|
+
}, 'slack lifecycle event: installation revoked');
|
|
178
|
+
return {
|
|
179
|
+
outcome: 'revoked'
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function slackTokensRevokedAffectsBot(event, botUserId) {
|
|
183
|
+
const parsed = slackTokensRevokedEventSchema.safeParse(event);
|
|
184
|
+
// Only a named bot token proves this installation credential was revoked; OAuth-only and missing lists do not.
|
|
185
|
+
return parsed.success && (parsed.data.tokens?.bot?.includes(botUserId) ?? false);
|
|
186
|
+
}
|
|
187
|
+
function isSlackLifecycleEventOlderThanInstallation(eventTime, installationUpdatedAt) {
|
|
188
|
+
// Slack event_time has second precision, so an event stamped in the installation second is not provably stale.
|
|
189
|
+
return eventTime < Math.floor(installationUpdatedAt.getTime() / 1000);
|
|
190
|
+
}
|
|
92
191
|
async function resolveSlackConnection(params) {
|
|
93
192
|
const installation = await getSlackInstallationByTeamId(params.teamId, {
|
|
94
193
|
tx: params.tx
|