@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,325 @@
1
+ import {
2
+ AUTH_USER,
3
+ buildUserContext,
4
+ setUserContext,
5
+ type UserContextMembership,
6
+ } from '@shipfox/api-auth-context';
7
+ import {
8
+ ConnectionSlugConflictError,
9
+ type IntegrationConnection,
10
+ } from '@shipfox/api-integration-core-dto';
11
+ import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
12
+ import type {FastifyInstance, FastifyRequest} from 'fastify';
13
+ import type {SentryApiClient} from '#api/client.js';
14
+ import {SentryIntegrationProviderError} from '#core/errors.js';
15
+ import type {ConnectSentryInstallationInput} from '#core/install.js';
16
+ import type {SentryInstallation} from '#db/installations.js';
17
+ import {createSentryIntegrationProvider} from '#index.js';
18
+
19
+ let authenticatedMemberships: UserContextMembership[] = [];
20
+
21
+ const fakeUserAuth: AuthMethod = {
22
+ name: AUTH_USER,
23
+ authenticate: (request: FastifyRequest) => {
24
+ if (request.headers.authorization !== 'Bearer user') {
25
+ throw new ClientError('Invalid user token', 'unauthorized', {status: 401});
26
+ }
27
+ setUserContext(
28
+ request,
29
+ buildUserContext({
30
+ userId: 'user-1',
31
+ email: 'user@example.com',
32
+ memberships: authenticatedMemberships,
33
+ }),
34
+ );
35
+ return Promise.resolve();
36
+ },
37
+ };
38
+
39
+ function sentryClient(overrides: Partial<SentryApiClient> = {}): SentryApiClient {
40
+ return {
41
+ exchangeAuthorizationCode: vi.fn(() =>
42
+ Promise.resolve({token: 'tok', refreshToken: 'refresh', expiresAt: 'x'}),
43
+ ),
44
+ getInstallation: vi.fn(() => Promise.resolve({orgSlug: 'acme'})),
45
+ verifyInstallation: vi.fn(() => Promise.resolve()),
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ function unclaimedInstallation(overrides: Partial<SentryInstallation> = {}): SentryInstallation {
51
+ return {
52
+ id: 'row-1',
53
+ connectionId: null,
54
+ installationUuid: 'install-uuid-1',
55
+ orgSlug: 'acme',
56
+ status: 'installed',
57
+ codeHash: null,
58
+ installerUserId: null,
59
+ createdAt: new Date(),
60
+ updatedAt: new Date(),
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ interface CreateTestAppOptions {
66
+ sentry?: SentryApiClient;
67
+ installation?: SentryInstallation | undefined;
68
+ existingConnection?: IntegrationConnection<'sentry'> | undefined;
69
+ connectSentryInstallation?:
70
+ | ((input: ConnectSentryInstallationInput) => Promise<IntegrationConnection<'sentry'>>)
71
+ | undefined;
72
+ }
73
+
74
+ async function createTestApp(options: CreateTestAppOptions = {}): Promise<FastifyInstance> {
75
+ const provider = createSentryIntegrationProvider({
76
+ sentry: options.sentry ?? sentryClient(),
77
+ getSentryInstallation: vi.fn(() => Promise.resolve(options.installation)),
78
+ getConnectionById: vi.fn(() => Promise.resolve(options.existingConnection)),
79
+ persistVerifiedUnclaimedInstallation: vi.fn((input) =>
80
+ Promise.resolve(unclaimedInstallation({...input})),
81
+ ),
82
+ connectSentryInstallation:
83
+ options.connectSentryInstallation ??
84
+ vi.fn((input) =>
85
+ Promise.resolve({
86
+ id: crypto.randomUUID(),
87
+ workspaceId: input.workspaceId,
88
+ provider: 'sentry' as const,
89
+ externalAccountId: input.installationUuid,
90
+ slug: 'sentry_acme',
91
+ displayName: input.displayName,
92
+ lifecycleStatus: 'active' as const,
93
+ createdAt: new Date(),
94
+ updatedAt: new Date(),
95
+ }),
96
+ ),
97
+ // Webhook receiver dependencies — install/connect tests don't exercise them.
98
+ coreDb: vi.fn() as never,
99
+ publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: false})),
100
+ recordDeliveryOnly: vi.fn(() => Promise.resolve()),
101
+ getIntegrationConnectionById: vi.fn(() => Promise.resolve(undefined)),
102
+ updateConnectionLifecycleStatus: vi.fn(() => Promise.resolve(undefined)),
103
+ });
104
+ const app = await createApp({auth: [fakeUserAuth], routes: provider.routes, swagger: false});
105
+ await app.ready();
106
+ return app;
107
+ }
108
+
109
+ function connectPayload(options: {authorize?: boolean} = {}) {
110
+ const workspaceId = crypto.randomUUID();
111
+ if (options.authorize !== false) {
112
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
113
+ }
114
+ return {
115
+ workspace_id: workspaceId,
116
+ code: 'the-code',
117
+ installation_id: 'install-uuid-1',
118
+ };
119
+ }
120
+
121
+ describe('Sentry integration routes', () => {
122
+ beforeEach(async () => {
123
+ authenticatedMemberships = [];
124
+ await closeApp();
125
+ });
126
+
127
+ afterEach(async () => {
128
+ await closeApp();
129
+ });
130
+
131
+ it('requires auth for the install URL', async () => {
132
+ const app = await createTestApp();
133
+
134
+ const res = await app.inject({
135
+ method: 'POST',
136
+ url: '/integrations/sentry/install',
137
+ payload: {workspace_id: crypto.randomUUID()},
138
+ });
139
+
140
+ expect(res.statusCode).toBe(401);
141
+ });
142
+
143
+ it('returns the external-install URL for a member', async () => {
144
+ const app = await createTestApp();
145
+ const workspaceId = crypto.randomUUID();
146
+ authenticatedMemberships = [{workspaceId, role: 'admin'}];
147
+
148
+ const res = await app.inject({
149
+ method: 'POST',
150
+ url: '/integrations/sentry/install',
151
+ headers: {authorization: 'Bearer user'},
152
+ payload: {workspace_id: workspaceId},
153
+ });
154
+
155
+ expect(res.statusCode).toBe(200);
156
+ expect(res.json().install_url).toBe(
157
+ 'https://sentry.io/sentry-apps/shipfox-test/external-install/',
158
+ );
159
+ });
160
+
161
+ it('requires auth for connect', async () => {
162
+ const app = await createTestApp();
163
+
164
+ const res = await app.inject({
165
+ method: 'POST',
166
+ url: '/integrations/sentry/connect',
167
+ payload: connectPayload(),
168
+ });
169
+
170
+ expect(res.statusCode).toBe(401);
171
+ });
172
+
173
+ it('connects an installation and returns a connection DTO with no capabilities', async () => {
174
+ const app = await createTestApp();
175
+
176
+ const res = await app.inject({
177
+ method: 'POST',
178
+ url: '/integrations/sentry/connect',
179
+ headers: {authorization: 'Bearer user'},
180
+ payload: connectPayload(),
181
+ });
182
+
183
+ expect(res.statusCode).toBe(200);
184
+ expect(res.json().provider).toBe('sentry');
185
+ expect(res.json().external_account_id).toBe('install-uuid-1');
186
+ expect(res.json().display_name).toBe('Sentry acme');
187
+ expect(res.json().capabilities).toEqual([]);
188
+ });
189
+
190
+ it('returns 403 when the caller is not a workspace member', async () => {
191
+ const app = await createTestApp();
192
+
193
+ const res = await app.inject({
194
+ method: 'POST',
195
+ url: '/integrations/sentry/connect',
196
+ headers: {authorization: 'Bearer user'},
197
+ payload: connectPayload({authorize: false}),
198
+ });
199
+
200
+ expect(res.statusCode).toBe(403);
201
+ });
202
+
203
+ it('returns 409 when the installation is already linked to another workspace', async () => {
204
+ const connectionId = crypto.randomUUID();
205
+ const app = await createTestApp({
206
+ installation: unclaimedInstallation({connectionId}),
207
+ existingConnection: {
208
+ id: connectionId,
209
+ workspaceId: crypto.randomUUID(),
210
+ provider: 'sentry',
211
+ externalAccountId: 'install-uuid-1',
212
+ slug: 'sentry_acme',
213
+ displayName: 'Sentry acme',
214
+ lifecycleStatus: 'active',
215
+ createdAt: new Date(),
216
+ updatedAt: new Date(),
217
+ },
218
+ });
219
+
220
+ const res = await app.inject({
221
+ method: 'POST',
222
+ url: '/integrations/sentry/connect',
223
+ headers: {authorization: 'Bearer user'},
224
+ payload: connectPayload(),
225
+ });
226
+
227
+ expect(res.statusCode).toBe(409);
228
+ expect(res.json().code).toBe('sentry-installation-already-linked');
229
+ });
230
+
231
+ it('returns 409 when connection slug allocation conflicts repeatedly', async () => {
232
+ const app = await createTestApp({
233
+ connectSentryInstallation: vi.fn(() =>
234
+ Promise.reject(new ConnectionSlugConflictError(new Error('duplicate slug'))),
235
+ ),
236
+ });
237
+
238
+ const res = await app.inject({
239
+ method: 'POST',
240
+ url: '/integrations/sentry/connect',
241
+ headers: {authorization: 'Bearer user'},
242
+ payload: connectPayload(),
243
+ });
244
+
245
+ expect(res.statusCode).toBe(409);
246
+ expect(res.json().code).toBe('slug-conflict');
247
+ });
248
+
249
+ it('maps a non-access-denied provider error to its status', async () => {
250
+ const app = await createTestApp({
251
+ sentry: sentryClient({
252
+ exchangeAuthorizationCode: vi.fn(() =>
253
+ Promise.reject(new SentryIntegrationProviderError('rate-limited', 'slow down')),
254
+ ),
255
+ }),
256
+ });
257
+
258
+ const res = await app.inject({
259
+ method: 'POST',
260
+ url: '/integrations/sentry/connect',
261
+ headers: {authorization: 'Bearer user'},
262
+ payload: connectPayload(),
263
+ });
264
+
265
+ expect(res.statusCode).toBe(429);
266
+ expect(res.json().code).toBe('rate-limited');
267
+ });
268
+
269
+ it('returns a retryable 503 when a concurrent webhook is still verifying the install', async () => {
270
+ const app = await createTestApp({
271
+ sentry: sentryClient({
272
+ exchangeAuthorizationCode: vi.fn(() =>
273
+ Promise.reject(new SentryIntegrationProviderError('access-denied', 'already used')),
274
+ ),
275
+ }),
276
+ });
277
+
278
+ const res = await app.inject({
279
+ method: 'POST',
280
+ url: '/integrations/sentry/connect',
281
+ headers: {authorization: 'Bearer user'},
282
+ payload: connectPayload(),
283
+ });
284
+
285
+ expect(res.statusCode).toBe(503);
286
+ expect(res.json().code).toBe('sentry-verification-in-progress');
287
+ });
288
+
289
+ it('returns 403 when the presented code cannot prove control of a verified install', async () => {
290
+ const app = await createTestApp({
291
+ installation: unclaimedInstallation({codeHash: 'a-different-hash'}),
292
+ sentry: sentryClient({
293
+ exchangeAuthorizationCode: vi.fn(() =>
294
+ Promise.reject(new SentryIntegrationProviderError('access-denied', 'already used')),
295
+ ),
296
+ }),
297
+ });
298
+
299
+ const res = await app.inject({
300
+ method: 'POST',
301
+ url: '/integrations/sentry/connect',
302
+ headers: {authorization: 'Bearer user'},
303
+ payload: connectPayload(),
304
+ });
305
+
306
+ expect(res.statusCode).toBe(403);
307
+ expect(res.json().code).toBe('sentry-claim-proof-mismatch');
308
+ });
309
+
310
+ it('returns 409 when the installation is tombstoned', async () => {
311
+ const app = await createTestApp({
312
+ installation: unclaimedInstallation({status: 'deleted'}),
313
+ });
314
+
315
+ const res = await app.inject({
316
+ method: 'POST',
317
+ url: '/integrations/sentry/connect',
318
+ headers: {authorization: 'Bearer user'},
319
+ payload: connectPayload(),
320
+ });
321
+
322
+ expect(res.statusCode).toBe(409);
323
+ expect(res.json().code).toBe('sentry-installation-deleted');
324
+ });
325
+ });
@@ -0,0 +1,102 @@
1
+ import {AUTH_USER, requireUserContext, requireWorkspaceAccess} from '@shipfox/api-auth-context';
2
+ import type {IntegrationConnection} from '@shipfox/api-integration-core-dto';
3
+ import {
4
+ createSentryInstallBodySchema,
5
+ createSentryInstallResponseSchema,
6
+ sentryConnectBodySchema,
7
+ sentryConnectResponseSchema,
8
+ } from '@shipfox/api-integration-sentry-dto';
9
+ import {defineRoute, type RouteGroup} from '@shipfox/node-fastify';
10
+ import type {SentryApiClient} from '#api/client.js';
11
+ import {config} from '#config.js';
12
+ import {type ConnectSentryInstallationInput, handleSentryConnect} from '#core/install.js';
13
+ import type {
14
+ PersistVerifiedUnclaimedInstallationParams,
15
+ SentryInstallation,
16
+ } from '#db/installations.js';
17
+ import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
18
+ import {sentryRouteErrorHandler} from './errors.js';
19
+
20
+ export interface CreateSentryIntegrationRoutesOptions {
21
+ sentry: SentryApiClient;
22
+ getSentryInstallation: (input: {
23
+ installationUuid: string;
24
+ }) => Promise<SentryInstallation | undefined>;
25
+ getConnectionById: (id: string) => Promise<IntegrationConnection<'sentry'> | undefined>;
26
+ connectSentryInstallation: (
27
+ input: ConnectSentryInstallationInput,
28
+ ) => Promise<IntegrationConnection<'sentry'>>;
29
+ persistVerifiedUnclaimedInstallation: (
30
+ input: PersistVerifiedUnclaimedInstallationParams,
31
+ ) => Promise<SentryInstallation>;
32
+ }
33
+
34
+ export function createSentryIntegrationRoutes({
35
+ sentry,
36
+ getSentryInstallation,
37
+ getConnectionById,
38
+ connectSentryInstallation,
39
+ persistVerifiedUnclaimedInstallation,
40
+ }: CreateSentryIntegrationRoutesOptions): RouteGroup {
41
+ const createInstallRoute = defineRoute({
42
+ method: 'POST',
43
+ path: '/install',
44
+ auth: AUTH_USER,
45
+ description: 'Create a Sentry app external-install URL for a workspace.',
46
+ schema: {
47
+ body: createSentryInstallBodySchema,
48
+ response: {
49
+ 200: createSentryInstallResponseSchema,
50
+ },
51
+ },
52
+ handler: (request) => {
53
+ const {workspace_id: workspaceId} = request.body;
54
+
55
+ requireWorkspaceAccess({request, workspaceId});
56
+
57
+ // Sentry has no state param to embed; the server owns the slug.
58
+ const installUrl = `https://sentry.io/sentry-apps/${config.SENTRY_APP_SLUG}/external-install/`;
59
+ return {install_url: installUrl};
60
+ },
61
+ });
62
+
63
+ const connectRoute = defineRoute({
64
+ method: 'POST',
65
+ path: '/connect',
66
+ auth: AUTH_USER,
67
+ description: 'Link a Sentry installation to a workspace after the install redirect.',
68
+ schema: {
69
+ body: sentryConnectBodySchema,
70
+ response: {
71
+ 200: sentryConnectResponseSchema,
72
+ },
73
+ },
74
+ errorHandler: sentryRouteErrorHandler,
75
+ handler: async (request) => {
76
+ const {workspace_id: workspaceId, code, installation_id: installationUuid} = request.body;
77
+ const actor = requireUserContext(request);
78
+
79
+ requireWorkspaceAccess({request, workspaceId});
80
+
81
+ const connection = await handleSentryConnect({
82
+ sentry,
83
+ workspaceId,
84
+ code,
85
+ installationUuid,
86
+ installerUserId: actor.userId,
87
+ verifyInstall: config.SENTRY_APP_VERIFY_INSTALL,
88
+ getSentryInstallation,
89
+ getConnectionById,
90
+ connectSentryInstallation,
91
+ persistVerifiedUnclaimedInstallation,
92
+ });
93
+
94
+ return toIntegrationConnectionDto(connection);
95
+ },
96
+ });
97
+
98
+ return {
99
+ prefix: '/integrations/sentry',
100
+ routes: [createInstallRoute, connectRoute],
101
+ };
102
+ }
@@ -0,0 +1,76 @@
1
+ import {sentryInstallationWebhookSchema} from '@shipfox/api-integration-sentry-dto';
2
+ import type {FastifyReply} from 'fastify';
3
+ import {config} from '#config.js';
4
+ import {handleSentryInstallationCreated, handleSentryInstallationDeleted} from '#core/webhook.js';
5
+ import {
6
+ getSentryInstallationByInstallationUuid,
7
+ persistVerifiedUnclaimedInstallation,
8
+ } from '#db/installations.js';
9
+ import type {SentryWebhookContext} from './webhook-context.js';
10
+ import {parseAndValidateOrDrop, SENTRY_PROVIDER} from './webhook-delivery.js';
11
+
12
+ // Validates an installation lifecycle delivery, then applies it. `created` is the
13
+ // authoritative install signal: it exchanges the single-use code and persists a
14
+ // verified-unclaimed row (exchange outside any transaction; persist + delivery
15
+ // record in one short transaction). `deleted` tombstones the install and disables
16
+ // the connection in a single transaction.
17
+ export async function handleInstallationResource(args: {
18
+ context: SentryWebhookContext;
19
+ reply: FastifyReply;
20
+ deliveryId: string;
21
+ rawBody: string;
22
+ }): Promise<null> {
23
+ const {context, reply, deliveryId, rawBody} = args;
24
+
25
+ const payload = await parseAndValidateOrDrop({
26
+ schema: sentryInstallationWebhookSchema,
27
+ rawBody,
28
+ deliveryId,
29
+ context,
30
+ reply,
31
+ });
32
+ if (!payload) return null;
33
+
34
+ const installation = payload.data.installation;
35
+
36
+ if (payload.action === 'deleted') {
37
+ await context.coreDb().transaction(async (tx) => {
38
+ await handleSentryInstallationDeleted({
39
+ tx,
40
+ deliveryId,
41
+ installationUuid: installation.uuid,
42
+ recordDeliveryOnly: context.recordDeliveryOnly,
43
+ updateConnectionLifecycleStatus: context.updateConnectionLifecycleStatus,
44
+ });
45
+ });
46
+ reply.code(204);
47
+ return null;
48
+ }
49
+
50
+ await handleSentryInstallationCreated({
51
+ deliveryId,
52
+ installationUuid: installation.uuid,
53
+ orgSlug: installation.organization?.slug,
54
+ code: installation.code,
55
+ sentry: context.sentry,
56
+ verifyInstall: config.SENTRY_APP_VERIFY_INSTALL,
57
+ getSentryInstallation: ({installationUuid}) =>
58
+ getSentryInstallationByInstallationUuid(installationUuid),
59
+ persistUnclaimedAndRecordDelivery: ({installationUuid, orgSlug, codeHash, deliveryId: id}) =>
60
+ context.coreDb().transaction(async (tx) => {
61
+ const persisted = await persistVerifiedUnclaimedInstallation(
62
+ {installationUuid, orgSlug, codeHash},
63
+ {tx},
64
+ );
65
+ await context.recordDeliveryOnly({tx, provider: SENTRY_PROVIDER, deliveryId: id});
66
+ return persisted;
67
+ }),
68
+ recordDelivery: (id) =>
69
+ context.coreDb().transaction(async (tx) => {
70
+ await context.recordDeliveryOnly({tx, provider: SENTRY_PROVIDER, deliveryId: id});
71
+ }),
72
+ });
73
+
74
+ reply.code(204);
75
+ return null;
76
+ }
@@ -0,0 +1,52 @@
1
+ import {sentryIssueWebhookSchema} from '@shipfox/api-integration-sentry-dto';
2
+ import {logger} from '@shipfox/node-opentelemetry';
3
+ import type {FastifyReply} from 'fastify';
4
+ import {SentryIssueDroppedError} from '#core/errors.js';
5
+ import {handleSentryIssueEvent, normalizeSentryIssueAction} from '#core/webhook.js';
6
+ import type {SentryWebhookContext} from './webhook-context.js';
7
+ import {parseAndValidateOrDrop, recordAndDrop} from './webhook-delivery.js';
8
+
9
+ // Validates an issue delivery, then publishes the mapped event in a transaction.
10
+ // A malformed or unknown-action payload is recorded-and-dropped (204) before we
11
+ // reach core. Core throws a SentryIssueDroppedError when the delivery references
12
+ // state we cannot publish against (unknown/deleted installation, missing
13
+ // connection); we record-and-drop those too. Any other error bubbles up.
14
+ export async function handleIssueResource(args: {
15
+ context: SentryWebhookContext;
16
+ reply: FastifyReply;
17
+ deliveryId: string;
18
+ rawBody: string;
19
+ }): Promise<null> {
20
+ const {context, reply, deliveryId, rawBody} = args;
21
+
22
+ const payload = await parseAndValidateOrDrop({
23
+ schema: sentryIssueWebhookSchema,
24
+ normalize: normalizeSentryIssueAction,
25
+ rawBody,
26
+ deliveryId,
27
+ context,
28
+ reply,
29
+ });
30
+ if (!payload) return null;
31
+
32
+ try {
33
+ await context.coreDb().transaction(async (tx) => {
34
+ await handleSentryIssueEvent({
35
+ tx,
36
+ deliveryId,
37
+ payload,
38
+ publishIntegrationEventReceived: context.publishIntegrationEventReceived,
39
+ getIntegrationConnectionById: context.getIntegrationConnectionById,
40
+ });
41
+ });
42
+ } catch (error) {
43
+ if (error instanceof SentryIssueDroppedError) {
44
+ logger().warn({deliveryId, err: error}, `sentry webhook: ${error.message}, dropping`);
45
+ return recordAndDrop({context, reply, deliveryId});
46
+ }
47
+ throw error;
48
+ }
49
+
50
+ reply.code(204);
51
+ return null;
52
+ }
@@ -0,0 +1,25 @@
1
+ import type {
2
+ GetIntegrationConnectionByIdFn,
3
+ PublishIntegrationEventReceivedFn,
4
+ RecordDeliveryOnlyFn,
5
+ UpdateIntegrationConnectionLifecycleStatusFn,
6
+ } from '@shipfox/api-integration-core-dto';
7
+ import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
8
+ import type {SentryApiClient} from '#api/client.js';
9
+
10
+ /**
11
+ * The capabilities a Sentry webhook handler needs from the rest of the system.
12
+ * `@shipfox/api-integration-core` owns and wires the core persistence in
13
+ * production; tests fake them with spies. Passing them in keeps the route
14
+ * decoupled from core's persistence and avoids a package dependency cycle. The
15
+ * Sentry client is injected too so the authoritative `installation.created` path
16
+ * can exchange the code, and tests can fake the exchange.
17
+ */
18
+ export interface SentryWebhookContext {
19
+ sentry: SentryApiClient;
20
+ coreDb: () => NodePgDatabase<Record<string, unknown>>;
21
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
22
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
23
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
24
+ updateConnectionLifecycleStatus: UpdateIntegrationConnectionLifecycleStatusFn;
25
+ }
@@ -0,0 +1,61 @@
1
+ import {logger} from '@shipfox/node-opentelemetry';
2
+ import type {FastifyReply} from 'fastify';
3
+ import type {z} from 'zod';
4
+ import type {SentryWebhookContext} from './webhook-context.js';
5
+
6
+ export const SENTRY_PROVIDER = 'sentry';
7
+
8
+ /**
9
+ * Parses and validates a delivery body, recording-and-dropping it (HTTP 204) when
10
+ * the JSON is malformed or fails the schema (an unknown action falls here too).
11
+ * Returns the validated payload, or null when the delivery was dropped.
12
+ */
13
+ export async function parseAndValidateOrDrop<TSchema extends z.ZodType>(args: {
14
+ schema: TSchema;
15
+ rawBody: string;
16
+ deliveryId: string;
17
+ context: SentryWebhookContext;
18
+ reply: FastifyReply;
19
+ normalize?: (parsedJson: unknown) => unknown;
20
+ }): Promise<z.infer<TSchema> | null> {
21
+ const {schema, rawBody, deliveryId, context, reply, normalize} = args;
22
+
23
+ let parsedJson: unknown;
24
+ try {
25
+ parsedJson = JSON.parse(rawBody);
26
+ } catch (error) {
27
+ logger().warn({deliveryId, err: error}, 'sentry webhook: payload JSON parse failed, dropping');
28
+ await recordAndDrop({context, reply, deliveryId});
29
+ return null;
30
+ }
31
+
32
+ const validated = schema.safeParse(normalize ? normalize(parsedJson) : parsedJson);
33
+ if (!validated.success) {
34
+ logger().warn(
35
+ {deliveryId, issues: validated.error.issues},
36
+ 'sentry webhook: payload failed validation (or unknown action), dropping',
37
+ );
38
+ await recordAndDrop({context, reply, deliveryId});
39
+ return null;
40
+ }
41
+
42
+ return validated.data;
43
+ }
44
+
45
+ /**
46
+ * Records the delivery for dedup and replies 204 without acting on it. A sustained
47
+ * non-2xx can degrade or disable the webhook on Sentry's side (a deliberate
48
+ * deviation from GitHub's 400), so deliveries we cannot use are acknowledged.
49
+ */
50
+ export async function recordAndDrop(args: {
51
+ context: SentryWebhookContext;
52
+ reply: FastifyReply;
53
+ deliveryId: string;
54
+ }): Promise<null> {
55
+ const {context, reply, deliveryId} = args;
56
+ await context.coreDb().transaction(async (tx) => {
57
+ await context.recordDeliveryOnly({tx, provider: SENTRY_PROVIDER, deliveryId});
58
+ });
59
+ reply.code(204);
60
+ return null;
61
+ }