@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
@@ -9,7 +9,9 @@ import {
9
9
  getJiraInstallationByWebhookId,
10
10
  markJiraInstallationRevoked,
11
11
  updateJiraInstallationTokenExpiry,
12
+ updateJiraInstallationWebhook,
12
13
  upsertJiraInstallation,
14
+ withJiraWebhookRegistrationLock,
13
15
  } from './installations.js';
14
16
 
15
17
  function createInstallationInput(
@@ -62,6 +64,36 @@ describe('jira installations', () => {
62
64
  });
63
65
  });
64
66
 
67
+ it('preserves webhook metadata when reconnect upsert omits it', async () => {
68
+ const webhookExpiresAt = new Date('2030-01-01T00:00:00.000Z');
69
+ const input = createInstallationInput({webhookIds: [321], webhookExpiresAt});
70
+ await upsertJiraInstallation(input);
71
+
72
+ const result = await upsertJiraInstallation({
73
+ ...input,
74
+ scopes: ['read:jira-work', 'write:jira-work'],
75
+ webhookIds: undefined,
76
+ webhookExpiresAt: undefined,
77
+ });
78
+
79
+ expect(result.webhookIds).toEqual([321]);
80
+ expect(result.webhookExpiresAt).toEqual(webhookExpiresAt);
81
+ });
82
+
83
+ it('replaces webhook ids and expiry together after registration', async () => {
84
+ const input = createInstallationInput({webhookIds: [321]});
85
+ await upsertJiraInstallation(input);
86
+ const webhookExpiresAt = new Date('2030-02-01T00:00:00.000Z');
87
+
88
+ const result = await updateJiraInstallationWebhook({
89
+ connectionId: input.connectionId,
90
+ webhookIds: [654],
91
+ webhookExpiresAt,
92
+ });
93
+
94
+ expect(result).toMatchObject({webhookIds: [654], webhookExpiresAt});
95
+ });
96
+
65
97
  it('refuses to repoint a connection to a different Jira site', async () => {
66
98
  const first = createInstallationInput();
67
99
  await upsertJiraInstallation(first);
@@ -126,4 +158,36 @@ describe('jira installations', () => {
126
158
  expect(revoked?.status).toBe('revoked');
127
159
  expect(missing).toBeUndefined();
128
160
  });
161
+
162
+ it('serializes webhook registration lock contenders for one connection', async () => {
163
+ const connectionId = crypto.randomUUID();
164
+ let releaseFirst!: () => void;
165
+ let firstEntered!: () => void;
166
+ const firstReady = new Promise<void>((resolve) => {
167
+ firstEntered = resolve;
168
+ });
169
+ const firstRelease = new Promise<void>((resolve) => {
170
+ releaseFirst = resolve;
171
+ });
172
+
173
+ const first = withJiraWebhookRegistrationLock(connectionId, async () => {
174
+ firstEntered();
175
+ await firstRelease;
176
+ return 'first';
177
+ });
178
+ await firstReady;
179
+
180
+ let secondFinished = false;
181
+ const second = withJiraWebhookRegistrationLock(connectionId, () => {
182
+ secondFinished = true;
183
+ return Promise.resolve('second');
184
+ });
185
+ await Promise.resolve();
186
+ expect(secondFinished).toBe(false);
187
+
188
+ releaseFirst();
189
+ await expect(first).resolves.toBe('first');
190
+ await expect(second).resolves.toBe('second');
191
+ expect(secondFinished).toBe(true);
192
+ });
129
193
  });
@@ -1,5 +1,5 @@
1
1
  import {isUniqueViolation} from '@shipfox/node-drizzle';
2
- import {pgClient} from '@shipfox/node-postgres';
2
+ import {pgClient, withPostgresSession} from '@shipfox/node-postgres';
3
3
  import {eq, sql} from 'drizzle-orm';
4
4
  import {
5
5
  JiraConnectionAlreadyLinkedError,
@@ -9,6 +9,41 @@ import {
9
9
  import {db} from './db.js';
10
10
  import {jiraInstallations, toJiraInstallation} from './schema/installations.js';
11
11
 
12
+ export type JiraInstallationLock = <T>(lockKey: string, fn: () => Promise<T>) => Promise<T>;
13
+
14
+ const JIRA_INSTALLATION_LOCK_RETRY_DELAY_MS = 100;
15
+ const JIRA_INSTALLATION_LOCK_MAX_RETRY_DELAY_MS = 1_000;
16
+ const JIRA_INSTALLATION_LOCK_TIMEOUT_MS = 30_000;
17
+
18
+ export async function withJiraInstallationLock<T>(lockKey: string, fn: () => Promise<T>) {
19
+ const advisoryKey = `jira-installation:${lockKey}`;
20
+ const deadline = Date.now() + JIRA_INSTALLATION_LOCK_TIMEOUT_MS;
21
+ let retryDelayMs = JIRA_INSTALLATION_LOCK_RETRY_DELAY_MS;
22
+
23
+ while (true) {
24
+ const attempt = await withPostgresSession(async (client) => {
25
+ const lock = await client.query<{acquired: boolean}>(
26
+ 'SELECT pg_try_advisory_lock(hashtext($1)) AS acquired',
27
+ [advisoryKey],
28
+ );
29
+ if (lock.rows[0]?.acquired !== true) return {acquired: false as const};
30
+
31
+ try {
32
+ return {acquired: true as const, value: await fn()};
33
+ } finally {
34
+ await client.query('SELECT pg_advisory_unlock(hashtext($1))', [advisoryKey]);
35
+ }
36
+ });
37
+
38
+ if (attempt.acquired) return attempt.value;
39
+ if (Date.now() >= deadline) {
40
+ throw new Error(`Timed out waiting for Jira installation lock: ${lockKey}`);
41
+ }
42
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
43
+ retryDelayMs = Math.min(retryDelayMs * 2, JIRA_INSTALLATION_LOCK_MAX_RETRY_DELAY_MS);
44
+ }
45
+ }
46
+
12
47
  export type JiraInstallationStatus = 'installed' | 'revoked';
13
48
 
14
49
  export interface JiraInstallation {
@@ -46,6 +81,12 @@ export interface UpdateJiraInstallationTokenExpiryParams {
46
81
  scopes?: string[] | undefined;
47
82
  }
48
83
 
84
+ export interface UpdateJiraInstallationWebhookParams {
85
+ connectionId: string;
86
+ webhookIds: number[];
87
+ webhookExpiresAt: Date | null;
88
+ }
89
+
49
90
  type JiraDb = ReturnType<typeof db>;
50
91
  type JiraTx = Parameters<Parameters<JiraDb['transaction']>[0]>[0];
51
92
 
@@ -55,7 +96,6 @@ export async function upsertJiraInstallation(
55
96
  ): Promise<JiraInstallation> {
56
97
  const executor = (options.tx ?? db()) as JiraDb | JiraTx;
57
98
  const now = new Date();
58
- const webhookIds = params.webhookIds ?? [];
59
99
  let row: typeof jiraInstallations.$inferSelect | undefined;
60
100
  try {
61
101
  [row] = await executor
@@ -67,7 +107,7 @@ export async function upsertJiraInstallation(
67
107
  siteName: params.siteName,
68
108
  authorizingAccountId: params.authorizingAccountId,
69
109
  scopes: params.scopes,
70
- webhookIds,
110
+ webhookIds: params.webhookIds ?? [],
71
111
  webhookExpiresAt: params.webhookExpiresAt ?? null,
72
112
  status: params.status,
73
113
  tokenExpiresAt: params.tokenExpiresAt ?? null,
@@ -81,8 +121,10 @@ export async function upsertJiraInstallation(
81
121
  siteName: params.siteName,
82
122
  authorizingAccountId: params.authorizingAccountId,
83
123
  scopes: params.scopes,
84
- webhookIds,
85
- webhookExpiresAt: params.webhookExpiresAt ?? null,
124
+ ...(params.webhookIds === undefined ? {} : {webhookIds: params.webhookIds}),
125
+ ...(params.webhookExpiresAt === undefined
126
+ ? {}
127
+ : {webhookExpiresAt: params.webhookExpiresAt}),
86
128
  status: params.status,
87
129
  tokenExpiresAt: params.tokenExpiresAt ?? null,
88
130
  updatedAt: now,
@@ -134,6 +176,23 @@ export async function updateJiraInstallationTokenExpiry(
134
176
  return row ? toJiraInstallation(row) : undefined;
135
177
  }
136
178
 
179
+ export async function updateJiraInstallationWebhook(
180
+ params: UpdateJiraInstallationWebhookParams,
181
+ options: {tx?: unknown} = {},
182
+ ): Promise<JiraInstallation | undefined> {
183
+ const executor = (options.tx ?? db()) as JiraDb | JiraTx;
184
+ const [row] = await executor
185
+ .update(jiraInstallations)
186
+ .set({
187
+ webhookIds: params.webhookIds,
188
+ webhookExpiresAt: params.webhookExpiresAt,
189
+ updatedAt: new Date(),
190
+ })
191
+ .where(eq(jiraInstallations.connectionId, params.connectionId))
192
+ .returning();
193
+ return row ? toJiraInstallation(row) : undefined;
194
+ }
195
+
137
196
  export async function deleteJiraInstallationByConnectionId(
138
197
  connectionId: string,
139
198
  options: {tx?: unknown} = {},
@@ -154,6 +213,13 @@ export function withJiraRefreshLock<T>(
154
213
  return withJiraRefreshLockClient(connectionId, fn);
155
214
  }
156
215
 
216
+ export function withJiraWebhookRegistrationLock<T>(
217
+ lockKey: string,
218
+ fn: (tx?: unknown) => Promise<T>,
219
+ ): Promise<T> {
220
+ return withJiraInstallationLock(lockKey, () => fn());
221
+ }
222
+
157
223
  async function withJiraRefreshLockClient<T>(
158
224
  connectionId: string,
159
225
  fn: () => Promise<T>,
package/src/index.test.ts CHANGED
@@ -11,4 +11,12 @@ describe('createJiraIntegrationProvider', () => {
11
11
  routes: [],
12
12
  });
13
13
  });
14
+
15
+ it('rejects incomplete receiver wiring instead of mounting registration without a receiver', () => {
16
+ expect(() =>
17
+ createJiraIntegrationProvider({
18
+ routes: {tokenStore: {} as never} as never,
19
+ }),
20
+ ).toThrow('requires all webhook receiver dependencies');
21
+ });
14
22
  });
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import {JIRA_PROVIDER} from '@shipfox/api-integration-jira-dto';
2
2
  import {createJiraApiClient, type JiraApiClient} from '#api/client.js';
3
3
  import {config} from '#config.js';
4
+ import {createJiraWebhookProcessor} from '#core/webhook-processor.js';
5
+ import {registerJiraWebhook} from '#core/webhook-registration.js';
4
6
  import {closeDb, db} from '#db/db.js';
5
7
  import {getJiraInstallationByConnectionId} from '#db/installations.js';
6
8
  import {migrationsPath} from '#db/migrations.js';
@@ -8,15 +10,28 @@ import {
8
10
  type CreateJiraIntegrationRoutesOptions,
9
11
  createJiraIntegrationRoutes,
10
12
  } from '#presentation/routes/install.js';
13
+ import {
14
+ type CreateJiraWebhookRoutesOptions,
15
+ createJiraWebhookRoutes,
16
+ JIRA_WEBHOOK_ROUTE_PREFIX,
17
+ } from '#presentation/routes/webhooks.js';
18
+
19
+ const TRAILING_SLASHES_RE = /\/+$/;
11
20
 
12
21
  export type {JiraProvider} from '@shipfox/api-integration-jira-dto';
13
22
  export type {
14
23
  JiraAccessibleResource,
15
24
  JiraApiClient,
16
25
  JiraAuthorization,
26
+ JiraDynamicWebhookRegistration,
17
27
  JiraIdentity,
18
28
  } from '#api/client.js';
19
- export {createJiraApiClient, mapJiraError} from '#api/client.js';
29
+ export {
30
+ createJiraApiClient,
31
+ JIRA_DYNAMIC_WEBHOOK_EVENTS,
32
+ JIRA_DYNAMIC_WEBHOOK_JQL,
33
+ mapJiraError,
34
+ } from '#api/client.js';
20
35
  export type {DisconnectJiraInstallationParams} from '#core/disconnect.js';
21
36
  export {disconnectJiraInstallation} from '#core/disconnect.js';
22
37
  export {
@@ -58,9 +73,18 @@ export type {
58
73
  StoreJiraTokensParams,
59
74
  } from '#core/tokens.js';
60
75
  export {createJiraTokenStore, jiraSecretsNamespace} from '#core/tokens.js';
76
+ export type {
77
+ CreateJiraWebhookProcessorOptions,
78
+ JiraWebhookProcessor,
79
+ } from '#core/webhook-processor.js';
80
+ export {createJiraWebhookProcessor} from '#core/webhook-processor.js';
81
+ export type {RegisterJiraWebhookParams} from '#core/webhook-registration.js';
82
+ export {JIRA_WEBHOOK_TTL_MS, registerJiraWebhook} from '#core/webhook-registration.js';
61
83
  export type {
62
84
  JiraInstallation,
85
+ JiraInstallationLock,
63
86
  JiraInstallationStatus,
87
+ UpdateJiraInstallationWebhookParams,
64
88
  UpsertJiraInstallationParams,
65
89
  } from '#db/installations.js';
66
90
  export {
@@ -70,23 +94,70 @@ export {
70
94
  getJiraInstallationByWebhookId,
71
95
  markJiraInstallationRevoked,
72
96
  updateJiraInstallationTokenExpiry,
97
+ updateJiraInstallationWebhook,
73
98
  upsertJiraInstallation,
74
99
  withJiraRefreshLock,
100
+ withJiraWebhookRegistrationLock,
75
101
  } from '#db/installations.js';
102
+ export type {CreateJiraWebhookRoutesOptions} from '#presentation/routes/webhooks.js';
103
+ export {createJiraWebhookRoutes} from '#presentation/routes/webhooks.js';
76
104
  export {closeDb, config, db, migrationsPath};
77
105
 
78
106
  export interface CreateJiraIntegrationProviderOptions {
79
107
  jira?: JiraApiClient | undefined;
80
108
  getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId | undefined;
81
- routes?: Omit<CreateJiraIntegrationRoutesOptions, 'jira' | 'connectionCapabilities'> | undefined;
109
+ routes?: JiraIntegrationProviderRoutesOptions | undefined;
82
110
  }
83
111
 
112
+ type JiraIntegrationProviderRoutesOptions = Omit<
113
+ CreateJiraIntegrationRoutesOptions,
114
+ 'jira' | 'connectionCapabilities' | 'registerJiraWebhook'
115
+ > &
116
+ Omit<CreateJiraWebhookRoutesOptions, 'processor'>;
117
+
84
118
  export function createJiraIntegrationProvider(options: CreateJiraIntegrationProviderOptions = {}) {
85
119
  const jira = options.jira ?? createJiraApiClient();
86
120
  const getInstallationByConnectionId =
87
121
  options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId;
122
+ const webhookOptions = toJiraWebhookOptions(options.routes);
123
+ const webhookProcessor = webhookOptions ? createJiraWebhookProcessor(webhookOptions) : undefined;
124
+ const webhookRoutes = webhookOptions
125
+ ? [
126
+ createJiraWebhookRoutes({
127
+ coreDb: webhookOptions.coreDb,
128
+ publishIntegrationEventReceived: webhookOptions.publishIntegrationEventReceived,
129
+ recordDeliveryOnly: webhookOptions.recordDeliveryOnly,
130
+ getIntegrationConnectionById: webhookOptions.getIntegrationConnectionById,
131
+ processor: webhookProcessor,
132
+ }),
133
+ ]
134
+ : [];
88
135
  const routes = options.routes
89
- ? [createJiraIntegrationRoutes({jira, connectionCapabilities: [], ...options.routes})]
136
+ ? [
137
+ createJiraIntegrationRoutes({
138
+ jira,
139
+ connectionCapabilities: [],
140
+ ...options.routes,
141
+ registerJiraWebhook: (input) =>
142
+ registerJiraWebhook({
143
+ jira,
144
+ connectionId: input.connectionId,
145
+ cloudId: input.cloudId,
146
+ accessToken: input.accessToken,
147
+ webhookUrl: jiraWebhookUrl(input.connectionId),
148
+ ...(input.withRegistrationLock
149
+ ? {withRegistrationLock: input.withRegistrationLock}
150
+ : {}),
151
+ ...(input.onRegistrationSuccess
152
+ ? {onRegistrationSuccess: input.onRegistrationSuccess}
153
+ : {}),
154
+ ...(input.onRegistrationFailure
155
+ ? {onRegistrationFailure: input.onRegistrationFailure}
156
+ : {}),
157
+ }).then(() => undefined),
158
+ }),
159
+ ...webhookRoutes,
160
+ ]
90
161
  : [];
91
162
  return {
92
163
  provider: JIRA_PROVIDER,
@@ -96,5 +167,33 @@ export function createJiraIntegrationProvider(options: CreateJiraIntegrationProv
96
167
  return (await getInstallationByConnectionId(connection.id))?.siteUrl;
97
168
  },
98
169
  routes,
170
+ webhookProcessors: webhookProcessor
171
+ ? [{routeIds: ['jira'] as const, processor: webhookProcessor}]
172
+ : undefined,
173
+ };
174
+ }
175
+
176
+ function jiraWebhookUrl(connectionId: string): string {
177
+ return `${config.JIRA_WEBHOOK_BASE_URL.replace(TRAILING_SLASHES_RE, '')}${JIRA_WEBHOOK_ROUTE_PREFIX}/${connectionId}`;
178
+ }
179
+
180
+ function toJiraWebhookOptions(
181
+ routes: CreateJiraIntegrationProviderOptions['routes'],
182
+ ): CreateJiraWebhookRoutesOptions | undefined {
183
+ if (!routes) return undefined;
184
+ if (
185
+ routes.coreDb === undefined ||
186
+ routes.publishIntegrationEventReceived === undefined ||
187
+ routes.recordDeliveryOnly === undefined ||
188
+ routes.getIntegrationConnectionById === undefined
189
+ )
190
+ throw new Error(
191
+ 'Jira integration provider requires all webhook receiver dependencies: coreDb, publishIntegrationEventReceived, recordDeliveryOnly, and getIntegrationConnectionById',
192
+ );
193
+ return {
194
+ coreDb: routes.coreDb,
195
+ publishIntegrationEventReceived: routes.publishIntegrationEventReceived,
196
+ recordDeliveryOnly: routes.recordDeliveryOnly,
197
+ getIntegrationConnectionById: routes.getIntegrationConnectionById,
99
198
  };
100
199
  }
@@ -25,6 +25,7 @@ import type {JiraPendingSelectionStore} from '#core/pending.js';
25
25
  import {formatJiraOAuthScopes} from '#core/scopes.js';
26
26
  import {signJiraInstallState} from '#core/state.js';
27
27
  import type {JiraTokenStore} from '#core/tokens.js';
28
+ import type {JiraInstallationLock} from '#db/installations.js';
28
29
  import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
29
30
  import {jiraRouteErrorHandler} from './errors.js';
30
31
 
@@ -38,6 +39,17 @@ export interface CreateJiraIntegrationRoutesOptions {
38
39
  connectJiraInstallation(
39
40
  input: ConnectJiraInstallationInput,
40
41
  ): Promise<IntegrationConnection<'jira'>>;
42
+ registerJiraWebhook(input: {
43
+ connectionId: string;
44
+ cloudId: string;
45
+ accessToken: string;
46
+ withRegistrationLock?: JiraInstallationLock;
47
+ onRegistrationSuccess: (input: {tx?: unknown}) => Promise<void>;
48
+ onRegistrationFailure: (input: {tx?: unknown}) => Promise<void>;
49
+ }): Promise<void>;
50
+ withJiraInstallationLock?: JiraInstallationLock;
51
+ markConnectionActive(input: {connectionId: string; tx?: unknown}): Promise<void>;
52
+ markConnectionError(input: {connectionId: string; tx?: unknown}): Promise<void>;
41
53
  disconnectJiraInstallation(input: {connectionId: string}): Promise<void>;
42
54
  connectionCapabilities: IntegrationCapability[];
43
55
  requireActiveWorkspaceMembership?: (input: {
@@ -0,0 +1,132 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {WEBHOOK_MAX_RAW_BODY_BYTES} from '@shipfox/api-integration-spi';
3
+ import {closeApp, createApp} from '@shipfox/node-fastify';
4
+ import type {FastifyInstance} from 'fastify';
5
+ import type {JiraWebhookProcessor} from '#core/webhook-processor.js';
6
+ import {createJiraWebhookRoutes} from './webhooks.js';
7
+
8
+ function createTestApp(processor: JiraWebhookProcessor): Promise<FastifyInstance> {
9
+ return createApp({
10
+ routes: [
11
+ createJiraWebhookRoutes({
12
+ coreDb: vi.fn() as never,
13
+ publishIntegrationEventReceived: vi.fn() as never,
14
+ recordDeliveryOnly: vi.fn() as never,
15
+ getIntegrationConnectionById: vi.fn() as never,
16
+ processor,
17
+ }),
18
+ ],
19
+ swagger: false,
20
+ });
21
+ }
22
+
23
+ describe('Jira webhook route', () => {
24
+ afterEach(async () => {
25
+ await closeApp();
26
+ });
27
+
28
+ it('stores the connection path and lower-case authorization header', async () => {
29
+ const process = vi.fn().mockResolvedValue({outcome: 'processed', deliveryId: 'delivery-1'});
30
+ const processor = {process: process as JiraWebhookProcessor['process']};
31
+ const app = await createTestApp(processor);
32
+ const connectionId = 'c0a8012e-0b6d-4d8f-8d5c-6d74102602b0';
33
+
34
+ const response = await app.inject({
35
+ method: 'POST',
36
+ url: `/webhooks/integrations/jira/${connectionId}`,
37
+ headers: {
38
+ authorization: 'Bearer signed-token',
39
+ 'content-type': 'application/json',
40
+ 'x-atlassian-webhook-identifier': 'jira-delivery-1',
41
+ },
42
+ payload: '{}',
43
+ });
44
+
45
+ expect(response.statusCode).toBe(200);
46
+ expect(process).toHaveBeenCalledWith(
47
+ expect.objectContaining({
48
+ route_id: 'jira',
49
+ path_parameters: {connection_id: connectionId},
50
+ headers: {
51
+ authorization: 'Bearer signed-token',
52
+ 'content-type': 'application/json',
53
+ 'x-atlassian-webhook-identifier': 'jira-delivery-1',
54
+ },
55
+ }),
56
+ );
57
+ });
58
+
59
+ it('returns 401 for an authentication failure without recording a delivery', async () => {
60
+ const process = vi.fn().mockResolvedValue({
61
+ outcome: 'discarded',
62
+ reason: 'invalid_signature',
63
+ deliveryId: 'delivery-1',
64
+ });
65
+ const processor = {process: process as JiraWebhookProcessor['process']};
66
+ const app = await createTestApp(processor);
67
+
68
+ const response = await app.inject({
69
+ method: 'POST',
70
+ url: '/webhooks/integrations/jira/c0a8012e-0b6d-4d8f-8d5c-6d74102602b0',
71
+ headers: {'content-type': 'application/json'},
72
+ payload: '{}',
73
+ });
74
+
75
+ expect(response.statusCode).toBe(401);
76
+ expect(response.json()).toEqual({error: 'invalid authorization'});
77
+ });
78
+
79
+ it.each([
80
+ ['a processed delivery', {outcome: 'processed', deliveryId: 'delivery-1'}],
81
+ ['a duplicate delivery', {outcome: 'duplicate', deliveryId: 'delivery-1'}],
82
+ ['a deliberate drop', {outcome: 'discarded', reason: 'connection_unavailable'}],
83
+ ] as const)('returns 200 for %s', async (_description, result) => {
84
+ const process = vi.fn().mockResolvedValue(result);
85
+ const app = await createTestApp({process: process as JiraWebhookProcessor['process']});
86
+
87
+ const response = await app.inject({
88
+ method: 'POST',
89
+ url: '/webhooks/integrations/jira/c0a8012e-0b6d-4d8f-8d5c-6d74102602b0',
90
+ headers: {authorization: 'Bearer signed-token', 'content-type': 'application/json'},
91
+ payload: '{}',
92
+ });
93
+
94
+ expect(response.statusCode).toBe(200);
95
+ expect(process).toHaveBeenCalledOnce();
96
+ });
97
+
98
+ it('returns 400 for a malformed payload result', async () => {
99
+ const process = vi.fn().mockResolvedValue({
100
+ outcome: 'discarded',
101
+ reason: 'malformed_payload',
102
+ deliveryId: 'delivery-1',
103
+ });
104
+ const app = await createTestApp({process: process as JiraWebhookProcessor['process']});
105
+
106
+ const response = await app.inject({
107
+ method: 'POST',
108
+ url: '/webhooks/integrations/jira/c0a8012e-0b6d-4d8f-8d5c-6d74102602b0',
109
+ headers: {authorization: 'Bearer signed-token', 'content-type': 'application/json'},
110
+ payload: '{}',
111
+ });
112
+
113
+ expect(response.statusCode).toBe(400);
114
+ expect(response.json()).toEqual({error: 'malformed JSON'});
115
+ expect(process).toHaveBeenCalledOnce();
116
+ });
117
+
118
+ it('returns 413 for oversized input without invoking the processor', async () => {
119
+ const process = vi.fn();
120
+ const app = await createTestApp({process: process as JiraWebhookProcessor['process']});
121
+
122
+ const response = await app.inject({
123
+ method: 'POST',
124
+ url: '/webhooks/integrations/jira/c0a8012e-0b6d-4d8f-8d5c-6d74102602b0',
125
+ headers: {authorization: 'Bearer signed-token', 'content-type': 'application/json'},
126
+ payload: Buffer.alloc(WEBHOOK_MAX_RAW_BODY_BYTES + 1, 97),
127
+ });
128
+
129
+ expect(response.statusCode).toBe(413);
130
+ expect(process).not.toHaveBeenCalled();
131
+ });
132
+ });
@@ -0,0 +1,115 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import type {StoredWebhookRequest, WebhookProcessingResult} from '@shipfox/api-integration-spi';
3
+ import {createStoredWebhookRequest, WEBHOOK_MAX_RAW_BODY_BYTES} from '@shipfox/api-integration-spi';
4
+ import {
5
+ ClientError,
6
+ defineRoute,
7
+ type RouteGroup,
8
+ rawBodyPlugin,
9
+ WEBHOOK_BODY_LIMIT,
10
+ } from '@shipfox/node-fastify';
11
+ import {z} from 'zod';
12
+ import {
13
+ type CreateJiraWebhookProcessorOptions,
14
+ createJiraWebhookProcessor,
15
+ type JiraWebhookProcessor,
16
+ } from '#core/webhook-processor.js';
17
+
18
+ const jiraWebhookParamsSchema = z.object({connectionId: z.string().uuid()});
19
+ export const JIRA_WEBHOOK_ROUTE_PREFIX = '/webhooks/integrations/jira';
20
+
21
+ export interface CreateJiraWebhookRoutesOptions
22
+ extends Omit<CreateJiraWebhookProcessorOptions, 'getJiraInstallationByConnectionId'> {
23
+ processor?: JiraWebhookProcessor | undefined;
24
+ }
25
+
26
+ export function createJiraWebhookRoutes(options: CreateJiraWebhookRoutesOptions): RouteGroup {
27
+ const processor = options.processor ?? createJiraWebhookProcessor(options);
28
+ const route = defineRoute({
29
+ method: 'POST',
30
+ path: '/:connectionId',
31
+ auth: [],
32
+ description: 'Jira dynamic webhook receiver.',
33
+ options: {bodyLimit: WEBHOOK_BODY_LIMIT},
34
+ schema: {params: jiraWebhookParamsSchema},
35
+ handler: async (request, reply) => {
36
+ const body = request.body;
37
+ if (!(body instanceof Uint8Array)) {
38
+ throw new ClientError('Expected raw JSON body', 'invalid-webhook-request', {status: 400});
39
+ }
40
+ const result = await processor.process(
41
+ createJiraStoredWebhookRequest({
42
+ body,
43
+ connectionId: request.params.connectionId,
44
+ headers: request.headers,
45
+ rawQueryString: request.raw.url?.split('?')[1] ?? '',
46
+ }),
47
+ );
48
+ return sendJiraWebhookResponse(reply, result);
49
+ },
50
+ });
51
+
52
+ return {
53
+ prefix: JIRA_WEBHOOK_ROUTE_PREFIX,
54
+ auth: [],
55
+ plugins: [rawBodyPlugin],
56
+ routes: [route],
57
+ };
58
+ }
59
+
60
+ function createJiraStoredWebhookRequest(input: {
61
+ body: Uint8Array;
62
+ connectionId: string;
63
+ headers: Record<string, string | string[] | undefined>;
64
+ rawQueryString: string;
65
+ }): StoredWebhookRequest {
66
+ if (input.body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {
67
+ throw new ClientError('Webhook request body is too large', 'body-too-large', {status: 413});
68
+ }
69
+ try {
70
+ return createStoredWebhookRequest({
71
+ requestId: randomUUID(),
72
+ routeId: 'jira',
73
+ receivedAt: new Date().toISOString(),
74
+ rawQueryString: input.rawQueryString,
75
+ headers: jiraWebhookHeaders(input.headers),
76
+ body: input.body,
77
+ connectionId: input.connectionId,
78
+ });
79
+ } catch (error) {
80
+ throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {
81
+ cause: error,
82
+ });
83
+ }
84
+ }
85
+
86
+ function jiraWebhookHeaders(
87
+ headers: Record<string, string | string[] | undefined>,
88
+ ): Record<string, string> {
89
+ return Object.fromEntries(
90
+ ['authorization', 'content-type', 'x-atlassian-webhook-identifier'].flatMap((name) => {
91
+ const value = headers[name];
92
+ return typeof value === 'string' ? [[name, value]] : [];
93
+ }),
94
+ );
95
+ }
96
+
97
+ function sendJiraWebhookResponse(
98
+ reply: {code(statusCode: number): void},
99
+ result: WebhookProcessingResult,
100
+ ) {
101
+ if (result.outcome !== 'discarded') {
102
+ reply.code(200);
103
+ return null;
104
+ }
105
+ if (result.reason === 'invalid_signature' || result.reason === 'missing_required_input') {
106
+ reply.code(401);
107
+ return {error: 'invalid authorization'};
108
+ }
109
+ if (result.reason === 'malformed_payload') {
110
+ reply.code(400);
111
+ return {error: 'malformed JSON'};
112
+ }
113
+ reply.code(200);
114
+ return null;
115
+ }
package/test/env.ts CHANGED
@@ -8,7 +8,6 @@ process.env.TZ = 'UTC';
8
8
  process.env.JIRA_OAUTH_CLIENT_ID = 'test-client-id';
9
9
  process.env.JIRA_OAUTH_CLIENT_SECRET = 'test-client-secret';
10
10
  process.env.JIRA_OAUTH_REDIRECT_URL = 'https://shipfox.example.com/integrations/jira/callback';
11
- process.env.JIRA_WEBHOOK_SIGNING_SECRET = 'test-signing-secret';
12
11
  process.env.JIRA_WEBHOOK_BASE_URL = 'https://shipfox.example.com';
13
12
  process.env.JIRA_API_BASE_URL = 'http://127.0.0.1:0';
14
13
  process.env.JIRA_AUTH_BASE_URL = 'http://127.0.0.1:0';