@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
@@ -1,16 +1,20 @@
1
- import {and, eq, isNull, lt, sql} from 'drizzle-orm';
1
+ import {and, eq, inArray, isNull, lt, ne, sql} from 'drizzle-orm';
2
2
  import {SentryInstallationAlreadyLinkedError} from '#core/errors.js';
3
3
  import {db} from './db.js';
4
4
  import {sentryInstallations, toSentryInstallation} from './schema/installations.js';
5
5
 
6
6
  export type SentryInstallationStatus = 'installed' | 'deleted';
7
+ export type SentryInstallationRowStatus =
8
+ | 'pending'
9
+ | 'exchange-succeeded'
10
+ | SentryInstallationStatus;
7
11
 
8
12
  export interface SentryInstallation {
9
13
  id: string;
10
14
  connectionId: string | null;
11
15
  installationUuid: string;
12
16
  orgSlug: string;
13
- status: string;
17
+ status: SentryInstallationRowStatus;
14
18
  codeHash: string | null;
15
19
  installerUserId: string | null;
16
20
  createdAt: Date;
@@ -32,6 +36,12 @@ export interface PersistVerifiedUnclaimedInstallationParams {
32
36
  codeHash: string;
33
37
  }
34
38
 
39
+ export interface ClaimSentryInstallationVerificationParams {
40
+ installationUuid: string;
41
+ orgSlug: string;
42
+ codeHash: string;
43
+ }
44
+
35
45
  type SentryDb = ReturnType<typeof db>;
36
46
  type SentryTx = Parameters<Parameters<SentryDb['transaction']>[0]>[0];
37
47
 
@@ -53,15 +63,10 @@ export async function upsertSentryInstallation(
53
63
  })
54
64
  .onConflictDoUpdate({
55
65
  target: sentryInstallations.installationUuid,
56
- // TOCTOU guard: allow the update only when this installation is unclaimed
57
- // (connection_id IS NULL, the first claim of a verified-unclaimed row) or
58
- // already owned by this same connection (idempotent reconnect). A concurrent
59
- // claim of the same installation to a different workspace commits its own
60
- // connection_id first; this losing transaction then sees a different non-null
61
- // connection_id, the predicate is false, Postgres updates nothing, and the
62
- // empty RETURNING below rolls it back instead of silently repointing the
63
- // installation (cross-tenant event misroute).
64
- setWhere: sql`${sentryInstallations.connectionId} is null or ${sentryInstallations.connectionId} = ${params.connectionId}`,
66
+ // A deleted UUID is terminal. Otherwise, allow the first claim of an
67
+ // unclaimed row or an idempotent reconnect by its current connection. This
68
+ // blocks both a concurrent cross-workspace claim and a claim racing deletion.
69
+ setWhere: sql`${sentryInstallations.status} <> 'deleted' and (${sentryInstallations.connectionId} is null or ${sentryInstallations.connectionId} = ${params.connectionId})`,
65
70
  set: {
66
71
  connectionId: params.connectionId,
67
72
  orgSlug: params.orgSlug,
@@ -80,9 +85,8 @@ export async function upsertSentryInstallation(
80
85
  /**
81
86
  * Inserts the verified-but-unclaimed install half (`connection_id = NULL`,
82
87
  * `status = 'installed'`) after a successful code exchange. Idempotent on the
83
- * installation uuid: a conflicting row refreshes only `code_hash`/`org_slug`/
84
- * `updated_at`, so a webhook that lands after a claim never clobbers the
85
- * `connection_id` it set nor downgrades a `deleted` tombstone back to installed.
88
+ * installation uuid: a pending row becomes installed, a claimed row keeps its
89
+ * `connection_id`, and a deleted tombstone never becomes installed again.
86
90
  */
87
91
  export async function persistVerifiedUnclaimedInstallation(
88
92
  params: PersistVerifiedUnclaimedInstallationParams,
@@ -101,23 +105,97 @@ export async function persistVerifiedUnclaimedInstallation(
101
105
  })
102
106
  .onConflictDoUpdate({
103
107
  target: sentryInstallations.installationUuid,
108
+ setWhere: ne(sentryInstallations.status, 'deleted'),
104
109
  set: {
105
110
  orgSlug: params.orgSlug,
111
+ status: 'installed',
106
112
  codeHash: params.codeHash,
107
113
  updatedAt: now,
108
114
  },
109
115
  })
110
116
  .returning();
111
117
 
112
- if (!row) throw new Error('Sentry installation persist returned no rows');
118
+ if (!row) {
119
+ const existing = await getSentryInstallationByInstallationUuid(params.installationUuid, {
120
+ tx: executor,
121
+ });
122
+ if (existing) return existing;
123
+ throw new Error('Sentry installation persist returned no rows');
124
+ }
125
+ return toSentryInstallation(row);
126
+ }
127
+
128
+ export async function claimSentryInstallationVerification(
129
+ params: ClaimSentryInstallationVerificationParams,
130
+ options: {tx?: unknown} = {},
131
+ ): Promise<SentryInstallation> {
132
+ const executor = (options.tx ?? db()) as SentryDb | SentryTx;
133
+ const [inserted] = await executor
134
+ .insert(sentryInstallations)
135
+ .values({
136
+ connectionId: null,
137
+ installationUuid: params.installationUuid,
138
+ orgSlug: params.orgSlug,
139
+ status: 'pending',
140
+ codeHash: params.codeHash,
141
+ })
142
+ .onConflictDoNothing({target: sentryInstallations.installationUuid})
143
+ .returning();
144
+
145
+ if (inserted) return toSentryInstallation(inserted);
146
+
147
+ const existing = await getSentryInstallationByInstallationUuid(params.installationUuid, {
148
+ tx: executor,
149
+ });
150
+ if (!existing) throw new Error('Sentry installation verification claim returned no rows');
151
+ return existing;
152
+ }
153
+
154
+ export async function completeSentryInstallationVerification(
155
+ params: {installationUuid: string; codeHash: string},
156
+ options: {tx?: unknown} = {},
157
+ ): Promise<SentryInstallation | undefined> {
158
+ const executor = (options.tx ?? db()) as SentryDb | SentryTx;
159
+ const [row] = await executor
160
+ .update(sentryInstallations)
161
+ .set({status: 'installed', updatedAt: new Date()})
162
+ .where(
163
+ and(
164
+ eq(sentryInstallations.installationUuid, params.installationUuid),
165
+ eq(sentryInstallations.status, 'exchange-succeeded'),
166
+ eq(sentryInstallations.codeHash, params.codeHash),
167
+ ),
168
+ )
169
+ .returning();
170
+ if (!row) return undefined;
171
+ return toSentryInstallation(row);
172
+ }
173
+
174
+ export async function markSentryInstallationExchangeSucceeded(
175
+ params: {installationUuid: string; codeHash: string},
176
+ options: {tx?: unknown} = {},
177
+ ): Promise<SentryInstallation | undefined> {
178
+ const executor = (options.tx ?? db()) as SentryDb | SentryTx;
179
+ const [row] = await executor
180
+ .update(sentryInstallations)
181
+ .set({status: 'exchange-succeeded', updatedAt: new Date()})
182
+ .where(
183
+ and(
184
+ eq(sentryInstallations.installationUuid, params.installationUuid),
185
+ eq(sentryInstallations.status, 'pending'),
186
+ eq(sentryInstallations.codeHash, params.codeHash),
187
+ ),
188
+ )
189
+ .returning();
190
+ if (!row) return undefined;
113
191
  return toSentryInstallation(row);
114
192
  }
115
193
 
116
194
  /**
117
- * Verified installs no user has claimed yet (`connection_id IS NULL`,
118
- * `status='installed'`). Tombstoned rows are excluded so the TTL cron stays
119
- * idempotent and the unclaimed metric stays accurate. Pass `olderThan` to scope
120
- * to stale rows for the cron.
195
+ * Install claims no user has bound to a connection yet. This includes pending
196
+ * and exchanged claims so abandoned verification work remains observable.
197
+ * Tombstoned rows are excluded. Pass `olderThan` to scope by the most recent
198
+ * state transition for retention work.
121
199
  */
122
200
  export async function listUnclaimedSentryInstallations(
123
201
  params: {olderThan?: Date} = {},
@@ -126,10 +204,10 @@ export async function listUnclaimedSentryInstallations(
126
204
  const executor = (options.tx ?? db()) as SentryDb | SentryTx;
127
205
  const conditions = [
128
206
  isNull(sentryInstallations.connectionId),
129
- eq(sentryInstallations.status, 'installed'),
207
+ inArray(sentryInstallations.status, ['pending', 'exchange-succeeded', 'installed']),
130
208
  ];
131
209
  if (params.olderThan) {
132
- conditions.push(lt(sentryInstallations.createdAt, params.olderThan));
210
+ conditions.push(lt(sentryInstallations.updatedAt, params.olderThan));
133
211
  }
134
212
  const rows = await executor
135
213
  .select()
@@ -169,39 +247,62 @@ export async function getSentryInstallationByConnectionId(
169
247
  }
170
248
 
171
249
  /**
172
- * Tombstones verified-unclaimed installs older than `olderThan` (the TTL cleanup
173
- * cron). Bounds a never-claimed install rather than leaving it pending forever.
174
- * Returns how many rows were tombstoned. We hold no Sentry token for these rows
175
- * (tokens are never persisted), so the Sentry-side uninstall is out of scope here.
250
+ * Releases abandoned pending claims and tombstones verified-unclaimed installs
251
+ * older than `olderThan`. Pending claims have not durably completed an exchange,
252
+ * so deleting them allows a later signed creation to try again. Exchanged and
253
+ * installed rows stay terminal after expiry. We hold no Sentry token for these
254
+ * rows, so the Sentry-side uninstall is out of scope here.
176
255
  */
177
256
  export async function pruneUnclaimedSentryInstallations(
178
257
  params: {olderThan: Date},
179
258
  options: {tx?: unknown} = {},
180
- ): Promise<{tombstoned: number}> {
259
+ ): Promise<{releasedPending: number; tombstoned: number}> {
181
260
  const executor = (options.tx ?? db()) as SentryDb | SentryTx;
182
- const result = await executor
261
+ const releasedPending = await executor
262
+ .delete(sentryInstallations)
263
+ .where(
264
+ and(
265
+ isNull(sentryInstallations.connectionId),
266
+ eq(sentryInstallations.status, 'pending'),
267
+ lt(sentryInstallations.updatedAt, params.olderThan),
268
+ ),
269
+ );
270
+ const tombstoned = await executor
183
271
  .update(sentryInstallations)
184
272
  .set({status: 'deleted', updatedAt: new Date()})
185
273
  .where(
186
274
  and(
187
275
  isNull(sentryInstallations.connectionId),
188
- eq(sentryInstallations.status, 'installed'),
189
- lt(sentryInstallations.createdAt, params.olderThan),
276
+ inArray(sentryInstallations.status, ['exchange-succeeded', 'installed']),
277
+ lt(sentryInstallations.updatedAt, params.olderThan),
190
278
  ),
191
279
  );
192
- return {tombstoned: result.rowCount ?? 0};
280
+ return {
281
+ releasedPending: releasedPending.rowCount ?? 0,
282
+ tombstoned: tombstoned.rowCount ?? 0,
283
+ };
193
284
  }
194
285
 
195
286
  export async function markSentryInstallationDeleted(
196
287
  params: {installationUuid: string},
197
288
  options: {tx?: unknown} = {},
198
- ): Promise<SentryInstallation | undefined> {
289
+ ): Promise<SentryInstallation> {
199
290
  const executor = (options.tx ?? db()) as SentryDb | SentryTx;
200
291
  const [row] = await executor
201
- .update(sentryInstallations)
202
- .set({status: 'deleted', updatedAt: new Date()})
203
- .where(eq(sentryInstallations.installationUuid, params.installationUuid))
292
+ .insert(sentryInstallations)
293
+ .values({
294
+ connectionId: null,
295
+ installationUuid: params.installationUuid,
296
+ // A reordered delete can arrive without organization data. Deleted rows never expose an organization URL.
297
+ orgSlug: '',
298
+ status: 'deleted',
299
+ codeHash: null,
300
+ })
301
+ .onConflictDoUpdate({
302
+ target: sentryInstallations.installationUuid,
303
+ set: {status: 'deleted', updatedAt: new Date()},
304
+ })
204
305
  .returning();
205
- if (!row) return undefined;
306
+ if (!row) throw new Error('Sentry installation deletion returned no rows');
206
307
  return toSentryInstallation(row);
207
308
  }
@@ -1,6 +1,6 @@
1
1
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
2
2
  import {text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
3
- import type {SentryInstallation} from '#db/installations.js';
3
+ import type {SentryInstallation, SentryInstallationRowStatus} from '#db/installations.js';
4
4
  import {pgTable} from './common.js';
5
5
 
6
6
  export const sentryInstallations = pgTable(
@@ -8,15 +8,15 @@ export const sentryInstallations = pgTable(
8
8
  {
9
9
  id: uuidv7PrimaryKey(),
10
10
  // Null until a logged-in user claims the verified install into a workspace.
11
- // An unclaimed install (`connection_id IS NULL`, `status='installed'`) is
12
- // persisted by the authoritative webhook before any browser claim arrives.
11
+ // Pending and verified installs remain unclaimed until the browser binds a
12
+ // connection. Deleted rows are terminal tombstones.
13
13
  connectionId: uuid('connection_id'),
14
14
  installationUuid: text('installation_uuid').notNull(),
15
15
  orgSlug: text('org_slug').notNull(),
16
- status: text('status').notNull(),
17
- // sha256(authorization code) of the exchange that verified this install.
18
- // The claim presents the code and we match the hash, so a bare uuid alone
19
- // cannot bind the install (IDOR guard) without storing a live credential.
16
+ status: text('status').notNull().$type<SentryInstallationRowStatus>(),
17
+ // sha256(authorization code) claimed for this install. The hash identifies
18
+ // which pending exchange reached the durable success checkpoint and keeps a
19
+ // bare uuid from binding an install (IDOR guard) without storing a credential.
20
20
  codeHash: text('code_hash'),
21
21
  installerUserId: uuid('installer_user_id'),
22
22
  createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
package/src/index.test.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import {createOutboxRegistry} from '@shipfox/node-module';
1
2
  import {createSentryMaintenanceWorker} from './index.js';
2
3
 
3
4
  describe('createSentryMaintenanceWorker', () => {
@@ -6,7 +7,9 @@ describe('createSentryMaintenanceWorker', () => {
6
7
 
7
8
  expect(worker.taskQueue).toBe('integrations-sentry-maintenance');
8
9
  expect(worker.workflowsPath.endsWith('dist/temporal/workflows/index.js')).toBe(true);
9
- expect(Object.keys(worker.activities())).toContain('pruneUnclaimedSentryInstallationsActivity');
10
+ expect(Object.keys(worker.activities({outboxRegistry: createOutboxRegistry()}))).toContain(
11
+ 'pruneUnclaimedSentryInstallationsActivity',
12
+ );
10
13
  expect(worker.workflows).toEqual([
11
14
  {
12
15
  name: 'pruneUnclaimedSentryInstallationsCron',
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  import type {ModuleWorker} from '@shipfox/node-module';
10
10
  import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
11
11
  import {createSentryApiClient, type SentryApiClient} from '#api/client.js';
12
+ import {createSentryWebhookProcessor} from '#core/webhook-processor.js';
12
13
  import {closeDb, db} from '#db/db.js';
13
14
  import {
14
15
  getSentryInstallationByConnectionId,
@@ -45,6 +46,11 @@ export {
45
46
  handleSentryInstallationDeleted,
46
47
  handleSentryIssueEvent,
47
48
  } from '#core/webhook.js';
49
+ export type {
50
+ CreateSentryWebhookProcessorOptions,
51
+ SentryWebhookProcessor,
52
+ } from '#core/webhook-processor.js';
53
+ export {createSentryWebhookProcessor} from '#core/webhook-processor.js';
48
54
  export type {
49
55
  PersistVerifiedUnclaimedInstallationParams,
50
56
  SentryInstallation,
@@ -79,6 +85,14 @@ export function createSentryIntegrationProvider(options: CreateSentryIntegration
79
85
  options.getSentryInstallationByConnectionId ?? getSentryInstallationByConnectionId;
80
86
  const persistUnclaimed =
81
87
  options.persistVerifiedUnclaimedInstallation ?? persistVerifiedUnclaimedInstallation;
88
+ const webhookProcessor = createSentryWebhookProcessor({
89
+ sentry,
90
+ coreDb: options.coreDb,
91
+ publishIntegrationEventReceived: options.publishIntegrationEventReceived,
92
+ recordDeliveryOnly: options.recordDeliveryOnly,
93
+ getIntegrationConnectionById: options.getIntegrationConnectionById,
94
+ updateConnectionLifecycleStatus: options.updateConnectionLifecycleStatus,
95
+ });
82
96
 
83
97
  return {
84
98
  provider: 'sentry' as const,
@@ -103,8 +117,10 @@ export function createSentryIntegrationProvider(options: CreateSentryIntegration
103
117
  recordDeliveryOnly: options.recordDeliveryOnly,
104
118
  getIntegrationConnectionById: options.getIntegrationConnectionById,
105
119
  updateConnectionLifecycleStatus: options.updateConnectionLifecycleStatus,
120
+ processor: webhookProcessor,
106
121
  }),
107
122
  ],
123
+ webhookProcessors: [{routeIds: ['sentry'] as const, processor: webhookProcessor}],
108
124
  };
109
125
  }
110
126
 
@@ -617,7 +617,7 @@ describe('Sentry webhook route', () => {
617
617
  expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
618
618
  });
619
619
 
620
- test('installation/deleted with no matching row records the delivery and does not disable', async () => {
620
+ test('installation/deleted with no matching row writes a tombstone and does not disable', async () => {
621
621
  const {app, recordDeliveryOnly, updateConnectionLifecycleStatus} = await createTestApp();
622
622
  const deliveryId = randomUUID();
623
623
  const body = JSON.stringify(
@@ -635,6 +635,7 @@ describe('Sentry webhook route', () => {
635
635
  });
636
636
 
637
637
  expect(res.statusCode).toBe(204);
638
+ expect((await readInstallation('never-installed'))?.status).toBe('deleted');
638
639
  expect(updateConnectionLifecycleStatus).not.toHaveBeenCalled();
639
640
  expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
640
641
  });
@@ -719,7 +720,7 @@ describe('Sentry webhook route', () => {
719
720
  expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
720
721
  });
721
722
 
722
- test('installation/created records-and-drops when the code exchange fails', async () => {
723
+ test('installation/created stays pending when access denied has no success checkpoint', async () => {
723
724
  const installationUuid = 'install-created-exchangefail';
724
725
  const {app, recordDeliveryOnly} = await createTestApp({
725
726
  sentry: sentryClient({
@@ -745,9 +746,9 @@ describe('Sentry webhook route', () => {
745
746
  payload: body,
746
747
  });
747
748
 
748
- expect(res.statusCode).toBe(204);
749
- expect(await readInstallation(installationUuid)).toBeUndefined();
750
- expect(recordDeliveryOnly).toHaveBeenCalledTimes(1);
749
+ expect(res.statusCode).toBe(500);
750
+ expect((await readInstallation(installationUuid))?.status).toBe('pending');
751
+ expect(recordDeliveryOnly).not.toHaveBeenCalled();
751
752
  });
752
753
 
753
754
  test('records the delivery only for a malformed installation payload', async () => {
@@ -1,3 +1,8 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {
3
+ createStoredWebhookRequest,
4
+ WEBHOOK_MAX_RAW_BODY_BYTES,
5
+ } from '@shipfox/api-integration-core-dto';
1
6
  import {
2
7
  ClientError,
3
8
  defineRoute,
@@ -5,48 +10,41 @@ import {
5
10
  rawBodyPlugin,
6
11
  WEBHOOK_BODY_LIMIT,
7
12
  } from '@shipfox/node-fastify';
8
- import {logger} from '@shipfox/node-opentelemetry';
9
- import {config} from '#config.js';
10
- import {verifySentrySignature} from '#core/signature.js';
11
- import {handleInstallationResource} from './installation-handler.js';
12
- import {handleIssueResource} from './issue-handler.js';
13
+ import {
14
+ createSentryWebhookProcessor,
15
+ type SentryWebhookProcessor,
16
+ } from '#core/webhook-processor.js';
13
17
  import type {SentryWebhookContext} from './webhook-context.js';
14
- import {recordAndDrop} from './webhook-delivery.js';
15
- import {parseSentryWebhookRequest} from './webhook-request.js';
16
-
17
- const ISSUE_RESOURCE = 'issue';
18
- const INSTALLATION_RESOURCE = 'installation';
19
18
 
20
19
  export type {SentryWebhookContext} from './webhook-context.js';
21
20
 
22
- export function createSentryWebhookRoutes(context: SentryWebhookContext): RouteGroup {
21
+ export function createSentryWebhookRoutes(
22
+ context: SentryWebhookContext & {processor?: SentryWebhookProcessor | undefined},
23
+ ): RouteGroup {
24
+ const processor = context.processor ?? createSentryWebhookProcessor(context);
23
25
  const webhookRoute = defineRoute({
24
26
  method: 'POST',
25
27
  path: '/',
26
28
  auth: [],
27
29
  description: 'Sentry integration webhook receiver.',
28
30
  options: {bodyLimit: WEBHOOK_BODY_LIMIT},
29
- handler: (request, reply) => {
30
- const {deliveryId, resource, signature, signatureHeaderName, rawBody} =
31
- parseSentryWebhookRequest(request);
32
-
33
- if (!verifySentrySignature({rawBody, signature, secret: config.SENTRY_APP_CLIENT_SECRET})) {
34
- throw new ClientError('invalid signature', 'invalid-signature', {status: 401});
31
+ handler: async (request, reply) => {
32
+ const body = request.body;
33
+ if (!(body instanceof Uint8Array)) {
34
+ throw new ClientError('expected raw JSON body', 'invalid-body');
35
35
  }
36
- logger().debug(
37
- {deliveryId, signatureHeader: signatureHeaderName},
38
- 'sentry webhook: signature verified',
39
- );
40
-
41
- if (resource === ISSUE_RESOURCE) {
42
- return handleIssueResource({context, reply, deliveryId, rawBody});
43
- }
44
- if (resource === INSTALLATION_RESOURCE) {
45
- return handleInstallationResource({context, reply, deliveryId, rawBody});
36
+ if (body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {
37
+ throw new ClientError('Webhook request body is too large', 'body-too-large', {status: 413});
46
38
  }
47
39
 
48
- // Unsupported resources are acknowledged so Sentry does not retry or disable the app.
49
- return recordAndDrop({context, reply, deliveryId});
40
+ const result = await processor.process(
41
+ createSentryStoredWebhookRequest(
42
+ body,
43
+ request.headers,
44
+ request.raw.url?.split('?')[1] ?? '',
45
+ ),
46
+ );
47
+ return sendSentryWebhookResponse(reply, result, request.headers);
50
48
  },
51
49
  });
52
50
 
@@ -57,3 +55,64 @@ export function createSentryWebhookRoutes(context: SentryWebhookContext): RouteG
57
55
  routes: [webhookRoute],
58
56
  };
59
57
  }
58
+
59
+ function createSentryStoredWebhookRequest(
60
+ body: Uint8Array,
61
+ headers: Record<string, string | string[] | undefined>,
62
+ rawQueryString: string,
63
+ ) {
64
+ try {
65
+ return createStoredWebhookRequest({
66
+ requestId: randomUUID(),
67
+ routeId: 'sentry',
68
+ receivedAt: new Date().toISOString(),
69
+ rawQueryString,
70
+ headers: sentryWebhookHeaders(headers),
71
+ body,
72
+ });
73
+ } catch (error) {
74
+ throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {
75
+ cause: error,
76
+ });
77
+ }
78
+ }
79
+
80
+ function sentryWebhookHeaders(headers: Record<string, string | string[] | undefined>) {
81
+ return Object.fromEntries(
82
+ [
83
+ 'content-type',
84
+ 'request-id',
85
+ 'sentry-hook-resource',
86
+ 'sentry-hook-signature',
87
+ 'sentry-app-signature',
88
+ ].flatMap((name) => {
89
+ const value = headers[name];
90
+ return typeof value === 'string' ? [[name, value]] : [];
91
+ }),
92
+ );
93
+ }
94
+
95
+ function sendSentryWebhookResponse(
96
+ reply: {code(statusCode: number): void},
97
+ result: import('@shipfox/api-integration-core-dto').WebhookProcessingResult,
98
+ headers: Record<string, string | string[] | undefined>,
99
+ ) {
100
+ if (result.outcome === 'discarded' && result.reason === 'invalid_signature') {
101
+ reply.code(401);
102
+ return {error: 'invalid signature'};
103
+ }
104
+ if (result.outcome === 'discarded' && result.reason === 'missing_required_input') {
105
+ const hasSignature =
106
+ typeof headers['sentry-hook-signature'] === 'string' ||
107
+ typeof headers['sentry-app-signature'] === 'string';
108
+ if (!hasSignature) {
109
+ reply.code(401);
110
+ return {error: 'missing signature'};
111
+ }
112
+ reply.code(400);
113
+ return {error: 'missing required input'};
114
+ }
115
+
116
+ reply.code(204);
117
+ return null;
118
+ }
@@ -7,8 +7,7 @@ import {pruneUnclaimedSentryInstallationsActivity} from './prune-unclaimed-insta
7
7
  const DAY_MS = 24 * 60 * 60 * 1000;
8
8
 
9
9
  // The activity derives its cutoff from SENTRY_UNCLAIMED_INSTALLATION_RETENTION_DAYS
10
- // (default 7), so rows are seeded with an explicit createdAt to straddle it; the
11
- // persist helpers always stamp createdAt=now and could not exercise the cutoff.
10
+ // (default 7), so rows are seeded with an explicit updatedAt to straddle it.
12
11
  describe('pruneUnclaimedSentryInstallationsActivity', () => {
13
12
  beforeEach(async () => {
14
13
  await db().delete(sentryInstallations);
@@ -30,6 +29,7 @@ describe('pruneUnclaimedSentryInstallationsActivity', () => {
30
29
  const result = await pruneUnclaimedSentryInstallationsActivity();
31
30
 
32
31
  expect(result.tombstoned).toBe(1);
32
+ expect(result.releasedPending).toBe(0);
33
33
  expect((await getSentryInstallationByInstallationUuid(staleUuid))?.status).toBe('deleted');
34
34
  });
35
35
 
@@ -47,6 +47,28 @@ describe('pruneUnclaimedSentryInstallationsActivity', () => {
47
47
  const result = await pruneUnclaimedSentryInstallationsActivity();
48
48
 
49
49
  expect(result.tombstoned).toBe(0);
50
+ expect(result.releasedPending).toBe(0);
50
51
  expect((await getSentryInstallationByInstallationUuid(freshUuid))?.status).toBe('installed');
51
52
  });
53
+
54
+ test('releases pending claims older than the retention window', async () => {
55
+ const staleUuid = randomUUID();
56
+ await db()
57
+ .insert(sentryInstallations)
58
+ .values({
59
+ connectionId: null,
60
+ installationUuid: staleUuid,
61
+ orgSlug: 'acme',
62
+ status: 'pending',
63
+ codeHash: 'hash',
64
+ createdAt: new Date(Date.now() - 8 * DAY_MS),
65
+ updatedAt: new Date(Date.now() - 8 * DAY_MS),
66
+ });
67
+
68
+ const result = await pruneUnclaimedSentryInstallationsActivity();
69
+ const installation = await getSentryInstallationByInstallationUuid(staleUuid);
70
+
71
+ expect(result).toEqual({releasedPending: 1, tombstoned: 0});
72
+ expect(installation).toBeUndefined();
73
+ });
52
74
  });
@@ -3,7 +3,10 @@ import {pruneUnclaimedSentryInstallations} from '#db/installations.js';
3
3
 
4
4
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
5
5
 
6
- export async function pruneUnclaimedSentryInstallationsActivity(): Promise<{tombstoned: number}> {
6
+ export async function pruneUnclaimedSentryInstallationsActivity(): Promise<{
7
+ releasedPending: number;
8
+ tombstoned: number;
9
+ }> {
7
10
  const olderThan = new Date(
8
11
  Date.now() - config.SENTRY_UNCLAIMED_INSTALLATION_RETENTION_DAYS * MS_PER_DAY,
9
12
  );
@@ -8,7 +8,10 @@ const {pruneUnclaimedSentryInstallationsActivity} = proxyActivities<
8
8
  });
9
9
 
10
10
  export async function pruneUnclaimedSentryInstallationsCron(): Promise<void> {
11
- const {tombstoned} = await pruneUnclaimedSentryInstallationsActivity();
11
+ const {releasedPending, tombstoned} = await pruneUnclaimedSentryInstallationsActivity();
12
+ if (releasedPending > 0) {
13
+ log.info('Released stale pending Sentry installation claims', {releasedPending});
14
+ }
12
15
  if (tombstoned > 0) {
13
16
  log.info('Tombstoned stale unclaimed Sentry installations', {tombstoned});
14
17
  }