@shipfox/api-integration-jira 12.2.0 → 12.4.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 (91) hide show
  1. package/.turbo/turbo-build.log +8 -7
  2. package/.turbo/turbo-type$colon$emit.log +0 -1
  3. package/CHANGELOG.md +17 -0
  4. package/README.md +5 -1
  5. package/dist/api/client.d.ts +11 -1
  6. package/dist/api/client.d.ts.map +1 -1
  7. package/dist/api/client.js +57 -12
  8. package/dist/api/client.js.map +1 -1
  9. package/dist/core/disconnect.d.ts +12 -0
  10. package/dist/core/disconnect.d.ts.map +1 -1
  11. package/dist/core/disconnect.js +45 -1
  12. package/dist/core/disconnect.js.map +1 -1
  13. package/dist/core/install.d.ts +1 -0
  14. package/dist/core/install.d.ts.map +1 -1
  15. package/dist/core/install.js +2 -1
  16. package/dist/core/install.js.map +1 -1
  17. package/dist/core/tokens.d.ts +2 -0
  18. package/dist/core/tokens.d.ts.map +1 -1
  19. package/dist/core/tokens.js +2 -2
  20. package/dist/core/tokens.js.map +1 -1
  21. package/dist/core/webhook-registration.d.ts +1 -0
  22. package/dist/core/webhook-registration.d.ts.map +1 -1
  23. package/dist/core/webhook-registration.js +3 -1
  24. package/dist/core/webhook-registration.js.map +1 -1
  25. package/dist/core/webhook-url.d.ts +3 -0
  26. package/dist/core/webhook-url.d.ts.map +1 -0
  27. package/dist/core/webhook-url.js +8 -0
  28. package/dist/core/webhook-url.js.map +1 -0
  29. package/dist/db/installations.d.ts +13 -0
  30. package/dist/db/installations.d.ts.map +1 -1
  31. package/dist/db/installations.js +18 -1
  32. package/dist/db/installations.js.map +1 -1
  33. package/dist/index.d.ts +17 -4
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +5 -7
  36. package/dist/index.js.map +1 -1
  37. package/dist/presentation/routes/webhooks.d.ts +2 -1
  38. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  39. package/dist/presentation/routes/webhooks.js +2 -1
  40. package/dist/presentation/routes/webhooks.js.map +1 -1
  41. package/dist/temporal/activities/index.d.ts +8 -1
  42. package/dist/temporal/activities/index.d.ts.map +1 -1
  43. package/dist/temporal/activities/index.js +8 -1
  44. package/dist/temporal/activities/index.js.map +1 -1
  45. package/dist/temporal/activities/renew-jira-webhooks.d.ts +26 -0
  46. package/dist/temporal/activities/renew-jira-webhooks.d.ts.map +1 -0
  47. package/dist/temporal/activities/renew-jira-webhooks.js +96 -0
  48. package/dist/temporal/activities/renew-jira-webhooks.js.map +1 -0
  49. package/dist/temporal/constants.d.ts +2 -0
  50. package/dist/temporal/constants.d.ts.map +1 -1
  51. package/dist/temporal/constants.js +2 -0
  52. package/dist/temporal/constants.js.map +1 -1
  53. package/dist/temporal/worker.d.ts +3 -0
  54. package/dist/temporal/worker.d.ts.map +1 -1
  55. package/dist/temporal/worker.js +5 -0
  56. package/dist/temporal/worker.js.map +1 -1
  57. package/dist/temporal/workflows/index.bundle.js +38 -2
  58. package/dist/temporal/workflows/index.d.ts +1 -0
  59. package/dist/temporal/workflows/index.d.ts.map +1 -1
  60. package/dist/temporal/workflows/index.js +1 -0
  61. package/dist/temporal/workflows/index.js.map +1 -1
  62. package/dist/temporal/workflows/renew-jira-webhooks-cron.d.ts +2 -0
  63. package/dist/temporal/workflows/renew-jira-webhooks-cron.d.ts.map +1 -0
  64. package/dist/temporal/workflows/renew-jira-webhooks-cron.js +18 -0
  65. package/dist/temporal/workflows/renew-jira-webhooks-cron.js.map +1 -0
  66. package/dist/tsconfig.test.tsbuildinfo +1 -1
  67. package/package.json +2 -2
  68. package/src/api/client.test.ts +83 -4
  69. package/src/api/client.ts +70 -8
  70. package/src/core/disconnect.test.ts +107 -1
  71. package/src/core/disconnect.ts +64 -1
  72. package/src/core/install.test.ts +23 -0
  73. package/src/core/install.ts +5 -2
  74. package/src/core/tokens-refresh.test.ts +39 -0
  75. package/src/core/tokens.ts +4 -2
  76. package/src/core/webhook-registration.test.ts +32 -0
  77. package/src/core/webhook-registration.ts +4 -1
  78. package/src/core/webhook-url.ts +9 -0
  79. package/src/db/installations.test.ts +82 -0
  80. package/src/db/installations.ts +54 -1
  81. package/src/index.test.ts +21 -1
  82. package/src/index.ts +21 -9
  83. package/src/presentation/routes/webhooks.ts +3 -1
  84. package/src/temporal/activities/index.ts +20 -3
  85. package/src/temporal/activities/renew-jira-webhooks.test.ts +215 -0
  86. package/src/temporal/activities/renew-jira-webhooks.ts +144 -0
  87. package/src/temporal/constants.ts +2 -0
  88. package/src/temporal/worker.ts +11 -0
  89. package/src/temporal/workflows/index.ts +1 -0
  90. package/src/temporal/workflows/renew-jira-webhooks-cron.ts +17 -0
  91. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-jira",
3
3
  "license": "MIT",
4
- "version": "12.2.0",
4
+ "version": "12.4.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -28,7 +28,7 @@
28
28
  "ky": "^2.0.0",
29
29
  "zod": "^4.4.3",
30
30
  "@shipfox/api-auth-context": "12.2.0",
31
- "@shipfox/api-integration-spi": "1.0.1",
31
+ "@shipfox/api-integration-spi": "1.1.0",
32
32
  "@shipfox/api-integration-jira-dto": "12.2.0",
33
33
  "@shipfox/config": "1.2.4",
34
34
  "@shipfox/node-drizzle": "0.3.5",
@@ -5,6 +5,7 @@ import {mapJiraError} from './client.js';
5
5
  const mocks = vi.hoisted(() => ({
6
6
  delete: vi.fn(),
7
7
  post: vi.fn(),
8
+ put: vi.fn(),
8
9
  request: vi.fn(),
9
10
  warn: vi.fn(),
10
11
  }));
@@ -31,7 +32,11 @@ vi.mock('ky', () => {
31
32
  }
32
33
  }
33
34
  return {
34
- default: Object.assign(mocks.request, {delete: mocks.delete, post: mocks.post}),
35
+ default: Object.assign(mocks.request, {
36
+ delete: mocks.delete,
37
+ post: mocks.post,
38
+ put: mocks.put,
39
+ }),
35
40
  HTTPError: MockHTTPError,
36
41
  TimeoutError: MockTimeoutError,
37
42
  };
@@ -80,18 +85,22 @@ describe('mapJiraError', () => {
80
85
  });
81
86
 
82
87
  function resolves(data: unknown) {
83
- return {json: () => Promise.resolve(data)};
88
+ return {
89
+ json: () => Promise.resolve(data),
90
+ text: () => Promise.resolve(data === undefined ? '' : JSON.stringify(data)),
91
+ };
84
92
  }
85
93
 
86
94
  describe('Jira dynamic webhook API', () => {
87
95
  beforeEach(() => {
88
96
  mocks.delete.mockReset();
89
97
  mocks.post.mockReset();
98
+ mocks.put.mockReset();
90
99
  mocks.request.mockReset();
91
100
  mocks.warn.mockReset();
92
101
  });
93
102
 
94
- it('registers the six curated events with the access token', async () => {
103
+ it('sends the six curated events with the configured JQL filter', async () => {
95
104
  mocks.post.mockReturnValue(
96
105
  resolves({webhookRegistrationResult: [{createdWebhookId: 123, errors: []}]}),
97
106
  );
@@ -121,7 +130,7 @@ describe('Jira dynamic webhook API', () => {
121
130
  'comment_updated',
122
131
  'comment_deleted',
123
132
  ],
124
- jqlFilter: '',
133
+ jqlFilter: 'project != null',
125
134
  },
126
135
  ],
127
136
  });
@@ -322,6 +331,76 @@ describe('Jira dynamic webhook API', () => {
322
331
  }),
323
332
  );
324
333
  });
334
+
335
+ it('refreshes dynamic webhooks and returns the provider expiration date', async () => {
336
+ const expirationDate = '2030-01-31T00:00:00.000Z';
337
+ mocks.put.mockReturnValue(resolves({expirationDate}));
338
+ const {createJiraApiClient} = await import('./client.js');
339
+
340
+ await expect(
341
+ createJiraApiClient().refreshDynamicWebhooks({
342
+ accessToken: 'access-token',
343
+ cloudId: 'cloud-1',
344
+ webhookIds: [123, 456],
345
+ }),
346
+ ).resolves.toEqual(new Date(expirationDate));
347
+
348
+ expect(mocks.put).toHaveBeenCalledWith(
349
+ 'http://127.0.0.1:0/ex/jira/cloud-1/rest/api/3/webhook/refresh',
350
+ expect.objectContaining({
351
+ headers: {authorization: 'Bearer access-token'},
352
+ json: {webhookIds: [123, 456]},
353
+ }),
354
+ );
355
+ });
356
+
357
+ it.each([
358
+ {},
359
+ null,
360
+ undefined,
361
+ ])('returns undefined for an empty refresh response: %j', async (response) => {
362
+ mocks.put.mockReturnValue(resolves(response));
363
+ const {createJiraApiClient} = await import('./client.js');
364
+
365
+ await expect(
366
+ createJiraApiClient().refreshDynamicWebhooks({
367
+ accessToken: 'access-token',
368
+ cloudId: 'cloud-1',
369
+ webhookIds: [123],
370
+ }),
371
+ ).resolves.toBeUndefined();
372
+ });
373
+
374
+ it.each([
375
+ [],
376
+ 'malformed',
377
+ 42,
378
+ false,
379
+ ])('rejects a non-object refresh response: %j', async (response) => {
380
+ mocks.put.mockReturnValue(resolves(response));
381
+ const {createJiraApiClient} = await import('./client.js');
382
+
383
+ await expect(
384
+ createJiraApiClient().refreshDynamicWebhooks({
385
+ accessToken: 'access-token',
386
+ cloudId: 'cloud-1',
387
+ webhookIds: [123],
388
+ }),
389
+ ).rejects.toMatchObject({reason: 'malformed-provider-response'});
390
+ });
391
+
392
+ it.each([400, 404])('treats refresh HTTP %i as a signal to re-register', async (status) => {
393
+ mocks.put.mockImplementation(rejectedRequest(status));
394
+ const {createJiraApiClient} = await import('./client.js');
395
+
396
+ await expect(
397
+ createJiraApiClient().refreshDynamicWebhooks({
398
+ accessToken: 'access-token',
399
+ cloudId: 'cloud-1',
400
+ webhookIds: [123],
401
+ }),
402
+ ).resolves.toBeUndefined();
403
+ });
325
404
  });
326
405
 
327
406
  describe('Jira agent-tools REST API', () => {
package/src/api/client.ts CHANGED
@@ -8,7 +8,8 @@ const JIRA_API_TIMEOUT_MS = 10_000;
8
8
  const SCOPE_SEPARATOR_RE = /[,\s]+/;
9
9
  const TRAILING_SLASHES_RE = /\/+$/;
10
10
  export const JIRA_DYNAMIC_WEBHOOK_EVENTS = jiraWebhookEventNames;
11
- export const JIRA_DYNAMIC_WEBHOOK_JQL = '';
11
+ // Jira rejects an empty dynamic-webhook filter; every issue belongs to a project.
12
+ export const JIRA_DYNAMIC_WEBHOOK_JQL = 'project != null';
12
13
 
13
14
  export interface JiraAuthorization {
14
15
  accessToken: string;
@@ -70,6 +71,16 @@ export interface JiraApiClient {
70
71
  cloudId: string;
71
72
  url: string;
72
73
  }): Promise<JiraDynamicWebhookRegistration>;
74
+ refreshDynamicWebhooks(input: {
75
+ accessToken: string;
76
+ cloudId: string;
77
+ webhookIds: number[];
78
+ }): Promise<Date | undefined>;
79
+ deleteDynamicWebhooks(input: {
80
+ accessToken: string;
81
+ cloudId: string;
82
+ webhookIds: number[];
83
+ }): Promise<void>;
73
84
  deleteDynamicWebhook(input: {
74
85
  accessToken: string;
75
86
  cloudId: string;
@@ -102,6 +113,16 @@ interface JiraDynamicWebhookRegistrationResponse {
102
113
  const JIRA_REFRESH_TOKEN_TERMINAL_ERROR_CODES = new Set(['invalid_grant', 'unauthorized_client']);
103
114
 
104
115
  export function createJiraApiClient(): JiraApiClient {
116
+ const deleteDynamicWebhooks: JiraApiClient['deleteDynamicWebhooks'] = async (input) => {
117
+ await mapJiraError('delete-dynamic-webhooks', () =>
118
+ ky.delete(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook`, {
119
+ headers: {authorization: `Bearer ${input.accessToken}`},
120
+ json: {webhookIds: input.webhookIds},
121
+ timeout: JIRA_API_TIMEOUT_MS,
122
+ }),
123
+ );
124
+ };
125
+
105
126
  return {
106
127
  async exchangeAuthorizationCode(input) {
107
128
  const body = await mapJiraError('exchange-authorization-code', () =>
@@ -189,14 +210,38 @@ export function createJiraApiClient(): JiraApiClient {
189
210
  return parseDynamicWebhookRegistration(body);
190
211
  },
191
212
 
213
+ async refreshDynamicWebhooks(input) {
214
+ const response = await mapJiraError('refresh-dynamic-webhooks', async () => {
215
+ try {
216
+ return await ky.put(
217
+ `${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook/refresh`,
218
+ {
219
+ headers: {authorization: `Bearer ${input.accessToken}`},
220
+ json: {webhookIds: input.webhookIds},
221
+ timeout: JIRA_API_TIMEOUT_MS,
222
+ },
223
+ );
224
+ } catch (error) {
225
+ if (
226
+ error instanceof HTTPError &&
227
+ (error.response.status === 400 || error.response.status === 404)
228
+ )
229
+ return undefined;
230
+ throw error;
231
+ }
232
+ });
233
+ if (!response) return undefined;
234
+ return parseDynamicWebhookExpiration(await readJiraResponseBody(response));
235
+ },
236
+
237
+ deleteDynamicWebhooks,
238
+
192
239
  async deleteDynamicWebhook(input) {
193
- await mapJiraError('delete-dynamic-webhook', () =>
194
- ky.delete(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook`, {
195
- headers: {authorization: `Bearer ${input.accessToken}`},
196
- json: {webhookIds: [input.webhookId]},
197
- timeout: JIRA_API_TIMEOUT_MS,
198
- }),
199
- );
240
+ await deleteDynamicWebhooks({
241
+ accessToken: input.accessToken,
242
+ cloudId: input.cloudId,
243
+ webhookIds: [input.webhookId],
244
+ });
200
245
  },
201
246
  };
202
247
  }
@@ -309,6 +354,23 @@ function parseDynamicWebhookRegistration(
309
354
  return {webhookId: createdWebhookId};
310
355
  }
311
356
 
357
+ function parseDynamicWebhookExpiration(body: unknown): Date | undefined {
358
+ if (body === undefined || body === null) return undefined;
359
+ if (typeof body !== 'object' || Array.isArray(body)) {
360
+ throw malformed('Jira webhook refresh response was malformed');
361
+ }
362
+ const expirationDate = (body as {expirationDate?: unknown}).expirationDate;
363
+ if (expirationDate === undefined) return undefined;
364
+ if (typeof expirationDate !== 'string') {
365
+ throw malformed('Jira webhook refresh response included a malformed expiration date');
366
+ }
367
+ const parsed = new Date(expirationDate);
368
+ if (Number.isNaN(parsed.getTime())) {
369
+ throw malformed('Jira webhook refresh response included a malformed expiration date');
370
+ }
371
+ return parsed;
372
+ }
373
+
312
374
  function parseAuthorization(body: JiraTokenResponse): JiraAuthorization {
313
375
  if (typeof body.access_token !== 'string' || body.access_token.length === 0) {
314
376
  throw malformed('Jira authorization response did not include an access token');
@@ -1,8 +1,12 @@
1
+ const {getInstallation} = vi.hoisted(() => ({getInstallation: vi.fn()}));
2
+
1
3
  vi.mock('#db/installations.js', () => ({
2
4
  deleteJiraInstallationByConnectionId: vi.fn().mockResolvedValue(true),
5
+ getJiraInstallationByConnectionId: getInstallation,
3
6
  }));
4
7
 
5
- import {disconnectJiraInstallation} from './disconnect.js';
8
+ import type {JiraInstallation} from '#db/installations.js';
9
+ import {deregisterJiraWebhooks, disconnectJiraInstallation} from './disconnect.js';
6
10
 
7
11
  describe('disconnectJiraInstallation', () => {
8
12
  it('deletes secrets, installation, and connection when the connection exists', async () => {
@@ -21,4 +25,106 @@ describe('disconnectJiraInstallation', () => {
21
25
  expect(deleteSecrets).toHaveBeenCalledOnce();
22
26
  expect(deleteConnection).toHaveBeenCalledOnce();
23
27
  });
28
+
29
+ it('deregisters webhooks before deletion and continues when the hook fails', async () => {
30
+ const deleteSecrets = vi.fn().mockResolvedValue(1);
31
+ const deleteConnection = vi.fn().mockResolvedValue(true);
32
+ const deregisterWebhooks = vi.fn().mockRejectedValue(new Error('Jira unavailable'));
33
+ const transaction = vi.fn(async (fn) => await fn({}));
34
+
35
+ await disconnectJiraInstallation({
36
+ connectionId: crypto.randomUUID(),
37
+ getConnection: vi.fn().mockResolvedValue({workspaceId: crypto.randomUUID()}),
38
+ deleteSecrets,
39
+ deregisterWebhooks,
40
+ transaction,
41
+ deleteConnection,
42
+ });
43
+
44
+ expect(deregisterWebhooks).toHaveBeenCalledOnce();
45
+ expect(deleteSecrets).toHaveBeenCalledOnce();
46
+ expect(deleteConnection).toHaveBeenCalledOnce();
47
+ });
48
+
49
+ it('uses a fresh access token and the stored webhook ids for deregistration', async () => {
50
+ const connectionId = crypto.randomUUID();
51
+ const getAccessToken = vi.fn().mockResolvedValue('fresh-access-token');
52
+ const deleteDynamicWebhooks = vi.fn().mockResolvedValue(undefined);
53
+
54
+ await deregisterJiraWebhooks({
55
+ connectionId,
56
+ tokenStore: {getAccessToken},
57
+ jira: {deleteDynamicWebhooks},
58
+ getInstallation: vi.fn().mockResolvedValue({
59
+ cloudId: 'cloud-1',
60
+ webhookIds: [123, 456],
61
+ } as JiraInstallation),
62
+ });
63
+
64
+ expect(getAccessToken).toHaveBeenCalledWith({connectionId, allowInactive: true});
65
+ expect(deleteDynamicWebhooks).toHaveBeenCalledWith({
66
+ accessToken: 'fresh-access-token',
67
+ cloudId: 'cloud-1',
68
+ webhookIds: [123, 456],
69
+ });
70
+ });
71
+
72
+ it('uses the database installation lookup by default', async () => {
73
+ const connectionId = crypto.randomUUID();
74
+ const getAccessToken = vi.fn().mockResolvedValue('fresh-access-token');
75
+ const deleteDynamicWebhooks = vi.fn().mockResolvedValue(undefined);
76
+ getInstallation.mockResolvedValue({
77
+ cloudId: 'cloud-1',
78
+ webhookIds: [123],
79
+ } as JiraInstallation);
80
+
81
+ await deregisterJiraWebhooks({
82
+ connectionId,
83
+ tokenStore: {getAccessToken},
84
+ jira: {deleteDynamicWebhooks},
85
+ });
86
+
87
+ expect(getInstallation).toHaveBeenCalledWith(connectionId);
88
+ expect(deleteDynamicWebhooks).toHaveBeenCalledWith({
89
+ accessToken: 'fresh-access-token',
90
+ cloudId: 'cloud-1',
91
+ webhookIds: [123],
92
+ });
93
+ });
94
+
95
+ it.each([
96
+ {name: 'the installation is gone', installation: undefined},
97
+ {name: 'no webhook ids are stored', installation: {cloudId: 'cloud-1', webhookIds: []}},
98
+ ])('skips deregistration and token minting when $name', async ({installation}) => {
99
+ const getAccessToken = vi.fn();
100
+ const deleteDynamicWebhooks = vi.fn();
101
+
102
+ await deregisterJiraWebhooks({
103
+ connectionId: crypto.randomUUID(),
104
+ tokenStore: {getAccessToken},
105
+ jira: {deleteDynamicWebhooks},
106
+ getInstallation: vi.fn().mockResolvedValue(installation as JiraInstallation | undefined),
107
+ });
108
+
109
+ expect(getAccessToken).not.toHaveBeenCalled();
110
+ expect(deleteDynamicWebhooks).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('swallows a rejected Jira deletion so the disconnect can proceed', async () => {
114
+ const deleteDynamicWebhooks = vi.fn().mockRejectedValue(new Error('Jira unavailable'));
115
+
116
+ await expect(
117
+ deregisterJiraWebhooks({
118
+ connectionId: crypto.randomUUID(),
119
+ tokenStore: {getAccessToken: vi.fn().mockResolvedValue('fresh-access-token')},
120
+ jira: {deleteDynamicWebhooks},
121
+ getInstallation: vi.fn().mockResolvedValue({
122
+ cloudId: 'cloud-1',
123
+ webhookIds: [123],
124
+ } as JiraInstallation),
125
+ }),
126
+ ).resolves.toBeUndefined();
127
+
128
+ expect(deleteDynamicWebhooks).toHaveBeenCalledOnce();
129
+ });
24
130
  });
@@ -1,10 +1,65 @@
1
- import {deleteJiraInstallationByConnectionId} from '#db/installations.js';
1
+ import {logger} from '@shipfox/node-opentelemetry';
2
+ import type {JiraApiClient} from '#api/client.js';
3
+ import {
4
+ deleteJiraInstallationByConnectionId,
5
+ getJiraInstallationByConnectionId,
6
+ type JiraInstallation,
7
+ } from '#db/installations.js';
8
+ import type {JiraTokenStore} from './tokens.js';
2
9
  import {jiraSecretsNamespace} from './tokens.js';
3
10
 
11
+ export interface DeregisterJiraWebhooksParams {
12
+ connectionId: string;
13
+ tokenStore: Pick<JiraTokenStore, 'getAccessToken'>;
14
+ jira: Pick<JiraApiClient, 'deleteDynamicWebhooks'>;
15
+ getInstallation?: (connectionId: string) => Promise<JiraInstallation | undefined>;
16
+ }
17
+
18
+ export async function prepareJiraWebhookDeregistration(
19
+ params: DeregisterJiraWebhooksParams,
20
+ ): Promise<(() => Promise<void>) | undefined> {
21
+ try {
22
+ const getInstallation = params.getInstallation ?? getJiraInstallationByConnectionId;
23
+ const installation = await getInstallation(params.connectionId);
24
+ if (!installation || installation.webhookIds.length === 0) return undefined;
25
+
26
+ const accessToken = await params.tokenStore.getAccessToken({
27
+ connectionId: params.connectionId,
28
+ allowInactive: true,
29
+ });
30
+ return async () => {
31
+ try {
32
+ await params.jira.deleteDynamicWebhooks({
33
+ accessToken,
34
+ cloudId: installation.cloudId,
35
+ webhookIds: installation.webhookIds,
36
+ });
37
+ } catch (error) {
38
+ logger().warn(
39
+ {err: error, connectionId: params.connectionId},
40
+ 'Jira webhook deregistration failed during disconnect',
41
+ );
42
+ }
43
+ };
44
+ } catch (error) {
45
+ logger().warn(
46
+ {err: error, connectionId: params.connectionId},
47
+ 'Jira webhook deregistration preparation failed during disconnect',
48
+ );
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ export async function deregisterJiraWebhooks(params: DeregisterJiraWebhooksParams): Promise<void> {
54
+ const cleanup = await prepareJiraWebhookDeregistration(params);
55
+ await cleanup?.();
56
+ }
57
+
4
58
  export interface DisconnectJiraInstallationParams<Tx = unknown> {
5
59
  connectionId: string;
6
60
  getConnection(connectionId: string): Promise<{workspaceId: string} | undefined>;
7
61
  deleteSecrets(params: {workspaceId: string; namespace: string}): Promise<number>;
62
+ deregisterWebhooks?: (() => Promise<void>) | undefined;
8
63
  transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
9
64
  deleteConnection(params: {connectionId: string}, options: {tx: Tx}): Promise<boolean>;
10
65
  }
@@ -13,6 +68,14 @@ export async function disconnectJiraInstallation<Tx = unknown>(
13
68
  params: DisconnectJiraInstallationParams<Tx>,
14
69
  ): Promise<void> {
15
70
  const connection = await params.getConnection(params.connectionId);
71
+ try {
72
+ await params.deregisterWebhooks?.();
73
+ } catch (error) {
74
+ logger().warn(
75
+ {err: error, connectionId: params.connectionId},
76
+ 'Jira webhook deregistration hook failed during disconnect',
77
+ );
78
+ }
16
79
  if (connection)
17
80
  await params.deleteSecrets({
18
81
  workspaceId: connection.workspaceId,
@@ -21,6 +21,8 @@ function createParams() {
21
21
  getMyself: vi.fn().mockResolvedValue({accountId: 'account-1'}),
22
22
  refreshAccessToken: vi.fn(),
23
23
  registerDynamicWebhook: vi.fn(),
24
+ refreshDynamicWebhooks: vi.fn(),
25
+ deleteDynamicWebhooks: vi.fn(),
24
26
  deleteDynamicWebhook: vi.fn(),
25
27
  };
26
28
  const tokenStore = {storeTokens: vi.fn().mockResolvedValue(undefined)};
@@ -190,6 +192,27 @@ describe('Jira OAuth installation', () => {
190
192
  expect(params.disconnectJiraInstallation).not.toHaveBeenCalled();
191
193
  });
192
194
 
195
+ it('compensates a new connection without reacquiring the installation lock', async () => {
196
+ const params = createParams();
197
+ params.jira.getAccessibleResources.mockResolvedValue([
198
+ {
199
+ cloudId: 'cloud-1',
200
+ name: 'Acme',
201
+ url: 'https://acme.atlassian.net',
202
+ scopes: ['read:jira-work'],
203
+ },
204
+ ]);
205
+ const storageError = new Error('secret storage unavailable');
206
+ params.tokenStore.storeTokens.mockRejectedValue(storageError);
207
+
208
+ await expect(handleJiraCallback(params)).rejects.toBe(storageError);
209
+
210
+ expect(params.disconnectJiraInstallation).toHaveBeenCalledWith({
211
+ connectionId: 'connection-1',
212
+ lockAlreadyHeld: true,
213
+ });
214
+ });
215
+
193
216
  it('falls back to a second error-state write when the registration callback cannot persist it', async () => {
194
217
  const params = createParams();
195
218
  params.jira.getAccessibleResources.mockResolvedValue([
@@ -58,7 +58,10 @@ export interface HandleJiraCallbackParams {
58
58
  withJiraInstallationLock?: JiraInstallationLock;
59
59
  markConnectionActive(input: {connectionId: string; tx?: unknown}): Promise<void>;
60
60
  markConnectionError(input: {connectionId: string; tx?: unknown}): Promise<void>;
61
- disconnectJiraInstallation(input: {connectionId: string}): Promise<void>;
61
+ disconnectJiraInstallation(input: {
62
+ connectionId: string;
63
+ lockAlreadyHeld?: boolean | undefined;
64
+ }): Promise<void>;
62
65
  }
63
66
 
64
67
  export async function handleJiraCallback(
@@ -226,7 +229,7 @@ async function bestEffortDisconnect(
226
229
  connectionId: string,
227
230
  ): Promise<void> {
228
231
  try {
229
- await params.disconnectJiraInstallation({connectionId});
232
+ await params.disconnectJiraInstallation({connectionId, lockAlreadyHeld: true});
230
233
  } catch (error) {
231
234
  logger().warn(
232
235
  {err: error, connectionId},
@@ -33,6 +33,8 @@ function createStore() {
33
33
  getAccessibleResources: vi.fn(),
34
34
  getMyself: vi.fn(),
35
35
  registerDynamicWebhook: vi.fn(),
36
+ refreshDynamicWebhooks: vi.fn(),
37
+ deleteDynamicWebhooks: vi.fn(),
36
38
  deleteDynamicWebhook: vi.fn(),
37
39
  };
38
40
  const markConnectionError = vi.fn().mockResolvedValue(undefined);
@@ -304,6 +306,43 @@ describe('Jira token refresh', () => {
304
306
  );
305
307
  });
306
308
 
309
+ it('allows cleanup to use the stored token for an inactive connection', async () => {
310
+ const {connectionId, resolveConnection, store} = createStore();
311
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
312
+ resolveConnection.mockResolvedValue({
313
+ workspaceId: crypto.randomUUID(),
314
+ lifecycleStatus: 'error',
315
+ });
316
+ getInstallation.mockResolvedValue({tokenExpiresAt: null});
317
+
318
+ await expect(store.getAccessToken({connectionId})).rejects.toBeInstanceOf(
319
+ JiraTokenUnrefreshableError,
320
+ );
321
+ await expect(store.getAccessToken({connectionId, allowInactive: true})).resolves.toBe(
322
+ 'access-0',
323
+ );
324
+ });
325
+
326
+ it('refreshes an expired token for inactive cleanup', async () => {
327
+ const {client, connectionId, resolveConnection, store} = createStore();
328
+ await store.storeTokens({connectionId, accessToken: 'access-0', refreshToken: 'refresh-0'});
329
+ resolveConnection.mockResolvedValue({
330
+ workspaceId: crypto.randomUUID(),
331
+ lifecycleStatus: 'error',
332
+ });
333
+ client.refreshAccessToken.mockResolvedValue({
334
+ accessToken: 'access-1',
335
+ refreshToken: 'refresh-1',
336
+ expiresAt: new Date('2030-01-01'),
337
+ scopes: [],
338
+ });
339
+
340
+ await expect(store.getAccessToken({connectionId, allowInactive: true})).resolves.toBe(
341
+ 'access-1',
342
+ );
343
+ expect(client.refreshAccessToken).toHaveBeenCalledWith({refreshToken: 'refresh-0'});
344
+ });
345
+
307
346
  it('requires a refresh token once the access token expires', async () => {
308
347
  const {connectionId, store} = createStore();
309
348
  await store.storeTokens({connectionId, accessToken: 'access-0'});
@@ -50,6 +50,8 @@ export interface StoreJiraTokensParams {
50
50
  export interface GetJiraAccessTokenParams {
51
51
  connectionId: string;
52
52
  forceRefresh?: boolean | undefined;
53
+ /** Allows cleanup to use the stored token after a connection leaves the active state. */
54
+ allowInactive?: boolean | undefined;
53
55
  }
54
56
 
55
57
  export interface JiraTokenStore {
@@ -139,11 +141,11 @@ export function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTo
139
141
  async getAccessToken(input) {
140
142
  await waitForCredentialWrites(input.connectionId);
141
143
  const connection = await resolveConnection(input.connectionId);
142
- if (connection.lifecycleStatus !== 'active') {
144
+ if (!input.allowInactive && connection.lifecycleStatus !== 'active') {
143
145
  throw new JiraTokenUnrefreshableError(input.connectionId);
144
146
  }
145
147
  await waitForCredentialWrites(input.connectionId);
146
- if (refreshStateUnknownConnections.has(input.connectionId)) {
148
+ if (!input.allowInactive && refreshStateUnknownConnections.has(input.connectionId)) {
147
149
  throw new JiraTokenUnrefreshableError(input.connectionId);
148
150
  }
149
151
  const credentialGeneration = currentCredentialGeneration(input.connectionId);
@@ -275,6 +275,38 @@ describe('registerJiraWebhook', () => {
275
275
  expect(deleteDynamicWebhook).toHaveBeenCalledTimes(2);
276
276
  });
277
277
 
278
+ it('retains a failed superseded webhook id when renewal replaces stale metadata', async () => {
279
+ const updateInstallation = vi
280
+ .fn()
281
+ .mockResolvedValueOnce({id: 'installation-1'})
282
+ .mockResolvedValueOnce({id: 'installation-1'});
283
+ const deleteDynamicWebhook = vi.fn().mockRejectedValue(new Error('webhook is gone'));
284
+
285
+ await registerJiraWebhook({
286
+ jira: {
287
+ registerDynamicWebhook: vi.fn().mockResolvedValue({webhookId: 456}),
288
+ deleteDynamicWebhook,
289
+ },
290
+ connectionId: 'connection-1',
291
+ cloudId: 'cloud-1',
292
+ accessToken: 'access-token',
293
+ webhookUrl: 'https://shipfox.example.com/webhooks/integrations/jira/connection-1',
294
+ getInstallation: vi.fn().mockResolvedValue({webhookIds: [123]}),
295
+ updateInstallation,
296
+ withRegistrationLock: runInlineRegistrationLock,
297
+ replaceExistingWebhooks: true,
298
+ });
299
+
300
+ expect(updateInstallation).toHaveBeenNthCalledWith(
301
+ 1,
302
+ expect.objectContaining({webhookIds: [456]}),
303
+ );
304
+ expect(updateInstallation).toHaveBeenNthCalledWith(
305
+ 2,
306
+ expect.objectContaining({webhookIds: [456, 123]}),
307
+ );
308
+ });
309
+
278
310
  it('keeps conservative metadata when failed cleanup cannot be persisted', async () => {
279
311
  const updateInstallation = vi
280
312
  .fn()
@@ -19,6 +19,7 @@ export interface RegisterJiraWebhookParams {
19
19
  getInstallation?: typeof getJiraInstallationByConnectionId;
20
20
  updateInstallation?: typeof updateJiraInstallationWebhook;
21
21
  withRegistrationLock?: JiraInstallationLock;
22
+ replaceExistingWebhooks?: boolean | undefined;
22
23
  onRegistrationSuccess?: (input: {tx?: unknown}) => Promise<void>;
23
24
  onRegistrationFailure?: (input: {tx?: unknown}) => Promise<void>;
24
25
  }
@@ -51,7 +52,9 @@ export async function registerJiraWebhook(
51
52
  const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
52
53
  const updateInput = {
53
54
  connectionId: params.connectionId,
54
- webhookIds: [...new Set([registration.webhookId, ...(previous?.webhookIds ?? [])])],
55
+ webhookIds: params.replaceExistingWebhooks
56
+ ? [registration.webhookId]
57
+ : [...new Set([registration.webhookId, ...(previous?.webhookIds ?? [])])],
55
58
  webhookExpiresAt,
56
59
  };
57
60
  const installation = await updateInstallation(updateInput);
@@ -0,0 +1,9 @@
1
+ import {config} from '#config.js';
2
+
3
+ export const JIRA_WEBHOOK_ROUTE_PREFIX = '/webhooks/integrations/jira';
4
+
5
+ const TRAILING_SLASHES_RE = /\/+$/;
6
+
7
+ export function jiraWebhookUrl(connectionId: string): string {
8
+ return `${config.JIRA_WEBHOOK_BASE_URL.replace(TRAILING_SLASHES_RE, '')}${JIRA_WEBHOOK_ROUTE_PREFIX}/${connectionId}`;
9
+ }