@shipfox/api-integration-sentry 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +192 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/dist/api/client.d.ts +24 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +101 -0
- package/dist/api/client.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +32 -0
- package/dist/config.js.map +1 -0
- package/dist/core/errors.d.ts +57 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +67 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/install.d.ts +63 -0
- package/dist/core/install.d.ts.map +1 -0
- package/dist/core/install.js +209 -0
- package/dist/core/install.js.map +1 -0
- package/dist/core/signature.d.ts +7 -0
- package/dist/core/signature.d.ts.map +1 -0
- package/dist/core/signature.js +6 -0
- package/dist/core/signature.js.map +1 -0
- package/dist/core/webhook.d.ts +49 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +175 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +327 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +18 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/installations.d.ts +74 -0
- package/dist/db/installations.d.ts.map +1 -0
- package/dist/db/installations.js +120 -0
- package/dist/db/installations.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +5 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/installations.d.ts +165 -0
- package/dist/db/schema/installations.d.ts.map +1 -0
- package/dist/db/schema/installations.js +42 -0
- package/dist/db/schema/installations.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation/dto/integrations.d.ts +15 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +9 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +51 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/install.d.ts +16 -0
- package/dist/presentation/routes/install.d.ts.map +1 -0
- package/dist/presentation/routes/install.js +76 -0
- package/dist/presentation/routes/install.js.map +1 -0
- package/dist/presentation/routes/installation-handler.d.ts +9 -0
- package/dist/presentation/routes/installation-handler.d.ts.map +1 -0
- package/dist/presentation/routes/installation-handler.js +70 -0
- package/dist/presentation/routes/installation-handler.js.map +1 -0
- package/dist/presentation/routes/issue-handler.d.ts +9 -0
- package/dist/presentation/routes/issue-handler.d.ts.map +1 -0
- package/dist/presentation/routes/issue-handler.js +50 -0
- package/dist/presentation/routes/issue-handler.js.map +1 -0
- package/dist/presentation/routes/webhook-context.d.ts +20 -0
- package/dist/presentation/routes/webhook-context.d.ts.map +1 -0
- package/dist/presentation/routes/webhook-context.js +10 -0
- package/dist/presentation/routes/webhook-context.js.map +1 -0
- package/dist/presentation/routes/webhook-delivery.d.ts +28 -0
- package/dist/presentation/routes/webhook-delivery.d.ts.map +1 -0
- package/dist/presentation/routes/webhook-delivery.js +56 -0
- package/dist/presentation/routes/webhook-delivery.js.map +1 -0
- package/dist/presentation/routes/webhook-request.d.ts +10 -0
- package/dist/presentation/routes/webhook-request.d.ts.map +1 -0
- package/dist/presentation/routes/webhook-request.js +56 -0
- package/dist/presentation/routes/webhook-request.js.map +1 -0
- package/dist/presentation/routes/webhooks.d.ts +5 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +71 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +5 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +8 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/prune-unclaimed-installations.d.ts +4 -0
- package/dist/temporal/activities/prune-unclaimed-installations.d.ts.map +1 -0
- package/dist/temporal/activities/prune-unclaimed-installations.js +11 -0
- package/dist/temporal/activities/prune-unclaimed-installations.js.map +1 -0
- package/dist/temporal/constants.d.ts +2 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +3 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts +2 -0
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.js +14 -0
- package/dist/temporal/workflows/prune-unclaimed-installations-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +14 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +70 -0
- package/src/api/client.test.ts +147 -0
- package/src/api/client.ts +147 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +35 -0
- package/src/connection-external-url.test.ts +79 -0
- package/src/core/errors.ts +81 -0
- package/src/core/install.test.ts +343 -0
- package/src/core/install.ts +305 -0
- package/src/core/signature.test.ts +50 -0
- package/src/core/signature.ts +11 -0
- package/src/core/webhook.test.ts +118 -0
- package/src/core/webhook.ts +246 -0
- package/src/db/db.ts +18 -0
- package/src/db/installations.test.ts +224 -0
- package/src/db/installations.ts +207 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/installations.ts +46 -0
- package/src/index.test.ts +18 -0
- package/src/index.ts +127 -0
- package/src/presentation/dto/integrations.ts +9 -0
- package/src/presentation/routes/errors.ts +48 -0
- package/src/presentation/routes/install.test.ts +325 -0
- package/src/presentation/routes/install.ts +102 -0
- package/src/presentation/routes/installation-handler.ts +76 -0
- package/src/presentation/routes/issue-handler.ts +52 -0
- package/src/presentation/routes/webhook-context.ts +25 -0
- package/src/presentation/routes/webhook-delivery.ts +61 -0
- package/src/presentation/routes/webhook-request.ts +66 -0
- package/src/presentation/routes/webhooks.test.ts +773 -0
- package/src/presentation/routes/webhooks.ts +59 -0
- package/src/temporal/activities/index.ts +7 -0
- package/src/temporal/activities/prune-unclaimed-installations.test.ts +52 -0
- package/src/temporal/activities/prune-unclaimed-installations.ts +11 -0
- package/src/temporal/constants.ts +1 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/prune-unclaimed-installations-cron.ts +15 -0
- package/test/env.ts +11 -0
- package/test/factories/index.ts +9 -0
- package/test/factories/sentry-installation-webhook.ts +35 -0
- package/test/factories/sentry-installation.ts +44 -0
- package/test/factories/sentry-issue-webhook.ts +34 -0
- package/test/globalSetup.ts +14 -0
- package/test/index.ts +9 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +9 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {verifyHexHmacSignature} from '@shipfox/node-fastify';
|
|
2
|
+
|
|
3
|
+
export interface VerifySentrySignatureParams {
|
|
4
|
+
rawBody: string;
|
|
5
|
+
signature: string;
|
|
6
|
+
secret: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function verifySentrySignature(params: VerifySentrySignatureParams): boolean {
|
|
10
|
+
return verifyHexHmacSignature(params);
|
|
11
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import type {SentryIssueWebhookDto} from '@shipfox/api-integration-sentry-dto';
|
|
3
|
+
import {db} from '#db/db.js';
|
|
4
|
+
import {sentryInstallations} from '#db/schema/installations.js';
|
|
5
|
+
import {sentryInstallationFactory} from '#test/index.js';
|
|
6
|
+
import {
|
|
7
|
+
SentryConnectionNotFoundError,
|
|
8
|
+
SentryInstallationDeletedError,
|
|
9
|
+
SentryInstallationNotFoundError,
|
|
10
|
+
SentryInstallationUnclaimedError,
|
|
11
|
+
} from './errors.js';
|
|
12
|
+
import {handleSentryIssueEvent, normalizeSentryIssueAction} from './webhook.js';
|
|
13
|
+
|
|
14
|
+
function issuePayload(installationUuid: string): SentryIssueWebhookDto {
|
|
15
|
+
return {
|
|
16
|
+
action: 'created',
|
|
17
|
+
installation: {uuid: installationUuid},
|
|
18
|
+
data: {issue: {id: 'issue-1'}},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('handleSentryIssueEvent', () => {
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
await db().delete(sentryInstallations);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('throws SentryInstallationNotFoundError for an unknown installation', async () => {
|
|
28
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
29
|
+
const getIntegrationConnectionById = vi.fn();
|
|
30
|
+
|
|
31
|
+
const run = handleSentryIssueEvent({
|
|
32
|
+
tx: db(),
|
|
33
|
+
deliveryId: randomUUID(),
|
|
34
|
+
payload: issuePayload('unknown-uuid'),
|
|
35
|
+
publishIntegrationEventReceived,
|
|
36
|
+
getIntegrationConnectionById,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await expect(run).rejects.toBeInstanceOf(SentryInstallationNotFoundError);
|
|
40
|
+
expect(getIntegrationConnectionById).not.toHaveBeenCalled();
|
|
41
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('throws SentryInstallationDeletedError for a deleted installation', async () => {
|
|
45
|
+
const installationUuid = randomUUID();
|
|
46
|
+
await sentryInstallationFactory.create({installationUuid, status: 'deleted'});
|
|
47
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
48
|
+
const getIntegrationConnectionById = vi.fn();
|
|
49
|
+
|
|
50
|
+
const run = handleSentryIssueEvent({
|
|
51
|
+
tx: db(),
|
|
52
|
+
deliveryId: randomUUID(),
|
|
53
|
+
payload: issuePayload(installationUuid),
|
|
54
|
+
publishIntegrationEventReceived,
|
|
55
|
+
getIntegrationConnectionById,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await expect(run).rejects.toBeInstanceOf(SentryInstallationDeletedError);
|
|
59
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('throws SentryInstallationUnclaimedError for a verified-but-unclaimed installation', async () => {
|
|
63
|
+
const installationUuid = randomUUID();
|
|
64
|
+
await sentryInstallationFactory.create({installationUuid, connectionId: null});
|
|
65
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
66
|
+
const getIntegrationConnectionById = vi.fn();
|
|
67
|
+
|
|
68
|
+
const run = handleSentryIssueEvent({
|
|
69
|
+
tx: db(),
|
|
70
|
+
deliveryId: randomUUID(),
|
|
71
|
+
payload: issuePayload(installationUuid),
|
|
72
|
+
publishIntegrationEventReceived,
|
|
73
|
+
getIntegrationConnectionById,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await expect(run).rejects.toBeInstanceOf(SentryInstallationUnclaimedError);
|
|
77
|
+
expect(getIntegrationConnectionById).not.toHaveBeenCalled();
|
|
78
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('throws SentryConnectionNotFoundError when the installation has no connection', async () => {
|
|
82
|
+
const installationUuid = randomUUID();
|
|
83
|
+
await sentryInstallationFactory.create({installationUuid});
|
|
84
|
+
const publishIntegrationEventReceived = vi.fn(() => Promise.resolve({published: true}));
|
|
85
|
+
const getIntegrationConnectionById = vi.fn(() => Promise.resolve(undefined));
|
|
86
|
+
|
|
87
|
+
const run = handleSentryIssueEvent({
|
|
88
|
+
tx: db(),
|
|
89
|
+
deliveryId: randomUUID(),
|
|
90
|
+
payload: issuePayload(installationUuid),
|
|
91
|
+
publishIntegrationEventReceived,
|
|
92
|
+
getIntegrationConnectionById,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await expect(run).rejects.toBeInstanceOf(SentryConnectionNotFoundError);
|
|
96
|
+
expect(getIntegrationConnectionById).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(publishIntegrationEventReceived).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('normalizeSentryIssueAction', () => {
|
|
102
|
+
test('rewrites a legacy "ignored" action to "archived"', () => {
|
|
103
|
+
const result = normalizeSentryIssueAction({action: 'ignored', data: {issue: {id: '1'}}});
|
|
104
|
+
|
|
105
|
+
expect(result).toMatchObject({action: 'archived', data: {issue: {id: '1'}}});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('leaves a known action untouched', () => {
|
|
109
|
+
const result = normalizeSentryIssueAction({action: 'resolved'});
|
|
110
|
+
|
|
111
|
+
expect(result).toMatchObject({action: 'resolved'});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('passes through a non-object payload unchanged', () => {
|
|
115
|
+
expect(normalizeSentryIssueAction(null)).toBeNull();
|
|
116
|
+
expect(normalizeSentryIssueAction('nope')).toBe('nope');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GetIntegrationConnectionByIdFn,
|
|
3
|
+
IntegrationTx,
|
|
4
|
+
PublishIntegrationEventReceivedFn,
|
|
5
|
+
RecordDeliveryOnlyFn,
|
|
6
|
+
SentryIssuePayload,
|
|
7
|
+
UpdateIntegrationConnectionLifecycleStatusFn,
|
|
8
|
+
} from '@shipfox/api-integration-core-dto';
|
|
9
|
+
import type {SentryIssueWebhookDto} from '@shipfox/api-integration-sentry-dto';
|
|
10
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
11
|
+
import type {SentryApiClient} from '#api/client.js';
|
|
12
|
+
import {
|
|
13
|
+
SentryConnectionNotFoundError,
|
|
14
|
+
SentryInstallationDeletedError,
|
|
15
|
+
SentryInstallationNotFoundError,
|
|
16
|
+
SentryInstallationUnclaimedError,
|
|
17
|
+
SentryIntegrationProviderError,
|
|
18
|
+
} from '#core/errors.js';
|
|
19
|
+
import {verifyAndPersistUnclaimedInstallation} from '#core/install.js';
|
|
20
|
+
import {
|
|
21
|
+
getSentryInstallationByInstallationUuid,
|
|
22
|
+
markSentryInstallationDeleted,
|
|
23
|
+
type SentryInstallation,
|
|
24
|
+
} from '#db/installations.js';
|
|
25
|
+
|
|
26
|
+
const SENTRY_SOURCE = 'sentry';
|
|
27
|
+
const DEFAULT_ISSUE_TITLE = 'Sentry issue';
|
|
28
|
+
const DELETED_STATUS = 'deleted';
|
|
29
|
+
|
|
30
|
+
export interface HandleSentryIssueEventParams {
|
|
31
|
+
tx: IntegrationTx;
|
|
32
|
+
deliveryId: string;
|
|
33
|
+
payload: SentryIssueWebhookDto;
|
|
34
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
35
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Publishes the mapped event for a verified issue delivery. Throws a typed
|
|
39
|
+
// SentryIssueDroppedError subclass when the delivery references state we cannot
|
|
40
|
+
// publish against (unknown/deleted installation, or an install not yet claimed
|
|
41
|
+
// into a workspace); the webhook layer records-and-drops those. Dedup of an
|
|
42
|
+
// already-seen delivery is handled inside publishIntegrationEventReceived.
|
|
43
|
+
export async function handleSentryIssueEvent(params: HandleSentryIssueEventParams): Promise<void> {
|
|
44
|
+
const installationUuid = params.payload.installation.uuid;
|
|
45
|
+
|
|
46
|
+
const installation = await getSentryInstallationByInstallationUuid(installationUuid, {
|
|
47
|
+
tx: params.tx,
|
|
48
|
+
});
|
|
49
|
+
if (!installation) {
|
|
50
|
+
throw new SentryInstallationNotFoundError(installationUuid);
|
|
51
|
+
}
|
|
52
|
+
if (installation.status === DELETED_STATUS) {
|
|
53
|
+
throw new SentryInstallationDeletedError(installationUuid);
|
|
54
|
+
}
|
|
55
|
+
// A verified-but-unclaimed install has no workspace yet, so there is nothing to
|
|
56
|
+
// publish an event against. This is the pre-claim drop window (counted/logged).
|
|
57
|
+
if (!installation.connectionId) {
|
|
58
|
+
throw new SentryInstallationUnclaimedError(installationUuid);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const connection = await params.getIntegrationConnectionById(installation.connectionId, {
|
|
62
|
+
tx: params.tx,
|
|
63
|
+
});
|
|
64
|
+
if (!connection) {
|
|
65
|
+
throw new SentryConnectionNotFoundError(installation.connectionId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await params.publishIntegrationEventReceived({
|
|
69
|
+
tx: params.tx,
|
|
70
|
+
event: {
|
|
71
|
+
provider: SENTRY_SOURCE,
|
|
72
|
+
source: connection.slug,
|
|
73
|
+
event: `issue.${params.payload.action}`,
|
|
74
|
+
workspaceId: connection.workspaceId,
|
|
75
|
+
connectionId: connection.id,
|
|
76
|
+
connectionName: connection.displayName,
|
|
77
|
+
deliveryId: params.deliveryId,
|
|
78
|
+
receivedAt: new Date().toISOString(),
|
|
79
|
+
payload: normalizeIssuePayload(params.payload),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface HandleSentryInstallationCreatedParams {
|
|
85
|
+
deliveryId: string;
|
|
86
|
+
installationUuid: string;
|
|
87
|
+
// From the signed payload; the webhook is authoritative so the slug is trusted
|
|
88
|
+
// without a getInstallation round-trip. The code is the single-use grant.
|
|
89
|
+
orgSlug: string | undefined;
|
|
90
|
+
code: string | undefined;
|
|
91
|
+
sentry: SentryApiClient;
|
|
92
|
+
verifyInstall: boolean;
|
|
93
|
+
getSentryInstallation: (input: {
|
|
94
|
+
installationUuid: string;
|
|
95
|
+
}) => Promise<SentryInstallation | undefined>;
|
|
96
|
+
// Persists the verified-unclaimed row and records the delivery in one short
|
|
97
|
+
// transaction, returning the persisted row.
|
|
98
|
+
persistUnclaimedAndRecordDelivery: (input: {
|
|
99
|
+
installationUuid: string;
|
|
100
|
+
orgSlug: string;
|
|
101
|
+
codeHash: string;
|
|
102
|
+
deliveryId: string;
|
|
103
|
+
}) => Promise<SentryInstallation>;
|
|
104
|
+
// Records the delivery for dedup without persisting an install (reconcile/no-op,
|
|
105
|
+
// missing code, or exchange failure → record-and-drop).
|
|
106
|
+
recordDelivery: (deliveryId: string) => Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The authoritative `installation.created` path. Whichever of webhook or browser
|
|
111
|
+
* arrives first exchanges the single-use code and persists a verified-unclaimed
|
|
112
|
+
* row; the other reconciles. Idempotent on the installation uuid. The exchange
|
|
113
|
+
* runs outside any DB transaction; a short transaction wraps only persist +
|
|
114
|
+
* delivery record. On an exchange failure the delivery is record-and-dropped
|
|
115
|
+
* (204) — the browser may still win, or the code expired. Never logs the raw code.
|
|
116
|
+
*/
|
|
117
|
+
export async function handleSentryInstallationCreated(
|
|
118
|
+
params: HandleSentryInstallationCreatedParams,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const existing = await params.getSentryInstallation({installationUuid: params.installationUuid});
|
|
121
|
+
if (existing) {
|
|
122
|
+
logger().debug(
|
|
123
|
+
{deliveryId: params.deliveryId, installationUuid: params.installationUuid},
|
|
124
|
+
'sentry webhook: installation.created for an existing row, reconciling',
|
|
125
|
+
);
|
|
126
|
+
await params.recordDelivery(params.deliveryId);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!params.code) {
|
|
131
|
+
logger().warn(
|
|
132
|
+
{deliveryId: params.deliveryId, installationUuid: params.installationUuid},
|
|
133
|
+
'sentry webhook: installation.created without an authorization code, dropping',
|
|
134
|
+
);
|
|
135
|
+
await params.recordDelivery(params.deliveryId);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await verifyAndPersistUnclaimedInstallation({
|
|
141
|
+
sentry: params.sentry,
|
|
142
|
+
installationUuid: params.installationUuid,
|
|
143
|
+
code: params.code,
|
|
144
|
+
orgSlug: params.orgSlug,
|
|
145
|
+
verifyInstall: params.verifyInstall,
|
|
146
|
+
persistVerifiedUnclaimedInstallation: ({installationUuid, orgSlug, codeHash}) =>
|
|
147
|
+
params.persistUnclaimedAndRecordDelivery({
|
|
148
|
+
installationUuid,
|
|
149
|
+
orgSlug,
|
|
150
|
+
codeHash,
|
|
151
|
+
deliveryId: params.deliveryId,
|
|
152
|
+
}),
|
|
153
|
+
});
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error instanceof SentryIntegrationProviderError) {
|
|
156
|
+
// The Sentry exchange (or org-slug lookup) failed, so we never durably
|
|
157
|
+
// spent the code on a row we own. `reason` distinguishes a transient/expected
|
|
158
|
+
// drop (access-denied: the code was already spent, e.g. the browser won) from
|
|
159
|
+
// a likely misconfiguration (a bad client id/secret/slug fails every install)
|
|
160
|
+
// so log-based alerting can fire on the sustained case. Never logs the raw code.
|
|
161
|
+
logger().warn(
|
|
162
|
+
{
|
|
163
|
+
deliveryId: params.deliveryId,
|
|
164
|
+
installationUuid: params.installationUuid,
|
|
165
|
+
reason: error.reason,
|
|
166
|
+
err: error,
|
|
167
|
+
},
|
|
168
|
+
'sentry webhook: installation.created exchange failed, dropping',
|
|
169
|
+
);
|
|
170
|
+
} else {
|
|
171
|
+
// The exchange succeeded (the single-use code is now spent) but the persist
|
|
172
|
+
// transaction failed, so no row exists and Sentry will not usefully
|
|
173
|
+
// re-deliver — the install is stranded until a reinstall mints a fresh uuid.
|
|
174
|
+
// Log at error so alerting catches it; still record-and-drop because retrying
|
|
175
|
+
// a spent code cannot recover the row. Never logs the raw code.
|
|
176
|
+
logger().error(
|
|
177
|
+
{deliveryId: params.deliveryId, installationUuid: params.installationUuid, err: error},
|
|
178
|
+
'sentry webhook: installation.created persisted nothing after a successful exchange, install stranded until reinstall',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
await params.recordDelivery(params.deliveryId);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface HandleSentryInstallationDeletedParams {
|
|
186
|
+
tx: IntegrationTx;
|
|
187
|
+
deliveryId: string;
|
|
188
|
+
installationUuid: string;
|
|
189
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
190
|
+
updateConnectionLifecycleStatus: UpdateIntegrationConnectionLifecycleStatusFn;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Tombstones the install and disables its connection if one exists. An unknown
|
|
194
|
+
// uuid (never installed, or reinstall mints a fresh uuid) only records the
|
|
195
|
+
// delivery: there is no tombstone row to write. Runs entirely in the caller's
|
|
196
|
+
// transaction — no exchange is needed.
|
|
197
|
+
export async function handleSentryInstallationDeleted(
|
|
198
|
+
params: HandleSentryInstallationDeletedParams,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const installation = await markSentryInstallationDeleted(
|
|
201
|
+
{installationUuid: params.installationUuid},
|
|
202
|
+
{tx: params.tx},
|
|
203
|
+
);
|
|
204
|
+
if (installation?.connectionId) {
|
|
205
|
+
await params.updateConnectionLifecycleStatus(
|
|
206
|
+
{id: installation.connectionId, lifecycleStatus: 'disabled'},
|
|
207
|
+
{tx: params.tx},
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await params.recordDeliveryOnly({
|
|
212
|
+
tx: params.tx,
|
|
213
|
+
provider: SENTRY_SOURCE,
|
|
214
|
+
deliveryId: params.deliveryId,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A raw Sentry `ignored` action is normalized to `archived` before validation,
|
|
219
|
+
// so legacy ignore events still fire `issue.archived` workflows.
|
|
220
|
+
export function normalizeSentryIssueAction(parsedJson: unknown): unknown {
|
|
221
|
+
if (typeof parsedJson !== 'object' || parsedJson === null) return parsedJson;
|
|
222
|
+
const obj = parsedJson as {action?: unknown};
|
|
223
|
+
if (obj.action === 'ignored') {
|
|
224
|
+
return {...obj, action: 'archived'};
|
|
225
|
+
}
|
|
226
|
+
return parsedJson;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeIssuePayload(payload: SentryIssueWebhookDto): SentryIssuePayload {
|
|
230
|
+
const issue = payload.data.issue;
|
|
231
|
+
return {
|
|
232
|
+
action: payload.action,
|
|
233
|
+
issueId: issue.id,
|
|
234
|
+
shortId: issue.shortId ?? null,
|
|
235
|
+
title: issue.title ?? DEFAULT_ISSUE_TITLE,
|
|
236
|
+
culprit: issue.culprit ?? null,
|
|
237
|
+
level: issue.level ?? null,
|
|
238
|
+
status: issue.status ?? null,
|
|
239
|
+
platform: issue.platform ?? null,
|
|
240
|
+
webUrl: issue.web_url ?? null,
|
|
241
|
+
issueUrl: issue.url ?? null,
|
|
242
|
+
projectUrl: issue.project_url ?? null,
|
|
243
|
+
firstSeenAt: issue.firstSeen ?? null,
|
|
244
|
+
lastSeenAt: issue.lastSeen ?? null,
|
|
245
|
+
};
|
|
246
|
+
}
|
package/src/db/db.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
|
|
2
|
+
import {pgClient} from '@shipfox/node-postgres';
|
|
3
|
+
import {sentryInstallations} from './schema/installations.js';
|
|
4
|
+
|
|
5
|
+
export const schema = {
|
|
6
|
+
sentryInstallations,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
let _db: NodePgDatabase<typeof schema> | undefined;
|
|
10
|
+
|
|
11
|
+
export function db() {
|
|
12
|
+
if (!_db) _db = drizzle(pgClient(), {schema});
|
|
13
|
+
return _db;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function closeDb(): void {
|
|
17
|
+
_db = undefined;
|
|
18
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {SentryInstallationAlreadyLinkedError} from '#core/errors.js';
|
|
3
|
+
import {db} from './db.js';
|
|
4
|
+
import {
|
|
5
|
+
getSentryInstallationByInstallationUuid,
|
|
6
|
+
listUnclaimedSentryInstallations,
|
|
7
|
+
markSentryInstallationDeleted,
|
|
8
|
+
persistVerifiedUnclaimedInstallation,
|
|
9
|
+
pruneUnclaimedSentryInstallations,
|
|
10
|
+
upsertSentryInstallation,
|
|
11
|
+
} from './installations.js';
|
|
12
|
+
import {sentryInstallations} from './schema/installations.js';
|
|
13
|
+
|
|
14
|
+
describe('sentry installations persistence', () => {
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
await db().delete(sentryInstallations);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('upsert updates in place when the same connection reconnects, without duplicating', async () => {
|
|
20
|
+
const installationUuid = randomUUID();
|
|
21
|
+
const connectionId = randomUUID();
|
|
22
|
+
|
|
23
|
+
await upsertSentryInstallation({
|
|
24
|
+
connectionId,
|
|
25
|
+
installationUuid,
|
|
26
|
+
orgSlug: 'acme',
|
|
27
|
+
status: 'installed',
|
|
28
|
+
});
|
|
29
|
+
const updated = await upsertSentryInstallation({
|
|
30
|
+
connectionId,
|
|
31
|
+
installationUuid,
|
|
32
|
+
orgSlug: 'acme-renamed',
|
|
33
|
+
status: 'installed',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(updated.connectionId).toBe(connectionId);
|
|
37
|
+
expect(updated.orgSlug).toBe('acme-renamed');
|
|
38
|
+
const fetched = await getSentryInstallationByInstallationUuid(installationUuid);
|
|
39
|
+
expect(fetched?.orgSlug).toBe('acme-renamed');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('upsert rejects repointing an installation to a different connection (TOCTOU guard)', async () => {
|
|
43
|
+
const installationUuid = randomUUID();
|
|
44
|
+
const firstConnectionId = randomUUID();
|
|
45
|
+
const secondConnectionId = randomUUID();
|
|
46
|
+
await upsertSentryInstallation({
|
|
47
|
+
connectionId: firstConnectionId,
|
|
48
|
+
installationUuid,
|
|
49
|
+
orgSlug: 'acme',
|
|
50
|
+
status: 'installed',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const repoint = upsertSentryInstallation({
|
|
54
|
+
connectionId: secondConnectionId,
|
|
55
|
+
installationUuid,
|
|
56
|
+
orgSlug: 'acme',
|
|
57
|
+
status: 'installed',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await expect(repoint).rejects.toBeInstanceOf(SentryInstallationAlreadyLinkedError);
|
|
61
|
+
const fetched = await getSentryInstallationByInstallationUuid(installationUuid);
|
|
62
|
+
expect(fetched?.connectionId).toBe(firstConnectionId);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('upsert claims a verified-unclaimed row by setting connection_id (first claim)', async () => {
|
|
66
|
+
const installationUuid = randomUUID();
|
|
67
|
+
const connectionId = randomUUID();
|
|
68
|
+
await persistVerifiedUnclaimedInstallation({
|
|
69
|
+
installationUuid,
|
|
70
|
+
orgSlug: 'acme',
|
|
71
|
+
codeHash: 'webhook-hash',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const claimed = await upsertSentryInstallation({
|
|
75
|
+
connectionId,
|
|
76
|
+
installationUuid,
|
|
77
|
+
orgSlug: 'acme',
|
|
78
|
+
status: 'installed',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(claimed.connectionId).toBe(connectionId);
|
|
82
|
+
const fetched = await getSentryInstallationByInstallationUuid(installationUuid);
|
|
83
|
+
expect(fetched?.connectionId).toBe(connectionId);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('markSentryInstallationDeleted sets status and returns the updated row', async () => {
|
|
87
|
+
const installationUuid = randomUUID();
|
|
88
|
+
const connectionId = randomUUID();
|
|
89
|
+
await upsertSentryInstallation({
|
|
90
|
+
connectionId,
|
|
91
|
+
installationUuid,
|
|
92
|
+
orgSlug: 'acme',
|
|
93
|
+
status: 'installed',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const deleted = await markSentryInstallationDeleted({installationUuid});
|
|
97
|
+
|
|
98
|
+
expect(deleted?.status).toBe('deleted');
|
|
99
|
+
expect(deleted?.connectionId).toBe(connectionId);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('markSentryInstallationDeleted returns undefined when no row matches', async () => {
|
|
103
|
+
const result = await markSentryInstallationDeleted({installationUuid: 'never-installed'});
|
|
104
|
+
|
|
105
|
+
expect(result).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('getSentryInstallationByInstallationUuid returns undefined for a miss', async () => {
|
|
109
|
+
const result = await getSentryInstallationByInstallationUuid('missing');
|
|
110
|
+
|
|
111
|
+
expect(result).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('persistVerifiedUnclaimedInstallation inserts an unclaimed row with a code hash', async () => {
|
|
115
|
+
const installationUuid = randomUUID();
|
|
116
|
+
|
|
117
|
+
const persisted = await persistVerifiedUnclaimedInstallation({
|
|
118
|
+
installationUuid,
|
|
119
|
+
orgSlug: 'acme',
|
|
120
|
+
codeHash: 'hash-1',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(persisted.connectionId).toBeNull();
|
|
124
|
+
expect(persisted.status).toBe('installed');
|
|
125
|
+
expect(persisted.codeHash).toBe('hash-1');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('persistVerifiedUnclaimedInstallation never clobbers a claimed connection or downgrades status', async () => {
|
|
129
|
+
const installationUuid = randomUUID();
|
|
130
|
+
const connectionId = randomUUID();
|
|
131
|
+
await upsertSentryInstallation({
|
|
132
|
+
connectionId,
|
|
133
|
+
installationUuid,
|
|
134
|
+
orgSlug: 'acme',
|
|
135
|
+
status: 'installed',
|
|
136
|
+
codeHash: 'claimed-hash',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const reconciled = await persistVerifiedUnclaimedInstallation({
|
|
140
|
+
installationUuid,
|
|
141
|
+
orgSlug: 'acme-renamed',
|
|
142
|
+
codeHash: 'webhook-hash',
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// A late webhook refreshes the slug/hash but leaves the claim intact.
|
|
146
|
+
expect(reconciled.connectionId).toBe(connectionId);
|
|
147
|
+
expect(reconciled.status).toBe('installed');
|
|
148
|
+
expect(reconciled.orgSlug).toBe('acme-renamed');
|
|
149
|
+
expect(reconciled.codeHash).toBe('webhook-hash');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('listUnclaimedSentryInstallations returns only rows with no connection', async () => {
|
|
153
|
+
const claimedUuid = randomUUID();
|
|
154
|
+
const unclaimedUuid = randomUUID();
|
|
155
|
+
await upsertSentryInstallation({
|
|
156
|
+
connectionId: randomUUID(),
|
|
157
|
+
installationUuid: claimedUuid,
|
|
158
|
+
orgSlug: 'acme',
|
|
159
|
+
status: 'installed',
|
|
160
|
+
});
|
|
161
|
+
await persistVerifiedUnclaimedInstallation({
|
|
162
|
+
installationUuid: unclaimedUuid,
|
|
163
|
+
orgSlug: 'acme',
|
|
164
|
+
codeHash: 'hash',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const unclaimed = await listUnclaimedSentryInstallations();
|
|
168
|
+
|
|
169
|
+
expect(unclaimed.map((row) => row.installationUuid)).toEqual([unclaimedUuid]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('listUnclaimedSentryInstallations filters by age when olderThan is given', async () => {
|
|
173
|
+
const unclaimedUuid = randomUUID();
|
|
174
|
+
await persistVerifiedUnclaimedInstallation({
|
|
175
|
+
installationUuid: unclaimedUuid,
|
|
176
|
+
orgSlug: 'acme',
|
|
177
|
+
codeHash: 'hash',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const future = new Date(Date.now() + 60_000);
|
|
181
|
+
const past = new Date(Date.now() - 60_000);
|
|
182
|
+
|
|
183
|
+
expect(await listUnclaimedSentryInstallations({olderThan: future})).toHaveLength(1);
|
|
184
|
+
expect(await listUnclaimedSentryInstallations({olderThan: past})).toHaveLength(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('pruneUnclaimedSentryInstallations tombstones stale unclaimed rows and leaves fresh ones', async () => {
|
|
188
|
+
const staleUuid = randomUUID();
|
|
189
|
+
await persistVerifiedUnclaimedInstallation({
|
|
190
|
+
installationUuid: staleUuid,
|
|
191
|
+
orgSlug: 'acme',
|
|
192
|
+
codeHash: 'hash',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const youngResult = await pruneUnclaimedSentryInstallations({
|
|
196
|
+
olderThan: new Date(Date.now() - 60_000),
|
|
197
|
+
});
|
|
198
|
+
expect(youngResult.tombstoned).toBe(0);
|
|
199
|
+
|
|
200
|
+
const staleResult = await pruneUnclaimedSentryInstallations({
|
|
201
|
+
olderThan: new Date(Date.now() + 60_000),
|
|
202
|
+
});
|
|
203
|
+
expect(staleResult.tombstoned).toBe(1);
|
|
204
|
+
expect((await getSentryInstallationByInstallationUuid(staleUuid))?.status).toBe('deleted');
|
|
205
|
+
expect(await listUnclaimedSentryInstallations()).toHaveLength(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('pruneUnclaimedSentryInstallations never tombstones a claimed install', async () => {
|
|
209
|
+
const claimedUuid = randomUUID();
|
|
210
|
+
await upsertSentryInstallation({
|
|
211
|
+
connectionId: randomUUID(),
|
|
212
|
+
installationUuid: claimedUuid,
|
|
213
|
+
orgSlug: 'acme',
|
|
214
|
+
status: 'installed',
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const result = await pruneUnclaimedSentryInstallations({
|
|
218
|
+
olderThan: new Date(Date.now() + 60_000),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(result.tombstoned).toBe(0);
|
|
222
|
+
expect((await getSentryInstallationByInstallationUuid(claimedUuid))?.status).toBe('installed');
|
|
223
|
+
});
|
|
224
|
+
});
|