@shipfox/api-integration-sentry 5.0.0 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.turbo/turbo-build.log +12 -15
  2. package/CHANGELOG.md +42 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +4 -5
  5. package/dist/config.js.map +1 -1
  6. package/dist/core/install.d.ts +6 -0
  7. package/dist/core/install.d.ts.map +1 -1
  8. package/dist/core/install.js +8 -5
  9. package/dist/core/install.js.map +1 -1
  10. package/dist/core/signature.d.ts +1 -1
  11. package/dist/core/signature.d.ts.map +1 -1
  12. package/dist/core/signature.js.map +1 -1
  13. package/dist/core/webhook-processor.d.ts +16 -0
  14. package/dist/core/webhook-processor.d.ts.map +1 -0
  15. package/dist/core/webhook-processor.js +198 -0
  16. package/dist/core/webhook-processor.js.map +1 -0
  17. package/dist/core/webhook.d.ts +4 -4
  18. package/dist/core/webhook.d.ts.map +1 -1
  19. package/dist/core/webhook.js +63 -49
  20. package/dist/core/webhook.js.map +1 -1
  21. package/dist/db/db.d.ts +8 -4
  22. package/dist/db/db.d.ts.map +1 -1
  23. package/dist/db/installations.d.ts +35 -13
  24. package/dist/db/installations.d.ts.map +1 -1
  25. package/dist/db/installations.js +89 -31
  26. package/dist/db/installations.js.map +1 -1
  27. package/dist/db/schema/installations.d.ts +5 -3
  28. package/dist/db/schema/installations.d.ts.map +1 -1
  29. package/dist/db/schema/installations.js +6 -6
  30. package/dist/db/schema/installations.js.map +1 -1
  31. package/dist/index.d.ts +6 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +20 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/presentation/routes/webhooks.d.ts +4 -1
  36. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  37. package/dist/presentation/routes/webhooks.js +69 -43
  38. package/dist/presentation/routes/webhooks.js.map +1 -1
  39. package/dist/temporal/activities/prune-unclaimed-installations.d.ts +1 -0
  40. package/dist/temporal/activities/prune-unclaimed-installations.d.ts.map +1 -1
  41. package/dist/temporal/activities/prune-unclaimed-installations.js.map +1 -1
  42. package/dist/temporal/workflows/index.bundle.js +17169 -3306
  43. package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts.map +1 -1
  44. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js +6 -1
  45. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js.map +1 -1
  46. package/dist/tsconfig.test.tsbuildinfo +1 -1
  47. package/package.json +9 -21
  48. package/src/config.ts +4 -5
  49. package/src/core/install.ts +21 -7
  50. package/src/core/signature.ts +1 -1
  51. package/src/core/webhook-processor.test.ts +304 -0
  52. package/src/core/webhook-processor.ts +224 -0
  53. package/src/core/webhook.ts +74 -53
  54. package/src/db/installations.test.ts +163 -17
  55. package/src/db/installations.ts +136 -35
  56. package/src/db/schema/installations.ts +7 -7
  57. package/src/index.test.ts +4 -1
  58. package/src/index.ts +16 -0
  59. package/src/presentation/routes/webhooks.test.ts +6 -5
  60. package/src/presentation/routes/webhooks.ts +88 -29
  61. package/src/temporal/activities/prune-unclaimed-installations.test.ts +24 -2
  62. package/src/temporal/activities/prune-unclaimed-installations.ts +4 -1
  63. package/src/temporal/workflows/prune-unclaimed-installations-cron.ts +4 -1
  64. package/tsconfig.build.tsbuildinfo +1 -1
  65. package/dist/presentation/routes/installation-handler.d.ts +0 -9
  66. package/dist/presentation/routes/installation-handler.d.ts.map +0 -1
  67. package/dist/presentation/routes/installation-handler.js +0 -70
  68. package/dist/presentation/routes/installation-handler.js.map +0 -1
  69. package/dist/presentation/routes/issue-handler.d.ts +0 -9
  70. package/dist/presentation/routes/issue-handler.d.ts.map +0 -1
  71. package/dist/presentation/routes/issue-handler.js +0 -50
  72. package/dist/presentation/routes/issue-handler.js.map +0 -1
  73. package/dist/presentation/routes/webhook-delivery.d.ts +0 -28
  74. package/dist/presentation/routes/webhook-delivery.d.ts.map +0 -1
  75. package/dist/presentation/routes/webhook-delivery.js +0 -56
  76. package/dist/presentation/routes/webhook-delivery.js.map +0 -1
  77. package/dist/presentation/routes/webhook-request.d.ts +0 -10
  78. package/dist/presentation/routes/webhook-request.d.ts.map +0 -1
  79. package/dist/presentation/routes/webhook-request.js +0 -56
  80. package/dist/presentation/routes/webhook-request.js.map +0 -1
  81. package/src/presentation/routes/installation-handler.ts +0 -76
  82. package/src/presentation/routes/issue-handler.ts +0 -52
  83. package/src/presentation/routes/webhook-delivery.ts +0 -61
  84. 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": "5.0.0",
4
+ "version": "7.1.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -26,33 +26,21 @@
26
26
  "drizzle-orm": "^0.45.2",
27
27
  "ky": "^2.0.0",
28
28
  "zod": "^4.4.3",
29
- "@shipfox/api-auth-context": "5.0.0",
30
- "@shipfox/api-integration-core-dto": "5.0.0",
29
+ "@shipfox/api-auth-context": "7.1.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.1",
33
- "@shipfox/api-integration-sentry-dto": "5.0.0",
34
- "@shipfox/node-module": "0.3.2",
35
- "@shipfox/node-opentelemetry": "0.5.2",
36
- "@shipfox/node-fastify": "0.2.3",
33
+ "@shipfox/node-drizzle": "0.3.2",
34
+ "@shipfox/node-fastify": "0.3.0",
35
+ "@shipfox/node-module": "0.5.0",
36
+ "@shipfox/node-opentelemetry": "0.6.0",
37
37
  "@shipfox/node-postgres": "0.4.2"
38
38
  },
39
- "devDependencies": {
40
- "@types/pg": "^8.15.5",
41
- "drizzle-kit": "^0.31.10",
42
- "fastify": "^5.3.3",
43
- "fastify-type-provider-zod": "^6.0.0",
44
- "fishery": "^2.4.0",
45
- "@shipfox/biome": "1.8.2",
46
- "@shipfox/node-temporal": "0.3.1",
47
- "@shipfox/swc": "1.2.6",
48
- "@shipfox/ts-config": "1.3.8",
49
- "@shipfox/vitest": "1.2.3",
50
- "@shipfox/typescript": "1.1.7"
51
- },
52
39
  "scripts": {
53
40
  "build": "shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js",
54
41
  "check": "shipfox-biome-check",
55
42
  "check:fix": "shipfox-biome-check --write",
43
+ "depcruise": "shipfox-depcruise",
56
44
  "test": "shipfox-vitest-run",
57
45
  "test:watch": "shipfox-vitest-watch",
58
46
  "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 a verified-but-unclaimed Sentry installation may sit before the daily cleanup cron tombstones it. Must be at least 1; a smaller value moves the cutoff to now or the future and would tombstone freshly created, still-unclaimed installs. A reinstall always mints a fresh uuid, so a tombstone is never revived. Defaults to 7.',
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
- // The daily cleanup cron tombstones every unclaimed install older than
24
- // `now - SENTRY_UNCLAIMED_INSTALLATION_RETENTION_DAYS`. A value below 1 moves that
25
- // cutoff to now or the future, which would wipe freshly created installs nobody has
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(
@@ -89,7 +89,7 @@ export async function verifyAndPersistUnclaimedInstallation(
89
89
  });
90
90
 
91
91
  if (params.verifyInstall) {
92
- await bestEffortVerifyInstallation({
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: SentryInstallation,
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 bestEffortVerifyInstallation({
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 bestEffortVerifyInstallation({
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
- function isVerifiedUnclaimed(install: SentryInstallation): boolean {
259
- return install.connectionId === null && install.status === 'installed';
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 bestEffortVerifyInstallation(input: {
290
+ export async function verifySentryInstallationBestEffort(input: {
277
291
  sentry: SentryApiClient;
278
292
  installationUuid: string;
279
293
  token: string;
@@ -1,7 +1,7 @@
1
1
  import {verifyHexHmacSignature} from '@shipfox/node-fastify';
2
2
 
3
3
  export interface VerifySentrySignatureParams {
4
- rawBody: string;
4
+ rawBody: string | Uint8Array;
5
5
  signature: string;
6
6
  secret: string;
7
7
  }
@@ -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
+ }