@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.
- package/.turbo/turbo-build.log +12 -15
- package/CHANGELOG.md +42 -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 +17169 -3306
- 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 +9 -21
- 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
package/src/db/installations.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
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
|
|
84
|
-
* `
|
|
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)
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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
|
-
|
|
207
|
+
inArray(sentryInstallations.status, ['pending', 'exchange-succeeded', 'installed']),
|
|
130
208
|
];
|
|
131
209
|
if (params.olderThan) {
|
|
132
|
-
conditions.push(lt(sentryInstallations.
|
|
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
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
|
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
|
-
|
|
189
|
-
lt(sentryInstallations.
|
|
276
|
+
inArray(sentryInstallations.status, ['exchange-succeeded', 'installed']),
|
|
277
|
+
lt(sentryInstallations.updatedAt, params.olderThan),
|
|
190
278
|
),
|
|
191
279
|
);
|
|
192
|
-
return {
|
|
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
|
|
289
|
+
): Promise<SentryInstallation> {
|
|
199
290
|
const executor = (options.tx ?? db()) as SentryDb | SentryTx;
|
|
200
291
|
const [row] = await executor
|
|
201
|
-
.
|
|
202
|
-
.
|
|
203
|
-
|
|
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)
|
|
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
|
-
//
|
|
12
|
-
//
|
|
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)
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
749
|
-
expect(await readInstallation(installationUuid)).
|
|
750
|
-
expect(recordDeliveryOnly).
|
|
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 {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
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
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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<{
|
|
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
|
}
|