@shipfox/api-integration-sentry 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +192 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/dist/api/client.d.ts +24 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +101 -0
- package/dist/api/client.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +32 -0
- package/dist/config.js.map +1 -0
- package/dist/core/errors.d.ts +57 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +67 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/install.d.ts +63 -0
- package/dist/core/install.d.ts.map +1 -0
- package/dist/core/install.js +209 -0
- package/dist/core/install.js.map +1 -0
- package/dist/core/signature.d.ts +7 -0
- package/dist/core/signature.d.ts.map +1 -0
- package/dist/core/signature.js +6 -0
- package/dist/core/signature.js.map +1 -0
- package/dist/core/webhook.d.ts +49 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +175 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +327 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +18 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/installations.d.ts +74 -0
- package/dist/db/installations.d.ts.map +1 -0
- package/dist/db/installations.js +120 -0
- package/dist/db/installations.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +5 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/installations.d.ts +165 -0
- package/dist/db/schema/installations.d.ts.map +1 -0
- package/dist/db/schema/installations.js +42 -0
- package/dist/db/schema/installations.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/integrations.d.ts +15 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +9 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +51 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/install.d.ts +16 -0
- package/dist/presentation/routes/install.d.ts.map +1 -0
- package/dist/presentation/routes/install.js +76 -0
- package/dist/presentation/routes/install.js.map +1 -0
- package/dist/presentation/routes/installation-handler.d.ts +9 -0
- package/dist/presentation/routes/installation-handler.d.ts.map +1 -0
- package/dist/presentation/routes/installation-handler.js +70 -0
- package/dist/presentation/routes/installation-handler.js.map +1 -0
- package/dist/presentation/routes/issue-handler.d.ts +9 -0
- package/dist/presentation/routes/issue-handler.d.ts.map +1 -0
- package/dist/presentation/routes/issue-handler.js +50 -0
- package/dist/presentation/routes/issue-handler.js.map +1 -0
- package/dist/presentation/routes/webhook-context.d.ts +20 -0
- package/dist/presentation/routes/webhook-context.d.ts.map +1 -0
- package/dist/presentation/routes/webhook-context.js +10 -0
- package/dist/presentation/routes/webhook-context.js.map +1 -0
- package/dist/presentation/routes/webhook-delivery.d.ts +28 -0
- package/dist/presentation/routes/webhook-delivery.d.ts.map +1 -0
- package/dist/presentation/routes/webhook-delivery.js +56 -0
- package/dist/presentation/routes/webhook-delivery.js.map +1 -0
- package/dist/presentation/routes/webhook-request.d.ts +10 -0
- package/dist/presentation/routes/webhook-request.d.ts.map +1 -0
- package/dist/presentation/routes/webhook-request.js +56 -0
- package/dist/presentation/routes/webhook-request.js.map +1 -0
- package/dist/presentation/routes/webhooks.d.ts +5 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +71 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +5 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +8 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/prune-unclaimed-installations.d.ts +4 -0
- package/dist/temporal/activities/prune-unclaimed-installations.d.ts.map +1 -0
- package/dist/temporal/activities/prune-unclaimed-installations.js +11 -0
- package/dist/temporal/activities/prune-unclaimed-installations.js.map +1 -0
- package/dist/temporal/constants.d.ts +2 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +3 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts +2 -0
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.js +14 -0
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +14 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +70 -0
- package/src/api/client.test.ts +147 -0
- package/src/api/client.ts +147 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +35 -0
- package/src/connection-external-url.test.ts +79 -0
- package/src/core/errors.ts +81 -0
- package/src/core/install.test.ts +343 -0
- package/src/core/install.ts +305 -0
- package/src/core/signature.test.ts +50 -0
- package/src/core/signature.ts +11 -0
- package/src/core/webhook.test.ts +118 -0
- package/src/core/webhook.ts +246 -0
- package/src/db/db.ts +18 -0
- package/src/db/installations.test.ts +224 -0
- package/src/db/installations.ts +207 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/installations.ts +46 -0
- package/src/index.test.ts +18 -0
- package/src/index.ts +127 -0
- package/src/presentation/dto/integrations.ts +9 -0
- package/src/presentation/routes/errors.ts +48 -0
- package/src/presentation/routes/install.test.ts +325 -0
- package/src/presentation/routes/install.ts +102 -0
- package/src/presentation/routes/installation-handler.ts +76 -0
- package/src/presentation/routes/issue-handler.ts +52 -0
- package/src/presentation/routes/webhook-context.ts +25 -0
- package/src/presentation/routes/webhook-delivery.ts +61 -0
- package/src/presentation/routes/webhook-request.ts +66 -0
- package/src/presentation/routes/webhooks.test.ts +773 -0
- package/src/presentation/routes/webhooks.ts +59 -0
- package/src/temporal/activities/index.ts +7 -0
- package/src/temporal/activities/prune-unclaimed-installations.test.ts +52 -0
- package/src/temporal/activities/prune-unclaimed-installations.ts +11 -0
- package/src/temporal/constants.ts +1 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/prune-unclaimed-installations-cron.ts +15 -0
- package/test/env.ts +11 -0
- package/test/factories/index.ts +9 -0
- package/test/factories/sentry-installation-webhook.ts +35 -0
- package/test/factories/sentry-installation.ts +44 -0
- package/test/factories/sentry-issue-webhook.ts +34 -0
- package/test/globalSetup.ts +14 -0
- package/test/index.ts +9 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +9 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import {and, eq, isNull, lt, sql} from 'drizzle-orm';
|
|
2
|
+
import {SentryInstallationAlreadyLinkedError} from '#core/errors.js';
|
|
3
|
+
import {db} from './db.js';
|
|
4
|
+
import {sentryInstallations, toSentryInstallation} from './schema/installations.js';
|
|
5
|
+
|
|
6
|
+
export type SentryInstallationStatus = 'installed' | 'deleted';
|
|
7
|
+
|
|
8
|
+
export interface SentryInstallation {
|
|
9
|
+
id: string;
|
|
10
|
+
connectionId: string | null;
|
|
11
|
+
installationUuid: string;
|
|
12
|
+
orgSlug: string;
|
|
13
|
+
status: string;
|
|
14
|
+
codeHash: string | null;
|
|
15
|
+
installerUserId: string | null;
|
|
16
|
+
createdAt: Date;
|
|
17
|
+
updatedAt: Date;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UpsertSentryInstallationParams {
|
|
21
|
+
connectionId: string;
|
|
22
|
+
installationUuid: string;
|
|
23
|
+
orgSlug: string;
|
|
24
|
+
status: SentryInstallationStatus;
|
|
25
|
+
codeHash?: string | null | undefined;
|
|
26
|
+
installerUserId?: string | null | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PersistVerifiedUnclaimedInstallationParams {
|
|
30
|
+
installationUuid: string;
|
|
31
|
+
orgSlug: string;
|
|
32
|
+
codeHash: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SentryDb = ReturnType<typeof db>;
|
|
36
|
+
type SentryTx = Parameters<Parameters<SentryDb['transaction']>[0]>[0];
|
|
37
|
+
|
|
38
|
+
export async function upsertSentryInstallation(
|
|
39
|
+
params: UpsertSentryInstallationParams,
|
|
40
|
+
options: {tx?: unknown} = {},
|
|
41
|
+
): Promise<SentryInstallation> {
|
|
42
|
+
const executor = (options.tx ?? db()) as SentryDb | SentryTx;
|
|
43
|
+
const now = new Date();
|
|
44
|
+
const [row] = await executor
|
|
45
|
+
.insert(sentryInstallations)
|
|
46
|
+
.values({
|
|
47
|
+
connectionId: params.connectionId,
|
|
48
|
+
installationUuid: params.installationUuid,
|
|
49
|
+
orgSlug: params.orgSlug,
|
|
50
|
+
status: params.status,
|
|
51
|
+
codeHash: params.codeHash ?? null,
|
|
52
|
+
installerUserId: params.installerUserId ?? null,
|
|
53
|
+
})
|
|
54
|
+
.onConflictDoUpdate({
|
|
55
|
+
target: sentryInstallations.installationUuid,
|
|
56
|
+
// TOCTOU guard: allow the update only when this installation is unclaimed
|
|
57
|
+
// (connection_id IS NULL, the first claim of a verified-unclaimed row) or
|
|
58
|
+
// already owned by this same connection (idempotent reconnect). A concurrent
|
|
59
|
+
// claim of the same installation to a different workspace commits its own
|
|
60
|
+
// connection_id first; this losing transaction then sees a different non-null
|
|
61
|
+
// connection_id, the predicate is false, Postgres updates nothing, and the
|
|
62
|
+
// empty RETURNING below rolls it back instead of silently repointing the
|
|
63
|
+
// installation (cross-tenant event misroute).
|
|
64
|
+
setWhere: sql`${sentryInstallations.connectionId} is null or ${sentryInstallations.connectionId} = ${params.connectionId}`,
|
|
65
|
+
set: {
|
|
66
|
+
connectionId: params.connectionId,
|
|
67
|
+
orgSlug: params.orgSlug,
|
|
68
|
+
status: params.status,
|
|
69
|
+
codeHash: params.codeHash ?? null,
|
|
70
|
+
installerUserId: params.installerUserId ?? null,
|
|
71
|
+
updatedAt: now,
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
.returning();
|
|
75
|
+
|
|
76
|
+
if (!row) throw new SentryInstallationAlreadyLinkedError(params.installationUuid);
|
|
77
|
+
return toSentryInstallation(row);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Inserts the verified-but-unclaimed install half (`connection_id = NULL`,
|
|
82
|
+
* `status = 'installed'`) after a successful code exchange. Idempotent on the
|
|
83
|
+
* installation uuid: a conflicting row refreshes only `code_hash`/`org_slug`/
|
|
84
|
+
* `updated_at`, so a webhook that lands after a claim never clobbers the
|
|
85
|
+
* `connection_id` it set nor downgrades a `deleted` tombstone back to installed.
|
|
86
|
+
*/
|
|
87
|
+
export async function persistVerifiedUnclaimedInstallation(
|
|
88
|
+
params: PersistVerifiedUnclaimedInstallationParams,
|
|
89
|
+
options: {tx?: unknown} = {},
|
|
90
|
+
): Promise<SentryInstallation> {
|
|
91
|
+
const executor = (options.tx ?? db()) as SentryDb | SentryTx;
|
|
92
|
+
const now = new Date();
|
|
93
|
+
const [row] = await executor
|
|
94
|
+
.insert(sentryInstallations)
|
|
95
|
+
.values({
|
|
96
|
+
connectionId: null,
|
|
97
|
+
installationUuid: params.installationUuid,
|
|
98
|
+
orgSlug: params.orgSlug,
|
|
99
|
+
status: 'installed',
|
|
100
|
+
codeHash: params.codeHash,
|
|
101
|
+
})
|
|
102
|
+
.onConflictDoUpdate({
|
|
103
|
+
target: sentryInstallations.installationUuid,
|
|
104
|
+
set: {
|
|
105
|
+
orgSlug: params.orgSlug,
|
|
106
|
+
codeHash: params.codeHash,
|
|
107
|
+
updatedAt: now,
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
.returning();
|
|
111
|
+
|
|
112
|
+
if (!row) throw new Error('Sentry installation persist returned no rows');
|
|
113
|
+
return toSentryInstallation(row);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Verified installs no user has claimed yet (`connection_id IS NULL`,
|
|
118
|
+
* `status='installed'`). Tombstoned rows are excluded so the TTL cron stays
|
|
119
|
+
* idempotent and the unclaimed metric stays accurate. Pass `olderThan` to scope
|
|
120
|
+
* to stale rows for the cron.
|
|
121
|
+
*/
|
|
122
|
+
export async function listUnclaimedSentryInstallations(
|
|
123
|
+
params: {olderThan?: Date} = {},
|
|
124
|
+
options: {tx?: unknown} = {},
|
|
125
|
+
): Promise<SentryInstallation[]> {
|
|
126
|
+
const executor = (options.tx ?? db()) as SentryDb | SentryTx;
|
|
127
|
+
const conditions = [
|
|
128
|
+
isNull(sentryInstallations.connectionId),
|
|
129
|
+
eq(sentryInstallations.status, 'installed'),
|
|
130
|
+
];
|
|
131
|
+
if (params.olderThan) {
|
|
132
|
+
conditions.push(lt(sentryInstallations.createdAt, params.olderThan));
|
|
133
|
+
}
|
|
134
|
+
const rows = await executor
|
|
135
|
+
.select()
|
|
136
|
+
.from(sentryInstallations)
|
|
137
|
+
.where(and(...conditions));
|
|
138
|
+
return rows.map(toSentryInstallation);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function getSentryInstallationByInstallationUuid(
|
|
142
|
+
installationUuid: string,
|
|
143
|
+
options: {tx?: unknown} = {},
|
|
144
|
+
): Promise<SentryInstallation | undefined> {
|
|
145
|
+
const executor = (options.tx ?? db()) as SentryDb | SentryTx;
|
|
146
|
+
const rows = await executor
|
|
147
|
+
.select()
|
|
148
|
+
.from(sentryInstallations)
|
|
149
|
+
.where(eq(sentryInstallations.installationUuid, installationUuid))
|
|
150
|
+
.limit(1);
|
|
151
|
+
const row = rows[0];
|
|
152
|
+
if (!row) return undefined;
|
|
153
|
+
return toSentryInstallation(row);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function getSentryInstallationByConnectionId(
|
|
157
|
+
connectionId: string,
|
|
158
|
+
options: {tx?: unknown} = {},
|
|
159
|
+
): Promise<SentryInstallation | undefined> {
|
|
160
|
+
const executor = (options.tx ?? db()) as SentryDb | SentryTx;
|
|
161
|
+
const rows = await executor
|
|
162
|
+
.select()
|
|
163
|
+
.from(sentryInstallations)
|
|
164
|
+
.where(eq(sentryInstallations.connectionId, connectionId))
|
|
165
|
+
.limit(1);
|
|
166
|
+
const row = rows[0];
|
|
167
|
+
if (!row) return undefined;
|
|
168
|
+
return toSentryInstallation(row);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Tombstones verified-unclaimed installs older than `olderThan` (the TTL cleanup
|
|
173
|
+
* cron). Bounds a never-claimed install rather than leaving it pending forever.
|
|
174
|
+
* Returns how many rows were tombstoned. We hold no Sentry token for these rows
|
|
175
|
+
* (tokens are never persisted), so the Sentry-side uninstall is out of scope here.
|
|
176
|
+
*/
|
|
177
|
+
export async function pruneUnclaimedSentryInstallations(
|
|
178
|
+
params: {olderThan: Date},
|
|
179
|
+
options: {tx?: unknown} = {},
|
|
180
|
+
): Promise<{tombstoned: number}> {
|
|
181
|
+
const executor = (options.tx ?? db()) as SentryDb | SentryTx;
|
|
182
|
+
const result = await executor
|
|
183
|
+
.update(sentryInstallations)
|
|
184
|
+
.set({status: 'deleted', updatedAt: new Date()})
|
|
185
|
+
.where(
|
|
186
|
+
and(
|
|
187
|
+
isNull(sentryInstallations.connectionId),
|
|
188
|
+
eq(sentryInstallations.status, 'installed'),
|
|
189
|
+
lt(sentryInstallations.createdAt, params.olderThan),
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
return {tombstoned: result.rowCount ?? 0};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function markSentryInstallationDeleted(
|
|
196
|
+
params: {installationUuid: string},
|
|
197
|
+
options: {tx?: unknown} = {},
|
|
198
|
+
): Promise<SentryInstallation | undefined> {
|
|
199
|
+
const executor = (options.tx ?? db()) as SentryDb | SentryTx;
|
|
200
|
+
const [row] = await executor
|
|
201
|
+
.update(sentryInstallations)
|
|
202
|
+
.set({status: 'deleted', updatedAt: new Date()})
|
|
203
|
+
.where(eq(sentryInstallations.installationUuid, params.installationUuid))
|
|
204
|
+
.returning();
|
|
205
|
+
if (!row) return undefined;
|
|
206
|
+
return toSentryInstallation(row);
|
|
207
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
3
|
+
import type {SentryInstallation} from '#db/installations.js';
|
|
4
|
+
import {pgTable} from './common.js';
|
|
5
|
+
|
|
6
|
+
export const sentryInstallations = pgTable(
|
|
7
|
+
'installations',
|
|
8
|
+
{
|
|
9
|
+
id: uuidv7PrimaryKey(),
|
|
10
|
+
// Null until a logged-in user claims the verified install into a workspace.
|
|
11
|
+
// An unclaimed install (`connection_id IS NULL`, `status='installed'`) is
|
|
12
|
+
// persisted by the authoritative webhook before any browser claim arrives.
|
|
13
|
+
connectionId: uuid('connection_id'),
|
|
14
|
+
installationUuid: text('installation_uuid').notNull(),
|
|
15
|
+
orgSlug: text('org_slug').notNull(),
|
|
16
|
+
status: text('status').notNull(),
|
|
17
|
+
// sha256(authorization code) of the exchange that verified this install.
|
|
18
|
+
// The claim presents the code and we match the hash, so a bare uuid alone
|
|
19
|
+
// cannot bind the install (IDOR guard) without storing a live credential.
|
|
20
|
+
codeHash: text('code_hash'),
|
|
21
|
+
installerUserId: uuid('installer_user_id'),
|
|
22
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
23
|
+
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
24
|
+
},
|
|
25
|
+
(table) => [
|
|
26
|
+
uniqueIndex('integrations_sentry_installations_connection_unique').on(table.connectionId),
|
|
27
|
+
uniqueIndex('integrations_sentry_installations_installation_unique').on(table.installationUuid),
|
|
28
|
+
],
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export type SentryInstallationDb = typeof sentryInstallations.$inferSelect;
|
|
32
|
+
export type SentryInstallationCreateDb = typeof sentryInstallations.$inferInsert;
|
|
33
|
+
|
|
34
|
+
export function toSentryInstallation(row: SentryInstallationDb): SentryInstallation {
|
|
35
|
+
return {
|
|
36
|
+
id: row.id,
|
|
37
|
+
connectionId: row.connectionId,
|
|
38
|
+
installationUuid: row.installationUuid,
|
|
39
|
+
orgSlug: row.orgSlug,
|
|
40
|
+
status: row.status,
|
|
41
|
+
codeHash: row.codeHash,
|
|
42
|
+
installerUserId: row.installerUserId,
|
|
43
|
+
createdAt: row.createdAt,
|
|
44
|
+
updatedAt: row.updatedAt,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {createSentryMaintenanceWorker} from './index.js';
|
|
2
|
+
|
|
3
|
+
describe('createSentryMaintenanceWorker', () => {
|
|
4
|
+
test('describes the sentry maintenance worker', () => {
|
|
5
|
+
const worker = createSentryMaintenanceWorker();
|
|
6
|
+
|
|
7
|
+
expect(worker.taskQueue).toBe('integrations-sentry-maintenance');
|
|
8
|
+
expect(worker.workflowsPath.endsWith('dist/temporal/workflows/index.js')).toBe(true);
|
|
9
|
+
expect(Object.keys(worker.activities())).toContain('pruneUnclaimedSentryInstallationsActivity');
|
|
10
|
+
expect(worker.workflows).toEqual([
|
|
11
|
+
{
|
|
12
|
+
name: 'pruneUnclaimedSentryInstallationsCron',
|
|
13
|
+
id: 'sentry-prune-unclaimed-installations',
|
|
14
|
+
cronSchedule: '0 4 * * *',
|
|
15
|
+
},
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {dirname, resolve} from 'node:path';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import type {
|
|
4
|
+
GetIntegrationConnectionByIdFn,
|
|
5
|
+
PublishIntegrationEventReceivedFn,
|
|
6
|
+
RecordDeliveryOnlyFn,
|
|
7
|
+
UpdateIntegrationConnectionLifecycleStatusFn,
|
|
8
|
+
} from '@shipfox/api-integration-core-dto';
|
|
9
|
+
import type {ModuleWorker} from '@shipfox/node-module';
|
|
10
|
+
import type {NodePgDatabase} from 'drizzle-orm/node-postgres';
|
|
11
|
+
import {createSentryApiClient, type SentryApiClient} from '#api/client.js';
|
|
12
|
+
import {closeDb, db} from '#db/db.js';
|
|
13
|
+
import {
|
|
14
|
+
getSentryInstallationByConnectionId,
|
|
15
|
+
persistVerifiedUnclaimedInstallation,
|
|
16
|
+
} from '#db/installations.js';
|
|
17
|
+
import {migrationsPath} from '#db/migrations.js';
|
|
18
|
+
import {
|
|
19
|
+
type CreateSentryIntegrationRoutesOptions,
|
|
20
|
+
createSentryIntegrationRoutes,
|
|
21
|
+
} from '#presentation/routes/install.js';
|
|
22
|
+
import {createSentryWebhookRoutes} from '#presentation/routes/webhooks.js';
|
|
23
|
+
import {createSentryMaintenanceActivities} from '#temporal/activities/index.js';
|
|
24
|
+
import {SENTRY_MAINTENANCE_TASK_QUEUE} from '#temporal/constants.js';
|
|
25
|
+
|
|
26
|
+
export type {SentryApiClient} from '#api/client.js';
|
|
27
|
+
export {
|
|
28
|
+
SentryClaimProofMismatchError,
|
|
29
|
+
SentryInstallationAlreadyLinkedError,
|
|
30
|
+
SentryInstallationDeletedError,
|
|
31
|
+
SentryIntegrationProviderError,
|
|
32
|
+
SentryVerificationInProgressError,
|
|
33
|
+
} from '#core/errors.js';
|
|
34
|
+
export type {
|
|
35
|
+
ConnectSentryInstallationInput,
|
|
36
|
+
VerifyAndPersistUnclaimedInstallationParams,
|
|
37
|
+
} from '#core/install.js';
|
|
38
|
+
export {
|
|
39
|
+
handleSentryConnect,
|
|
40
|
+
hashAuthorizationCode,
|
|
41
|
+
verifyAndPersistUnclaimedInstallation,
|
|
42
|
+
} from '#core/install.js';
|
|
43
|
+
export {
|
|
44
|
+
handleSentryInstallationCreated,
|
|
45
|
+
handleSentryInstallationDeleted,
|
|
46
|
+
handleSentryIssueEvent,
|
|
47
|
+
} from '#core/webhook.js';
|
|
48
|
+
export type {
|
|
49
|
+
PersistVerifiedUnclaimedInstallationParams,
|
|
50
|
+
SentryInstallation,
|
|
51
|
+
SentryInstallationStatus,
|
|
52
|
+
UpsertSentryInstallationParams,
|
|
53
|
+
} from '#db/installations.js';
|
|
54
|
+
export {
|
|
55
|
+
getSentryInstallationByInstallationUuid,
|
|
56
|
+
persistVerifiedUnclaimedInstallation,
|
|
57
|
+
upsertSentryInstallation,
|
|
58
|
+
} from '#db/installations.js';
|
|
59
|
+
export {closeDb, db, migrationsPath};
|
|
60
|
+
|
|
61
|
+
export interface CreateSentryIntegrationProviderOptions
|
|
62
|
+
extends Omit<
|
|
63
|
+
CreateSentryIntegrationRoutesOptions,
|
|
64
|
+
'sentry' | 'persistVerifiedUnclaimedInstallation'
|
|
65
|
+
> {
|
|
66
|
+
sentry?: SentryApiClient | undefined;
|
|
67
|
+
coreDb: () => NodePgDatabase<Record<string, unknown>>;
|
|
68
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
69
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
70
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
71
|
+
updateConnectionLifecycleStatus: UpdateIntegrationConnectionLifecycleStatusFn;
|
|
72
|
+
getSentryInstallationByConnectionId?: typeof getSentryInstallationByConnectionId | undefined;
|
|
73
|
+
persistVerifiedUnclaimedInstallation?: typeof persistVerifiedUnclaimedInstallation | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createSentryIntegrationProvider(options: CreateSentryIntegrationProviderOptions) {
|
|
77
|
+
const sentry = options.sentry ?? createSentryApiClient();
|
|
78
|
+
const getInstallationByConnectionId =
|
|
79
|
+
options.getSentryInstallationByConnectionId ?? getSentryInstallationByConnectionId;
|
|
80
|
+
const persistUnclaimed =
|
|
81
|
+
options.persistVerifiedUnclaimedInstallation ?? persistVerifiedUnclaimedInstallation;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
provider: 'sentry' as const,
|
|
85
|
+
displayName: 'Sentry',
|
|
86
|
+
async connectionExternalUrl(connection: {id: string}): Promise<string | undefined> {
|
|
87
|
+
const installation = await getInstallationByConnectionId(connection.id);
|
|
88
|
+
if (!installation?.orgSlug) return undefined;
|
|
89
|
+
return `https://sentry.io/organizations/${encodeURIComponent(installation.orgSlug)}/`;
|
|
90
|
+
},
|
|
91
|
+
routes: [
|
|
92
|
+
createSentryIntegrationRoutes({
|
|
93
|
+
sentry,
|
|
94
|
+
getSentryInstallation: options.getSentryInstallation,
|
|
95
|
+
getConnectionById: options.getConnectionById,
|
|
96
|
+
connectSentryInstallation: options.connectSentryInstallation,
|
|
97
|
+
persistVerifiedUnclaimedInstallation: persistUnclaimed,
|
|
98
|
+
}),
|
|
99
|
+
createSentryWebhookRoutes({
|
|
100
|
+
sentry,
|
|
101
|
+
coreDb: options.coreDb,
|
|
102
|
+
publishIntegrationEventReceived: options.publishIntegrationEventReceived,
|
|
103
|
+
recordDeliveryOnly: options.recordDeliveryOnly,
|
|
104
|
+
getIntegrationConnectionById: options.getIntegrationConnectionById,
|
|
105
|
+
updateConnectionLifecycleStatus: options.updateConnectionLifecycleStatus,
|
|
106
|
+
}),
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
112
|
+
const maintenanceWorkflowsPath = resolve(packageRoot, 'dist/temporal/workflows/index.js');
|
|
113
|
+
|
|
114
|
+
export function createSentryMaintenanceWorker(): ModuleWorker {
|
|
115
|
+
return {
|
|
116
|
+
taskQueue: SENTRY_MAINTENANCE_TASK_QUEUE,
|
|
117
|
+
workflowsPath: maintenanceWorkflowsPath,
|
|
118
|
+
activities: createSentryMaintenanceActivities,
|
|
119
|
+
workflows: [
|
|
120
|
+
{
|
|
121
|
+
name: 'pruneUnclaimedSentryInstallationsCron',
|
|
122
|
+
id: 'sentry-prune-unclaimed-installations',
|
|
123
|
+
cronSchedule: '0 4 * * *',
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type IntegrationConnection,
|
|
3
|
+
toIntegrationConnectionDto as toCoreIntegrationConnectionDto,
|
|
4
|
+
} from '@shipfox/api-integration-core-dto';
|
|
5
|
+
|
|
6
|
+
// Sentry exposes no adapters, so its connections carry no capabilities.
|
|
7
|
+
export function toIntegrationConnectionDto(connection: IntegrationConnection<'sentry'>) {
|
|
8
|
+
return toCoreIntegrationConnectionDto(connection, {capabilities: []});
|
|
9
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectionSlugConflictError,
|
|
3
|
+
type IntegrationProviderErrorReason,
|
|
4
|
+
} from '@shipfox/api-integration-core-dto';
|
|
5
|
+
import {ClientError} from '@shipfox/node-fastify';
|
|
6
|
+
import {
|
|
7
|
+
SentryClaimProofMismatchError,
|
|
8
|
+
SentryInstallationAlreadyLinkedError,
|
|
9
|
+
SentryInstallationDeletedError,
|
|
10
|
+
SentryIntegrationProviderError,
|
|
11
|
+
SentryVerificationInProgressError,
|
|
12
|
+
} from '#core/errors.js';
|
|
13
|
+
|
|
14
|
+
function providerStatus(reason: IntegrationProviderErrorReason): number {
|
|
15
|
+
if (reason === 'rate-limited') return 429;
|
|
16
|
+
if (reason === 'timeout' || reason === 'provider-unavailable') return 503;
|
|
17
|
+
return 422;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function sentryRouteErrorHandler(error: unknown): never {
|
|
21
|
+
if (error instanceof SentryInstallationAlreadyLinkedError) {
|
|
22
|
+
throw new ClientError(error.message, 'sentry-installation-already-linked', {status: 409});
|
|
23
|
+
}
|
|
24
|
+
if (error instanceof SentryInstallationDeletedError) {
|
|
25
|
+
throw new ClientError(error.message, 'sentry-installation-deleted', {status: 409});
|
|
26
|
+
}
|
|
27
|
+
if (error instanceof SentryClaimProofMismatchError) {
|
|
28
|
+
throw new ClientError(error.message, 'sentry-claim-proof-mismatch', {status: 403});
|
|
29
|
+
}
|
|
30
|
+
if (error instanceof ConnectionSlugConflictError) {
|
|
31
|
+
throw new ClientError(error.message, 'slug-conflict', {status: 409});
|
|
32
|
+
}
|
|
33
|
+
if (error instanceof SentryVerificationInProgressError) {
|
|
34
|
+
// Retryable: a concurrent webhook is mid-exchange. 503 + retry_after lands in
|
|
35
|
+
// the client backoff that already treats >= 500 as retryable.
|
|
36
|
+
throw new ClientError(error.message, 'sentry-verification-in-progress', {
|
|
37
|
+
status: 503,
|
|
38
|
+
details: {retry_after_seconds: error.retryAfterSeconds},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (error instanceof SentryIntegrationProviderError) {
|
|
42
|
+
throw new ClientError(error.message, error.reason, {
|
|
43
|
+
details: {retry_after_seconds: error.retryAfterSeconds},
|
|
44
|
+
status: providerStatus(error.reason),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
throw error;
|
|
48
|
+
}
|