@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,14 @@
|
|
|
1
|
+
CREATE TABLE "integrations_sentry_installations" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
|
|
3
|
+
"connection_id" uuid,
|
|
4
|
+
"installation_uuid" text NOT NULL,
|
|
5
|
+
"org_slug" text NOT NULL,
|
|
6
|
+
"status" text NOT NULL,
|
|
7
|
+
"code_hash" text,
|
|
8
|
+
"installer_user_id" uuid,
|
|
9
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
10
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
11
|
+
);
|
|
12
|
+
--> statement-breakpoint
|
|
13
|
+
CREATE UNIQUE INDEX "integrations_sentry_installations_connection_unique" ON "integrations_sentry_installations" USING btree ("connection_id");--> statement-breakpoint
|
|
14
|
+
CREATE UNIQUE INDEX "integrations_sentry_installations_installation_unique" ON "integrations_sentry_installations" USING btree ("installation_uuid");
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@shipfox/api-integration-sentry",
|
|
3
|
+
"license": "MIT",
|
|
4
|
+
"version": "2.0.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
8
|
+
"directory": "libs/api/integration/sentry"
|
|
9
|
+
},
|
|
10
|
+
"private": false,
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"imports": {
|
|
15
|
+
"#test/*": "./test/*",
|
|
16
|
+
"#*": {
|
|
17
|
+
"workspace-source": "./src/*",
|
|
18
|
+
"development": "./src/*",
|
|
19
|
+
"default": "./dist/*"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"development": {
|
|
25
|
+
"types": "./src/index.ts",
|
|
26
|
+
"default": "./src/index.ts"
|
|
27
|
+
},
|
|
28
|
+
"default": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@temporalio/workflow": "^1.16.1",
|
|
36
|
+
"drizzle-orm": "^0.45.2",
|
|
37
|
+
"ky": "^2.0.0",
|
|
38
|
+
"zod": "^4.4.3",
|
|
39
|
+
"@shipfox/api-auth-context": "2.0.0",
|
|
40
|
+
"@shipfox/api-integration-core-dto": "2.0.0",
|
|
41
|
+
"@shipfox/config": "1.2.1",
|
|
42
|
+
"@shipfox/node-fastify": "0.2.1",
|
|
43
|
+
"@shipfox/api-integration-sentry-dto": "2.0.0",
|
|
44
|
+
"@shipfox/node-drizzle": "0.2.1",
|
|
45
|
+
"@shipfox/node-module": "0.2.0",
|
|
46
|
+
"@shipfox/node-postgres": "0.4.1",
|
|
47
|
+
"@shipfox/node-opentelemetry": "0.5.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/pg": "^8.15.5",
|
|
51
|
+
"drizzle-kit": "^0.31.10",
|
|
52
|
+
"fastify": "^5.3.3",
|
|
53
|
+
"fastify-type-provider-zod": "^6.0.0",
|
|
54
|
+
"fishery": "^2.2.2",
|
|
55
|
+
"@shipfox/swc": "1.2.5",
|
|
56
|
+
"@shipfox/ts-config": "1.3.8",
|
|
57
|
+
"@shipfox/typescript": "1.1.6",
|
|
58
|
+
"@shipfox/biome": "1.8.1",
|
|
59
|
+
"@shipfox/vitest": "1.2.2"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "shipfox-swc",
|
|
63
|
+
"check": "shipfox-biome-check",
|
|
64
|
+
"check:fix": "shipfox-biome-check --write",
|
|
65
|
+
"test": "shipfox-vitest-run",
|
|
66
|
+
"test:watch": "shipfox-vitest-watch",
|
|
67
|
+
"type": "shipfox-tsc-check",
|
|
68
|
+
"type:emit": "shipfox-tsc-emit"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {HTTPError, TimeoutError} from 'ky';
|
|
2
|
+
import {SentryIntegrationProviderError} from '#core/errors.js';
|
|
3
|
+
import {createSentryApiClient} from './client.js';
|
|
4
|
+
|
|
5
|
+
const {postMock, getMock, putMock} = vi.hoisted(() => ({
|
|
6
|
+
postMock: vi.fn(),
|
|
7
|
+
getMock: vi.fn(),
|
|
8
|
+
putMock: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock('ky', () => {
|
|
12
|
+
class HTTPError extends Error {
|
|
13
|
+
constructor(public response: {status: number; headers: Headers}) {
|
|
14
|
+
super('http');
|
|
15
|
+
this.name = 'HTTPError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
class TimeoutError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super('timeout');
|
|
21
|
+
this.name = 'TimeoutError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return {default: {post: postMock, get: getMock, put: putMock}, HTTPError, TimeoutError};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function resolves(data: unknown) {
|
|
28
|
+
return {json: () => Promise.resolve(data)};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rejects(error: unknown) {
|
|
32
|
+
return {json: () => Promise.reject(error)};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function httpError(status: number, headers: Record<string, string> = {}): HTTPError {
|
|
36
|
+
return new HTTPError({status, headers: new Headers(headers)} as never, {} as never, {} as never);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('createSentryApiClient.exchangeAuthorizationCode', () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
postMock.mockReset();
|
|
42
|
+
getMock.mockReset();
|
|
43
|
+
putMock.mockReset();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns the token, refresh token, and expiry', async () => {
|
|
47
|
+
postMock.mockReturnValue(
|
|
48
|
+
resolves({token: 'tok', refreshToken: 'refresh', expiresAt: '2026-06-11T12:00:00.000Z'}),
|
|
49
|
+
);
|
|
50
|
+
const client = createSentryApiClient();
|
|
51
|
+
|
|
52
|
+
const result = await client.exchangeAuthorizationCode({
|
|
53
|
+
installationUuid: 'uuid-1',
|
|
54
|
+
code: 'the-code',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(result).toEqual({
|
|
58
|
+
token: 'tok',
|
|
59
|
+
refreshToken: 'refresh',
|
|
60
|
+
expiresAt: '2026-06-11T12:00:00.000Z',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('maps a 429 to rate-limited with retry-after seconds', async () => {
|
|
65
|
+
postMock.mockReturnValue(rejects(httpError(429, {'retry-after': '30'})));
|
|
66
|
+
const client = createSentryApiClient();
|
|
67
|
+
|
|
68
|
+
const result = client.exchangeAuthorizationCode({installationUuid: 'uuid-1', code: 'c'});
|
|
69
|
+
|
|
70
|
+
await expect(result).rejects.toMatchObject({reason: 'rate-limited', retryAfterSeconds: 30});
|
|
71
|
+
await expect(result).rejects.toBeInstanceOf(SentryIntegrationProviderError);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('maps a 5xx to provider-unavailable', async () => {
|
|
75
|
+
postMock.mockReturnValue(rejects(httpError(503)));
|
|
76
|
+
const client = createSentryApiClient();
|
|
77
|
+
|
|
78
|
+
const result = client.exchangeAuthorizationCode({installationUuid: 'uuid-1', code: 'c'});
|
|
79
|
+
|
|
80
|
+
await expect(result).rejects.toMatchObject({reason: 'provider-unavailable'});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('maps a forged-code 4xx to access-denied', async () => {
|
|
84
|
+
postMock.mockReturnValue(rejects(httpError(403)));
|
|
85
|
+
const client = createSentryApiClient();
|
|
86
|
+
|
|
87
|
+
const result = client.exchangeAuthorizationCode({installationUuid: 'uuid-1', code: 'c'});
|
|
88
|
+
|
|
89
|
+
await expect(result).rejects.toMatchObject({reason: 'access-denied'});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('maps a timeout to timeout', async () => {
|
|
93
|
+
postMock.mockReturnValue(rejects(new TimeoutError({} as never)));
|
|
94
|
+
const client = createSentryApiClient();
|
|
95
|
+
|
|
96
|
+
const result = client.exchangeAuthorizationCode({installationUuid: 'uuid-1', code: 'c'});
|
|
97
|
+
|
|
98
|
+
await expect(result).rejects.toMatchObject({reason: 'timeout'});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('maps a response without a token to malformed-provider-response', async () => {
|
|
102
|
+
postMock.mockReturnValue(resolves({refreshToken: 'r', expiresAt: 'x'}));
|
|
103
|
+
const client = createSentryApiClient();
|
|
104
|
+
|
|
105
|
+
const result = client.exchangeAuthorizationCode({installationUuid: 'uuid-1', code: 'c'});
|
|
106
|
+
|
|
107
|
+
await expect(result).rejects.toMatchObject({reason: 'malformed-provider-response'});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('never leaks the code or client secret into the thrown error', async () => {
|
|
111
|
+
postMock.mockReturnValue(rejects(httpError(403)));
|
|
112
|
+
const client = createSentryApiClient();
|
|
113
|
+
|
|
114
|
+
const error = await client
|
|
115
|
+
.exchangeAuthorizationCode({installationUuid: 'uuid-1', code: 'super-secret-code'})
|
|
116
|
+
.catch((thrown: unknown) => thrown);
|
|
117
|
+
|
|
118
|
+
const serialized = `${(error as Error).message} ${JSON.stringify(error)} ${String((error as {cause?: unknown}).cause)}`;
|
|
119
|
+
expect(serialized).not.toContain('super-secret-code');
|
|
120
|
+
expect(serialized).not.toContain('test-client-secret');
|
|
121
|
+
expect((error as {cause?: unknown}).cause).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('createSentryApiClient.getInstallation', () => {
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
getMock.mockReset();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('derives the organization slug', async () => {
|
|
131
|
+
getMock.mockReturnValue(resolves({organization: {slug: 'acme'}}));
|
|
132
|
+
const client = createSentryApiClient();
|
|
133
|
+
|
|
134
|
+
const result = await client.getInstallation({installationUuid: 'uuid-1', token: 'tok'});
|
|
135
|
+
|
|
136
|
+
expect(result).toEqual({orgSlug: 'acme'});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('maps a response without an organization slug to malformed-provider-response', async () => {
|
|
140
|
+
getMock.mockReturnValue(resolves({organization: {}}));
|
|
141
|
+
const client = createSentryApiClient();
|
|
142
|
+
|
|
143
|
+
const result = client.getInstallation({installationUuid: 'uuid-1', token: 'tok'});
|
|
144
|
+
|
|
145
|
+
await expect(result).rejects.toMatchObject({reason: 'malformed-provider-response'});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import ky, {HTTPError, TimeoutError} from 'ky';
|
|
3
|
+
import {config} from '#config.js';
|
|
4
|
+
import {SentryIntegrationProviderError} from '#core/errors.js';
|
|
5
|
+
|
|
6
|
+
const SENTRY_API_BASE = 'https://sentry.io/api/0';
|
|
7
|
+
|
|
8
|
+
export interface SentryAuthorization {
|
|
9
|
+
token: string;
|
|
10
|
+
refreshToken: string;
|
|
11
|
+
expiresAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SentryInstallationDetails {
|
|
15
|
+
orgSlug: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SentryApiClient {
|
|
19
|
+
exchangeAuthorizationCode(input: {
|
|
20
|
+
installationUuid: string;
|
|
21
|
+
code: string;
|
|
22
|
+
}): Promise<SentryAuthorization>;
|
|
23
|
+
// Derived from Sentry so the org slug is never trusted from the client body.
|
|
24
|
+
getInstallation(input: {
|
|
25
|
+
installationUuid: string;
|
|
26
|
+
token: string;
|
|
27
|
+
}): Promise<SentryInstallationDetails>;
|
|
28
|
+
verifyInstallation(input: {installationUuid: string; token: string}): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createSentryApiClient(): SentryApiClient {
|
|
32
|
+
return {
|
|
33
|
+
async exchangeAuthorizationCode(input) {
|
|
34
|
+
// Encode the uuid: it originates from the request body / webhook payload, so
|
|
35
|
+
// a path separator in it must not be able to alter the request path (the host
|
|
36
|
+
// is already pinned to SENTRY_API_BASE).
|
|
37
|
+
const body = await mapSentryError('exchange-authorization-code', () =>
|
|
38
|
+
ky
|
|
39
|
+
.post(
|
|
40
|
+
`${SENTRY_API_BASE}/sentry-app-installations/${encodeURIComponent(input.installationUuid)}/authorizations/`,
|
|
41
|
+
{
|
|
42
|
+
json: {
|
|
43
|
+
grant_type: 'authorization_code',
|
|
44
|
+
client_id: config.SENTRY_APP_CLIENT_ID,
|
|
45
|
+
client_secret: config.SENTRY_APP_CLIENT_SECRET,
|
|
46
|
+
code: input.code,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
.json<{token?: unknown; refreshToken?: unknown; expiresAt?: unknown}>(),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (
|
|
54
|
+
typeof body.token !== 'string' ||
|
|
55
|
+
typeof body.refreshToken !== 'string' ||
|
|
56
|
+
typeof body.expiresAt !== 'string'
|
|
57
|
+
) {
|
|
58
|
+
throw new SentryIntegrationProviderError(
|
|
59
|
+
'malformed-provider-response',
|
|
60
|
+
'Sentry authorization response did not include a token',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return {token: body.token, refreshToken: body.refreshToken, expiresAt: body.expiresAt};
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
async getInstallation(input) {
|
|
67
|
+
const body = await mapSentryError('get-installation', () =>
|
|
68
|
+
ky
|
|
69
|
+
.get(
|
|
70
|
+
`${SENTRY_API_BASE}/sentry-app-installations/${encodeURIComponent(input.installationUuid)}/`,
|
|
71
|
+
{
|
|
72
|
+
headers: {authorization: `Bearer ${input.token}`},
|
|
73
|
+
},
|
|
74
|
+
)
|
|
75
|
+
.json<{organization?: {slug?: unknown}}>(),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const slug = body.organization?.slug;
|
|
79
|
+
if (typeof slug !== 'string' || slug.length === 0) {
|
|
80
|
+
throw new SentryIntegrationProviderError(
|
|
81
|
+
'malformed-provider-response',
|
|
82
|
+
'Sentry installation response did not include an organization slug',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return {orgSlug: slug};
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async verifyInstallation(input) {
|
|
89
|
+
await mapSentryError('verify-installation', () =>
|
|
90
|
+
ky
|
|
91
|
+
.put(
|
|
92
|
+
`${SENTRY_API_BASE}/sentry-app-installations/${encodeURIComponent(input.installationUuid)}/`,
|
|
93
|
+
{
|
|
94
|
+
headers: {authorization: `Bearer ${input.token}`},
|
|
95
|
+
json: {status: 'installed'},
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
.json<unknown>(),
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The typed error deliberately drops Sentry's status and body so no token, code,
|
|
105
|
+
// or client secret can leak to the client or the logged error chain. That also
|
|
106
|
+
// strips the one detail a self-hoster needs to fix a misconfigured Sentry app —
|
|
107
|
+
// e.g. a 403 on `get-installation` means the app is missing "Organization: Read"
|
|
108
|
+
// (see README "Required permissions"). So we log the upstream status
|
|
109
|
+
// here, the only place it survives, keyed by the operation that failed.
|
|
110
|
+
async function mapSentryError<T>(operation: string, request: () => Promise<T>): Promise<T> {
|
|
111
|
+
try {
|
|
112
|
+
return await request();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof SentryIntegrationProviderError) throw error;
|
|
115
|
+
if (error instanceof HTTPError) {
|
|
116
|
+
const {status, statusText, headers} = error.response;
|
|
117
|
+
logger().warn({operation, status, statusText}, 'Sentry API request rejected');
|
|
118
|
+
if (status === 429) {
|
|
119
|
+
throw new SentryIntegrationProviderError(
|
|
120
|
+
'rate-limited',
|
|
121
|
+
'Sentry request was rate limited',
|
|
122
|
+
retryAfterSeconds(headers),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (status >= 500) {
|
|
126
|
+
throw new SentryIntegrationProviderError('provider-unavailable', 'Sentry request failed');
|
|
127
|
+
}
|
|
128
|
+
throw new SentryIntegrationProviderError('access-denied', 'Sentry request was rejected');
|
|
129
|
+
}
|
|
130
|
+
if (error instanceof TimeoutError) {
|
|
131
|
+
logger().warn({operation}, 'Sentry API request timed out');
|
|
132
|
+
throw new SentryIntegrationProviderError('timeout', 'Sentry request timed out');
|
|
133
|
+
}
|
|
134
|
+
logger().warn(
|
|
135
|
+
{operation, errName: error instanceof Error ? error.name : typeof error},
|
|
136
|
+
'Sentry API request failed',
|
|
137
|
+
);
|
|
138
|
+
throw new SentryIntegrationProviderError('provider-unavailable', 'Sentry request failed');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function retryAfterSeconds(headers: Headers): number | undefined {
|
|
143
|
+
const retryAfter = headers.get('retry-after');
|
|
144
|
+
if (!retryAfter) return undefined;
|
|
145
|
+
const parsed = Number.parseInt(retryAfter, 10);
|
|
146
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
147
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {assertRetentionDaysWithinBounds} from './config.js';
|
|
2
|
+
|
|
3
|
+
describe('assertRetentionDaysWithinBounds', () => {
|
|
4
|
+
test('accepts a retention window of at least one day', () => {
|
|
5
|
+
expect(() => assertRetentionDaysWithinBounds(7)).not.toThrow();
|
|
6
|
+
expect(() => assertRetentionDaysWithinBounds(1)).not.toThrow();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('rejects a zero window that would tombstone freshly created installs', () => {
|
|
10
|
+
expect(() => assertRetentionDaysWithinBounds(0)).toThrow();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('rejects a negative window that pushes the cutoff into the future', () => {
|
|
14
|
+
expect(() => assertRetentionDaysWithinBounds(-7)).toThrow();
|
|
15
|
+
});
|
|
16
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {bool, createConfig, num, str} from '@shipfox/config';
|
|
2
|
+
|
|
3
|
+
export const config = createConfig({
|
|
4
|
+
SENTRY_APP_CLIENT_ID: str({
|
|
5
|
+
desc: 'OAuth client ID of the Sentry app. Reserved for the install and app-token-exchange flow; the webhook receiver does not read it yet. Required.',
|
|
6
|
+
}),
|
|
7
|
+
SENTRY_APP_CLIENT_SECRET: str({
|
|
8
|
+
desc: 'Shared secret used to verify the HMAC-SHA256 signature on inbound Sentry webhooks. Must match the value set on the Sentry app. Required.',
|
|
9
|
+
}),
|
|
10
|
+
SENTRY_APP_SLUG: str({
|
|
11
|
+
desc: 'URL slug of the Sentry app, used to build install and callback links. Required.',
|
|
12
|
+
}),
|
|
13
|
+
SENTRY_APP_VERIFY_INSTALL: bool({
|
|
14
|
+
desc: 'Verifies the signature on Sentry app installation requests. Keep it true; turn it off only for local testing.',
|
|
15
|
+
default: true,
|
|
16
|
+
}),
|
|
17
|
+
SENTRY_UNCLAIMED_INSTALLATION_RETENTION_DAYS: num({
|
|
18
|
+
desc: 'How many days a verified-but-unclaimed Sentry installation may sit before the daily cleanup cron tombstones it. Must be at least 1; a smaller value moves the cutoff to now or the future and would tombstone freshly created, still-unclaimed installs. A reinstall always mints a fresh uuid, so a tombstone is never revived. Defaults to 7.',
|
|
19
|
+
default: 7,
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// The daily cleanup cron tombstones every unclaimed install older than
|
|
24
|
+
// `now - SENTRY_UNCLAIMED_INSTALLATION_RETENTION_DAYS`. A value below 1 moves that
|
|
25
|
+
// cutoff to now or the future, which would wipe freshly created installs nobody has
|
|
26
|
+
// claimed yet. Reject it at startup rather than let the 04:00 cron fail silently.
|
|
27
|
+
export function assertRetentionDaysWithinBounds(days: number): void {
|
|
28
|
+
if (!Number.isFinite(days) || days < 1) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`SENTRY_UNCLAIMED_INSTALLATION_RETENTION_DAYS must be a finite number of at least 1, received ${days}.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
assertRetentionDaysWithinBounds(config.SENTRY_UNCLAIMED_INSTALLATION_RETENTION_DAYS);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {SentryApiClient} from '#api/client.js';
|
|
2
|
+
import type {SentryInstallation} from '#db/installations.js';
|
|
3
|
+
import {createSentryIntegrationProvider} from '#index.js';
|
|
4
|
+
|
|
5
|
+
function sentryClient(): SentryApiClient {
|
|
6
|
+
return {
|
|
7
|
+
exchangeAuthorizationCode: vi.fn(() =>
|
|
8
|
+
Promise.resolve({token: 'tok', refreshToken: 'refresh', expiresAt: 'x'}),
|
|
9
|
+
),
|
|
10
|
+
getInstallation: vi.fn(() => Promise.resolve({orgSlug: 'acme'})),
|
|
11
|
+
verifyInstallation: vi.fn(() => Promise.resolve()),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function installation(overrides: Partial<SentryInstallation> = {}): SentryInstallation {
|
|
16
|
+
return {
|
|
17
|
+
id: crypto.randomUUID(),
|
|
18
|
+
connectionId: crypto.randomUUID(),
|
|
19
|
+
installationUuid: 'install-1',
|
|
20
|
+
orgSlug: 'acme',
|
|
21
|
+
status: 'installed',
|
|
22
|
+
codeHash: null,
|
|
23
|
+
installerUserId: null,
|
|
24
|
+
createdAt: new Date(),
|
|
25
|
+
updatedAt: new Date(),
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createProvider(lookup: (connectionId: string) => Promise<SentryInstallation | undefined>) {
|
|
31
|
+
return createSentryIntegrationProvider({
|
|
32
|
+
sentry: sentryClient(),
|
|
33
|
+
getSentryInstallation: vi.fn(() => Promise.resolve(undefined)),
|
|
34
|
+
getConnectionById: vi.fn(() => Promise.resolve(undefined)),
|
|
35
|
+
connectSentryInstallation: vi.fn() as never,
|
|
36
|
+
persistVerifiedUnclaimedInstallation: vi.fn() as never,
|
|
37
|
+
coreDb: vi.fn() as never,
|
|
38
|
+
publishIntegrationEventReceived: vi.fn(() => Promise.resolve({published: false})),
|
|
39
|
+
recordDeliveryOnly: vi.fn(() => Promise.resolve()),
|
|
40
|
+
getIntegrationConnectionById: vi.fn(() => Promise.resolve(undefined)),
|
|
41
|
+
updateConnectionLifecycleStatus: vi.fn(() => Promise.resolve(undefined)),
|
|
42
|
+
getSentryInstallationByConnectionId: lookup,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('sentry connectionExternalUrl', () => {
|
|
47
|
+
it('resolves the org URL from the installation row', async () => {
|
|
48
|
+
const connectionId = crypto.randomUUID();
|
|
49
|
+
const provider = createProvider(() => Promise.resolve(installation({orgSlug: 'acme-corp'})));
|
|
50
|
+
|
|
51
|
+
const url = await provider.connectionExternalUrl({id: connectionId});
|
|
52
|
+
|
|
53
|
+
expect(url).toBe('https://sentry.io/organizations/acme-corp/');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('URL-encodes the org slug', async () => {
|
|
57
|
+
const provider = createProvider(() => Promise.resolve(installation({orgSlug: 'a/b c'})));
|
|
58
|
+
|
|
59
|
+
const url = await provider.connectionExternalUrl({id: crypto.randomUUID()});
|
|
60
|
+
|
|
61
|
+
expect(url).toBe('https://sentry.io/organizations/a%2Fb%20c/');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns undefined when the installation row is missing', async () => {
|
|
65
|
+
const provider = createProvider(() => Promise.resolve(undefined));
|
|
66
|
+
|
|
67
|
+
const url = await provider.connectionExternalUrl({id: crypto.randomUUID()});
|
|
68
|
+
|
|
69
|
+
expect(url).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns undefined when the org slug is absent', async () => {
|
|
73
|
+
const provider = createProvider(() => Promise.resolve(installation({orgSlug: ''})));
|
|
74
|
+
|
|
75
|
+
const url = await provider.connectionExternalUrl({id: crypto.randomUUID()});
|
|
76
|
+
|
|
77
|
+
expect(url).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {IntegrationProviderError} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
|
|
3
|
+
export class SentryIntegrationProviderError extends IntegrationProviderError {}
|
|
4
|
+
|
|
5
|
+
export class SentryInstallationAlreadyLinkedError extends Error {
|
|
6
|
+
constructor(public readonly installationUuid: string) {
|
|
7
|
+
super(
|
|
8
|
+
`Sentry installation is already linked to another Shipfox workspace: ${installationUuid}`,
|
|
9
|
+
);
|
|
10
|
+
this.name = 'SentryInstallationAlreadyLinkedError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The claim could not prove control of the named install: the presented code
|
|
16
|
+
* neither exchanged successfully nor matched the stored `code_hash`. Surfaced as
|
|
17
|
+
* a 403 so a forged or leaked bare uuid cannot bind someone else's install.
|
|
18
|
+
*/
|
|
19
|
+
export class SentryClaimProofMismatchError extends Error {
|
|
20
|
+
constructor(public readonly installationUuid: string) {
|
|
21
|
+
super(`Sentry claim could not be verified for installation: ${installationUuid}`);
|
|
22
|
+
this.name = 'SentryClaimProofMismatchError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A concurrent path (the signed webhook) is mid-exchange for this install, so the
|
|
28
|
+
* verified row is not visible to the claim yet. Retryable: the existing client
|
|
29
|
+
* backoff re-calls and finds the now-persisted row.
|
|
30
|
+
*/
|
|
31
|
+
export class SentryVerificationInProgressError extends Error {
|
|
32
|
+
constructor(
|
|
33
|
+
public readonly installationUuid: string,
|
|
34
|
+
public readonly retryAfterSeconds = 2,
|
|
35
|
+
) {
|
|
36
|
+
super(`Sentry installation verification is still in progress: ${installationUuid}`);
|
|
37
|
+
this.name = 'SentryVerificationInProgressError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Base for issue deliveries we received and authenticated but deliberately do
|
|
43
|
+
* not publish. The webhook layer records them for dedup and acknowledges with a
|
|
44
|
+
* 204 rather than treating them as failures — a sustained non-2xx can make Sentry
|
|
45
|
+
* disable the webhook. Carries no HTTP concerns so workers/jobs can reuse it.
|
|
46
|
+
*/
|
|
47
|
+
export class SentryIssueDroppedError extends Error {}
|
|
48
|
+
|
|
49
|
+
export class SentryInstallationNotFoundError extends SentryIssueDroppedError {
|
|
50
|
+
constructor(public readonly installationUuid: string) {
|
|
51
|
+
super(`Sentry installation not found: ${installationUuid}`);
|
|
52
|
+
this.name = 'SentryInstallationNotFoundError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class SentryInstallationDeletedError extends SentryIssueDroppedError {
|
|
57
|
+
constructor(public readonly installationUuid: string) {
|
|
58
|
+
super(`Sentry installation is deleted: ${installationUuid}`);
|
|
59
|
+
this.name = 'SentryInstallationDeletedError';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class SentryConnectionNotFoundError extends SentryIssueDroppedError {
|
|
64
|
+
constructor(public readonly connectionId: string) {
|
|
65
|
+
super(`Sentry installation has no connection: ${connectionId}`);
|
|
66
|
+
this.name = 'SentryConnectionNotFoundError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A verified install that no workspace has claimed yet (`connection_id IS NULL`),
|
|
72
|
+
* so an issue delivery has no workspace to publish against. Dropped (204) like the
|
|
73
|
+
* other pre-publish cases; kept distinct from SentryConnectionNotFoundError so the
|
|
74
|
+
* carried identifier is unambiguously an installation uuid, not a connection id.
|
|
75
|
+
*/
|
|
76
|
+
export class SentryInstallationUnclaimedError extends SentryIssueDroppedError {
|
|
77
|
+
constructor(public readonly installationUuid: string) {
|
|
78
|
+
super(`Sentry installation is verified but not yet claimed: ${installationUuid}`);
|
|
79
|
+
this.name = 'SentryInstallationUnclaimedError';
|
|
80
|
+
}
|
|
81
|
+
}
|