@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.
- package/.turbo/turbo-build.log +7 -7
- package/CHANGELOG.md +49 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -5
- package/dist/config.js.map +1 -1
- package/dist/core/install.d.ts +6 -0
- package/dist/core/install.d.ts.map +1 -1
- package/dist/core/install.js +8 -5
- package/dist/core/install.js.map +1 -1
- package/dist/core/signature.d.ts +1 -1
- package/dist/core/signature.d.ts.map +1 -1
- package/dist/core/signature.js.map +1 -1
- package/dist/core/webhook-processor.d.ts +16 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +198 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/core/webhook.d.ts +4 -4
- package/dist/core/webhook.d.ts.map +1 -1
- package/dist/core/webhook.js +63 -49
- package/dist/core/webhook.js.map +1 -1
- package/dist/db/db.d.ts +8 -4
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +35 -13
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +89 -31
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +5 -3
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +6 -6
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +4 -1
- package/dist/presentation/routes/webhooks.d.ts.map +1 -1
- package/dist/presentation/routes/webhooks.js +69 -43
- package/dist/presentation/routes/webhooks.js.map +1 -1
- package/dist/temporal/activities/prune-unclaimed-installations.d.ts +1 -0
- package/dist/temporal/activities/prune-unclaimed-installations.d.ts.map +1 -1
- package/dist/temporal/activities/prune-unclaimed-installations.js.map +1 -1
- package/dist/temporal/workflows/index.bundle.js +7 -2
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts.map +1 -1
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.js +6 -1
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +20 -28
- package/src/config.ts +4 -5
- package/src/core/install.ts +21 -7
- package/src/core/signature.ts +1 -1
- package/src/core/webhook-processor.test.ts +304 -0
- package/src/core/webhook-processor.ts +224 -0
- package/src/core/webhook.ts +74 -53
- package/src/db/installations.test.ts +163 -17
- package/src/db/installations.ts +136 -35
- package/src/db/schema/installations.ts +7 -7
- package/src/index.test.ts +4 -1
- package/src/index.ts +16 -0
- package/src/presentation/routes/webhooks.test.ts +6 -5
- package/src/presentation/routes/webhooks.ts +88 -29
- package/src/temporal/activities/prune-unclaimed-installations.test.ts +24 -2
- package/src/temporal/activities/prune-unclaimed-installations.ts +4 -1
- package/src/temporal/workflows/prune-unclaimed-installations-cron.ts +4 -1
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/presentation/routes/installation-handler.d.ts +0 -9
- package/dist/presentation/routes/installation-handler.d.ts.map +0 -1
- package/dist/presentation/routes/installation-handler.js +0 -70
- package/dist/presentation/routes/installation-handler.js.map +0 -1
- package/dist/presentation/routes/issue-handler.d.ts +0 -9
- package/dist/presentation/routes/issue-handler.d.ts.map +0 -1
- package/dist/presentation/routes/issue-handler.js +0 -50
- package/dist/presentation/routes/issue-handler.js.map +0 -1
- package/dist/presentation/routes/webhook-delivery.d.ts +0 -28
- package/dist/presentation/routes/webhook-delivery.d.ts.map +0 -1
- package/dist/presentation/routes/webhook-delivery.js +0 -56
- package/dist/presentation/routes/webhook-delivery.js.map +0 -1
- package/dist/presentation/routes/webhook-request.d.ts +0 -10
- package/dist/presentation/routes/webhook-request.d.ts.map +0 -1
- package/dist/presentation/routes/webhook-request.js +0 -56
- package/dist/presentation/routes/webhook-request.js.map +0 -1
- package/src/presentation/routes/installation-handler.ts +0 -76
- package/src/presentation/routes/issue-handler.ts +0 -52
- package/src/presentation/routes/webhook-delivery.ts +0 -61
- 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
|
+
}
|
package/src/core/webhook.ts
CHANGED
|
@@ -16,10 +16,12 @@ import {
|
|
|
16
16
|
SentryInstallationUnclaimedError,
|
|
17
17
|
SentryIntegrationProviderError,
|
|
18
18
|
} from '#core/errors.js';
|
|
19
|
-
import {
|
|
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
|
-
//
|
|
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.
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
195
|
-
//
|
|
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
|
|
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).
|
|
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
|
|
226
|
+
test('listUnclaimedSentryInstallations returns every non-deleted row with no connection', async () => {
|
|
153
227
|
const claimedUuid = randomUUID();
|
|
154
|
-
const
|
|
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:
|
|
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(
|
|
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
|
|
188
|
-
const
|
|
189
|
-
await
|
|
190
|
-
installationUuid
|
|
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
|
|
196
|
-
olderThan: new Date(Date.now()
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
expect(
|
|
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({
|