@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.
Files changed (165) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +192 -0
  5. package/LICENSE +21 -0
  6. package/README.md +145 -0
  7. package/dist/api/client.d.ts +24 -0
  8. package/dist/api/client.d.ts.map +1 -0
  9. package/dist/api/client.js +101 -0
  10. package/dist/api/client.js.map +1 -0
  11. package/dist/config.d.ts +9 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +32 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/errors.d.ts +57 -0
  16. package/dist/core/errors.d.ts.map +1 -0
  17. package/dist/core/errors.js +67 -0
  18. package/dist/core/errors.js.map +1 -0
  19. package/dist/core/install.d.ts +63 -0
  20. package/dist/core/install.d.ts.map +1 -0
  21. package/dist/core/install.js +209 -0
  22. package/dist/core/install.js.map +1 -0
  23. package/dist/core/signature.d.ts +7 -0
  24. package/dist/core/signature.d.ts.map +1 -0
  25. package/dist/core/signature.js +6 -0
  26. package/dist/core/signature.js.map +1 -0
  27. package/dist/core/webhook.d.ts +49 -0
  28. package/dist/core/webhook.d.ts.map +1 -0
  29. package/dist/core/webhook.js +175 -0
  30. package/dist/core/webhook.js.map +1 -0
  31. package/dist/db/db.d.ts +327 -0
  32. package/dist/db/db.d.ts.map +1 -0
  33. package/dist/db/db.js +18 -0
  34. package/dist/db/db.js.map +1 -0
  35. package/dist/db/installations.d.ts +74 -0
  36. package/dist/db/installations.d.ts.map +1 -0
  37. package/dist/db/installations.js +120 -0
  38. package/dist/db/installations.js.map +1 -0
  39. package/dist/db/migrations.d.ts +2 -0
  40. package/dist/db/migrations.d.ts.map +1 -0
  41. package/dist/db/migrations.js +5 -0
  42. package/dist/db/migrations.js.map +1 -0
  43. package/dist/db/schema/common.d.ts +2 -0
  44. package/dist/db/schema/common.d.ts.map +1 -0
  45. package/dist/db/schema/common.js +4 -0
  46. package/dist/db/schema/common.js.map +1 -0
  47. package/dist/db/schema/installations.d.ts +165 -0
  48. package/dist/db/schema/installations.d.ts.map +1 -0
  49. package/dist/db/schema/installations.js +42 -0
  50. package/dist/db/schema/installations.js.map +1 -0
  51. package/dist/index.d.ts +36 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +64 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/presentation/dto/integrations.d.ts +15 -0
  56. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  57. package/dist/presentation/dto/integrations.js +9 -0
  58. package/dist/presentation/dto/integrations.js.map +1 -0
  59. package/dist/presentation/routes/errors.d.ts +2 -0
  60. package/dist/presentation/routes/errors.d.ts.map +1 -0
  61. package/dist/presentation/routes/errors.js +51 -0
  62. package/dist/presentation/routes/errors.js.map +1 -0
  63. package/dist/presentation/routes/install.d.ts +16 -0
  64. package/dist/presentation/routes/install.d.ts.map +1 -0
  65. package/dist/presentation/routes/install.js +76 -0
  66. package/dist/presentation/routes/install.js.map +1 -0
  67. package/dist/presentation/routes/installation-handler.d.ts +9 -0
  68. package/dist/presentation/routes/installation-handler.d.ts.map +1 -0
  69. package/dist/presentation/routes/installation-handler.js +70 -0
  70. package/dist/presentation/routes/installation-handler.js.map +1 -0
  71. package/dist/presentation/routes/issue-handler.d.ts +9 -0
  72. package/dist/presentation/routes/issue-handler.d.ts.map +1 -0
  73. package/dist/presentation/routes/issue-handler.js +50 -0
  74. package/dist/presentation/routes/issue-handler.js.map +1 -0
  75. package/dist/presentation/routes/webhook-context.d.ts +20 -0
  76. package/dist/presentation/routes/webhook-context.d.ts.map +1 -0
  77. package/dist/presentation/routes/webhook-context.js +10 -0
  78. package/dist/presentation/routes/webhook-context.js.map +1 -0
  79. package/dist/presentation/routes/webhook-delivery.d.ts +28 -0
  80. package/dist/presentation/routes/webhook-delivery.d.ts.map +1 -0
  81. package/dist/presentation/routes/webhook-delivery.js +56 -0
  82. package/dist/presentation/routes/webhook-delivery.js.map +1 -0
  83. package/dist/presentation/routes/webhook-request.d.ts +10 -0
  84. package/dist/presentation/routes/webhook-request.d.ts.map +1 -0
  85. package/dist/presentation/routes/webhook-request.js +56 -0
  86. package/dist/presentation/routes/webhook-request.js.map +1 -0
  87. package/dist/presentation/routes/webhooks.d.ts +5 -0
  88. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  89. package/dist/presentation/routes/webhooks.js +71 -0
  90. package/dist/presentation/routes/webhooks.js.map +1 -0
  91. package/dist/temporal/activities/index.d.ts +5 -0
  92. package/dist/temporal/activities/index.d.ts.map +1 -0
  93. package/dist/temporal/activities/index.js +8 -0
  94. package/dist/temporal/activities/index.js.map +1 -0
  95. package/dist/temporal/activities/prune-unclaimed-installations.d.ts +4 -0
  96. package/dist/temporal/activities/prune-unclaimed-installations.d.ts.map +1 -0
  97. package/dist/temporal/activities/prune-unclaimed-installations.js +11 -0
  98. package/dist/temporal/activities/prune-unclaimed-installations.js.map +1 -0
  99. package/dist/temporal/constants.d.ts +2 -0
  100. package/dist/temporal/constants.d.ts.map +1 -0
  101. package/dist/temporal/constants.js +3 -0
  102. package/dist/temporal/constants.js.map +1 -0
  103. package/dist/temporal/workflows/index.d.ts +2 -0
  104. package/dist/temporal/workflows/index.d.ts.map +1 -0
  105. package/dist/temporal/workflows/index.js +3 -0
  106. package/dist/temporal/workflows/index.js.map +1 -0
  107. package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts +2 -0
  108. package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts.map +1 -0
  109. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js +14 -0
  110. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js.map +1 -0
  111. package/dist/tsconfig.test.tsbuildinfo +1 -0
  112. package/drizzle/0000_initial.sql +14 -0
  113. package/drizzle/meta/_journal.json +13 -0
  114. package/drizzle.config.ts +7 -0
  115. package/package.json +70 -0
  116. package/src/api/client.test.ts +147 -0
  117. package/src/api/client.ts +147 -0
  118. package/src/config.test.ts +16 -0
  119. package/src/config.ts +35 -0
  120. package/src/connection-external-url.test.ts +79 -0
  121. package/src/core/errors.ts +81 -0
  122. package/src/core/install.test.ts +343 -0
  123. package/src/core/install.ts +305 -0
  124. package/src/core/signature.test.ts +50 -0
  125. package/src/core/signature.ts +11 -0
  126. package/src/core/webhook.test.ts +118 -0
  127. package/src/core/webhook.ts +246 -0
  128. package/src/db/db.ts +18 -0
  129. package/src/db/installations.test.ts +224 -0
  130. package/src/db/installations.ts +207 -0
  131. package/src/db/migrations.ts +4 -0
  132. package/src/db/schema/common.ts +3 -0
  133. package/src/db/schema/installations.ts +46 -0
  134. package/src/index.test.ts +18 -0
  135. package/src/index.ts +127 -0
  136. package/src/presentation/dto/integrations.ts +9 -0
  137. package/src/presentation/routes/errors.ts +48 -0
  138. package/src/presentation/routes/install.test.ts +325 -0
  139. package/src/presentation/routes/install.ts +102 -0
  140. package/src/presentation/routes/installation-handler.ts +76 -0
  141. package/src/presentation/routes/issue-handler.ts +52 -0
  142. package/src/presentation/routes/webhook-context.ts +25 -0
  143. package/src/presentation/routes/webhook-delivery.ts +61 -0
  144. package/src/presentation/routes/webhook-request.ts +66 -0
  145. package/src/presentation/routes/webhooks.test.ts +773 -0
  146. package/src/presentation/routes/webhooks.ts +59 -0
  147. package/src/temporal/activities/index.ts +7 -0
  148. package/src/temporal/activities/prune-unclaimed-installations.test.ts +52 -0
  149. package/src/temporal/activities/prune-unclaimed-installations.ts +11 -0
  150. package/src/temporal/constants.ts +1 -0
  151. package/src/temporal/workflows/index.ts +1 -0
  152. package/src/temporal/workflows/prune-unclaimed-installations-cron.ts +15 -0
  153. package/test/env.ts +11 -0
  154. package/test/factories/index.ts +9 -0
  155. package/test/factories/sentry-installation-webhook.ts +35 -0
  156. package/test/factories/sentry-installation.ts +44 -0
  157. package/test/factories/sentry-issue-webhook.ts +34 -0
  158. package/test/globalSetup.ts +14 -0
  159. package/test/index.ts +9 -0
  160. package/test/setup.ts +17 -0
  161. package/tsconfig.build.json +9 -0
  162. package/tsconfig.build.tsbuildinfo +1 -0
  163. package/tsconfig.json +3 -0
  164. package/tsconfig.test.json +9 -0
  165. 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,4 @@
1
+ import {dirname, resolve} from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+
4
+ export const migrationsPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../drizzle');
@@ -0,0 +1,3 @@
1
+ import {pgTableCreator} from 'drizzle-orm/pg-core';
2
+
3
+ export const pgTable = pgTableCreator((name) => `integrations_sentry_${name}`);
@@ -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
+ }