@shipfox/api-integration-jira 12.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 (86) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +58 -2
  4. package/dist/api/client.d.ts +19 -0
  5. package/dist/api/client.d.ts.map +1 -1
  6. package/dist/api/client.js +78 -0
  7. package/dist/api/client.js.map +1 -1
  8. package/dist/core/agent-tools-provider.d.ts +26 -0
  9. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  10. package/dist/core/agent-tools-provider.js +130 -0
  11. package/dist/core/agent-tools-provider.js.map +1 -0
  12. package/dist/core/agent-tools.d.ts +16 -0
  13. package/dist/core/agent-tools.d.ts.map +1 -0
  14. package/dist/core/agent-tools.js +441 -0
  15. package/dist/core/agent-tools.js.map +1 -0
  16. package/dist/core/tokens.d.ts +2 -0
  17. package/dist/core/tokens.d.ts.map +1 -1
  18. package/dist/core/tokens.js +129 -37
  19. package/dist/core/tokens.js.map +1 -1
  20. package/dist/db/db.d.ts +68 -0
  21. package/dist/db/db.d.ts.map +1 -1
  22. package/dist/db/installations.d.ts +23 -7
  23. package/dist/db/installations.d.ts.map +1 -1
  24. package/dist/db/installations.js +42 -19
  25. package/dist/db/installations.js.map +1 -1
  26. package/dist/db/schema/installations.d.ts +34 -0
  27. package/dist/db/schema/installations.d.ts.map +1 -1
  28. package/dist/db/schema/installations.js +12 -2
  29. package/dist/db/schema/installations.js.map +1 -1
  30. package/dist/index.d.ts +20 -5
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +17 -5
  33. package/dist/index.js.map +1 -1
  34. package/dist/temporal/activities/index.d.ts +2 -0
  35. package/dist/temporal/activities/index.d.ts.map +1 -0
  36. package/dist/temporal/activities/index.js +3 -0
  37. package/dist/temporal/activities/index.js.map +1 -0
  38. package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
  39. package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
  40. package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
  41. package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
  42. package/dist/temporal/constants.d.ts +6 -0
  43. package/dist/temporal/constants.d.ts.map +1 -0
  44. package/dist/temporal/constants.js +8 -0
  45. package/dist/temporal/constants.js.map +1 -0
  46. package/dist/temporal/worker.d.ts +9 -0
  47. package/dist/temporal/worker.d.ts.map +1 -0
  48. package/dist/temporal/worker.js +26 -0
  49. package/dist/temporal/worker.js.map +1 -0
  50. package/dist/temporal/workflows/index.bundle.js +25225 -0
  51. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  52. package/dist/temporal/workflows/index.d.ts +2 -0
  53. package/dist/temporal/workflows/index.d.ts.map +1 -0
  54. package/dist/temporal/workflows/index.js +3 -0
  55. package/dist/temporal/workflows/index.js.map +1 -0
  56. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
  57. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
  58. package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
  59. package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
  60. package/dist/tsconfig.test.tsbuildinfo +1 -1
  61. package/drizzle/0001_jira-token-refresh-state.sql +12 -0
  62. package/drizzle/meta/0001_snapshot.json +245 -0
  63. package/drizzle/meta/_journal.json +7 -0
  64. package/package.json +12 -5
  65. package/src/api/client.test.ts +133 -12
  66. package/src/api/client.ts +107 -0
  67. package/src/core/agent-tools-provider.test.ts +299 -0
  68. package/src/core/agent-tools-provider.ts +175 -0
  69. package/src/core/agent-tools.ts +450 -0
  70. package/src/core/tokens-refresh.test.ts +221 -4
  71. package/src/core/tokens.test.ts +1 -1
  72. package/src/core/tokens.ts +153 -33
  73. package/src/db/installations.test.ts +84 -2
  74. package/src/db/installations.ts +81 -21
  75. package/src/db/schema/installations.ts +21 -1
  76. package/src/index.test.ts +34 -0
  77. package/src/index.ts +47 -3
  78. package/src/temporal/activities/index.ts +5 -0
  79. package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
  80. package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
  81. package/src/temporal/constants.ts +8 -0
  82. package/src/temporal/worker.ts +39 -0
  83. package/src/temporal/workflows/index.ts +1 -0
  84. package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
  85. package/test/factories/jira-installation.ts +4 -0
  86. package/tsconfig.build.tsbuildinfo +1 -1
@@ -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>();
@@ -35,13 +37,24 @@ function createStore() {
35
37
  };
36
38
  const markConnectionError = vi.fn().mockResolvedValue(undefined);
37
39
  const connectionId = crypto.randomUUID();
40
+ const workspaceId = crypto.randomUUID();
41
+ const resolveConnection = vi.fn().mockResolvedValue({workspaceId, lifecycleStatus: 'active'});
38
42
  const store = createJiraTokenStore({
39
- resolveConnection: vi.fn().mockResolvedValue({workspaceId: crypto.randomUUID()}),
43
+ resolveConnection,
40
44
  secrets,
41
45
  client,
42
46
  markConnectionError,
43
47
  });
44
- 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
+ };
45
58
  }
46
59
 
47
60
  describe('Jira token refresh', () => {
@@ -52,6 +65,7 @@ describe('Jira token refresh', () => {
52
65
  acquired: true,
53
66
  value: await fn(),
54
67
  }));
68
+ withRefreshLockAndWait.mockImplementation(async (_connectionId, fn) => fn());
55
69
  });
56
70
 
57
71
  it('rotates and persists both tokens when the access token expires', async () => {
@@ -87,6 +101,209 @@ describe('Jira token refresh', () => {
87
101
  expect(markConnectionError).toHaveBeenCalledWith({connectionId});
88
102
  });
89
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
+
90
307
  it('requires a refresh token once the access token expires', async () => {
91
308
  const {connectionId, store} = createStore();
92
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};
@@ -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
+ }
@@ -7,7 +7,9 @@ import {
7
7
  getJiraInstallationByCloudId,
8
8
  getJiraInstallationByConnectionId,
9
9
  getJiraInstallationByWebhookId,
10
+ listJiraInstallationsDueForTokenRefresh,
10
11
  markJiraInstallationRevoked,
12
+ markJiraInstallationTokenRefreshAttempt,
11
13
  updateJiraInstallationTokenExpiry,
12
14
  updateJiraInstallationWebhook,
13
15
  upsertJiraInstallation,
@@ -121,10 +123,19 @@ describe('jira installations', () => {
121
123
  });
122
124
 
123
125
  it('updates token metadata and deletes an installation', async () => {
124
- const input = createInstallationInput();
125
- await upsertJiraInstallation(input);
126
+ const input = createInstallationInput({
127
+ refreshTokenLastUsedAt: new Date('2020-01-01T00:00:00.000Z'),
128
+ refreshTokenLastAttemptedAt: new Date('2020-01-01T00:00:00.000Z'),
129
+ });
130
+ const initial = await upsertJiraInstallation(input);
126
131
  const tokenExpiresAt = new Date('2030-01-01T00:00:00.000Z');
127
132
 
133
+ const attempted = await markJiraInstallationTokenRefreshAttempt(input.connectionId);
134
+
135
+ expect(attempted?.refreshTokenLastAttemptedAt.getTime()).toBeGreaterThan(
136
+ initial.refreshTokenLastAttemptedAt.getTime(),
137
+ );
138
+
128
139
  const updated = await updateJiraInstallationTokenExpiry({
129
140
  connectionId: input.connectionId,
130
141
  tokenExpiresAt,
@@ -133,10 +144,81 @@ describe('jira installations', () => {
133
144
  const deleted = await deleteJiraInstallationByConnectionId(input.connectionId);
134
145
 
135
146
  expect(updated).toMatchObject({tokenExpiresAt, scopes: ['read:jira-work', 'write:jira-work']});
147
+ expect(updated?.refreshTokenLastUsedAt.getTime()).toBeGreaterThan(
148
+ initial.refreshTokenLastUsedAt.getTime(),
149
+ );
136
150
  expect(deleted).toBe(true);
137
151
  await expect(getJiraInstallationByConnectionId(input.connectionId)).resolves.toBeUndefined();
138
152
  });
139
153
 
154
+ it('lists only installed installations whose refresh tokens are nearing idle expiry', async () => {
155
+ const due = createInstallationInput({
156
+ refreshTokenLastUsedAt: new Date('2026-01-01T00:00:00.000Z'),
157
+ });
158
+ const fresh = createInstallationInput({
159
+ refreshTokenLastUsedAt: new Date('2026-07-01T00:00:00.000Z'),
160
+ });
161
+ const boundary = createInstallationInput({
162
+ refreshTokenLastUsedAt: new Date('2026-06-01T00:00:00.000Z'),
163
+ });
164
+ const revoked = createInstallationInput({
165
+ refreshTokenLastUsedAt: new Date('2026-01-01T00:00:00.000Z'),
166
+ status: 'revoked',
167
+ });
168
+ await upsertJiraInstallation(due);
169
+ await upsertJiraInstallation(fresh);
170
+ await upsertJiraInstallation(boundary);
171
+ await upsertJiraInstallation(revoked);
172
+
173
+ const result = await listJiraInstallationsDueForTokenRefresh({
174
+ before: new Date('2026-06-01T00:00:00.000Z'),
175
+ });
176
+
177
+ expect(result.map((installation) => installation.connectionId)).toContain(due.connectionId);
178
+ expect(result.map((installation) => installation.connectionId)).toContain(
179
+ boundary.connectionId,
180
+ );
181
+ expect(result.map((installation) => installation.connectionId)).not.toContain(
182
+ fresh.connectionId,
183
+ );
184
+ expect(result.map((installation) => installation.connectionId)).not.toContain(
185
+ revoked.connectionId,
186
+ );
187
+ });
188
+
189
+ it('orders due installations by their latest refresh attempt', async () => {
190
+ const refreshTokenLastUsedAt = new Date('1970-01-01T00:00:00.000Z');
191
+ const attemptBase = Date.now();
192
+ const latestAttempt = createInstallationInput({
193
+ refreshTokenLastUsedAt,
194
+ refreshTokenLastAttemptedAt: new Date(attemptBase + 3_000),
195
+ });
196
+ const middleAttempt = createInstallationInput({
197
+ refreshTokenLastUsedAt,
198
+ refreshTokenLastAttemptedAt: new Date(attemptBase + 2_000),
199
+ });
200
+ const oldestAttempt = createInstallationInput({
201
+ refreshTokenLastUsedAt,
202
+ refreshTokenLastAttemptedAt: new Date(attemptBase + 1_000),
203
+ });
204
+ await upsertJiraInstallation(latestAttempt);
205
+ await upsertJiraInstallation(middleAttempt);
206
+ await upsertJiraInstallation(oldestAttempt);
207
+
208
+ const result = await listJiraInstallationsDueForTokenRefresh({
209
+ before: new Date('2000-01-01T00:00:00.000Z'),
210
+ limit: 1_000,
211
+ });
212
+ const connectionIds = result.map((installation) => installation.connectionId);
213
+
214
+ expect(connectionIds.indexOf(oldestAttempt.connectionId)).toBeLessThan(
215
+ connectionIds.indexOf(middleAttempt.connectionId),
216
+ );
217
+ expect(connectionIds.indexOf(middleAttempt.connectionId)).toBeLessThan(
218
+ connectionIds.indexOf(latestAttempt.connectionId),
219
+ );
220
+ });
221
+
140
222
  it('returns undefined for unknown connection and webhook ids', async () => {
141
223
  const connectionId = crypto.randomUUID();
142
224
  const webhookId = Number.MAX_SAFE_INTEGER;