@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.
- package/.turbo/turbo-build.log +17 -2
- package/CHANGELOG.md +10 -0
- package/README.md +58 -2
- package/dist/api/client.d.ts +19 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +78 -0
- package/dist/api/client.js.map +1 -1
- package/dist/core/agent-tools-provider.d.ts +26 -0
- package/dist/core/agent-tools-provider.d.ts.map +1 -0
- package/dist/core/agent-tools-provider.js +130 -0
- package/dist/core/agent-tools-provider.js.map +1 -0
- package/dist/core/agent-tools.d.ts +16 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +441 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/tokens.d.ts +2 -0
- package/dist/core/tokens.d.ts.map +1 -1
- package/dist/core/tokens.js +129 -37
- package/dist/core/tokens.js.map +1 -1
- package/dist/db/db.d.ts +68 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +23 -7
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +42 -19
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +34 -0
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +12 -2
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +20 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -5
- package/dist/index.js.map +1 -1
- package/dist/temporal/activities/index.d.ts +2 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +3 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
- package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
- package/dist/temporal/constants.d.ts +6 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +8 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/worker.d.ts +9 -0
- package/dist/temporal/worker.d.ts.map +1 -0
- package/dist/temporal/worker.js +26 -0
- package/dist/temporal/worker.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +25225 -0
- package/dist/temporal/workflows/index.bundle.meta.json +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_jira-token-refresh-state.sql +12 -0
- package/drizzle/meta/0001_snapshot.json +245 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +12 -5
- package/src/api/client.test.ts +133 -12
- package/src/api/client.ts +107 -0
- package/src/core/agent-tools-provider.test.ts +299 -0
- package/src/core/agent-tools-provider.ts +175 -0
- package/src/core/agent-tools.ts +450 -0
- package/src/core/tokens-refresh.test.ts +221 -4
- package/src/core/tokens.test.ts +1 -1
- package/src/core/tokens.ts +153 -33
- package/src/db/installations.test.ts +84 -2
- package/src/db/installations.ts +81 -21
- package/src/db/schema/installations.ts +21 -1
- package/src/index.test.ts +34 -0
- package/src/index.ts +47 -3
- package/src/temporal/activities/index.ts +5 -0
- package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
- package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
- package/src/temporal/constants.ts +8 -0
- package/src/temporal/worker.ts +39 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
- package/test/factories/jira-installation.ts +4 -0
- 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
|
|
43
|
+
resolveConnection,
|
|
40
44
|
secrets,
|
|
41
45
|
client,
|
|
42
46
|
markConnectionError,
|
|
43
47
|
});
|
|
44
|
-
return {
|
|
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'});
|
package/src/core/tokens.test.ts
CHANGED
|
@@ -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};
|
package/src/core/tokens.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
() =>
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
210
|
-
await
|
|
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
|
-
|
|
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;
|