@shipfox/api-integration-sentry 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +7 -7
- package/CHANGELOG.md +31 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -5
- package/dist/config.js.map +1 -1
- package/dist/core/install.d.ts +6 -0
- package/dist/core/install.d.ts.map +1 -1
- package/dist/core/install.js +8 -5
- package/dist/core/install.js.map +1 -1
- package/dist/core/signature.d.ts +1 -1
- package/dist/core/signature.d.ts.map +1 -1
- package/dist/core/signature.js.map +1 -1
- package/dist/core/webhook-processor.d.ts +16 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +198 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/core/webhook.d.ts +4 -4
- package/dist/core/webhook.d.ts.map +1 -1
- package/dist/core/webhook.js +63 -49
- package/dist/core/webhook.js.map +1 -1
- package/dist/db/db.d.ts +8 -4
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +35 -13
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +89 -31
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +5 -3
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +6 -6
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +4 -1
- package/dist/presentation/routes/webhooks.d.ts.map +1 -1
- package/dist/presentation/routes/webhooks.js +69 -43
- package/dist/presentation/routes/webhooks.js.map +1 -1
- package/dist/temporal/activities/prune-unclaimed-installations.d.ts +1 -0
- package/dist/temporal/activities/prune-unclaimed-installations.d.ts.map +1 -1
- package/dist/temporal/activities/prune-unclaimed-installations.js.map +1 -1
- package/dist/temporal/workflows/index.bundle.js +7 -2
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts.map +1 -1
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.js +6 -1
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +12 -10
- package/src/config.ts +4 -5
- package/src/core/install.ts +21 -7
- package/src/core/signature.ts +1 -1
- package/src/core/webhook-processor.test.ts +304 -0
- package/src/core/webhook-processor.ts +224 -0
- package/src/core/webhook.ts +74 -53
- package/src/db/installations.test.ts +163 -17
- package/src/db/installations.ts +136 -35
- package/src/db/schema/installations.ts +7 -7
- package/src/index.test.ts +4 -1
- package/src/index.ts +16 -0
- package/src/presentation/routes/webhooks.test.ts +6 -5
- package/src/presentation/routes/webhooks.ts +88 -29
- package/src/temporal/activities/prune-unclaimed-installations.test.ts +24 -2
- package/src/temporal/activities/prune-unclaimed-installations.ts +4 -1
- package/src/temporal/workflows/prune-unclaimed-installations-cron.ts +4 -1
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/presentation/routes/installation-handler.d.ts +0 -9
- package/dist/presentation/routes/installation-handler.d.ts.map +0 -1
- package/dist/presentation/routes/installation-handler.js +0 -70
- package/dist/presentation/routes/installation-handler.js.map +0 -1
- package/dist/presentation/routes/issue-handler.d.ts +0 -9
- package/dist/presentation/routes/issue-handler.d.ts.map +0 -1
- package/dist/presentation/routes/issue-handler.js +0 -50
- package/dist/presentation/routes/issue-handler.js.map +0 -1
- package/dist/presentation/routes/webhook-delivery.d.ts +0 -28
- package/dist/presentation/routes/webhook-delivery.d.ts.map +0 -1
- package/dist/presentation/routes/webhook-delivery.js +0 -56
- package/dist/presentation/routes/webhook-delivery.js.map +0 -1
- package/dist/presentation/routes/webhook-request.d.ts +0 -10
- package/dist/presentation/routes/webhook-request.d.ts.map +0 -1
- package/dist/presentation/routes/webhook-request.js +0 -56
- package/dist/presentation/routes/webhook-request.js.map +0 -1
- package/src/presentation/routes/installation-handler.ts +0 -76
- package/src/presentation/routes/issue-handler.ts +0 -52
- package/src/presentation/routes/webhook-delivery.ts +0 -61
- package/src/presentation/routes/webhook-request.ts +0 -66
package/dist/core/webhook.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
73
|
+
}, 'sentry webhook: installation.created without claim input, dropping');
|
|
74
74
|
await params.recordDelivery(params.deliveryId);
|
|
75
75
|
return;
|
|
76
76
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
}
|
|
116
|
+
codeHash
|
|
117
|
+
});
|
|
115
118
|
}
|
|
116
|
-
|
|
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
|
|
121
|
-
//
|
|
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
|
package/dist/core/webhook.js.map
CHANGED
|
@@ -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:
|
|
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:
|
|
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";
|
package/dist/db/db.d.ts.map
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
33
|
-
* `
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
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;
|
|
1
|
+
{"version":3,"file":"installations.d.ts","sourceRoot":"","sources":["../../src/db/installations.ts"],"names":[],"mappings":"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"}
|
package/dist/db/installations.js
CHANGED
|
@@ -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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
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
|
|
42
|
-
* `
|
|
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)
|
|
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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
111
|
+
inArray(sentryInstallations.status, [
|
|
112
|
+
'pending',
|
|
113
|
+
'exchange-succeeded',
|
|
114
|
+
'installed'
|
|
115
|
+
])
|
|
74
116
|
];
|
|
75
117
|
if (params.olderThan) {
|
|
76
|
-
conditions.push(lt(sentryInstallations.
|
|
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
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
|
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),
|
|
149
|
+
}).where(and(isNull(sentryInstallations.connectionId), inArray(sentryInstallations.status, [
|
|
150
|
+
'exchange-succeeded',
|
|
151
|
+
'installed'
|
|
152
|
+
]), lt(sentryInstallations.updatedAt, params.olderThan)));
|
|
106
153
|
return {
|
|
107
|
-
|
|
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.
|
|
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
|
-
|
|
115
|
-
}).
|
|
116
|
-
|
|
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
|
|