@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.
Files changed (126) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +58 -2
  4. package/dist/api/client.d.ts +34 -0
  5. package/dist/api/client.d.ts.map +1 -1
  6. package/dist/api/client.js +131 -0
  7. package/dist/api/client.js.map +1 -1
  8. package/dist/config.d.ts +0 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +1 -4
  11. package/dist/config.js.map +1 -1
  12. package/dist/core/agent-tools-provider.d.ts +26 -0
  13. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  14. package/dist/core/agent-tools-provider.js +130 -0
  15. package/dist/core/agent-tools-provider.js.map +1 -0
  16. package/dist/core/agent-tools.d.ts +16 -0
  17. package/dist/core/agent-tools.d.ts.map +1 -0
  18. package/dist/core/agent-tools.js +441 -0
  19. package/dist/core/agent-tools.js.map +1 -0
  20. package/dist/core/install.d.ts +22 -0
  21. package/dist/core/install.d.ts.map +1 -1
  22. package/dist/core/install.js +68 -20
  23. package/dist/core/install.js.map +1 -1
  24. package/dist/core/tokens.d.ts +2 -0
  25. package/dist/core/tokens.d.ts.map +1 -1
  26. package/dist/core/tokens.js +129 -37
  27. package/dist/core/tokens.js.map +1 -1
  28. package/dist/core/webhook-processor.d.ts +15 -0
  29. package/dist/core/webhook-processor.d.ts.map +1 -0
  30. package/dist/core/webhook-processor.js +137 -0
  31. package/dist/core/webhook-processor.js.map +1 -0
  32. package/dist/core/webhook-registration.d.ts +26 -0
  33. package/dist/core/webhook-registration.d.ts.map +1 -0
  34. package/dist/core/webhook-registration.js +165 -0
  35. package/dist/core/webhook-registration.js.map +1 -0
  36. package/dist/core/webhook.d.ts +18 -0
  37. package/dist/core/webhook.d.ts.map +1 -0
  38. package/dist/core/webhook.js +38 -0
  39. package/dist/core/webhook.js.map +1 -0
  40. package/dist/db/db.d.ts +68 -0
  41. package/dist/db/db.d.ts.map +1 -1
  42. package/dist/db/installations.d.ts +33 -6
  43. package/dist/db/installations.d.ts.map +1 -1
  44. package/dist/db/installations.js +79 -7
  45. package/dist/db/installations.js.map +1 -1
  46. package/dist/db/schema/installations.d.ts +34 -0
  47. package/dist/db/schema/installations.d.ts.map +1 -1
  48. package/dist/db/schema/installations.js +12 -2
  49. package/dist/db/schema/installations.js.map +1 -1
  50. package/dist/index.d.ts +34 -7
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +76 -8
  53. package/dist/index.js.map +1 -1
  54. package/dist/presentation/routes/install.d.ts +22 -0
  55. package/dist/presentation/routes/install.d.ts.map +1 -1
  56. package/dist/presentation/routes/install.js.map +1 -1
  57. package/dist/presentation/routes/webhooks.d.ts +8 -0
  58. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  59. package/dist/presentation/routes/webhooks.js +108 -0
  60. package/dist/presentation/routes/webhooks.js.map +1 -0
  61. package/dist/temporal/activities/index.d.ts +2 -0
  62. package/dist/temporal/activities/index.d.ts.map +1 -0
  63. package/dist/temporal/activities/index.js +3 -0
  64. package/dist/temporal/activities/index.js.map +1 -0
  65. package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
  66. package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
  67. package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
  68. package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
  69. package/dist/temporal/constants.d.ts +6 -0
  70. package/dist/temporal/constants.d.ts.map +1 -0
  71. package/dist/temporal/constants.js +8 -0
  72. package/dist/temporal/constants.js.map +1 -0
  73. package/dist/temporal/worker.d.ts +9 -0
  74. package/dist/temporal/worker.d.ts.map +1 -0
  75. package/dist/temporal/worker.js +26 -0
  76. package/dist/temporal/worker.js.map +1 -0
  77. package/dist/temporal/workflows/index.bundle.js +25225 -0
  78. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  79. package/dist/temporal/workflows/index.d.ts +2 -0
  80. package/dist/temporal/workflows/index.d.ts.map +1 -0
  81. package/dist/temporal/workflows/index.js +3 -0
  82. package/dist/temporal/workflows/index.js.map +1 -0
  83. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
  84. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
  85. package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
  86. package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
  87. package/dist/tsconfig.test.tsbuildinfo +1 -1
  88. package/drizzle/0001_jira-token-refresh-state.sql +12 -0
  89. package/drizzle/meta/0001_snapshot.json +245 -0
  90. package/drizzle/meta/_journal.json +7 -0
  91. package/package.json +17 -8
  92. package/src/api/client.test.ts +236 -9
  93. package/src/api/client.ts +192 -0
  94. package/src/config.test.ts +0 -1
  95. package/src/config.ts +1 -4
  96. package/src/core/agent-tools-provider.test.ts +299 -0
  97. package/src/core/agent-tools-provider.ts +175 -0
  98. package/src/core/agent-tools.ts +450 -0
  99. package/src/core/install.test.ts +179 -0
  100. package/src/core/install.ts +85 -19
  101. package/src/core/tokens-refresh.test.ts +223 -4
  102. package/src/core/tokens.test.ts +1 -1
  103. package/src/core/tokens.ts +153 -33
  104. package/src/core/webhook-processor.test.ts +418 -0
  105. package/src/core/webhook-processor.ts +166 -0
  106. package/src/core/webhook-registration.test.ts +375 -0
  107. package/src/core/webhook-registration.ts +216 -0
  108. package/src/core/webhook.ts +69 -0
  109. package/src/db/installations.test.ts +148 -2
  110. package/src/db/installations.ts +133 -7
  111. package/src/db/schema/installations.ts +21 -1
  112. package/src/index.test.ts +42 -0
  113. package/src/index.ts +148 -5
  114. package/src/presentation/routes/install.ts +12 -0
  115. package/src/presentation/routes/webhooks.test.ts +132 -0
  116. package/src/presentation/routes/webhooks.ts +115 -0
  117. package/src/temporal/activities/index.ts +5 -0
  118. package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
  119. package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
  120. package/src/temporal/constants.ts +8 -0
  121. package/src/temporal/worker.ts +39 -0
  122. package/src/temporal/workflows/index.ts +1 -0
  123. package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
  124. package/test/env.ts +0 -1
  125. package/test/factories/jira-installation.ts +4 -0
  126. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,3 +1,5 @@
1
+ import type {IntegrationConnectionLifecycleStatus} from '@shipfox/api-integration-spi';
2
+ import {logger} from '@shipfox/node-opentelemetry';
1
3
  import {createJiraApiClient, type JiraApiClient} from '#api/client.js';
2
4
  import {
3
5
  JiraAccessTokenMissingError,
@@ -9,15 +11,16 @@ import {
9
11
  getJiraInstallationByConnectionId,
10
12
  updateJiraInstallationTokenExpiry,
11
13
  withJiraRefreshLock,
14
+ withJiraRefreshLockAndWait,
12
15
  } from '#db/installations.js';
13
16
 
14
17
  const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
15
18
  const REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';
16
19
  const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
17
- const tokenRefreshes = new Map<string, Promise<string>>();
18
20
 
19
21
  export interface JiraConnectionResolverResult {
20
22
  workspaceId: string;
23
+ lifecycleStatus: IntegrationConnectionLifecycleStatus;
21
24
  }
22
25
 
23
26
  export interface JiraSecretsStore {
@@ -60,10 +63,44 @@ export function jiraSecretsNamespace(connectionId: string): string {
60
63
 
61
64
  export function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTokenStore {
62
65
  const client = params.client ?? createJiraApiClient();
63
- async function resolveWorkspaceId(connectionId: string): Promise<string> {
66
+ const tokenRefreshes = new Map<string, Promise<string>>();
67
+ const refreshStateUnknownConnections = new Set<string>();
68
+ const credentialGenerations = new Map<string, number>();
69
+ const credentialWriteQueues = new Map<string, Promise<void>>();
70
+ const currentCredentialGeneration = (connectionId: string): number =>
71
+ credentialGenerations.get(connectionId) ?? 0;
72
+ const advanceCredentialGeneration = (connectionId: string): number => {
73
+ const generation = currentCredentialGeneration(connectionId) + 1;
74
+ credentialGenerations.set(connectionId, generation);
75
+ return generation;
76
+ };
77
+ function withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T> {
78
+ const previous = credentialWriteQueues.get(connectionId) ?? Promise.resolve();
79
+ const operationResult = previous.then(operation);
80
+ const completion = operationResult.then(
81
+ () => undefined,
82
+ () => undefined,
83
+ );
84
+ credentialWriteQueues.set(connectionId, completion);
85
+ return operationResult.finally(() => {
86
+ if (credentialWriteQueues.get(connectionId) === completion) {
87
+ credentialWriteQueues.delete(connectionId);
88
+ }
89
+ });
90
+ }
91
+ async function waitForCredentialWrites(connectionId: string): Promise<void> {
92
+ await credentialWriteQueues.get(connectionId);
93
+ }
94
+ async function resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult> {
64
95
  const connection = await params.resolveConnection(connectionId);
65
96
  if (!connection) throw new JiraConnectionNotFoundError(connectionId);
66
- return connection.workspaceId;
97
+ return connection;
98
+ }
99
+ async function resolveWorkspaceId(connectionId: string): Promise<string> {
100
+ return (await resolveConnection(connectionId)).workspaceId;
101
+ }
102
+ function clearTokenRefresh(connectionId: string, refresh: Promise<string>): void {
103
+ if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);
67
104
  }
68
105
  const readSecretToken = (
69
106
  workspaceId: string,
@@ -78,18 +115,39 @@ export function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTo
78
115
 
79
116
  return {
80
117
  async storeTokens(input) {
81
- const workspaceId = await resolveWorkspaceId(input.connectionId);
82
- const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};
83
- if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;
84
- await params.secrets.setSecrets({
85
- workspaceId,
86
- namespace: jiraSecretsNamespace(input.connectionId),
87
- values,
88
- editedBy: input.editedBy,
89
- });
118
+ const credentialGeneration = advanceCredentialGeneration(input.connectionId);
119
+ tokenRefreshes.delete(input.connectionId);
120
+ await withJiraRefreshLockAndWait(input.connectionId, () =>
121
+ withCredentialWrite(input.connectionId, async () => {
122
+ if (currentCredentialGeneration(input.connectionId) !== credentialGeneration) return;
123
+ const workspaceId = await resolveWorkspaceId(input.connectionId);
124
+ if (currentCredentialGeneration(input.connectionId) !== credentialGeneration) return;
125
+ const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};
126
+ if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;
127
+ await params.secrets.setSecrets({
128
+ workspaceId,
129
+ namespace: jiraSecretsNamespace(input.connectionId),
130
+ values,
131
+ editedBy: input.editedBy,
132
+ });
133
+ if (currentCredentialGeneration(input.connectionId) === credentialGeneration) {
134
+ refreshStateUnknownConnections.delete(input.connectionId);
135
+ }
136
+ }),
137
+ );
90
138
  },
91
139
  async getAccessToken(input) {
92
- const workspaceId = await resolveWorkspaceId(input.connectionId);
140
+ await waitForCredentialWrites(input.connectionId);
141
+ const connection = await resolveConnection(input.connectionId);
142
+ if (connection.lifecycleStatus !== 'active') {
143
+ throw new JiraTokenUnrefreshableError(input.connectionId);
144
+ }
145
+ await waitForCredentialWrites(input.connectionId);
146
+ if (refreshStateUnknownConnections.has(input.connectionId)) {
147
+ throw new JiraTokenUnrefreshableError(input.connectionId);
148
+ }
149
+ const credentialGeneration = currentCredentialGeneration(input.connectionId);
150
+ const workspaceId = connection.workspaceId;
93
151
  const accessToken = await readAccessToken(workspaceId, input.connectionId);
94
152
  if (
95
153
  !input.forceRefresh &&
@@ -110,11 +168,23 @@ export function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTo
110
168
  readAccessToken,
111
169
  readSecretToken,
112
170
  markConnectionError: params.markConnectionError,
171
+ withCredentialWrite,
172
+ waitForCredentialWrites,
173
+ isCurrentCredentialGeneration: () =>
174
+ currentCredentialGeneration(input.connectionId) === credentialGeneration,
113
175
  });
114
176
  tokenRefreshes.set(input.connectionId, refresh);
115
177
  void refresh.then(
116
178
  () => clearTokenRefresh(input.connectionId, refresh),
117
- () => clearTokenRefresh(input.connectionId, refresh),
179
+ (error) => {
180
+ if (
181
+ isTerminalRefreshFailure(error) &&
182
+ currentCredentialGeneration(input.connectionId) === credentialGeneration
183
+ ) {
184
+ refreshStateUnknownConnections.add(input.connectionId);
185
+ }
186
+ clearTokenRefresh(input.connectionId, refresh);
187
+ },
118
188
  );
119
189
  return refresh;
120
190
  },
@@ -125,10 +195,6 @@ function shouldRefresh(expiresAt: Date | null): boolean {
125
195
  return expiresAt !== null && expiresAt.getTime() <= Date.now() + TOKEN_REFRESH_MARGIN_MS;
126
196
  }
127
197
 
128
- function clearTokenRefresh(connectionId: string, refresh: Promise<string>): void {
129
- if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);
130
- }
131
-
132
198
  async function refreshAccessTokenWithLock(params: {
133
199
  connectionId: string;
134
200
  workspaceId: string;
@@ -143,11 +209,17 @@ async function refreshAccessTokenWithLock(params: {
143
209
  key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,
144
210
  ): Promise<string | null>;
145
211
  markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;
212
+ withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T>;
213
+ waitForCredentialWrites(connectionId: string): Promise<void>;
214
+ isCurrentCredentialGeneration(): boolean;
146
215
  }): Promise<string> {
147
216
  const lock = await withJiraRefreshLock(params.connectionId, () =>
148
217
  refreshAccessTokenForConnection(params),
149
218
  );
150
219
  if (lock.acquired) return lock.value;
220
+ if (!params.isCurrentCredentialGeneration()) {
221
+ return params.readAccessToken(params.workspaceId, params.connectionId);
222
+ }
151
223
  const reread = await params.readAccessToken(params.workspaceId, params.connectionId);
152
224
  if (reread !== params.originalAccessToken) return reread;
153
225
  throw new JiraIntegrationProviderError(
@@ -170,7 +242,13 @@ async function refreshAccessTokenForConnection(params: {
170
242
  key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,
171
243
  ): Promise<string | null>;
172
244
  markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;
245
+ withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T>;
246
+ waitForCredentialWrites(connectionId: string): Promise<void>;
247
+ isCurrentCredentialGeneration(): boolean;
173
248
  }): Promise<string> {
249
+ if (!params.isCurrentCredentialGeneration()) {
250
+ return params.readAccessToken(params.workspaceId, params.connectionId);
251
+ }
174
252
  const current = await params.readAccessToken(params.workspaceId, params.connectionId);
175
253
  const installation = await getJiraInstallationByConnectionId(params.connectionId);
176
254
  if (
@@ -190,25 +268,67 @@ async function refreshAccessTokenForConnection(params: {
190
268
  }
191
269
  try {
192
270
  const refreshed = await params.client.refreshAccessToken({refreshToken});
193
- if (!refreshed.refreshToken) throw new JiraTokenUnrefreshableError(params.connectionId);
194
- await params.secrets.setSecrets({
195
- workspaceId: params.workspaceId,
196
- namespace: jiraSecretsNamespace(params.connectionId),
197
- values: {
198
- [ACCESS_TOKEN_KEY]: refreshed.accessToken,
199
- [REFRESH_TOKEN_KEY]: refreshed.refreshToken,
200
- },
201
- });
202
- await updateJiraInstallationTokenExpiry({
203
- connectionId: params.connectionId,
204
- tokenExpiresAt: refreshed.expiresAt ?? null,
205
- scopes: refreshed.scopes.length > 0 ? refreshed.scopes : undefined,
271
+ const refreshedRefreshToken = refreshed.refreshToken;
272
+ if (!refreshedRefreshToken) throw new JiraTokenUnrefreshableError(params.connectionId);
273
+ const persisted = await params.withCredentialWrite(params.connectionId, async () => {
274
+ if (!params.isCurrentCredentialGeneration()) return false;
275
+ await params.secrets.setSecrets({
276
+ workspaceId: params.workspaceId,
277
+ namespace: jiraSecretsNamespace(params.connectionId),
278
+ values: {
279
+ [ACCESS_TOKEN_KEY]: refreshed.accessToken,
280
+ [REFRESH_TOKEN_KEY]: refreshedRefreshToken,
281
+ },
282
+ });
283
+ if (!params.isCurrentCredentialGeneration()) return false;
284
+ await updateJiraInstallationTokenExpiry({
285
+ connectionId: params.connectionId,
286
+ tokenExpiresAt: refreshed.expiresAt ?? null,
287
+ scopes: refreshed.scopes.length > 0 ? refreshed.scopes : undefined,
288
+ });
289
+ return true;
206
290
  });
291
+ if (!persisted || !params.isCurrentCredentialGeneration()) {
292
+ await params.waitForCredentialWrites(params.connectionId);
293
+ return params.readAccessToken(params.workspaceId, params.connectionId);
294
+ }
207
295
  return refreshed.accessToken;
208
296
  } catch (error) {
209
- if (error instanceof JiraIntegrationProviderError && error.reason === 'access-denied') {
210
- await params.markConnectionError?.({connectionId: params.connectionId});
297
+ if (isTerminalRefreshFailure(error)) {
298
+ await markConnectionError({
299
+ callback: params.markConnectionError,
300
+ connectionId: params.connectionId,
301
+ isCurrentCredentialGeneration: params.isCurrentCredentialGeneration,
302
+ withCredentialWrite: params.withCredentialWrite,
303
+ });
211
304
  }
212
305
  throw error;
213
306
  }
214
307
  }
308
+
309
+ function isTerminalRefreshFailure(error: unknown): boolean {
310
+ return (
311
+ error instanceof JiraIntegrationProviderError &&
312
+ (error.reason === 'access-denied' || error.reason === 'timeout')
313
+ );
314
+ }
315
+
316
+ async function markConnectionError(params: {
317
+ callback: CreateJiraTokenStoreParams['markConnectionError'];
318
+ connectionId: string;
319
+ isCurrentCredentialGeneration(): boolean;
320
+ withCredentialWrite<T>(connectionId: string, operation: () => Promise<T>): Promise<T>;
321
+ }): Promise<void> {
322
+ if (!params.isCurrentCredentialGeneration()) return;
323
+ try {
324
+ await params.withCredentialWrite(params.connectionId, async () => {
325
+ if (!params.isCurrentCredentialGeneration()) return;
326
+ await params.callback?.({connectionId: params.connectionId});
327
+ });
328
+ } catch (error) {
329
+ logger().warn(
330
+ {err: error, connectionId: params.connectionId},
331
+ 'Jira connection error-state update failed after token refresh failure',
332
+ );
333
+ }
334
+ }
@@ -0,0 +1,418 @@
1
+ import {createHmac} from 'node:crypto';
2
+ import type {JiraWebhookEnvelopeDto} from '@shipfox/api-integration-jira-dto';
3
+ import {createStoredWebhookRequest, type IntegrationConnection} from '@shipfox/api-integration-spi';
4
+ import {signHs256} from '@shipfox/node-jwt';
5
+ import {createJiraWebhookProcessor} from './webhook-processor.js';
6
+
7
+ const connectionId = 'c0a8012e-0b6d-4d8f-8d5c-6d74102602b0';
8
+ const cloudId = 'cloud-1';
9
+ const receivedAt = new Date().toISOString();
10
+
11
+ function createConnection(
12
+ id = connectionId,
13
+ externalAccountId = cloudId,
14
+ ): IntegrationConnection<'jira'> {
15
+ const now = new Date();
16
+ return {
17
+ id,
18
+ workspaceId: 'a0a8012e-0b6d-4d8f-8d5c-6d74102602b0',
19
+ provider: 'jira',
20
+ externalAccountId,
21
+ slug: 'jira_acme',
22
+ displayName: 'Jira Acme',
23
+ lifecycleStatus: 'active',
24
+ createdAt: now,
25
+ updatedAt: now,
26
+ };
27
+ }
28
+
29
+ function createPayload(
30
+ event: JiraWebhookEnvelopeDto['webhookEvent'] = 'jira:issue_created',
31
+ accountId = 'account-2',
32
+ ): JiraWebhookEnvelopeDto {
33
+ return {
34
+ webhookEvent: event,
35
+ timestamp: Date.now(),
36
+ issue_event_type_name: event.startsWith('jira:') ? event.slice('jira:'.length) : 'comment',
37
+ issue: {
38
+ id: '10001',
39
+ key: 'ENG-1',
40
+ fields: {summary: 'Webhook test', status: null, assignee: null},
41
+ },
42
+ user: {accountId},
43
+ ...(event.startsWith('jira:')
44
+ ? {changelog: {items: []}}
45
+ : {
46
+ comment: {
47
+ id: '10002',
48
+ author: {accountId: 'account-2'},
49
+ body: {type: 'doc', version: 1, content: []},
50
+ },
51
+ }),
52
+ matchedWebhookIds: [42],
53
+ } as JiraWebhookEnvelopeDto;
54
+ }
55
+
56
+ function createRequest(
57
+ payload: unknown,
58
+ authorization = 'Bearer invalid',
59
+ requestReceivedAt = receivedAt,
60
+ requestConnectionId = connectionId,
61
+ jiraWebhookIdentifier?: string,
62
+ ) {
63
+ const body = new TextEncoder().encode(JSON.stringify(payload));
64
+ return createStoredWebhookRequest({
65
+ requestId: crypto.randomUUID(),
66
+ routeId: 'jira',
67
+ receivedAt: requestReceivedAt,
68
+ rawQueryString: '',
69
+ headers: {
70
+ authorization,
71
+ 'content-type': 'application/json',
72
+ ...(jiraWebhookIdentifier ? {'x-atlassian-webhook-identifier': jiraWebhookIdentifier} : {}),
73
+ },
74
+ body,
75
+ connectionId: requestConnectionId,
76
+ });
77
+ }
78
+
79
+ async function signedAuthorization(expiresIn = '1h') {
80
+ return await signHs256({
81
+ payload: {iss: 'atlassian'},
82
+ secret: 'test-client-secret',
83
+ expiresIn,
84
+ });
85
+ }
86
+
87
+ function createWrongAlgorithmToken(token: string): string {
88
+ const header = Buffer.from(JSON.stringify({alg: 'HS384', typ: 'JWT'})).toString('base64url');
89
+ const [, payload] = token.split('.');
90
+ const signingInput = `${header}.${payload}`;
91
+ const signature = createHmac('sha384', 'test-client-secret')
92
+ .update(signingInput)
93
+ .digest('base64url');
94
+ return `${signingInput}.${signature}`;
95
+ }
96
+
97
+ function createHarness(overrides: {published?: boolean; deduplicate?: boolean} = {}) {
98
+ const seenDeliveryIds = new Set<string>();
99
+ const publishIntegrationEventReceived = vi.fn().mockImplementation(({event}) => {
100
+ if (overrides.deduplicate && seenDeliveryIds.has(event.deliveryId)) {
101
+ return Promise.resolve({published: false});
102
+ }
103
+ seenDeliveryIds.add(event.deliveryId);
104
+ return Promise.resolve({published: overrides.published ?? true});
105
+ });
106
+ const recordDeliveryOnly = vi.fn().mockResolvedValue(undefined);
107
+ const coreDb = vi.fn(() => ({
108
+ transaction: async (callback: (tx: unknown) => Promise<unknown>) => await callback({}),
109
+ }));
110
+ const getIntegrationConnectionById = vi
111
+ .fn()
112
+ .mockImplementation(async (id: string) =>
113
+ createConnection(id, id === connectionId ? cloudId : `cloud-${id}`),
114
+ );
115
+ const getJiraInstallationByConnectionId = vi.fn().mockImplementation(async (id: string) => ({
116
+ connectionId: id,
117
+ cloudId: id === connectionId ? cloudId : `cloud-${id}`,
118
+ authorizingAccountId: 'account-1',
119
+ webhookIds: [42],
120
+ status: 'installed',
121
+ }));
122
+ const processor = createJiraWebhookProcessor({
123
+ coreDb: coreDb as never,
124
+ publishIntegrationEventReceived,
125
+ recordDeliveryOnly,
126
+ getIntegrationConnectionById,
127
+ getJiraInstallationByConnectionId,
128
+ });
129
+ return {
130
+ processor,
131
+ coreDb,
132
+ getIntegrationConnectionById,
133
+ getJiraInstallationByConnectionId,
134
+ publishIntegrationEventReceived,
135
+ recordDeliveryOnly,
136
+ };
137
+ }
138
+
139
+ describe('Jira webhook processor', () => {
140
+ it('verifies, normalizes, and publishes a supported event', async () => {
141
+ const harness = createHarness();
142
+ const token = await signedAuthorization();
143
+ const request = await createRequest(createPayload(), `Bearer ${token}`);
144
+
145
+ const result = await harness.processor.process(request);
146
+
147
+ expect(result).toMatchObject({outcome: 'processed'});
148
+ expect(harness.publishIntegrationEventReceived).toHaveBeenCalledWith(
149
+ expect.objectContaining({
150
+ event: expect.objectContaining({
151
+ provider: 'jira',
152
+ event: 'jira:issue_created',
153
+ connectionId,
154
+ payload: expect.objectContaining({cloudId}),
155
+ }),
156
+ }),
157
+ );
158
+ expect(
159
+ harness.publishIntegrationEventReceived.mock.calls[0]?.[0].event.payload,
160
+ ).not.toHaveProperty('authorization');
161
+ });
162
+
163
+ it('rejects missing or tampered authorization without recording a delivery', async () => {
164
+ const harness = createHarness();
165
+ const payload = createPayload();
166
+ const missing = await harness.processor.process(await createRequest(payload, ''));
167
+ const tamperedToken = `${await signedAuthorization()}tampered`;
168
+ const tampered = await harness.processor.process(
169
+ await createRequest(payload, `Bearer ${tamperedToken}`),
170
+ );
171
+
172
+ expect(missing).toMatchObject({outcome: 'discarded', reason: 'invalid_signature'});
173
+ expect(tampered).toMatchObject({outcome: 'discarded', reason: 'invalid_signature'});
174
+ expect(harness.recordDeliveryOnly).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it('rejects tokens that are expired at Jira receipt time or use another algorithm', async () => {
178
+ const receiptTime = new Date('2030-01-01T00:00:00.000Z');
179
+ vi.useFakeTimers({now: new Date(receiptTime.getTime() - 120_000)});
180
+ try {
181
+ const expiredToken = await signedAuthorization('60s');
182
+ const validToken = await signedAuthorization();
183
+ const harness = createHarness();
184
+
185
+ const expired = await harness.processor.process(
186
+ createRequest(createPayload(), `Bearer ${expiredToken}`, receiptTime.toISOString()),
187
+ );
188
+ const wrongAlgorithm = await harness.processor.process(
189
+ createRequest(
190
+ createPayload(),
191
+ `Bearer ${createWrongAlgorithmToken(validToken)}`,
192
+ receiptTime.toISOString(),
193
+ ),
194
+ );
195
+
196
+ expect(expired).toMatchObject({outcome: 'discarded', reason: 'invalid_signature'});
197
+ expect(wrongAlgorithm).toMatchObject({outcome: 'discarded', reason: 'invalid_signature'});
198
+ expect(harness.recordDeliveryOnly).not.toHaveBeenCalled();
199
+ expect(harness.publishIntegrationEventReceived).not.toHaveBeenCalled();
200
+ } finally {
201
+ vi.useRealTimers();
202
+ }
203
+ });
204
+
205
+ it('records authenticated deliberate drops for mismatched and self-authored deliveries', async () => {
206
+ const harness = createHarness();
207
+ const token = await signedAuthorization();
208
+ const mismatch = await createRequest(
209
+ {...createPayload(), matchedWebhookIds: [99]},
210
+ `Bearer ${token}`,
211
+ );
212
+ const selfAuthored = await createRequest(
213
+ createPayload('comment_created', 'account-1'),
214
+ `Bearer ${token}`,
215
+ );
216
+
217
+ const mismatchResult = await harness.processor.process(mismatch);
218
+ const selfAuthoredResult = await harness.processor.process(selfAuthored);
219
+
220
+ expect(mismatchResult).toMatchObject({outcome: 'discarded', reason: 'connection_unavailable'});
221
+ expect(selfAuthoredResult).toMatchObject({outcome: 'discarded', reason: 'unsupported_event'});
222
+ expect(harness.recordDeliveryOnly).toHaveBeenCalledTimes(2);
223
+ expect(harness.publishIntegrationEventReceived).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it.each([
227
+ 'comment_updated',
228
+ 'jira:issue_updated',
229
+ ] as const)('drops self-authored %s events without publishing', async (event) => {
230
+ const harness = createHarness();
231
+ const token = await signedAuthorization();
232
+
233
+ const result = await harness.processor.process(
234
+ createRequest(createPayload(event, 'account-1'), `Bearer ${token}`),
235
+ );
236
+
237
+ expect(result).toMatchObject({outcome: 'discarded', reason: 'unsupported_event'});
238
+ expect(harness.recordDeliveryOnly).toHaveBeenCalledOnce();
239
+ expect(harness.publishIntegrationEventReceived).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it('publishes a self-authored comment_deleted event', async () => {
243
+ const harness = createHarness();
244
+ const token = await signedAuthorization();
245
+
246
+ const result = await harness.processor.process(
247
+ createRequest(createPayload('comment_deleted', 'account-1'), `Bearer ${token}`),
248
+ );
249
+
250
+ expect(result).toMatchObject({outcome: 'processed'});
251
+ expect(harness.publishIntegrationEventReceived).toHaveBeenCalledOnce();
252
+ });
253
+
254
+ it('records unsupported events and rejects missing, wrong-provider, inactive, and revoked connections', async () => {
255
+ const token = await signedAuthorization();
256
+ const unsupported = createHarness();
257
+ const unsupportedResult = await unsupported.processor.process(
258
+ createRequest({...createPayload(), webhookEvent: 'jira:unknown'}, `Bearer ${token}`),
259
+ );
260
+ expect(unsupportedResult).toMatchObject({outcome: 'discarded', reason: 'unsupported_event'});
261
+ expect(unsupported.recordDeliveryOnly).toHaveBeenCalledOnce();
262
+
263
+ const missing = createHarness();
264
+ missing.getIntegrationConnectionById.mockResolvedValue(undefined);
265
+ const missingResult = await missing.processor.process(
266
+ createRequest(createPayload(), `Bearer ${token}`),
267
+ );
268
+
269
+ const missingInstallation = createHarness();
270
+ missingInstallation.getJiraInstallationByConnectionId.mockResolvedValue(undefined);
271
+ const missingInstallationResult = await missingInstallation.processor.process(
272
+ createRequest(createPayload(), `Bearer ${token}`),
273
+ );
274
+
275
+ const wrongProvider = createHarness();
276
+ wrongProvider.getIntegrationConnectionById.mockResolvedValue({
277
+ ...createConnection(),
278
+ provider: 'github',
279
+ });
280
+ const wrongProviderResult = await wrongProvider.processor.process(
281
+ createRequest(createPayload(), `Bearer ${token}`),
282
+ );
283
+
284
+ const inactive = createHarness();
285
+ inactive.getIntegrationConnectionById.mockResolvedValue({
286
+ ...createConnection(),
287
+ lifecycleStatus: 'error',
288
+ });
289
+ const inactiveResult = await inactive.processor.process(
290
+ createRequest(createPayload(), `Bearer ${token}`),
291
+ );
292
+
293
+ const revoked = createHarness();
294
+ revoked.getJiraInstallationByConnectionId.mockResolvedValue({
295
+ connectionId,
296
+ cloudId,
297
+ authorizingAccountId: 'account-1',
298
+ webhookIds: [42],
299
+ status: 'revoked',
300
+ });
301
+ const revokedResult = await revoked.processor.process(
302
+ createRequest(createPayload(), `Bearer ${token}`),
303
+ );
304
+
305
+ for (const result of [
306
+ missingResult,
307
+ missingInstallationResult,
308
+ wrongProviderResult,
309
+ inactiveResult,
310
+ revokedResult,
311
+ ]) {
312
+ expect(result).toMatchObject({outcome: 'discarded', reason: 'connection_unavailable'});
313
+ }
314
+ for (const harness of [missing, missingInstallation, wrongProvider, inactive, revoked]) {
315
+ expect(harness.recordDeliveryOnly).toHaveBeenCalledOnce();
316
+ expect(harness.publishIntegrationEventReceived).not.toHaveBeenCalled();
317
+ }
318
+ });
319
+
320
+ it('returns duplicate for a repeated body hash without publishing twice', async () => {
321
+ const harness = createHarness({published: false});
322
+ const token = await signedAuthorization();
323
+ const request = await createRequest(createPayload(), `Bearer ${token}`);
324
+
325
+ const result = await harness.processor.process(request);
326
+
327
+ expect(result).toMatchObject({outcome: 'duplicate'});
328
+ expect(harness.publishIntegrationEventReceived).toHaveBeenCalledOnce();
329
+ });
330
+
331
+ it('uses Jira’s stable delivery identifier when it is present', async () => {
332
+ const harness = createHarness({deduplicate: true});
333
+ const token = await signedAuthorization();
334
+ const firstPayload = createPayload();
335
+ const replayPayload = {...firstPayload, timestamp: firstPayload.timestamp + 1};
336
+
337
+ const first = await harness.processor.process(
338
+ createRequest(firstPayload, `Bearer ${token}`, receivedAt, connectionId, 'jira-delivery-1'),
339
+ );
340
+ const replay = await harness.processor.process(
341
+ createRequest(replayPayload, `Bearer ${token}`, receivedAt, connectionId, 'jira-delivery-1'),
342
+ );
343
+
344
+ expect(first).toMatchObject({
345
+ outcome: 'processed',
346
+ deliveryId: `${connectionId}:jira-delivery-1`,
347
+ });
348
+ expect(replay).toMatchObject({
349
+ outcome: 'duplicate',
350
+ deliveryId: `${connectionId}:jira-delivery-1`,
351
+ });
352
+ expect(harness.publishIntegrationEventReceived).toHaveBeenCalledTimes(2);
353
+ });
354
+
355
+ it('scopes delivery deduplication by connection and still rejects a replay on that connection', async () => {
356
+ const harness = createHarness({deduplicate: true});
357
+ const token = await signedAuthorization();
358
+ const otherConnectionId = crypto.randomUUID();
359
+ const payload = createPayload();
360
+
361
+ const first = await harness.processor.process(createRequest(payload, `Bearer ${token}`));
362
+ const replay = await harness.processor.process(createRequest(payload, `Bearer ${token}`));
363
+ const otherConnection = await harness.processor.process(
364
+ createRequest(payload, `Bearer ${token}`, receivedAt, otherConnectionId),
365
+ );
366
+
367
+ expect(first).toMatchObject({outcome: 'processed'});
368
+ expect(replay).toMatchObject({outcome: 'duplicate'});
369
+ expect(otherConnection).toMatchObject({outcome: 'processed'});
370
+ const events = harness.publishIntegrationEventReceived.mock.calls.map(([input]) => input.event);
371
+ expect(events).toHaveLength(3);
372
+ expect(events[0]?.deliveryId).not.toBe(events[2]?.deliveryId);
373
+ expect(events[0]?.connectionId).toBe(connectionId);
374
+ expect(events[2]?.connectionId).toBe(otherConnectionId);
375
+ });
376
+
377
+ it('accepts a JWT that was valid when Jira delivered the request', async () => {
378
+ const receiptTime = new Date('2030-01-01T00:00:00.000Z');
379
+ vi.useFakeTimers({now: receiptTime});
380
+ try {
381
+ const token = await signHs256({
382
+ payload: {iss: 'atlassian'},
383
+ secret: 'test-client-secret',
384
+ expiresIn: '60s',
385
+ });
386
+ vi.setSystemTime(new Date(receiptTime.getTime() + 120_000));
387
+
388
+ const harness = createHarness();
389
+ const result = await harness.processor.process(
390
+ createRequest(createPayload(), `Bearer ${token}`, receiptTime.toISOString()),
391
+ );
392
+
393
+ expect(result).toMatchObject({outcome: 'processed'});
394
+ } finally {
395
+ vi.useRealTimers();
396
+ }
397
+ });
398
+
399
+ it('returns malformed payload after authenticating before parsing succeeds', async () => {
400
+ const harness = createHarness();
401
+ const token = await signedAuthorization();
402
+ const request = createStoredWebhookRequest({
403
+ requestId: crypto.randomUUID(),
404
+ routeId: 'jira',
405
+ receivedAt,
406
+ rawQueryString: '',
407
+ headers: {authorization: `Bearer ${token}`},
408
+ body: new TextEncoder().encode('{'),
409
+ connectionId,
410
+ });
411
+
412
+ await expect(harness.processor.process(request)).resolves.toMatchObject({
413
+ outcome: 'discarded',
414
+ reason: 'malformed_payload',
415
+ });
416
+ expect(harness.recordDeliveryOnly).not.toHaveBeenCalled();
417
+ });
418
+ });