@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +53 -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/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/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/installations.d.ts +11 -0
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +54 -5
- package/dist/db/installations.js.map +1 -1
- package/dist/index.d.ts +17 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +61 -5
- 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/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +9 -7
- package/src/api/client.test.ts +106 -0
- package/src/api/client.ts +85 -0
- package/src/config.test.ts +0 -1
- package/src/config.ts +1 -4
- package/src/core/install.test.ts +179 -0
- package/src/core/install.ts +85 -19
- package/src/core/tokens-refresh.test.ts +2 -0
- 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 +64 -0
- package/src/db/installations.ts +71 -5
- package/src/index.test.ts +8 -0
- package/src/index.ts +102 -3
- 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/test/env.ts +0 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { jiraWebhookEnvelopeSchema } from '@shipfox/api-integration-jira-dto';
|
|
4
|
+
import { decodeWebhookBody } from '@shipfox/api-integration-spi';
|
|
5
|
+
import { extractBearerToken } from '@shipfox/node-fastify';
|
|
6
|
+
import { verifyHs256 } from '@shipfox/node-jwt';
|
|
7
|
+
import { logger } from '@shipfox/node-opentelemetry';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { config } from '#config.js';
|
|
10
|
+
import { handleJiraWebhook, isJiraInstallationUsable } from '#core/webhook.js';
|
|
11
|
+
import { getJiraInstallationByConnectionId } from '#db/installations.js';
|
|
12
|
+
const JIRA_PROVIDER = 'jira';
|
|
13
|
+
const jiraWebhookJwtClaimsSchema = z.object({
|
|
14
|
+
iat: z.number().int(),
|
|
15
|
+
exp: z.number().int()
|
|
16
|
+
}).passthrough();
|
|
17
|
+
export function createJiraWebhookProcessor(options) {
|
|
18
|
+
return {
|
|
19
|
+
process: (request)=>processJiraWebhookRequest(options, request)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function processJiraWebhookRequest(options, request) {
|
|
23
|
+
if (request.route_id !== 'jira') {
|
|
24
|
+
throw new Error(`Jira processor cannot process ${request.route_id} requests`);
|
|
25
|
+
}
|
|
26
|
+
const rawBody = Buffer.from(decodeWebhookBody(request.body));
|
|
27
|
+
const deliveryId = jiraDeliveryId(request, rawBody);
|
|
28
|
+
const authorization = request.headers.authorization;
|
|
29
|
+
if (!authorization) return invalidAuthorization(deliveryId);
|
|
30
|
+
const token = extractBearerToken(authorization);
|
|
31
|
+
if (!token) return invalidAuthorization(deliveryId);
|
|
32
|
+
try {
|
|
33
|
+
await verifyHs256({
|
|
34
|
+
token,
|
|
35
|
+
secret: config.JIRA_OAUTH_CLIENT_SECRET,
|
|
36
|
+
schema: jiraWebhookJwtClaimsSchema,
|
|
37
|
+
verificationTime: new Date(request.received_at)
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
logger().warn({
|
|
41
|
+
deliveryId,
|
|
42
|
+
errName: error instanceof Error ? error.name : typeof error
|
|
43
|
+
}, 'Jira webhook authorization verification failed');
|
|
44
|
+
return invalidAuthorization(deliveryId);
|
|
45
|
+
}
|
|
46
|
+
let rawPayload;
|
|
47
|
+
try {
|
|
48
|
+
rawPayload = JSON.parse(rawBody.toString('utf8'));
|
|
49
|
+
} catch (error) {
|
|
50
|
+
logger().warn({
|
|
51
|
+
deliveryId,
|
|
52
|
+
err: error
|
|
53
|
+
}, 'Jira webhook payload JSON parse failed');
|
|
54
|
+
return {
|
|
55
|
+
outcome: 'discarded',
|
|
56
|
+
reason: 'malformed_payload',
|
|
57
|
+
deliveryId
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const payload = jiraWebhookEnvelopeSchema.safeParse(rawPayload);
|
|
61
|
+
if (!payload.success) {
|
|
62
|
+
await recordDeliveryOnly(options, deliveryId);
|
|
63
|
+
return {
|
|
64
|
+
outcome: 'discarded',
|
|
65
|
+
reason: 'unsupported_event',
|
|
66
|
+
deliveryId
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const connectionId = request.path_parameters.connection_id;
|
|
70
|
+
const connection = await options.getIntegrationConnectionById(connectionId);
|
|
71
|
+
if (!connection || connection.provider !== JIRA_PROVIDER || connection.lifecycleStatus !== 'active') {
|
|
72
|
+
return await discardUnavailableConnection(options, deliveryId);
|
|
73
|
+
}
|
|
74
|
+
const installation = await (options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId)(connectionId);
|
|
75
|
+
if (!isJiraInstallationUsable(installation) || !hasMatchingWebhookId(payload.data, installation.webhookIds)) {
|
|
76
|
+
return await discardUnavailableConnection(options, deliveryId);
|
|
77
|
+
}
|
|
78
|
+
const result = await options.coreDb().transaction(async (tx)=>handleJiraWebhook({
|
|
79
|
+
tx,
|
|
80
|
+
deliveryId,
|
|
81
|
+
receivedAt: request.received_at,
|
|
82
|
+
rawPayload: payload.data,
|
|
83
|
+
cloudId: installation.cloudId,
|
|
84
|
+
connection: connection,
|
|
85
|
+
authorizingAccountId: installation.authorizingAccountId,
|
|
86
|
+
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
87
|
+
recordDeliveryOnly: options.recordDeliveryOnly
|
|
88
|
+
}));
|
|
89
|
+
if (result === 'duplicate') return {
|
|
90
|
+
outcome: 'duplicate',
|
|
91
|
+
deliveryId
|
|
92
|
+
};
|
|
93
|
+
if (result === 'discarded') return {
|
|
94
|
+
outcome: 'discarded',
|
|
95
|
+
reason: 'unsupported_event',
|
|
96
|
+
deliveryId
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
outcome: 'processed',
|
|
100
|
+
deliveryId
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function invalidAuthorization(deliveryId) {
|
|
104
|
+
return {
|
|
105
|
+
outcome: 'discarded',
|
|
106
|
+
reason: 'invalid_signature',
|
|
107
|
+
deliveryId
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function jiraDeliveryId(request, rawBody) {
|
|
111
|
+
const connectionId = request.path_parameters.connection_id;
|
|
112
|
+
const jiraIdentifier = request.headers['x-atlassian-webhook-identifier'];
|
|
113
|
+
if (jiraIdentifier) return `${connectionId}:${jiraIdentifier}`;
|
|
114
|
+
return createHash('sha256').update(connectionId).update('\0').update(rawBody).digest('hex');
|
|
115
|
+
}
|
|
116
|
+
async function recordDeliveryOnly(options, deliveryId) {
|
|
117
|
+
await options.coreDb().transaction(async (tx)=>{
|
|
118
|
+
await options.recordDeliveryOnly({
|
|
119
|
+
tx,
|
|
120
|
+
provider: JIRA_PROVIDER,
|
|
121
|
+
deliveryId
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async function discardUnavailableConnection(options, deliveryId) {
|
|
126
|
+
await recordDeliveryOnly(options, deliveryId);
|
|
127
|
+
return {
|
|
128
|
+
outcome: 'discarded',
|
|
129
|
+
reason: 'connection_unavailable',
|
|
130
|
+
deliveryId
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function hasMatchingWebhookId(payload, storedWebhookIds) {
|
|
134
|
+
return payload.matchedWebhookIds?.some((webhookId)=>storedWebhookIds.includes(webhookId)) === true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
//# sourceMappingURL=webhook-processor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/webhook-processor.ts"],"sourcesContent":["import {Buffer} from 'node:buffer';\nimport {createHash} from 'node:crypto';\nimport {\n type JiraWebhookEnvelopeDto,\n jiraWebhookEnvelopeSchema,\n} from '@shipfox/api-integration-jira-dto';\nimport {\n decodeWebhookBody,\n type GetIntegrationConnectionByIdFn,\n type PublishIntegrationEventReceivedFn,\n type RecordDeliveryOnlyFn,\n type StoredWebhookRequest,\n type WebhookProcessingResult,\n} from '@shipfox/api-integration-spi';\nimport {extractBearerToken} from '@shipfox/node-fastify';\nimport {verifyHs256} from '@shipfox/node-jwt';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport type {NodePgDatabase} from 'drizzle-orm/node-postgres';\nimport {z} from 'zod';\nimport {config} from '#config.js';\nimport {handleJiraWebhook, isJiraInstallationUsable} from '#core/webhook.js';\nimport {getJiraInstallationByConnectionId} from '#db/installations.js';\n\nconst JIRA_PROVIDER = 'jira';\nconst jiraWebhookJwtClaimsSchema = z\n .object({iat: z.number().int(), exp: z.number().int()})\n .passthrough();\n\nexport interface CreateJiraWebhookProcessorOptions {\n coreDb: () => NodePgDatabase<Record<string, unknown>>;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n getIntegrationConnectionById: GetIntegrationConnectionByIdFn;\n getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId;\n}\n\nexport interface JiraWebhookProcessor {\n process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;\n}\n\nexport function createJiraWebhookProcessor(\n options: CreateJiraWebhookProcessorOptions,\n): JiraWebhookProcessor {\n return {process: (request) => processJiraWebhookRequest(options, request)};\n}\n\nasync function processJiraWebhookRequest(\n options: CreateJiraWebhookProcessorOptions,\n request: StoredWebhookRequest,\n): Promise<WebhookProcessingResult> {\n if (request.route_id !== 'jira') {\n throw new Error(`Jira processor cannot process ${request.route_id} requests`);\n }\n\n const rawBody = Buffer.from(decodeWebhookBody(request.body));\n const deliveryId = jiraDeliveryId(request, rawBody);\n const authorization = request.headers.authorization;\n if (!authorization) return invalidAuthorization(deliveryId);\n\n const token = extractBearerToken(authorization);\n if (!token) return invalidAuthorization(deliveryId);\n\n try {\n await verifyHs256({\n token,\n secret: config.JIRA_OAUTH_CLIENT_SECRET,\n schema: jiraWebhookJwtClaimsSchema,\n verificationTime: new Date(request.received_at),\n });\n } catch (error) {\n logger().warn(\n {deliveryId, errName: error instanceof Error ? error.name : typeof error},\n 'Jira webhook authorization verification failed',\n );\n return invalidAuthorization(deliveryId);\n }\n\n let rawPayload: unknown;\n try {\n rawPayload = JSON.parse(rawBody.toString('utf8'));\n } catch (error) {\n logger().warn({deliveryId, err: error}, 'Jira webhook payload JSON parse failed');\n return {outcome: 'discarded', reason: 'malformed_payload', deliveryId};\n }\n\n const payload = jiraWebhookEnvelopeSchema.safeParse(rawPayload);\n if (!payload.success) {\n await recordDeliveryOnly(options, deliveryId);\n return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};\n }\n\n const connectionId = request.path_parameters.connection_id;\n const connection = await options.getIntegrationConnectionById(connectionId);\n if (\n !connection ||\n connection.provider !== JIRA_PROVIDER ||\n connection.lifecycleStatus !== 'active'\n ) {\n return await discardUnavailableConnection(options, deliveryId);\n }\n const installation = await (\n options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId\n )(connectionId);\n if (\n !isJiraInstallationUsable(installation) ||\n !hasMatchingWebhookId(payload.data, installation.webhookIds)\n ) {\n return await discardUnavailableConnection(options, deliveryId);\n }\n\n const result = await options.coreDb().transaction(async (tx) =>\n handleJiraWebhook({\n tx,\n deliveryId,\n receivedAt: request.received_at,\n rawPayload: payload.data,\n cloudId: installation.cloudId,\n connection: connection as typeof connection & {provider: 'jira'},\n authorizingAccountId: installation.authorizingAccountId,\n publishIntegrationEventReceived: options.publishIntegrationEventReceived,\n recordDeliveryOnly: options.recordDeliveryOnly,\n }),\n );\n\n if (result === 'duplicate') return {outcome: 'duplicate', deliveryId};\n if (result === 'discarded')\n return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};\n return {outcome: 'processed', deliveryId};\n}\n\nfunction invalidAuthorization(deliveryId: string): WebhookProcessingResult {\n return {outcome: 'discarded', reason: 'invalid_signature', deliveryId};\n}\n\nfunction jiraDeliveryId(request: StoredWebhookRequest, rawBody: Uint8Array): string {\n const connectionId = request.path_parameters.connection_id;\n const jiraIdentifier = request.headers['x-atlassian-webhook-identifier'];\n if (jiraIdentifier) return `${connectionId}:${jiraIdentifier}`;\n return createHash('sha256').update(connectionId).update('\\0').update(rawBody).digest('hex');\n}\n\nasync function recordDeliveryOnly(\n options: Pick<CreateJiraWebhookProcessorOptions, 'coreDb' | 'recordDeliveryOnly'>,\n deliveryId: string,\n): Promise<void> {\n await options.coreDb().transaction(async (tx) => {\n await options.recordDeliveryOnly({tx, provider: JIRA_PROVIDER, deliveryId});\n });\n}\n\nasync function discardUnavailableConnection(\n options: Pick<CreateJiraWebhookProcessorOptions, 'coreDb' | 'recordDeliveryOnly'>,\n deliveryId: string,\n): Promise<WebhookProcessingResult> {\n await recordDeliveryOnly(options, deliveryId);\n return {outcome: 'discarded', reason: 'connection_unavailable', deliveryId};\n}\n\nfunction hasMatchingWebhookId(\n payload: JiraWebhookEnvelopeDto,\n storedWebhookIds: number[],\n): boolean {\n return (\n payload.matchedWebhookIds?.some((webhookId) => storedWebhookIds.includes(webhookId)) === true\n );\n}\n"],"names":["Buffer","createHash","jiraWebhookEnvelopeSchema","decodeWebhookBody","extractBearerToken","verifyHs256","logger","z","config","handleJiraWebhook","isJiraInstallationUsable","getJiraInstallationByConnectionId","JIRA_PROVIDER","jiraWebhookJwtClaimsSchema","object","iat","number","int","exp","passthrough","createJiraWebhookProcessor","options","process","request","processJiraWebhookRequest","route_id","Error","rawBody","from","body","deliveryId","jiraDeliveryId","authorization","headers","invalidAuthorization","token","secret","JIRA_OAUTH_CLIENT_SECRET","schema","verificationTime","Date","received_at","error","warn","errName","name","rawPayload","JSON","parse","toString","err","outcome","reason","payload","safeParse","success","recordDeliveryOnly","connectionId","path_parameters","connection_id","connection","getIntegrationConnectionById","provider","lifecycleStatus","discardUnavailableConnection","installation","hasMatchingWebhookId","data","webhookIds","result","coreDb","transaction","tx","receivedAt","cloudId","authorizingAccountId","publishIntegrationEventReceived","jiraIdentifier","update","digest","storedWebhookIds","matchedWebhookIds","some","webhookId","includes"],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { JiraApiClient } from '#api/client.js';
|
|
2
|
+
import { getJiraInstallationByConnectionId, type JiraInstallationLock, updateJiraInstallationWebhook } from '#db/installations.js';
|
|
3
|
+
export declare const JIRA_WEBHOOK_TTL_MS: number;
|
|
4
|
+
export interface RegisterJiraWebhookParams {
|
|
5
|
+
jira: Pick<JiraApiClient, 'registerDynamicWebhook' | 'deleteDynamicWebhook'>;
|
|
6
|
+
connectionId: string;
|
|
7
|
+
cloudId: string;
|
|
8
|
+
accessToken: string;
|
|
9
|
+
webhookUrl: string;
|
|
10
|
+
now?: () => Date;
|
|
11
|
+
getInstallation?: typeof getJiraInstallationByConnectionId;
|
|
12
|
+
updateInstallation?: typeof updateJiraInstallationWebhook;
|
|
13
|
+
withRegistrationLock?: JiraInstallationLock;
|
|
14
|
+
onRegistrationSuccess?: (input: {
|
|
15
|
+
tx?: unknown;
|
|
16
|
+
}) => Promise<void>;
|
|
17
|
+
onRegistrationFailure?: (input: {
|
|
18
|
+
tx?: unknown;
|
|
19
|
+
}) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/** Register Jira's curated webhook and replace local metadata as one serialized state transition. */
|
|
22
|
+
export declare function registerJiraWebhook(params: RegisterJiraWebhookParams): Promise<{
|
|
23
|
+
webhookId: number;
|
|
24
|
+
webhookExpiresAt: Date;
|
|
25
|
+
}>;
|
|
26
|
+
//# sourceMappingURL=webhook-registration.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook-registration.d.ts","sourceRoot":"","sources":["../../src/core/webhook-registration.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAClD,OAAO,EACL,iCAAiC,EACjC,KAAK,oBAAoB,EACzB,6BAA6B,EAE9B,MAAM,sBAAsB,CAAC;AAE9B,eAAO,MAAM,mBAAmB,QAA2B,CAAC;AAE5D,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,IAAI,CAAC,aAAa,EAAE,wBAAwB,GAAG,sBAAsB,CAAC,CAAC;IAC7E,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB,eAAe,CAAC,EAAE,OAAO,iCAAiC,CAAC;IAC3D,kBAAkB,CAAC,EAAE,OAAO,6BAA6B,CAAC;IAC1D,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,qBAAqB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAC,EAAE,CAAC,EAAE,OAAO,CAAA;KAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,qBAAqB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAC,EAAE,CAAC,EAAE,OAAO,CAAA;KAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClE;AAED,qGAAqG;AACrG,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,yBAAyB,GAChC,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,gBAAgB,EAAE,IAAI,CAAA;CAAC,CAAC,CA8FtD"}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { logger } from '@shipfox/node-opentelemetry';
|
|
2
|
+
import { getJiraInstallationByConnectionId, updateJiraInstallationWebhook, withJiraInstallationLock } from '#db/installations.js';
|
|
3
|
+
export const JIRA_WEBHOOK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
4
|
+
/** Register Jira's curated webhook and replace local metadata as one serialized state transition. */ export async function registerJiraWebhook(params) {
|
|
5
|
+
const withRegistrationLock = params.withRegistrationLock ?? withJiraInstallationLock;
|
|
6
|
+
let callbackFailed = false;
|
|
7
|
+
let registeredWebhookId;
|
|
8
|
+
let previous;
|
|
9
|
+
let metadataPersisted = false;
|
|
10
|
+
let cleanupSupersededWebhooks;
|
|
11
|
+
let result;
|
|
12
|
+
try {
|
|
13
|
+
result = await withRegistrationLock(params.cloudId, async ()=>{
|
|
14
|
+
try {
|
|
15
|
+
const getInstallation = params.getInstallation ?? getJiraInstallationByConnectionId;
|
|
16
|
+
previous = await getInstallation(params.connectionId);
|
|
17
|
+
const registration = await params.jira.registerDynamicWebhook({
|
|
18
|
+
accessToken: params.accessToken,
|
|
19
|
+
cloudId: params.cloudId,
|
|
20
|
+
url: params.webhookUrl
|
|
21
|
+
});
|
|
22
|
+
registeredWebhookId = registration.webhookId;
|
|
23
|
+
const now = params.now ?? (()=>new Date());
|
|
24
|
+
const webhookExpiresAt = new Date(now().getTime() + JIRA_WEBHOOK_TTL_MS);
|
|
25
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
26
|
+
const updateInput = {
|
|
27
|
+
connectionId: params.connectionId,
|
|
28
|
+
webhookIds: [
|
|
29
|
+
...new Set([
|
|
30
|
+
registration.webhookId,
|
|
31
|
+
...previous?.webhookIds ?? []
|
|
32
|
+
])
|
|
33
|
+
],
|
|
34
|
+
webhookExpiresAt
|
|
35
|
+
};
|
|
36
|
+
const installation = await updateInstallation(updateInput);
|
|
37
|
+
if (!installation) throw new Error('Jira webhook registration lost its installation record');
|
|
38
|
+
metadataPersisted = true;
|
|
39
|
+
await params.onRegistrationSuccess?.({});
|
|
40
|
+
cleanupSupersededWebhooks = ()=>finishSupersededWebhookCleanup(params, previous, registration.webhookId, webhookExpiresAt);
|
|
41
|
+
return {
|
|
42
|
+
webhookId: registration.webhookId,
|
|
43
|
+
webhookExpiresAt
|
|
44
|
+
};
|
|
45
|
+
} catch (error) {
|
|
46
|
+
callbackFailed = true;
|
|
47
|
+
if (registeredWebhookId !== undefined) {
|
|
48
|
+
const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);
|
|
49
|
+
if (metadataPersisted || !deleted) {
|
|
50
|
+
await restorePreviousWebhookMetadata(params, previous, deleted ? undefined : registeredWebhookId);
|
|
51
|
+
}
|
|
52
|
+
} else if (metadataPersisted) {
|
|
53
|
+
await restorePreviousWebhookMetadata(params, previous);
|
|
54
|
+
}
|
|
55
|
+
await bestEffortRegistrationFailure(params);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (!callbackFailed) {
|
|
61
|
+
if (registeredWebhookId !== undefined) {
|
|
62
|
+
const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);
|
|
63
|
+
if (metadataPersisted || !deleted) {
|
|
64
|
+
await restorePreviousWebhookMetadata(params, previous, deleted ? undefined : registeredWebhookId);
|
|
65
|
+
}
|
|
66
|
+
} else if (metadataPersisted) {
|
|
67
|
+
await restorePreviousWebhookMetadata(params, previous);
|
|
68
|
+
}
|
|
69
|
+
await bestEffortRegistrationFailure(params);
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
if (cleanupSupersededWebhooks) {
|
|
74
|
+
try {
|
|
75
|
+
await withRegistrationLock(params.cloudId, cleanupSupersededWebhooks);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
logger().warn({
|
|
78
|
+
err: error,
|
|
79
|
+
connectionId: params.connectionId
|
|
80
|
+
}, 'Jira superseded webhook cleanup persistence failed');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
async function finishSupersededWebhookCleanup(params, previous, registeredWebhookId, webhookExpiresAt) {
|
|
86
|
+
const supersededWebhookIds = (previous?.webhookIds ?? []).filter((webhookId)=>webhookId !== registeredWebhookId);
|
|
87
|
+
if (supersededWebhookIds.length === 0) return;
|
|
88
|
+
const failedCleanupIds = [];
|
|
89
|
+
for (const webhookId of supersededWebhookIds){
|
|
90
|
+
try {
|
|
91
|
+
await params.jira.deleteDynamicWebhook({
|
|
92
|
+
accessToken: params.accessToken,
|
|
93
|
+
cloudId: params.cloudId,
|
|
94
|
+
webhookId
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
failedCleanupIds.push(webhookId);
|
|
98
|
+
logger().warn({
|
|
99
|
+
err: error,
|
|
100
|
+
connectionId: params.connectionId,
|
|
101
|
+
webhookId
|
|
102
|
+
}, 'Jira superseded webhook cleanup failed');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const retainedWebhookIds = [
|
|
106
|
+
registeredWebhookId,
|
|
107
|
+
...failedCleanupIds
|
|
108
|
+
];
|
|
109
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
110
|
+
const cleanupMetadata = await updateInstallation({
|
|
111
|
+
connectionId: params.connectionId,
|
|
112
|
+
webhookIds: retainedWebhookIds,
|
|
113
|
+
webhookExpiresAt
|
|
114
|
+
});
|
|
115
|
+
if (!cleanupMetadata) throw new Error('Jira webhook cleanup metadata persistence returned no installation');
|
|
116
|
+
}
|
|
117
|
+
async function bestEffortRegistrationFailure(params) {
|
|
118
|
+
try {
|
|
119
|
+
await params.onRegistrationFailure?.({});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logger().warn({
|
|
122
|
+
err: error,
|
|
123
|
+
connectionId: params.connectionId
|
|
124
|
+
}, 'Jira connection error-state update failed after webhook registration rejection');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function restorePreviousWebhookMetadata(params, previous, retainedWebhookId) {
|
|
128
|
+
try {
|
|
129
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
130
|
+
await updateInstallation({
|
|
131
|
+
connectionId: params.connectionId,
|
|
132
|
+
webhookIds: [
|
|
133
|
+
...retainedWebhookId === undefined ? [] : [
|
|
134
|
+
retainedWebhookId
|
|
135
|
+
],
|
|
136
|
+
...(previous?.webhookIds ?? []).filter((webhookId)=>webhookId !== retainedWebhookId)
|
|
137
|
+
],
|
|
138
|
+
webhookExpiresAt: previous?.webhookExpiresAt ?? null
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
logger().warn({
|
|
142
|
+
err: error,
|
|
143
|
+
connectionId: params.connectionId
|
|
144
|
+
}, 'Jira webhook metadata restoration failed after registration rejection');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function deleteNewWebhookAfterPersistenceFailure(params, webhookId) {
|
|
148
|
+
try {
|
|
149
|
+
await params.jira.deleteDynamicWebhook({
|
|
150
|
+
accessToken: params.accessToken,
|
|
151
|
+
cloudId: params.cloudId,
|
|
152
|
+
webhookId
|
|
153
|
+
});
|
|
154
|
+
return true;
|
|
155
|
+
} catch (cleanupError) {
|
|
156
|
+
logger().warn({
|
|
157
|
+
err: cleanupError,
|
|
158
|
+
connectionId: params.connectionId,
|
|
159
|
+
webhookId
|
|
160
|
+
}, 'Jira webhook cleanup failed after metadata persistence rejection');
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
//# sourceMappingURL=webhook-registration.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/webhook-registration.ts"],"sourcesContent":["import {logger} from '@shipfox/node-opentelemetry';\nimport type {JiraApiClient} from '#api/client.js';\nimport {\n getJiraInstallationByConnectionId,\n type JiraInstallationLock,\n updateJiraInstallationWebhook,\n withJiraInstallationLock,\n} from '#db/installations.js';\n\nexport const JIRA_WEBHOOK_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\nexport interface RegisterJiraWebhookParams {\n jira: Pick<JiraApiClient, 'registerDynamicWebhook' | 'deleteDynamicWebhook'>;\n connectionId: string;\n cloudId: string;\n accessToken: string;\n webhookUrl: string;\n now?: () => Date;\n getInstallation?: typeof getJiraInstallationByConnectionId;\n updateInstallation?: typeof updateJiraInstallationWebhook;\n withRegistrationLock?: JiraInstallationLock;\n onRegistrationSuccess?: (input: {tx?: unknown}) => Promise<void>;\n onRegistrationFailure?: (input: {tx?: unknown}) => Promise<void>;\n}\n\n/** Register Jira's curated webhook and replace local metadata as one serialized state transition. */\nexport async function registerJiraWebhook(\n params: RegisterJiraWebhookParams,\n): Promise<{webhookId: number; webhookExpiresAt: Date}> {\n const withRegistrationLock = params.withRegistrationLock ?? withJiraInstallationLock;\n let callbackFailed = false;\n let registeredWebhookId: number | undefined;\n let previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>;\n let metadataPersisted = false;\n let cleanupSupersededWebhooks: (() => Promise<void>) | undefined;\n let result: {webhookId: number; webhookExpiresAt: Date};\n try {\n result = await withRegistrationLock(params.cloudId, async () => {\n try {\n const getInstallation = params.getInstallation ?? getJiraInstallationByConnectionId;\n previous = await getInstallation(params.connectionId);\n const registration = await params.jira.registerDynamicWebhook({\n accessToken: params.accessToken,\n cloudId: params.cloudId,\n url: params.webhookUrl,\n });\n registeredWebhookId = registration.webhookId;\n const now = params.now ?? (() => new Date());\n const webhookExpiresAt = new Date(now().getTime() + JIRA_WEBHOOK_TTL_MS);\n\n const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;\n const updateInput = {\n connectionId: params.connectionId,\n webhookIds: [...new Set([registration.webhookId, ...(previous?.webhookIds ?? [])])],\n webhookExpiresAt,\n };\n const installation = await updateInstallation(updateInput);\n if (!installation)\n throw new Error('Jira webhook registration lost its installation record');\n metadataPersisted = true;\n await params.onRegistrationSuccess?.({});\n\n cleanupSupersededWebhooks = () =>\n finishSupersededWebhookCleanup(\n params,\n previous,\n registration.webhookId,\n webhookExpiresAt,\n );\n return {webhookId: registration.webhookId, webhookExpiresAt};\n } catch (error) {\n callbackFailed = true;\n if (registeredWebhookId !== undefined) {\n const deleted = await deleteNewWebhookAfterPersistenceFailure(\n params,\n registeredWebhookId,\n );\n if (metadataPersisted || !deleted) {\n await restorePreviousWebhookMetadata(\n params,\n previous,\n deleted ? undefined : registeredWebhookId,\n );\n }\n } else if (metadataPersisted) {\n await restorePreviousWebhookMetadata(params, previous);\n }\n await bestEffortRegistrationFailure(params);\n throw error;\n }\n });\n } catch (error) {\n if (!callbackFailed) {\n if (registeredWebhookId !== undefined) {\n const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);\n if (metadataPersisted || !deleted) {\n await restorePreviousWebhookMetadata(\n params,\n previous,\n deleted ? undefined : registeredWebhookId,\n );\n }\n } else if (metadataPersisted) {\n await restorePreviousWebhookMetadata(params, previous);\n }\n await bestEffortRegistrationFailure(params);\n }\n throw error;\n }\n\n if (cleanupSupersededWebhooks) {\n try {\n await withRegistrationLock(params.cloudId, cleanupSupersededWebhooks);\n } catch (error) {\n logger().warn(\n {err: error, connectionId: params.connectionId},\n 'Jira superseded webhook cleanup persistence failed',\n );\n }\n }\n\n return result;\n}\n\nasync function finishSupersededWebhookCleanup(\n params: RegisterJiraWebhookParams,\n previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,\n registeredWebhookId: number,\n webhookExpiresAt: Date,\n): Promise<void> {\n const supersededWebhookIds = (previous?.webhookIds ?? []).filter(\n (webhookId) => webhookId !== registeredWebhookId,\n );\n if (supersededWebhookIds.length === 0) return;\n\n const failedCleanupIds: number[] = [];\n for (const webhookId of supersededWebhookIds) {\n try {\n await params.jira.deleteDynamicWebhook({\n accessToken: params.accessToken,\n cloudId: params.cloudId,\n webhookId,\n });\n } catch (error) {\n failedCleanupIds.push(webhookId);\n logger().warn(\n {err: error, connectionId: params.connectionId, webhookId},\n 'Jira superseded webhook cleanup failed',\n );\n }\n }\n\n const retainedWebhookIds = [registeredWebhookId, ...failedCleanupIds];\n const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;\n const cleanupMetadata = await updateInstallation({\n connectionId: params.connectionId,\n webhookIds: retainedWebhookIds,\n webhookExpiresAt,\n });\n if (!cleanupMetadata)\n throw new Error('Jira webhook cleanup metadata persistence returned no installation');\n}\n\nasync function bestEffortRegistrationFailure(params: RegisterJiraWebhookParams): Promise<void> {\n try {\n await params.onRegistrationFailure?.({});\n } catch (error) {\n logger().warn(\n {err: error, connectionId: params.connectionId},\n 'Jira connection error-state update failed after webhook registration rejection',\n );\n }\n}\n\nasync function restorePreviousWebhookMetadata(\n params: RegisterJiraWebhookParams,\n previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,\n retainedWebhookId?: number,\n): Promise<void> {\n try {\n const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;\n await updateInstallation({\n connectionId: params.connectionId,\n webhookIds: [\n ...(retainedWebhookId === undefined ? [] : [retainedWebhookId]),\n ...(previous?.webhookIds ?? []).filter((webhookId) => webhookId !== retainedWebhookId),\n ],\n webhookExpiresAt: previous?.webhookExpiresAt ?? null,\n });\n } catch (error) {\n logger().warn(\n {err: error, connectionId: params.connectionId},\n 'Jira webhook metadata restoration failed after registration rejection',\n );\n }\n}\n\nasync function deleteNewWebhookAfterPersistenceFailure(\n params: RegisterJiraWebhookParams,\n webhookId: number,\n): Promise<boolean> {\n try {\n await params.jira.deleteDynamicWebhook({\n accessToken: params.accessToken,\n cloudId: params.cloudId,\n webhookId,\n });\n return true;\n } catch (cleanupError) {\n logger().warn(\n {err: cleanupError, connectionId: params.connectionId, webhookId},\n 'Jira webhook cleanup failed after metadata persistence rejection',\n );\n return false;\n }\n}\n"],"names":["logger","getJiraInstallationByConnectionId","updateJiraInstallationWebhook","withJiraInstallationLock","JIRA_WEBHOOK_TTL_MS","registerJiraWebhook","params","withRegistrationLock","callbackFailed","registeredWebhookId","previous","metadataPersisted","cleanupSupersededWebhooks","result","cloudId","getInstallation","connectionId","registration","jira","registerDynamicWebhook","accessToken","url","webhookUrl","webhookId","now","Date","webhookExpiresAt","getTime","updateInstallation","updateInput","webhookIds","Set","installation","Error","onRegistrationSuccess","finishSupersededWebhookCleanup","error","undefined","deleted","deleteNewWebhookAfterPersistenceFailure","restorePreviousWebhookMetadata","bestEffortRegistrationFailure","warn","err","supersededWebhookIds","filter","length","failedCleanupIds","deleteDynamicWebhook","push","retainedWebhookIds","cleanupMetadata","onRegistrationFailure","retainedWebhookId","cleanupError"],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { JiraWebhookEnvelopeDto } from '@shipfox/api-integration-jira-dto';
|
|
2
|
+
import type { IntegrationConnection, IntegrationTx, PublishIntegrationEventReceivedFn, RecordDeliveryOnlyFn } from '@shipfox/api-integration-spi';
|
|
3
|
+
import type { JiraInstallation } from '#db/installations.js';
|
|
4
|
+
export interface HandleJiraWebhookParams {
|
|
5
|
+
tx: IntegrationTx;
|
|
6
|
+
deliveryId: string;
|
|
7
|
+
receivedAt: string;
|
|
8
|
+
rawPayload: JiraWebhookEnvelopeDto;
|
|
9
|
+
cloudId: string;
|
|
10
|
+
connection: IntegrationConnection<'jira'>;
|
|
11
|
+
authorizingAccountId: string;
|
|
12
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
13
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
14
|
+
}
|
|
15
|
+
export type HandleJiraWebhookResult = 'published' | 'duplicate' | 'discarded';
|
|
16
|
+
export declare function handleJiraWebhook(params: HandleJiraWebhookParams): Promise<HandleJiraWebhookResult>;
|
|
17
|
+
export declare function isJiraInstallationUsable(installation: JiraInstallation | undefined): installation is JiraInstallation;
|
|
18
|
+
//# sourceMappingURL=webhook.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../../src/core/webhook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,mCAAmC,CAAC;AAC9E,OAAO,KAAK,EACV,qBAAqB,EACrB,aAAa,EACb,iCAAiC,EACjC,oBAAoB,EACrB,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,sBAAsB,CAAC;AAI3D,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,aAAa,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,sBAAsB,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAC1C,oBAAoB,EAAE,MAAM,CAAC;IAC7B,+BAA+B,EAAE,iCAAiC,CAAC;IACnE,kBAAkB,EAAE,oBAAoB,CAAC;CAC1C;AAED,MAAM,MAAM,uBAAuB,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE9E,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,uBAAuB,CAAC,CA0BlC;AAWD,wBAAgB,wBAAwB,CACtC,YAAY,EAAE,gBAAgB,GAAG,SAAS,GACzC,YAAY,IAAI,gBAAgB,CAElC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const JIRA_PROVIDER = 'jira';
|
|
2
|
+
export async function handleJiraWebhook(params) {
|
|
3
|
+
if (isSelfAuthoredEvent(params.rawPayload, params.authorizingAccountId)) {
|
|
4
|
+
await params.recordDeliveryOnly({
|
|
5
|
+
tx: params.tx,
|
|
6
|
+
provider: JIRA_PROVIDER,
|
|
7
|
+
deliveryId: params.deliveryId
|
|
8
|
+
});
|
|
9
|
+
return 'discarded';
|
|
10
|
+
}
|
|
11
|
+
const payload = {
|
|
12
|
+
...params.rawPayload,
|
|
13
|
+
cloudId: params.cloudId
|
|
14
|
+
};
|
|
15
|
+
const result = await params.publishIntegrationEventReceived({
|
|
16
|
+
tx: params.tx,
|
|
17
|
+
event: {
|
|
18
|
+
provider: JIRA_PROVIDER,
|
|
19
|
+
source: params.connection.slug,
|
|
20
|
+
event: params.rawPayload.webhookEvent,
|
|
21
|
+
workspaceId: params.connection.workspaceId,
|
|
22
|
+
connectionId: params.connection.id,
|
|
23
|
+
connectionName: params.connection.displayName,
|
|
24
|
+
deliveryId: params.deliveryId,
|
|
25
|
+
receivedAt: params.receivedAt,
|
|
26
|
+
payload
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return result.published ? 'published' : 'duplicate';
|
|
30
|
+
}
|
|
31
|
+
function isSelfAuthoredEvent(payload, authorizingAccountId) {
|
|
32
|
+
return (payload.webhookEvent === 'comment_created' || payload.webhookEvent === 'comment_updated' || payload.webhookEvent === 'jira:issue_updated') && payload.user.accountId === authorizingAccountId;
|
|
33
|
+
}
|
|
34
|
+
export function isJiraInstallationUsable(installation) {
|
|
35
|
+
return installation?.status === 'installed';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
//# sourceMappingURL=webhook.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/webhook.ts"],"sourcesContent":["import type {JiraWebhookEnvelopeDto} from '@shipfox/api-integration-jira-dto';\nimport type {\n IntegrationConnection,\n IntegrationTx,\n PublishIntegrationEventReceivedFn,\n RecordDeliveryOnlyFn,\n} from '@shipfox/api-integration-spi';\nimport type {JiraInstallation} from '#db/installations.js';\n\nconst JIRA_PROVIDER = 'jira';\n\nexport interface HandleJiraWebhookParams {\n tx: IntegrationTx;\n deliveryId: string;\n receivedAt: string;\n rawPayload: JiraWebhookEnvelopeDto;\n cloudId: string;\n connection: IntegrationConnection<'jira'>;\n authorizingAccountId: string;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n}\n\nexport type HandleJiraWebhookResult = 'published' | 'duplicate' | 'discarded';\n\nexport async function handleJiraWebhook(\n params: HandleJiraWebhookParams,\n): Promise<HandleJiraWebhookResult> {\n if (isSelfAuthoredEvent(params.rawPayload, params.authorizingAccountId)) {\n await params.recordDeliveryOnly({\n tx: params.tx,\n provider: JIRA_PROVIDER,\n deliveryId: params.deliveryId,\n });\n return 'discarded';\n }\n\n const payload = {...params.rawPayload, cloudId: params.cloudId};\n const result = await params.publishIntegrationEventReceived({\n tx: params.tx,\n event: {\n provider: JIRA_PROVIDER,\n source: params.connection.slug,\n event: params.rawPayload.webhookEvent,\n workspaceId: params.connection.workspaceId,\n connectionId: params.connection.id,\n connectionName: params.connection.displayName,\n deliveryId: params.deliveryId,\n receivedAt: params.receivedAt,\n payload,\n },\n });\n return result.published ? 'published' : 'duplicate';\n}\n\nfunction isSelfAuthoredEvent(payload: JiraWebhookEnvelopeDto, authorizingAccountId: string) {\n return (\n (payload.webhookEvent === 'comment_created' ||\n payload.webhookEvent === 'comment_updated' ||\n payload.webhookEvent === 'jira:issue_updated') &&\n payload.user.accountId === authorizingAccountId\n );\n}\n\nexport function isJiraInstallationUsable(\n installation: JiraInstallation | undefined,\n): installation is JiraInstallation {\n return installation?.status === 'installed';\n}\n"],"names":["JIRA_PROVIDER","handleJiraWebhook","params","isSelfAuthoredEvent","rawPayload","authorizingAccountId","recordDeliveryOnly","tx","provider","deliveryId","payload","cloudId","result","publishIntegrationEventReceived","event","source","connection","slug","webhookEvent","workspaceId","connectionId","id","connectionName","displayName","receivedAt","published","user","accountId","isJiraInstallationUsable","installation","status"],"mappings":"AASA,MAAMA,gBAAgB;AAgBtB,OAAO,eAAeC,kBACpBC,MAA+B;IAE/B,IAAIC,oBAAoBD,OAAOE,UAAU,EAAEF,OAAOG,oBAAoB,GAAG;QACvE,MAAMH,OAAOI,kBAAkB,CAAC;YAC9BC,IAAIL,OAAOK,EAAE;YACbC,UAAUR;YACVS,YAAYP,OAAOO,UAAU;QAC/B;QACA,OAAO;IACT;IAEA,MAAMC,UAAU;QAAC,GAAGR,OAAOE,UAAU;QAAEO,SAAST,OAAOS,OAAO;IAAA;IAC9D,MAAMC,SAAS,MAAMV,OAAOW,+BAA+B,CAAC;QAC1DN,IAAIL,OAAOK,EAAE;QACbO,OAAO;YACLN,UAAUR;YACVe,QAAQb,OAAOc,UAAU,CAACC,IAAI;YAC9BH,OAAOZ,OAAOE,UAAU,CAACc,YAAY;YACrCC,aAAajB,OAAOc,UAAU,CAACG,WAAW;YAC1CC,cAAclB,OAAOc,UAAU,CAACK,EAAE;YAClCC,gBAAgBpB,OAAOc,UAAU,CAACO,WAAW;YAC7Cd,YAAYP,OAAOO,UAAU;YAC7Be,YAAYtB,OAAOsB,UAAU;YAC7Bd;QACF;IACF;IACA,OAAOE,OAAOa,SAAS,GAAG,cAAc;AAC1C;AAEA,SAAStB,oBAAoBO,OAA+B,EAAEL,oBAA4B;IACxF,OACE,AAACK,CAAAA,QAAQQ,YAAY,KAAK,qBACxBR,QAAQQ,YAAY,KAAK,qBACzBR,QAAQQ,YAAY,KAAK,oBAAmB,KAC9CR,QAAQgB,IAAI,CAACC,SAAS,KAAKtB;AAE/B;AAEA,OAAO,SAASuB,yBACdC,YAA0C;IAE1C,OAAOA,cAAcC,WAAW;AAClC"}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type JiraInstallationLock = <T>(lockKey: string, fn: () => Promise<T>) => Promise<T>;
|
|
2
|
+
export declare function withJiraInstallationLock<T>(lockKey: string, fn: () => Promise<T>): Promise<T>;
|
|
1
3
|
export type JiraInstallationStatus = 'installed' | 'revoked';
|
|
2
4
|
export interface JiraInstallation {
|
|
3
5
|
id: string;
|
|
@@ -31,6 +33,11 @@ export interface UpdateJiraInstallationTokenExpiryParams {
|
|
|
31
33
|
tokenExpiresAt: Date | null;
|
|
32
34
|
scopes?: string[] | undefined;
|
|
33
35
|
}
|
|
36
|
+
export interface UpdateJiraInstallationWebhookParams {
|
|
37
|
+
connectionId: string;
|
|
38
|
+
webhookIds: number[];
|
|
39
|
+
webhookExpiresAt: Date | null;
|
|
40
|
+
}
|
|
34
41
|
export declare function upsertJiraInstallation(params: UpsertJiraInstallationParams, options?: {
|
|
35
42
|
tx?: unknown;
|
|
36
43
|
}): Promise<JiraInstallation>;
|
|
@@ -40,6 +47,9 @@ export declare function getJiraInstallationByCloudId(cloudId: string, options?:
|
|
|
40
47
|
export declare function updateJiraInstallationTokenExpiry(params: UpdateJiraInstallationTokenExpiryParams, options?: {
|
|
41
48
|
tx?: unknown;
|
|
42
49
|
}): Promise<JiraInstallation | undefined>;
|
|
50
|
+
export declare function updateJiraInstallationWebhook(params: UpdateJiraInstallationWebhookParams, options?: {
|
|
51
|
+
tx?: unknown;
|
|
52
|
+
}): Promise<JiraInstallation | undefined>;
|
|
43
53
|
export declare function deleteJiraInstallationByConnectionId(connectionId: string, options?: {
|
|
44
54
|
tx?: unknown;
|
|
45
55
|
}): Promise<boolean>;
|
|
@@ -50,6 +60,7 @@ export type JiraRefreshLockResult<T> = {
|
|
|
50
60
|
acquired: false;
|
|
51
61
|
};
|
|
52
62
|
export declare function withJiraRefreshLock<T>(connectionId: string, fn: () => Promise<T>): Promise<JiraRefreshLockResult<T>>;
|
|
63
|
+
export declare function withJiraWebhookRegistrationLock<T>(lockKey: string, fn: (tx?: unknown) => Promise<T>): Promise<T>;
|
|
53
64
|
export declare function getJiraInstallationByConnectionId(connectionId: string, options?: {
|
|
54
65
|
tx?: unknown;
|
|
55
66
|
}): Promise<JiraInstallation | undefined>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"installations.d.ts","sourceRoot":"","sources":["../../src/db/installations.ts"],"names":[],"mappings":"AAWA,MAAM,MAAM,sBAAsB,GAAG,WAAW,GAAG,SAAS,CAAC;AAE7D,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,gBAAgB,EAAE,IAAI,GAAG,IAAI,CAAC;IAC9B,MAAM,EAAE,sBAAsB,CAAC;IAC/B,cAAc,EAAE,IAAI,GAAG,IAAI,CAAC;IAC5B,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,4BAA4B;IAC3C,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAClC,gBAAgB,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,SAAS,CAAC;IAC3C,MAAM,EAAE,sBAAsB,CAAC;IAC/B,cAAc,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,SAAS,CAAC;CAC1C;AAED,MAAM,WAAW,uCAAuC;IACtD,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,IAAI,GAAG,IAAI,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;CAC/B;AAKD,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,4BAA4B,EACpC,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,gBAAgB,CAAC,
|
|
1
|
+
{"version":3,"file":"installations.d.ts","sourceRoot":"","sources":["../../src/db/installations.ts"],"names":[],"mappings":"AAWA,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;AAM5F,wBAAsB,wBAAwB,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,cA2BtF;AAED,MAAM,MAAM,sBAAsB,GAAG,WAAW,GAAG,SAAS,CAAC;AAE7D,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,gBAAgB,EAAE,IAAI,GAAG,IAAI,CAAC;IAC9B,MAAM,EAAE,sBAAsB,CAAC;IAC/B,cAAc,EAAE,IAAI,GAAG,IAAI,CAAC;IAC5B,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,4BAA4B;IAC3C,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;IAClC,gBAAgB,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,SAAS,CAAC;IAC3C,MAAM,EAAE,sBAAsB,CAAC;IAC/B,cAAc,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,SAAS,CAAC;CAC1C;AAED,MAAM,WAAW,uCAAuC;IACtD,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,EAAE,IAAI,GAAG,IAAI,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;CAC/B;AAED,MAAM,WAAW,mCAAmC;IAClD,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,gBAAgB,EAAE,IAAI,GAAG,IAAI,CAAC;CAC/B;AAKD,wBAAsB,sBAAsB,CAC1C,MAAM,EAAE,4BAA4B,EACpC,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,gBAAgB,CAAC,CAkD3B;AAED,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CASvC;AAED,wBAAsB,iCAAiC,CACrD,MAAM,EAAE,uCAAuC,EAC/C,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAYvC;AAED,wBAAsB,6BAA6B,CACjD,MAAM,EAAE,mCAAmC,EAC3C,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAYvC;AAED,wBAAsB,oCAAoC,CACxD,YAAY,EAAE,MAAM,EACpB,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,OAAO,CAAC,CAMlB;AAED,MAAM,MAAM,qBAAqB,CAAC,CAAC,IAAI;IAAC,QAAQ,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAC,GAAG;IAAC,QAAQ,EAAE,KAAK,CAAA;CAAC,CAAC;AAEtF,wBAAgB,mBAAmB,CAAC,CAAC,EACnC,YAAY,EAAE,MAAM,EACpB,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACnB,OAAO,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,CAEnC;AAED,wBAAgB,+BAA+B,CAAC,CAAC,EAC/C,OAAO,EAAE,MAAM,EACf,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,GAC/B,OAAO,CAAC,CAAC,CAAC,CAEZ;AAyBD,wBAAsB,iCAAiC,CACrD,YAAY,EAAE,MAAM,EACpB,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAUvC;AAED,wBAAsB,8BAA8B,CAClD,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAUvC;AAED,wBAAsB,2BAA2B,CAC/C,YAAY,EAAE,MAAM,EACpB,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CASvC"}
|
package/dist/db/installations.js
CHANGED
|
@@ -1,13 +1,46 @@
|
|
|
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 { JiraConnectionAlreadyLinkedError, JiraInstallationAlreadyLinkedError, JiraInstallationSiteMismatchError } from '#core/errors.js';
|
|
5
5
|
import { db } from './db.js';
|
|
6
6
|
import { jiraInstallations, toJiraInstallation } from './schema/installations.js';
|
|
7
|
+
const JIRA_INSTALLATION_LOCK_RETRY_DELAY_MS = 100;
|
|
8
|
+
const JIRA_INSTALLATION_LOCK_MAX_RETRY_DELAY_MS = 1_000;
|
|
9
|
+
const JIRA_INSTALLATION_LOCK_TIMEOUT_MS = 30_000;
|
|
10
|
+
export async function withJiraInstallationLock(lockKey, fn) {
|
|
11
|
+
const advisoryKey = `jira-installation:${lockKey}`;
|
|
12
|
+
const deadline = Date.now() + JIRA_INSTALLATION_LOCK_TIMEOUT_MS;
|
|
13
|
+
let retryDelayMs = JIRA_INSTALLATION_LOCK_RETRY_DELAY_MS;
|
|
14
|
+
while(true){
|
|
15
|
+
const attempt = await withPostgresSession(async (client)=>{
|
|
16
|
+
const lock = await client.query('SELECT pg_try_advisory_lock(hashtext($1)) AS acquired', [
|
|
17
|
+
advisoryKey
|
|
18
|
+
]);
|
|
19
|
+
if (lock.rows[0]?.acquired !== true) return {
|
|
20
|
+
acquired: false
|
|
21
|
+
};
|
|
22
|
+
try {
|
|
23
|
+
return {
|
|
24
|
+
acquired: true,
|
|
25
|
+
value: await fn()
|
|
26
|
+
};
|
|
27
|
+
} finally{
|
|
28
|
+
await client.query('SELECT pg_advisory_unlock(hashtext($1))', [
|
|
29
|
+
advisoryKey
|
|
30
|
+
]);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
if (attempt.acquired) return attempt.value;
|
|
34
|
+
if (Date.now() >= deadline) {
|
|
35
|
+
throw new Error(`Timed out waiting for Jira installation lock: ${lockKey}`);
|
|
36
|
+
}
|
|
37
|
+
await new Promise((resolve)=>setTimeout(resolve, retryDelayMs));
|
|
38
|
+
retryDelayMs = Math.min(retryDelayMs * 2, JIRA_INSTALLATION_LOCK_MAX_RETRY_DELAY_MS);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
7
41
|
export async function upsertJiraInstallation(params, options = {}) {
|
|
8
42
|
const executor = options.tx ?? db();
|
|
9
43
|
const now = new Date();
|
|
10
|
-
const webhookIds = params.webhookIds ?? [];
|
|
11
44
|
let row;
|
|
12
45
|
try {
|
|
13
46
|
[row] = await executor.insert(jiraInstallations).values({
|
|
@@ -17,7 +50,7 @@ export async function upsertJiraInstallation(params, options = {}) {
|
|
|
17
50
|
siteName: params.siteName,
|
|
18
51
|
authorizingAccountId: params.authorizingAccountId,
|
|
19
52
|
scopes: params.scopes,
|
|
20
|
-
webhookIds,
|
|
53
|
+
webhookIds: params.webhookIds ?? [],
|
|
21
54
|
webhookExpiresAt: params.webhookExpiresAt ?? null,
|
|
22
55
|
status: params.status,
|
|
23
56
|
tokenExpiresAt: params.tokenExpiresAt ?? null
|
|
@@ -30,8 +63,12 @@ export async function upsertJiraInstallation(params, options = {}) {
|
|
|
30
63
|
siteName: params.siteName,
|
|
31
64
|
authorizingAccountId: params.authorizingAccountId,
|
|
32
65
|
scopes: params.scopes,
|
|
33
|
-
webhookIds
|
|
34
|
-
|
|
66
|
+
...params.webhookIds === undefined ? {} : {
|
|
67
|
+
webhookIds: params.webhookIds
|
|
68
|
+
},
|
|
69
|
+
...params.webhookExpiresAt === undefined ? {} : {
|
|
70
|
+
webhookExpiresAt: params.webhookExpiresAt
|
|
71
|
+
},
|
|
35
72
|
status: params.status,
|
|
36
73
|
tokenExpiresAt: params.tokenExpiresAt ?? null,
|
|
37
74
|
updatedAt: now
|
|
@@ -66,6 +103,15 @@ export async function updateJiraInstallationTokenExpiry(params, options = {}) {
|
|
|
66
103
|
}).where(eq(jiraInstallations.connectionId, params.connectionId)).returning();
|
|
67
104
|
return row ? toJiraInstallation(row) : undefined;
|
|
68
105
|
}
|
|
106
|
+
export async function updateJiraInstallationWebhook(params, options = {}) {
|
|
107
|
+
const executor = options.tx ?? db();
|
|
108
|
+
const [row] = await executor.update(jiraInstallations).set({
|
|
109
|
+
webhookIds: params.webhookIds,
|
|
110
|
+
webhookExpiresAt: params.webhookExpiresAt,
|
|
111
|
+
updatedAt: new Date()
|
|
112
|
+
}).where(eq(jiraInstallations.connectionId, params.connectionId)).returning();
|
|
113
|
+
return row ? toJiraInstallation(row) : undefined;
|
|
114
|
+
}
|
|
69
115
|
export async function deleteJiraInstallationByConnectionId(connectionId, options = {}) {
|
|
70
116
|
const executor = options.tx ?? db();
|
|
71
117
|
const result = await executor.delete(jiraInstallations).where(eq(jiraInstallations.connectionId, connectionId));
|
|
@@ -74,6 +120,9 @@ export async function deleteJiraInstallationByConnectionId(connectionId, options
|
|
|
74
120
|
export function withJiraRefreshLock(connectionId, fn) {
|
|
75
121
|
return withJiraRefreshLockClient(connectionId, fn);
|
|
76
122
|
}
|
|
123
|
+
export function withJiraWebhookRegistrationLock(lockKey, fn) {
|
|
124
|
+
return withJiraInstallationLock(lockKey, ()=>fn());
|
|
125
|
+
}
|
|
77
126
|
async function withJiraRefreshLockClient(connectionId, fn) {
|
|
78
127
|
const client = await pgClient().connect();
|
|
79
128
|
let acquired = false;
|