@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,4 @@
1
+ import {type JiraInstallationLock, withJiraInstallationLock} from '#db/installations.js';
1
2
  import {
2
3
  JiraOfflineAccessNotGrantedError,
3
4
  JiraPendingSelectionNotFoundError,
@@ -19,6 +20,8 @@ function createParams() {
19
20
  getAccessibleResources: vi.fn(),
20
21
  getMyself: vi.fn().mockResolvedValue({accountId: 'account-1'}),
21
22
  refreshAccessToken: vi.fn(),
23
+ registerDynamicWebhook: vi.fn(),
24
+ deleteDynamicWebhook: vi.fn(),
22
25
  };
23
26
  const tokenStore = {storeTokens: vi.fn().mockResolvedValue(undefined)};
24
27
  const pendingStore = {
@@ -29,6 +32,12 @@ function createParams() {
29
32
  const connectJiraInstallation = vi
30
33
  .fn()
31
34
  .mockResolvedValue({id: 'connection-1', workspaceId, provider: 'jira'});
35
+ const registerJiraWebhook = vi.fn().mockImplementation(async ({onRegistrationSuccess}) => {
36
+ await onRegistrationSuccess?.({});
37
+ });
38
+ const markConnectionActive = vi.fn().mockResolvedValue(undefined);
39
+ const markConnectionError = vi.fn().mockResolvedValue(undefined);
40
+ const withJiraInstallationLock: JiraInstallationLock = async (_key, fn) => fn();
32
41
  return {
33
42
  workspaceId,
34
43
  state,
@@ -36,6 +45,10 @@ function createParams() {
36
45
  tokenStore,
37
46
  pendingStore,
38
47
  connectJiraInstallation,
48
+ registerJiraWebhook,
49
+ markConnectionActive,
50
+ markConnectionError,
51
+ withJiraInstallationLock,
39
52
  code: 'code',
40
53
  sessionUserId: 'user-1',
41
54
  sessionMemberships: [],
@@ -66,6 +79,7 @@ describe('Jira OAuth installation', () => {
66
79
  expect(params.tokenStore.storeTokens).toHaveBeenCalledWith(
67
80
  expect.objectContaining({connectionId: 'connection-1', refreshToken: 'refresh'}),
68
81
  );
82
+ expect(params.markConnectionActive).toHaveBeenCalledWith({connectionId: 'connection-1'});
69
83
  });
70
84
 
71
85
  it('stores a multi-site grant until the selected site completes', async () => {
@@ -126,4 +140,169 @@ describe('Jira OAuth installation', () => {
126
140
  handleJiraSiteSelection({...mismatchedSite, cloudId: 'cloud-1'}),
127
141
  ).rejects.toBeInstanceOf(JiraSiteSelectionMismatchError);
128
142
  });
143
+
144
+ it('marks the connection in error while retaining state when registration fails', async () => {
145
+ const params = createParams();
146
+ params.jira.getAccessibleResources.mockResolvedValue([
147
+ {
148
+ cloudId: 'cloud-1',
149
+ name: 'Acme',
150
+ url: 'https://acme.atlassian.net',
151
+ scopes: ['read:jira-work'],
152
+ },
153
+ ]);
154
+ const registrationError = new Error('Jira rejected the webhook');
155
+ params.registerJiraWebhook.mockRejectedValue(registrationError);
156
+ await expect(handleJiraCallback(params)).rejects.toBe(registrationError);
157
+ expect(params.tokenStore.storeTokens).toHaveBeenCalled();
158
+ expect(params.disconnectJiraInstallation).not.toHaveBeenCalled();
159
+ expect(params.markConnectionError).toHaveBeenCalledWith({connectionId: 'connection-1'});
160
+ });
161
+
162
+ it('does not overwrite an existing installation when token storage fails', async () => {
163
+ const params = createParams();
164
+ params.jira.getAccessibleResources.mockResolvedValue([
165
+ {
166
+ cloudId: 'cloud-1',
167
+ name: 'Acme',
168
+ url: 'https://acme.atlassian.net',
169
+ scopes: ['read:jira-work'],
170
+ },
171
+ ]);
172
+ const existing = {
173
+ id: 'connection-1',
174
+ workspaceId: params.workspaceId,
175
+ provider: 'jira',
176
+ } as const;
177
+ params.getExistingJiraConnection.mockResolvedValue(existing);
178
+ const storageError = new Error('secret storage unavailable');
179
+ params.tokenStore.storeTokens.mockRejectedValue(storageError);
180
+ params.markConnectionError
181
+ .mockRejectedValueOnce(new Error('database unavailable'))
182
+ .mockResolvedValueOnce(undefined);
183
+
184
+ await expect(handleJiraCallback(params)).rejects.toBe(storageError);
185
+
186
+ expect(params.connectJiraInstallation).not.toHaveBeenCalled();
187
+ expect(params.markConnectionError).toHaveBeenCalledTimes(2);
188
+ expect(params.markConnectionError).toHaveBeenNthCalledWith(1, {connectionId: existing.id});
189
+ expect(params.markConnectionError).toHaveBeenNthCalledWith(2, {connectionId: existing.id});
190
+ expect(params.disconnectJiraInstallation).not.toHaveBeenCalled();
191
+ });
192
+
193
+ it('falls back to a second error-state write when the registration callback cannot persist it', async () => {
194
+ const params = createParams();
195
+ params.jira.getAccessibleResources.mockResolvedValue([
196
+ {
197
+ cloudId: 'cloud-1',
198
+ name: 'Acme',
199
+ url: 'https://acme.atlassian.net',
200
+ scopes: ['read:jira-work'],
201
+ },
202
+ ]);
203
+ const registrationError = new Error('Jira rejected the webhook');
204
+ params.registerJiraWebhook.mockImplementation(async ({onRegistrationFailure}) => {
205
+ await onRegistrationFailure?.({});
206
+ throw registrationError;
207
+ });
208
+ params.markConnectionError
209
+ .mockRejectedValueOnce(new Error('database unavailable'))
210
+ .mockResolvedValueOnce(undefined);
211
+
212
+ await expect(handleJiraCallback(params)).rejects.toBe(registrationError);
213
+
214
+ expect(params.markConnectionError).toHaveBeenCalledTimes(2);
215
+ expect(params.markConnectionError).toHaveBeenNthCalledWith(1, {connectionId: 'connection-1'});
216
+ expect(params.markConnectionError).toHaveBeenNthCalledWith(2, {connectionId: 'connection-1'});
217
+ });
218
+
219
+ it('serializes the complete same-site replacement before allowing another callback to proceed', async () => {
220
+ const first = createParams();
221
+ const second = createParams();
222
+ second.workspaceId = first.workspaceId;
223
+ second.state = signJiraInstallState({workspaceId: first.workspaceId, userId: 'user-1'});
224
+ const sites = [
225
+ {
226
+ cloudId: 'cloud-1',
227
+ name: 'Acme',
228
+ url: 'https://acme.atlassian.net',
229
+ scopes: ['read:jira-work'],
230
+ },
231
+ ];
232
+ const connection = {id: 'connection-1', workspaceId: first.workspaceId, provider: 'jira'};
233
+ const getExisting = vi.fn().mockResolvedValue(undefined);
234
+ const connect = vi.fn().mockResolvedValue(connection);
235
+ const storeTokens = vi.fn().mockResolvedValue(undefined);
236
+ const register = vi.fn();
237
+ const events: string[] = [];
238
+ let releaseFirstRegistration!: () => void;
239
+ const firstRegistrationReady = new Promise<void>((resolve) => {
240
+ releaseFirstRegistration = resolve;
241
+ });
242
+
243
+ first.jira.getAccessibleResources.mockResolvedValue(sites);
244
+ second.jira.getAccessibleResources.mockResolvedValue(sites);
245
+ first.jira.getMyself.mockResolvedValue({accountId: 'account-1'});
246
+ second.jira.getMyself.mockResolvedValue({accountId: 'account-2'});
247
+ first.jira.exchangeAuthorizationCode.mockResolvedValue({
248
+ accessToken: 'access-1',
249
+ refreshToken: 'refresh-1',
250
+ scopes: [],
251
+ });
252
+ second.jira.exchangeAuthorizationCode.mockResolvedValue({
253
+ accessToken: 'access-2',
254
+ refreshToken: 'refresh-2',
255
+ scopes: [],
256
+ });
257
+ getExisting.mockImplementation(() => {
258
+ events.push('get-existing');
259
+ return Promise.resolve(connect.mock.calls.length > 0 ? connection : undefined);
260
+ });
261
+ connect.mockImplementation((input) => {
262
+ events.push(`connect:${input.authorizingAccountId}`);
263
+ return Promise.resolve(connection);
264
+ });
265
+ storeTokens.mockImplementation(({accessToken}) => {
266
+ events.push(`store:${accessToken}`);
267
+ return Promise.resolve();
268
+ });
269
+ register.mockImplementation(async ({accessToken, onRegistrationSuccess}) => {
270
+ events.push(`register:${accessToken}`);
271
+ if (accessToken === 'access-1') await firstRegistrationReady;
272
+ await onRegistrationSuccess?.({});
273
+ });
274
+
275
+ for (const params of [first, second]) {
276
+ params.getExistingJiraConnection = getExisting;
277
+ params.connectJiraInstallation = connect;
278
+ params.tokenStore.storeTokens = storeTokens;
279
+ params.registerJiraWebhook = register;
280
+ }
281
+
282
+ first.withJiraInstallationLock = withJiraInstallationLock;
283
+ second.withJiraInstallationLock = withJiraInstallationLock;
284
+
285
+ const firstCallback = handleJiraCallback(first);
286
+ await vi.waitFor(() =>
287
+ expect(register).toHaveBeenCalledWith(expect.objectContaining({accessToken: 'access-1'})),
288
+ );
289
+ const secondCallback = handleJiraCallback(second);
290
+ await Promise.resolve();
291
+ expect(getExisting).toHaveBeenCalledTimes(1);
292
+
293
+ releaseFirstRegistration();
294
+ await expect(firstCallback).resolves.toMatchObject({id: 'connection-1'});
295
+ await expect(secondCallback).resolves.toMatchObject({id: 'connection-1'});
296
+
297
+ expect(events).toEqual([
298
+ 'get-existing',
299
+ 'connect:account-1',
300
+ 'store:access-1',
301
+ 'register:access-1',
302
+ 'get-existing',
303
+ 'store:access-2',
304
+ 'connect:account-2',
305
+ 'register:access-2',
306
+ ]);
307
+ });
129
308
  });
@@ -4,6 +4,7 @@ import {logger} from '@shipfox/node-opentelemetry';
4
4
  import type {JiraAccessibleResource, JiraApiClient, JiraAuthorization} from '#api/client.js';
5
5
  import type {JiraPendingSelectionStore} from '#core/pending.js';
6
6
  import type {JiraTokenStore} from '#core/tokens.js';
7
+ import {type JiraInstallationLock, withJiraInstallationLock} from '#db/installations.js';
7
8
  import {
8
9
  JiraInstallationAlreadyLinkedError,
9
10
  JiraInstallStateActorMismatchError,
@@ -46,6 +47,17 @@ export interface HandleJiraCallbackParams {
46
47
  connectJiraInstallation(
47
48
  input: ConnectJiraInstallationInput,
48
49
  ): Promise<IntegrationConnection<'jira'>>;
50
+ registerJiraWebhook(input: {
51
+ connectionId: string;
52
+ cloudId: string;
53
+ accessToken: string;
54
+ withRegistrationLock?: JiraInstallationLock;
55
+ onRegistrationSuccess: (input: {tx?: unknown}) => Promise<void>;
56
+ onRegistrationFailure: (input: {tx?: unknown}) => Promise<void>;
57
+ }): Promise<void>;
58
+ withJiraInstallationLock?: JiraInstallationLock;
59
+ markConnectionActive(input: {connectionId: string; tx?: unknown}): Promise<void>;
60
+ markConnectionError(input: {connectionId: string; tx?: unknown}): Promise<void>;
49
61
  disconnectJiraInstallation(input: {connectionId: string}): Promise<void>;
50
62
  }
51
63
 
@@ -136,23 +148,23 @@ async function verifyClaims(
136
148
  return claims;
137
149
  }
138
150
 
139
- async function resolveJiraSite(
151
+ function resolveJiraSite(
140
152
  params: Omit<HandleJiraCallbackParams, 'code'> & {
141
153
  authorization: JiraAuthorization;
142
154
  site: JiraAccessibleResource;
143
155
  claims: {workspaceId: string; userId: string};
144
156
  },
145
157
  ): Promise<IntegrationConnection<'jira'>> {
146
- const existing = await params.getExistingJiraConnection({cloudId: params.site.cloudId});
147
- if (existing && existing.workspaceId !== params.claims.workspaceId)
148
- throw new JiraInstallationAlreadyLinkedError(params.site.cloudId);
149
- let connectionId: string | undefined;
150
- try {
158
+ const withInstallationLock = params.withJiraInstallationLock ?? withJiraInstallationLock;
159
+ return withInstallationLock(params.site.cloudId, async () => {
160
+ const existing = await params.getExistingJiraConnection({cloudId: params.site.cloudId});
161
+ if (existing && existing.workspaceId !== params.claims.workspaceId)
162
+ throw new JiraInstallationAlreadyLinkedError(params.site.cloudId);
151
163
  const identity = await params.jira.getMyself({
152
164
  accessToken: params.authorization.accessToken,
153
165
  cloudId: params.site.cloudId,
154
166
  });
155
- const connection = await params.connectJiraInstallation({
167
+ const installationInput = {
156
168
  workspaceId: params.claims.workspaceId,
157
169
  cloudId: params.site.cloudId,
158
170
  siteUrl: params.site.url,
@@ -161,19 +173,52 @@ async function resolveJiraSite(
161
173
  scopes: params.site.scopes,
162
174
  tokenExpiresAt: params.authorization.expiresAt ?? null,
163
175
  displayName: `Jira ${params.site.name}`,
164
- });
165
- connectionId = connection.id;
166
- await params.tokenStore.storeTokens({
167
- connectionId: connection.id,
168
- accessToken: params.authorization.accessToken,
169
- refreshToken: params.authorization.refreshToken,
170
- editedBy: params.claims.userId,
171
- });
176
+ };
177
+ let connection = existing;
178
+ try {
179
+ if (!connection) connection = await params.connectJiraInstallation(installationInput);
180
+ await params.tokenStore.storeTokens({
181
+ connectionId: connection.id,
182
+ accessToken: params.authorization.accessToken,
183
+ refreshToken: params.authorization.refreshToken,
184
+ editedBy: params.claims.userId,
185
+ });
186
+ if (existing) connection = await params.connectJiraInstallation(installationInput);
187
+ } catch (error) {
188
+ if (existing) {
189
+ const markedError = await bestEffortMarkConnectionError(params, existing.id);
190
+ if (!markedError) await bestEffortMarkConnectionError(params, existing.id);
191
+ } else if (connection) {
192
+ await bestEffortDisconnect(params, connection.id);
193
+ }
194
+ throw error;
195
+ }
196
+
197
+ let registrationFailureHandled = false;
198
+ try {
199
+ await params.registerJiraWebhook({
200
+ connectionId: connection.id,
201
+ cloudId: params.site.cloudId,
202
+ accessToken: params.authorization.accessToken,
203
+ withRegistrationLock: async (_lockKey, fn) => fn(),
204
+ onRegistrationSuccess: async ({tx}) => {
205
+ await params.markConnectionActive(connectionLifecycleInput(connection.id, tx));
206
+ },
207
+ onRegistrationFailure: async ({tx}) => {
208
+ registrationFailureHandled = await bestEffortMarkConnectionError(
209
+ params,
210
+ connection.id,
211
+ tx,
212
+ );
213
+ },
214
+ });
215
+ } catch (error) {
216
+ if (!registrationFailureHandled) await bestEffortMarkConnectionError(params, connection.id);
217
+ throw error;
218
+ }
219
+
172
220
  return connection;
173
- } catch (error) {
174
- if (connectionId && !existing) await bestEffortDisconnect(params, connectionId);
175
- throw error;
176
- }
221
+ });
177
222
  }
178
223
 
179
224
  async function bestEffortDisconnect(
@@ -189,3 +234,24 @@ async function bestEffortDisconnect(
189
234
  );
190
235
  }
191
236
  }
237
+
238
+ async function bestEffortMarkConnectionError(
239
+ params: Pick<HandleJiraCallbackParams, 'markConnectionError'>,
240
+ connectionId: string,
241
+ tx?: unknown,
242
+ ): Promise<boolean> {
243
+ try {
244
+ await params.markConnectionError(connectionLifecycleInput(connectionId, tx));
245
+ return true;
246
+ } catch (error) {
247
+ logger().warn(
248
+ {err: error, connectionId},
249
+ 'Jira connection error-state update failed after webhook registration rejection',
250
+ );
251
+ return false;
252
+ }
253
+ }
254
+
255
+ function connectionLifecycleInput(connectionId: string, tx?: unknown) {
256
+ return tx === undefined ? {connectionId} : {connectionId, tx};
257
+ }
@@ -1,20 +1,22 @@
1
- const {getInstallation, updateExpiry, withRefreshLock} = vi.hoisted(() => ({
1
+ const {getInstallation, updateExpiry, withRefreshLock, withRefreshLockAndWait} = vi.hoisted(() => ({
2
2
  getInstallation: vi.fn(),
3
3
  updateExpiry: vi.fn(),
4
4
  withRefreshLock: vi.fn(async (_connectionId: string, fn: () => Promise<string>) => ({
5
5
  acquired: true as const,
6
6
  value: await fn(),
7
7
  })),
8
+ withRefreshLockAndWait: vi.fn(async (_connectionId: string, fn: () => Promise<void>) => fn()),
8
9
  }));
9
10
 
10
11
  vi.mock('#db/installations.js', () => ({
11
12
  getJiraInstallationByConnectionId: getInstallation,
12
13
  updateJiraInstallationTokenExpiry: updateExpiry,
13
14
  withJiraRefreshLock: withRefreshLock,
15
+ withJiraRefreshLockAndWait: withRefreshLockAndWait,
14
16
  }));
15
17
 
16
18
  import {JiraIntegrationProviderError, JiraTokenUnrefreshableError} from './errors.js';
17
- import {createJiraTokenStore} from './tokens.js';
19
+ import {createJiraTokenStore, type JiraConnectionResolverResult} from './tokens.js';
18
20
 
19
21
  function createStore() {
20
22
  const values = new Map<string, string>();
@@ -30,16 +32,29 @@ function createStore() {
30
32
  refreshAccessToken: vi.fn(),
31
33
  getAccessibleResources: vi.fn(),
32
34
  getMyself: vi.fn(),
35
+ registerDynamicWebhook: vi.fn(),
36
+ deleteDynamicWebhook: vi.fn(),
33
37
  };
34
38
  const markConnectionError = vi.fn().mockResolvedValue(undefined);
35
39
  const connectionId = crypto.randomUUID();
40
+ const workspaceId = crypto.randomUUID();
41
+ const resolveConnection = vi.fn().mockResolvedValue({workspaceId, lifecycleStatus: 'active'});
36
42
  const store = createJiraTokenStore({
37
- resolveConnection: vi.fn().mockResolvedValue({workspaceId: crypto.randomUUID()}),
43
+ resolveConnection,
38
44
  secrets,
39
45
  client,
40
46
  markConnectionError,
41
47
  });
42
- return {client, connectionId, markConnectionError, secrets, store, values};
48
+ return {
49
+ client,
50
+ connectionId,
51
+ markConnectionError,
52
+ resolveConnection,
53
+ secrets,
54
+ store,
55
+ values,
56
+ workspaceId,
57
+ };
43
58
  }
44
59
 
45
60
  describe('Jira token refresh', () => {
@@ -50,6 +65,7 @@ describe('Jira token refresh', () => {
50
65
  acquired: true,
51
66
  value: await fn(),
52
67
  }));
68
+ withRefreshLockAndWait.mockImplementation(async (_connectionId, fn) => fn());
53
69
  });
54
70
 
55
71
  it('rotates and persists both tokens when the access token expires', async () => {
@@ -85,6 +101,209 @@ describe('Jira token refresh', () => {
85
101
  expect(markConnectionError).toHaveBeenCalledWith({connectionId});
86
102
  });
87
103
 
104
+ it('marks timeout failures as requiring reconnect because refresh state is unknown', async () => {
105
+ const {client, connectionId, markConnectionError, store} = createStore();
106
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
107
+ client.refreshAccessToken.mockRejectedValue(
108
+ new JiraIntegrationProviderError('timeout', 'refresh timed out'),
109
+ );
110
+
111
+ const result = store.getAccessToken({connectionId});
112
+
113
+ await expect(result).rejects.toMatchObject({reason: 'timeout'});
114
+ expect(markConnectionError).toHaveBeenCalledWith({connectionId});
115
+ });
116
+
117
+ it('does not retry a refresh after the connection enters an error state', async () => {
118
+ const {client, connectionId, store} = createStore();
119
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
120
+ client.refreshAccessToken.mockRejectedValue(
121
+ new JiraIntegrationProviderError('timeout', 'refresh timed out'),
122
+ );
123
+
124
+ await expect(store.getAccessToken({connectionId})).rejects.toMatchObject({reason: 'timeout'});
125
+
126
+ await expect(store.getAccessToken({connectionId})).rejects.toBeInstanceOf(
127
+ JiraTokenUnrefreshableError,
128
+ );
129
+ expect(client.refreshAccessToken).toHaveBeenCalledOnce();
130
+ });
131
+
132
+ it('allows a reconnect to clear the unknown refresh state', async () => {
133
+ const {client, connectionId, store} = createStore();
134
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
135
+ client.refreshAccessToken.mockRejectedValue(
136
+ new JiraIntegrationProviderError('timeout', 'refresh timed out'),
137
+ );
138
+
139
+ await expect(store.getAccessToken({connectionId})).rejects.toMatchObject({reason: 'timeout'});
140
+
141
+ await store.storeTokens({connectionId, accessToken: 'access-1', refreshToken: 'refresh-1'});
142
+ getInstallation.mockResolvedValue({tokenExpiresAt: null});
143
+
144
+ await expect(store.getAccessToken({connectionId})).resolves.toBe('access-1');
145
+ });
146
+
147
+ it('ignores a terminal refresh result from before a reconnect', async () => {
148
+ const {client, connectionId, markConnectionError, store} = createStore();
149
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
150
+ let rejectRefresh!: (error: unknown) => void;
151
+ client.refreshAccessToken.mockImplementation(
152
+ () =>
153
+ new Promise<never>((_resolve, reject) => {
154
+ rejectRefresh = reject;
155
+ }),
156
+ );
157
+
158
+ const staleRefresh = store.getAccessToken({connectionId});
159
+ await vi.waitFor(() => expect(client.refreshAccessToken).toHaveBeenCalledOnce());
160
+
161
+ await store.storeTokens({connectionId, accessToken: 'access-1', refreshToken: 'refresh-1'});
162
+ getInstallation.mockResolvedValue({tokenExpiresAt: null});
163
+ await expect(store.getAccessToken({connectionId})).resolves.toBe('access-1');
164
+
165
+ rejectRefresh(new JiraIntegrationProviderError('timeout', 'refresh timed out'));
166
+ await expect(staleRefresh).rejects.toMatchObject({reason: 'timeout'});
167
+ expect(markConnectionError).not.toHaveBeenCalled();
168
+ await expect(store.getAccessToken({connectionId})).resolves.toBe('access-1');
169
+ });
170
+
171
+ it('serializes connection error updates with reconnect credential writes', async () => {
172
+ const {client, connectionId, markConnectionError, store} = createStore();
173
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
174
+ let releaseError!: () => void;
175
+ let errorStarted!: () => void;
176
+ const errorCanFinish = new Promise<void>((resolve) => {
177
+ releaseError = resolve;
178
+ });
179
+ const errorUpdateStarted = new Promise<void>((resolve) => {
180
+ errorStarted = resolve;
181
+ });
182
+ markConnectionError.mockImplementation(async () => {
183
+ errorStarted();
184
+ await errorCanFinish;
185
+ });
186
+ client.refreshAccessToken.mockRejectedValue(
187
+ new JiraIntegrationProviderError('access-denied', 'invalid grant'),
188
+ );
189
+
190
+ const staleRefresh = store.getAccessToken({connectionId});
191
+ await errorUpdateStarted;
192
+
193
+ let reconnectFinished = false;
194
+ const reconnect = store
195
+ .storeTokens({connectionId, accessToken: 'access-1', refreshToken: 'refresh-1'})
196
+ .then(() => {
197
+ reconnectFinished = true;
198
+ });
199
+ await Promise.resolve();
200
+ expect(reconnectFinished).toBe(false);
201
+
202
+ releaseError();
203
+ await expect(staleRefresh).rejects.toMatchObject({reason: 'access-denied'});
204
+ await reconnect;
205
+ expect(markConnectionError).toHaveBeenCalledOnce();
206
+ });
207
+
208
+ it('waits for the shared refresh lock before storing reconnect credentials', async () => {
209
+ const {connectionId, store, values} = createStore();
210
+ let releaseLock!: () => void;
211
+ let lockStarted!: () => void;
212
+ const lockCanFinish = new Promise<void>((resolve) => {
213
+ releaseLock = resolve;
214
+ });
215
+ const lockEntered = new Promise<void>((resolve) => {
216
+ lockStarted = resolve;
217
+ });
218
+ withRefreshLockAndWait.mockImplementationOnce(async (_connectionId, fn) => {
219
+ lockStarted();
220
+ await lockCanFinish;
221
+ return fn();
222
+ });
223
+
224
+ const reconnect = store.storeTokens({
225
+ connectionId,
226
+ accessToken: 'access-1',
227
+ refreshToken: 'refresh-1',
228
+ });
229
+ await lockEntered;
230
+
231
+ expect(values.get('ACCESS_TOKEN')).toBeUndefined();
232
+ releaseLock();
233
+ await reconnect;
234
+ expect(values.get('ACCESS_TOKEN')).toBe('access-1');
235
+ expect(values.get('REFRESH_TOKEN')).toBe('refresh-1');
236
+ expect(withRefreshLockAndWait).toHaveBeenCalledWith(connectionId, expect.any(Function));
237
+ });
238
+
239
+ it('waits for reconnect credentials before starting another refresh', async () => {
240
+ const {client, connectionId, resolveConnection, secrets, store, values, workspaceId} =
241
+ createStore();
242
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
243
+ let releaseConnection!: () => void;
244
+ let connectionStarted!: () => void;
245
+ const connectionCanFinish = new Promise<void>((resolve) => {
246
+ releaseConnection = resolve;
247
+ });
248
+ const connectionLookupStarted = new Promise<void>((resolve) => {
249
+ connectionStarted = resolve;
250
+ });
251
+ resolveConnection.mockImplementationOnce(async () => {
252
+ connectionStarted();
253
+ await connectionCanFinish;
254
+ return {workspaceId, lifecycleStatus: 'active'};
255
+ });
256
+ let releaseReconnect!: () => void;
257
+ let reconnectStarted!: () => void;
258
+ const reconnectCanFinish = new Promise<void>((resolve) => {
259
+ releaseReconnect = resolve;
260
+ });
261
+ const reconnectWriteStarted = new Promise<void>((resolve) => {
262
+ reconnectStarted = resolve;
263
+ });
264
+ secrets.setSecrets.mockImplementation(
265
+ async ({values: next}: {values: Record<string, string>}) => {
266
+ if (next.ACCESS_TOKEN === 'access-1') {
267
+ reconnectStarted();
268
+ await reconnectCanFinish;
269
+ }
270
+ for (const [key, value] of Object.entries(next)) values.set(key, value);
271
+ },
272
+ );
273
+
274
+ const accessToken = store.getAccessToken({connectionId});
275
+ await connectionLookupStarted;
276
+ const reconnect = store.storeTokens({
277
+ connectionId,
278
+ accessToken: 'access-1',
279
+ refreshToken: 'refresh-1',
280
+ });
281
+ await reconnectWriteStarted;
282
+
283
+ releaseConnection();
284
+ await Promise.resolve();
285
+ await Promise.resolve();
286
+ expect(client.refreshAccessToken).not.toHaveBeenCalled();
287
+
288
+ getInstallation.mockResolvedValue({tokenExpiresAt: null});
289
+ releaseReconnect();
290
+ await reconnect;
291
+ await expect(accessToken).resolves.toBe('access-1');
292
+ expect(client.refreshAccessToken).not.toHaveBeenCalled();
293
+ });
294
+
295
+ it('fails closed when a connection resolver omits its lifecycle status', async () => {
296
+ const {connectionId, resolveConnection, store} = createStore();
297
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
298
+ resolveConnection.mockResolvedValue({
299
+ workspaceId: crypto.randomUUID(),
300
+ } as unknown as JiraConnectionResolverResult);
301
+
302
+ await expect(store.getAccessToken({connectionId})).rejects.toBeInstanceOf(
303
+ JiraTokenUnrefreshableError,
304
+ );
305
+ });
306
+
88
307
  it('requires a refresh token once the access token expires', async () => {
89
308
  const {connectionId, store} = createStore();
90
309
  await store.storeTokens({connectionId, accessToken: 'access-0'});
@@ -17,7 +17,7 @@ function createConnectionContext() {
17
17
  const connectionId = crypto.randomUUID();
18
18
  const resolveConnection = vi
19
19
  .fn<(connectionId: string) => Promise<JiraConnectionResolverResult | undefined>>()
20
- .mockResolvedValue({workspaceId});
20
+ .mockResolvedValue({workspaceId, lifecycleStatus: 'active'});
21
21
  const store = createJiraTokenStore({resolveConnection, secrets});
22
22
 
23
23
  return {workspaceId, connectionId, resolveConnection, store};