@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.
- package/.turbo/turbo-build.log +17 -2
- package/CHANGELOG.md +36 -0
- package/README.md +58 -2
- package/dist/api/client.d.ts +34 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +131 -0
- package/dist/api/client.js.map +1 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -4
- package/dist/config.js.map +1 -1
- package/dist/core/agent-tools-provider.d.ts +26 -0
- package/dist/core/agent-tools-provider.d.ts.map +1 -0
- package/dist/core/agent-tools-provider.js +130 -0
- package/dist/core/agent-tools-provider.js.map +1 -0
- package/dist/core/agent-tools.d.ts +16 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +441 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/install.d.ts +22 -0
- package/dist/core/install.d.ts.map +1 -1
- package/dist/core/install.js +68 -20
- package/dist/core/install.js.map +1 -1
- package/dist/core/tokens.d.ts +2 -0
- package/dist/core/tokens.d.ts.map +1 -1
- package/dist/core/tokens.js +129 -37
- package/dist/core/tokens.js.map +1 -1
- package/dist/core/webhook-processor.d.ts +15 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +137 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/core/webhook-registration.d.ts +26 -0
- package/dist/core/webhook-registration.d.ts.map +1 -0
- package/dist/core/webhook-registration.js +165 -0
- package/dist/core/webhook-registration.js.map +1 -0
- package/dist/core/webhook.d.ts +18 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +38 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +68 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +33 -6
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +79 -7
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +34 -0
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +12 -2
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +34 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +76 -8
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/install.d.ts +22 -0
- package/dist/presentation/routes/install.d.ts.map +1 -1
- package/dist/presentation/routes/install.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +8 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +108 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +2 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +3 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
- package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
- package/dist/temporal/constants.d.ts +6 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +8 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/worker.d.ts +9 -0
- package/dist/temporal/worker.d.ts.map +1 -0
- package/dist/temporal/worker.js +26 -0
- package/dist/temporal/worker.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +25225 -0
- package/dist/temporal/workflows/index.bundle.meta.json +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_jira-token-refresh-state.sql +12 -0
- package/drizzle/meta/0001_snapshot.json +245 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +17 -8
- package/src/api/client.test.ts +236 -9
- package/src/api/client.ts +192 -0
- package/src/config.test.ts +0 -1
- package/src/config.ts +1 -4
- package/src/core/agent-tools-provider.test.ts +299 -0
- package/src/core/agent-tools-provider.ts +175 -0
- package/src/core/agent-tools.ts +450 -0
- package/src/core/install.test.ts +179 -0
- package/src/core/install.ts +85 -19
- package/src/core/tokens-refresh.test.ts +223 -4
- package/src/core/tokens.test.ts +1 -1
- package/src/core/tokens.ts +153 -33
- package/src/core/webhook-processor.test.ts +418 -0
- package/src/core/webhook-processor.ts +166 -0
- package/src/core/webhook-registration.test.ts +375 -0
- package/src/core/webhook-registration.ts +216 -0
- package/src/core/webhook.ts +69 -0
- package/src/db/installations.test.ts +148 -2
- package/src/db/installations.ts +133 -7
- package/src/db/schema/installations.ts +21 -1
- package/src/index.test.ts +42 -0
- package/src/index.ts +148 -5
- package/src/presentation/routes/install.ts +12 -0
- package/src/presentation/routes/webhooks.test.ts +132 -0
- package/src/presentation/routes/webhooks.ts +115 -0
- package/src/temporal/activities/index.ts +5 -0
- package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
- package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
- package/src/temporal/constants.ts +8 -0
- package/src/temporal/worker.ts +39 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
- package/test/env.ts +0 -1
- package/test/factories/jira-installation.ts +4 -0
- 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 {
|
|
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 {
|
|
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?:
|
|
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
|
-
? [
|
|
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
|
+
}
|