@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.
Files changed (84) hide show
  1. package/.turbo/turbo-build.log +12 -15
  2. package/CHANGELOG.md +42 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +4 -5
  5. package/dist/config.js.map +1 -1
  6. package/dist/core/install.d.ts +6 -0
  7. package/dist/core/install.d.ts.map +1 -1
  8. package/dist/core/install.js +8 -5
  9. package/dist/core/install.js.map +1 -1
  10. package/dist/core/signature.d.ts +1 -1
  11. package/dist/core/signature.d.ts.map +1 -1
  12. package/dist/core/signature.js.map +1 -1
  13. package/dist/core/webhook-processor.d.ts +16 -0
  14. package/dist/core/webhook-processor.d.ts.map +1 -0
  15. package/dist/core/webhook-processor.js +198 -0
  16. package/dist/core/webhook-processor.js.map +1 -0
  17. package/dist/core/webhook.d.ts +4 -4
  18. package/dist/core/webhook.d.ts.map +1 -1
  19. package/dist/core/webhook.js +63 -49
  20. package/dist/core/webhook.js.map +1 -1
  21. package/dist/db/db.d.ts +8 -4
  22. package/dist/db/db.d.ts.map +1 -1
  23. package/dist/db/installations.d.ts +35 -13
  24. package/dist/db/installations.d.ts.map +1 -1
  25. package/dist/db/installations.js +89 -31
  26. package/dist/db/installations.js.map +1 -1
  27. package/dist/db/schema/installations.d.ts +5 -3
  28. package/dist/db/schema/installations.d.ts.map +1 -1
  29. package/dist/db/schema/installations.js +6 -6
  30. package/dist/db/schema/installations.js.map +1 -1
  31. package/dist/index.d.ts +6 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +20 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/presentation/routes/webhooks.d.ts +4 -1
  36. package/dist/presentation/routes/webhooks.d.ts.map +1 -1
  37. package/dist/presentation/routes/webhooks.js +69 -43
  38. package/dist/presentation/routes/webhooks.js.map +1 -1
  39. package/dist/temporal/activities/prune-unclaimed-installations.d.ts +1 -0
  40. package/dist/temporal/activities/prune-unclaimed-installations.d.ts.map +1 -1
  41. package/dist/temporal/activities/prune-unclaimed-installations.js.map +1 -1
  42. package/dist/temporal/workflows/index.bundle.js +17169 -3306
  43. package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts.map +1 -1
  44. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js +6 -1
  45. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js.map +1 -1
  46. package/dist/tsconfig.test.tsbuildinfo +1 -1
  47. package/package.json +9 -21
  48. package/src/config.ts +4 -5
  49. package/src/core/install.ts +21 -7
  50. package/src/core/signature.ts +1 -1
  51. package/src/core/webhook-processor.test.ts +304 -0
  52. package/src/core/webhook-processor.ts +224 -0
  53. package/src/core/webhook.ts +74 -53
  54. package/src/db/installations.test.ts +163 -17
  55. package/src/db/installations.ts +136 -35
  56. package/src/db/schema/installations.ts +7 -7
  57. package/src/index.test.ts +4 -1
  58. package/src/index.ts +16 -0
  59. package/src/presentation/routes/webhooks.test.ts +6 -5
  60. package/src/presentation/routes/webhooks.ts +88 -29
  61. package/src/temporal/activities/prune-unclaimed-installations.test.ts +24 -2
  62. package/src/temporal/activities/prune-unclaimed-installations.ts +4 -1
  63. package/src/temporal/workflows/prune-unclaimed-installations-cron.ts +4 -1
  64. package/tsconfig.build.tsbuildinfo +1 -1
  65. package/dist/presentation/routes/installation-handler.d.ts +0 -9
  66. package/dist/presentation/routes/installation-handler.d.ts.map +0 -1
  67. package/dist/presentation/routes/installation-handler.js +0 -70
  68. package/dist/presentation/routes/installation-handler.js.map +0 -1
  69. package/dist/presentation/routes/issue-handler.d.ts +0 -9
  70. package/dist/presentation/routes/issue-handler.d.ts.map +0 -1
  71. package/dist/presentation/routes/issue-handler.js +0 -50
  72. package/dist/presentation/routes/issue-handler.js.map +0 -1
  73. package/dist/presentation/routes/webhook-delivery.d.ts +0 -28
  74. package/dist/presentation/routes/webhook-delivery.d.ts.map +0 -1
  75. package/dist/presentation/routes/webhook-delivery.js +0 -56
  76. package/dist/presentation/routes/webhook-delivery.js.map +0 -1
  77. package/dist/presentation/routes/webhook-request.d.ts +0 -10
  78. package/dist/presentation/routes/webhook-request.d.ts.map +0 -1
  79. package/dist/presentation/routes/webhook-request.js +0 -56
  80. package/dist/presentation/routes/webhook-request.js.map +0 -1
  81. package/src/presentation/routes/installation-handler.ts +0 -76
  82. package/src/presentation/routes/issue-handler.ts +0 -52
  83. package/src/presentation/routes/webhook-delivery.ts +0 -61
  84. package/src/presentation/routes/webhook-request.ts +0 -66
@@ -1,7 +1,7 @@
1
1
  import { logger } from '@shipfox/node-opentelemetry';
2
2
  import { SentryConnectionNotFoundError, SentryInstallationDeletedError, SentryInstallationNotFoundError, SentryInstallationUnclaimedError, SentryIntegrationProviderError } from '#core/errors.js';
3
- import { verifyAndPersistUnclaimedInstallation } from '#core/install.js';
4
- import { getSentryInstallationByInstallationUuid, markSentryInstallationDeleted } from '#db/installations.js';
3
+ import { hashAuthorizationCode, verifySentryInstallationBestEffort } from '#core/install.js';
4
+ import { claimSentryInstallationVerification, getSentryInstallationByInstallationUuid, markSentryInstallationDeleted, markSentryInstallationExchangeSucceeded } from '#db/installations.js';
5
5
  const SENTRY_SOURCE = 'sentry';
6
6
  const DEFAULT_ISSUE_TITLE = 'Sentry issue';
7
7
  const DELETED_STATUS = 'deleted';
@@ -50,15 +50,15 @@ export async function handleSentryIssueEvent(params) {
50
50
  /**
51
51
  * The authoritative `installation.created` path. Whichever of webhook or browser
52
52
  * arrives first exchanges the single-use code and persists a verified-unclaimed
53
- * row; the other reconciles. Idempotent on the installation uuid. The exchange
54
- * runs outside any DB transaction; a short transaction wraps only persist +
55
- * delivery record. On an exchange failure the delivery is record-and-dropped
56
- * (204) the browser may still win, or the code expired. Never logs the raw code.
53
+ * row; the other reconciles. The webhook writes a pending claim before the
54
+ * exchange and a durable checkpoint after it succeeds. Only that checkpoint can
55
+ * bypass the single-use exchange on retry. The installed transition and delivery
56
+ * record commit in one transaction. Never logs the raw code.
57
57
  */ export async function handleSentryInstallationCreated(params) {
58
58
  const existing = await params.getSentryInstallation({
59
59
  installationUuid: params.installationUuid
60
60
  });
61
- if (existing) {
61
+ if (existing && (existing.status === 'installed' || existing.status === 'deleted')) {
62
62
  logger().debug({
63
63
  deliveryId: params.deliveryId,
64
64
  installationUuid: params.installationUuid
@@ -66,60 +66,74 @@ export async function handleSentryIssueEvent(params) {
66
66
  await params.recordDelivery(params.deliveryId);
67
67
  return;
68
68
  }
69
- if (!params.code) {
69
+ if (!params.code || !params.orgSlug) {
70
70
  logger().warn({
71
71
  deliveryId: params.deliveryId,
72
72
  installationUuid: params.installationUuid
73
- }, 'sentry webhook: installation.created without an authorization code, dropping');
73
+ }, 'sentry webhook: installation.created without claim input, dropping');
74
74
  await params.recordDelivery(params.deliveryId);
75
75
  return;
76
76
  }
77
- try {
78
- await verifyAndPersistUnclaimedInstallation({
79
- sentry: params.sentry,
80
- installationUuid: params.installationUuid,
81
- code: params.code,
82
- orgSlug: params.orgSlug,
83
- verifyInstall: params.verifyInstall,
84
- persistVerifiedUnclaimedInstallation: ({ installationUuid, orgSlug, codeHash })=>params.persistUnclaimedAndRecordDelivery({
85
- installationUuid,
86
- orgSlug,
87
- codeHash,
88
- deliveryId: params.deliveryId
89
- })
90
- });
91
- } catch (error) {
92
- if (error instanceof SentryIntegrationProviderError) {
93
- // The Sentry exchange (or org-slug lookup) failed, so we never durably
94
- // spent the code on a row we own. `reason` distinguishes a transient/expected
95
- // drop (access-denied: the code was already spent, e.g. the browser won) from
96
- // a likely misconfiguration (a bad client id/secret/slug fails every install)
97
- // so log-based alerting can fire on the sustained case. Never logs the raw code.
98
- logger().warn({
99
- deliveryId: params.deliveryId,
77
+ const codeHash = hashAuthorizationCode(params.code);
78
+ let claimed = existing ?? await claimSentryInstallationVerification({
79
+ installationUuid: params.installationUuid,
80
+ orgSlug: params.orgSlug,
81
+ codeHash
82
+ });
83
+ if (claimed.status === 'installed' || claimed.status === 'deleted') {
84
+ await params.recordDelivery(params.deliveryId);
85
+ return;
86
+ }
87
+ if (claimed.codeHash !== codeHash) {
88
+ logger().warn({
89
+ deliveryId: params.deliveryId,
90
+ installationUuid: params.installationUuid
91
+ }, 'sentry webhook: installation.created does not match the pending claim, dropping');
92
+ await params.recordDelivery(params.deliveryId);
93
+ return;
94
+ }
95
+ let authorization;
96
+ if (claimed.status === 'pending') {
97
+ try {
98
+ authorization = await params.sentry.exchangeAuthorizationCode({
100
99
  installationUuid: params.installationUuid,
101
- reason: error.reason,
102
- err: error
103
- }, 'sentry webhook: installation.created exchange failed, dropping');
104
- } else {
105
- // The exchange succeeded (the single-use code is now spent) but the persist
106
- // transaction failed, so no row exists and Sentry will not usefully
107
- // re-deliver the install is stranded until a reinstall mints a fresh uuid.
108
- // Log at error so alerting catches it; still record-and-drop because retrying
109
- // a spent code cannot recover the row. Never logs the raw code.
110
- logger().error({
111
- deliveryId: params.deliveryId,
100
+ code: params.code
101
+ });
102
+ } catch (error) {
103
+ if (!(error instanceof SentryIntegrationProviderError) || error.reason !== 'access-denied') {
104
+ throw error;
105
+ }
106
+ const current = await params.getSentryInstallation({
107
+ installationUuid: params.installationUuid
108
+ });
109
+ const concurrentAttemptSucceeded = current?.codeHash === codeHash && (current.status === 'exchange-succeeded' || current.status === 'installed');
110
+ if (!current || !concurrentAttemptSucceeded) throw error;
111
+ claimed = current;
112
+ }
113
+ if (authorization) {
114
+ await markSentryInstallationExchangeSucceeded({
112
115
  installationUuid: params.installationUuid,
113
- err: error
114
- }, 'sentry webhook: installation.created persisted nothing after a successful exchange, install stranded until reinstall');
116
+ codeHash
117
+ });
115
118
  }
116
- await params.recordDelivery(params.deliveryId);
119
+ }
120
+ const completed = await params.persistUnclaimedAndRecordDelivery({
121
+ installationUuid: params.installationUuid,
122
+ orgSlug: claimed.orgSlug,
123
+ codeHash,
124
+ deliveryId: params.deliveryId
125
+ });
126
+ if (authorization && completed.status === 'installed' && params.verifyInstall) {
127
+ await verifySentryInstallationBestEffort({
128
+ sentry: params.sentry,
129
+ installationUuid: params.installationUuid,
130
+ token: authorization.token
131
+ });
117
132
  }
118
133
  }
119
134
  // Tombstones the install and disables its connection if one exists. An unknown
120
- // uuid (never installed, or reinstall mints a fresh uuid) only records the
121
- // delivery: there is no tombstone row to write. Runs entirely in the caller's
122
- // transaction — no exchange is needed.
135
+ // uuid also gets a tombstone, so a reordered creation cannot restore it. The
136
+ // state change and delivery record share the caller's transaction.
123
137
  export async function handleSentryInstallationDeleted(params) {
124
138
  const installation = await markSentryInstallationDeleted({
125
139
  installationUuid: params.installationUuid
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/webhook.ts"],"sourcesContent":["import type {\n GetIntegrationConnectionByIdFn,\n IntegrationTx,\n PublishIntegrationEventReceivedFn,\n RecordDeliveryOnlyFn,\n SentryIssuePayload,\n UpdateIntegrationConnectionLifecycleStatusFn,\n} from '@shipfox/api-integration-core-dto';\nimport type {SentryIssueWebhookDto} from '@shipfox/api-integration-sentry-dto';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport type {SentryApiClient} from '#api/client.js';\nimport {\n SentryConnectionNotFoundError,\n SentryInstallationDeletedError,\n SentryInstallationNotFoundError,\n SentryInstallationUnclaimedError,\n SentryIntegrationProviderError,\n} from '#core/errors.js';\nimport {verifyAndPersistUnclaimedInstallation} from '#core/install.js';\nimport {\n getSentryInstallationByInstallationUuid,\n markSentryInstallationDeleted,\n type SentryInstallation,\n} from '#db/installations.js';\n\nconst SENTRY_SOURCE = 'sentry';\nconst DEFAULT_ISSUE_TITLE = 'Sentry issue';\nconst DELETED_STATUS = 'deleted';\n\nexport interface HandleSentryIssueEventParams {\n tx: IntegrationTx;\n deliveryId: string;\n payload: SentryIssueWebhookDto;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n getIntegrationConnectionById: GetIntegrationConnectionByIdFn;\n}\n\n// Publishes the mapped event for a verified issue delivery. Throws a typed\n// SentryIssueDroppedError subclass when the delivery references state we cannot\n// publish against (unknown/deleted installation, or an install not yet claimed\n// into a workspace); the webhook layer records-and-drops those. Dedup of an\n// already-seen delivery is handled inside publishIntegrationEventReceived.\nexport async function handleSentryIssueEvent(params: HandleSentryIssueEventParams): Promise<void> {\n const installationUuid = params.payload.installation.uuid;\n\n const installation = await getSentryInstallationByInstallationUuid(installationUuid, {\n tx: params.tx,\n });\n if (!installation) {\n throw new SentryInstallationNotFoundError(installationUuid);\n }\n if (installation.status === DELETED_STATUS) {\n throw new SentryInstallationDeletedError(installationUuid);\n }\n // A verified-but-unclaimed install has no workspace yet, so there is nothing to\n // publish an event against. This is the pre-claim drop window (counted/logged).\n if (!installation.connectionId) {\n throw new SentryInstallationUnclaimedError(installationUuid);\n }\n\n const connection = await params.getIntegrationConnectionById(installation.connectionId, {\n tx: params.tx,\n });\n if (!connection) {\n throw new SentryConnectionNotFoundError(installation.connectionId);\n }\n\n await params.publishIntegrationEventReceived({\n tx: params.tx,\n event: {\n provider: SENTRY_SOURCE,\n source: connection.slug,\n event: `issue.${params.payload.action}`,\n workspaceId: connection.workspaceId,\n connectionId: connection.id,\n connectionName: connection.displayName,\n deliveryId: params.deliveryId,\n receivedAt: new Date().toISOString(),\n payload: normalizeIssuePayload(params.payload),\n },\n });\n}\n\nexport interface HandleSentryInstallationCreatedParams {\n deliveryId: string;\n installationUuid: string;\n // From the signed payload; the webhook is authoritative so the slug is trusted\n // without a getInstallation round-trip. The code is the single-use grant.\n orgSlug: string | undefined;\n code: string | undefined;\n sentry: SentryApiClient;\n verifyInstall: boolean;\n getSentryInstallation: (input: {\n installationUuid: string;\n }) => Promise<SentryInstallation | undefined>;\n // Persists the verified-unclaimed row and records the delivery in one short\n // transaction, returning the persisted row.\n persistUnclaimedAndRecordDelivery: (input: {\n installationUuid: string;\n orgSlug: string;\n codeHash: string;\n deliveryId: string;\n }) => Promise<SentryInstallation>;\n // Records the delivery for dedup without persisting an install (reconcile/no-op,\n // missing code, or exchange failure → record-and-drop).\n recordDelivery: (deliveryId: string) => Promise<void>;\n}\n\n/**\n * The authoritative `installation.created` path. Whichever of webhook or browser\n * arrives first exchanges the single-use code and persists a verified-unclaimed\n * row; the other reconciles. Idempotent on the installation uuid. The exchange\n * runs outside any DB transaction; a short transaction wraps only persist +\n * delivery record. On an exchange failure the delivery is record-and-dropped\n * (204) — the browser may still win, or the code expired. Never logs the raw code.\n */\nexport async function handleSentryInstallationCreated(\n params: HandleSentryInstallationCreatedParams,\n): Promise<void> {\n const existing = await params.getSentryInstallation({installationUuid: params.installationUuid});\n if (existing) {\n logger().debug(\n {deliveryId: params.deliveryId, installationUuid: params.installationUuid},\n 'sentry webhook: installation.created for an existing row, reconciling',\n );\n await params.recordDelivery(params.deliveryId);\n return;\n }\n\n if (!params.code) {\n logger().warn(\n {deliveryId: params.deliveryId, installationUuid: params.installationUuid},\n 'sentry webhook: installation.created without an authorization code, dropping',\n );\n await params.recordDelivery(params.deliveryId);\n return;\n }\n\n try {\n await verifyAndPersistUnclaimedInstallation({\n sentry: params.sentry,\n installationUuid: params.installationUuid,\n code: params.code,\n orgSlug: params.orgSlug,\n verifyInstall: params.verifyInstall,\n persistVerifiedUnclaimedInstallation: ({installationUuid, orgSlug, codeHash}) =>\n params.persistUnclaimedAndRecordDelivery({\n installationUuid,\n orgSlug,\n codeHash,\n deliveryId: params.deliveryId,\n }),\n });\n } catch (error) {\n if (error instanceof SentryIntegrationProviderError) {\n // The Sentry exchange (or org-slug lookup) failed, so we never durably\n // spent the code on a row we own. `reason` distinguishes a transient/expected\n // drop (access-denied: the code was already spent, e.g. the browser won) from\n // a likely misconfiguration (a bad client id/secret/slug fails every install)\n // so log-based alerting can fire on the sustained case. Never logs the raw code.\n logger().warn(\n {\n deliveryId: params.deliveryId,\n installationUuid: params.installationUuid,\n reason: error.reason,\n err: error,\n },\n 'sentry webhook: installation.created exchange failed, dropping',\n );\n } else {\n // The exchange succeeded (the single-use code is now spent) but the persist\n // transaction failed, so no row exists and Sentry will not usefully\n // re-deliver — the install is stranded until a reinstall mints a fresh uuid.\n // Log at error so alerting catches it; still record-and-drop because retrying\n // a spent code cannot recover the row. Never logs the raw code.\n logger().error(\n {deliveryId: params.deliveryId, installationUuid: params.installationUuid, err: error},\n 'sentry webhook: installation.created persisted nothing after a successful exchange, install stranded until reinstall',\n );\n }\n await params.recordDelivery(params.deliveryId);\n }\n}\n\nexport interface HandleSentryInstallationDeletedParams {\n tx: IntegrationTx;\n deliveryId: string;\n installationUuid: string;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n updateConnectionLifecycleStatus: UpdateIntegrationConnectionLifecycleStatusFn;\n}\n\n// Tombstones the install and disables its connection if one exists. An unknown\n// uuid (never installed, or reinstall mints a fresh uuid) only records the\n// delivery: there is no tombstone row to write. Runs entirely in the caller's\n// transaction — no exchange is needed.\nexport async function handleSentryInstallationDeleted(\n params: HandleSentryInstallationDeletedParams,\n): Promise<void> {\n const installation = await markSentryInstallationDeleted(\n {installationUuid: params.installationUuid},\n {tx: params.tx},\n );\n if (installation?.connectionId) {\n await params.updateConnectionLifecycleStatus(\n {id: installation.connectionId, lifecycleStatus: 'disabled'},\n {tx: params.tx},\n );\n }\n\n await params.recordDeliveryOnly({\n tx: params.tx,\n provider: SENTRY_SOURCE,\n deliveryId: params.deliveryId,\n });\n}\n\n// A raw Sentry `ignored` action is normalized to `archived` before validation,\n// so legacy ignore events still fire `issue.archived` workflows.\nexport function normalizeSentryIssueAction(parsedJson: unknown): unknown {\n if (typeof parsedJson !== 'object' || parsedJson === null) return parsedJson;\n const obj = parsedJson as {action?: unknown};\n if (obj.action === 'ignored') {\n return {...obj, action: 'archived'};\n }\n return parsedJson;\n}\n\nfunction normalizeIssuePayload(payload: SentryIssueWebhookDto): SentryIssuePayload {\n const issue = payload.data.issue;\n return {\n action: payload.action,\n issueId: issue.id,\n shortId: issue.shortId ?? null,\n title: issue.title ?? DEFAULT_ISSUE_TITLE,\n culprit: issue.culprit ?? null,\n level: issue.level ?? null,\n status: issue.status ?? null,\n platform: issue.platform ?? null,\n webUrl: issue.web_url ?? null,\n issueUrl: issue.url ?? null,\n projectUrl: issue.project_url ?? null,\n firstSeenAt: issue.firstSeen ?? null,\n lastSeenAt: issue.lastSeen ?? null,\n };\n}\n"],"names":["logger","SentryConnectionNotFoundError","SentryInstallationDeletedError","SentryInstallationNotFoundError","SentryInstallationUnclaimedError","SentryIntegrationProviderError","verifyAndPersistUnclaimedInstallation","getSentryInstallationByInstallationUuid","markSentryInstallationDeleted","SENTRY_SOURCE","DEFAULT_ISSUE_TITLE","DELETED_STATUS","handleSentryIssueEvent","params","installationUuid","payload","installation","uuid","tx","status","connectionId","connection","getIntegrationConnectionById","publishIntegrationEventReceived","event","provider","source","slug","action","workspaceId","id","connectionName","displayName","deliveryId","receivedAt","Date","toISOString","normalizeIssuePayload","handleSentryInstallationCreated","existing","getSentryInstallation","debug","recordDelivery","code","warn","sentry","orgSlug","verifyInstall","persistVerifiedUnclaimedInstallation","codeHash","persistUnclaimedAndRecordDelivery","error","reason","err","handleSentryInstallationDeleted","updateConnectionLifecycleStatus","lifecycleStatus","recordDeliveryOnly","normalizeSentryIssueAction","parsedJson","obj","issue","data","issueId","shortId","title","culprit","level","platform","webUrl","web_url","issueUrl","url","projectUrl","project_url","firstSeenAt","firstSeen","lastSeenAt","lastSeen"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/core/webhook.ts"],"sourcesContent":["import type {\n GetIntegrationConnectionByIdFn,\n IntegrationTx,\n PublishIntegrationEventReceivedFn,\n RecordDeliveryOnlyFn,\n SentryIssuePayload,\n UpdateIntegrationConnectionLifecycleStatusFn,\n} from '@shipfox/api-integration-core-dto';\nimport type {SentryIssueWebhookDto} from '@shipfox/api-integration-sentry-dto';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport type {SentryApiClient} from '#api/client.js';\nimport {\n SentryConnectionNotFoundError,\n SentryInstallationDeletedError,\n SentryInstallationNotFoundError,\n SentryInstallationUnclaimedError,\n SentryIntegrationProviderError,\n} from '#core/errors.js';\nimport {hashAuthorizationCode, verifySentryInstallationBestEffort} from '#core/install.js';\nimport {\n claimSentryInstallationVerification,\n getSentryInstallationByInstallationUuid,\n markSentryInstallationDeleted,\n markSentryInstallationExchangeSucceeded,\n type SentryInstallation,\n} from '#db/installations.js';\n\nconst SENTRY_SOURCE = 'sentry';\nconst DEFAULT_ISSUE_TITLE = 'Sentry issue';\nconst DELETED_STATUS = 'deleted';\n\nexport interface HandleSentryIssueEventParams {\n tx: IntegrationTx;\n deliveryId: string;\n payload: SentryIssueWebhookDto;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n getIntegrationConnectionById: GetIntegrationConnectionByIdFn;\n}\n\n// Publishes the mapped event for a verified issue delivery. Throws a typed\n// SentryIssueDroppedError subclass when the delivery references state we cannot\n// publish against (unknown/deleted installation, or an install not yet claimed\n// into a workspace); the webhook layer records-and-drops those. Dedup of an\n// already-seen delivery is handled inside publishIntegrationEventReceived.\nexport async function handleSentryIssueEvent(params: HandleSentryIssueEventParams): Promise<void> {\n const installationUuid = params.payload.installation.uuid;\n\n const installation = await getSentryInstallationByInstallationUuid(installationUuid, {\n tx: params.tx,\n });\n if (!installation) {\n throw new SentryInstallationNotFoundError(installationUuid);\n }\n if (installation.status === DELETED_STATUS) {\n throw new SentryInstallationDeletedError(installationUuid);\n }\n // A verified-but-unclaimed install has no workspace yet, so there is nothing to\n // publish an event against. This is the pre-claim drop window (counted/logged).\n if (!installation.connectionId) {\n throw new SentryInstallationUnclaimedError(installationUuid);\n }\n\n const connection = await params.getIntegrationConnectionById(installation.connectionId, {\n tx: params.tx,\n });\n if (!connection) {\n throw new SentryConnectionNotFoundError(installation.connectionId);\n }\n\n await params.publishIntegrationEventReceived({\n tx: params.tx,\n event: {\n provider: SENTRY_SOURCE,\n source: connection.slug,\n event: `issue.${params.payload.action}`,\n workspaceId: connection.workspaceId,\n connectionId: connection.id,\n connectionName: connection.displayName,\n deliveryId: params.deliveryId,\n receivedAt: new Date().toISOString(),\n payload: normalizeIssuePayload(params.payload),\n },\n });\n}\n\nexport interface HandleSentryInstallationCreatedParams {\n deliveryId: string;\n installationUuid: string;\n // From the signed payload; the webhook is authoritative so the slug is trusted\n // without a getInstallation round-trip. The code is the single-use grant.\n orgSlug: string | undefined;\n code: string | undefined;\n sentry: SentryApiClient;\n verifyInstall: boolean;\n getSentryInstallation: (input: {\n installationUuid: string;\n }) => Promise<SentryInstallation | undefined>;\n // Persists the verified-unclaimed row and records the delivery in one short\n // transaction, returning the persisted row.\n persistUnclaimedAndRecordDelivery: (input: {\n installationUuid: string;\n orgSlug: string;\n codeHash: string;\n deliveryId: string;\n }) => Promise<SentryInstallation>;\n // Records the delivery for dedup without persisting an install (reconcile/no-op\n // or missing claim input).\n recordDelivery: (deliveryId: string) => Promise<void>;\n}\n\n/**\n * The authoritative `installation.created` path. Whichever of webhook or browser\n * arrives first exchanges the single-use code and persists a verified-unclaimed\n * row; the other reconciles. The webhook writes a pending claim before the\n * exchange and a durable checkpoint after it succeeds. Only that checkpoint can\n * bypass the single-use exchange on retry. The installed transition and delivery\n * record commit in one transaction. Never logs the raw code.\n */\nexport async function handleSentryInstallationCreated(\n params: HandleSentryInstallationCreatedParams,\n): Promise<void> {\n const existing = await params.getSentryInstallation({installationUuid: params.installationUuid});\n if (existing && (existing.status === 'installed' || existing.status === 'deleted')) {\n logger().debug(\n {deliveryId: params.deliveryId, installationUuid: params.installationUuid},\n 'sentry webhook: installation.created for an existing row, reconciling',\n );\n await params.recordDelivery(params.deliveryId);\n return;\n }\n\n if (!params.code || !params.orgSlug) {\n logger().warn(\n {deliveryId: params.deliveryId, installationUuid: params.installationUuid},\n 'sentry webhook: installation.created without claim input, dropping',\n );\n await params.recordDelivery(params.deliveryId);\n return;\n }\n\n const codeHash = hashAuthorizationCode(params.code);\n let claimed =\n existing ??\n (await claimSentryInstallationVerification({\n installationUuid: params.installationUuid,\n orgSlug: params.orgSlug,\n codeHash,\n }));\n if (claimed.status === 'installed' || claimed.status === 'deleted') {\n await params.recordDelivery(params.deliveryId);\n return;\n }\n if (claimed.codeHash !== codeHash) {\n logger().warn(\n {deliveryId: params.deliveryId, installationUuid: params.installationUuid},\n 'sentry webhook: installation.created does not match the pending claim, dropping',\n );\n await params.recordDelivery(params.deliveryId);\n return;\n }\n\n let authorization: Awaited<ReturnType<SentryApiClient['exchangeAuthorizationCode']>> | undefined;\n if (claimed.status === 'pending') {\n try {\n authorization = await params.sentry.exchangeAuthorizationCode({\n installationUuid: params.installationUuid,\n code: params.code,\n });\n } catch (error) {\n if (!(error instanceof SentryIntegrationProviderError) || error.reason !== 'access-denied') {\n throw error;\n }\n\n const current = await params.getSentryInstallation({\n installationUuid: params.installationUuid,\n });\n const concurrentAttemptSucceeded =\n current?.codeHash === codeHash &&\n (current.status === 'exchange-succeeded' || current.status === 'installed');\n if (!current || !concurrentAttemptSucceeded) throw error;\n claimed = current;\n }\n\n if (authorization) {\n await markSentryInstallationExchangeSucceeded({\n installationUuid: params.installationUuid,\n codeHash,\n });\n }\n }\n\n const completed = await params.persistUnclaimedAndRecordDelivery({\n installationUuid: params.installationUuid,\n orgSlug: claimed.orgSlug,\n codeHash,\n deliveryId: params.deliveryId,\n });\n if (authorization && completed.status === 'installed' && params.verifyInstall) {\n await verifySentryInstallationBestEffort({\n sentry: params.sentry,\n installationUuid: params.installationUuid,\n token: authorization.token,\n });\n }\n}\n\nexport interface HandleSentryInstallationDeletedParams {\n tx: IntegrationTx;\n deliveryId: string;\n installationUuid: string;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n updateConnectionLifecycleStatus: UpdateIntegrationConnectionLifecycleStatusFn;\n}\n\n// Tombstones the install and disables its connection if one exists. An unknown\n// uuid also gets a tombstone, so a reordered creation cannot restore it. The\n// state change and delivery record share the caller's transaction.\nexport async function handleSentryInstallationDeleted(\n params: HandleSentryInstallationDeletedParams,\n): Promise<void> {\n const installation = await markSentryInstallationDeleted(\n {installationUuid: params.installationUuid},\n {tx: params.tx},\n );\n if (installation?.connectionId) {\n await params.updateConnectionLifecycleStatus(\n {id: installation.connectionId, lifecycleStatus: 'disabled'},\n {tx: params.tx},\n );\n }\n\n await params.recordDeliveryOnly({\n tx: params.tx,\n provider: SENTRY_SOURCE,\n deliveryId: params.deliveryId,\n });\n}\n\n// A raw Sentry `ignored` action is normalized to `archived` before validation,\n// so legacy ignore events still fire `issue.archived` workflows.\nexport function normalizeSentryIssueAction(parsedJson: unknown): unknown {\n if (typeof parsedJson !== 'object' || parsedJson === null) return parsedJson;\n const obj = parsedJson as {action?: unknown};\n if (obj.action === 'ignored') {\n return {...obj, action: 'archived'};\n }\n return parsedJson;\n}\n\nfunction normalizeIssuePayload(payload: SentryIssueWebhookDto): SentryIssuePayload {\n const issue = payload.data.issue;\n return {\n action: payload.action,\n issueId: issue.id,\n shortId: issue.shortId ?? null,\n title: issue.title ?? DEFAULT_ISSUE_TITLE,\n culprit: issue.culprit ?? null,\n level: issue.level ?? null,\n status: issue.status ?? null,\n platform: issue.platform ?? null,\n webUrl: issue.web_url ?? null,\n issueUrl: issue.url ?? null,\n projectUrl: issue.project_url ?? null,\n firstSeenAt: issue.firstSeen ?? null,\n lastSeenAt: issue.lastSeen ?? null,\n };\n}\n"],"names":["logger","SentryConnectionNotFoundError","SentryInstallationDeletedError","SentryInstallationNotFoundError","SentryInstallationUnclaimedError","SentryIntegrationProviderError","hashAuthorizationCode","verifySentryInstallationBestEffort","claimSentryInstallationVerification","getSentryInstallationByInstallationUuid","markSentryInstallationDeleted","markSentryInstallationExchangeSucceeded","SENTRY_SOURCE","DEFAULT_ISSUE_TITLE","DELETED_STATUS","handleSentryIssueEvent","params","installationUuid","payload","installation","uuid","tx","status","connectionId","connection","getIntegrationConnectionById","publishIntegrationEventReceived","event","provider","source","slug","action","workspaceId","id","connectionName","displayName","deliveryId","receivedAt","Date","toISOString","normalizeIssuePayload","handleSentryInstallationCreated","existing","getSentryInstallation","debug","recordDelivery","code","orgSlug","warn","codeHash","claimed","authorization","sentry","exchangeAuthorizationCode","error","reason","current","concurrentAttemptSucceeded","completed","persistUnclaimedAndRecordDelivery","verifyInstall","token","handleSentryInstallationDeleted","updateConnectionLifecycleStatus","lifecycleStatus","recordDeliveryOnly","normalizeSentryIssueAction","parsedJson","obj","issue","data","issueId","shortId","title","culprit","level","platform","webUrl","web_url","issueUrl","url","projectUrl","project_url","firstSeenAt","firstSeen","lastSeenAt","lastSeen"],"mappings":"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"}
package/dist/db/db.d.ts CHANGED
@@ -77,7 +77,7 @@ export declare const schema: {
77
77
  tableName: "installations";
78
78
  dataType: "string";
79
79
  columnType: "PgText";
80
- data: string;
80
+ data: import("./installations.js").SentryInstallationRowStatus;
81
81
  driverParam: string;
82
82
  notNull: true;
83
83
  hasDefault: false;
@@ -88,7 +88,9 @@ export declare const schema: {
88
88
  baseColumn: never;
89
89
  identity: undefined;
90
90
  generated: undefined;
91
- }, {}, {}>;
91
+ }, {}, {
92
+ $type: import("./installations.js").SentryInstallationRowStatus;
93
+ }>;
92
94
  codeHash: import("drizzle-orm/pg-core").PgColumn<{
93
95
  name: "code_hash";
94
96
  tableName: "installations";
@@ -239,7 +241,7 @@ export declare function db(): NodePgDatabase<{
239
241
  tableName: "installations";
240
242
  dataType: "string";
241
243
  columnType: "PgText";
242
- data: string;
244
+ data: import("./installations.js").SentryInstallationRowStatus;
243
245
  driverParam: string;
244
246
  notNull: true;
245
247
  hasDefault: false;
@@ -250,7 +252,9 @@ export declare function db(): NodePgDatabase<{
250
252
  baseColumn: never;
251
253
  identity: undefined;
252
254
  generated: undefined;
253
- }, {}, {}>;
255
+ }, {}, {
256
+ $type: import("./installations.js").SentryInstallationRowStatus;
257
+ }>;
254
258
  codeHash: import("drizzle-orm/pg-core").PgColumn<{
255
259
  name: "code_hash";
256
260
  tableName: "installations";
@@ -1 +1 @@
1
- {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/db/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,cAAc,EAAC,MAAM,uBAAuB,CAAC;AAInE,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAElB,CAAC;AAIF,wBAAgB,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAGjB;AAED,wBAAgB,OAAO,IAAI,IAAI,CAE9B"}
1
+ {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/db/db.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,cAAc,EAAC,MAAM,uBAAuB,CAAC;AAInE,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAElB,CAAC;AAIF,wBAAgB,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAGjB;AAED,wBAAgB,OAAO,IAAI,IAAI,CAE9B"}
@@ -1,10 +1,11 @@
1
1
  export type SentryInstallationStatus = 'installed' | 'deleted';
2
+ export type SentryInstallationRowStatus = 'pending' | 'exchange-succeeded' | SentryInstallationStatus;
2
3
  export interface SentryInstallation {
3
4
  id: string;
4
5
  connectionId: string | null;
5
6
  installationUuid: string;
6
7
  orgSlug: string;
7
- status: string;
8
+ status: SentryInstallationRowStatus;
8
9
  codeHash: string | null;
9
10
  installerUserId: string | null;
10
11
  createdAt: Date;
@@ -23,24 +24,43 @@ export interface PersistVerifiedUnclaimedInstallationParams {
23
24
  orgSlug: string;
24
25
  codeHash: string;
25
26
  }
27
+ export interface ClaimSentryInstallationVerificationParams {
28
+ installationUuid: string;
29
+ orgSlug: string;
30
+ codeHash: string;
31
+ }
26
32
  export declare function upsertSentryInstallation(params: UpsertSentryInstallationParams, options?: {
27
33
  tx?: unknown;
28
34
  }): Promise<SentryInstallation>;
29
35
  /**
30
36
  * Inserts the verified-but-unclaimed install half (`connection_id = NULL`,
31
37
  * `status = 'installed'`) after a successful code exchange. Idempotent on the
32
- * installation uuid: a conflicting row refreshes only `code_hash`/`org_slug`/
33
- * `updated_at`, so a webhook that lands after a claim never clobbers the
34
- * `connection_id` it set nor downgrades a `deleted` tombstone back to installed.
38
+ * installation uuid: a pending row becomes installed, a claimed row keeps its
39
+ * `connection_id`, and a deleted tombstone never becomes installed again.
35
40
  */
36
41
  export declare function persistVerifiedUnclaimedInstallation(params: PersistVerifiedUnclaimedInstallationParams, options?: {
37
42
  tx?: unknown;
38
43
  }): Promise<SentryInstallation>;
44
+ export declare function claimSentryInstallationVerification(params: ClaimSentryInstallationVerificationParams, options?: {
45
+ tx?: unknown;
46
+ }): Promise<SentryInstallation>;
47
+ export declare function completeSentryInstallationVerification(params: {
48
+ installationUuid: string;
49
+ codeHash: string;
50
+ }, options?: {
51
+ tx?: unknown;
52
+ }): Promise<SentryInstallation | undefined>;
53
+ export declare function markSentryInstallationExchangeSucceeded(params: {
54
+ installationUuid: string;
55
+ codeHash: string;
56
+ }, options?: {
57
+ tx?: unknown;
58
+ }): Promise<SentryInstallation | undefined>;
39
59
  /**
40
- * Verified installs no user has claimed yet (`connection_id IS NULL`,
41
- * `status='installed'`). Tombstoned rows are excluded so the TTL cron stays
42
- * idempotent and the unclaimed metric stays accurate. Pass `olderThan` to scope
43
- * to stale rows for the cron.
60
+ * Install claims no user has bound to a connection yet. This includes pending
61
+ * and exchanged claims so abandoned verification work remains observable.
62
+ * Tombstoned rows are excluded. Pass `olderThan` to scope by the most recent
63
+ * state transition for retention work.
44
64
  */
45
65
  export declare function listUnclaimedSentryInstallations(params?: {
46
66
  olderThan?: Date;
@@ -54,21 +74,23 @@ export declare function getSentryInstallationByConnectionId(connectionId: string
54
74
  tx?: unknown;
55
75
  }): Promise<SentryInstallation | undefined>;
56
76
  /**
57
- * Tombstones verified-unclaimed installs older than `olderThan` (the TTL cleanup
58
- * cron). Bounds a never-claimed install rather than leaving it pending forever.
59
- * Returns how many rows were tombstoned. We hold no Sentry token for these rows
60
- * (tokens are never persisted), so the Sentry-side uninstall is out of scope here.
77
+ * Releases abandoned pending claims and tombstones verified-unclaimed installs
78
+ * older than `olderThan`. Pending claims have not durably completed an exchange,
79
+ * so deleting them allows a later signed creation to try again. Exchanged and
80
+ * installed rows stay terminal after expiry. We hold no Sentry token for these
81
+ * rows, so the Sentry-side uninstall is out of scope here.
61
82
  */
62
83
  export declare function pruneUnclaimedSentryInstallations(params: {
63
84
  olderThan: Date;
64
85
  }, options?: {
65
86
  tx?: unknown;
66
87
  }): Promise<{
88
+ releasedPending: number;
67
89
  tombstoned: number;
68
90
  }>;
69
91
  export declare function markSentryInstallationDeleted(params: {
70
92
  installationUuid: string;
71
93
  }, options?: {
72
94
  tx?: unknown;
73
- }): Promise<SentryInstallation | undefined>;
95
+ }): Promise<SentryInstallation>;
74
96
  //# sourceMappingURL=installations.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"installations.d.ts","sourceRoot":"","sources":["../../src/db/installations.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,wBAAwB,GAAG,WAAW,GAAG,SAAS,CAAC;AAE/D,MAAM,WAAW,kBAAkB;IACjC,EAAE,EAAE,MAAM,CAAC;IACX,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,gBAAgB,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,8BAA8B;IAC7C,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,wBAAwB,CAAC;IACjC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACrC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CAC7C;AAED,MAAM,WAAW,0CAA0C;IACzD,gBAAgB,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAKD,wBAAsB,wBAAwB,CAC5C,MAAM,EAAE,8BAA8B,EACtC,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,kBAAkB,CAAC,CAqC7B;AAED;;;;;;GAMG;AACH,wBAAsB,oCAAoC,CACxD,MAAM,EAAE,0CAA0C,EAClD,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,kBAAkB,CAAC,CAwB7B;AAED;;;;;GAKG;AACH,wBAAsB,gCAAgC,CACpD,MAAM,GAAE;IAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAAM,EAC/B,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAc/B;AAED,wBAAsB,uCAAuC,CAC3D,gBAAgB,EAAE,MAAM,EACxB,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAUzC;AAED,wBAAsB,mCAAmC,CACvD,YAAY,EAAE,MAAM,EACpB,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAUzC;AAED;;;;;GAKG;AACH,wBAAsB,iCAAiC,CACrD,MAAM,EAAE;IAAC,SAAS,EAAE,IAAI,CAAA;CAAC,EACzB,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAC,CAAC,CAa/B;AAED,wBAAsB,6BAA6B,CACjD,MAAM,EAAE;IAAC,gBAAgB,EAAE,MAAM,CAAA;CAAC,EAClC,OAAO,GAAE;IAAC,EAAE,CAAC,EAAE,OAAO,CAAA;CAAM,GAC3B,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CASzC"}
1
+ {"version":3,"file":"installations.d.ts","sourceRoot":"","sources":["../../src/db/installations.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,4 @@
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';
@@ -14,15 +14,10 @@ export async function upsertSentryInstallation(params, options = {}) {
14
14
  installerUserId: params.installerUserId ?? null
15
15
  }).onConflictDoUpdate({
16
16
  target: sentryInstallations.installationUuid,
17
- // TOCTOU guard: allow the update only when this installation is unclaimed
18
- // (connection_id IS NULL, the first claim of a verified-unclaimed row) or
19
- // already owned by this same connection (idempotent reconnect). A concurrent
20
- // claim of the same installation to a different workspace commits its own
21
- // connection_id first; this losing transaction then sees a different non-null
22
- // connection_id, the predicate is false, Postgres updates nothing, and the
23
- // empty RETURNING below rolls it back instead of silently repointing the
24
- // installation (cross-tenant event misroute).
25
- setWhere: sql`${sentryInstallations.connectionId} is null or ${sentryInstallations.connectionId} = ${params.connectionId}`,
17
+ // A deleted UUID is terminal. Otherwise, allow the first claim of an
18
+ // unclaimed row or an idempotent reconnect by its current connection. This
19
+ // blocks both a concurrent cross-workspace claim and a claim racing deletion.
20
+ setWhere: sql`${sentryInstallations.status} <> 'deleted' and (${sentryInstallations.connectionId} is null or ${sentryInstallations.connectionId} = ${params.connectionId})`,
26
21
  set: {
27
22
  connectionId: params.connectionId,
28
23
  orgSlug: params.orgSlug,
@@ -38,9 +33,8 @@ export async function upsertSentryInstallation(params, options = {}) {
38
33
  /**
39
34
  * Inserts the verified-but-unclaimed install half (`connection_id = NULL`,
40
35
  * `status = 'installed'`) after a successful code exchange. Idempotent on the
41
- * installation uuid: a conflicting row refreshes only `code_hash`/`org_slug`/
42
- * `updated_at`, so a webhook that lands after a claim never clobbers the
43
- * `connection_id` it set nor downgrades a `deleted` tombstone back to installed.
36
+ * installation uuid: a pending row becomes installed, a claimed row keeps its
37
+ * `connection_id`, and a deleted tombstone never becomes installed again.
44
38
  */ export async function persistVerifiedUnclaimedInstallation(params, options = {}) {
45
39
  const executor = options.tx ?? db();
46
40
  const now = new Date();
@@ -52,28 +46,76 @@ export async function upsertSentryInstallation(params, options = {}) {
52
46
  codeHash: params.codeHash
53
47
  }).onConflictDoUpdate({
54
48
  target: sentryInstallations.installationUuid,
49
+ setWhere: ne(sentryInstallations.status, 'deleted'),
55
50
  set: {
56
51
  orgSlug: params.orgSlug,
52
+ status: 'installed',
57
53
  codeHash: params.codeHash,
58
54
  updatedAt: now
59
55
  }
60
56
  }).returning();
61
- if (!row) throw new Error('Sentry installation persist returned no rows');
57
+ if (!row) {
58
+ const existing = await getSentryInstallationByInstallationUuid(params.installationUuid, {
59
+ tx: executor
60
+ });
61
+ if (existing) return existing;
62
+ throw new Error('Sentry installation persist returned no rows');
63
+ }
64
+ return toSentryInstallation(row);
65
+ }
66
+ export async function claimSentryInstallationVerification(params, options = {}) {
67
+ const executor = options.tx ?? db();
68
+ const [inserted] = await executor.insert(sentryInstallations).values({
69
+ connectionId: null,
70
+ installationUuid: params.installationUuid,
71
+ orgSlug: params.orgSlug,
72
+ status: 'pending',
73
+ codeHash: params.codeHash
74
+ }).onConflictDoNothing({
75
+ target: sentryInstallations.installationUuid
76
+ }).returning();
77
+ if (inserted) return toSentryInstallation(inserted);
78
+ const existing = await getSentryInstallationByInstallationUuid(params.installationUuid, {
79
+ tx: executor
80
+ });
81
+ if (!existing) throw new Error('Sentry installation verification claim returned no rows');
82
+ return existing;
83
+ }
84
+ export async function completeSentryInstallationVerification(params, options = {}) {
85
+ const executor = options.tx ?? db();
86
+ const [row] = await executor.update(sentryInstallations).set({
87
+ status: 'installed',
88
+ updatedAt: new Date()
89
+ }).where(and(eq(sentryInstallations.installationUuid, params.installationUuid), eq(sentryInstallations.status, 'exchange-succeeded'), eq(sentryInstallations.codeHash, params.codeHash))).returning();
90
+ if (!row) return undefined;
91
+ return toSentryInstallation(row);
92
+ }
93
+ export async function markSentryInstallationExchangeSucceeded(params, options = {}) {
94
+ const executor = options.tx ?? db();
95
+ const [row] = await executor.update(sentryInstallations).set({
96
+ status: 'exchange-succeeded',
97
+ updatedAt: new Date()
98
+ }).where(and(eq(sentryInstallations.installationUuid, params.installationUuid), eq(sentryInstallations.status, 'pending'), eq(sentryInstallations.codeHash, params.codeHash))).returning();
99
+ if (!row) return undefined;
62
100
  return toSentryInstallation(row);
63
101
  }
64
102
  /**
65
- * Verified installs no user has claimed yet (`connection_id IS NULL`,
66
- * `status='installed'`). Tombstoned rows are excluded so the TTL cron stays
67
- * idempotent and the unclaimed metric stays accurate. Pass `olderThan` to scope
68
- * to stale rows for the cron.
103
+ * Install claims no user has bound to a connection yet. This includes pending
104
+ * and exchanged claims so abandoned verification work remains observable.
105
+ * Tombstoned rows are excluded. Pass `olderThan` to scope by the most recent
106
+ * state transition for retention work.
69
107
  */ export async function listUnclaimedSentryInstallations(params = {}, options = {}) {
70
108
  const executor = options.tx ?? db();
71
109
  const conditions = [
72
110
  isNull(sentryInstallations.connectionId),
73
- eq(sentryInstallations.status, 'installed')
111
+ inArray(sentryInstallations.status, [
112
+ 'pending',
113
+ 'exchange-succeeded',
114
+ 'installed'
115
+ ])
74
116
  ];
75
117
  if (params.olderThan) {
76
- conditions.push(lt(sentryInstallations.createdAt, params.olderThan));
118
+ conditions.push(lt(sentryInstallations.updatedAt, params.olderThan));
77
119
  }
78
120
  const rows = await executor.select().from(sentryInstallations).where(and(...conditions));
79
121
  return rows.map(toSentryInstallation);
@@ -93,27 +135,43 @@ export async function getSentryInstallationByConnectionId(connectionId, options
93
135
  return toSentryInstallation(row);
94
136
  }
95
137
  /**
96
- * Tombstones verified-unclaimed installs older than `olderThan` (the TTL cleanup
97
- * cron). Bounds a never-claimed install rather than leaving it pending forever.
98
- * Returns how many rows were tombstoned. We hold no Sentry token for these rows
99
- * (tokens are never persisted), so the Sentry-side uninstall is out of scope here.
138
+ * Releases abandoned pending claims and tombstones verified-unclaimed installs
139
+ * older than `olderThan`. Pending claims have not durably completed an exchange,
140
+ * so deleting them allows a later signed creation to try again. Exchanged and
141
+ * installed rows stay terminal after expiry. We hold no Sentry token for these
142
+ * rows, so the Sentry-side uninstall is out of scope here.
100
143
  */ export async function pruneUnclaimedSentryInstallations(params, options = {}) {
101
144
  const executor = options.tx ?? db();
102
- const result = await executor.update(sentryInstallations).set({
145
+ const releasedPending = await executor.delete(sentryInstallations).where(and(isNull(sentryInstallations.connectionId), eq(sentryInstallations.status, 'pending'), lt(sentryInstallations.updatedAt, params.olderThan)));
146
+ const tombstoned = await executor.update(sentryInstallations).set({
103
147
  status: 'deleted',
104
148
  updatedAt: new Date()
105
- }).where(and(isNull(sentryInstallations.connectionId), eq(sentryInstallations.status, 'installed'), lt(sentryInstallations.createdAt, params.olderThan)));
149
+ }).where(and(isNull(sentryInstallations.connectionId), inArray(sentryInstallations.status, [
150
+ 'exchange-succeeded',
151
+ 'installed'
152
+ ]), lt(sentryInstallations.updatedAt, params.olderThan)));
106
153
  return {
107
- tombstoned: result.rowCount ?? 0
154
+ releasedPending: releasedPending.rowCount ?? 0,
155
+ tombstoned: tombstoned.rowCount ?? 0
108
156
  };
109
157
  }
110
158
  export async function markSentryInstallationDeleted(params, options = {}) {
111
159
  const executor = options.tx ?? db();
112
- const [row] = await executor.update(sentryInstallations).set({
160
+ const [row] = await executor.insert(sentryInstallations).values({
161
+ connectionId: null,
162
+ installationUuid: params.installationUuid,
163
+ // A reordered delete can arrive without organization data. Deleted rows never expose an organization URL.
164
+ orgSlug: '',
113
165
  status: 'deleted',
114
- updatedAt: new Date()
115
- }).where(eq(sentryInstallations.installationUuid, params.installationUuid)).returning();
116
- if (!row) return undefined;
166
+ codeHash: null
167
+ }).onConflictDoUpdate({
168
+ target: sentryInstallations.installationUuid,
169
+ set: {
170
+ status: 'deleted',
171
+ updatedAt: new Date()
172
+ }
173
+ }).returning();
174
+ if (!row) throw new Error('Sentry installation deletion returned no rows');
117
175
  return toSentryInstallation(row);
118
176
  }
119
177