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