@shipfox/api-integration-sentry 2.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.
Files changed (165) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +192 -0
  5. package/LICENSE +21 -0
  6. package/README.md +145 -0
  7. package/dist/api/client.d.ts +24 -0
  8. package/dist/api/client.d.ts.map +1 -0
  9. package/dist/api/client.js +101 -0
  10. package/dist/api/client.js.map +1 -0
  11. package/dist/config.d.ts +9 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +32 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/errors.d.ts +57 -0
  16. package/dist/core/errors.d.ts.map +1 -0
  17. package/dist/core/errors.js +67 -0
  18. package/dist/core/errors.js.map +1 -0
  19. package/dist/core/install.d.ts +63 -0
  20. package/dist/core/install.d.ts.map +1 -0
  21. package/dist/core/install.js +209 -0
  22. package/dist/core/install.js.map +1 -0
  23. package/dist/core/signature.d.ts +7 -0
  24. package/dist/core/signature.d.ts.map +1 -0
  25. package/dist/core/signature.js +6 -0
  26. package/dist/core/signature.js.map +1 -0
  27. package/dist/core/webhook.d.ts +49 -0
  28. package/dist/core/webhook.d.ts.map +1 -0
  29. package/dist/core/webhook.js +175 -0
  30. package/dist/core/webhook.js.map +1 -0
  31. package/dist/db/db.d.ts +327 -0
  32. package/dist/db/db.d.ts.map +1 -0
  33. package/dist/db/db.js +18 -0
  34. package/dist/db/db.js.map +1 -0
  35. package/dist/db/installations.d.ts +74 -0
  36. package/dist/db/installations.d.ts.map +1 -0
  37. package/dist/db/installations.js +120 -0
  38. package/dist/db/installations.js.map +1 -0
  39. package/dist/db/migrations.d.ts +2 -0
  40. package/dist/db/migrations.d.ts.map +1 -0
  41. package/dist/db/migrations.js +5 -0
  42. package/dist/db/migrations.js.map +1 -0
  43. package/dist/db/schema/common.d.ts +2 -0
  44. package/dist/db/schema/common.d.ts.map +1 -0
  45. package/dist/db/schema/common.js +4 -0
  46. package/dist/db/schema/common.js.map +1 -0
  47. package/dist/db/schema/installations.d.ts +165 -0
  48. package/dist/db/schema/installations.d.ts.map +1 -0
  49. package/dist/db/schema/installations.js +42 -0
  50. package/dist/db/schema/installations.js.map +1 -0
  51. package/dist/index.d.ts +36 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +64 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/presentation/dto/integrations.d.ts +15 -0
  56. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  57. package/dist/presentation/dto/integrations.js +9 -0
  58. package/dist/presentation/dto/integrations.js.map +1 -0
  59. package/dist/presentation/routes/errors.d.ts +2 -0
  60. package/dist/presentation/routes/errors.d.ts.map +1 -0
  61. package/dist/presentation/routes/errors.js +51 -0
  62. package/dist/presentation/routes/errors.js.map +1 -0
  63. package/dist/presentation/routes/install.d.ts +16 -0
  64. package/dist/presentation/routes/install.d.ts.map +1 -0
  65. package/dist/presentation/routes/install.js +76 -0
  66. package/dist/presentation/routes/install.js.map +1 -0
  67. package/dist/presentation/routes/installation-handler.d.ts +9 -0
  68. package/dist/presentation/routes/installation-handler.d.ts.map +1 -0
  69. package/dist/presentation/routes/installation-handler.js +70 -0
  70. package/dist/presentation/routes/installation-handler.js.map +1 -0
  71. package/dist/presentation/routes/issue-handler.d.ts +9 -0
  72. package/dist/presentation/routes/issue-handler.d.ts.map +1 -0
  73. package/dist/presentation/routes/issue-handler.js +50 -0
  74. package/dist/presentation/routes/issue-handler.js.map +1 -0
  75. package/dist/presentation/routes/webhook-context.d.ts +20 -0
  76. package/dist/presentation/routes/webhook-context.d.ts.map +1 -0
  77. package/dist/presentation/routes/webhook-context.js +10 -0
  78. package/dist/presentation/routes/webhook-context.js.map +1 -0
  79. package/dist/presentation/routes/webhook-delivery.d.ts +28 -0
  80. package/dist/presentation/routes/webhook-delivery.d.ts.map +1 -0
  81. package/dist/presentation/routes/webhook-delivery.js +56 -0
  82. package/dist/presentation/routes/webhook-delivery.js.map +1 -0
  83. package/dist/presentation/routes/webhook-request.d.ts +10 -0
  84. package/dist/presentation/routes/webhook-request.d.ts.map +1 -0
  85. package/dist/presentation/routes/webhook-request.js +56 -0
  86. package/dist/presentation/routes/webhook-request.js.map +1 -0
  87. package/dist/presentation/routes/webhooks.d.ts +5 -0
  88. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  89. package/dist/presentation/routes/webhooks.js +71 -0
  90. package/dist/presentation/routes/webhooks.js.map +1 -0
  91. package/dist/temporal/activities/index.d.ts +5 -0
  92. package/dist/temporal/activities/index.d.ts.map +1 -0
  93. package/dist/temporal/activities/index.js +8 -0
  94. package/dist/temporal/activities/index.js.map +1 -0
  95. package/dist/temporal/activities/prune-unclaimed-installations.d.ts +4 -0
  96. package/dist/temporal/activities/prune-unclaimed-installations.d.ts.map +1 -0
  97. package/dist/temporal/activities/prune-unclaimed-installations.js +11 -0
  98. package/dist/temporal/activities/prune-unclaimed-installations.js.map +1 -0
  99. package/dist/temporal/constants.d.ts +2 -0
  100. package/dist/temporal/constants.d.ts.map +1 -0
  101. package/dist/temporal/constants.js +3 -0
  102. package/dist/temporal/constants.js.map +1 -0
  103. package/dist/temporal/workflows/index.d.ts +2 -0
  104. package/dist/temporal/workflows/index.d.ts.map +1 -0
  105. package/dist/temporal/workflows/index.js +3 -0
  106. package/dist/temporal/workflows/index.js.map +1 -0
  107. package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts +2 -0
  108. package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts.map +1 -0
  109. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js +14 -0
  110. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js.map +1 -0
  111. package/dist/tsconfig.test.tsbuildinfo +1 -0
  112. package/drizzle/0000_initial.sql +14 -0
  113. package/drizzle/meta/_journal.json +13 -0
  114. package/drizzle.config.ts +7 -0
  115. package/package.json +70 -0
  116. package/src/api/client.test.ts +147 -0
  117. package/src/api/client.ts +147 -0
  118. package/src/config.test.ts +16 -0
  119. package/src/config.ts +35 -0
  120. package/src/connection-external-url.test.ts +79 -0
  121. package/src/core/errors.ts +81 -0
  122. package/src/core/install.test.ts +343 -0
  123. package/src/core/install.ts +305 -0
  124. package/src/core/signature.test.ts +50 -0
  125. package/src/core/signature.ts +11 -0
  126. package/src/core/webhook.test.ts +118 -0
  127. package/src/core/webhook.ts +246 -0
  128. package/src/db/db.ts +18 -0
  129. package/src/db/installations.test.ts +224 -0
  130. package/src/db/installations.ts +207 -0
  131. package/src/db/migrations.ts +4 -0
  132. package/src/db/schema/common.ts +3 -0
  133. package/src/db/schema/installations.ts +46 -0
  134. package/src/index.test.ts +18 -0
  135. package/src/index.ts +127 -0
  136. package/src/presentation/dto/integrations.ts +9 -0
  137. package/src/presentation/routes/errors.ts +48 -0
  138. package/src/presentation/routes/install.test.ts +325 -0
  139. package/src/presentation/routes/install.ts +102 -0
  140. package/src/presentation/routes/installation-handler.ts +76 -0
  141. package/src/presentation/routes/issue-handler.ts +52 -0
  142. package/src/presentation/routes/webhook-context.ts +25 -0
  143. package/src/presentation/routes/webhook-delivery.ts +61 -0
  144. package/src/presentation/routes/webhook-request.ts +66 -0
  145. package/src/presentation/routes/webhooks.test.ts +773 -0
  146. package/src/presentation/routes/webhooks.ts +59 -0
  147. package/src/temporal/activities/index.ts +7 -0
  148. package/src/temporal/activities/prune-unclaimed-installations.test.ts +52 -0
  149. package/src/temporal/activities/prune-unclaimed-installations.ts +11 -0
  150. package/src/temporal/constants.ts +1 -0
  151. package/src/temporal/workflows/index.ts +1 -0
  152. package/src/temporal/workflows/prune-unclaimed-installations-cron.ts +15 -0
  153. package/test/env.ts +11 -0
  154. package/test/factories/index.ts +9 -0
  155. package/test/factories/sentry-installation-webhook.ts +35 -0
  156. package/test/factories/sentry-installation.ts +44 -0
  157. package/test/factories/sentry-issue-webhook.ts +34 -0
  158. package/test/globalSetup.ts +14 -0
  159. package/test/index.ts +9 -0
  160. package/test/setup.ts +17 -0
  161. package/tsconfig.build.json +9 -0
  162. package/tsconfig.build.tsbuildinfo +1 -0
  163. package/tsconfig.json +3 -0
  164. package/tsconfig.test.json +9 -0
  165. package/vitest.config.ts +12 -0
@@ -0,0 +1,343 @@
1
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
2
+ import type {SentryApiClient} from '#api/client.js';
3
+ import {
4
+ SentryClaimProofMismatchError,
5
+ SentryInstallationAlreadyLinkedError,
6
+ SentryInstallationDeletedError,
7
+ SentryIntegrationProviderError,
8
+ SentryVerificationInProgressError,
9
+ } from '#core/errors.js';
10
+ import type {SentryInstallation} from '#db/installations.js';
11
+ import {
12
+ type ConnectSentryInstallationInput,
13
+ handleSentryConnect,
14
+ hashAuthorizationCode,
15
+ } from './install.js';
16
+
17
+ const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
18
+ const OTHER_WORKSPACE_ID = '22222222-2222-4222-8222-222222222222';
19
+ const INSTALL_UUID = 'install-uuid-1';
20
+ const CODE = 'the-code';
21
+ const CODE_HASH = hashAuthorizationCode(CODE);
22
+ const CONNECTION_ID = '33333333-3333-4333-8333-333333333333';
23
+
24
+ function sentryClient(overrides: Partial<SentryApiClient> = {}): SentryApiClient {
25
+ return {
26
+ exchangeAuthorizationCode: vi.fn(() =>
27
+ Promise.resolve({token: 'tok', refreshToken: 'refresh', expiresAt: 'x'}),
28
+ ),
29
+ getInstallation: vi.fn(() => Promise.resolve({orgSlug: 'acme'})),
30
+ verifyInstallation: vi.fn(() => Promise.resolve()),
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function connection(
36
+ overrides: Partial<IntegrationConnection<'sentry'>> = {},
37
+ ): IntegrationConnection<'sentry'> {
38
+ return {
39
+ id: CONNECTION_ID,
40
+ workspaceId: WORKSPACE_ID,
41
+ provider: 'sentry',
42
+ externalAccountId: INSTALL_UUID,
43
+ slug: 'sentry_acme',
44
+ displayName: 'Sentry acme',
45
+ lifecycleStatus: 'active',
46
+ createdAt: new Date(),
47
+ updatedAt: new Date(),
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ function installation(overrides: Partial<SentryInstallation> = {}): SentryInstallation {
53
+ return {
54
+ id: 'row-1',
55
+ connectionId: null,
56
+ installationUuid: INSTALL_UUID,
57
+ orgSlug: 'acme',
58
+ status: 'installed',
59
+ codeHash: CODE_HASH,
60
+ installerUserId: null,
61
+ createdAt: new Date(),
62
+ updatedAt: new Date(),
63
+ ...overrides,
64
+ };
65
+ }
66
+
67
+ interface RunOptions {
68
+ sentry?: SentryApiClient;
69
+ verifyInstall?: boolean;
70
+ code?: string;
71
+ install?: SentryInstallation | undefined;
72
+ installSequence?: (SentryInstallation | undefined)[];
73
+ connection?: IntegrationConnection<'sentry'> | undefined;
74
+ }
75
+
76
+ function run(options: RunOptions = {}) {
77
+ const sentry = options.sentry ?? sentryClient();
78
+ const sequence = options.installSequence ?? [options.install];
79
+ let call = 0;
80
+ const getSentryInstallation = vi.fn(() => {
81
+ const value = sequence[Math.min(call, sequence.length - 1)];
82
+ call += 1;
83
+ return Promise.resolve(value);
84
+ });
85
+ const getConnectionById = vi.fn(() => Promise.resolve(options.connection));
86
+ const connectSentryInstallation = vi.fn((input: ConnectSentryInstallationInput) =>
87
+ Promise.resolve(
88
+ connection({
89
+ workspaceId: input.workspaceId,
90
+ externalAccountId: input.installationUuid,
91
+ slug: 'sentry_acme',
92
+ displayName: input.displayName,
93
+ }),
94
+ ),
95
+ );
96
+ const persistVerifiedUnclaimedInstallation = vi.fn(
97
+ (input: {installationUuid: string; orgSlug: string; codeHash: string}) =>
98
+ Promise.resolve(installation({connectionId: null, ...input})),
99
+ );
100
+
101
+ const result = handleSentryConnect({
102
+ sentry,
103
+ workspaceId: WORKSPACE_ID,
104
+ code: options.code ?? CODE,
105
+ installationUuid: INSTALL_UUID,
106
+ installerUserId: 'user-1',
107
+ verifyInstall: options.verifyInstall ?? true,
108
+ getSentryInstallation,
109
+ getConnectionById,
110
+ connectSentryInstallation,
111
+ persistVerifiedUnclaimedInstallation,
112
+ });
113
+
114
+ return {
115
+ sentry,
116
+ getSentryInstallation,
117
+ getConnectionById,
118
+ connectSentryInstallation,
119
+ persistVerifiedUnclaimedInstallation,
120
+ result,
121
+ };
122
+ }
123
+
124
+ describe('handleSentryConnect — browser-first (no row)', () => {
125
+ it('exchanges, persists the unclaimed row, then binds and verifies last', async () => {
126
+ const sentry = sentryClient();
127
+ const {connectSentryInstallation, persistVerifiedUnclaimedInstallation, result} = run({sentry});
128
+
129
+ const connected = await result;
130
+
131
+ expect(sentry.exchangeAuthorizationCode).toHaveBeenCalledWith({
132
+ installationUuid: INSTALL_UUID,
133
+ code: CODE,
134
+ });
135
+ expect(persistVerifiedUnclaimedInstallation).toHaveBeenCalledWith({
136
+ installationUuid: INSTALL_UUID,
137
+ orgSlug: 'acme',
138
+ codeHash: CODE_HASH,
139
+ });
140
+ expect(connectSentryInstallation).toHaveBeenCalledWith(
141
+ expect.objectContaining({
142
+ workspaceId: WORKSPACE_ID,
143
+ installationUuid: INSTALL_UUID,
144
+ orgSlug: 'acme',
145
+ codeHash: CODE_HASH,
146
+ installerUserId: 'user-1',
147
+ }),
148
+ );
149
+ expect(connected.provider).toBe('sentry');
150
+
151
+ const persistOrder = persistVerifiedUnclaimedInstallation.mock.invocationCallOrder[0];
152
+ const verifyOrder = vi.mocked(sentry.verifyInstallation).mock.invocationCallOrder[0];
153
+ expect(persistOrder).toBeLessThan(verifyOrder ?? 0);
154
+ });
155
+
156
+ it('binds even when the best-effort verify fails (non-fatal)', async () => {
157
+ const sentry = sentryClient({
158
+ verifyInstallation: vi.fn(() => Promise.reject(new Error('verify failed'))),
159
+ });
160
+ const {connectSentryInstallation, result} = run({sentry});
161
+
162
+ const connected = await result;
163
+
164
+ expect(connectSentryInstallation).toHaveBeenCalled();
165
+ expect(connected.lifecycleStatus).toBe('active');
166
+ });
167
+
168
+ it('persists nothing when the code exchange fails outright', async () => {
169
+ const sentry = sentryClient({
170
+ exchangeAuthorizationCode: vi.fn(() =>
171
+ Promise.reject(new SentryIntegrationProviderError('timeout', 'slow')),
172
+ ),
173
+ });
174
+ const {connectSentryInstallation, persistVerifiedUnclaimedInstallation, result} = run({sentry});
175
+
176
+ await expect(result).rejects.toBeInstanceOf(SentryIntegrationProviderError);
177
+ expect(persistVerifiedUnclaimedInstallation).not.toHaveBeenCalled();
178
+ expect(connectSentryInstallation).not.toHaveBeenCalled();
179
+ });
180
+ });
181
+
182
+ describe('handleSentryConnect — verified, unclaimed row exists (webhook-first)', () => {
183
+ it('binds via same-code race when the exchange is already used and the hash matches', async () => {
184
+ const sentry = sentryClient({
185
+ exchangeAuthorizationCode: vi.fn(() =>
186
+ Promise.reject(new SentryIntegrationProviderError('access-denied', 'already used')),
187
+ ),
188
+ });
189
+ const {connectSentryInstallation, persistVerifiedUnclaimedInstallation, result} = run({
190
+ sentry,
191
+ install: installation({connectionId: null, codeHash: CODE_HASH}),
192
+ });
193
+
194
+ const connected = await result;
195
+
196
+ expect(persistVerifiedUnclaimedInstallation).not.toHaveBeenCalled();
197
+ expect(connectSentryInstallation).toHaveBeenCalledWith(
198
+ expect.objectContaining({installationUuid: INSTALL_UUID, codeHash: CODE_HASH}),
199
+ );
200
+ expect(connected.provider).toBe('sentry');
201
+ });
202
+
203
+ it('binds via a fresh successful exchange (re-entry with a new code), updating the hash', async () => {
204
+ const freshCode = 'fresh-code';
205
+ const sentry = sentryClient();
206
+ const {connectSentryInstallation, result} = run({
207
+ sentry,
208
+ code: freshCode,
209
+ install: installation({connectionId: null, codeHash: hashAuthorizationCode('old-code')}),
210
+ });
211
+
212
+ await result;
213
+
214
+ expect(sentry.exchangeAuthorizationCode).toHaveBeenCalledWith({
215
+ installationUuid: INSTALL_UUID,
216
+ code: freshCode,
217
+ });
218
+ expect(connectSentryInstallation).toHaveBeenCalledWith(
219
+ expect.objectContaining({codeHash: hashAuthorizationCode(freshCode)}),
220
+ );
221
+ });
222
+
223
+ it('rejects with proof mismatch (IDOR) when the code is already used and the hash differs', async () => {
224
+ const sentry = sentryClient({
225
+ exchangeAuthorizationCode: vi.fn(() =>
226
+ Promise.reject(new SentryIntegrationProviderError('access-denied', 'already used')),
227
+ ),
228
+ });
229
+ const {connectSentryInstallation, result} = run({
230
+ sentry,
231
+ code: 'forged-code',
232
+ install: installation({connectionId: null, codeHash: CODE_HASH}),
233
+ });
234
+
235
+ await expect(result).rejects.toBeInstanceOf(SentryClaimProofMismatchError);
236
+ expect(connectSentryInstallation).not.toHaveBeenCalled();
237
+ });
238
+ });
239
+
240
+ describe('handleSentryConnect — already claimed / tombstoned', () => {
241
+ it('is idempotent for an install already claimed to the same workspace', async () => {
242
+ const existing = connection({workspaceId: WORKSPACE_ID});
243
+ const {sentry, connectSentryInstallation, result} = run({
244
+ install: installation({connectionId: CONNECTION_ID}),
245
+ connection: existing,
246
+ });
247
+
248
+ const connected = await result;
249
+
250
+ expect(connected).toBe(existing);
251
+ expect(sentry.exchangeAuthorizationCode).not.toHaveBeenCalled();
252
+ expect(connectSentryInstallation).not.toHaveBeenCalled();
253
+ });
254
+
255
+ it('throws when the install is claimed to a different workspace', async () => {
256
+ const {sentry, result} = run({
257
+ install: installation({connectionId: CONNECTION_ID}),
258
+ connection: connection({workspaceId: OTHER_WORKSPACE_ID}),
259
+ });
260
+
261
+ await expect(result).rejects.toBeInstanceOf(SentryInstallationAlreadyLinkedError);
262
+ expect(sentry.exchangeAuthorizationCode).not.toHaveBeenCalled();
263
+ });
264
+
265
+ it('throws when the install is tombstoned', async () => {
266
+ const {sentry, result} = run({
267
+ install: installation({status: 'deleted'}),
268
+ });
269
+
270
+ await expect(result).rejects.toBeInstanceOf(SentryInstallationDeletedError);
271
+ expect(sentry.exchangeAuthorizationCode).not.toHaveBeenCalled();
272
+ });
273
+ });
274
+
275
+ describe('handleSentryConnect — simultaneous race', () => {
276
+ it('returns a retryable error when the code is already used and no row is visible yet', async () => {
277
+ const sentry = sentryClient({
278
+ exchangeAuthorizationCode: vi.fn(() =>
279
+ Promise.reject(new SentryIntegrationProviderError('access-denied', 'already used')),
280
+ ),
281
+ });
282
+ const {result} = run({sentry, installSequence: [undefined, undefined]});
283
+
284
+ await expect(result).rejects.toBeInstanceOf(SentryVerificationInProgressError);
285
+ });
286
+
287
+ it('reconciles when the concurrent webhook persisted the row between lookups', async () => {
288
+ const sentry = sentryClient({
289
+ exchangeAuthorizationCode: vi
290
+ .fn()
291
+ // First (browser) exchange loses the race; on re-read we find the row and
292
+ // the second exchange is "already used" with a matching hash → bind.
293
+ .mockRejectedValueOnce(new SentryIntegrationProviderError('access-denied', 'already used'))
294
+ .mockRejectedValueOnce(new SentryIntegrationProviderError('access-denied', 'already used')),
295
+ });
296
+ const {connectSentryInstallation, result} = run({
297
+ sentry,
298
+ installSequence: [undefined, installation({connectionId: null, codeHash: CODE_HASH})],
299
+ });
300
+
301
+ const connected = await result;
302
+
303
+ expect(connected.provider).toBe('sentry');
304
+ expect(connectSentryInstallation).toHaveBeenCalledWith(
305
+ expect.objectContaining({codeHash: CODE_HASH}),
306
+ );
307
+ });
308
+
309
+ it('resolves idempotently when the concurrent webhook claimed the row between lookups', async () => {
310
+ const sentry = sentryClient({
311
+ exchangeAuthorizationCode: vi.fn(() =>
312
+ Promise.reject(new SentryIntegrationProviderError('access-denied', 'already used')),
313
+ ),
314
+ });
315
+ const existing = connection({workspaceId: WORKSPACE_ID});
316
+ const {connectSentryInstallation, result} = run({
317
+ sentry,
318
+ // First lookup sees no row; the re-read finds it already claimed.
319
+ installSequence: [undefined, installation({connectionId: CONNECTION_ID})],
320
+ connection: existing,
321
+ });
322
+
323
+ const connected = await result;
324
+
325
+ expect(connected).toBe(existing);
326
+ expect(connectSentryInstallation).not.toHaveBeenCalled();
327
+ });
328
+
329
+ it('surfaces a terminal deleted error when the row is tombstoned between lookups', async () => {
330
+ const sentry = sentryClient({
331
+ exchangeAuthorizationCode: vi.fn(() =>
332
+ Promise.reject(new SentryIntegrationProviderError('access-denied', 'already used')),
333
+ ),
334
+ });
335
+ const {result} = run({
336
+ sentry,
337
+ // First lookup sees no row; the re-read finds it tombstoned.
338
+ installSequence: [undefined, installation({status: 'deleted'})],
339
+ });
340
+
341
+ await expect(result).rejects.toBeInstanceOf(SentryInstallationDeletedError);
342
+ });
343
+ });
@@ -0,0 +1,305 @@
1
+ import {createHash, timingSafeEqual} from 'node:crypto';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {logger} from '@shipfox/node-opentelemetry';
4
+ import type {SentryApiClient, SentryAuthorization} from '#api/client.js';
5
+ import {
6
+ SentryClaimProofMismatchError,
7
+ SentryInstallationAlreadyLinkedError,
8
+ SentryInstallationDeletedError,
9
+ SentryIntegrationProviderError,
10
+ SentryVerificationInProgressError,
11
+ } from '#core/errors.js';
12
+ import type {
13
+ PersistVerifiedUnclaimedInstallationParams,
14
+ SentryInstallation,
15
+ } from '#db/installations.js';
16
+
17
+ export interface ConnectSentryInstallationInput {
18
+ workspaceId: string;
19
+ installationUuid: string;
20
+ orgSlug: string;
21
+ displayName: string;
22
+ installerUserId: string;
23
+ codeHash: string;
24
+ }
25
+
26
+ // sha256 of the single-use authorization code. Stored on the install row so the
27
+ // claim can prove the claimant holds the same code Sentry issued, without ever
28
+ // persisting a live credential (the code is dead once exchanged).
29
+ export function hashAuthorizationCode(code: string): string {
30
+ return createHash('sha256').update(code).digest('hex');
31
+ }
32
+
33
+ // Constant-time compare of two sha256 hex digests, matching the HMAC check in
34
+ // signature.ts. The length guard runs first because timingSafeEqual throws on a
35
+ // length mismatch, and a digest's length is not secret.
36
+ function codeHashesEqual(presented: string, stored: string): boolean {
37
+ if (presented.length !== stored.length) return false;
38
+ return timingSafeEqual(Buffer.from(presented), Buffer.from(stored));
39
+ }
40
+
41
+ export interface VerifyAndPersistUnclaimedInstallationParams {
42
+ sentry: SentryApiClient;
43
+ installationUuid: string;
44
+ code: string;
45
+ // Known from the signed webhook payload; omit to derive it from Sentry after
46
+ // the exchange (the browser-first claim carries no payload).
47
+ orgSlug?: string | undefined;
48
+ verifyInstall: boolean;
49
+ persistVerifiedUnclaimedInstallation: (
50
+ input: PersistVerifiedUnclaimedInstallationParams,
51
+ ) => Promise<SentryInstallation>;
52
+ }
53
+
54
+ export interface VerifyAndPersistUnclaimedInstallationResult {
55
+ installation: SentryInstallation;
56
+ authorization: SentryAuthorization;
57
+ }
58
+
59
+ /**
60
+ * Security-critical exchange → persist → best-effort verify, shared by the signed
61
+ * webhook and the browser-first claim. The exchange is the authenticity check and
62
+ * spends the single-use code, so it runs OUTSIDE any DB transaction; the caller's
63
+ * `persistVerifiedUnclaimedInstallation` owns the short transaction. The verify
64
+ * runs AFTER the row is durably persisted, so a verify failure leaves a claimable
65
+ * row rather than a Sentry-side "installed" state pointing at a row that was never
66
+ * written. Never logs the raw code.
67
+ */
68
+ export async function verifyAndPersistUnclaimedInstallation(
69
+ params: VerifyAndPersistUnclaimedInstallationParams,
70
+ ): Promise<VerifyAndPersistUnclaimedInstallationResult> {
71
+ const authorization = await params.sentry.exchangeAuthorizationCode({
72
+ installationUuid: params.installationUuid,
73
+ code: params.code,
74
+ });
75
+
76
+ const orgSlug =
77
+ params.orgSlug ??
78
+ (
79
+ await params.sentry.getInstallation({
80
+ installationUuid: params.installationUuid,
81
+ token: authorization.token,
82
+ })
83
+ ).orgSlug;
84
+
85
+ const installation = await params.persistVerifiedUnclaimedInstallation({
86
+ installationUuid: params.installationUuid,
87
+ orgSlug,
88
+ codeHash: hashAuthorizationCode(params.code),
89
+ });
90
+
91
+ if (params.verifyInstall) {
92
+ await bestEffortVerifyInstallation({
93
+ sentry: params.sentry,
94
+ installationUuid: params.installationUuid,
95
+ token: authorization.token,
96
+ });
97
+ }
98
+
99
+ return {installation, authorization};
100
+ }
101
+
102
+ export interface HandleSentryConnectParams {
103
+ sentry: SentryApiClient;
104
+ workspaceId: string;
105
+ code: string;
106
+ installationUuid: string;
107
+ installerUserId: string;
108
+ verifyInstall: boolean;
109
+ getSentryInstallation: (input: {
110
+ installationUuid: string;
111
+ }) => Promise<SentryInstallation | undefined>;
112
+ getConnectionById: (id: string) => Promise<IntegrationConnection<'sentry'> | undefined>;
113
+ connectSentryInstallation: (
114
+ input: ConnectSentryInstallationInput,
115
+ ) => Promise<IntegrationConnection<'sentry'>>;
116
+ persistVerifiedUnclaimedInstallation: (
117
+ input: PersistVerifiedUnclaimedInstallationParams,
118
+ ) => Promise<SentryInstallation>;
119
+ }
120
+
121
+ /**
122
+ * Binds a verified Sentry installation to a workspace (the claim half of the
123
+ * webhook-authoritative flow). The webhook persists the verified-unclaimed row;
124
+ * this proves the claimant controls the install and sets `connection_id`.
125
+ *
126
+ * Proof rules (unified claim auth):
127
+ * - exchange succeeds → browser-first winner or a re-entry with a fresh code.
128
+ * - exchange "already used" + the presented code hashes to the stored hash →
129
+ * the same code the webhook spent, so the claimant holds it (same-code race).
130
+ * - exchange "already used" + the verified row is not visible yet → a concurrent
131
+ * webhook is mid-exchange; retryable so the client backoff reconciles.
132
+ * - anything else on an existing unclaimed row → proof mismatch (403, IDOR gate).
133
+ */
134
+ export async function handleSentryConnect(
135
+ params: HandleSentryConnectParams,
136
+ ): Promise<IntegrationConnection<'sentry'>> {
137
+ const install = await params.getSentryInstallation({installationUuid: params.installationUuid});
138
+
139
+ if (install) {
140
+ if (install.status === 'deleted') {
141
+ throw new SentryInstallationDeletedError(params.installationUuid);
142
+ }
143
+ if (install.connectionId) {
144
+ return resolveClaimedInstall(params, install.connectionId);
145
+ }
146
+ return claimVerifiedInstall(params, install);
147
+ }
148
+
149
+ return claimBrowserFirst(params);
150
+ }
151
+
152
+ async function resolveClaimedInstall(
153
+ params: HandleSentryConnectParams,
154
+ connectionId: string,
155
+ ): Promise<IntegrationConnection<'sentry'>> {
156
+ const connection = await params.getConnectionById(connectionId);
157
+ if (connection && connection.workspaceId === params.workspaceId) {
158
+ return connection;
159
+ }
160
+ throw new SentryInstallationAlreadyLinkedError(params.installationUuid);
161
+ }
162
+
163
+ async function claimVerifiedInstall(
164
+ params: HandleSentryConnectParams,
165
+ install: SentryInstallation,
166
+ ): Promise<IntegrationConnection<'sentry'>> {
167
+ let authorization: SentryAuthorization;
168
+ try {
169
+ authorization = await params.sentry.exchangeAuthorizationCode({
170
+ installationUuid: params.installationUuid,
171
+ code: params.code,
172
+ });
173
+ } catch (error) {
174
+ if (isCodeAlreadyUsed(error)) {
175
+ if (
176
+ install.codeHash &&
177
+ codeHashesEqual(hashAuthorizationCode(params.code), install.codeHash)
178
+ ) {
179
+ return bindClaim(params, {orgSlug: install.orgSlug, codeHash: install.codeHash});
180
+ }
181
+ throw new SentryClaimProofMismatchError(params.installationUuid);
182
+ }
183
+ throw error;
184
+ }
185
+
186
+ const connection = await bindClaim(params, {
187
+ orgSlug: install.orgSlug,
188
+ codeHash: hashAuthorizationCode(params.code),
189
+ });
190
+ if (params.verifyInstall) {
191
+ await bestEffortVerifyInstallation({
192
+ sentry: params.sentry,
193
+ installationUuid: params.installationUuid,
194
+ token: authorization.token,
195
+ connectionId: connection.id,
196
+ });
197
+ }
198
+ return connection;
199
+ }
200
+
201
+ async function claimBrowserFirst(
202
+ params: HandleSentryConnectParams,
203
+ ): Promise<IntegrationConnection<'sentry'>> {
204
+ let result: VerifyAndPersistUnclaimedInstallationResult;
205
+ try {
206
+ result = await verifyAndPersistUnclaimedInstallation({
207
+ sentry: params.sentry,
208
+ installationUuid: params.installationUuid,
209
+ code: params.code,
210
+ verifyInstall: false,
211
+ persistVerifiedUnclaimedInstallation: params.persistVerifiedUnclaimedInstallation,
212
+ });
213
+ } catch (error) {
214
+ if (isCodeAlreadyUsed(error)) {
215
+ return reconcileConcurrentClaim(params);
216
+ }
217
+ throw error;
218
+ }
219
+
220
+ const connection = await bindClaim(params, {
221
+ orgSlug: result.installation.orgSlug,
222
+ codeHash: hashAuthorizationCode(params.code),
223
+ });
224
+ if (params.verifyInstall) {
225
+ await bestEffortVerifyInstallation({
226
+ sentry: params.sentry,
227
+ installationUuid: params.installationUuid,
228
+ token: result.authorization.token,
229
+ connectionId: connection.id,
230
+ });
231
+ }
232
+ return connection;
233
+ }
234
+
235
+ // The browser-first exchange got "already used" with no row visible at lookup: a
236
+ // concurrent webhook won the exchange. Re-read once — if its verified row is now
237
+ // visible we reconcile through the same proof rules; if it got claimed we resolve
238
+ // it; if it was tombstoned we surface that terminally (matching the top-level
239
+ // check); otherwise it is still mid-flight, so the claim is retryable.
240
+ async function reconcileConcurrentClaim(
241
+ params: HandleSentryConnectParams,
242
+ ): Promise<IntegrationConnection<'sentry'>> {
243
+ const reread = await params.getSentryInstallation({installationUuid: params.installationUuid});
244
+ if (reread?.status === 'deleted') {
245
+ throw new SentryInstallationDeletedError(params.installationUuid);
246
+ }
247
+ if (reread) {
248
+ if (reread.connectionId) {
249
+ return resolveClaimedInstall(params, reread.connectionId);
250
+ }
251
+ if (isVerifiedUnclaimed(reread)) {
252
+ return claimVerifiedInstall(params, reread);
253
+ }
254
+ }
255
+ throw new SentryVerificationInProgressError(params.installationUuid);
256
+ }
257
+
258
+ function isVerifiedUnclaimed(install: SentryInstallation): boolean {
259
+ return install.connectionId === null && install.status === 'installed';
260
+ }
261
+
262
+ function bindClaim(
263
+ params: HandleSentryConnectParams,
264
+ binding: {orgSlug: string; codeHash: string},
265
+ ): Promise<IntegrationConnection<'sentry'>> {
266
+ return params.connectSentryInstallation({
267
+ workspaceId: params.workspaceId,
268
+ installationUuid: params.installationUuid,
269
+ orgSlug: binding.orgSlug,
270
+ displayName: `Sentry ${binding.orgSlug}`,
271
+ installerUserId: params.installerUserId,
272
+ codeHash: binding.codeHash,
273
+ });
274
+ }
275
+
276
+ async function bestEffortVerifyInstallation(input: {
277
+ sentry: SentryApiClient;
278
+ installationUuid: string;
279
+ token: string;
280
+ connectionId?: string;
281
+ }): Promise<void> {
282
+ // The row is already persisted and receiving webhooks, so a verify failure only
283
+ // leaves the install pending on Sentry's side (re-verifying needs a fresh token
284
+ // we cannot mint here). Log it rather than failing a working claim.
285
+ try {
286
+ await input.sentry.verifyInstallation({
287
+ installationUuid: input.installationUuid,
288
+ token: input.token,
289
+ });
290
+ } catch (error) {
291
+ logger().warn(
292
+ {installationUuid: input.installationUuid, connectionId: input.connectionId, err: error},
293
+ 'sentry connect: verify-install failed after persistence',
294
+ );
295
+ }
296
+ }
297
+
298
+ // The Sentry client collapses a reused, expired, or forged code to a single
299
+ // `access-denied` provider error; we cannot tell them apart from the response.
300
+ // On an install we already know was verified, the most likely cause is the code
301
+ // having been spent already, so the hash check disambiguates rather than the
302
+ // error itself.
303
+ function isCodeAlreadyUsed(error: unknown): boolean {
304
+ return error instanceof SentryIntegrationProviderError && error.reason === 'access-denied';
305
+ }
@@ -0,0 +1,50 @@
1
+ import {createHmac} from 'node:crypto';
2
+ import {verifySentrySignature} from './signature.js';
3
+
4
+ const SECRET = 'test-client-secret';
5
+
6
+ function sign(rawBody: string, secret = SECRET): string {
7
+ return createHmac('sha256', secret).update(rawBody).digest('hex');
8
+ }
9
+
10
+ describe('verifySentrySignature', () => {
11
+ test('accepts a signature computed with the matching secret', () => {
12
+ const rawBody = JSON.stringify({action: 'created'});
13
+
14
+ const result = verifySentrySignature({rawBody, signature: sign(rawBody), secret: SECRET});
15
+
16
+ expect(result).toBe(true);
17
+ });
18
+
19
+ test('rejects a signature for a tampered body', () => {
20
+ const rawBody = JSON.stringify({action: 'created'});
21
+
22
+ const result = verifySentrySignature({
23
+ rawBody,
24
+ signature: sign(`${rawBody}tampered`),
25
+ secret: SECRET,
26
+ });
27
+
28
+ expect(result).toBe(false);
29
+ });
30
+
31
+ test('rejects a signature computed with a different secret', () => {
32
+ const rawBody = JSON.stringify({action: 'created'});
33
+
34
+ const result = verifySentrySignature({
35
+ rawBody,
36
+ signature: sign(rawBody, 'other-secret'),
37
+ secret: SECRET,
38
+ });
39
+
40
+ expect(result).toBe(false);
41
+ });
42
+
43
+ test('rejects a garbage signature whose length differs from the digest without throwing', () => {
44
+ const rawBody = JSON.stringify({action: 'created'});
45
+
46
+ const result = verifySentrySignature({rawBody, signature: 'deadbeef', secret: SECRET});
47
+
48
+ expect(result).toBe(false);
49
+ });
50
+ });