@shipfox/api-integration-sentry 5.0.0 → 6.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 +7 -7
- package/CHANGELOG.md +31 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -5
- package/dist/config.js.map +1 -1
- package/dist/core/install.d.ts +6 -0
- package/dist/core/install.d.ts.map +1 -1
- package/dist/core/install.js +8 -5
- package/dist/core/install.js.map +1 -1
- package/dist/core/signature.d.ts +1 -1
- package/dist/core/signature.d.ts.map +1 -1
- package/dist/core/signature.js.map +1 -1
- package/dist/core/webhook-processor.d.ts +16 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +198 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/core/webhook.d.ts +4 -4
- package/dist/core/webhook.d.ts.map +1 -1
- package/dist/core/webhook.js +63 -49
- package/dist/core/webhook.js.map +1 -1
- package/dist/db/db.d.ts +8 -4
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +35 -13
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +89 -31
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +5 -3
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +6 -6
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +4 -1
- package/dist/presentation/routes/webhooks.d.ts.map +1 -1
- package/dist/presentation/routes/webhooks.js +69 -43
- package/dist/presentation/routes/webhooks.js.map +1 -1
- package/dist/temporal/activities/prune-unclaimed-installations.d.ts +1 -0
- package/dist/temporal/activities/prune-unclaimed-installations.d.ts.map +1 -1
- package/dist/temporal/activities/prune-unclaimed-installations.js.map +1 -1
- package/dist/temporal/workflows/index.bundle.js +7 -2
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts.map +1 -1
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.js +6 -1
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +12 -10
- package/src/config.ts +4 -5
- package/src/core/install.ts +21 -7
- package/src/core/signature.ts +1 -1
- package/src/core/webhook-processor.test.ts +304 -0
- package/src/core/webhook-processor.ts +224 -0
- package/src/core/webhook.ts +74 -53
- package/src/db/installations.test.ts +163 -17
- package/src/db/installations.ts +136 -35
- package/src/db/schema/installations.ts +7 -7
- package/src/index.test.ts +4 -1
- package/src/index.ts +16 -0
- package/src/presentation/routes/webhooks.test.ts +6 -5
- package/src/presentation/routes/webhooks.ts +88 -29
- package/src/temporal/activities/prune-unclaimed-installations.test.ts +24 -2
- package/src/temporal/activities/prune-unclaimed-installations.ts +4 -1
- package/src/temporal/workflows/prune-unclaimed-installations-cron.ts +4 -1
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/presentation/routes/installation-handler.d.ts +0 -9
- package/dist/presentation/routes/installation-handler.d.ts.map +0 -1
- package/dist/presentation/routes/installation-handler.js +0 -70
- package/dist/presentation/routes/installation-handler.js.map +0 -1
- package/dist/presentation/routes/issue-handler.d.ts +0 -9
- package/dist/presentation/routes/issue-handler.d.ts.map +0 -1
- package/dist/presentation/routes/issue-handler.js +0 -50
- package/dist/presentation/routes/issue-handler.js.map +0 -1
- package/dist/presentation/routes/webhook-delivery.d.ts +0 -28
- package/dist/presentation/routes/webhook-delivery.d.ts.map +0 -1
- package/dist/presentation/routes/webhook-delivery.js +0 -56
- package/dist/presentation/routes/webhook-delivery.js.map +0 -1
- package/dist/presentation/routes/webhook-request.d.ts +0 -10
- package/dist/presentation/routes/webhook-request.d.ts.map +0 -1
- package/dist/presentation/routes/webhook-request.js +0 -56
- package/dist/presentation/routes/webhook-request.js.map +0 -1
- package/src/presentation/routes/installation-handler.ts +0 -76
- package/src/presentation/routes/issue-handler.ts +0 -52
- package/src/presentation/routes/webhook-delivery.ts +0 -61
- package/src/presentation/routes/webhook-request.ts +0 -66
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-integration-sentry",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "6.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -26,14 +26,14 @@
|
|
|
26
26
|
"drizzle-orm": "^0.45.2",
|
|
27
27
|
"ky": "^2.0.0",
|
|
28
28
|
"zod": "^4.4.3",
|
|
29
|
-
"@shipfox/api-auth-context": "
|
|
30
|
-
"@shipfox/api-integration-core-dto": "
|
|
29
|
+
"@shipfox/api-auth-context": "6.0.0",
|
|
30
|
+
"@shipfox/api-integration-core-dto": "6.0.0",
|
|
31
|
+
"@shipfox/api-integration-sentry-dto": "6.0.0",
|
|
31
32
|
"@shipfox/config": "1.2.2",
|
|
32
|
-
"@shipfox/node-drizzle": "0.3.
|
|
33
|
-
"@shipfox/
|
|
34
|
-
"@shipfox/node-module": "0.
|
|
33
|
+
"@shipfox/node-drizzle": "0.3.2",
|
|
34
|
+
"@shipfox/node-fastify": "0.2.4",
|
|
35
|
+
"@shipfox/node-module": "0.4.0",
|
|
35
36
|
"@shipfox/node-opentelemetry": "0.5.2",
|
|
36
|
-
"@shipfox/node-fastify": "0.2.3",
|
|
37
37
|
"@shipfox/node-postgres": "0.4.2"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
@@ -43,16 +43,18 @@
|
|
|
43
43
|
"fastify-type-provider-zod": "^6.0.0",
|
|
44
44
|
"fishery": "^2.4.0",
|
|
45
45
|
"@shipfox/biome": "1.8.2",
|
|
46
|
-
"@shipfox/
|
|
46
|
+
"@shipfox/depcruise": "1.0.2",
|
|
47
|
+
"@shipfox/node-temporal": "0.3.2",
|
|
47
48
|
"@shipfox/swc": "1.2.6",
|
|
48
49
|
"@shipfox/ts-config": "1.3.8",
|
|
49
|
-
"@shipfox/
|
|
50
|
-
"@shipfox/
|
|
50
|
+
"@shipfox/typescript": "1.1.7",
|
|
51
|
+
"@shipfox/vitest": "1.2.3"
|
|
51
52
|
},
|
|
52
53
|
"scripts": {
|
|
53
54
|
"build": "shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js",
|
|
54
55
|
"check": "shipfox-biome-check",
|
|
55
56
|
"check:fix": "shipfox-biome-check --write",
|
|
57
|
+
"depcruise": "shipfox-depcruise",
|
|
56
58
|
"test": "shipfox-vitest-run",
|
|
57
59
|
"test:watch": "shipfox-vitest-watch",
|
|
58
60
|
"type": "shipfox-tsc-check",
|
package/src/config.ts
CHANGED
|
@@ -15,15 +15,14 @@ export const config = createConfig({
|
|
|
15
15
|
default: true,
|
|
16
16
|
}),
|
|
17
17
|
SENTRY_UNCLAIMED_INSTALLATION_RETENTION_DAYS: num({
|
|
18
|
-
desc: 'How many days
|
|
18
|
+
desc: 'How many days an unclaimed Sentry installation or incomplete exchange may sit without a state transition. The daily cleanup releases pending claims and tombstones verified installs after this window. Must be at least 1. Defaults to 7.',
|
|
19
19
|
default: 7,
|
|
20
20
|
}),
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// claimed yet. Reject it at startup rather than let the 04:00 cron fail silently.
|
|
23
|
+
// A value below 1 moves the cutoff to now or the future, which would expire fresh
|
|
24
|
+
// claims before a browser can bind them. Reject it at startup rather than let the
|
|
25
|
+
// 04:00 cron fail silently.
|
|
27
26
|
export function assertRetentionDaysWithinBounds(days: number): void {
|
|
28
27
|
if (!Number.isFinite(days) || days < 1) {
|
|
29
28
|
throw new Error(
|
package/src/core/install.ts
CHANGED
|
@@ -89,7 +89,7 @@ export async function verifyAndPersistUnclaimedInstallation(
|
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
if (params.verifyInstall) {
|
|
92
|
-
await
|
|
92
|
+
await verifySentryInstallationBestEffort({
|
|
93
93
|
sentry: params.sentry,
|
|
94
94
|
installationUuid: params.installationUuid,
|
|
95
95
|
token: authorization.token,
|
|
@@ -143,6 +143,9 @@ export async function handleSentryConnect(
|
|
|
143
143
|
if (install.connectionId) {
|
|
144
144
|
return resolveClaimedInstall(params, install.connectionId);
|
|
145
145
|
}
|
|
146
|
+
if (!isVerifiedUnclaimed(install)) {
|
|
147
|
+
throw new SentryVerificationInProgressError(params.installationUuid);
|
|
148
|
+
}
|
|
146
149
|
return claimVerifiedInstall(params, install);
|
|
147
150
|
}
|
|
148
151
|
|
|
@@ -162,7 +165,7 @@ async function resolveClaimedInstall(
|
|
|
162
165
|
|
|
163
166
|
async function claimVerifiedInstall(
|
|
164
167
|
params: HandleSentryConnectParams,
|
|
165
|
-
install:
|
|
168
|
+
install: VerifiedUnclaimedSentryInstallation,
|
|
166
169
|
): Promise<IntegrationConnection<'sentry'>> {
|
|
167
170
|
let authorization: SentryAuthorization;
|
|
168
171
|
try {
|
|
@@ -188,7 +191,7 @@ async function claimVerifiedInstall(
|
|
|
188
191
|
codeHash: hashAuthorizationCode(params.code),
|
|
189
192
|
});
|
|
190
193
|
if (params.verifyInstall) {
|
|
191
|
-
await
|
|
194
|
+
await verifySentryInstallationBestEffort({
|
|
192
195
|
sentry: params.sentry,
|
|
193
196
|
installationUuid: params.installationUuid,
|
|
194
197
|
token: authorization.token,
|
|
@@ -222,7 +225,7 @@ async function claimBrowserFirst(
|
|
|
222
225
|
codeHash: hashAuthorizationCode(params.code),
|
|
223
226
|
});
|
|
224
227
|
if (params.verifyInstall) {
|
|
225
|
-
await
|
|
228
|
+
await verifySentryInstallationBestEffort({
|
|
226
229
|
sentry: params.sentry,
|
|
227
230
|
installationUuid: params.installationUuid,
|
|
228
231
|
token: result.authorization.token,
|
|
@@ -255,8 +258,19 @@ async function reconcileConcurrentClaim(
|
|
|
255
258
|
throw new SentryVerificationInProgressError(params.installationUuid);
|
|
256
259
|
}
|
|
257
260
|
|
|
258
|
-
|
|
259
|
-
|
|
261
|
+
type VerifiedUnclaimedSentryInstallation = SentryInstallation & {
|
|
262
|
+
connectionId: null;
|
|
263
|
+
status: 'installed';
|
|
264
|
+
orgSlug: string;
|
|
265
|
+
codeHash: string;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
function isVerifiedUnclaimed(
|
|
269
|
+
install: SentryInstallation,
|
|
270
|
+
): install is VerifiedUnclaimedSentryInstallation {
|
|
271
|
+
return (
|
|
272
|
+
install.connectionId === null && install.status === 'installed' && install.codeHash !== null
|
|
273
|
+
);
|
|
260
274
|
}
|
|
261
275
|
|
|
262
276
|
function bindClaim(
|
|
@@ -273,7 +287,7 @@ function bindClaim(
|
|
|
273
287
|
});
|
|
274
288
|
}
|
|
275
289
|
|
|
276
|
-
async function
|
|
290
|
+
export async function verifySentryInstallationBestEffort(input: {
|
|
277
291
|
sentry: SentryApiClient;
|
|
278
292
|
installationUuid: string;
|
|
279
293
|
token: string;
|
package/src/core/signature.ts
CHANGED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import {createHmac, randomUUID} from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
createStoredWebhookRequest,
|
|
4
|
+
type IntegrationConnection,
|
|
5
|
+
} from '@shipfox/api-integration-core-dto';
|
|
6
|
+
import {eq} from 'drizzle-orm';
|
|
7
|
+
import type {SentryApiClient} from '#api/client.js';
|
|
8
|
+
import {db} from '#db/db.js';
|
|
9
|
+
import {sentryInstallations} from '#db/schema/installations.js';
|
|
10
|
+
import {sentryInstallationFactory, sentryIssueWebhookFactory} from '#test/index.js';
|
|
11
|
+
import {createSentryWebhookProcessor} from './webhook-processor.js';
|
|
12
|
+
|
|
13
|
+
const CLIENT_SECRET = 'test-client-secret';
|
|
14
|
+
|
|
15
|
+
function fakeConnection(overrides: Partial<IntegrationConnection> = {}): IntegrationConnection {
|
|
16
|
+
return {
|
|
17
|
+
id: randomUUID(),
|
|
18
|
+
workspaceId: randomUUID(),
|
|
19
|
+
provider: 'sentry',
|
|
20
|
+
externalAccountId: 'install-uuid',
|
|
21
|
+
slug: 'sentry_acme',
|
|
22
|
+
displayName: 'Sentry acme',
|
|
23
|
+
lifecycleStatus: 'active',
|
|
24
|
+
createdAt: new Date(),
|
|
25
|
+
updatedAt: new Date(),
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sentryClient(overrides: Partial<SentryApiClient> = {}): SentryApiClient {
|
|
31
|
+
return {
|
|
32
|
+
exchangeAuthorizationCode: vi.fn(() =>
|
|
33
|
+
Promise.resolve({token: 'tok', refreshToken: 'refresh', expiresAt: 'x'}),
|
|
34
|
+
),
|
|
35
|
+
getInstallation: vi.fn(() => Promise.resolve({orgSlug: 'acme'})),
|
|
36
|
+
verifyInstallation: vi.fn(() => Promise.resolve()),
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function signedRequest(input: {body: unknown; deliveryId?: string; resource: string}) {
|
|
42
|
+
const rawBody = Buffer.from(JSON.stringify(input.body));
|
|
43
|
+
const signature = createHmac('sha256', CLIENT_SECRET).update(rawBody).digest('hex');
|
|
44
|
+
|
|
45
|
+
return createStoredWebhookRequest({
|
|
46
|
+
requestId: randomUUID(),
|
|
47
|
+
routeId: 'sentry',
|
|
48
|
+
receivedAt: new Date().toISOString(),
|
|
49
|
+
rawQueryString: '',
|
|
50
|
+
headers: {
|
|
51
|
+
'content-type': 'application/json',
|
|
52
|
+
'request-id': input.deliveryId ?? randomUUID(),
|
|
53
|
+
'sentry-hook-resource': input.resource,
|
|
54
|
+
'sentry-hook-signature': signature,
|
|
55
|
+
},
|
|
56
|
+
body: rawBody,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('Sentry webhook processor', () => {
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
await db().delete(sentryInstallations);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('processes a valid issue delivery outside the HTTP adapter', async () => {
|
|
66
|
+
const connection = fakeConnection();
|
|
67
|
+
const installationUuid = 'processor-issue-installation';
|
|
68
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
69
|
+
const processor = createSentryWebhookProcessor({
|
|
70
|
+
sentry: sentryClient(),
|
|
71
|
+
coreDb: db,
|
|
72
|
+
publishIntegrationEventReceived,
|
|
73
|
+
recordDeliveryOnly: vi.fn(() => Promise.resolve()),
|
|
74
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(connection)),
|
|
75
|
+
updateConnectionLifecycleStatus: vi.fn(() => Promise.resolve(undefined)),
|
|
76
|
+
});
|
|
77
|
+
await sentryInstallationFactory.create({installationUuid, connectionId: connection.id});
|
|
78
|
+
const request = signedRequest({
|
|
79
|
+
resource: 'issue',
|
|
80
|
+
body: sentryIssueWebhookFactory.build({
|
|
81
|
+
action: 'created',
|
|
82
|
+
installation: {uuid: installationUuid},
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = await processor.process(request);
|
|
87
|
+
|
|
88
|
+
expect(result).toMatchObject({outcome: 'processed'});
|
|
89
|
+
expect(publishIntegrationEventReceived).toHaveBeenCalledOnce();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('processes an installation deletion outside the HTTP adapter', async () => {
|
|
93
|
+
const installationUuid = 'processor-installation-deletion';
|
|
94
|
+
const updateConnectionLifecycleStatus = vi.fn(() => Promise.resolve(undefined));
|
|
95
|
+
const processor = createSentryWebhookProcessor({
|
|
96
|
+
sentry: sentryClient(),
|
|
97
|
+
coreDb: db,
|
|
98
|
+
publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
|
|
99
|
+
recordDeliveryOnly: vi.fn(() => Promise.resolve()),
|
|
100
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(fakeConnection())),
|
|
101
|
+
updateConnectionLifecycleStatus,
|
|
102
|
+
});
|
|
103
|
+
const connectionId = randomUUID();
|
|
104
|
+
await sentryInstallationFactory.create({installationUuid, connectionId});
|
|
105
|
+
const request = signedRequest({
|
|
106
|
+
resource: 'installation',
|
|
107
|
+
body: {action: 'deleted', data: {installation: {uuid: installationUuid}}},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const result = await processor.process(request);
|
|
111
|
+
|
|
112
|
+
const [installation] = await db()
|
|
113
|
+
.select()
|
|
114
|
+
.from(sentryInstallations)
|
|
115
|
+
.where(eq(sentryInstallations.installationUuid, installationUuid));
|
|
116
|
+
expect(result).toMatchObject({outcome: 'processed'});
|
|
117
|
+
expect(installation?.status).toBe('deleted');
|
|
118
|
+
expect(updateConnectionLifecycleStatus).toHaveBeenCalledWith(
|
|
119
|
+
{id: connectionId, lifecycleStatus: 'disabled'},
|
|
120
|
+
expect.objectContaining({tx: expect.anything()}),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does not create a side effect for an invalid signature', async () => {
|
|
125
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
126
|
+
const processor = createSentryWebhookProcessor({
|
|
127
|
+
sentry: sentryClient(),
|
|
128
|
+
coreDb: db,
|
|
129
|
+
publishIntegrationEventReceived,
|
|
130
|
+
recordDeliveryOnly: vi.fn(() => Promise.resolve()),
|
|
131
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(fakeConnection())),
|
|
132
|
+
updateConnectionLifecycleStatus: vi.fn(() => Promise.resolve(undefined)),
|
|
133
|
+
});
|
|
134
|
+
const request = signedRequest({
|
|
135
|
+
resource: 'issue',
|
|
136
|
+
body: sentryIssueWebhookFactory.build({action: 'created'}),
|
|
137
|
+
});
|
|
138
|
+
request.headers['sentry-hook-signature'] = 'not-a-signature';
|
|
139
|
+
|
|
140
|
+
const result = await processor.process(request);
|
|
141
|
+
|
|
142
|
+
expect(result).toMatchObject({outcome: 'discarded', reason: 'invalid_signature'});
|
|
143
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('continues from the durable exchange checkpoint after completion persistence fails', async () => {
|
|
147
|
+
const installationUuid = 'processor-installation-retry';
|
|
148
|
+
const deliveryId = randomUUID();
|
|
149
|
+
const exchangeAuthorizationCode = vi.fn(() =>
|
|
150
|
+
Promise.resolve({token: 'tok', refreshToken: 'refresh', expiresAt: 'x'}),
|
|
151
|
+
);
|
|
152
|
+
const recordDeliveryOnly = vi
|
|
153
|
+
.fn()
|
|
154
|
+
.mockRejectedValueOnce(new Error('delivery persistence failed'))
|
|
155
|
+
.mockResolvedValueOnce(undefined);
|
|
156
|
+
const processor = createSentryWebhookProcessor({
|
|
157
|
+
sentry: sentryClient({exchangeAuthorizationCode}),
|
|
158
|
+
coreDb: db,
|
|
159
|
+
publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
|
|
160
|
+
recordDeliveryOnly,
|
|
161
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(fakeConnection())),
|
|
162
|
+
updateConnectionLifecycleStatus: vi.fn(() => Promise.resolve(undefined)),
|
|
163
|
+
});
|
|
164
|
+
const request = signedRequest({
|
|
165
|
+
deliveryId,
|
|
166
|
+
resource: 'installation',
|
|
167
|
+
body: {
|
|
168
|
+
action: 'created',
|
|
169
|
+
data: {
|
|
170
|
+
installation: {
|
|
171
|
+
uuid: installationUuid,
|
|
172
|
+
code: 'single-use-code',
|
|
173
|
+
organization: {slug: 'acme'},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const firstAttempt = processor.process(request);
|
|
180
|
+
await expect(firstAttempt).rejects.toThrow('delivery persistence failed');
|
|
181
|
+
const checkpoint = await db()
|
|
182
|
+
.select()
|
|
183
|
+
.from(sentryInstallations)
|
|
184
|
+
.where(eq(sentryInstallations.installationUuid, installationUuid));
|
|
185
|
+
const retryResult = await processor.process(request);
|
|
186
|
+
const [completed] = await db()
|
|
187
|
+
.select()
|
|
188
|
+
.from(sentryInstallations)
|
|
189
|
+
.where(eq(sentryInstallations.installationUuid, installationUuid));
|
|
190
|
+
|
|
191
|
+
expect(checkpoint[0]?.status).toBe('exchange-succeeded');
|
|
192
|
+
expect(retryResult).toEqual({outcome: 'processed', deliveryId});
|
|
193
|
+
expect(completed?.status).toBe('installed');
|
|
194
|
+
expect(exchangeAuthorizationCode).toHaveBeenCalledTimes(1);
|
|
195
|
+
expect(recordDeliveryOnly).toHaveBeenCalledTimes(2);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('keeps deletion terminal when it races a pending installation exchange', async () => {
|
|
199
|
+
const installationUuid = 'processor-installation-delete-race';
|
|
200
|
+
let signalExchangeStarted = (): void => undefined;
|
|
201
|
+
const exchangeStarted = new Promise<void>((resolve) => {
|
|
202
|
+
signalExchangeStarted = resolve;
|
|
203
|
+
});
|
|
204
|
+
let finishExchange!: (authorization: {
|
|
205
|
+
token: string;
|
|
206
|
+
refreshToken: string;
|
|
207
|
+
expiresAt: string;
|
|
208
|
+
}) => void;
|
|
209
|
+
const exchangeResult = new Promise<{
|
|
210
|
+
token: string;
|
|
211
|
+
refreshToken: string;
|
|
212
|
+
expiresAt: string;
|
|
213
|
+
}>((resolve) => {
|
|
214
|
+
finishExchange = resolve;
|
|
215
|
+
});
|
|
216
|
+
const exchangeAuthorizationCode = vi.fn(() => {
|
|
217
|
+
signalExchangeStarted();
|
|
218
|
+
return exchangeResult;
|
|
219
|
+
});
|
|
220
|
+
const processor = createSentryWebhookProcessor({
|
|
221
|
+
sentry: sentryClient({exchangeAuthorizationCode}),
|
|
222
|
+
coreDb: db,
|
|
223
|
+
publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
|
|
224
|
+
recordDeliveryOnly: vi.fn(() => Promise.resolve()),
|
|
225
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(fakeConnection())),
|
|
226
|
+
updateConnectionLifecycleStatus: vi.fn(() => Promise.resolve(undefined)),
|
|
227
|
+
});
|
|
228
|
+
const creation = signedRequest({
|
|
229
|
+
resource: 'installation',
|
|
230
|
+
body: {
|
|
231
|
+
action: 'created',
|
|
232
|
+
data: {
|
|
233
|
+
installation: {
|
|
234
|
+
uuid: installationUuid,
|
|
235
|
+
code: 'single-use-code',
|
|
236
|
+
organization: {slug: 'acme'},
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
const deletion = signedRequest({
|
|
242
|
+
resource: 'installation',
|
|
243
|
+
body: {action: 'deleted', data: {installation: {uuid: installationUuid}}},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const creationAttempt = processor.process(creation);
|
|
247
|
+
await exchangeStarted;
|
|
248
|
+
const deletionResult = await processor.process(deletion);
|
|
249
|
+
finishExchange({token: 'tok', refreshToken: 'refresh', expiresAt: 'x'});
|
|
250
|
+
const creationResult = await creationAttempt;
|
|
251
|
+
const [installation] = await db()
|
|
252
|
+
.select()
|
|
253
|
+
.from(sentryInstallations)
|
|
254
|
+
.where(eq(sentryInstallations.installationUuid, installationUuid));
|
|
255
|
+
|
|
256
|
+
expect(creationResult.outcome).toBe('processed');
|
|
257
|
+
expect(deletionResult.outcome).toBe('processed');
|
|
258
|
+
expect(installation?.status).toBe('deleted');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('keeps an out-of-order deletion as a monotonic tombstone', async () => {
|
|
262
|
+
const installationUuid = 'processor-installation-reordered';
|
|
263
|
+
const exchangeAuthorizationCode = vi.fn(() =>
|
|
264
|
+
Promise.resolve({token: 'tok', refreshToken: 'refresh', expiresAt: 'x'}),
|
|
265
|
+
);
|
|
266
|
+
const processor = createSentryWebhookProcessor({
|
|
267
|
+
sentry: sentryClient({exchangeAuthorizationCode}),
|
|
268
|
+
coreDb: db,
|
|
269
|
+
publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: true})),
|
|
270
|
+
recordDeliveryOnly: vi.fn(() => Promise.resolve()),
|
|
271
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(fakeConnection())),
|
|
272
|
+
updateConnectionLifecycleStatus: vi.fn(() => Promise.resolve(undefined)),
|
|
273
|
+
});
|
|
274
|
+
const deletion = signedRequest({
|
|
275
|
+
resource: 'installation',
|
|
276
|
+
body: {action: 'deleted', data: {installation: {uuid: installationUuid}}},
|
|
277
|
+
});
|
|
278
|
+
const creation = signedRequest({
|
|
279
|
+
resource: 'installation',
|
|
280
|
+
body: {
|
|
281
|
+
action: 'created',
|
|
282
|
+
data: {
|
|
283
|
+
installation: {
|
|
284
|
+
uuid: installationUuid,
|
|
285
|
+
code: 'single-use-code',
|
|
286
|
+
organization: {slug: 'acme'},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const deletionResult = await processor.process(deletion);
|
|
293
|
+
const creationResult = await processor.process(creation);
|
|
294
|
+
const [installation] = await db()
|
|
295
|
+
.select()
|
|
296
|
+
.from(sentryInstallations)
|
|
297
|
+
.where(eq(sentryInstallations.installationUuid, installationUuid));
|
|
298
|
+
|
|
299
|
+
expect(deletionResult.outcome).toBe('processed');
|
|
300
|
+
expect(creationResult.outcome).toBe('processed');
|
|
301
|
+
expect(installation?.status).toBe('deleted');
|
|
302
|
+
expect(exchangeAuthorizationCode).not.toHaveBeenCalled();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {
|
|
3
|
+
decodeWebhookBody,
|
|
4
|
+
type GetIntegrationConnectionByIdFn,
|
|
5
|
+
type PublishIntegrationEventReceivedFn,
|
|
6
|
+
type RecordDeliveryOnlyFn,
|
|
7
|
+
type StoredWebhookRequest,
|
|
8
|
+
type UpdateIntegrationConnectionLifecycleStatusFn,
|
|
9
|
+
type WebhookProcessingResult,
|
|
10
|
+
} from '@shipfox/api-integration-core-dto';
|
|
11
|
+
import {
|
|
12
|
+
sentryInstallationWebhookSchema,
|
|
13
|
+
sentryIssueWebhookSchema,
|
|
14
|
+
} from '@shipfox/api-integration-sentry-dto';
|
|
15
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
16
|
+
import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
|
|
17
|
+
import type {SentryApiClient} from '#api/client.js';
|
|
18
|
+
import {config} from '#config.js';
|
|
19
|
+
import {SentryIssueDroppedError} from '#core/errors.js';
|
|
20
|
+
import {
|
|
21
|
+
handleSentryInstallationCreated,
|
|
22
|
+
handleSentryInstallationDeleted,
|
|
23
|
+
handleSentryIssueEvent,
|
|
24
|
+
normalizeSentryIssueAction,
|
|
25
|
+
} from '#core/webhook.js';
|
|
26
|
+
import {
|
|
27
|
+
completeSentryInstallationVerification,
|
|
28
|
+
getSentryInstallationByInstallationUuid,
|
|
29
|
+
} from '#db/installations.js';
|
|
30
|
+
import {verifySentrySignature} from './signature.js';
|
|
31
|
+
|
|
32
|
+
const DELIVERY_ID_HEADER = 'request-id';
|
|
33
|
+
const RESOURCE_HEADER = 'sentry-hook-resource';
|
|
34
|
+
const SIGNATURE_HEADER = 'sentry-hook-signature';
|
|
35
|
+
const LEGACY_SIGNATURE_HEADER = 'sentry-app-signature';
|
|
36
|
+
const ISSUE_RESOURCE = 'issue';
|
|
37
|
+
const INSTALLATION_RESOURCE = 'installation';
|
|
38
|
+
const SENTRY_PROVIDER = 'sentry';
|
|
39
|
+
|
|
40
|
+
export interface SentryWebhookProcessor {
|
|
41
|
+
process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CreateSentryWebhookProcessorOptions {
|
|
45
|
+
sentry: SentryApiClient;
|
|
46
|
+
coreDb: () => NodePgDatabase<Record<string, unknown>>;
|
|
47
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
48
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
49
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
50
|
+
updateConnectionLifecycleStatus: UpdateIntegrationConnectionLifecycleStatusFn;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createSentryWebhookProcessor(
|
|
54
|
+
context: CreateSentryWebhookProcessorOptions,
|
|
55
|
+
): SentryWebhookProcessor {
|
|
56
|
+
return {process: (request) => processSentryWebhookRequest(context, request)};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function processSentryWebhookRequest(
|
|
60
|
+
context: CreateSentryWebhookProcessorOptions,
|
|
61
|
+
request: StoredWebhookRequest,
|
|
62
|
+
): Promise<WebhookProcessingResult> {
|
|
63
|
+
if (request.route_id !== 'sentry') {
|
|
64
|
+
throw new Error(`Sentry processor cannot process ${request.route_id} requests`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const deliveryId = request.headers[DELIVERY_ID_HEADER];
|
|
68
|
+
const resource = request.headers[RESOURCE_HEADER];
|
|
69
|
+
const signature = request.headers[SIGNATURE_HEADER] ?? request.headers[LEGACY_SIGNATURE_HEADER];
|
|
70
|
+
if (!deliveryId || !resource || !signature) {
|
|
71
|
+
return {outcome: 'discarded', reason: 'missing_required_input'};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rawBody = Buffer.from(decodeWebhookBody(request.body));
|
|
75
|
+
if (!verifySentrySignature({rawBody, signature, secret: config.SENTRY_APP_CLIENT_SECRET})) {
|
|
76
|
+
return {outcome: 'discarded', reason: 'invalid_signature', deliveryId};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
logger().debug(
|
|
80
|
+
{
|
|
81
|
+
deliveryId,
|
|
82
|
+
signatureHeader: request.headers[SIGNATURE_HEADER]
|
|
83
|
+
? SIGNATURE_HEADER
|
|
84
|
+
: LEGACY_SIGNATURE_HEADER,
|
|
85
|
+
},
|
|
86
|
+
'sentry webhook: signature verified',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (resource === ISSUE_RESOURCE) return processIssueResource(context, deliveryId, rawBody);
|
|
90
|
+
if (resource === INSTALLATION_RESOURCE)
|
|
91
|
+
return processInstallationResource(context, deliveryId, rawBody);
|
|
92
|
+
|
|
93
|
+
await recordDeliveryOnly(context, deliveryId);
|
|
94
|
+
return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function processIssueResource(
|
|
98
|
+
context: CreateSentryWebhookProcessorOptions,
|
|
99
|
+
deliveryId: string,
|
|
100
|
+
rawBody: Uint8Array,
|
|
101
|
+
): Promise<WebhookProcessingResult> {
|
|
102
|
+
const parsed = parsePayload(
|
|
103
|
+
sentryIssueWebhookSchema,
|
|
104
|
+
rawBody,
|
|
105
|
+
deliveryId,
|
|
106
|
+
normalizeSentryIssueAction,
|
|
107
|
+
);
|
|
108
|
+
if (!parsed.success) {
|
|
109
|
+
await recordDeliveryOnly(context, deliveryId);
|
|
110
|
+
return {outcome: 'discarded', reason: parsed.reason, deliveryId};
|
|
111
|
+
}
|
|
112
|
+
const payload = parsed.data;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await context.coreDb().transaction(async (tx) => {
|
|
116
|
+
await handleSentryIssueEvent({
|
|
117
|
+
tx,
|
|
118
|
+
deliveryId,
|
|
119
|
+
payload,
|
|
120
|
+
publishIntegrationEventReceived: context.publishIntegrationEventReceived,
|
|
121
|
+
getIntegrationConnectionById: context.getIntegrationConnectionById,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (!(error instanceof SentryIssueDroppedError)) throw error;
|
|
126
|
+
|
|
127
|
+
logger().warn({deliveryId, err: error}, `sentry webhook: ${error.message}, dropping`);
|
|
128
|
+
await recordDeliveryOnly(context, deliveryId);
|
|
129
|
+
return {outcome: 'discarded', reason: 'connection_unavailable', deliveryId};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {outcome: 'processed', deliveryId};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function processInstallationResource(
|
|
136
|
+
context: CreateSentryWebhookProcessorOptions,
|
|
137
|
+
deliveryId: string,
|
|
138
|
+
rawBody: Uint8Array,
|
|
139
|
+
): Promise<WebhookProcessingResult> {
|
|
140
|
+
const parsed = parsePayload(sentryInstallationWebhookSchema, rawBody, deliveryId);
|
|
141
|
+
if (!parsed.success) {
|
|
142
|
+
await recordDeliveryOnly(context, deliveryId);
|
|
143
|
+
return {outcome: 'discarded', reason: parsed.reason, deliveryId};
|
|
144
|
+
}
|
|
145
|
+
const payload = parsed.data;
|
|
146
|
+
|
|
147
|
+
const installation = payload.data.installation;
|
|
148
|
+
if (payload.action === 'deleted') {
|
|
149
|
+
await context.coreDb().transaction(async (tx) => {
|
|
150
|
+
await handleSentryInstallationDeleted({
|
|
151
|
+
tx,
|
|
152
|
+
deliveryId,
|
|
153
|
+
installationUuid: installation.uuid,
|
|
154
|
+
recordDeliveryOnly: context.recordDeliveryOnly,
|
|
155
|
+
updateConnectionLifecycleStatus: context.updateConnectionLifecycleStatus,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
return {outcome: 'processed', deliveryId};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await handleSentryInstallationCreated({
|
|
162
|
+
deliveryId,
|
|
163
|
+
installationUuid: installation.uuid,
|
|
164
|
+
orgSlug: installation.organization?.slug,
|
|
165
|
+
code: installation.code,
|
|
166
|
+
sentry: context.sentry,
|
|
167
|
+
verifyInstall: config.SENTRY_APP_VERIFY_INSTALL,
|
|
168
|
+
getSentryInstallation: ({installationUuid}) =>
|
|
169
|
+
getSentryInstallationByInstallationUuid(installationUuid),
|
|
170
|
+
persistUnclaimedAndRecordDelivery: ({installationUuid, codeHash, deliveryId: id}) =>
|
|
171
|
+
context.coreDb().transaction(async (tx) => {
|
|
172
|
+
const completed = await completeSentryInstallationVerification(
|
|
173
|
+
{installationUuid, codeHash},
|
|
174
|
+
{tx},
|
|
175
|
+
);
|
|
176
|
+
await context.recordDeliveryOnly({tx, provider: SENTRY_PROVIDER, deliveryId: id});
|
|
177
|
+
if (completed) return completed;
|
|
178
|
+
|
|
179
|
+
const current = await getSentryInstallationByInstallationUuid(installationUuid, {tx});
|
|
180
|
+
if (!current) throw new Error('Sentry installation verification lost its claim');
|
|
181
|
+
return current;
|
|
182
|
+
}),
|
|
183
|
+
recordDelivery: (id) => recordDeliveryOnly(context, id),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return {outcome: 'processed', deliveryId};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
type ParsePayloadResult<T> =
|
|
190
|
+
| {success: true; data: T}
|
|
191
|
+
| {success: false; reason: 'malformed_payload' | 'unsupported_event'};
|
|
192
|
+
|
|
193
|
+
function parsePayload<T>(
|
|
194
|
+
schema: {safeParse(value: unknown): {success: true; data: T} | {success: false; error: unknown}},
|
|
195
|
+
rawBody: Uint8Array,
|
|
196
|
+
deliveryId: string,
|
|
197
|
+
normalize: (payload: unknown) => unknown = (payload) => payload,
|
|
198
|
+
): ParsePayloadResult<T> {
|
|
199
|
+
let parsedJson: unknown;
|
|
200
|
+
try {
|
|
201
|
+
parsedJson = JSON.parse(Buffer.from(rawBody).toString('utf8'));
|
|
202
|
+
} catch (error) {
|
|
203
|
+
logger().warn({deliveryId, err: error}, 'sentry webhook: payload JSON parse failed, dropping');
|
|
204
|
+
return {success: false, reason: 'malformed_payload'};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const payload = schema.safeParse(normalize(parsedJson));
|
|
208
|
+
if (payload.success) return {success: true, data: payload.data};
|
|
209
|
+
|
|
210
|
+
logger().warn(
|
|
211
|
+
{deliveryId, issues: payload.error},
|
|
212
|
+
'sentry webhook: payload failed validation, dropping',
|
|
213
|
+
);
|
|
214
|
+
return {success: false, reason: 'unsupported_event'};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function recordDeliveryOnly(
|
|
218
|
+
context: CreateSentryWebhookProcessorOptions,
|
|
219
|
+
deliveryId: string,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
await context.coreDb().transaction(async (tx) => {
|
|
222
|
+
await context.recordDeliveryOnly({tx, provider: SENTRY_PROVIDER, deliveryId});
|
|
223
|
+
});
|
|
224
|
+
}
|