@shipfox/api-integration-sentry 4.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.turbo/turbo-build.log +7 -7
  2. package/CHANGELOG.md +49 -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 +7 -2
  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 +20 -28
  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
@@ -0,0 +1,224 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {
3
+ decodeWebhookBody,
4
+ type GetIntegrationConnectionByIdFn,
5
+ type PublishIntegrationEventReceivedFn,
6
+ type RecordDeliveryOnlyFn,
7
+ type StoredWebhookRequest,
8
+ type UpdateIntegrationConnectionLifecycleStatusFn,
9
+ type WebhookProcessingResult,
10
+ } from '@shipfox/api-integration-core-dto';
11
+ import {
12
+ sentryInstallationWebhookSchema,
13
+ sentryIssueWebhookSchema,
14
+ } from '@shipfox/api-integration-sentry-dto';
15
+ import {logger} from '@shipfox/node-opentelemetry';
16
+ import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
17
+ import type {SentryApiClient} from '#api/client.js';
18
+ import {config} from '#config.js';
19
+ import {SentryIssueDroppedError} from '#core/errors.js';
20
+ import {
21
+ handleSentryInstallationCreated,
22
+ handleSentryInstallationDeleted,
23
+ handleSentryIssueEvent,
24
+ normalizeSentryIssueAction,
25
+ } from '#core/webhook.js';
26
+ import {
27
+ completeSentryInstallationVerification,
28
+ getSentryInstallationByInstallationUuid,
29
+ } from '#db/installations.js';
30
+ import {verifySentrySignature} from './signature.js';
31
+
32
+ const DELIVERY_ID_HEADER = 'request-id';
33
+ const RESOURCE_HEADER = 'sentry-hook-resource';
34
+ const SIGNATURE_HEADER = 'sentry-hook-signature';
35
+ const LEGACY_SIGNATURE_HEADER = 'sentry-app-signature';
36
+ const ISSUE_RESOURCE = 'issue';
37
+ const INSTALLATION_RESOURCE = 'installation';
38
+ const SENTRY_PROVIDER = 'sentry';
39
+
40
+ export interface SentryWebhookProcessor {
41
+ process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;
42
+ }
43
+
44
+ export interface CreateSentryWebhookProcessorOptions {
45
+ sentry: SentryApiClient;
46
+ coreDb: () => NodePgDatabase<Record<string, unknown>>;
47
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
48
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
49
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
50
+ updateConnectionLifecycleStatus: UpdateIntegrationConnectionLifecycleStatusFn;
51
+ }
52
+
53
+ export function createSentryWebhookProcessor(
54
+ context: CreateSentryWebhookProcessorOptions,
55
+ ): SentryWebhookProcessor {
56
+ return {process: (request) => processSentryWebhookRequest(context, request)};
57
+ }
58
+
59
+ async function processSentryWebhookRequest(
60
+ context: CreateSentryWebhookProcessorOptions,
61
+ request: StoredWebhookRequest,
62
+ ): Promise<WebhookProcessingResult> {
63
+ if (request.route_id !== 'sentry') {
64
+ throw new Error(`Sentry processor cannot process ${request.route_id} requests`);
65
+ }
66
+
67
+ const deliveryId = request.headers[DELIVERY_ID_HEADER];
68
+ const resource = request.headers[RESOURCE_HEADER];
69
+ const signature = request.headers[SIGNATURE_HEADER] ?? request.headers[LEGACY_SIGNATURE_HEADER];
70
+ if (!deliveryId || !resource || !signature) {
71
+ return {outcome: 'discarded', reason: 'missing_required_input'};
72
+ }
73
+
74
+ const rawBody = Buffer.from(decodeWebhookBody(request.body));
75
+ if (!verifySentrySignature({rawBody, signature, secret: config.SENTRY_APP_CLIENT_SECRET})) {
76
+ return {outcome: 'discarded', reason: 'invalid_signature', deliveryId};
77
+ }
78
+
79
+ logger().debug(
80
+ {
81
+ deliveryId,
82
+ signatureHeader: request.headers[SIGNATURE_HEADER]
83
+ ? SIGNATURE_HEADER
84
+ : LEGACY_SIGNATURE_HEADER,
85
+ },
86
+ 'sentry webhook: signature verified',
87
+ );
88
+
89
+ if (resource === ISSUE_RESOURCE) return processIssueResource(context, deliveryId, rawBody);
90
+ if (resource === INSTALLATION_RESOURCE)
91
+ return processInstallationResource(context, deliveryId, rawBody);
92
+
93
+ await recordDeliveryOnly(context, deliveryId);
94
+ return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};
95
+ }
96
+
97
+ async function processIssueResource(
98
+ context: CreateSentryWebhookProcessorOptions,
99
+ deliveryId: string,
100
+ rawBody: Uint8Array,
101
+ ): Promise<WebhookProcessingResult> {
102
+ const parsed = parsePayload(
103
+ sentryIssueWebhookSchema,
104
+ rawBody,
105
+ deliveryId,
106
+ normalizeSentryIssueAction,
107
+ );
108
+ if (!parsed.success) {
109
+ await recordDeliveryOnly(context, deliveryId);
110
+ return {outcome: 'discarded', reason: parsed.reason, deliveryId};
111
+ }
112
+ const payload = parsed.data;
113
+
114
+ try {
115
+ await context.coreDb().transaction(async (tx) => {
116
+ await handleSentryIssueEvent({
117
+ tx,
118
+ deliveryId,
119
+ payload,
120
+ publishIntegrationEventReceived: context.publishIntegrationEventReceived,
121
+ getIntegrationConnectionById: context.getIntegrationConnectionById,
122
+ });
123
+ });
124
+ } catch (error) {
125
+ if (!(error instanceof SentryIssueDroppedError)) throw error;
126
+
127
+ logger().warn({deliveryId, err: error}, `sentry webhook: ${error.message}, dropping`);
128
+ await recordDeliveryOnly(context, deliveryId);
129
+ return {outcome: 'discarded', reason: 'connection_unavailable', deliveryId};
130
+ }
131
+
132
+ return {outcome: 'processed', deliveryId};
133
+ }
134
+
135
+ async function processInstallationResource(
136
+ context: CreateSentryWebhookProcessorOptions,
137
+ deliveryId: string,
138
+ rawBody: Uint8Array,
139
+ ): Promise<WebhookProcessingResult> {
140
+ const parsed = parsePayload(sentryInstallationWebhookSchema, rawBody, deliveryId);
141
+ if (!parsed.success) {
142
+ await recordDeliveryOnly(context, deliveryId);
143
+ return {outcome: 'discarded', reason: parsed.reason, deliveryId};
144
+ }
145
+ const payload = parsed.data;
146
+
147
+ const installation = payload.data.installation;
148
+ if (payload.action === 'deleted') {
149
+ await context.coreDb().transaction(async (tx) => {
150
+ await handleSentryInstallationDeleted({
151
+ tx,
152
+ deliveryId,
153
+ installationUuid: installation.uuid,
154
+ recordDeliveryOnly: context.recordDeliveryOnly,
155
+ updateConnectionLifecycleStatus: context.updateConnectionLifecycleStatus,
156
+ });
157
+ });
158
+ return {outcome: 'processed', deliveryId};
159
+ }
160
+
161
+ await handleSentryInstallationCreated({
162
+ deliveryId,
163
+ installationUuid: installation.uuid,
164
+ orgSlug: installation.organization?.slug,
165
+ code: installation.code,
166
+ sentry: context.sentry,
167
+ verifyInstall: config.SENTRY_APP_VERIFY_INSTALL,
168
+ getSentryInstallation: ({installationUuid}) =>
169
+ getSentryInstallationByInstallationUuid(installationUuid),
170
+ persistUnclaimedAndRecordDelivery: ({installationUuid, codeHash, deliveryId: id}) =>
171
+ context.coreDb().transaction(async (tx) => {
172
+ const completed = await completeSentryInstallationVerification(
173
+ {installationUuid, codeHash},
174
+ {tx},
175
+ );
176
+ await context.recordDeliveryOnly({tx, provider: SENTRY_PROVIDER, deliveryId: id});
177
+ if (completed) return completed;
178
+
179
+ const current = await getSentryInstallationByInstallationUuid(installationUuid, {tx});
180
+ if (!current) throw new Error('Sentry installation verification lost its claim');
181
+ return current;
182
+ }),
183
+ recordDelivery: (id) => recordDeliveryOnly(context, id),
184
+ });
185
+
186
+ return {outcome: 'processed', deliveryId};
187
+ }
188
+
189
+ type ParsePayloadResult<T> =
190
+ | {success: true; data: T}
191
+ | {success: false; reason: 'malformed_payload' | 'unsupported_event'};
192
+
193
+ function parsePayload<T>(
194
+ schema: {safeParse(value: unknown): {success: true; data: T} | {success: false; error: unknown}},
195
+ rawBody: Uint8Array,
196
+ deliveryId: string,
197
+ normalize: (payload: unknown) => unknown = (payload) => payload,
198
+ ): ParsePayloadResult<T> {
199
+ let parsedJson: unknown;
200
+ try {
201
+ parsedJson = JSON.parse(Buffer.from(rawBody).toString('utf8'));
202
+ } catch (error) {
203
+ logger().warn({deliveryId, err: error}, 'sentry webhook: payload JSON parse failed, dropping');
204
+ return {success: false, reason: 'malformed_payload'};
205
+ }
206
+
207
+ const payload = schema.safeParse(normalize(parsedJson));
208
+ if (payload.success) return {success: true, data: payload.data};
209
+
210
+ logger().warn(
211
+ {deliveryId, issues: payload.error},
212
+ 'sentry webhook: payload failed validation, dropping',
213
+ );
214
+ return {success: false, reason: 'unsupported_event'};
215
+ }
216
+
217
+ async function recordDeliveryOnly(
218
+ context: CreateSentryWebhookProcessorOptions,
219
+ deliveryId: string,
220
+ ): Promise<void> {
221
+ await context.coreDb().transaction(async (tx) => {
222
+ await context.recordDeliveryOnly({tx, provider: SENTRY_PROVIDER, deliveryId});
223
+ });
224
+ }
@@ -16,10 +16,12 @@ import {
16
16
  SentryInstallationUnclaimedError,
17
17
  SentryIntegrationProviderError,
18
18
  } from '#core/errors.js';
19
- import {verifyAndPersistUnclaimedInstallation} from '#core/install.js';
19
+ import {hashAuthorizationCode, verifySentryInstallationBestEffort} from '#core/install.js';
20
20
  import {
21
+ claimSentryInstallationVerification,
21
22
  getSentryInstallationByInstallationUuid,
22
23
  markSentryInstallationDeleted,
24
+ markSentryInstallationExchangeSucceeded,
23
25
  type SentryInstallation,
24
26
  } from '#db/installations.js';
25
27
 
@@ -101,24 +103,24 @@ export interface HandleSentryInstallationCreatedParams {
101
103
  codeHash: string;
102
104
  deliveryId: string;
103
105
  }) => Promise<SentryInstallation>;
104
- // Records the delivery for dedup without persisting an install (reconcile/no-op,
105
- // missing code, or exchange failure → record-and-drop).
106
+ // Records the delivery for dedup without persisting an install (reconcile/no-op
107
+ // or missing claim input).
106
108
  recordDelivery: (deliveryId: string) => Promise<void>;
107
109
  }
108
110
 
109
111
  /**
110
112
  * The authoritative `installation.created` path. Whichever of webhook or browser
111
113
  * arrives first exchanges the single-use code and persists a verified-unclaimed
112
- * row; the other reconciles. Idempotent on the installation uuid. The exchange
113
- * runs outside any DB transaction; a short transaction wraps only persist +
114
- * delivery record. On an exchange failure the delivery is record-and-dropped
115
- * (204) the browser may still win, or the code expired. Never logs the raw code.
114
+ * row; the other reconciles. The webhook writes a pending claim before the
115
+ * exchange and a durable checkpoint after it succeeds. Only that checkpoint can
116
+ * bypass the single-use exchange on retry. The installed transition and delivery
117
+ * record commit in one transaction. Never logs the raw code.
116
118
  */
117
119
  export async function handleSentryInstallationCreated(
118
120
  params: HandleSentryInstallationCreatedParams,
119
121
  ): Promise<void> {
120
122
  const existing = await params.getSentryInstallation({installationUuid: params.installationUuid});
121
- if (existing) {
123
+ if (existing && (existing.status === 'installed' || existing.status === 'deleted')) {
122
124
  logger().debug(
123
125
  {deliveryId: params.deliveryId, installationUuid: params.installationUuid},
124
126
  'sentry webhook: installation.created for an existing row, reconciling',
@@ -127,58 +129,78 @@ export async function handleSentryInstallationCreated(
127
129
  return;
128
130
  }
129
131
 
130
- if (!params.code) {
132
+ if (!params.code || !params.orgSlug) {
131
133
  logger().warn(
132
134
  {deliveryId: params.deliveryId, installationUuid: params.installationUuid},
133
- 'sentry webhook: installation.created without an authorization code, dropping',
135
+ 'sentry webhook: installation.created without claim input, dropping',
134
136
  );
135
137
  await params.recordDelivery(params.deliveryId);
136
138
  return;
137
139
  }
138
140
 
139
- try {
140
- await verifyAndPersistUnclaimedInstallation({
141
- sentry: params.sentry,
141
+ const codeHash = hashAuthorizationCode(params.code);
142
+ let claimed =
143
+ existing ??
144
+ (await claimSentryInstallationVerification({
142
145
  installationUuid: params.installationUuid,
143
- code: params.code,
144
146
  orgSlug: params.orgSlug,
145
- verifyInstall: params.verifyInstall,
146
- persistVerifiedUnclaimedInstallation: ({installationUuid, orgSlug, codeHash}) =>
147
- params.persistUnclaimedAndRecordDelivery({
148
- installationUuid,
149
- orgSlug,
150
- codeHash,
151
- deliveryId: params.deliveryId,
152
- }),
153
- });
154
- } catch (error) {
155
- if (error instanceof SentryIntegrationProviderError) {
156
- // The Sentry exchange (or org-slug lookup) failed, so we never durably
157
- // spent the code on a row we own. `reason` distinguishes a transient/expected
158
- // drop (access-denied: the code was already spent, e.g. the browser won) from
159
- // a likely misconfiguration (a bad client id/secret/slug fails every install)
160
- // so log-based alerting can fire on the sustained case. Never logs the raw code.
161
- logger().warn(
162
- {
163
- deliveryId: params.deliveryId,
164
- installationUuid: params.installationUuid,
165
- reason: error.reason,
166
- err: error,
167
- },
168
- 'sentry webhook: installation.created exchange failed, dropping',
169
- );
170
- } else {
171
- // The exchange succeeded (the single-use code is now spent) but the persist
172
- // transaction failed, so no row exists and Sentry will not usefully
173
- // re-deliver — the install is stranded until a reinstall mints a fresh uuid.
174
- // Log at error so alerting catches it; still record-and-drop because retrying
175
- // a spent code cannot recover the row. Never logs the raw code.
176
- logger().error(
177
- {deliveryId: params.deliveryId, installationUuid: params.installationUuid, err: error},
178
- 'sentry webhook: installation.created persisted nothing after a successful exchange, install stranded until reinstall',
179
- );
180
- }
147
+ codeHash,
148
+ }));
149
+ if (claimed.status === 'installed' || claimed.status === 'deleted') {
150
+ await params.recordDelivery(params.deliveryId);
151
+ return;
152
+ }
153
+ if (claimed.codeHash !== codeHash) {
154
+ logger().warn(
155
+ {deliveryId: params.deliveryId, installationUuid: params.installationUuid},
156
+ 'sentry webhook: installation.created does not match the pending claim, dropping',
157
+ );
181
158
  await params.recordDelivery(params.deliveryId);
159
+ return;
160
+ }
161
+
162
+ let authorization: Awaited<ReturnType<SentryApiClient['exchangeAuthorizationCode']>> | undefined;
163
+ if (claimed.status === 'pending') {
164
+ try {
165
+ authorization = await params.sentry.exchangeAuthorizationCode({
166
+ installationUuid: params.installationUuid,
167
+ code: params.code,
168
+ });
169
+ } catch (error) {
170
+ if (!(error instanceof SentryIntegrationProviderError) || error.reason !== 'access-denied') {
171
+ throw error;
172
+ }
173
+
174
+ const current = await params.getSentryInstallation({
175
+ installationUuid: params.installationUuid,
176
+ });
177
+ const concurrentAttemptSucceeded =
178
+ current?.codeHash === codeHash &&
179
+ (current.status === 'exchange-succeeded' || current.status === 'installed');
180
+ if (!current || !concurrentAttemptSucceeded) throw error;
181
+ claimed = current;
182
+ }
183
+
184
+ if (authorization) {
185
+ await markSentryInstallationExchangeSucceeded({
186
+ installationUuid: params.installationUuid,
187
+ codeHash,
188
+ });
189
+ }
190
+ }
191
+
192
+ const completed = await params.persistUnclaimedAndRecordDelivery({
193
+ installationUuid: params.installationUuid,
194
+ orgSlug: claimed.orgSlug,
195
+ codeHash,
196
+ deliveryId: params.deliveryId,
197
+ });
198
+ if (authorization && completed.status === 'installed' && params.verifyInstall) {
199
+ await verifySentryInstallationBestEffort({
200
+ sentry: params.sentry,
201
+ installationUuid: params.installationUuid,
202
+ token: authorization.token,
203
+ });
182
204
  }
183
205
  }
184
206
 
@@ -191,9 +213,8 @@ export interface HandleSentryInstallationDeletedParams {
191
213
  }
192
214
 
193
215
  // Tombstones the install and disables its connection if one exists. An unknown
194
- // uuid (never installed, or reinstall mints a fresh uuid) only records the
195
- // delivery: there is no tombstone row to write. Runs entirely in the caller's
196
- // transaction — no exchange is needed.
216
+ // uuid also gets a tombstone, so a reordered creation cannot restore it. The
217
+ // state change and delivery record share the caller's transaction.
197
218
  export async function handleSentryInstallationDeleted(
198
219
  params: HandleSentryInstallationDeletedParams,
199
220
  ): Promise<void> {
@@ -1,10 +1,14 @@
1
1
  import {randomUUID} from 'node:crypto';
2
+ import {eq} from 'drizzle-orm';
2
3
  import {SentryInstallationAlreadyLinkedError} from '#core/errors.js';
3
4
  import {db} from './db.js';
4
5
  import {
6
+ claimSentryInstallationVerification,
7
+ completeSentryInstallationVerification,
5
8
  getSentryInstallationByInstallationUuid,
6
9
  listUnclaimedSentryInstallations,
7
10
  markSentryInstallationDeleted,
11
+ markSentryInstallationExchangeSucceeded,
8
12
  persistVerifiedUnclaimedInstallation,
9
13
  pruneUnclaimedSentryInstallations,
10
14
  upsertSentryInstallation,
@@ -99,10 +103,29 @@ describe('sentry installations persistence', () => {
99
103
  expect(deleted?.connectionId).toBe(connectionId);
100
104
  });
101
105
 
102
- test('markSentryInstallationDeleted returns undefined when no row matches', async () => {
106
+ test('markSentryInstallationDeleted creates a tombstone when no row matches', async () => {
103
107
  const result = await markSentryInstallationDeleted({installationUuid: 'never-installed'});
104
108
 
105
- expect(result).toBeUndefined();
109
+ expect(result.status).toBe('deleted');
110
+ expect(result.connectionId).toBeNull();
111
+ expect(result.orgSlug).toBe('');
112
+ });
113
+
114
+ test('upsert never restores a deleted tombstone', async () => {
115
+ const installationUuid = randomUUID();
116
+ await markSentryInstallationDeleted({installationUuid});
117
+
118
+ const reconnect = upsertSentryInstallation({
119
+ connectionId: randomUUID(),
120
+ installationUuid,
121
+ orgSlug: 'acme',
122
+ status: 'installed',
123
+ });
124
+
125
+ await expect(reconnect).rejects.toBeInstanceOf(SentryInstallationAlreadyLinkedError);
126
+ expect((await getSentryInstallationByInstallationUuid(installationUuid))?.status).toBe(
127
+ 'deleted',
128
+ );
106
129
  });
107
130
 
108
131
  test('getSentryInstallationByInstallationUuid returns undefined for a miss', async () => {
@@ -125,6 +148,57 @@ describe('sentry installations persistence', () => {
125
148
  expect(persisted.codeHash).toBe('hash-1');
126
149
  });
127
150
 
151
+ test('claimSentryInstallationVerification preserves the first pending code', async () => {
152
+ const installationUuid = randomUUID();
153
+ const first = await claimSentryInstallationVerification({
154
+ installationUuid,
155
+ orgSlug: 'acme',
156
+ codeHash: 'hash-1',
157
+ });
158
+
159
+ const second = await claimSentryInstallationVerification({
160
+ installationUuid,
161
+ orgSlug: 'other',
162
+ codeHash: 'hash-2',
163
+ });
164
+
165
+ expect(first.status).toBe('pending');
166
+ expect(second.status).toBe('pending');
167
+ expect(second.orgSlug).toBe('acme');
168
+ expect(second.codeHash).toBe('hash-1');
169
+ });
170
+
171
+ test('verification advances only from a matching pending claim through the exchange checkpoint', async () => {
172
+ const installationUuid = randomUUID();
173
+ await claimSentryInstallationVerification({
174
+ installationUuid,
175
+ orgSlug: 'acme',
176
+ codeHash: 'hash-1',
177
+ });
178
+
179
+ const prematureCompletion = await completeSentryInstallationVerification({
180
+ installationUuid,
181
+ codeHash: 'hash-1',
182
+ });
183
+ const mismatch = await markSentryInstallationExchangeSucceeded({
184
+ installationUuid,
185
+ codeHash: 'hash-2',
186
+ });
187
+ const exchanged = await markSentryInstallationExchangeSucceeded({
188
+ installationUuid,
189
+ codeHash: 'hash-1',
190
+ });
191
+ const completed = await completeSentryInstallationVerification({
192
+ installationUuid,
193
+ codeHash: 'hash-1',
194
+ });
195
+
196
+ expect(prematureCompletion).toBeUndefined();
197
+ expect(mismatch).toBeUndefined();
198
+ expect(exchanged?.status).toBe('exchange-succeeded');
199
+ expect(completed?.status).toBe('installed');
200
+ });
201
+
128
202
  test('persistVerifiedUnclaimedInstallation never clobbers a claimed connection or downgrades status', async () => {
129
203
  const installationUuid = randomUUID();
130
204
  const connectionId = randomUUID();
@@ -149,24 +223,42 @@ describe('sentry installations persistence', () => {
149
223
  expect(reconciled.codeHash).toBe('webhook-hash');
150
224
  });
151
225
 
152
- test('listUnclaimedSentryInstallations returns only rows with no connection', async () => {
226
+ test('listUnclaimedSentryInstallations returns every non-deleted row with no connection', async () => {
153
227
  const claimedUuid = randomUUID();
154
- const unclaimedUuid = randomUUID();
228
+ const pendingUuid = randomUUID();
229
+ const exchangedUuid = randomUUID();
230
+ const installedUuid = randomUUID();
155
231
  await upsertSentryInstallation({
156
232
  connectionId: randomUUID(),
157
233
  installationUuid: claimedUuid,
158
234
  orgSlug: 'acme',
159
235
  status: 'installed',
160
236
  });
237
+ await claimSentryInstallationVerification({
238
+ installationUuid: pendingUuid,
239
+ orgSlug: 'acme',
240
+ codeHash: 'pending-hash',
241
+ });
242
+ await claimSentryInstallationVerification({
243
+ installationUuid: exchangedUuid,
244
+ orgSlug: 'acme',
245
+ codeHash: 'exchanged-hash',
246
+ });
247
+ await markSentryInstallationExchangeSucceeded({
248
+ installationUuid: exchangedUuid,
249
+ codeHash: 'exchanged-hash',
250
+ });
161
251
  await persistVerifiedUnclaimedInstallation({
162
- installationUuid: unclaimedUuid,
252
+ installationUuid: installedUuid,
163
253
  orgSlug: 'acme',
164
254
  codeHash: 'hash',
165
255
  });
166
256
 
167
257
  const unclaimed = await listUnclaimedSentryInstallations();
168
258
 
169
- expect(unclaimed.map((row) => row.installationUuid)).toEqual([unclaimedUuid]);
259
+ expect(unclaimed.map((row) => row.installationUuid).sort()).toEqual(
260
+ [pendingUuid, exchangedUuid, installedUuid].sort(),
261
+ );
170
262
  });
171
263
 
172
264
  test('listUnclaimedSentryInstallations filters by age when olderThan is given', async () => {
@@ -184,27 +276,81 @@ describe('sentry installations persistence', () => {
184
276
  expect(await listUnclaimedSentryInstallations({olderThan: past})).toHaveLength(0);
185
277
  });
186
278
 
187
- test('pruneUnclaimedSentryInstallations tombstones stale unclaimed rows and leaves fresh ones', async () => {
188
- const staleUuid = randomUUID();
189
- await persistVerifiedUnclaimedInstallation({
190
- installationUuid: staleUuid,
279
+ test('pruneUnclaimedSentryInstallations releases stale pending claims', async () => {
280
+ const installationUuid = randomUUID();
281
+ await claimSentryInstallationVerification({
282
+ installationUuid,
191
283
  orgSlug: 'acme',
192
- codeHash: 'hash',
284
+ codeHash: 'stale-hash',
193
285
  });
194
286
 
195
- const youngResult = await pruneUnclaimedSentryInstallations({
196
- olderThan: new Date(Date.now() - 60_000),
287
+ const result = await pruneUnclaimedSentryInstallations({
288
+ olderThan: new Date(Date.now() + 60_000),
289
+ });
290
+ const reclaimed = await claimSentryInstallationVerification({
291
+ installationUuid,
292
+ orgSlug: 'acme',
293
+ codeHash: 'fresh-hash',
197
294
  });
198
- expect(youngResult.tombstoned).toBe(0);
199
295
 
200
- const staleResult = await pruneUnclaimedSentryInstallations({
296
+ expect(result).toEqual({releasedPending: 1, tombstoned: 0});
297
+ expect(reclaimed.status).toBe('pending');
298
+ expect(reclaimed.codeHash).toBe('fresh-hash');
299
+ });
300
+
301
+ test('pruneUnclaimedSentryInstallations tombstones stale exchanged and installed rows', async () => {
302
+ const exchangedUuid = randomUUID();
303
+ const installedUuid = randomUUID();
304
+ await claimSentryInstallationVerification({
305
+ installationUuid: exchangedUuid,
306
+ orgSlug: 'acme',
307
+ codeHash: 'exchanged-hash',
308
+ });
309
+ await markSentryInstallationExchangeSucceeded({
310
+ installationUuid: exchangedUuid,
311
+ codeHash: 'exchanged-hash',
312
+ });
313
+ await persistVerifiedUnclaimedInstallation({
314
+ installationUuid: installedUuid,
315
+ orgSlug: 'acme',
316
+ codeHash: 'hash',
317
+ });
318
+
319
+ const result = await pruneUnclaimedSentryInstallations({
201
320
  olderThan: new Date(Date.now() + 60_000),
202
321
  });
203
- expect(staleResult.tombstoned).toBe(1);
204
- expect((await getSentryInstallationByInstallationUuid(staleUuid))?.status).toBe('deleted');
322
+
323
+ expect(result).toEqual({releasedPending: 0, tombstoned: 2});
324
+ expect((await getSentryInstallationByInstallationUuid(exchangedUuid))?.status).toBe('deleted');
325
+ expect((await getSentryInstallationByInstallationUuid(installedUuid))?.status).toBe('deleted');
205
326
  expect(await listUnclaimedSentryInstallations()).toHaveLength(0);
206
327
  });
207
328
 
329
+ test('completing an old claim starts a fresh installed retention window', async () => {
330
+ const installationUuid = randomUUID();
331
+ const oldTimestamp = new Date(Date.now() - 120_000);
332
+ await claimSentryInstallationVerification({
333
+ installationUuid,
334
+ orgSlug: 'acme',
335
+ codeHash: 'hash',
336
+ });
337
+ await db()
338
+ .update(sentryInstallations)
339
+ .set({createdAt: oldTimestamp, updatedAt: oldTimestamp})
340
+ .where(eq(sentryInstallations.installationUuid, installationUuid));
341
+ await markSentryInstallationExchangeSucceeded({installationUuid, codeHash: 'hash'});
342
+ await completeSentryInstallationVerification({installationUuid, codeHash: 'hash'});
343
+
344
+ const result = await pruneUnclaimedSentryInstallations({
345
+ olderThan: new Date(Date.now() - 60_000),
346
+ });
347
+
348
+ expect(result).toEqual({releasedPending: 0, tombstoned: 0});
349
+ expect((await getSentryInstallationByInstallationUuid(installationUuid))?.status).toBe(
350
+ 'installed',
351
+ );
352
+ });
353
+
208
354
  test('pruneUnclaimedSentryInstallations never tombstones a claimed install', async () => {
209
355
  const claimedUuid = randomUUID();
210
356
  await upsertSentryInstallation({