@shipfox/api-integration-jira 11.0.0 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.turbo/turbo-build.log +17 -2
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +58 -2
  4. package/dist/api/client.d.ts +34 -0
  5. package/dist/api/client.d.ts.map +1 -1
  6. package/dist/api/client.js +131 -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/agent-tools-provider.d.ts +26 -0
  13. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  14. package/dist/core/agent-tools-provider.js +130 -0
  15. package/dist/core/agent-tools-provider.js.map +1 -0
  16. package/dist/core/agent-tools.d.ts +16 -0
  17. package/dist/core/agent-tools.d.ts.map +1 -0
  18. package/dist/core/agent-tools.js +441 -0
  19. package/dist/core/agent-tools.js.map +1 -0
  20. package/dist/core/install.d.ts +22 -0
  21. package/dist/core/install.d.ts.map +1 -1
  22. package/dist/core/install.js +68 -20
  23. package/dist/core/install.js.map +1 -1
  24. package/dist/core/tokens.d.ts +2 -0
  25. package/dist/core/tokens.d.ts.map +1 -1
  26. package/dist/core/tokens.js +129 -37
  27. package/dist/core/tokens.js.map +1 -1
  28. package/dist/core/webhook-processor.d.ts +15 -0
  29. package/dist/core/webhook-processor.d.ts.map +1 -0
  30. package/dist/core/webhook-processor.js +137 -0
  31. package/dist/core/webhook-processor.js.map +1 -0
  32. package/dist/core/webhook-registration.d.ts +26 -0
  33. package/dist/core/webhook-registration.d.ts.map +1 -0
  34. package/dist/core/webhook-registration.js +165 -0
  35. package/dist/core/webhook-registration.js.map +1 -0
  36. package/dist/core/webhook.d.ts +18 -0
  37. package/dist/core/webhook.d.ts.map +1 -0
  38. package/dist/core/webhook.js +38 -0
  39. package/dist/core/webhook.js.map +1 -0
  40. package/dist/db/db.d.ts +68 -0
  41. package/dist/db/db.d.ts.map +1 -1
  42. package/dist/db/installations.d.ts +33 -6
  43. package/dist/db/installations.d.ts.map +1 -1
  44. package/dist/db/installations.js +79 -7
  45. package/dist/db/installations.js.map +1 -1
  46. package/dist/db/schema/installations.d.ts +34 -0
  47. package/dist/db/schema/installations.d.ts.map +1 -1
  48. package/dist/db/schema/installations.js +12 -2
  49. package/dist/db/schema/installations.js.map +1 -1
  50. package/dist/index.d.ts +34 -7
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +76 -8
  53. package/dist/index.js.map +1 -1
  54. package/dist/presentation/routes/install.d.ts +22 -0
  55. package/dist/presentation/routes/install.d.ts.map +1 -1
  56. package/dist/presentation/routes/install.js.map +1 -1
  57. package/dist/presentation/routes/webhooks.d.ts +8 -0
  58. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  59. package/dist/presentation/routes/webhooks.js +108 -0
  60. package/dist/presentation/routes/webhooks.js.map +1 -0
  61. package/dist/temporal/activities/index.d.ts +2 -0
  62. package/dist/temporal/activities/index.d.ts.map +1 -0
  63. package/dist/temporal/activities/index.js +3 -0
  64. package/dist/temporal/activities/index.js.map +1 -0
  65. package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
  66. package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
  67. package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
  68. package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
  69. package/dist/temporal/constants.d.ts +6 -0
  70. package/dist/temporal/constants.d.ts.map +1 -0
  71. package/dist/temporal/constants.js +8 -0
  72. package/dist/temporal/constants.js.map +1 -0
  73. package/dist/temporal/worker.d.ts +9 -0
  74. package/dist/temporal/worker.d.ts.map +1 -0
  75. package/dist/temporal/worker.js +26 -0
  76. package/dist/temporal/worker.js.map +1 -0
  77. package/dist/temporal/workflows/index.bundle.js +25225 -0
  78. package/dist/temporal/workflows/index.bundle.meta.json +1 -0
  79. package/dist/temporal/workflows/index.d.ts +2 -0
  80. package/dist/temporal/workflows/index.d.ts.map +1 -0
  81. package/dist/temporal/workflows/index.js +3 -0
  82. package/dist/temporal/workflows/index.js.map +1 -0
  83. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
  84. package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
  85. package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
  86. package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
  87. package/dist/tsconfig.test.tsbuildinfo +1 -1
  88. package/drizzle/0001_jira-token-refresh-state.sql +12 -0
  89. package/drizzle/meta/0001_snapshot.json +245 -0
  90. package/drizzle/meta/_journal.json +7 -0
  91. package/package.json +17 -8
  92. package/src/api/client.test.ts +236 -9
  93. package/src/api/client.ts +192 -0
  94. package/src/config.test.ts +0 -1
  95. package/src/config.ts +1 -4
  96. package/src/core/agent-tools-provider.test.ts +299 -0
  97. package/src/core/agent-tools-provider.ts +175 -0
  98. package/src/core/agent-tools.ts +450 -0
  99. package/src/core/install.test.ts +179 -0
  100. package/src/core/install.ts +85 -19
  101. package/src/core/tokens-refresh.test.ts +223 -4
  102. package/src/core/tokens.test.ts +1 -1
  103. package/src/core/tokens.ts +153 -33
  104. package/src/core/webhook-processor.test.ts +418 -0
  105. package/src/core/webhook-processor.ts +166 -0
  106. package/src/core/webhook-registration.test.ts +375 -0
  107. package/src/core/webhook-registration.ts +216 -0
  108. package/src/core/webhook.ts +69 -0
  109. package/src/db/installations.test.ts +148 -2
  110. package/src/db/installations.ts +133 -7
  111. package/src/db/schema/installations.ts +21 -1
  112. package/src/index.test.ts +42 -0
  113. package/src/index.ts +148 -5
  114. package/src/presentation/routes/install.ts +12 -0
  115. package/src/presentation/routes/webhooks.test.ts +132 -0
  116. package/src/presentation/routes/webhooks.ts +115 -0
  117. package/src/temporal/activities/index.ts +5 -0
  118. package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
  119. package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
  120. package/src/temporal/constants.ts +8 -0
  121. package/src/temporal/worker.ts +39 -0
  122. package/src/temporal/workflows/index.ts +1 -0
  123. package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
  124. package/test/env.ts +0 -1
  125. package/test/factories/jira-installation.ts +4 -0
  126. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,5 +1,5 @@
1
1
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
- import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
2
+ import {index, jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
3
  import type {JiraInstallation} from '#db/installations.js';
4
4
  import {pgTable} from './common.js';
5
5
 
@@ -17,12 +17,30 @@ export const jiraInstallations = pgTable(
17
17
  webhookExpiresAt: timestamp('webhook_expires_at', {withTimezone: true}),
18
18
  status: text('status').notNull().$type<JiraInstallation['status']>(),
19
19
  tokenExpiresAt: timestamp('token_expires_at', {withTimezone: true}),
20
+ refreshTokenLastUsedAt: timestamp('refresh_token_last_used_at', {
21
+ withTimezone: true,
22
+ })
23
+ .notNull()
24
+ .defaultNow(),
25
+ refreshTokenLastAttemptedAt: timestamp('refresh_token_last_attempted_at', {
26
+ withTimezone: true,
27
+ })
28
+ .notNull()
29
+ .defaultNow(),
20
30
  createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
21
31
  updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
22
32
  },
23
33
  (table) => [
24
34
  uniqueIndex('integrations_jira_installations_connection_unique').on(table.connectionId),
25
35
  uniqueIndex('integrations_jira_installations_cloud_id_unique').on(table.cloudId),
36
+ index('integrations_jira_installations_token_refresh_due_idx').on(
37
+ table.status,
38
+ table.refreshTokenLastUsedAt,
39
+ ),
40
+ index('integrations_jira_installations_token_refresh_attempt_idx').on(
41
+ table.status,
42
+ table.refreshTokenLastAttemptedAt,
43
+ ),
26
44
  ],
27
45
  );
28
46
 
@@ -42,6 +60,8 @@ export function toJiraInstallation(row: JiraInstallationDb): JiraInstallation {
42
60
  webhookExpiresAt: row.webhookExpiresAt,
43
61
  status: row.status,
44
62
  tokenExpiresAt: row.tokenExpiresAt,
63
+ refreshTokenLastUsedAt: row.refreshTokenLastUsedAt,
64
+ refreshTokenLastAttemptedAt: row.refreshTokenLastAttemptedAt,
45
65
  createdAt: row.createdAt,
46
66
  updatedAt: row.updatedAt,
47
67
  };
package/src/index.test.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import {createOutboxRegistry} from '@shipfox/node-module';
1
2
  import {createJiraIntegrationProvider} from '#index.js';
3
+ import {createJiraMaintenanceWorker} from '#temporal/worker.js';
2
4
 
3
5
  describe('createJiraIntegrationProvider', () => {
4
6
  it('creates the Jira provider', () => {
@@ -11,4 +13,44 @@ describe('createJiraIntegrationProvider', () => {
11
13
  routes: [],
12
14
  });
13
15
  });
16
+
17
+ it('mounts the in-process agent-tools adapter and advertises its capability', () => {
18
+ const getAccessToken = vi.fn().mockResolvedValue('access-token');
19
+ const provider = createJiraIntegrationProvider({
20
+ agentTools: {tokenStore: {getAccessToken}},
21
+ });
22
+
23
+ expect(provider.adapters.agent_tools).toBeDefined();
24
+ expect(provider.routes).toEqual([]);
25
+ });
26
+
27
+ it('rejects incomplete receiver wiring instead of mounting registration without a receiver', () => {
28
+ expect(() =>
29
+ createJiraIntegrationProvider({
30
+ routes: {tokenStore: {} as never} as never,
31
+ }),
32
+ ).toThrow('requires all webhook receiver dependencies');
33
+ });
34
+ });
35
+
36
+ describe('createJiraMaintenanceWorker', () => {
37
+ it('describes the Jira proactive token refresh worker', () => {
38
+ const worker = createJiraMaintenanceWorker({
39
+ tokenStore: {getAccessToken: vi.fn()},
40
+ resolveConnection: vi.fn(),
41
+ });
42
+
43
+ expect(worker.taskQueue).toBe('integrations-jira-maintenance');
44
+ expect(worker.workflowsPath.endsWith('dist/temporal/workflows/index.js')).toBe(true);
45
+ expect(Object.keys(worker.activities({outboxRegistry: createOutboxRegistry()}))).toContain(
46
+ 'refreshJiraTokensActivity',
47
+ );
48
+ expect(worker.workflows).toEqual([
49
+ {
50
+ name: 'refreshJiraTokensCron',
51
+ id: 'jira-refresh-tokens',
52
+ cronSchedule: '0 */6 * * *',
53
+ },
54
+ ]);
55
+ });
14
56
  });
package/src/index.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import {JIRA_PROVIDER} from '@shipfox/api-integration-jira-dto';
2
- import {createJiraApiClient, type JiraApiClient} from '#api/client.js';
2
+ import {
3
+ createJiraAgentToolsClient,
4
+ createJiraApiClient,
5
+ type JiraAgentToolsClient,
6
+ type JiraApiClient,
7
+ } from '#api/client.js';
3
8
  import {config} from '#config.js';
9
+ import {JiraAgentToolsProvider} from '#core/agent-tools-provider.js';
10
+ import type {JiraTokenStore} from '#core/tokens.js';
11
+ import {createJiraWebhookProcessor} from '#core/webhook-processor.js';
12
+ import {registerJiraWebhook} from '#core/webhook-registration.js';
4
13
  import {closeDb, db} from '#db/db.js';
5
14
  import {getJiraInstallationByConnectionId} from '#db/installations.js';
6
15
  import {migrationsPath} from '#db/migrations.js';
@@ -8,15 +17,49 @@ import {
8
17
  type CreateJiraIntegrationRoutesOptions,
9
18
  createJiraIntegrationRoutes,
10
19
  } from '#presentation/routes/install.js';
20
+ import {
21
+ type CreateJiraWebhookRoutesOptions,
22
+ createJiraWebhookRoutes,
23
+ JIRA_WEBHOOK_ROUTE_PREFIX,
24
+ } from '#presentation/routes/webhooks.js';
25
+
26
+ const TRAILING_SLASHES_RE = /\/+$/;
11
27
 
12
28
  export type {JiraProvider} from '@shipfox/api-integration-jira-dto';
13
29
  export type {
14
30
  JiraAccessibleResource,
31
+ JiraAgentToolHttpMethod,
32
+ JiraAgentToolQueryValue,
33
+ JiraAgentToolRequest,
34
+ JiraAgentToolResponse,
35
+ JiraAgentToolsClient,
15
36
  JiraApiClient,
16
37
  JiraAuthorization,
38
+ JiraDynamicWebhookRegistration,
17
39
  JiraIdentity,
18
40
  } from '#api/client.js';
19
- export {createJiraApiClient, mapJiraError} from '#api/client.js';
41
+ export {
42
+ createJiraAgentToolsClient,
43
+ createJiraApiClient,
44
+ JIRA_DYNAMIC_WEBHOOK_EVENTS,
45
+ JIRA_DYNAMIC_WEBHOOK_JQL,
46
+ mapJiraError,
47
+ } from '#api/client.js';
48
+ export type {
49
+ JiraAgentToolCatalogEntry,
50
+ JiraAgentToolId,
51
+ JiraAgentToolRequiredScope,
52
+ } from '#core/agent-tools.js';
53
+ export {
54
+ jiraAgentToolCatalog,
55
+ jiraAgentToolSelectionCatalog,
56
+ jiraPlainTextToAdf,
57
+ } from '#core/agent-tools.js';
58
+ export type {
59
+ JiraAgentToolsProviderOptions,
60
+ JiraToolCallResult,
61
+ } from '#core/agent-tools-provider.js';
62
+ export {JiraAgentToolsProvider} from '#core/agent-tools-provider.js';
20
63
  export type {DisconnectJiraInstallationParams} from '#core/disconnect.js';
21
64
  export {disconnectJiraInstallation} from '#core/disconnect.js';
22
65
  export {
@@ -58,9 +101,18 @@ export type {
58
101
  StoreJiraTokensParams,
59
102
  } from '#core/tokens.js';
60
103
  export {createJiraTokenStore, jiraSecretsNamespace} from '#core/tokens.js';
104
+ export type {
105
+ CreateJiraWebhookProcessorOptions,
106
+ JiraWebhookProcessor,
107
+ } from '#core/webhook-processor.js';
108
+ export {createJiraWebhookProcessor} from '#core/webhook-processor.js';
109
+ export type {RegisterJiraWebhookParams} from '#core/webhook-registration.js';
110
+ export {JIRA_WEBHOOK_TTL_MS, registerJiraWebhook} from '#core/webhook-registration.js';
61
111
  export type {
62
112
  JiraInstallation,
113
+ JiraInstallationLock,
63
114
  JiraInstallationStatus,
115
+ UpdateJiraInstallationWebhookParams,
64
116
  UpsertJiraInstallationParams,
65
117
  } from '#db/installations.js';
66
118
  export {
@@ -68,33 +120,124 @@ export {
68
120
  getJiraInstallationByCloudId,
69
121
  getJiraInstallationByConnectionId,
70
122
  getJiraInstallationByWebhookId,
123
+ listJiraInstallationsDueForTokenRefresh,
71
124
  markJiraInstallationRevoked,
72
125
  updateJiraInstallationTokenExpiry,
126
+ updateJiraInstallationWebhook,
73
127
  upsertJiraInstallation,
74
128
  withJiraRefreshLock,
129
+ withJiraWebhookRegistrationLock,
75
130
  } from '#db/installations.js';
131
+ export type {CreateJiraWebhookRoutesOptions} from '#presentation/routes/webhooks.js';
132
+ export {createJiraWebhookRoutes} from '#presentation/routes/webhooks.js';
133
+ export {createJiraMaintenanceWorker} from '#temporal/worker.js';
76
134
  export {closeDb, config, db, migrationsPath};
77
135
 
78
136
  export interface CreateJiraIntegrationProviderOptions {
79
137
  jira?: JiraApiClient | undefined;
138
+ agentTools?:
139
+ | {
140
+ tokenStore: Pick<JiraTokenStore, 'getAccessToken'>;
141
+ jira?: JiraAgentToolsClient | undefined;
142
+ }
143
+ | undefined;
80
144
  getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId | undefined;
81
- routes?: Omit<CreateJiraIntegrationRoutesOptions, 'jira' | 'connectionCapabilities'> | undefined;
145
+ routes?: JiraIntegrationProviderRoutesOptions | undefined;
82
146
  }
83
147
 
148
+ type JiraIntegrationProviderRoutesOptions = Omit<
149
+ CreateJiraIntegrationRoutesOptions,
150
+ 'jira' | 'connectionCapabilities' | 'registerJiraWebhook'
151
+ > &
152
+ Omit<CreateJiraWebhookRoutesOptions, 'processor'>;
153
+
84
154
  export function createJiraIntegrationProvider(options: CreateJiraIntegrationProviderOptions = {}) {
85
155
  const jira = options.jira ?? createJiraApiClient();
86
156
  const getInstallationByConnectionId =
87
157
  options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId;
158
+ const adapters = options.agentTools
159
+ ? {
160
+ agent_tools: new JiraAgentToolsProvider({
161
+ jira: options.agentTools.jira ?? createJiraAgentToolsClient(),
162
+ tokenStore: options.agentTools.tokenStore,
163
+ }),
164
+ }
165
+ : {};
166
+ const webhookOptions = toJiraWebhookOptions(options.routes);
167
+ const webhookProcessor = webhookOptions ? createJiraWebhookProcessor(webhookOptions) : undefined;
168
+ const webhookRoutes = webhookOptions
169
+ ? [
170
+ createJiraWebhookRoutes({
171
+ coreDb: webhookOptions.coreDb,
172
+ publishIntegrationEventReceived: webhookOptions.publishIntegrationEventReceived,
173
+ recordDeliveryOnly: webhookOptions.recordDeliveryOnly,
174
+ getIntegrationConnectionById: webhookOptions.getIntegrationConnectionById,
175
+ processor: webhookProcessor,
176
+ }),
177
+ ]
178
+ : [];
88
179
  const routes = options.routes
89
- ? [createJiraIntegrationRoutes({jira, connectionCapabilities: [], ...options.routes})]
180
+ ? [
181
+ createJiraIntegrationRoutes({
182
+ jira,
183
+ connectionCapabilities: adapters.agent_tools ? ['agent_tools'] : [],
184
+ ...options.routes,
185
+ registerJiraWebhook: (input) =>
186
+ registerJiraWebhook({
187
+ jira,
188
+ connectionId: input.connectionId,
189
+ cloudId: input.cloudId,
190
+ accessToken: input.accessToken,
191
+ webhookUrl: jiraWebhookUrl(input.connectionId),
192
+ ...(input.withRegistrationLock
193
+ ? {withRegistrationLock: input.withRegistrationLock}
194
+ : {}),
195
+ ...(input.onRegistrationSuccess
196
+ ? {onRegistrationSuccess: input.onRegistrationSuccess}
197
+ : {}),
198
+ ...(input.onRegistrationFailure
199
+ ? {onRegistrationFailure: input.onRegistrationFailure}
200
+ : {}),
201
+ }).then(() => undefined),
202
+ }),
203
+ ...webhookRoutes,
204
+ ]
90
205
  : [];
91
206
  return {
92
207
  provider: JIRA_PROVIDER,
93
208
  displayName: 'Jira',
94
- adapters: {},
209
+ adapters,
95
210
  async connectionExternalUrl(connection: {id: string}): Promise<string | undefined> {
96
211
  return (await getInstallationByConnectionId(connection.id))?.siteUrl;
97
212
  },
98
213
  routes,
214
+ webhookProcessors: webhookProcessor
215
+ ? [{routeIds: ['jira'] as const, processor: webhookProcessor}]
216
+ : undefined,
217
+ };
218
+ }
219
+
220
+ function jiraWebhookUrl(connectionId: string): string {
221
+ return `${config.JIRA_WEBHOOK_BASE_URL.replace(TRAILING_SLASHES_RE, '')}${JIRA_WEBHOOK_ROUTE_PREFIX}/${connectionId}`;
222
+ }
223
+
224
+ function toJiraWebhookOptions(
225
+ routes: CreateJiraIntegrationProviderOptions['routes'],
226
+ ): CreateJiraWebhookRoutesOptions | undefined {
227
+ if (!routes) return undefined;
228
+ if (
229
+ routes.coreDb === undefined ||
230
+ routes.publishIntegrationEventReceived === undefined ||
231
+ routes.recordDeliveryOnly === undefined ||
232
+ routes.getIntegrationConnectionById === undefined
233
+ )
234
+ throw new Error(
235
+ 'Jira integration provider requires all webhook receiver dependencies: coreDb, publishIntegrationEventReceived, recordDeliveryOnly, and getIntegrationConnectionById',
236
+ );
237
+ return {
238
+ coreDb: routes.coreDb,
239
+ publishIntegrationEventReceived: routes.publishIntegrationEventReceived,
240
+ recordDeliveryOnly: routes.recordDeliveryOnly,
241
+ getIntegrationConnectionById: routes.getIntegrationConnectionById,
99
242
  };
100
243
  }
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ type CreateJiraMaintenanceActivitiesOptions,
3
+ createJiraMaintenanceActivities,
4
+ type JiraTokenRefreshActivityResult,
5
+ } from './refresh-jira-tokens.js';