@shipfox/api-integration-jira 11.0.0 → 12.1.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 +17 -2
- package/CHANGELOG.md +36 -0
- package/README.md +58 -2
- package/dist/api/client.d.ts +34 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +131 -0
- package/dist/api/client.js.map +1 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -4
- package/dist/config.js.map +1 -1
- package/dist/core/agent-tools-provider.d.ts +26 -0
- package/dist/core/agent-tools-provider.d.ts.map +1 -0
- package/dist/core/agent-tools-provider.js +130 -0
- package/dist/core/agent-tools-provider.js.map +1 -0
- package/dist/core/agent-tools.d.ts +16 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +441 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/install.d.ts +22 -0
- package/dist/core/install.d.ts.map +1 -1
- package/dist/core/install.js +68 -20
- package/dist/core/install.js.map +1 -1
- package/dist/core/tokens.d.ts +2 -0
- package/dist/core/tokens.d.ts.map +1 -1
- package/dist/core/tokens.js +129 -37
- package/dist/core/tokens.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 +137 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/core/webhook-registration.d.ts +26 -0
- package/dist/core/webhook-registration.d.ts.map +1 -0
- package/dist/core/webhook-registration.js +165 -0
- package/dist/core/webhook-registration.js.map +1 -0
- package/dist/core/webhook.d.ts +18 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +38 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +68 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +33 -6
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +79 -7
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +34 -0
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +12 -2
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +34 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +76 -8
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/install.d.ts +22 -0
- package/dist/presentation/routes/install.d.ts.map +1 -1
- package/dist/presentation/routes/install.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +8 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +108 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +2 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +3 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
- package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
- package/dist/temporal/constants.d.ts +6 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +8 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/worker.d.ts +9 -0
- package/dist/temporal/worker.d.ts.map +1 -0
- package/dist/temporal/worker.js +26 -0
- package/dist/temporal/worker.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +25225 -0
- package/dist/temporal/workflows/index.bundle.meta.json +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_jira-token-refresh-state.sql +12 -0
- package/drizzle/meta/0001_snapshot.json +245 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +17 -8
- package/src/api/client.test.ts +236 -9
- package/src/api/client.ts +192 -0
- package/src/config.test.ts +0 -1
- package/src/config.ts +1 -4
- package/src/core/agent-tools-provider.test.ts +299 -0
- package/src/core/agent-tools-provider.ts +175 -0
- package/src/core/agent-tools.ts +450 -0
- package/src/core/install.test.ts +179 -0
- package/src/core/install.ts +85 -19
- package/src/core/tokens-refresh.test.ts +223 -4
- package/src/core/tokens.test.ts +1 -1
- package/src/core/tokens.ts +153 -33
- package/src/core/webhook-processor.test.ts +418 -0
- package/src/core/webhook-processor.ts +166 -0
- package/src/core/webhook-registration.test.ts +375 -0
- package/src/core/webhook-registration.ts +216 -0
- package/src/core/webhook.ts +69 -0
- package/src/db/installations.test.ts +148 -2
- package/src/db/installations.ts +133 -7
- package/src/db/schema/installations.ts +21 -1
- package/src/index.test.ts +42 -0
- package/src/index.ts +148 -5
- package/src/presentation/routes/install.ts +12 -0
- package/src/presentation/routes/webhooks.test.ts +132 -0
- package/src/presentation/routes/webhooks.ts +115 -0
- package/src/temporal/activities/index.ts +5 -0
- package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
- package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
- package/src/temporal/constants.ts +8 -0
- package/src/temporal/worker.ts +39 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
- package/test/env.ts +0 -1
- package/test/factories/jira-installation.ts +4 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/dist/core/tokens.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/core/tokens.ts"],"sourcesContent":["import {createJiraApiClient, type JiraApiClient} from '#api/client.js';\nimport {\n JiraAccessTokenMissingError,\n JiraConnectionNotFoundError,\n JiraIntegrationProviderError,\n JiraTokenUnrefreshableError,\n} from '#core/errors.js';\nimport {\n getJiraInstallationByConnectionId,\n updateJiraInstallationTokenExpiry,\n withJiraRefreshLock,\n} from '#db/installations.js';\n\nconst ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';\nconst REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';\nconst TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;\nconst tokenRefreshes = new Map<string, Promise<string>>();\n\nexport interface JiraConnectionResolverResult {\n workspaceId: string;\n}\n\nexport interface JiraSecretsStore {\n getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;\n setSecrets(params: {\n workspaceId: string;\n namespace: string;\n values: Record<string, string>;\n editedBy?: string | null | undefined;\n }): Promise<void>;\n}\n\nexport interface CreateJiraTokenStoreParams {\n resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult | undefined>;\n secrets: JiraSecretsStore;\n client?: JiraApiClient | undefined;\n markConnectionError?: (params: {connectionId: string}) => Promise<void>;\n}\n\nexport interface StoreJiraTokensParams {\n connectionId: string;\n accessToken: string;\n refreshToken?: string | undefined;\n editedBy?: string | null | undefined;\n}\n\nexport interface GetJiraAccessTokenParams {\n connectionId: string;\n forceRefresh?: boolean | undefined;\n}\n\nexport interface JiraTokenStore {\n storeTokens(params: StoreJiraTokensParams): Promise<void>;\n getAccessToken(params: GetJiraAccessTokenParams): Promise<string>;\n}\n\nexport function jiraSecretsNamespace(connectionId: string): string {\n return `system/integrations/jira/${connectionId}`;\n}\n\nexport function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTokenStore {\n const client = params.client ?? createJiraApiClient();\n async function resolveWorkspaceId(connectionId: string): Promise<string> {\n const connection = await params.resolveConnection(connectionId);\n if (!connection) throw new JiraConnectionNotFoundError(connectionId);\n return connection.workspaceId;\n }\n const readSecretToken = (\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ) => params.secrets.getSecret({workspaceId, namespace: jiraSecretsNamespace(connectionId), key});\n async function readAccessToken(workspaceId: string, connectionId: string): Promise<string> {\n const token = await readSecretToken(workspaceId, connectionId, ACCESS_TOKEN_KEY);\n if (!token) throw new JiraAccessTokenMissingError(connectionId);\n return token;\n }\n\n return {\n async storeTokens(input) {\n const workspaceId = await resolveWorkspaceId(input.connectionId);\n const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};\n if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;\n await params.secrets.setSecrets({\n workspaceId,\n namespace: jiraSecretsNamespace(input.connectionId),\n values,\n editedBy: input.editedBy,\n });\n },\n async getAccessToken(input) {\n const workspaceId = await resolveWorkspaceId(input.connectionId);\n const accessToken = await readAccessToken(workspaceId, input.connectionId);\n if (\n !input.forceRefresh &&\n !shouldRefresh(\n (await getJiraInstallationByConnectionId(input.connectionId))?.tokenExpiresAt ?? null,\n )\n )\n return accessToken;\n const inFlight = tokenRefreshes.get(input.connectionId);\n if (inFlight) return inFlight;\n const refresh = refreshAccessTokenWithLock({\n connectionId: input.connectionId,\n workspaceId,\n originalAccessToken: accessToken,\n forceRefresh: input.forceRefresh === true,\n client,\n secrets: params.secrets,\n readAccessToken,\n readSecretToken,\n markConnectionError: params.markConnectionError,\n });\n tokenRefreshes.set(input.connectionId, refresh);\n void refresh.then(\n () => clearTokenRefresh(input.connectionId, refresh),\n () => clearTokenRefresh(input.connectionId, refresh),\n );\n return refresh;\n },\n };\n}\n\nfunction shouldRefresh(expiresAt: Date | null): boolean {\n return expiresAt !== null && expiresAt.getTime() <= Date.now() + TOKEN_REFRESH_MARGIN_MS;\n}\n\nfunction clearTokenRefresh(connectionId: string, refresh: Promise<string>): void {\n if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);\n}\n\nasync function refreshAccessTokenWithLock(params: {\n connectionId: string;\n workspaceId: string;\n originalAccessToken: string;\n forceRefresh: boolean;\n client: JiraApiClient;\n secrets: JiraSecretsStore;\n readAccessToken(workspaceId: string, connectionId: string): Promise<string>;\n readSecretToken(\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ): Promise<string | null>;\n markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;\n}): Promise<string> {\n const lock = await withJiraRefreshLock(params.connectionId, () =>\n refreshAccessTokenForConnection(params),\n );\n if (lock.acquired) return lock.value;\n const reread = await params.readAccessToken(params.workspaceId, params.connectionId);\n if (reread !== params.originalAccessToken) return reread;\n throw new JiraIntegrationProviderError(\n 'provider-unavailable',\n 'Jira token refresh is already in progress',\n );\n}\n\nasync function refreshAccessTokenForConnection(params: {\n connectionId: string;\n workspaceId: string;\n originalAccessToken: string;\n forceRefresh: boolean;\n client: JiraApiClient;\n secrets: JiraSecretsStore;\n readAccessToken(workspaceId: string, connectionId: string): Promise<string>;\n readSecretToken(\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ): Promise<string | null>;\n markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;\n}): Promise<string> {\n const current = await params.readAccessToken(params.workspaceId, params.connectionId);\n const installation = await getJiraInstallationByConnectionId(params.connectionId);\n if (\n current !== params.originalAccessToken ||\n (!params.forceRefresh && !shouldRefresh(installation?.tokenExpiresAt ?? null))\n )\n return current;\n const refreshToken = await params.readSecretToken(\n params.workspaceId,\n params.connectionId,\n REFRESH_TOKEN_KEY,\n );\n if (!refreshToken) {\n if (params.forceRefresh || shouldRefresh(installation?.tokenExpiresAt ?? null))\n throw new JiraTokenUnrefreshableError(params.connectionId);\n return current;\n }\n try {\n const refreshed = await params.client.refreshAccessToken({refreshToken});\n if (!refreshed.refreshToken) throw new JiraTokenUnrefreshableError(params.connectionId);\n await params.secrets.setSecrets({\n workspaceId: params.workspaceId,\n namespace: jiraSecretsNamespace(params.connectionId),\n values: {\n [ACCESS_TOKEN_KEY]: refreshed.accessToken,\n [REFRESH_TOKEN_KEY]: refreshed.refreshToken,\n },\n });\n await updateJiraInstallationTokenExpiry({\n connectionId: params.connectionId,\n tokenExpiresAt: refreshed.expiresAt ?? null,\n scopes: refreshed.scopes.length > 0 ? refreshed.scopes : undefined,\n });\n return refreshed.accessToken;\n } catch (error) {\n if (error instanceof JiraIntegrationProviderError && error.reason === 'access-denied') {\n await params.markConnectionError?.({connectionId: params.connectionId});\n }\n throw error;\n }\n}\n"],"names":["createJiraApiClient","JiraAccessTokenMissingError","JiraConnectionNotFoundError","JiraIntegrationProviderError","JiraTokenUnrefreshableError","getJiraInstallationByConnectionId","updateJiraInstallationTokenExpiry","withJiraRefreshLock","ACCESS_TOKEN_KEY","REFRESH_TOKEN_KEY","TOKEN_REFRESH_MARGIN_MS","tokenRefreshes","Map","jiraSecretsNamespace","connectionId","createJiraTokenStore","params","client","resolveWorkspaceId","connection","resolveConnection","workspaceId","readSecretToken","key","secrets","getSecret","namespace","readAccessToken","token","storeTokens","input","values","accessToken","refreshToken","setSecrets","editedBy","getAccessToken","forceRefresh","shouldRefresh","tokenExpiresAt","inFlight","get","refresh","refreshAccessTokenWithLock","originalAccessToken","markConnectionError","set","then","clearTokenRefresh","expiresAt","getTime","Date","now","delete","lock","refreshAccessTokenForConnection","acquired","value","reread","current","installation","refreshed","refreshAccessToken","scopes","length","undefined","error","reason"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/core/tokens.ts"],"sourcesContent":["import type {IntegrationConnectionLifecycleStatus} from '@shipfox/api-integration-spi';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport {createJiraApiClient, type JiraApiClient} from '#api/client.js';\nimport {\n JiraAccessTokenMissingError,\n JiraConnectionNotFoundError,\n JiraIntegrationProviderError,\n JiraTokenUnrefreshableError,\n} from '#core/errors.js';\nimport {\n getJiraInstallationByConnectionId,\n updateJiraInstallationTokenExpiry,\n withJiraRefreshLock,\n withJiraRefreshLockAndWait,\n} from '#db/installations.js';\n\nconst ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';\nconst REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';\nconst TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;\n\nexport interface JiraConnectionResolverResult {\n workspaceId: string;\n lifecycleStatus: IntegrationConnectionLifecycleStatus;\n}\n\nexport interface JiraSecretsStore {\n getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;\n setSecrets(params: {\n workspaceId: string;\n namespace: string;\n values: Record<string, string>;\n editedBy?: string | null | undefined;\n }): Promise<void>;\n}\n\nexport interface CreateJiraTokenStoreParams {\n resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult | undefined>;\n secrets: JiraSecretsStore;\n client?: JiraApiClient | undefined;\n markConnectionError?: (params: {connectionId: string}) => Promise<void>;\n}\n\nexport interface StoreJiraTokensParams {\n connectionId: string;\n accessToken: string;\n refreshToken?: string | undefined;\n editedBy?: string | null | undefined;\n}\n\nexport interface GetJiraAccessTokenParams {\n connectionId: string;\n forceRefresh?: boolean | undefined;\n}\n\nexport interface JiraTokenStore {\n storeTokens(params: StoreJiraTokensParams): Promise<void>;\n getAccessToken(params: GetJiraAccessTokenParams): Promise<string>;\n}\n\nexport function jiraSecretsNamespace(connectionId: string): string {\n return `system/integrations/jira/${connectionId}`;\n}\n\nexport function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTokenStore {\n const client = params.client ?? createJiraApiClient();\n const tokenRefreshes = new Map<string, Promise<string>>();\n const refreshStateUnknownConnections = new Set<string>();\n const credentialGenerations = new Map<string, number>();\n const credentialWriteQueues = new Map<string, Promise<void>>();\n const currentCredentialGeneration = (connectionId: string): number =>\n credentialGenerations.get(connectionId) ?? 0;\n const advanceCredentialGeneration = (connectionId: string): number => {\n const generation = currentCredentialGeneration(connectionId) + 1;\n credentialGenerations.set(connectionId, generation);\n return generation;\n };\n function withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T> {\n const previous = credentialWriteQueues.get(connectionId) ?? Promise.resolve();\n const operationResult = previous.then(operation);\n const completion = operationResult.then(\n () => undefined,\n () => undefined,\n );\n credentialWriteQueues.set(connectionId, completion);\n return operationResult.finally(() => {\n if (credentialWriteQueues.get(connectionId) === completion) {\n credentialWriteQueues.delete(connectionId);\n }\n });\n }\n async function waitForCredentialWrites(connectionId: string): Promise<void> {\n await credentialWriteQueues.get(connectionId);\n }\n async function resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult> {\n const connection = await params.resolveConnection(connectionId);\n if (!connection) throw new JiraConnectionNotFoundError(connectionId);\n return connection;\n }\n async function resolveWorkspaceId(connectionId: string): Promise<string> {\n return (await resolveConnection(connectionId)).workspaceId;\n }\n function clearTokenRefresh(connectionId: string, refresh: Promise<string>): void {\n if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);\n }\n const readSecretToken = (\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ) => params.secrets.getSecret({workspaceId, namespace: jiraSecretsNamespace(connectionId), key});\n async function readAccessToken(workspaceId: string, connectionId: string): Promise<string> {\n const token = await readSecretToken(workspaceId, connectionId, ACCESS_TOKEN_KEY);\n if (!token) throw new JiraAccessTokenMissingError(connectionId);\n return token;\n }\n\n return {\n async storeTokens(input) {\n const credentialGeneration = advanceCredentialGeneration(input.connectionId);\n tokenRefreshes.delete(input.connectionId);\n await withJiraRefreshLockAndWait(input.connectionId, () =>\n withCredentialWrite(input.connectionId, async () => {\n if (currentCredentialGeneration(input.connectionId) !== credentialGeneration) return;\n const workspaceId = await resolveWorkspaceId(input.connectionId);\n if (currentCredentialGeneration(input.connectionId) !== credentialGeneration) return;\n const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};\n if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;\n await params.secrets.setSecrets({\n workspaceId,\n namespace: jiraSecretsNamespace(input.connectionId),\n values,\n editedBy: input.editedBy,\n });\n if (currentCredentialGeneration(input.connectionId) === credentialGeneration) {\n refreshStateUnknownConnections.delete(input.connectionId);\n }\n }),\n );\n },\n async getAccessToken(input) {\n await waitForCredentialWrites(input.connectionId);\n const connection = await resolveConnection(input.connectionId);\n if (connection.lifecycleStatus !== 'active') {\n throw new JiraTokenUnrefreshableError(input.connectionId);\n }\n await waitForCredentialWrites(input.connectionId);\n if (refreshStateUnknownConnections.has(input.connectionId)) {\n throw new JiraTokenUnrefreshableError(input.connectionId);\n }\n const credentialGeneration = currentCredentialGeneration(input.connectionId);\n const workspaceId = connection.workspaceId;\n const accessToken = await readAccessToken(workspaceId, input.connectionId);\n if (\n !input.forceRefresh &&\n !shouldRefresh(\n (await getJiraInstallationByConnectionId(input.connectionId))?.tokenExpiresAt ?? null,\n )\n )\n return accessToken;\n const inFlight = tokenRefreshes.get(input.connectionId);\n if (inFlight) return inFlight;\n const refresh = refreshAccessTokenWithLock({\n connectionId: input.connectionId,\n workspaceId,\n originalAccessToken: accessToken,\n forceRefresh: input.forceRefresh === true,\n client,\n secrets: params.secrets,\n readAccessToken,\n readSecretToken,\n markConnectionError: params.markConnectionError,\n withCredentialWrite,\n waitForCredentialWrites,\n isCurrentCredentialGeneration: () =>\n currentCredentialGeneration(input.connectionId) === credentialGeneration,\n });\n tokenRefreshes.set(input.connectionId, refresh);\n void refresh.then(\n () => clearTokenRefresh(input.connectionId, refresh),\n (error) => {\n if (\n isTerminalRefreshFailure(error) &&\n currentCredentialGeneration(input.connectionId) === credentialGeneration\n ) {\n refreshStateUnknownConnections.add(input.connectionId);\n }\n clearTokenRefresh(input.connectionId, refresh);\n },\n );\n return refresh;\n },\n };\n}\n\nfunction shouldRefresh(expiresAt: Date | null): boolean {\n return expiresAt !== null && expiresAt.getTime() <= Date.now() + TOKEN_REFRESH_MARGIN_MS;\n}\n\nasync function refreshAccessTokenWithLock(params: {\n connectionId: string;\n workspaceId: string;\n originalAccessToken: string;\n forceRefresh: boolean;\n client: JiraApiClient;\n secrets: JiraSecretsStore;\n readAccessToken(workspaceId: string, connectionId: string): Promise<string>;\n readSecretToken(\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ): Promise<string | null>;\n markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;\n withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T>;\n waitForCredentialWrites(connectionId: string): Promise<void>;\n isCurrentCredentialGeneration(): boolean;\n}): Promise<string> {\n const lock = await withJiraRefreshLock(params.connectionId, () =>\n refreshAccessTokenForConnection(params),\n );\n if (lock.acquired) return lock.value;\n if (!params.isCurrentCredentialGeneration()) {\n return params.readAccessToken(params.workspaceId, params.connectionId);\n }\n const reread = await params.readAccessToken(params.workspaceId, params.connectionId);\n if (reread !== params.originalAccessToken) return reread;\n throw new JiraIntegrationProviderError(\n 'provider-unavailable',\n 'Jira token refresh is already in progress',\n );\n}\n\nasync function refreshAccessTokenForConnection(params: {\n connectionId: string;\n workspaceId: string;\n originalAccessToken: string;\n forceRefresh: boolean;\n client: JiraApiClient;\n secrets: JiraSecretsStore;\n readAccessToken(workspaceId: string, connectionId: string): Promise<string>;\n readSecretToken(\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ): Promise<string | null>;\n markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;\n withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T>;\n waitForCredentialWrites(connectionId: string): Promise<void>;\n isCurrentCredentialGeneration(): boolean;\n}): Promise<string> {\n if (!params.isCurrentCredentialGeneration()) {\n return params.readAccessToken(params.workspaceId, params.connectionId);\n }\n const current = await params.readAccessToken(params.workspaceId, params.connectionId);\n const installation = await getJiraInstallationByConnectionId(params.connectionId);\n if (\n current !== params.originalAccessToken ||\n (!params.forceRefresh && !shouldRefresh(installation?.tokenExpiresAt ?? null))\n )\n return current;\n const refreshToken = await params.readSecretToken(\n params.workspaceId,\n params.connectionId,\n REFRESH_TOKEN_KEY,\n );\n if (!refreshToken) {\n if (params.forceRefresh || shouldRefresh(installation?.tokenExpiresAt ?? null))\n throw new JiraTokenUnrefreshableError(params.connectionId);\n return current;\n }\n try {\n const refreshed = await params.client.refreshAccessToken({refreshToken});\n const refreshedRefreshToken = refreshed.refreshToken;\n if (!refreshedRefreshToken) throw new JiraTokenUnrefreshableError(params.connectionId);\n const persisted = await params.withCredentialWrite(params.connectionId, async () => {\n if (!params.isCurrentCredentialGeneration()) return false;\n await params.secrets.setSecrets({\n workspaceId: params.workspaceId,\n namespace: jiraSecretsNamespace(params.connectionId),\n values: {\n [ACCESS_TOKEN_KEY]: refreshed.accessToken,\n [REFRESH_TOKEN_KEY]: refreshedRefreshToken,\n },\n });\n if (!params.isCurrentCredentialGeneration()) return false;\n await updateJiraInstallationTokenExpiry({\n connectionId: params.connectionId,\n tokenExpiresAt: refreshed.expiresAt ?? null,\n scopes: refreshed.scopes.length > 0 ? refreshed.scopes : undefined,\n });\n return true;\n });\n if (!persisted || !params.isCurrentCredentialGeneration()) {\n await params.waitForCredentialWrites(params.connectionId);\n return params.readAccessToken(params.workspaceId, params.connectionId);\n }\n return refreshed.accessToken;\n } catch (error) {\n if (isTerminalRefreshFailure(error)) {\n await markConnectionError({\n callback: params.markConnectionError,\n connectionId: params.connectionId,\n isCurrentCredentialGeneration: params.isCurrentCredentialGeneration,\n withCredentialWrite: params.withCredentialWrite,\n });\n }\n throw error;\n }\n}\n\nfunction isTerminalRefreshFailure(error: unknown): boolean {\n return (\n error instanceof JiraIntegrationProviderError &&\n (error.reason === 'access-denied' || error.reason === 'timeout')\n );\n}\n\nasync function markConnectionError(params: {\n callback: CreateJiraTokenStoreParams['markConnectionError'];\n connectionId: string;\n isCurrentCredentialGeneration(): boolean;\n withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T>;\n}): Promise<void> {\n if (!params.isCurrentCredentialGeneration()) return;\n try {\n await params.withCredentialWrite(params.connectionId, async () => {\n if (!params.isCurrentCredentialGeneration()) return;\n await params.callback?.({connectionId: params.connectionId});\n });\n } catch (error) {\n logger().warn(\n {err: error, connectionId: params.connectionId},\n 'Jira connection error-state update failed after token refresh failure',\n );\n }\n}\n"],"names":["logger","createJiraApiClient","JiraAccessTokenMissingError","JiraConnectionNotFoundError","JiraIntegrationProviderError","JiraTokenUnrefreshableError","getJiraInstallationByConnectionId","updateJiraInstallationTokenExpiry","withJiraRefreshLock","withJiraRefreshLockAndWait","ACCESS_TOKEN_KEY","REFRESH_TOKEN_KEY","TOKEN_REFRESH_MARGIN_MS","jiraSecretsNamespace","connectionId","createJiraTokenStore","params","client","tokenRefreshes","Map","refreshStateUnknownConnections","Set","credentialGenerations","credentialWriteQueues","currentCredentialGeneration","get","advanceCredentialGeneration","generation","set","withCredentialWrite","operation","previous","Promise","resolve","operationResult","then","completion","undefined","finally","delete","waitForCredentialWrites","resolveConnection","connection","resolveWorkspaceId","workspaceId","clearTokenRefresh","refresh","readSecretToken","key","secrets","getSecret","namespace","readAccessToken","token","storeTokens","input","credentialGeneration","values","accessToken","refreshToken","setSecrets","editedBy","getAccessToken","lifecycleStatus","has","forceRefresh","shouldRefresh","tokenExpiresAt","inFlight","refreshAccessTokenWithLock","originalAccessToken","markConnectionError","isCurrentCredentialGeneration","error","isTerminalRefreshFailure","add","expiresAt","getTime","Date","now","lock","refreshAccessTokenForConnection","acquired","value","reread","current","installation","refreshed","refreshAccessToken","refreshedRefreshToken","persisted","scopes","length","callback","reason","warn","err"],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type GetIntegrationConnectionByIdFn, type PublishIntegrationEventReceivedFn, type RecordDeliveryOnlyFn, type StoredWebhookRequest, type WebhookProcessingResult } from '@shipfox/api-integration-spi';
|
|
2
|
+
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
|
3
|
+
import { getJiraInstallationByConnectionId } from '#db/installations.js';
|
|
4
|
+
export interface CreateJiraWebhookProcessorOptions {
|
|
5
|
+
coreDb: () => NodePgDatabase<Record<string, unknown>>;
|
|
6
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
7
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
8
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
9
|
+
getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId;
|
|
10
|
+
}
|
|
11
|
+
export interface JiraWebhookProcessor {
|
|
12
|
+
process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;
|
|
13
|
+
}
|
|
14
|
+
export declare function createJiraWebhookProcessor(options: CreateJiraWebhookProcessorOptions): JiraWebhookProcessor;
|
|
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":"AAMA,OAAO,EAEL,KAAK,8BAA8B,EACnC,KAAK,iCAAiC,EACtC,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,uBAAuB,EAC7B,MAAM,8BAA8B,CAAC;AAItC,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAI9D,OAAO,EAAC,iCAAiC,EAAC,MAAM,sBAAsB,CAAC;AAOvE,MAAM,WAAW,iCAAiC;IAChD,MAAM,EAAE,MAAM,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,+BAA+B,EAAE,iCAAiC,CAAC;IACnE,kBAAkB,EAAE,oBAAoB,CAAC;IACzC,4BAA4B,EAAE,8BAA8B,CAAC;IAC7D,iCAAiC,CAAC,EAAE,OAAO,iCAAiC,CAAC;CAC9E;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;CAC1E;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iCAAiC,GACzC,oBAAoB,CAEtB"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { jiraWebhookEnvelopeSchema } from '@shipfox/api-integration-jira-dto';
|
|
4
|
+
import { decodeWebhookBody } from '@shipfox/api-integration-spi';
|
|
5
|
+
import { extractBearerToken } from '@shipfox/node-fastify';
|
|
6
|
+
import { verifyHs256 } from '@shipfox/node-jwt';
|
|
7
|
+
import { logger } from '@shipfox/node-opentelemetry';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { config } from '#config.js';
|
|
10
|
+
import { handleJiraWebhook, isJiraInstallationUsable } from '#core/webhook.js';
|
|
11
|
+
import { getJiraInstallationByConnectionId } from '#db/installations.js';
|
|
12
|
+
const JIRA_PROVIDER = 'jira';
|
|
13
|
+
const jiraWebhookJwtClaimsSchema = z.object({
|
|
14
|
+
iat: z.number().int(),
|
|
15
|
+
exp: z.number().int()
|
|
16
|
+
}).passthrough();
|
|
17
|
+
export function createJiraWebhookProcessor(options) {
|
|
18
|
+
return {
|
|
19
|
+
process: (request)=>processJiraWebhookRequest(options, request)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function processJiraWebhookRequest(options, request) {
|
|
23
|
+
if (request.route_id !== 'jira') {
|
|
24
|
+
throw new Error(`Jira processor cannot process ${request.route_id} requests`);
|
|
25
|
+
}
|
|
26
|
+
const rawBody = Buffer.from(decodeWebhookBody(request.body));
|
|
27
|
+
const deliveryId = jiraDeliveryId(request, rawBody);
|
|
28
|
+
const authorization = request.headers.authorization;
|
|
29
|
+
if (!authorization) return invalidAuthorization(deliveryId);
|
|
30
|
+
const token = extractBearerToken(authorization);
|
|
31
|
+
if (!token) return invalidAuthorization(deliveryId);
|
|
32
|
+
try {
|
|
33
|
+
await verifyHs256({
|
|
34
|
+
token,
|
|
35
|
+
secret: config.JIRA_OAUTH_CLIENT_SECRET,
|
|
36
|
+
schema: jiraWebhookJwtClaimsSchema,
|
|
37
|
+
verificationTime: new Date(request.received_at)
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
logger().warn({
|
|
41
|
+
deliveryId,
|
|
42
|
+
errName: error instanceof Error ? error.name : typeof error
|
|
43
|
+
}, 'Jira webhook authorization verification failed');
|
|
44
|
+
return invalidAuthorization(deliveryId);
|
|
45
|
+
}
|
|
46
|
+
let rawPayload;
|
|
47
|
+
try {
|
|
48
|
+
rawPayload = JSON.parse(rawBody.toString('utf8'));
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger().warn({
|
|
51
|
+
deliveryId,
|
|
52
|
+
err: error
|
|
53
|
+
}, 'Jira webhook payload JSON parse failed');
|
|
54
|
+
return {
|
|
55
|
+
outcome: 'discarded',
|
|
56
|
+
reason: 'malformed_payload',
|
|
57
|
+
deliveryId
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const payload = jiraWebhookEnvelopeSchema.safeParse(rawPayload);
|
|
61
|
+
if (!payload.success) {
|
|
62
|
+
await recordDeliveryOnly(options, deliveryId);
|
|
63
|
+
return {
|
|
64
|
+
outcome: 'discarded',
|
|
65
|
+
reason: 'unsupported_event',
|
|
66
|
+
deliveryId
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const connectionId = request.path_parameters.connection_id;
|
|
70
|
+
const connection = await options.getIntegrationConnectionById(connectionId);
|
|
71
|
+
if (!connection || connection.provider !== JIRA_PROVIDER || connection.lifecycleStatus !== 'active') {
|
|
72
|
+
return await discardUnavailableConnection(options, deliveryId);
|
|
73
|
+
}
|
|
74
|
+
const installation = await (options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId)(connectionId);
|
|
75
|
+
if (!isJiraInstallationUsable(installation) || !hasMatchingWebhookId(payload.data, installation.webhookIds)) {
|
|
76
|
+
return await discardUnavailableConnection(options, deliveryId);
|
|
77
|
+
}
|
|
78
|
+
const result = await options.coreDb().transaction(async (tx)=>handleJiraWebhook({
|
|
79
|
+
tx,
|
|
80
|
+
deliveryId,
|
|
81
|
+
receivedAt: request.received_at,
|
|
82
|
+
rawPayload: payload.data,
|
|
83
|
+
cloudId: installation.cloudId,
|
|
84
|
+
connection: connection,
|
|
85
|
+
authorizingAccountId: installation.authorizingAccountId,
|
|
86
|
+
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
87
|
+
recordDeliveryOnly: options.recordDeliveryOnly
|
|
88
|
+
}));
|
|
89
|
+
if (result === 'duplicate') return {
|
|
90
|
+
outcome: 'duplicate',
|
|
91
|
+
deliveryId
|
|
92
|
+
};
|
|
93
|
+
if (result === 'discarded') return {
|
|
94
|
+
outcome: 'discarded',
|
|
95
|
+
reason: 'unsupported_event',
|
|
96
|
+
deliveryId
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
outcome: 'processed',
|
|
100
|
+
deliveryId
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function invalidAuthorization(deliveryId) {
|
|
104
|
+
return {
|
|
105
|
+
outcome: 'discarded',
|
|
106
|
+
reason: 'invalid_signature',
|
|
107
|
+
deliveryId
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function jiraDeliveryId(request, rawBody) {
|
|
111
|
+
const connectionId = request.path_parameters.connection_id;
|
|
112
|
+
const jiraIdentifier = request.headers['x-atlassian-webhook-identifier'];
|
|
113
|
+
if (jiraIdentifier) return `${connectionId}:${jiraIdentifier}`;
|
|
114
|
+
return createHash('sha256').update(connectionId).update('\0').update(rawBody).digest('hex');
|
|
115
|
+
}
|
|
116
|
+
async function recordDeliveryOnly(options, deliveryId) {
|
|
117
|
+
await options.coreDb().transaction(async (tx)=>{
|
|
118
|
+
await options.recordDeliveryOnly({
|
|
119
|
+
tx,
|
|
120
|
+
provider: JIRA_PROVIDER,
|
|
121
|
+
deliveryId
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async function discardUnavailableConnection(options, deliveryId) {
|
|
126
|
+
await recordDeliveryOnly(options, deliveryId);
|
|
127
|
+
return {
|
|
128
|
+
outcome: 'discarded',
|
|
129
|
+
reason: 'connection_unavailable',
|
|
130
|
+
deliveryId
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function hasMatchingWebhookId(payload, storedWebhookIds) {
|
|
134
|
+
return payload.matchedWebhookIds?.some((webhookId)=>storedWebhookIds.includes(webhookId)) === true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
//# sourceMappingURL=webhook-processor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/webhook-processor.ts"],"sourcesContent":["import {Buffer} from 'node:buffer';\nimport {createHash} from 'node:crypto';\nimport {\n type JiraWebhookEnvelopeDto,\n jiraWebhookEnvelopeSchema,\n} from '@shipfox/api-integration-jira-dto';\nimport {\n decodeWebhookBody,\n type GetIntegrationConnectionByIdFn,\n type PublishIntegrationEventReceivedFn,\n type RecordDeliveryOnlyFn,\n type StoredWebhookRequest,\n type WebhookProcessingResult,\n} from '@shipfox/api-integration-spi';\nimport {extractBearerToken} from '@shipfox/node-fastify';\nimport {verifyHs256} from '@shipfox/node-jwt';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport type {NodePgDatabase} from 'drizzle-orm/node-postgres';\nimport {z} from 'zod';\nimport {config} from '#config.js';\nimport {handleJiraWebhook, isJiraInstallationUsable} from '#core/webhook.js';\nimport {getJiraInstallationByConnectionId} from '#db/installations.js';\n\nconst JIRA_PROVIDER = 'jira';\nconst jiraWebhookJwtClaimsSchema = z\n .object({iat: z.number().int(), exp: z.number().int()})\n .passthrough();\n\nexport interface CreateJiraWebhookProcessorOptions {\n coreDb: () => NodePgDatabase<Record<string, unknown>>;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n getIntegrationConnectionById: GetIntegrationConnectionByIdFn;\n getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId;\n}\n\nexport interface JiraWebhookProcessor {\n process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;\n}\n\nexport function createJiraWebhookProcessor(\n options: CreateJiraWebhookProcessorOptions,\n): JiraWebhookProcessor {\n return {process: (request) => processJiraWebhookRequest(options, request)};\n}\n\nasync function processJiraWebhookRequest(\n options: CreateJiraWebhookProcessorOptions,\n request: StoredWebhookRequest,\n): Promise<WebhookProcessingResult> {\n if (request.route_id !== 'jira') {\n throw new Error(`Jira processor cannot process ${request.route_id} requests`);\n }\n\n const rawBody = Buffer.from(decodeWebhookBody(request.body));\n const deliveryId = jiraDeliveryId(request, rawBody);\n const authorization = request.headers.authorization;\n if (!authorization) return invalidAuthorization(deliveryId);\n\n const token = extractBearerToken(authorization);\n if (!token) return invalidAuthorization(deliveryId);\n\n try {\n await verifyHs256({\n token,\n secret: config.JIRA_OAUTH_CLIENT_SECRET,\n schema: jiraWebhookJwtClaimsSchema,\n verificationTime: new Date(request.received_at),\n });\n } catch (error) {\n logger().warn(\n {deliveryId, errName: error instanceof Error ? error.name : typeof error},\n 'Jira webhook authorization verification failed',\n );\n return invalidAuthorization(deliveryId);\n }\n\n let rawPayload: unknown;\n try {\n rawPayload = JSON.parse(rawBody.toString('utf8'));\n } catch (error) {\n logger().warn({deliveryId, err: error}, 'Jira webhook payload JSON parse failed');\n return {outcome: 'discarded', reason: 'malformed_payload', deliveryId};\n }\n\n const payload = jiraWebhookEnvelopeSchema.safeParse(rawPayload);\n if (!payload.success) {\n await recordDeliveryOnly(options, deliveryId);\n return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};\n }\n\n const connectionId = request.path_parameters.connection_id;\n const connection = await options.getIntegrationConnectionById(connectionId);\n if (\n !connection ||\n connection.provider !== JIRA_PROVIDER ||\n connection.lifecycleStatus !== 'active'\n ) {\n return await discardUnavailableConnection(options, deliveryId);\n }\n const installation = await (\n options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId\n )(connectionId);\n if (\n !isJiraInstallationUsable(installation) ||\n !hasMatchingWebhookId(payload.data, installation.webhookIds)\n ) {\n return await discardUnavailableConnection(options, deliveryId);\n }\n\n const result = await options.coreDb().transaction(async (tx) =>\n handleJiraWebhook({\n tx,\n deliveryId,\n receivedAt: request.received_at,\n rawPayload: payload.data,\n cloudId: installation.cloudId,\n connection: connection as typeof connection & {provider: 'jira'},\n authorizingAccountId: installation.authorizingAccountId,\n publishIntegrationEventReceived: options.publishIntegrationEventReceived,\n recordDeliveryOnly: options.recordDeliveryOnly,\n }),\n );\n\n if (result === 'duplicate') return {outcome: 'duplicate', deliveryId};\n if (result === 'discarded')\n return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};\n return {outcome: 'processed', deliveryId};\n}\n\nfunction invalidAuthorization(deliveryId: string): WebhookProcessingResult {\n return {outcome: 'discarded', reason: 'invalid_signature', deliveryId};\n}\n\nfunction jiraDeliveryId(request: StoredWebhookRequest, rawBody: Uint8Array): string {\n const connectionId = request.path_parameters.connection_id;\n const jiraIdentifier = request.headers['x-atlassian-webhook-identifier'];\n if (jiraIdentifier) return `${connectionId}:${jiraIdentifier}`;\n return createHash('sha256').update(connectionId).update('\\0').update(rawBody).digest('hex');\n}\n\nasync function recordDeliveryOnly(\n options: Pick<CreateJiraWebhookProcessorOptions, 'coreDb' | 'recordDeliveryOnly'>,\n deliveryId: string,\n): Promise<void> {\n await options.coreDb().transaction(async (tx) => {\n await options.recordDeliveryOnly({tx, provider: JIRA_PROVIDER, deliveryId});\n });\n}\n\nasync function discardUnavailableConnection(\n options: Pick<CreateJiraWebhookProcessorOptions, 'coreDb' | 'recordDeliveryOnly'>,\n deliveryId: string,\n): Promise<WebhookProcessingResult> {\n await recordDeliveryOnly(options, deliveryId);\n return {outcome: 'discarded', reason: 'connection_unavailable', deliveryId};\n}\n\nfunction hasMatchingWebhookId(\n payload: JiraWebhookEnvelopeDto,\n storedWebhookIds: number[],\n): boolean {\n return (\n payload.matchedWebhookIds?.some((webhookId) => storedWebhookIds.includes(webhookId)) === true\n );\n}\n"],"names":["Buffer","createHash","jiraWebhookEnvelopeSchema","decodeWebhookBody","extractBearerToken","verifyHs256","logger","z","config","handleJiraWebhook","isJiraInstallationUsable","getJiraInstallationByConnectionId","JIRA_PROVIDER","jiraWebhookJwtClaimsSchema","object","iat","number","int","exp","passthrough","createJiraWebhookProcessor","options","process","request","processJiraWebhookRequest","route_id","Error","rawBody","from","body","deliveryId","jiraDeliveryId","authorization","headers","invalidAuthorization","token","secret","JIRA_OAUTH_CLIENT_SECRET","schema","verificationTime","Date","received_at","error","warn","errName","name","rawPayload","JSON","parse","toString","err","outcome","reason","payload","safeParse","success","recordDeliveryOnly","connectionId","path_parameters","connection_id","connection","getIntegrationConnectionById","provider","lifecycleStatus","discardUnavailableConnection","installation","hasMatchingWebhookId","data","webhookIds","result","coreDb","transaction","tx","receivedAt","cloudId","authorizingAccountId","publishIntegrationEventReceived","jiraIdentifier","update","digest","storedWebhookIds","matchedWebhookIds","some","webhookId","includes"],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { JiraApiClient } from '#api/client.js';
|
|
2
|
+
import { getJiraInstallationByConnectionId, type JiraInstallationLock, updateJiraInstallationWebhook } from '#db/installations.js';
|
|
3
|
+
export declare const JIRA_WEBHOOK_TTL_MS: number;
|
|
4
|
+
export interface RegisterJiraWebhookParams {
|
|
5
|
+
jira: Pick<JiraApiClient, 'registerDynamicWebhook' | 'deleteDynamicWebhook'>;
|
|
6
|
+
connectionId: string;
|
|
7
|
+
cloudId: string;
|
|
8
|
+
accessToken: string;
|
|
9
|
+
webhookUrl: string;
|
|
10
|
+
now?: () => Date;
|
|
11
|
+
getInstallation?: typeof getJiraInstallationByConnectionId;
|
|
12
|
+
updateInstallation?: typeof updateJiraInstallationWebhook;
|
|
13
|
+
withRegistrationLock?: JiraInstallationLock;
|
|
14
|
+
onRegistrationSuccess?: (input: {
|
|
15
|
+
tx?: unknown;
|
|
16
|
+
}) => Promise<void>;
|
|
17
|
+
onRegistrationFailure?: (input: {
|
|
18
|
+
tx?: unknown;
|
|
19
|
+
}) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/** Register Jira's curated webhook and replace local metadata as one serialized state transition. */
|
|
22
|
+
export declare function registerJiraWebhook(params: RegisterJiraWebhookParams): Promise<{
|
|
23
|
+
webhookId: number;
|
|
24
|
+
webhookExpiresAt: Date;
|
|
25
|
+
}>;
|
|
26
|
+
//# sourceMappingURL=webhook-registration.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-registration.d.ts","sourceRoot":"","sources":["../../src/core/webhook-registration.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAClD,OAAO,EACL,iCAAiC,EACjC,KAAK,oBAAoB,EACzB,6BAA6B,EAE9B,MAAM,sBAAsB,CAAC;AAE9B,eAAO,MAAM,mBAAmB,QAA2B,CAAC;AAE5D,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,IAAI,CAAC,aAAa,EAAE,wBAAwB,GAAG,sBAAsB,CAAC,CAAC;IAC7E,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB,eAAe,CAAC,EAAE,OAAO,iCAAiC,CAAC;IAC3D,kBAAkB,CAAC,EAAE,OAAO,6BAA6B,CAAC;IAC1D,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,qBAAqB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAC,EAAE,CAAC,EAAE,OAAO,CAAA;KAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,qBAAqB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAC,EAAE,CAAC,EAAE,OAAO,CAAA;KAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClE;AAED,qGAAqG;AACrG,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,yBAAyB,GAChC,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,gBAAgB,EAAE,IAAI,CAAA;CAAC,CAAC,CA8FtD"}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { logger } from '@shipfox/node-opentelemetry';
|
|
2
|
+
import { getJiraInstallationByConnectionId, updateJiraInstallationWebhook, withJiraInstallationLock } from '#db/installations.js';
|
|
3
|
+
export const JIRA_WEBHOOK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
4
|
+
/** Register Jira's curated webhook and replace local metadata as one serialized state transition. */ export async function registerJiraWebhook(params) {
|
|
5
|
+
const withRegistrationLock = params.withRegistrationLock ?? withJiraInstallationLock;
|
|
6
|
+
let callbackFailed = false;
|
|
7
|
+
let registeredWebhookId;
|
|
8
|
+
let previous;
|
|
9
|
+
let metadataPersisted = false;
|
|
10
|
+
let cleanupSupersededWebhooks;
|
|
11
|
+
let result;
|
|
12
|
+
try {
|
|
13
|
+
result = await withRegistrationLock(params.cloudId, async ()=>{
|
|
14
|
+
try {
|
|
15
|
+
const getInstallation = params.getInstallation ?? getJiraInstallationByConnectionId;
|
|
16
|
+
previous = await getInstallation(params.connectionId);
|
|
17
|
+
const registration = await params.jira.registerDynamicWebhook({
|
|
18
|
+
accessToken: params.accessToken,
|
|
19
|
+
cloudId: params.cloudId,
|
|
20
|
+
url: params.webhookUrl
|
|
21
|
+
});
|
|
22
|
+
registeredWebhookId = registration.webhookId;
|
|
23
|
+
const now = params.now ?? (()=>new Date());
|
|
24
|
+
const webhookExpiresAt = new Date(now().getTime() + JIRA_WEBHOOK_TTL_MS);
|
|
25
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
26
|
+
const updateInput = {
|
|
27
|
+
connectionId: params.connectionId,
|
|
28
|
+
webhookIds: [
|
|
29
|
+
...new Set([
|
|
30
|
+
registration.webhookId,
|
|
31
|
+
...previous?.webhookIds ?? []
|
|
32
|
+
])
|
|
33
|
+
],
|
|
34
|
+
webhookExpiresAt
|
|
35
|
+
};
|
|
36
|
+
const installation = await updateInstallation(updateInput);
|
|
37
|
+
if (!installation) throw new Error('Jira webhook registration lost its installation record');
|
|
38
|
+
metadataPersisted = true;
|
|
39
|
+
await params.onRegistrationSuccess?.({});
|
|
40
|
+
cleanupSupersededWebhooks = ()=>finishSupersededWebhookCleanup(params, previous, registration.webhookId, webhookExpiresAt);
|
|
41
|
+
return {
|
|
42
|
+
webhookId: registration.webhookId,
|
|
43
|
+
webhookExpiresAt
|
|
44
|
+
};
|
|
45
|
+
} catch (error) {
|
|
46
|
+
callbackFailed = true;
|
|
47
|
+
if (registeredWebhookId !== undefined) {
|
|
48
|
+
const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);
|
|
49
|
+
if (metadataPersisted || !deleted) {
|
|
50
|
+
await restorePreviousWebhookMetadata(params, previous, deleted ? undefined : registeredWebhookId);
|
|
51
|
+
}
|
|
52
|
+
} else if (metadataPersisted) {
|
|
53
|
+
await restorePreviousWebhookMetadata(params, previous);
|
|
54
|
+
}
|
|
55
|
+
await bestEffortRegistrationFailure(params);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (!callbackFailed) {
|
|
61
|
+
if (registeredWebhookId !== undefined) {
|
|
62
|
+
const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);
|
|
63
|
+
if (metadataPersisted || !deleted) {
|
|
64
|
+
await restorePreviousWebhookMetadata(params, previous, deleted ? undefined : registeredWebhookId);
|
|
65
|
+
}
|
|
66
|
+
} else if (metadataPersisted) {
|
|
67
|
+
await restorePreviousWebhookMetadata(params, previous);
|
|
68
|
+
}
|
|
69
|
+
await bestEffortRegistrationFailure(params);
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
if (cleanupSupersededWebhooks) {
|
|
74
|
+
try {
|
|
75
|
+
await withRegistrationLock(params.cloudId, cleanupSupersededWebhooks);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
logger().warn({
|
|
78
|
+
err: error,
|
|
79
|
+
connectionId: params.connectionId
|
|
80
|
+
}, 'Jira superseded webhook cleanup persistence failed');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
async function finishSupersededWebhookCleanup(params, previous, registeredWebhookId, webhookExpiresAt) {
|
|
86
|
+
const supersededWebhookIds = (previous?.webhookIds ?? []).filter((webhookId)=>webhookId !== registeredWebhookId);
|
|
87
|
+
if (supersededWebhookIds.length === 0) return;
|
|
88
|
+
const failedCleanupIds = [];
|
|
89
|
+
for (const webhookId of supersededWebhookIds){
|
|
90
|
+
try {
|
|
91
|
+
await params.jira.deleteDynamicWebhook({
|
|
92
|
+
accessToken: params.accessToken,
|
|
93
|
+
cloudId: params.cloudId,
|
|
94
|
+
webhookId
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
failedCleanupIds.push(webhookId);
|
|
98
|
+
logger().warn({
|
|
99
|
+
err: error,
|
|
100
|
+
connectionId: params.connectionId,
|
|
101
|
+
webhookId
|
|
102
|
+
}, 'Jira superseded webhook cleanup failed');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const retainedWebhookIds = [
|
|
106
|
+
registeredWebhookId,
|
|
107
|
+
...failedCleanupIds
|
|
108
|
+
];
|
|
109
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
110
|
+
const cleanupMetadata = await updateInstallation({
|
|
111
|
+
connectionId: params.connectionId,
|
|
112
|
+
webhookIds: retainedWebhookIds,
|
|
113
|
+
webhookExpiresAt
|
|
114
|
+
});
|
|
115
|
+
if (!cleanupMetadata) throw new Error('Jira webhook cleanup metadata persistence returned no installation');
|
|
116
|
+
}
|
|
117
|
+
async function bestEffortRegistrationFailure(params) {
|
|
118
|
+
try {
|
|
119
|
+
await params.onRegistrationFailure?.({});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger().warn({
|
|
122
|
+
err: error,
|
|
123
|
+
connectionId: params.connectionId
|
|
124
|
+
}, 'Jira connection error-state update failed after webhook registration rejection');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function restorePreviousWebhookMetadata(params, previous, retainedWebhookId) {
|
|
128
|
+
try {
|
|
129
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
130
|
+
await updateInstallation({
|
|
131
|
+
connectionId: params.connectionId,
|
|
132
|
+
webhookIds: [
|
|
133
|
+
...retainedWebhookId === undefined ? [] : [
|
|
134
|
+
retainedWebhookId
|
|
135
|
+
],
|
|
136
|
+
...(previous?.webhookIds ?? []).filter((webhookId)=>webhookId !== retainedWebhookId)
|
|
137
|
+
],
|
|
138
|
+
webhookExpiresAt: previous?.webhookExpiresAt ?? null
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
logger().warn({
|
|
142
|
+
err: error,
|
|
143
|
+
connectionId: params.connectionId
|
|
144
|
+
}, 'Jira webhook metadata restoration failed after registration rejection');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function deleteNewWebhookAfterPersistenceFailure(params, webhookId) {
|
|
148
|
+
try {
|
|
149
|
+
await params.jira.deleteDynamicWebhook({
|
|
150
|
+
accessToken: params.accessToken,
|
|
151
|
+
cloudId: params.cloudId,
|
|
152
|
+
webhookId
|
|
153
|
+
});
|
|
154
|
+
return true;
|
|
155
|
+
} catch (cleanupError) {
|
|
156
|
+
logger().warn({
|
|
157
|
+
err: cleanupError,
|
|
158
|
+
connectionId: params.connectionId,
|
|
159
|
+
webhookId
|
|
160
|
+
}, 'Jira webhook cleanup failed after metadata persistence rejection');
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
//# sourceMappingURL=webhook-registration.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/webhook-registration.ts"],"sourcesContent":["import {logger} from '@shipfox/node-opentelemetry';\nimport type {JiraApiClient} from '#api/client.js';\nimport {\n getJiraInstallationByConnectionId,\n type JiraInstallationLock,\n updateJiraInstallationWebhook,\n withJiraInstallationLock,\n} from '#db/installations.js';\n\nexport const JIRA_WEBHOOK_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\nexport interface RegisterJiraWebhookParams {\n jira: Pick<JiraApiClient, 'registerDynamicWebhook' | 'deleteDynamicWebhook'>;\n connectionId: string;\n cloudId: string;\n accessToken: string;\n webhookUrl: string;\n now?: () => Date;\n getInstallation?: typeof getJiraInstallationByConnectionId;\n updateInstallation?: typeof updateJiraInstallationWebhook;\n withRegistrationLock?: JiraInstallationLock;\n onRegistrationSuccess?: (input: {tx?: unknown}) => Promise<void>;\n onRegistrationFailure?: (input: {tx?: unknown}) => Promise<void>;\n}\n\n/** Register Jira's curated webhook and replace local metadata as one serialized state transition. */\nexport async function registerJiraWebhook(\n params: RegisterJiraWebhookParams,\n): Promise<{webhookId: number; webhookExpiresAt: Date}> {\n const withRegistrationLock = params.withRegistrationLock ?? withJiraInstallationLock;\n let callbackFailed = false;\n let registeredWebhookId: number | undefined;\n let previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>;\n let metadataPersisted = false;\n let cleanupSupersededWebhooks: (() => Promise<void>) | undefined;\n let result: {webhookId: number; webhookExpiresAt: Date};\n try {\n result = await withRegistrationLock(params.cloudId, async () => {\n try {\n const getInstallation = params.getInstallation ?? getJiraInstallationByConnectionId;\n previous = await getInstallation(params.connectionId);\n const registration = await params.jira.registerDynamicWebhook({\n accessToken: params.accessToken,\n cloudId: params.cloudId,\n url: params.webhookUrl,\n });\n registeredWebhookId = registration.webhookId;\n const now = params.now ?? (() => new Date());\n const webhookExpiresAt = new Date(now().getTime() + JIRA_WEBHOOK_TTL_MS);\n\n const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;\n const updateInput = {\n connectionId: params.connectionId,\n webhookIds: [...new Set([registration.webhookId, ...(previous?.webhookIds ?? [])])],\n webhookExpiresAt,\n };\n const installation = await updateInstallation(updateInput);\n if (!installation)\n throw new Error('Jira webhook registration lost its installation record');\n metadataPersisted = true;\n await params.onRegistrationSuccess?.({});\n\n cleanupSupersededWebhooks = () =>\n finishSupersededWebhookCleanup(\n params,\n previous,\n registration.webhookId,\n webhookExpiresAt,\n );\n return {webhookId: registration.webhookId, webhookExpiresAt};\n } catch (error) {\n callbackFailed = true;\n if (registeredWebhookId !== undefined) {\n const deleted = await deleteNewWebhookAfterPersistenceFailure(\n params,\n registeredWebhookId,\n );\n if (metadataPersisted || !deleted) {\n await restorePreviousWebhookMetadata(\n params,\n previous,\n deleted ? undefined : registeredWebhookId,\n );\n }\n } else if (metadataPersisted) {\n await restorePreviousWebhookMetadata(params, previous);\n }\n await bestEffortRegistrationFailure(params);\n throw error;\n }\n });\n } catch (error) {\n if (!callbackFailed) {\n if (registeredWebhookId !== undefined) {\n const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);\n if (metadataPersisted || !deleted) {\n await restorePreviousWebhookMetadata(\n params,\n previous,\n deleted ? undefined : registeredWebhookId,\n );\n }\n } else if (metadataPersisted) {\n await restorePreviousWebhookMetadata(params, previous);\n }\n await bestEffortRegistrationFailure(params);\n }\n throw error;\n }\n\n if (cleanupSupersededWebhooks) {\n try {\n await withRegistrationLock(params.cloudId, cleanupSupersededWebhooks);\n } catch (error) {\n logger().warn(\n {err: error, connectionId: params.connectionId},\n 'Jira superseded webhook cleanup persistence failed',\n );\n }\n }\n\n return result;\n}\n\nasync function finishSupersededWebhookCleanup(\n params: RegisterJiraWebhookParams,\n previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,\n registeredWebhookId: number,\n webhookExpiresAt: Date,\n): Promise<void> {\n const supersededWebhookIds = (previous?.webhookIds ?? []).filter(\n (webhookId) => webhookId !== registeredWebhookId,\n );\n if (supersededWebhookIds.length === 0) return;\n\n const failedCleanupIds: number[] = [];\n for (const webhookId of supersededWebhookIds) {\n try {\n await params.jira.deleteDynamicWebhook({\n accessToken: params.accessToken,\n cloudId: params.cloudId,\n webhookId,\n });\n } catch (error) {\n failedCleanupIds.push(webhookId);\n logger().warn(\n {err: error, connectionId: params.connectionId, webhookId},\n 'Jira superseded webhook cleanup failed',\n );\n }\n }\n\n const retainedWebhookIds = [registeredWebhookId, ...failedCleanupIds];\n const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;\n const cleanupMetadata = await updateInstallation({\n connectionId: params.connectionId,\n webhookIds: retainedWebhookIds,\n webhookExpiresAt,\n });\n if (!cleanupMetadata)\n throw new Error('Jira webhook cleanup metadata persistence returned no installation');\n}\n\nasync function bestEffortRegistrationFailure(params: RegisterJiraWebhookParams): Promise<void> {\n try {\n await params.onRegistrationFailure?.({});\n } catch (error) {\n logger().warn(\n {err: error, connectionId: params.connectionId},\n 'Jira connection error-state update failed after webhook registration rejection',\n );\n }\n}\n\nasync function restorePreviousWebhookMetadata(\n params: RegisterJiraWebhookParams,\n previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,\n retainedWebhookId?: number,\n): Promise<void> {\n try {\n const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;\n await updateInstallation({\n connectionId: params.connectionId,\n webhookIds: [\n ...(retainedWebhookId === undefined ? [] : [retainedWebhookId]),\n ...(previous?.webhookIds ?? []).filter((webhookId) => webhookId !== retainedWebhookId),\n ],\n webhookExpiresAt: previous?.webhookExpiresAt ?? null,\n });\n } catch (error) {\n logger().warn(\n {err: error, connectionId: params.connectionId},\n 'Jira webhook metadata restoration failed after registration rejection',\n );\n }\n}\n\nasync function deleteNewWebhookAfterPersistenceFailure(\n params: RegisterJiraWebhookParams,\n webhookId: number,\n): Promise<boolean> {\n try {\n await params.jira.deleteDynamicWebhook({\n accessToken: params.accessToken,\n cloudId: params.cloudId,\n webhookId,\n });\n return true;\n } catch (cleanupError) {\n logger().warn(\n {err: cleanupError, connectionId: params.connectionId, webhookId},\n 'Jira webhook cleanup failed after metadata persistence rejection',\n );\n return false;\n }\n}\n"],"names":["logger","getJiraInstallationByConnectionId","updateJiraInstallationWebhook","withJiraInstallationLock","JIRA_WEBHOOK_TTL_MS","registerJiraWebhook","params","withRegistrationLock","callbackFailed","registeredWebhookId","previous","metadataPersisted","cleanupSupersededWebhooks","result","cloudId","getInstallation","connectionId","registration","jira","registerDynamicWebhook","accessToken","url","webhookUrl","webhookId","now","Date","webhookExpiresAt","getTime","updateInstallation","updateInput","webhookIds","Set","installation","Error","onRegistrationSuccess","finishSupersededWebhookCleanup","error","undefined","deleted","deleteNewWebhookAfterPersistenceFailure","restorePreviousWebhookMetadata","bestEffortRegistrationFailure","warn","err","supersededWebhookIds","filter","length","failedCleanupIds","deleteDynamicWebhook","push","retainedWebhookIds","cleanupMetadata","onRegistrationFailure","retainedWebhookId","cleanupError"],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { JiraWebhookEnvelopeDto } from '@shipfox/api-integration-jira-dto';
|
|
2
|
+
import type { IntegrationConnection, IntegrationTx, PublishIntegrationEventReceivedFn, RecordDeliveryOnlyFn } from '@shipfox/api-integration-spi';
|
|
3
|
+
import type { JiraInstallation } from '#db/installations.js';
|
|
4
|
+
export interface HandleJiraWebhookParams {
|
|
5
|
+
tx: IntegrationTx;
|
|
6
|
+
deliveryId: string;
|
|
7
|
+
receivedAt: string;
|
|
8
|
+
rawPayload: JiraWebhookEnvelopeDto;
|
|
9
|
+
cloudId: string;
|
|
10
|
+
connection: IntegrationConnection<'jira'>;
|
|
11
|
+
authorizingAccountId: string;
|
|
12
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
13
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
14
|
+
}
|
|
15
|
+
export type HandleJiraWebhookResult = 'published' | 'duplicate' | 'discarded';
|
|
16
|
+
export declare function handleJiraWebhook(params: HandleJiraWebhookParams): Promise<HandleJiraWebhookResult>;
|
|
17
|
+
export declare function isJiraInstallationUsable(installation: JiraInstallation | undefined): installation is JiraInstallation;
|
|
18
|
+
//# sourceMappingURL=webhook.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../../src/core/webhook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,mCAAmC,CAAC;AAC9E,OAAO,KAAK,EACV,qBAAqB,EACrB,aAAa,EACb,iCAAiC,EACjC,oBAAoB,EACrB,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,sBAAsB,CAAC;AAI3D,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,aAAa,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,sBAAsB,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAC1C,oBAAoB,EAAE,MAAM,CAAC;IAC7B,+BAA+B,EAAE,iCAAiC,CAAC;IACnE,kBAAkB,EAAE,oBAAoB,CAAC;CAC1C;AAED,MAAM,MAAM,uBAAuB,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE9E,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,uBAAuB,CAAC,CA0BlC;AAWD,wBAAgB,wBAAwB,CACtC,YAAY,EAAE,gBAAgB,GAAG,SAAS,GACzC,YAAY,IAAI,gBAAgB,CAElC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const JIRA_PROVIDER = 'jira';
|
|
2
|
+
export async function handleJiraWebhook(params) {
|
|
3
|
+
if (isSelfAuthoredEvent(params.rawPayload, params.authorizingAccountId)) {
|
|
4
|
+
await params.recordDeliveryOnly({
|
|
5
|
+
tx: params.tx,
|
|
6
|
+
provider: JIRA_PROVIDER,
|
|
7
|
+
deliveryId: params.deliveryId
|
|
8
|
+
});
|
|
9
|
+
return 'discarded';
|
|
10
|
+
}
|
|
11
|
+
const payload = {
|
|
12
|
+
...params.rawPayload,
|
|
13
|
+
cloudId: params.cloudId
|
|
14
|
+
};
|
|
15
|
+
const result = await params.publishIntegrationEventReceived({
|
|
16
|
+
tx: params.tx,
|
|
17
|
+
event: {
|
|
18
|
+
provider: JIRA_PROVIDER,
|
|
19
|
+
source: params.connection.slug,
|
|
20
|
+
event: params.rawPayload.webhookEvent,
|
|
21
|
+
workspaceId: params.connection.workspaceId,
|
|
22
|
+
connectionId: params.connection.id,
|
|
23
|
+
connectionName: params.connection.displayName,
|
|
24
|
+
deliveryId: params.deliveryId,
|
|
25
|
+
receivedAt: params.receivedAt,
|
|
26
|
+
payload
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return result.published ? 'published' : 'duplicate';
|
|
30
|
+
}
|
|
31
|
+
function isSelfAuthoredEvent(payload, authorizingAccountId) {
|
|
32
|
+
return (payload.webhookEvent === 'comment_created' || payload.webhookEvent === 'comment_updated' || payload.webhookEvent === 'jira:issue_updated') && payload.user.accountId === authorizingAccountId;
|
|
33
|
+
}
|
|
34
|
+
export function isJiraInstallationUsable(installation) {
|
|
35
|
+
return installation?.status === 'installed';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
//# sourceMappingURL=webhook.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/webhook.ts"],"sourcesContent":["import type {JiraWebhookEnvelopeDto} from '@shipfox/api-integration-jira-dto';\nimport type {\n IntegrationConnection,\n IntegrationTx,\n PublishIntegrationEventReceivedFn,\n RecordDeliveryOnlyFn,\n} from '@shipfox/api-integration-spi';\nimport type {JiraInstallation} from '#db/installations.js';\n\nconst JIRA_PROVIDER = 'jira';\n\nexport interface HandleJiraWebhookParams {\n tx: IntegrationTx;\n deliveryId: string;\n receivedAt: string;\n rawPayload: JiraWebhookEnvelopeDto;\n cloudId: string;\n connection: IntegrationConnection<'jira'>;\n authorizingAccountId: string;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n}\n\nexport type HandleJiraWebhookResult = 'published' | 'duplicate' | 'discarded';\n\nexport async function handleJiraWebhook(\n params: HandleJiraWebhookParams,\n): Promise<HandleJiraWebhookResult> {\n if (isSelfAuthoredEvent(params.rawPayload, params.authorizingAccountId)) {\n await params.recordDeliveryOnly({\n tx: params.tx,\n provider: JIRA_PROVIDER,\n deliveryId: params.deliveryId,\n });\n return 'discarded';\n }\n\n const payload = {...params.rawPayload, cloudId: params.cloudId};\n const result = await params.publishIntegrationEventReceived({\n tx: params.tx,\n event: {\n provider: JIRA_PROVIDER,\n source: params.connection.slug,\n event: params.rawPayload.webhookEvent,\n workspaceId: params.connection.workspaceId,\n connectionId: params.connection.id,\n connectionName: params.connection.displayName,\n deliveryId: params.deliveryId,\n receivedAt: params.receivedAt,\n payload,\n },\n });\n return result.published ? 'published' : 'duplicate';\n}\n\nfunction isSelfAuthoredEvent(payload: JiraWebhookEnvelopeDto, authorizingAccountId: string) {\n return (\n (payload.webhookEvent === 'comment_created' ||\n payload.webhookEvent === 'comment_updated' ||\n payload.webhookEvent === 'jira:issue_updated') &&\n payload.user.accountId === authorizingAccountId\n );\n}\n\nexport function isJiraInstallationUsable(\n installation: JiraInstallation | undefined,\n): installation is JiraInstallation {\n return installation?.status === 'installed';\n}\n"],"names":["JIRA_PROVIDER","handleJiraWebhook","params","isSelfAuthoredEvent","rawPayload","authorizingAccountId","recordDeliveryOnly","tx","provider","deliveryId","payload","cloudId","result","publishIntegrationEventReceived","event","source","connection","slug","webhookEvent","workspaceId","connectionId","id","connectionName","displayName","receivedAt","published","user","accountId","isJiraInstallationUsable","installation","status"],"mappings":"AASA,MAAMA,gBAAgB;AAgBtB,OAAO,eAAeC,kBACpBC,MAA+B;IAE/B,IAAIC,oBAAoBD,OAAOE,UAAU,EAAEF,OAAOG,oBAAoB,GAAG;QACvE,MAAMH,OAAOI,kBAAkB,CAAC;YAC9BC,IAAIL,OAAOK,EAAE;YACbC,UAAUR;YACVS,YAAYP,OAAOO,UAAU;QAC/B;QACA,OAAO;IACT;IAEA,MAAMC,UAAU;QAAC,GAAGR,OAAOE,UAAU;QAAEO,SAAST,OAAOS,OAAO;IAAA;IAC9D,MAAMC,SAAS,MAAMV,OAAOW,+BAA+B,CAAC;QAC1DN,IAAIL,OAAOK,EAAE;QACbO,OAAO;YACLN,UAAUR;YACVe,QAAQb,OAAOc,UAAU,CAACC,IAAI;YAC9BH,OAAOZ,OAAOE,UAAU,CAACc,YAAY;YACrCC,aAAajB,OAAOc,UAAU,CAACG,WAAW;YAC1CC,cAAclB,OAAOc,UAAU,CAACK,EAAE;YAClCC,gBAAgBpB,OAAOc,UAAU,CAACO,WAAW;YAC7Cd,YAAYP,OAAOO,UAAU;YAC7Be,YAAYtB,OAAOsB,UAAU;YAC7Bd;QACF;IACF;IACA,OAAOE,OAAOa,SAAS,GAAG,cAAc;AAC1C;AAEA,SAAStB,oBAAoBO,OAA+B,EAAEL,oBAA4B;IACxF,OACE,AAACK,CAAAA,QAAQQ,YAAY,KAAK,qBACxBR,QAAQQ,YAAY,KAAK,qBACzBR,QAAQQ,YAAY,KAAK,oBAAmB,KAC9CR,QAAQgB,IAAI,CAACC,SAAS,KAAKtB;AAE/B;AAEA,OAAO,SAASuB,yBACdC,YAA0C;IAE1C,OAAOA,cAAcC,WAAW;AAClC"}
|