@shipfox/api-integration-slack 5.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.
Files changed (44) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +35 -0
  3. package/dist/core/signature.d.ts +2 -1
  4. package/dist/core/signature.d.ts.map +1 -1
  5. package/dist/core/signature.js +10 -6
  6. package/dist/core/signature.js.map +1 -1
  7. package/dist/core/webhook-processor.d.ts +15 -0
  8. package/dist/core/webhook-processor.d.ts.map +1 -0
  9. package/dist/core/webhook-processor.js +127 -0
  10. package/dist/core/webhook-processor.js.map +1 -0
  11. package/dist/index.d.ts +7 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +24 -6
  14. package/dist/index.js.map +1 -1
  15. package/dist/presentation/routes/errors.d.ts.map +1 -1
  16. package/dist/presentation/routes/errors.js +22 -0
  17. package/dist/presentation/routes/errors.js.map +1 -1
  18. package/dist/presentation/routes/install.d.ts +6 -1
  19. package/dist/presentation/routes/install.d.ts.map +1 -1
  20. package/dist/presentation/routes/install.js +6 -4
  21. package/dist/presentation/routes/install.js.map +1 -1
  22. package/dist/presentation/routes/webhooks.d.ts +2 -0
  23. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  24. package/dist/presentation/routes/webhooks.js +94 -93
  25. package/dist/presentation/routes/webhooks.js.map +1 -1
  26. package/dist/tsconfig.test.tsbuildinfo +1 -1
  27. package/package.json +10 -10
  28. package/src/core/disconnect.test.ts +47 -0
  29. package/src/core/signature.test.ts +3 -3
  30. package/src/core/signature.ts +19 -9
  31. package/src/core/tokens.test.ts +16 -2
  32. package/src/core/webhook-processor.test.ts +106 -0
  33. package/src/core/webhook-processor.ts +152 -0
  34. package/src/index.ts +19 -3
  35. package/src/presentation/routes/errors.test.ts +25 -0
  36. package/src/presentation/routes/errors.ts +20 -0
  37. package/src/presentation/routes/install.test.ts +2 -4
  38. package/src/presentation/routes/install.ts +18 -3
  39. package/src/presentation/routes/webhooks.test.ts +18 -0
  40. package/src/presentation/routes/webhooks.ts +125 -98
  41. package/test/globalSetup.ts +0 -9
  42. package/tsconfig.build.tsbuildinfo +1 -1
  43. package/turbo.json +1 -1
  44. package/test/api-secrets.d.ts +0 -26
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/presentation/routes/install.ts"],"sourcesContent":["import {AUTH_USER, requireUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';\nimport type {IntegrationCapability, IntegrationConnection} from '@shipfox/api-integration-core-dto';\nimport {\n createSlackInstallBodySchema,\n createSlackInstallResponseSchema,\n type SlackCallbackQueryDto,\n slackCallbackQuerySchema,\n slackCallbackResponseSchema,\n} from '@shipfox/api-integration-slack-dto';\nimport {requireWorkspaceMembership} from '@shipfox/api-workspaces';\nimport {defineRoute, type RouteGroup} from '@shipfox/node-fastify';\nimport type {SlackApiClient} from '#api/client.js';\nimport {config} from '#config.js';\nimport {\n type ConnectSlackInstallationInput,\n handleSlackCallback,\n handleSlackOAuthCallbackError,\n} from '#core/install.js';\nimport {formatSlackBotScopes} from '#core/scopes.js';\nimport {signSlackInstallState} from '#core/state.js';\nimport type {SlackTokenStore} from '#core/tokens.js';\nimport {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';\nimport {slackRouteErrorHandler} from './errors.js';\n\nexport interface CreateSlackIntegrationRoutesOptions {\n slack: SlackApiClient;\n tokenStore: Pick<SlackTokenStore, 'storeTokens'>;\n getExistingSlackConnection: (input: {\n teamId: string;\n }) => Promise<IntegrationConnection<'slack'> | undefined>;\n connectSlackInstallation: (\n input: ConnectSlackInstallationInput,\n ) => Promise<IntegrationConnection<'slack'>>;\n disconnectSlackInstallation: (input: {connectionId: string}) => Promise<void>;\n connectionCapabilities: IntegrationCapability[];\n}\n\nexport function createSlackIntegrationRoutes({\n slack,\n tokenStore,\n getExistingSlackConnection,\n connectSlackInstallation,\n disconnectSlackInstallation,\n connectionCapabilities,\n}: CreateSlackIntegrationRoutesOptions): RouteGroup {\n const installRoute = defineRoute({\n method: 'POST',\n path: '/install',\n auth: AUTH_USER,\n description: 'Create a Slack OAuth authorization URL for a workspace.',\n schema: {\n body: createSlackInstallBodySchema,\n response: {200: createSlackInstallResponseSchema},\n },\n handler: (request) => {\n const {workspace_id: workspaceId} = request.body;\n const actor = requireUserContext(request);\n requireWorkspaceAccess({request, workspaceId});\n const state = signSlackInstallState({workspaceId, userId: actor.userId});\n const installUrl = new URL('https://slack.com/oauth/v2/authorize');\n installUrl.searchParams.set('client_id', config.SLACK_OAUTH_CLIENT_ID);\n installUrl.searchParams.set('scope', formatSlackBotScopes());\n installUrl.searchParams.set('redirect_uri', config.SLACK_OAUTH_REDIRECT_URL);\n installUrl.searchParams.set('state', state);\n return {install_url: installUrl.toString()};\n },\n });\n\n const callbackApiRoute = defineRoute({\n method: 'GET',\n path: '/callback/api',\n auth: AUTH_USER,\n description: 'Handle the Slack OAuth callback.',\n schema: {\n querystring: slackCallbackQuerySchema,\n response: {200: slackCallbackResponseSchema},\n },\n errorHandler: slackRouteErrorHandler,\n handler: async (request) => {\n const actor = requireUserContext(request);\n const query = request.query;\n if (isSlackOAuthErrorCallback(query)) {\n return await handleSlackOAuthCallbackError({\n state: query.state,\n error: query.error,\n errorDescription: query.error_description,\n sessionUserId: actor.userId,\n sessionMemberships: actor.memberships,\n requireWorkspaceMembership,\n });\n }\n const connection = await handleSlackCallback({\n slack,\n tokenStore,\n code: query.code,\n state: query.state,\n sessionUserId: actor.userId,\n sessionMemberships: actor.memberships,\n requireWorkspaceMembership,\n getExistingSlackConnection,\n connectSlackInstallation,\n disconnectSlackInstallation,\n });\n return toIntegrationConnectionDto(connection, {capabilities: connectionCapabilities});\n },\n });\n\n return {prefix: '/integrations/slack', routes: [installRoute, callbackApiRoute]};\n}\n\nfunction isSlackOAuthErrorCallback(query: SlackCallbackQueryDto): query is SlackCallbackQueryDto & {\n error: string;\n error_description?: string | undefined;\n state: string;\n} {\n return 'error' in query && typeof query.error === 'string';\n}\n"],"names":["AUTH_USER","requireUserContext","requireWorkspaceAccess","createSlackInstallBodySchema","createSlackInstallResponseSchema","slackCallbackQuerySchema","slackCallbackResponseSchema","requireWorkspaceMembership","defineRoute","config","handleSlackCallback","handleSlackOAuthCallbackError","formatSlackBotScopes","signSlackInstallState","toIntegrationConnectionDto","slackRouteErrorHandler","createSlackIntegrationRoutes","slack","tokenStore","getExistingSlackConnection","connectSlackInstallation","disconnectSlackInstallation","connectionCapabilities","installRoute","method","path","auth","description","schema","body","response","handler","request","workspace_id","workspaceId","actor","state","userId","installUrl","URL","searchParams","set","SLACK_OAUTH_CLIENT_ID","SLACK_OAUTH_REDIRECT_URL","install_url","toString","callbackApiRoute","querystring","errorHandler","query","isSlackOAuthErrorCallback","error","errorDescription","error_description","sessionUserId","sessionMemberships","memberships","connection","code","capabilities","prefix","routes"],"mappings":"AAAA,SAAQA,SAAS,EAAEC,kBAAkB,EAAEC,sBAAsB,QAAO,4BAA4B;AAEhG,SACEC,4BAA4B,EAC5BC,gCAAgC,EAEhCC,wBAAwB,EACxBC,2BAA2B,QACtB,qCAAqC;AAC5C,SAAQC,0BAA0B,QAAO,0BAA0B;AACnE,SAAQC,WAAW,QAAwB,wBAAwB;AAEnE,SAAQC,MAAM,QAAO,aAAa;AAClC,SAEEC,mBAAmB,EACnBC,6BAA6B,QACxB,mBAAmB;AAC1B,SAAQC,oBAAoB,QAAO,kBAAkB;AACrD,SAAQC,qBAAqB,QAAO,iBAAiB;AAErD,SAAQC,0BAA0B,QAAO,oCAAoC;AAC7E,SAAQC,sBAAsB,QAAO,cAAc;AAenD,OAAO,SAASC,6BAA6B,EAC3CC,KAAK,EACLC,UAAU,EACVC,0BAA0B,EAC1BC,wBAAwB,EACxBC,2BAA2B,EAC3BC,sBAAsB,EACc;IACpC,MAAMC,eAAef,YAAY;QAC/BgB,QAAQ;QACRC,MAAM;QACNC,MAAM1B;QACN2B,aAAa;QACbC,QAAQ;YACNC,MAAM1B;YACN2B,UAAU;gBAAC,KAAK1B;YAAgC;QAClD;QACA2B,SAAS,CAACC;YACR,MAAM,EAACC,cAAcC,WAAW,EAAC,GAAGF,QAAQH,IAAI;YAChD,MAAMM,QAAQlC,mBAAmB+B;YACjC9B,uBAAuB;gBAAC8B;gBAASE;YAAW;YAC5C,MAAME,QAAQvB,sBAAsB;gBAACqB;gBAAaG,QAAQF,MAAME,MAAM;YAAA;YACtE,MAAMC,aAAa,IAAIC,IAAI;YAC3BD,WAAWE,YAAY,CAACC,GAAG,CAAC,aAAahC,OAAOiC,qBAAqB;YACrEJ,WAAWE,YAAY,CAACC,GAAG,CAAC,SAAS7B;YACrC0B,WAAWE,YAAY,CAACC,GAAG,CAAC,gBAAgBhC,OAAOkC,wBAAwB;YAC3EL,WAAWE,YAAY,CAACC,GAAG,CAAC,SAASL;YACrC,OAAO;gBAACQ,aAAaN,WAAWO,QAAQ;YAAE;QAC5C;IACF;IAEA,MAAMC,mBAAmBtC,YAAY;QACnCgB,QAAQ;QACRC,MAAM;QACNC,MAAM1B;QACN2B,aAAa;QACbC,QAAQ;YACNmB,aAAa1C;YACbyB,UAAU;gBAAC,KAAKxB;YAA2B;QAC7C;QACA0C,cAAcjC;QACdgB,SAAS,OAAOC;YACd,MAAMG,QAAQlC,mBAAmB+B;YACjC,MAAMiB,QAAQjB,QAAQiB,KAAK;YAC3B,IAAIC,0BAA0BD,QAAQ;gBACpC,OAAO,MAAMtC,8BAA8B;oBACzCyB,OAAOa,MAAMb,KAAK;oBAClBe,OAAOF,MAAME,KAAK;oBAClBC,kBAAkBH,MAAMI,iBAAiB;oBACzCC,eAAenB,MAAME,MAAM;oBAC3BkB,oBAAoBpB,MAAMqB,WAAW;oBACrCjD;gBACF;YACF;YACA,MAAMkD,aAAa,MAAM/C,oBAAoB;gBAC3CO;gBACAC;gBACAwC,MAAMT,MAAMS,IAAI;gBAChBtB,OAAOa,MAAMb,KAAK;gBAClBkB,eAAenB,MAAME,MAAM;gBAC3BkB,oBAAoBpB,MAAMqB,WAAW;gBACrCjD;gBACAY;gBACAC;gBACAC;YACF;YACA,OAAOP,2BAA2B2C,YAAY;gBAACE,cAAcrC;YAAsB;QACrF;IACF;IAEA,OAAO;QAACsC,QAAQ;QAAuBC,QAAQ;YAACtC;YAAcuB;SAAiB;IAAA;AACjF;AAEA,SAASI,0BAA0BD,KAA4B;IAK7D,OAAO,WAAWA,SAAS,OAAOA,MAAME,KAAK,KAAK;AACpD"}
1
+ {"version":3,"sources":["../../../src/presentation/routes/install.ts"],"sourcesContent":["import {AUTH_USER, requireUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';\nimport type {IntegrationCapability, IntegrationConnection} from '@shipfox/api-integration-core-dto';\nimport {\n createSlackInstallBodySchema,\n createSlackInstallResponseSchema,\n type SlackCallbackQueryDto,\n slackCallbackQuerySchema,\n slackCallbackResponseSchema,\n} from '@shipfox/api-integration-slack-dto';\nimport {defineRoute, type RouteGroup} from '@shipfox/node-fastify';\nimport type {SlackApiClient} from '#api/client.js';\nimport {config} from '#config.js';\nimport {\n type ConnectSlackInstallationInput,\n handleSlackCallback,\n handleSlackOAuthCallbackError,\n} from '#core/install.js';\nimport {formatSlackBotScopes} from '#core/scopes.js';\nimport {signSlackInstallState} from '#core/state.js';\nimport type {SlackTokenStore} from '#core/tokens.js';\nimport {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';\nimport {slackRouteErrorHandler} from './errors.js';\n\nexport interface CreateSlackIntegrationRoutesOptions {\n slack: SlackApiClient;\n tokenStore: Pick<SlackTokenStore, 'storeTokens'>;\n getExistingSlackConnection: (input: {\n teamId: string;\n }) => Promise<IntegrationConnection<'slack'> | undefined>;\n connectSlackInstallation: (\n input: ConnectSlackInstallationInput,\n ) => Promise<IntegrationConnection<'slack'>>;\n disconnectSlackInstallation: (input: {connectionId: string}) => Promise<void>;\n connectionCapabilities: IntegrationCapability[];\n requireActiveWorkspaceMembership?: (input: {\n workspaceId: string;\n userId: string;\n memberships: ReadonlyArray<import('@shipfox/api-auth-context').UserContextMembership>;\n }) => Promise<unknown>;\n}\n\nexport function createSlackIntegrationRoutes({\n slack,\n tokenStore,\n getExistingSlackConnection,\n connectSlackInstallation,\n disconnectSlackInstallation,\n connectionCapabilities,\n requireActiveWorkspaceMembership,\n}: CreateSlackIntegrationRoutesOptions): RouteGroup {\n const installRoute = defineRoute({\n method: 'POST',\n path: '/install',\n auth: AUTH_USER,\n description: 'Create a Slack OAuth authorization URL for a workspace.',\n schema: {\n body: createSlackInstallBodySchema,\n response: {200: createSlackInstallResponseSchema},\n },\n handler: (request) => {\n const {workspace_id: workspaceId} = request.body;\n const actor = requireUserContext(request);\n requireWorkspaceAccess({request, workspaceId});\n const state = signSlackInstallState({workspaceId, userId: actor.userId});\n const installUrl = new URL('https://slack.com/oauth/v2/authorize');\n installUrl.searchParams.set('client_id', config.SLACK_OAUTH_CLIENT_ID);\n installUrl.searchParams.set('scope', formatSlackBotScopes());\n installUrl.searchParams.set('redirect_uri', config.SLACK_OAUTH_REDIRECT_URL);\n installUrl.searchParams.set('state', state);\n return {install_url: installUrl.toString()};\n },\n });\n\n const callbackApiRoute = defineRoute({\n method: 'GET',\n path: '/callback/api',\n auth: AUTH_USER,\n description: 'Handle the Slack OAuth callback.',\n schema: {\n querystring: slackCallbackQuerySchema,\n response: {200: slackCallbackResponseSchema},\n },\n errorHandler: slackRouteErrorHandler,\n handler: async (request) => {\n const actor = requireUserContext(request);\n const query = request.query;\n if (isSlackOAuthErrorCallback(query)) {\n return await handleSlackOAuthCallbackError({\n state: query.state,\n error: query.error,\n errorDescription: query.error_description,\n sessionUserId: actor.userId,\n sessionMemberships: actor.memberships,\n requireWorkspaceMembership:\n requireActiveWorkspaceMembership ?? unavailableWorkspaceMembershipCheck,\n });\n }\n const connection = await handleSlackCallback({\n slack,\n tokenStore,\n code: query.code,\n state: query.state,\n sessionUserId: actor.userId,\n sessionMemberships: actor.memberships,\n requireWorkspaceMembership:\n requireActiveWorkspaceMembership ?? unavailableWorkspaceMembershipCheck,\n getExistingSlackConnection,\n connectSlackInstallation,\n disconnectSlackInstallation,\n });\n return toIntegrationConnectionDto(connection, {capabilities: connectionCapabilities});\n },\n });\n\n return {prefix: '/integrations/slack', routes: [installRoute, callbackApiRoute]};\n}\n\nfunction unavailableWorkspaceMembershipCheck(_input: {\n workspaceId: string;\n userId: string;\n memberships: ReadonlyArray<import('@shipfox/api-auth-context').UserContextMembership>;\n}): Promise<never> {\n return Promise.reject(new Error('Workspaces inter-module client is not configured'));\n}\n\nfunction isSlackOAuthErrorCallback(query: SlackCallbackQueryDto): query is SlackCallbackQueryDto & {\n error: string;\n error_description?: string | undefined;\n state: string;\n} {\n return 'error' in query && typeof query.error === 'string';\n}\n"],"names":["AUTH_USER","requireUserContext","requireWorkspaceAccess","createSlackInstallBodySchema","createSlackInstallResponseSchema","slackCallbackQuerySchema","slackCallbackResponseSchema","defineRoute","config","handleSlackCallback","handleSlackOAuthCallbackError","formatSlackBotScopes","signSlackInstallState","toIntegrationConnectionDto","slackRouteErrorHandler","createSlackIntegrationRoutes","slack","tokenStore","getExistingSlackConnection","connectSlackInstallation","disconnectSlackInstallation","connectionCapabilities","requireActiveWorkspaceMembership","installRoute","method","path","auth","description","schema","body","response","handler","request","workspace_id","workspaceId","actor","state","userId","installUrl","URL","searchParams","set","SLACK_OAUTH_CLIENT_ID","SLACK_OAUTH_REDIRECT_URL","install_url","toString","callbackApiRoute","querystring","errorHandler","query","isSlackOAuthErrorCallback","error","errorDescription","error_description","sessionUserId","sessionMemberships","memberships","requireWorkspaceMembership","unavailableWorkspaceMembershipCheck","connection","code","capabilities","prefix","routes","_input","Promise","reject","Error"],"mappings":"AAAA,SAAQA,SAAS,EAAEC,kBAAkB,EAAEC,sBAAsB,QAAO,4BAA4B;AAEhG,SACEC,4BAA4B,EAC5BC,gCAAgC,EAEhCC,wBAAwB,EACxBC,2BAA2B,QACtB,qCAAqC;AAC5C,SAAQC,WAAW,QAAwB,wBAAwB;AAEnE,SAAQC,MAAM,QAAO,aAAa;AAClC,SAEEC,mBAAmB,EACnBC,6BAA6B,QACxB,mBAAmB;AAC1B,SAAQC,oBAAoB,QAAO,kBAAkB;AACrD,SAAQC,qBAAqB,QAAO,iBAAiB;AAErD,SAAQC,0BAA0B,QAAO,oCAAoC;AAC7E,SAAQC,sBAAsB,QAAO,cAAc;AAoBnD,OAAO,SAASC,6BAA6B,EAC3CC,KAAK,EACLC,UAAU,EACVC,0BAA0B,EAC1BC,wBAAwB,EACxBC,2BAA2B,EAC3BC,sBAAsB,EACtBC,gCAAgC,EACI;IACpC,MAAMC,eAAehB,YAAY;QAC/BiB,QAAQ;QACRC,MAAM;QACNC,MAAM1B;QACN2B,aAAa;QACbC,QAAQ;YACNC,MAAM1B;YACN2B,UAAU;gBAAC,KAAK1B;YAAgC;QAClD;QACA2B,SAAS,CAACC;YACR,MAAM,EAACC,cAAcC,WAAW,EAAC,GAAGF,QAAQH,IAAI;YAChD,MAAMM,QAAQlC,mBAAmB+B;YACjC9B,uBAAuB;gBAAC8B;gBAASE;YAAW;YAC5C,MAAME,QAAQxB,sBAAsB;gBAACsB;gBAAaG,QAAQF,MAAME,MAAM;YAAA;YACtE,MAAMC,aAAa,IAAIC,IAAI;YAC3BD,WAAWE,YAAY,CAACC,GAAG,CAAC,aAAajC,OAAOkC,qBAAqB;YACrEJ,WAAWE,YAAY,CAACC,GAAG,CAAC,SAAS9B;YACrC2B,WAAWE,YAAY,CAACC,GAAG,CAAC,gBAAgBjC,OAAOmC,wBAAwB;YAC3EL,WAAWE,YAAY,CAACC,GAAG,CAAC,SAASL;YACrC,OAAO;gBAACQ,aAAaN,WAAWO,QAAQ;YAAE;QAC5C;IACF;IAEA,MAAMC,mBAAmBvC,YAAY;QACnCiB,QAAQ;QACRC,MAAM;QACNC,MAAM1B;QACN2B,aAAa;QACbC,QAAQ;YACNmB,aAAa1C;YACbyB,UAAU;gBAAC,KAAKxB;YAA2B;QAC7C;QACA0C,cAAclC;QACdiB,SAAS,OAAOC;YACd,MAAMG,QAAQlC,mBAAmB+B;YACjC,MAAMiB,QAAQjB,QAAQiB,KAAK;YAC3B,IAAIC,0BAA0BD,QAAQ;gBACpC,OAAO,MAAMvC,8BAA8B;oBACzC0B,OAAOa,MAAMb,KAAK;oBAClBe,OAAOF,MAAME,KAAK;oBAClBC,kBAAkBH,MAAMI,iBAAiB;oBACzCC,eAAenB,MAAME,MAAM;oBAC3BkB,oBAAoBpB,MAAMqB,WAAW;oBACrCC,4BACEnC,oCAAoCoC;gBACxC;YACF;YACA,MAAMC,aAAa,MAAMlD,oBAAoB;gBAC3CO;gBACAC;gBACA2C,MAAMX,MAAMW,IAAI;gBAChBxB,OAAOa,MAAMb,KAAK;gBAClBkB,eAAenB,MAAME,MAAM;gBAC3BkB,oBAAoBpB,MAAMqB,WAAW;gBACrCC,4BACEnC,oCAAoCoC;gBACtCxC;gBACAC;gBACAC;YACF;YACA,OAAOP,2BAA2B8C,YAAY;gBAACE,cAAcxC;YAAsB;QACrF;IACF;IAEA,OAAO;QAACyC,QAAQ;QAAuBC,QAAQ;YAACxC;YAAcuB;SAAiB;IAAA;AACjF;AAEA,SAASY,oCAAoCM,MAI5C;IACC,OAAOC,QAAQC,MAAM,CAAC,IAAIC,MAAM;AAClC;AAEA,SAASjB,0BAA0BD,KAA4B;IAK7D,OAAO,WAAWA,SAAS,OAAOA,MAAME,KAAK,KAAK;AACpD"}
@@ -1,6 +1,7 @@
1
1
  import type { ClaimWebhookDeliveryFn, GetIntegrationConnectionByIdFn, PublishIntegrationEventReceivedFn, RecordDeliveryOnlyFn } from '@shipfox/api-integration-core-dto';
2
2
  import { type RouteGroup } from '@shipfox/node-fastify';
3
3
  import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
4
+ import { type SlackWebhookProcessor } from '#core/webhook-processor.js';
4
5
  export declare const SLACK_WEBHOOK_BODY_LIMIT: number;
5
6
  export declare const SLASH_COMMAND_ACK: {
6
7
  readonly response_type: "ephemeral";
@@ -12,6 +13,7 @@ export interface CreateSlackWebhookRoutesOptions {
12
13
  publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
13
14
  recordDeliveryOnly: RecordDeliveryOnlyFn;
14
15
  getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
16
+ processor?: SlackWebhookProcessor | undefined;
15
17
  }
16
18
  export declare function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOptions): RouteGroup[];
17
19
  //# sourceMappingURL=webhooks.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"webhooks.d.ts","sourceRoot":"","sources":["../../../src/presentation/routes/webhooks.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,sBAAsB,EACtB,8BAA8B,EAC9B,iCAAiC,EACjC,oBAAoB,EACrB,MAAM,mCAAmC,CAAC;AAK3C,OAAO,EAAmC,KAAK,UAAU,EAAC,MAAM,uBAAuB,CAAC;AAExF,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAK9D,eAAO,MAAM,wBAAwB,QAAc,CAAC;AACpD,eAAO,MAAM,iBAAiB;;;CAAgE,CAAC;AAW/F,MAAM,WAAW,+BAA+B;IAC9C,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,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,+BAA+B,GAAG,UAAU,EAAE,CAoH/F"}
1
+ {"version":3,"file":"webhooks.d.ts","sourceRoot":"","sources":["../../../src/presentation/routes/webhooks.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,sBAAsB,EACtB,8BAA8B,EAC9B,iCAAiC,EACjC,oBAAoB,EAGrB,MAAM,mCAAmC,CAAC;AAK3C,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAC9D,OAAO,EAA8B,KAAK,qBAAqB,EAAC,MAAM,4BAA4B,CAAC;AAEnG,eAAO,MAAM,wBAAwB,QAAc,CAAC;AACpD,eAAO,MAAM,iBAAiB;;;CAAgE,CAAC;AAW/F,MAAM,WAAW,+BAA+B;IAC9C,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;IAC7D,SAAS,CAAC,EAAE,qBAAqB,GAAG,SAAS,CAAC;CAC/C;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,+BAA+B,GAAG,UAAU,EAAE,CAqE/F"}
@@ -1,10 +1,7 @@
1
- import { Buffer } from 'node:buffer';
2
- import { slackEventsRequestSchema, slackSlashCommandSchema } from '@shipfox/api-integration-slack-dto';
3
- import { createRawBodyPlugin, defineRoute } from '@shipfox/node-fastify';
4
- import { logger } from '@shipfox/node-opentelemetry';
5
- import { config } from '#config.js';
6
- import { verifySlackSignature } from '#core/signature.js';
7
- import { handleSlackCommand, handleSlackEvent } from '#core/webhook.js';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createStoredWebhookRequest, WEBHOOK_MAX_RAW_BODY_BYTES } from '@shipfox/api-integration-core-dto';
3
+ import { ClientError, createRawBodyPlugin, defineRoute } from '@shipfox/node-fastify';
4
+ import { createSlackWebhookProcessor } from '#core/webhook-processor.js';
8
5
  export const SLACK_WEBHOOK_BODY_LIMIT = 1024 * 1024;
9
6
  export const SLASH_COMMAND_ACK = {
10
7
  response_type: 'ephemeral',
@@ -19,6 +16,7 @@ const slackFormRawBodyPlugin = createRawBodyPlugin({
19
16
  bodyLimit: SLACK_WEBHOOK_BODY_LIMIT
20
17
  });
21
18
  export function createSlackWebhookRoutes(options) {
19
+ const processor = options.processor ?? createSlackWebhookProcessor(options);
22
20
  const eventsRoute = defineRoute({
23
21
  method: 'POST',
24
22
  path: '/',
@@ -28,59 +26,20 @@ export function createSlackWebhookRoutes(options) {
28
26
  bodyLimit: SLACK_WEBHOOK_BODY_LIMIT
29
27
  },
30
28
  handler: async (request, reply)=>{
31
- const rawBody = rawRequestBody(request.body);
32
- if (rawBody === undefined) {
29
+ const body = rawRequestBody(request.body);
30
+ if (!body) {
33
31
  reply.code(400);
34
32
  return {
35
33
  error: 'expected raw JSON body'
36
34
  };
37
35
  }
38
- if (!hasValidSlackSignature(request.headers, rawBody)) {
39
- reply.code(401);
40
- return {
41
- error: 'invalid signature'
42
- };
43
- }
44
- let parsedJson;
45
- try {
46
- parsedJson = JSON.parse(rawBody);
47
- } catch (error) {
48
- logger().warn({
49
- errorName: error instanceof Error ? error.name : 'unknown'
50
- }, 'slack events payload JSON parse failed');
51
- reply.code(400);
52
- return {
53
- error: 'malformed JSON'
54
- };
55
- }
56
- const parsed = slackEventsRequestSchema.safeParse(parsedJson);
57
- if (!parsed.success) {
58
- logger().warn({
59
- issues: parsed.error.issues
60
- }, 'slack events envelope failed schema validation');
61
- reply.code(200);
62
- return null;
63
- }
64
- const eventRequest = parsed.data;
65
- if (eventRequest.type === 'url_verification') {
66
- reply.code(200);
67
- return {
68
- challenge: eventRequest.challenge
69
- };
70
- }
71
- await options.coreDb().transaction(async (tx)=>{
72
- await handleSlackEvent({
73
- tx,
74
- deliveryId: eventRequest.event_id,
75
- envelope: eventRequest,
76
- claimWebhookDelivery: options.claimWebhookDelivery,
77
- publishIntegrationEventReceived: options.publishIntegrationEventReceived,
78
- recordDeliveryOnly: options.recordDeliveryOnly,
79
- getIntegrationConnectionById: options.getIntegrationConnectionById
80
- });
81
- });
82
- reply.code(200);
83
- return null;
36
+ const result = await processor.process(createSlackStoredWebhookRequest({
37
+ routeId: 'slack.event',
38
+ body,
39
+ headers: request.headers,
40
+ rawQueryString: rawQueryString(request)
41
+ }, new Date().toISOString()));
42
+ return sendSlackEventResponse(reply, result);
84
43
  }
85
44
  });
86
45
  const commandsRoute = defineRoute({
@@ -92,39 +51,20 @@ export function createSlackWebhookRoutes(options) {
92
51
  bodyLimit: SLACK_WEBHOOK_BODY_LIMIT
93
52
  },
94
53
  handler: async (request, reply)=>{
95
- const rawBody = rawRequestBody(request.body);
96
- if (rawBody === undefined) {
54
+ const body = rawRequestBody(request.body);
55
+ if (!body) {
97
56
  reply.code(400);
98
57
  return {
99
58
  error: 'expected raw form body'
100
59
  };
101
60
  }
102
- if (!hasValidSlackSignature(request.headers, rawBody)) {
103
- reply.code(401);
104
- return {
105
- error: 'invalid signature'
106
- };
107
- }
108
- const command = slackSlashCommandSchema.safeParse(Object.fromEntries(new URLSearchParams(rawBody)));
109
- if (!command.success) {
110
- logger().warn({
111
- issues: command.error.issues
112
- }, 'slack command failed schema validation');
113
- reply.code(200);
114
- return SLASH_COMMAND_ACK;
115
- }
116
- await options.coreDb().transaction(async (tx)=>{
117
- await handleSlackCommand({
118
- tx,
119
- deliveryId: command.data.trigger_id,
120
- command: command.data,
121
- publishIntegrationEventReceived: options.publishIntegrationEventReceived,
122
- recordDeliveryOnly: options.recordDeliveryOnly,
123
- getIntegrationConnectionById: options.getIntegrationConnectionById
124
- });
125
- });
126
- reply.code(200);
127
- return SLASH_COMMAND_ACK;
61
+ const result = await processor.process(createSlackStoredWebhookRequest({
62
+ routeId: 'slack.command',
63
+ body,
64
+ headers: request.headers,
65
+ rawQueryString: rawQueryString(request)
66
+ }, new Date().toISOString()));
67
+ return sendSlackCommandResponse(reply, result);
128
68
  }
129
69
  });
130
70
  return [
@@ -151,17 +91,78 @@ export function createSlackWebhookRoutes(options) {
151
91
  ];
152
92
  }
153
93
  function rawRequestBody(body) {
154
- return Buffer.isBuffer(body) ? body.toString('utf8') : undefined;
94
+ return body instanceof Uint8Array ? body : undefined;
155
95
  }
156
- function hasValidSlackSignature(headers, rawBody) {
157
- const signature = headers['x-slack-signature'];
158
- const timestamp = headers['x-slack-request-timestamp'];
159
- return verifySlackSignature({
160
- signingSecret: config.SLACK_SIGNING_SECRET,
161
- signature: typeof signature === 'string' ? signature : undefined,
162
- timestamp: typeof timestamp === 'string' ? timestamp : undefined,
163
- rawBody
164
- });
96
+ function rawQueryString(request) {
97
+ return request.raw.url?.split('?')[1] ?? '';
98
+ }
99
+ function createSlackStoredWebhookRequest(input, receivedAt) {
100
+ if (input.body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {
101
+ throw new ClientError('Webhook request body is too large', 'body-too-large', {
102
+ status: 413
103
+ });
104
+ }
105
+ try {
106
+ return createStoredWebhookRequest({
107
+ requestId: randomUUID(),
108
+ routeId: input.routeId,
109
+ receivedAt,
110
+ rawQueryString: input.rawQueryString,
111
+ headers: slackWebhookHeaders(input.headers),
112
+ body: input.body
113
+ });
114
+ } catch (error) {
115
+ throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {
116
+ cause: error
117
+ });
118
+ }
119
+ }
120
+ function slackWebhookHeaders(headers) {
121
+ return Object.fromEntries([
122
+ 'content-type',
123
+ 'x-slack-signature',
124
+ 'x-slack-request-timestamp'
125
+ ].flatMap((name)=>{
126
+ const value = headers[name];
127
+ return typeof value === 'string' ? [
128
+ [
129
+ name,
130
+ value
131
+ ]
132
+ ] : [];
133
+ }));
134
+ }
135
+ function sendSlackEventResponse(reply, result) {
136
+ if (result.outcome === 'processed' && 'challenge' in result) {
137
+ reply.code(200);
138
+ return {
139
+ challenge: result.challenge
140
+ };
141
+ }
142
+ if (result.outcome === 'discarded' && (result.reason === 'invalid_signature' || result.reason === 'missing_required_input' || result.reason === 'stale_at_receipt')) {
143
+ reply.code(401);
144
+ return {
145
+ error: 'invalid signature'
146
+ };
147
+ }
148
+ if (result.outcome === 'discarded' && result.reason === 'malformed_payload') {
149
+ reply.code(400);
150
+ return {
151
+ error: 'malformed JSON'
152
+ };
153
+ }
154
+ reply.code(200);
155
+ return null;
156
+ }
157
+ function sendSlackCommandResponse(reply, result) {
158
+ if (result.outcome === 'discarded' && (result.reason === 'invalid_signature' || result.reason === 'missing_required_input' || result.reason === 'stale_at_receipt')) {
159
+ reply.code(401);
160
+ return {
161
+ error: 'invalid signature'
162
+ };
163
+ }
164
+ reply.code(200);
165
+ return SLASH_COMMAND_ACK;
165
166
  }
166
167
 
167
168
  //# sourceMappingURL=webhooks.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/presentation/routes/webhooks.ts"],"sourcesContent":["import {Buffer} from 'node:buffer';\nimport type {\n ClaimWebhookDeliveryFn,\n GetIntegrationConnectionByIdFn,\n PublishIntegrationEventReceivedFn,\n RecordDeliveryOnlyFn,\n} from '@shipfox/api-integration-core-dto';\nimport {\n slackEventsRequestSchema,\n slackSlashCommandSchema,\n} from '@shipfox/api-integration-slack-dto';\nimport {createRawBodyPlugin, defineRoute, type RouteGroup} from '@shipfox/node-fastify';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport type {NodePgDatabase} from 'drizzle-orm/node-postgres';\nimport {config} from '#config.js';\nimport {verifySlackSignature} from '#core/signature.js';\nimport {handleSlackCommand, handleSlackEvent} from '#core/webhook.js';\n\nexport const SLACK_WEBHOOK_BODY_LIMIT = 1024 * 1024;\nexport const SLASH_COMMAND_ACK = {response_type: 'ephemeral', text: 'Working on it.'} as const;\n\nconst slackJsonRawBodyPlugin = createRawBodyPlugin({\n contentType: 'application/json',\n bodyLimit: SLACK_WEBHOOK_BODY_LIMIT,\n});\nconst slackFormRawBodyPlugin = createRawBodyPlugin({\n contentType: 'application/x-www-form-urlencoded',\n bodyLimit: SLACK_WEBHOOK_BODY_LIMIT,\n});\n\nexport interface CreateSlackWebhookRoutesOptions {\n coreDb: () => NodePgDatabase<Record<string, unknown>>;\n claimWebhookDelivery: ClaimWebhookDeliveryFn;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n getIntegrationConnectionById: GetIntegrationConnectionByIdFn;\n}\n\nexport function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOptions): RouteGroup[] {\n const eventsRoute = defineRoute({\n method: 'POST',\n path: '/',\n auth: [],\n description: 'Slack Events API receiver.',\n options: {bodyLimit: SLACK_WEBHOOK_BODY_LIMIT},\n handler: async (request, reply) => {\n const rawBody = rawRequestBody(request.body);\n if (rawBody === undefined) {\n reply.code(400);\n return {error: 'expected raw JSON body'};\n }\n if (!hasValidSlackSignature(request.headers, rawBody)) {\n reply.code(401);\n return {error: 'invalid signature'};\n }\n\n let parsedJson: unknown;\n try {\n parsedJson = JSON.parse(rawBody);\n } catch (error) {\n logger().warn(\n {errorName: error instanceof Error ? error.name : 'unknown'},\n 'slack events payload JSON parse failed',\n );\n reply.code(400);\n return {error: 'malformed JSON'};\n }\n\n const parsed = slackEventsRequestSchema.safeParse(parsedJson);\n if (!parsed.success) {\n logger().warn(\n {issues: parsed.error.issues},\n 'slack events envelope failed schema validation',\n );\n reply.code(200);\n return null;\n }\n const eventRequest = parsed.data;\n if (eventRequest.type === 'url_verification') {\n reply.code(200);\n return {challenge: eventRequest.challenge};\n }\n\n await options.coreDb().transaction(async (tx) => {\n await handleSlackEvent({\n tx,\n deliveryId: eventRequest.event_id,\n envelope: eventRequest,\n claimWebhookDelivery: options.claimWebhookDelivery,\n publishIntegrationEventReceived: options.publishIntegrationEventReceived,\n recordDeliveryOnly: options.recordDeliveryOnly,\n getIntegrationConnectionById: options.getIntegrationConnectionById,\n });\n });\n reply.code(200);\n return null;\n },\n });\n\n const commandsRoute = defineRoute({\n method: 'POST',\n path: '/',\n auth: [],\n description: 'Slack slash-command receiver.',\n options: {bodyLimit: SLACK_WEBHOOK_BODY_LIMIT},\n handler: async (request, reply) => {\n const rawBody = rawRequestBody(request.body);\n if (rawBody === undefined) {\n reply.code(400);\n return {error: 'expected raw form body'};\n }\n if (!hasValidSlackSignature(request.headers, rawBody)) {\n reply.code(401);\n return {error: 'invalid signature'};\n }\n\n const command = slackSlashCommandSchema.safeParse(\n Object.fromEntries(new URLSearchParams(rawBody)),\n );\n if (!command.success) {\n logger().warn({issues: command.error.issues}, 'slack command failed schema validation');\n reply.code(200);\n return SLASH_COMMAND_ACK;\n }\n\n await options.coreDb().transaction(async (tx) => {\n await 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 reply.code(200);\n return SLASH_COMMAND_ACK;\n },\n });\n\n return [\n {\n prefix: '/webhooks/integrations/slack/events',\n auth: [],\n plugins: [slackJsonRawBodyPlugin],\n routes: [eventsRoute],\n },\n {\n prefix: '/webhooks/integrations/slack/commands',\n auth: [],\n plugins: [slackFormRawBodyPlugin],\n routes: [commandsRoute],\n },\n ];\n}\n\nfunction rawRequestBody(body: unknown): string | undefined {\n return Buffer.isBuffer(body) ? body.toString('utf8') : undefined;\n}\n\nfunction hasValidSlackSignature(\n headers: Record<string, string | string[] | undefined>,\n rawBody: string,\n): boolean {\n const signature = headers['x-slack-signature'];\n const timestamp = headers['x-slack-request-timestamp'];\n return verifySlackSignature({\n signingSecret: config.SLACK_SIGNING_SECRET,\n signature: typeof signature === 'string' ? signature : undefined,\n timestamp: typeof timestamp === 'string' ? timestamp : undefined,\n rawBody,\n });\n}\n"],"names":["Buffer","slackEventsRequestSchema","slackSlashCommandSchema","createRawBodyPlugin","defineRoute","logger","config","verifySlackSignature","handleSlackCommand","handleSlackEvent","SLACK_WEBHOOK_BODY_LIMIT","SLASH_COMMAND_ACK","response_type","text","slackJsonRawBodyPlugin","contentType","bodyLimit","slackFormRawBodyPlugin","createSlackWebhookRoutes","options","eventsRoute","method","path","auth","description","handler","request","reply","rawBody","rawRequestBody","body","undefined","code","error","hasValidSlackSignature","headers","parsedJson","JSON","parse","warn","errorName","Error","name","parsed","safeParse","success","issues","eventRequest","data","type","challenge","coreDb","transaction","tx","deliveryId","event_id","envelope","claimWebhookDelivery","publishIntegrationEventReceived","recordDeliveryOnly","getIntegrationConnectionById","commandsRoute","command","Object","fromEntries","URLSearchParams","trigger_id","prefix","plugins","routes","isBuffer","toString","signature","timestamp","signingSecret","SLACK_SIGNING_SECRET"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../src/presentation/routes/webhooks.ts"],"sourcesContent":["import {randomUUID} from 'node:crypto';\nimport type {\n ClaimWebhookDeliveryFn,\n GetIntegrationConnectionByIdFn,\n PublishIntegrationEventReceivedFn,\n RecordDeliveryOnlyFn,\n StoredWebhookRequest,\n WebhookProcessingResult,\n} from '@shipfox/api-integration-core-dto';\nimport {\n createStoredWebhookRequest,\n WEBHOOK_MAX_RAW_BODY_BYTES,\n} from '@shipfox/api-integration-core-dto';\nimport {\n ClientError,\n createRawBodyPlugin,\n defineRoute,\n type RouteGroup,\n} from '@shipfox/node-fastify';\nimport type {NodePgDatabase} from 'drizzle-orm/node-postgres';\nimport {createSlackWebhookProcessor, type SlackWebhookProcessor} from '#core/webhook-processor.js';\n\nexport const SLACK_WEBHOOK_BODY_LIMIT = 1024 * 1024;\nexport const SLASH_COMMAND_ACK = {response_type: 'ephemeral', text: 'Working on it.'} as const;\n\nconst slackJsonRawBodyPlugin = createRawBodyPlugin({\n contentType: 'application/json',\n bodyLimit: SLACK_WEBHOOK_BODY_LIMIT,\n});\nconst slackFormRawBodyPlugin = createRawBodyPlugin({\n contentType: 'application/x-www-form-urlencoded',\n bodyLimit: SLACK_WEBHOOK_BODY_LIMIT,\n});\n\nexport interface CreateSlackWebhookRoutesOptions {\n coreDb: () => NodePgDatabase<Record<string, unknown>>;\n claimWebhookDelivery: ClaimWebhookDeliveryFn;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n getIntegrationConnectionById: GetIntegrationConnectionByIdFn;\n processor?: SlackWebhookProcessor | undefined;\n}\n\nexport function createSlackWebhookRoutes(options: CreateSlackWebhookRoutesOptions): RouteGroup[] {\n const processor = options.processor ?? createSlackWebhookProcessor(options);\n const eventsRoute = defineRoute({\n method: 'POST',\n path: '/',\n auth: [],\n description: 'Slack Events API receiver.',\n options: {bodyLimit: SLACK_WEBHOOK_BODY_LIMIT},\n handler: async (request, reply) => {\n const body = rawRequestBody(request.body);\n if (!body) {\n reply.code(400);\n return {error: 'expected raw JSON body'};\n }\n const result = await processor.process(\n createSlackStoredWebhookRequest(\n {\n routeId: 'slack.event',\n body,\n headers: request.headers,\n rawQueryString: rawQueryString(request),\n },\n new Date().toISOString(),\n ),\n );\n return sendSlackEventResponse(reply, result);\n },\n });\n const commandsRoute = defineRoute({\n method: 'POST',\n path: '/',\n auth: [],\n description: 'Slack slash-command receiver.',\n options: {bodyLimit: SLACK_WEBHOOK_BODY_LIMIT},\n handler: async (request, reply) => {\n const body = rawRequestBody(request.body);\n if (!body) {\n reply.code(400);\n return {error: 'expected raw form body'};\n }\n const result = await processor.process(\n createSlackStoredWebhookRequest(\n {\n routeId: 'slack.command',\n body,\n headers: request.headers,\n rawQueryString: rawQueryString(request),\n },\n new Date().toISOString(),\n ),\n );\n return sendSlackCommandResponse(reply, result);\n },\n });\n\n return [\n {\n prefix: '/webhooks/integrations/slack/events',\n auth: [],\n plugins: [slackJsonRawBodyPlugin],\n routes: [eventsRoute],\n },\n {\n prefix: '/webhooks/integrations/slack/commands',\n auth: [],\n plugins: [slackFormRawBodyPlugin],\n routes: [commandsRoute],\n },\n ];\n}\n\nfunction rawRequestBody(body: unknown): Uint8Array | undefined {\n return body instanceof Uint8Array ? body : undefined;\n}\n\nfunction rawQueryString(request: {raw: {url?: string | undefined}}): string {\n return request.raw.url?.split('?')[1] ?? '';\n}\n\nfunction createSlackStoredWebhookRequest(\n input: {\n routeId: 'slack.event' | 'slack.command';\n body: Uint8Array;\n headers: Record<string, string | string[] | undefined>;\n rawQueryString: string;\n },\n receivedAt: string,\n): StoredWebhookRequest {\n if (input.body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {\n throw new ClientError('Webhook request body is too large', 'body-too-large', {status: 413});\n }\n try {\n return createStoredWebhookRequest({\n requestId: randomUUID(),\n routeId: input.routeId,\n receivedAt,\n rawQueryString: input.rawQueryString,\n headers: slackWebhookHeaders(input.headers),\n body: input.body,\n });\n } catch (error) {\n throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {\n cause: error,\n });\n }\n}\n\nfunction slackWebhookHeaders(headers: Record<string, string | string[] | undefined>) {\n return Object.fromEntries(\n ['content-type', 'x-slack-signature', 'x-slack-request-timestamp'].flatMap((name) => {\n const value = headers[name];\n return typeof value === 'string' ? [[name, value]] : [];\n }),\n );\n}\n\nfunction sendSlackEventResponse(\n reply: {code(statusCode: number): void},\n result: WebhookProcessingResult,\n) {\n if (result.outcome === 'processed' && 'challenge' in result) {\n reply.code(200);\n return {challenge: result.challenge};\n }\n if (\n result.outcome === 'discarded' &&\n (result.reason === 'invalid_signature' ||\n result.reason === 'missing_required_input' ||\n result.reason === 'stale_at_receipt')\n ) {\n reply.code(401);\n return {error: 'invalid signature'};\n }\n if (result.outcome === 'discarded' && result.reason === 'malformed_payload') {\n reply.code(400);\n return {error: 'malformed JSON'};\n }\n reply.code(200);\n return null;\n}\n\nfunction sendSlackCommandResponse(\n reply: {code(statusCode: number): void},\n result: WebhookProcessingResult,\n) {\n if (\n result.outcome === 'discarded' &&\n (result.reason === 'invalid_signature' ||\n result.reason === 'missing_required_input' ||\n result.reason === 'stale_at_receipt')\n ) {\n reply.code(401);\n return {error: 'invalid signature'};\n }\n reply.code(200);\n return SLASH_COMMAND_ACK;\n}\n"],"names":["randomUUID","createStoredWebhookRequest","WEBHOOK_MAX_RAW_BODY_BYTES","ClientError","createRawBodyPlugin","defineRoute","createSlackWebhookProcessor","SLACK_WEBHOOK_BODY_LIMIT","SLASH_COMMAND_ACK","response_type","text","slackJsonRawBodyPlugin","contentType","bodyLimit","slackFormRawBodyPlugin","createSlackWebhookRoutes","options","processor","eventsRoute","method","path","auth","description","handler","request","reply","body","rawRequestBody","code","error","result","process","createSlackStoredWebhookRequest","routeId","headers","rawQueryString","Date","toISOString","sendSlackEventResponse","commandsRoute","sendSlackCommandResponse","prefix","plugins","routes","Uint8Array","undefined","raw","url","split","input","receivedAt","byteLength","status","requestId","slackWebhookHeaders","cause","Object","fromEntries","flatMap","name","value","outcome","challenge","reason"],"mappings":"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"}