@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,67 @@
|
|
|
1
|
+
import { IntegrationProviderError } from '@shipfox/api-integration-core-dto';
|
|
2
|
+
export class SentryIntegrationProviderError extends IntegrationProviderError {
|
|
3
|
+
}
|
|
4
|
+
export class SentryInstallationAlreadyLinkedError extends Error {
|
|
5
|
+
constructor(installationUuid){
|
|
6
|
+
super(`Sentry installation is already linked to another Shipfox workspace: ${installationUuid}`), this.installationUuid = installationUuid;
|
|
7
|
+
this.name = 'SentryInstallationAlreadyLinkedError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The claim could not prove control of the named install: the presented code
|
|
12
|
+
* neither exchanged successfully nor matched the stored `code_hash`. Surfaced as
|
|
13
|
+
* a 403 so a forged or leaked bare uuid cannot bind someone else's install.
|
|
14
|
+
*/ export class SentryClaimProofMismatchError extends Error {
|
|
15
|
+
constructor(installationUuid){
|
|
16
|
+
super(`Sentry claim could not be verified for installation: ${installationUuid}`), this.installationUuid = installationUuid;
|
|
17
|
+
this.name = 'SentryClaimProofMismatchError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A concurrent path (the signed webhook) is mid-exchange for this install, so the
|
|
22
|
+
* verified row is not visible to the claim yet. Retryable: the existing client
|
|
23
|
+
* backoff re-calls and finds the now-persisted row.
|
|
24
|
+
*/ export class SentryVerificationInProgressError extends Error {
|
|
25
|
+
constructor(installationUuid, retryAfterSeconds = 2){
|
|
26
|
+
super(`Sentry installation verification is still in progress: ${installationUuid}`), this.installationUuid = installationUuid, this.retryAfterSeconds = retryAfterSeconds;
|
|
27
|
+
this.name = 'SentryVerificationInProgressError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Base for issue deliveries we received and authenticated but deliberately do
|
|
32
|
+
* not publish. The webhook layer records them for dedup and acknowledges with a
|
|
33
|
+
* 204 rather than treating them as failures — a sustained non-2xx can make Sentry
|
|
34
|
+
* disable the webhook. Carries no HTTP concerns so workers/jobs can reuse it.
|
|
35
|
+
*/ export class SentryIssueDroppedError extends Error {
|
|
36
|
+
}
|
|
37
|
+
export class SentryInstallationNotFoundError extends SentryIssueDroppedError {
|
|
38
|
+
constructor(installationUuid){
|
|
39
|
+
super(`Sentry installation not found: ${installationUuid}`), this.installationUuid = installationUuid;
|
|
40
|
+
this.name = 'SentryInstallationNotFoundError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class SentryInstallationDeletedError extends SentryIssueDroppedError {
|
|
44
|
+
constructor(installationUuid){
|
|
45
|
+
super(`Sentry installation is deleted: ${installationUuid}`), this.installationUuid = installationUuid;
|
|
46
|
+
this.name = 'SentryInstallationDeletedError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export class SentryConnectionNotFoundError extends SentryIssueDroppedError {
|
|
50
|
+
constructor(connectionId){
|
|
51
|
+
super(`Sentry installation has no connection: ${connectionId}`), this.connectionId = connectionId;
|
|
52
|
+
this.name = 'SentryConnectionNotFoundError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A verified install that no workspace has claimed yet (`connection_id IS NULL`),
|
|
57
|
+
* so an issue delivery has no workspace to publish against. Dropped (204) like the
|
|
58
|
+
* other pre-publish cases; kept distinct from SentryConnectionNotFoundError so the
|
|
59
|
+
* carried identifier is unambiguously an installation uuid, not a connection id.
|
|
60
|
+
*/ export class SentryInstallationUnclaimedError extends SentryIssueDroppedError {
|
|
61
|
+
constructor(installationUuid){
|
|
62
|
+
super(`Sentry installation is verified but not yet claimed: ${installationUuid}`), this.installationUuid = installationUuid;
|
|
63
|
+
this.name = 'SentryInstallationUnclaimedError';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/errors.ts"],"sourcesContent":["import {IntegrationProviderError} from '@shipfox/api-integration-core-dto';\n\nexport class SentryIntegrationProviderError extends IntegrationProviderError {}\n\nexport class SentryInstallationAlreadyLinkedError extends Error {\n constructor(public readonly installationUuid: string) {\n super(\n `Sentry installation is already linked to another Shipfox workspace: ${installationUuid}`,\n );\n this.name = 'SentryInstallationAlreadyLinkedError';\n }\n}\n\n/**\n * The claim could not prove control of the named install: the presented code\n * neither exchanged successfully nor matched the stored `code_hash`. Surfaced as\n * a 403 so a forged or leaked bare uuid cannot bind someone else's install.\n */\nexport class SentryClaimProofMismatchError extends Error {\n constructor(public readonly installationUuid: string) {\n super(`Sentry claim could not be verified for installation: ${installationUuid}`);\n this.name = 'SentryClaimProofMismatchError';\n }\n}\n\n/**\n * A concurrent path (the signed webhook) is mid-exchange for this install, so the\n * verified row is not visible to the claim yet. Retryable: the existing client\n * backoff re-calls and finds the now-persisted row.\n */\nexport class SentryVerificationInProgressError extends Error {\n constructor(\n public readonly installationUuid: string,\n public readonly retryAfterSeconds = 2,\n ) {\n super(`Sentry installation verification is still in progress: ${installationUuid}`);\n this.name = 'SentryVerificationInProgressError';\n }\n}\n\n/**\n * Base for issue deliveries we received and authenticated but deliberately do\n * not publish. The webhook layer records them for dedup and acknowledges with a\n * 204 rather than treating them as failures — a sustained non-2xx can make Sentry\n * disable the webhook. Carries no HTTP concerns so workers/jobs can reuse it.\n */\nexport class SentryIssueDroppedError extends Error {}\n\nexport class SentryInstallationNotFoundError extends SentryIssueDroppedError {\n constructor(public readonly installationUuid: string) {\n super(`Sentry installation not found: ${installationUuid}`);\n this.name = 'SentryInstallationNotFoundError';\n }\n}\n\nexport class SentryInstallationDeletedError extends SentryIssueDroppedError {\n constructor(public readonly installationUuid: string) {\n super(`Sentry installation is deleted: ${installationUuid}`);\n this.name = 'SentryInstallationDeletedError';\n }\n}\n\nexport class SentryConnectionNotFoundError extends SentryIssueDroppedError {\n constructor(public readonly connectionId: string) {\n super(`Sentry installation has no connection: ${connectionId}`);\n this.name = 'SentryConnectionNotFoundError';\n }\n}\n\n/**\n * A verified install that no workspace has claimed yet (`connection_id IS NULL`),\n * so an issue delivery has no workspace to publish against. Dropped (204) like the\n * other pre-publish cases; kept distinct from SentryConnectionNotFoundError so the\n * carried identifier is unambiguously an installation uuid, not a connection id.\n */\nexport class SentryInstallationUnclaimedError extends SentryIssueDroppedError {\n constructor(public readonly installationUuid: string) {\n super(`Sentry installation is verified but not yet claimed: ${installationUuid}`);\n this.name = 'SentryInstallationUnclaimedError';\n }\n}\n"],"names":["IntegrationProviderError","SentryIntegrationProviderError","SentryInstallationAlreadyLinkedError","Error","installationUuid","name","SentryClaimProofMismatchError","SentryVerificationInProgressError","retryAfterSeconds","SentryIssueDroppedError","SentryInstallationNotFoundError","SentryInstallationDeletedError","SentryConnectionNotFoundError","connectionId","SentryInstallationUnclaimedError"],"mappings":"AAAA,SAAQA,wBAAwB,QAAO,oCAAoC;AAE3E,OAAO,MAAMC,uCAAuCD;AAA0B;AAE9E,OAAO,MAAME,6CAA6CC;IACxD,YAAY,AAAgBC,gBAAwB,CAAE;QACpD,KAAK,CACH,CAAC,oEAAoE,EAAEA,kBAAkB,QAFjEA,mBAAAA;QAI1B,IAAI,CAACC,IAAI,GAAG;IACd;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAMC,sCAAsCH;IACjD,YAAY,AAAgBC,gBAAwB,CAAE;QACpD,KAAK,CAAC,CAAC,qDAAqD,EAAEA,kBAAkB,QADtDA,mBAAAA;QAE1B,IAAI,CAACC,IAAI,GAAG;IACd;AACF;AAEA;;;;CAIC,GACD,OAAO,MAAME,0CAA0CJ;IACrD,YACE,AAAgBC,gBAAwB,EACxC,AAAgBI,oBAAoB,CAAC,CACrC;QACA,KAAK,CAAC,CAAC,uDAAuD,EAAEJ,kBAAkB,QAHlEA,mBAAAA,uBACAI,oBAAAA;QAGhB,IAAI,CAACH,IAAI,GAAG;IACd;AACF;AAEA;;;;;CAKC,GACD,OAAO,MAAMI,gCAAgCN;AAAO;AAEpD,OAAO,MAAMO,wCAAwCD;IACnD,YAAY,AAAgBL,gBAAwB,CAAE;QACpD,KAAK,CAAC,CAAC,+BAA+B,EAAEA,kBAAkB,QADhCA,mBAAAA;QAE1B,IAAI,CAACC,IAAI,GAAG;IACd;AACF;AAEA,OAAO,MAAMM,uCAAuCF;IAClD,YAAY,AAAgBL,gBAAwB,CAAE;QACpD,KAAK,CAAC,CAAC,gCAAgC,EAAEA,kBAAkB,QADjCA,mBAAAA;QAE1B,IAAI,CAACC,IAAI,GAAG;IACd;AACF;AAEA,OAAO,MAAMO,sCAAsCH;IACjD,YAAY,AAAgBI,YAAoB,CAAE;QAChD,KAAK,CAAC,CAAC,uCAAuC,EAAEA,cAAc,QADpCA,eAAAA;QAE1B,IAAI,CAACR,IAAI,GAAG;IACd;AACF;AAEA;;;;;CAKC,GACD,OAAO,MAAMS,yCAAyCL;IACpD,YAAY,AAAgBL,gBAAwB,CAAE;QACpD,KAAK,CAAC,CAAC,qDAAqD,EAAEA,kBAAkB,QADtDA,mBAAAA;QAE1B,IAAI,CAACC,IAAI,GAAG;IACd;AACF"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { IntegrationConnection } from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import type { SentryApiClient, SentryAuthorization } from '#api/client.js';
|
|
3
|
+
import type { PersistVerifiedUnclaimedInstallationParams, SentryInstallation } from '#db/installations.js';
|
|
4
|
+
export interface ConnectSentryInstallationInput {
|
|
5
|
+
workspaceId: string;
|
|
6
|
+
installationUuid: string;
|
|
7
|
+
orgSlug: string;
|
|
8
|
+
displayName: string;
|
|
9
|
+
installerUserId: string;
|
|
10
|
+
codeHash: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function hashAuthorizationCode(code: string): string;
|
|
13
|
+
export interface VerifyAndPersistUnclaimedInstallationParams {
|
|
14
|
+
sentry: SentryApiClient;
|
|
15
|
+
installationUuid: string;
|
|
16
|
+
code: string;
|
|
17
|
+
orgSlug?: string | undefined;
|
|
18
|
+
verifyInstall: boolean;
|
|
19
|
+
persistVerifiedUnclaimedInstallation: (input: PersistVerifiedUnclaimedInstallationParams) => Promise<SentryInstallation>;
|
|
20
|
+
}
|
|
21
|
+
export interface VerifyAndPersistUnclaimedInstallationResult {
|
|
22
|
+
installation: SentryInstallation;
|
|
23
|
+
authorization: SentryAuthorization;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Security-critical exchange → persist → best-effort verify, shared by the signed
|
|
27
|
+
* webhook and the browser-first claim. The exchange is the authenticity check and
|
|
28
|
+
* spends the single-use code, so it runs OUTSIDE any DB transaction; the caller's
|
|
29
|
+
* `persistVerifiedUnclaimedInstallation` owns the short transaction. The verify
|
|
30
|
+
* runs AFTER the row is durably persisted, so a verify failure leaves a claimable
|
|
31
|
+
* row rather than a Sentry-side "installed" state pointing at a row that was never
|
|
32
|
+
* written. Never logs the raw code.
|
|
33
|
+
*/
|
|
34
|
+
export declare function verifyAndPersistUnclaimedInstallation(params: VerifyAndPersistUnclaimedInstallationParams): Promise<VerifyAndPersistUnclaimedInstallationResult>;
|
|
35
|
+
export interface HandleSentryConnectParams {
|
|
36
|
+
sentry: SentryApiClient;
|
|
37
|
+
workspaceId: string;
|
|
38
|
+
code: string;
|
|
39
|
+
installationUuid: string;
|
|
40
|
+
installerUserId: string;
|
|
41
|
+
verifyInstall: boolean;
|
|
42
|
+
getSentryInstallation: (input: {
|
|
43
|
+
installationUuid: string;
|
|
44
|
+
}) => Promise<SentryInstallation | undefined>;
|
|
45
|
+
getConnectionById: (id: string) => Promise<IntegrationConnection<'sentry'> | undefined>;
|
|
46
|
+
connectSentryInstallation: (input: ConnectSentryInstallationInput) => Promise<IntegrationConnection<'sentry'>>;
|
|
47
|
+
persistVerifiedUnclaimedInstallation: (input: PersistVerifiedUnclaimedInstallationParams) => Promise<SentryInstallation>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Binds a verified Sentry installation to a workspace (the claim half of the
|
|
51
|
+
* webhook-authoritative flow). The webhook persists the verified-unclaimed row;
|
|
52
|
+
* this proves the claimant controls the install and sets `connection_id`.
|
|
53
|
+
*
|
|
54
|
+
* Proof rules (unified claim auth):
|
|
55
|
+
* - exchange succeeds → browser-first winner or a re-entry with a fresh code.
|
|
56
|
+
* - exchange "already used" + the presented code hashes to the stored hash →
|
|
57
|
+
* the same code the webhook spent, so the claimant holds it (same-code race).
|
|
58
|
+
* - exchange "already used" + the verified row is not visible yet → a concurrent
|
|
59
|
+
* webhook is mid-exchange; retryable so the client backoff reconciles.
|
|
60
|
+
* - anything else on an existing unclaimed row → proof mismatch (403, IDOR gate).
|
|
61
|
+
*/
|
|
62
|
+
export declare function handleSentryConnect(params: HandleSentryConnectParams): Promise<IntegrationConnection<'sentry'>>;
|
|
63
|
+
//# sourceMappingURL=install.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../src/core/install.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,qBAAqB,EAAC,MAAM,mCAAmC,CAAC;AAE7E,OAAO,KAAK,EAAC,eAAe,EAAE,mBAAmB,EAAC,MAAM,gBAAgB,CAAC;AAQzE,OAAO,KAAK,EACV,0CAA0C,EAC1C,kBAAkB,EACnB,MAAM,sBAAsB,CAAC;AAE9B,MAAM,WAAW,8BAA8B;IAC7C,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAKD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1D;AAUD,MAAM,WAAW,2CAA2C;IAC1D,MAAM,EAAE,eAAe,CAAC;IACxB,gBAAgB,EAAE,MAAM,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IAGb,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,aAAa,EAAE,OAAO,CAAC;IACvB,oCAAoC,EAAE,CACpC,KAAK,EAAE,0CAA0C,KAC9C,OAAO,CAAC,kBAAkB,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,2CAA2C;IAC1D,YAAY,EAAE,kBAAkB,CAAC;IACjC,aAAa,EAAE,mBAAmB,CAAC;CACpC;AAED;;;;;;;;GAQG;AACH,wBAAsB,qCAAqC,CACzD,MAAM,EAAE,2CAA2C,GAClD,OAAO,CAAC,2CAA2C,CAAC,CA8BtD;AAED,MAAM,WAAW,yBAAyB;IACxC,MAAM,EAAE,eAAe,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,aAAa,EAAE,OAAO,CAAC;IACvB,qBAAqB,EAAE,CAAC,KAAK,EAAE;QAC7B,gBAAgB,EAAE,MAAM,CAAC;KAC1B,KAAK,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC;IAC9C,iBAAiB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,QAAQ,CAAC,GAAG,SAAS,CAAC,CAAC;IACxF,yBAAyB,EAAE,CACzB,KAAK,EAAE,8BAA8B,KAClC,OAAO,CAAC,qBAAqB,CAAC,QAAQ,CAAC,CAAC,CAAC;IAC9C,oCAAoC,EAAE,CACpC,KAAK,EAAE,0CAA0C,KAC9C,OAAO,CAAC,kBAAkB,CAAC,CAAC;CAClC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,yBAAyB,GAChC,OAAO,CAAC,qBAAqB,CAAC,QAAQ,CAAC,CAAC,CAc1C"}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { logger } from '@shipfox/node-opentelemetry';
|
|
3
|
+
import { SentryClaimProofMismatchError, SentryInstallationAlreadyLinkedError, SentryInstallationDeletedError, SentryIntegrationProviderError, SentryVerificationInProgressError } from '#core/errors.js';
|
|
4
|
+
// sha256 of the single-use authorization code. Stored on the install row so the
|
|
5
|
+
// claim can prove the claimant holds the same code Sentry issued, without ever
|
|
6
|
+
// persisting a live credential (the code is dead once exchanged).
|
|
7
|
+
export function hashAuthorizationCode(code) {
|
|
8
|
+
return createHash('sha256').update(code).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
// Constant-time compare of two sha256 hex digests, matching the HMAC check in
|
|
11
|
+
// signature.ts. The length guard runs first because timingSafeEqual throws on a
|
|
12
|
+
// length mismatch, and a digest's length is not secret.
|
|
13
|
+
function codeHashesEqual(presented, stored) {
|
|
14
|
+
if (presented.length !== stored.length) return false;
|
|
15
|
+
return timingSafeEqual(Buffer.from(presented), Buffer.from(stored));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Security-critical exchange → persist → best-effort verify, shared by the signed
|
|
19
|
+
* webhook and the browser-first claim. The exchange is the authenticity check and
|
|
20
|
+
* spends the single-use code, so it runs OUTSIDE any DB transaction; the caller's
|
|
21
|
+
* `persistVerifiedUnclaimedInstallation` owns the short transaction. The verify
|
|
22
|
+
* runs AFTER the row is durably persisted, so a verify failure leaves a claimable
|
|
23
|
+
* row rather than a Sentry-side "installed" state pointing at a row that was never
|
|
24
|
+
* written. Never logs the raw code.
|
|
25
|
+
*/ export async function verifyAndPersistUnclaimedInstallation(params) {
|
|
26
|
+
const authorization = await params.sentry.exchangeAuthorizationCode({
|
|
27
|
+
installationUuid: params.installationUuid,
|
|
28
|
+
code: params.code
|
|
29
|
+
});
|
|
30
|
+
const orgSlug = params.orgSlug ?? (await params.sentry.getInstallation({
|
|
31
|
+
installationUuid: params.installationUuid,
|
|
32
|
+
token: authorization.token
|
|
33
|
+
})).orgSlug;
|
|
34
|
+
const installation = await params.persistVerifiedUnclaimedInstallation({
|
|
35
|
+
installationUuid: params.installationUuid,
|
|
36
|
+
orgSlug,
|
|
37
|
+
codeHash: hashAuthorizationCode(params.code)
|
|
38
|
+
});
|
|
39
|
+
if (params.verifyInstall) {
|
|
40
|
+
await bestEffortVerifyInstallation({
|
|
41
|
+
sentry: params.sentry,
|
|
42
|
+
installationUuid: params.installationUuid,
|
|
43
|
+
token: authorization.token
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
installation,
|
|
48
|
+
authorization
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Binds a verified Sentry installation to a workspace (the claim half of the
|
|
53
|
+
* webhook-authoritative flow). The webhook persists the verified-unclaimed row;
|
|
54
|
+
* this proves the claimant controls the install and sets `connection_id`.
|
|
55
|
+
*
|
|
56
|
+
* Proof rules (unified claim auth):
|
|
57
|
+
* - exchange succeeds → browser-first winner or a re-entry with a fresh code.
|
|
58
|
+
* - exchange "already used" + the presented code hashes to the stored hash →
|
|
59
|
+
* the same code the webhook spent, so the claimant holds it (same-code race).
|
|
60
|
+
* - exchange "already used" + the verified row is not visible yet → a concurrent
|
|
61
|
+
* webhook is mid-exchange; retryable so the client backoff reconciles.
|
|
62
|
+
* - anything else on an existing unclaimed row → proof mismatch (403, IDOR gate).
|
|
63
|
+
*/ export async function handleSentryConnect(params) {
|
|
64
|
+
const install = await params.getSentryInstallation({
|
|
65
|
+
installationUuid: params.installationUuid
|
|
66
|
+
});
|
|
67
|
+
if (install) {
|
|
68
|
+
if (install.status === 'deleted') {
|
|
69
|
+
throw new SentryInstallationDeletedError(params.installationUuid);
|
|
70
|
+
}
|
|
71
|
+
if (install.connectionId) {
|
|
72
|
+
return resolveClaimedInstall(params, install.connectionId);
|
|
73
|
+
}
|
|
74
|
+
return claimVerifiedInstall(params, install);
|
|
75
|
+
}
|
|
76
|
+
return claimBrowserFirst(params);
|
|
77
|
+
}
|
|
78
|
+
async function resolveClaimedInstall(params, connectionId) {
|
|
79
|
+
const connection = await params.getConnectionById(connectionId);
|
|
80
|
+
if (connection && connection.workspaceId === params.workspaceId) {
|
|
81
|
+
return connection;
|
|
82
|
+
}
|
|
83
|
+
throw new SentryInstallationAlreadyLinkedError(params.installationUuid);
|
|
84
|
+
}
|
|
85
|
+
async function claimVerifiedInstall(params, install) {
|
|
86
|
+
let authorization;
|
|
87
|
+
try {
|
|
88
|
+
authorization = await params.sentry.exchangeAuthorizationCode({
|
|
89
|
+
installationUuid: params.installationUuid,
|
|
90
|
+
code: params.code
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (isCodeAlreadyUsed(error)) {
|
|
94
|
+
if (install.codeHash && codeHashesEqual(hashAuthorizationCode(params.code), install.codeHash)) {
|
|
95
|
+
return bindClaim(params, {
|
|
96
|
+
orgSlug: install.orgSlug,
|
|
97
|
+
codeHash: install.codeHash
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
throw new SentryClaimProofMismatchError(params.installationUuid);
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
const connection = await bindClaim(params, {
|
|
105
|
+
orgSlug: install.orgSlug,
|
|
106
|
+
codeHash: hashAuthorizationCode(params.code)
|
|
107
|
+
});
|
|
108
|
+
if (params.verifyInstall) {
|
|
109
|
+
await bestEffortVerifyInstallation({
|
|
110
|
+
sentry: params.sentry,
|
|
111
|
+
installationUuid: params.installationUuid,
|
|
112
|
+
token: authorization.token,
|
|
113
|
+
connectionId: connection.id
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return connection;
|
|
117
|
+
}
|
|
118
|
+
async function claimBrowserFirst(params) {
|
|
119
|
+
let result;
|
|
120
|
+
try {
|
|
121
|
+
result = await verifyAndPersistUnclaimedInstallation({
|
|
122
|
+
sentry: params.sentry,
|
|
123
|
+
installationUuid: params.installationUuid,
|
|
124
|
+
code: params.code,
|
|
125
|
+
verifyInstall: false,
|
|
126
|
+
persistVerifiedUnclaimedInstallation: params.persistVerifiedUnclaimedInstallation
|
|
127
|
+
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (isCodeAlreadyUsed(error)) {
|
|
130
|
+
return reconcileConcurrentClaim(params);
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
const connection = await bindClaim(params, {
|
|
135
|
+
orgSlug: result.installation.orgSlug,
|
|
136
|
+
codeHash: hashAuthorizationCode(params.code)
|
|
137
|
+
});
|
|
138
|
+
if (params.verifyInstall) {
|
|
139
|
+
await bestEffortVerifyInstallation({
|
|
140
|
+
sentry: params.sentry,
|
|
141
|
+
installationUuid: params.installationUuid,
|
|
142
|
+
token: result.authorization.token,
|
|
143
|
+
connectionId: connection.id
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return connection;
|
|
147
|
+
}
|
|
148
|
+
// The browser-first exchange got "already used" with no row visible at lookup: a
|
|
149
|
+
// concurrent webhook won the exchange. Re-read once — if its verified row is now
|
|
150
|
+
// visible we reconcile through the same proof rules; if it got claimed we resolve
|
|
151
|
+
// it; if it was tombstoned we surface that terminally (matching the top-level
|
|
152
|
+
// check); otherwise it is still mid-flight, so the claim is retryable.
|
|
153
|
+
async function reconcileConcurrentClaim(params) {
|
|
154
|
+
const reread = await params.getSentryInstallation({
|
|
155
|
+
installationUuid: params.installationUuid
|
|
156
|
+
});
|
|
157
|
+
if (reread?.status === 'deleted') {
|
|
158
|
+
throw new SentryInstallationDeletedError(params.installationUuid);
|
|
159
|
+
}
|
|
160
|
+
if (reread) {
|
|
161
|
+
if (reread.connectionId) {
|
|
162
|
+
return resolveClaimedInstall(params, reread.connectionId);
|
|
163
|
+
}
|
|
164
|
+
if (isVerifiedUnclaimed(reread)) {
|
|
165
|
+
return claimVerifiedInstall(params, reread);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw new SentryVerificationInProgressError(params.installationUuid);
|
|
169
|
+
}
|
|
170
|
+
function isVerifiedUnclaimed(install) {
|
|
171
|
+
return install.connectionId === null && install.status === 'installed';
|
|
172
|
+
}
|
|
173
|
+
function bindClaim(params, binding) {
|
|
174
|
+
return params.connectSentryInstallation({
|
|
175
|
+
workspaceId: params.workspaceId,
|
|
176
|
+
installationUuid: params.installationUuid,
|
|
177
|
+
orgSlug: binding.orgSlug,
|
|
178
|
+
displayName: `Sentry ${binding.orgSlug}`,
|
|
179
|
+
installerUserId: params.installerUserId,
|
|
180
|
+
codeHash: binding.codeHash
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async function bestEffortVerifyInstallation(input) {
|
|
184
|
+
// The row is already persisted and receiving webhooks, so a verify failure only
|
|
185
|
+
// leaves the install pending on Sentry's side (re-verifying needs a fresh token
|
|
186
|
+
// we cannot mint here). Log it rather than failing a working claim.
|
|
187
|
+
try {
|
|
188
|
+
await input.sentry.verifyInstallation({
|
|
189
|
+
installationUuid: input.installationUuid,
|
|
190
|
+
token: input.token
|
|
191
|
+
});
|
|
192
|
+
} catch (error) {
|
|
193
|
+
logger().warn({
|
|
194
|
+
installationUuid: input.installationUuid,
|
|
195
|
+
connectionId: input.connectionId,
|
|
196
|
+
err: error
|
|
197
|
+
}, 'sentry connect: verify-install failed after persistence');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// The Sentry client collapses a reused, expired, or forged code to a single
|
|
201
|
+
// `access-denied` provider error; we cannot tell them apart from the response.
|
|
202
|
+
// On an install we already know was verified, the most likely cause is the code
|
|
203
|
+
// having been spent already, so the hash check disambiguates rather than the
|
|
204
|
+
// error itself.
|
|
205
|
+
function isCodeAlreadyUsed(error) {
|
|
206
|
+
return error instanceof SentryIntegrationProviderError && error.reason === 'access-denied';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
//# sourceMappingURL=install.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/install.ts"],"sourcesContent":["import {createHash, timingSafeEqual} from 'node:crypto';\nimport type {IntegrationConnection} from '@shipfox/api-integration-core-dto';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport type {SentryApiClient, SentryAuthorization} from '#api/client.js';\nimport {\n SentryClaimProofMismatchError,\n SentryInstallationAlreadyLinkedError,\n SentryInstallationDeletedError,\n SentryIntegrationProviderError,\n SentryVerificationInProgressError,\n} from '#core/errors.js';\nimport type {\n PersistVerifiedUnclaimedInstallationParams,\n SentryInstallation,\n} from '#db/installations.js';\n\nexport interface ConnectSentryInstallationInput {\n workspaceId: string;\n installationUuid: string;\n orgSlug: string;\n displayName: string;\n installerUserId: string;\n codeHash: string;\n}\n\n// sha256 of the single-use authorization code. Stored on the install row so the\n// claim can prove the claimant holds the same code Sentry issued, without ever\n// persisting a live credential (the code is dead once exchanged).\nexport function hashAuthorizationCode(code: string): string {\n return createHash('sha256').update(code).digest('hex');\n}\n\n// Constant-time compare of two sha256 hex digests, matching the HMAC check in\n// signature.ts. The length guard runs first because timingSafeEqual throws on a\n// length mismatch, and a digest's length is not secret.\nfunction codeHashesEqual(presented: string, stored: string): boolean {\n if (presented.length !== stored.length) return false;\n return timingSafeEqual(Buffer.from(presented), Buffer.from(stored));\n}\n\nexport interface VerifyAndPersistUnclaimedInstallationParams {\n sentry: SentryApiClient;\n installationUuid: string;\n code: string;\n // Known from the signed webhook payload; omit to derive it from Sentry after\n // the exchange (the browser-first claim carries no payload).\n orgSlug?: string | undefined;\n verifyInstall: boolean;\n persistVerifiedUnclaimedInstallation: (\n input: PersistVerifiedUnclaimedInstallationParams,\n ) => Promise<SentryInstallation>;\n}\n\nexport interface VerifyAndPersistUnclaimedInstallationResult {\n installation: SentryInstallation;\n authorization: SentryAuthorization;\n}\n\n/**\n * Security-critical exchange → persist → best-effort verify, shared by the signed\n * webhook and the browser-first claim. The exchange is the authenticity check and\n * spends the single-use code, so it runs OUTSIDE any DB transaction; the caller's\n * `persistVerifiedUnclaimedInstallation` owns the short transaction. The verify\n * runs AFTER the row is durably persisted, so a verify failure leaves a claimable\n * row rather than a Sentry-side \"installed\" state pointing at a row that was never\n * written. Never logs the raw code.\n */\nexport async function verifyAndPersistUnclaimedInstallation(\n params: VerifyAndPersistUnclaimedInstallationParams,\n): Promise<VerifyAndPersistUnclaimedInstallationResult> {\n const authorization = await params.sentry.exchangeAuthorizationCode({\n installationUuid: params.installationUuid,\n code: params.code,\n });\n\n const orgSlug =\n params.orgSlug ??\n (\n await params.sentry.getInstallation({\n installationUuid: params.installationUuid,\n token: authorization.token,\n })\n ).orgSlug;\n\n const installation = await params.persistVerifiedUnclaimedInstallation({\n installationUuid: params.installationUuid,\n orgSlug,\n codeHash: hashAuthorizationCode(params.code),\n });\n\n if (params.verifyInstall) {\n await bestEffortVerifyInstallation({\n sentry: params.sentry,\n installationUuid: params.installationUuid,\n token: authorization.token,\n });\n }\n\n return {installation, authorization};\n}\n\nexport interface HandleSentryConnectParams {\n sentry: SentryApiClient;\n workspaceId: string;\n code: string;\n installationUuid: string;\n installerUserId: string;\n verifyInstall: boolean;\n getSentryInstallation: (input: {\n installationUuid: string;\n }) => Promise<SentryInstallation | undefined>;\n getConnectionById: (id: string) => Promise<IntegrationConnection<'sentry'> | undefined>;\n connectSentryInstallation: (\n input: ConnectSentryInstallationInput,\n ) => Promise<IntegrationConnection<'sentry'>>;\n persistVerifiedUnclaimedInstallation: (\n input: PersistVerifiedUnclaimedInstallationParams,\n ) => Promise<SentryInstallation>;\n}\n\n/**\n * Binds a verified Sentry installation to a workspace (the claim half of the\n * webhook-authoritative flow). The webhook persists the verified-unclaimed row;\n * this proves the claimant controls the install and sets `connection_id`.\n *\n * Proof rules (unified claim auth):\n * - exchange succeeds → browser-first winner or a re-entry with a fresh code.\n * - exchange \"already used\" + the presented code hashes to the stored hash →\n * the same code the webhook spent, so the claimant holds it (same-code race).\n * - exchange \"already used\" + the verified row is not visible yet → a concurrent\n * webhook is mid-exchange; retryable so the client backoff reconciles.\n * - anything else on an existing unclaimed row → proof mismatch (403, IDOR gate).\n */\nexport async function handleSentryConnect(\n params: HandleSentryConnectParams,\n): Promise<IntegrationConnection<'sentry'>> {\n const install = await params.getSentryInstallation({installationUuid: params.installationUuid});\n\n if (install) {\n if (install.status === 'deleted') {\n throw new SentryInstallationDeletedError(params.installationUuid);\n }\n if (install.connectionId) {\n return resolveClaimedInstall(params, install.connectionId);\n }\n return claimVerifiedInstall(params, install);\n }\n\n return claimBrowserFirst(params);\n}\n\nasync function resolveClaimedInstall(\n params: HandleSentryConnectParams,\n connectionId: string,\n): Promise<IntegrationConnection<'sentry'>> {\n const connection = await params.getConnectionById(connectionId);\n if (connection && connection.workspaceId === params.workspaceId) {\n return connection;\n }\n throw new SentryInstallationAlreadyLinkedError(params.installationUuid);\n}\n\nasync function claimVerifiedInstall(\n params: HandleSentryConnectParams,\n install: SentryInstallation,\n): Promise<IntegrationConnection<'sentry'>> {\n let authorization: SentryAuthorization;\n try {\n authorization = await params.sentry.exchangeAuthorizationCode({\n installationUuid: params.installationUuid,\n code: params.code,\n });\n } catch (error) {\n if (isCodeAlreadyUsed(error)) {\n if (\n install.codeHash &&\n codeHashesEqual(hashAuthorizationCode(params.code), install.codeHash)\n ) {\n return bindClaim(params, {orgSlug: install.orgSlug, codeHash: install.codeHash});\n }\n throw new SentryClaimProofMismatchError(params.installationUuid);\n }\n throw error;\n }\n\n const connection = await bindClaim(params, {\n orgSlug: install.orgSlug,\n codeHash: hashAuthorizationCode(params.code),\n });\n if (params.verifyInstall) {\n await bestEffortVerifyInstallation({\n sentry: params.sentry,\n installationUuid: params.installationUuid,\n token: authorization.token,\n connectionId: connection.id,\n });\n }\n return connection;\n}\n\nasync function claimBrowserFirst(\n params: HandleSentryConnectParams,\n): Promise<IntegrationConnection<'sentry'>> {\n let result: VerifyAndPersistUnclaimedInstallationResult;\n try {\n result = await verifyAndPersistUnclaimedInstallation({\n sentry: params.sentry,\n installationUuid: params.installationUuid,\n code: params.code,\n verifyInstall: false,\n persistVerifiedUnclaimedInstallation: params.persistVerifiedUnclaimedInstallation,\n });\n } catch (error) {\n if (isCodeAlreadyUsed(error)) {\n return reconcileConcurrentClaim(params);\n }\n throw error;\n }\n\n const connection = await bindClaim(params, {\n orgSlug: result.installation.orgSlug,\n codeHash: hashAuthorizationCode(params.code),\n });\n if (params.verifyInstall) {\n await bestEffortVerifyInstallation({\n sentry: params.sentry,\n installationUuid: params.installationUuid,\n token: result.authorization.token,\n connectionId: connection.id,\n });\n }\n return connection;\n}\n\n// The browser-first exchange got \"already used\" with no row visible at lookup: a\n// concurrent webhook won the exchange. Re-read once — if its verified row is now\n// visible we reconcile through the same proof rules; if it got claimed we resolve\n// it; if it was tombstoned we surface that terminally (matching the top-level\n// check); otherwise it is still mid-flight, so the claim is retryable.\nasync function reconcileConcurrentClaim(\n params: HandleSentryConnectParams,\n): Promise<IntegrationConnection<'sentry'>> {\n const reread = await params.getSentryInstallation({installationUuid: params.installationUuid});\n if (reread?.status === 'deleted') {\n throw new SentryInstallationDeletedError(params.installationUuid);\n }\n if (reread) {\n if (reread.connectionId) {\n return resolveClaimedInstall(params, reread.connectionId);\n }\n if (isVerifiedUnclaimed(reread)) {\n return claimVerifiedInstall(params, reread);\n }\n }\n throw new SentryVerificationInProgressError(params.installationUuid);\n}\n\nfunction isVerifiedUnclaimed(install: SentryInstallation): boolean {\n return install.connectionId === null && install.status === 'installed';\n}\n\nfunction bindClaim(\n params: HandleSentryConnectParams,\n binding: {orgSlug: string; codeHash: string},\n): Promise<IntegrationConnection<'sentry'>> {\n return params.connectSentryInstallation({\n workspaceId: params.workspaceId,\n installationUuid: params.installationUuid,\n orgSlug: binding.orgSlug,\n displayName: `Sentry ${binding.orgSlug}`,\n installerUserId: params.installerUserId,\n codeHash: binding.codeHash,\n });\n}\n\nasync function bestEffortVerifyInstallation(input: {\n sentry: SentryApiClient;\n installationUuid: string;\n token: string;\n connectionId?: string;\n}): Promise<void> {\n // The row is already persisted and receiving webhooks, so a verify failure only\n // leaves the install pending on Sentry's side (re-verifying needs a fresh token\n // we cannot mint here). Log it rather than failing a working claim.\n try {\n await input.sentry.verifyInstallation({\n installationUuid: input.installationUuid,\n token: input.token,\n });\n } catch (error) {\n logger().warn(\n {installationUuid: input.installationUuid, connectionId: input.connectionId, err: error},\n 'sentry connect: verify-install failed after persistence',\n );\n }\n}\n\n// The Sentry client collapses a reused, expired, or forged code to a single\n// `access-denied` provider error; we cannot tell them apart from the response.\n// On an install we already know was verified, the most likely cause is the code\n// having been spent already, so the hash check disambiguates rather than the\n// error itself.\nfunction isCodeAlreadyUsed(error: unknown): boolean {\n return error instanceof SentryIntegrationProviderError && error.reason === 'access-denied';\n}\n"],"names":["createHash","timingSafeEqual","logger","SentryClaimProofMismatchError","SentryInstallationAlreadyLinkedError","SentryInstallationDeletedError","SentryIntegrationProviderError","SentryVerificationInProgressError","hashAuthorizationCode","code","update","digest","codeHashesEqual","presented","stored","length","Buffer","from","verifyAndPersistUnclaimedInstallation","params","authorization","sentry","exchangeAuthorizationCode","installationUuid","orgSlug","getInstallation","token","installation","persistVerifiedUnclaimedInstallation","codeHash","verifyInstall","bestEffortVerifyInstallation","handleSentryConnect","install","getSentryInstallation","status","connectionId","resolveClaimedInstall","claimVerifiedInstall","claimBrowserFirst","connection","getConnectionById","workspaceId","error","isCodeAlreadyUsed","bindClaim","id","result","reconcileConcurrentClaim","reread","isVerifiedUnclaimed","binding","connectSentryInstallation","displayName","installerUserId","input","verifyInstallation","warn","err","reason"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signature.d.ts","sourceRoot":"","sources":["../../src/core/signature.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,2BAA2B,GAAG,OAAO,CAElF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/signature.ts"],"sourcesContent":["import {verifyHexHmacSignature} from '@shipfox/node-fastify';\n\nexport interface VerifySentrySignatureParams {\n rawBody: string;\n signature: string;\n secret: string;\n}\n\nexport function verifySentrySignature(params: VerifySentrySignatureParams): boolean {\n return verifyHexHmacSignature(params);\n}\n"],"names":["verifyHexHmacSignature","verifySentrySignature","params"],"mappings":"AAAA,SAAQA,sBAAsB,QAAO,wBAAwB;AAQ7D,OAAO,SAASC,sBAAsBC,MAAmC;IACvE,OAAOF,uBAAuBE;AAChC"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { GetIntegrationConnectionByIdFn, IntegrationTx, PublishIntegrationEventReceivedFn, RecordDeliveryOnlyFn, UpdateIntegrationConnectionLifecycleStatusFn } from '@shipfox/api-integration-core-dto';
|
|
2
|
+
import type { SentryIssueWebhookDto } from '@shipfox/api-integration-sentry-dto';
|
|
3
|
+
import type { SentryApiClient } from '#api/client.js';
|
|
4
|
+
import { type SentryInstallation } from '#db/installations.js';
|
|
5
|
+
export interface HandleSentryIssueEventParams {
|
|
6
|
+
tx: IntegrationTx;
|
|
7
|
+
deliveryId: string;
|
|
8
|
+
payload: SentryIssueWebhookDto;
|
|
9
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
10
|
+
getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
|
|
11
|
+
}
|
|
12
|
+
export declare function handleSentryIssueEvent(params: HandleSentryIssueEventParams): Promise<void>;
|
|
13
|
+
export interface HandleSentryInstallationCreatedParams {
|
|
14
|
+
deliveryId: string;
|
|
15
|
+
installationUuid: string;
|
|
16
|
+
orgSlug: string | undefined;
|
|
17
|
+
code: string | undefined;
|
|
18
|
+
sentry: SentryApiClient;
|
|
19
|
+
verifyInstall: boolean;
|
|
20
|
+
getSentryInstallation: (input: {
|
|
21
|
+
installationUuid: string;
|
|
22
|
+
}) => Promise<SentryInstallation | undefined>;
|
|
23
|
+
persistUnclaimedAndRecordDelivery: (input: {
|
|
24
|
+
installationUuid: string;
|
|
25
|
+
orgSlug: string;
|
|
26
|
+
codeHash: string;
|
|
27
|
+
deliveryId: string;
|
|
28
|
+
}) => Promise<SentryInstallation>;
|
|
29
|
+
recordDelivery: (deliveryId: string) => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The authoritative `installation.created` path. Whichever of webhook or browser
|
|
33
|
+
* arrives first exchanges the single-use code and persists a verified-unclaimed
|
|
34
|
+
* row; the other reconciles. Idempotent on the installation uuid. The exchange
|
|
35
|
+
* runs outside any DB transaction; a short transaction wraps only persist +
|
|
36
|
+
* delivery record. On an exchange failure the delivery is record-and-dropped
|
|
37
|
+
* (204) — the browser may still win, or the code expired. Never logs the raw code.
|
|
38
|
+
*/
|
|
39
|
+
export declare function handleSentryInstallationCreated(params: HandleSentryInstallationCreatedParams): Promise<void>;
|
|
40
|
+
export interface HandleSentryInstallationDeletedParams {
|
|
41
|
+
tx: IntegrationTx;
|
|
42
|
+
deliveryId: string;
|
|
43
|
+
installationUuid: string;
|
|
44
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
45
|
+
updateConnectionLifecycleStatus: UpdateIntegrationConnectionLifecycleStatusFn;
|
|
46
|
+
}
|
|
47
|
+
export declare function handleSentryInstallationDeleted(params: HandleSentryInstallationDeletedParams): Promise<void>;
|
|
48
|
+
export declare function normalizeSentryIssueAction(parsedJson: unknown): unknown;
|
|
49
|
+
//# sourceMappingURL=webhook.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../../src/core/webhook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,8BAA8B,EAC9B,aAAa,EACb,iCAAiC,EACjC,oBAAoB,EAEpB,4CAA4C,EAC7C,MAAM,mCAAmC,CAAC;AAC3C,OAAO,KAAK,EAAC,qBAAqB,EAAC,MAAM,qCAAqC,CAAC;AAE/E,OAAO,KAAK,EAAC,eAAe,EAAC,MAAM,gBAAgB,CAAC;AASpD,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,sBAAsB,CAAC;AAM9B,MAAM,WAAW,4BAA4B;IAC3C,EAAE,EAAE,aAAa,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,qBAAqB,CAAC;IAC/B,+BAA+B,EAAE,iCAAiC,CAAC;IACnE,4BAA4B,EAAE,8BAA8B,CAAC;CAC9D;AAOD,wBAAsB,sBAAsB,CAAC,MAAM,EAAE,4BAA4B,GAAG,OAAO,CAAC,IAAI,CAAC,CAuChG;AAED,MAAM,WAAW,qCAAqC;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IAGzB,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5B,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,MAAM,EAAE,eAAe,CAAC;IACxB,aAAa,EAAE,OAAO,CAAC;IACvB,qBAAqB,EAAE,CAAC,KAAK,EAAE;QAC7B,gBAAgB,EAAE,MAAM,CAAC;KAC1B,KAAK,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAAC;IAG9C,iCAAiC,EAAE,CAAC,KAAK,EAAE;QACzC,gBAAgB,EAAE,MAAM,CAAC;QACzB,OAAO,EAAE,MAAM,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;KACpB,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;IAGlC,cAAc,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvD;AAED;;;;;;;GAOG;AACH,wBAAsB,+BAA+B,CACnD,MAAM,EAAE,qCAAqC,GAC5C,OAAO,CAAC,IAAI,CAAC,CAgEf;AAED,MAAM,WAAW,qCAAqC;IACpD,EAAE,EAAE,aAAa,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IACzB,kBAAkB,EAAE,oBAAoB,CAAC;IACzC,+BAA+B,EAAE,4CAA4C,CAAC;CAC/E;AAMD,wBAAsB,+BAA+B,CACnD,MAAM,EAAE,qCAAqC,GAC5C,OAAO,CAAC,IAAI,CAAC,CAiBf;AAID,wBAAgB,0BAA0B,CAAC,UAAU,EAAE,OAAO,GAAG,OAAO,CAOvE"}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { logger } from '@shipfox/node-opentelemetry';
|
|
2
|
+
import { SentryConnectionNotFoundError, SentryInstallationDeletedError, SentryInstallationNotFoundError, SentryInstallationUnclaimedError, SentryIntegrationProviderError } from '#core/errors.js';
|
|
3
|
+
import { verifyAndPersistUnclaimedInstallation } from '#core/install.js';
|
|
4
|
+
import { getSentryInstallationByInstallationUuid, markSentryInstallationDeleted } from '#db/installations.js';
|
|
5
|
+
const SENTRY_SOURCE = 'sentry';
|
|
6
|
+
const DEFAULT_ISSUE_TITLE = 'Sentry issue';
|
|
7
|
+
const DELETED_STATUS = 'deleted';
|
|
8
|
+
// Publishes the mapped event for a verified issue delivery. Throws a typed
|
|
9
|
+
// SentryIssueDroppedError subclass when the delivery references state we cannot
|
|
10
|
+
// publish against (unknown/deleted installation, or an install not yet claimed
|
|
11
|
+
// into a workspace); the webhook layer records-and-drops those. Dedup of an
|
|
12
|
+
// already-seen delivery is handled inside publishIntegrationEventReceived.
|
|
13
|
+
export async function handleSentryIssueEvent(params) {
|
|
14
|
+
const installationUuid = params.payload.installation.uuid;
|
|
15
|
+
const installation = await getSentryInstallationByInstallationUuid(installationUuid, {
|
|
16
|
+
tx: params.tx
|
|
17
|
+
});
|
|
18
|
+
if (!installation) {
|
|
19
|
+
throw new SentryInstallationNotFoundError(installationUuid);
|
|
20
|
+
}
|
|
21
|
+
if (installation.status === DELETED_STATUS) {
|
|
22
|
+
throw new SentryInstallationDeletedError(installationUuid);
|
|
23
|
+
}
|
|
24
|
+
// A verified-but-unclaimed install has no workspace yet, so there is nothing to
|
|
25
|
+
// publish an event against. This is the pre-claim drop window (counted/logged).
|
|
26
|
+
if (!installation.connectionId) {
|
|
27
|
+
throw new SentryInstallationUnclaimedError(installationUuid);
|
|
28
|
+
}
|
|
29
|
+
const connection = await params.getIntegrationConnectionById(installation.connectionId, {
|
|
30
|
+
tx: params.tx
|
|
31
|
+
});
|
|
32
|
+
if (!connection) {
|
|
33
|
+
throw new SentryConnectionNotFoundError(installation.connectionId);
|
|
34
|
+
}
|
|
35
|
+
await params.publishIntegrationEventReceived({
|
|
36
|
+
tx: params.tx,
|
|
37
|
+
event: {
|
|
38
|
+
provider: SENTRY_SOURCE,
|
|
39
|
+
source: connection.slug,
|
|
40
|
+
event: `issue.${params.payload.action}`,
|
|
41
|
+
workspaceId: connection.workspaceId,
|
|
42
|
+
connectionId: connection.id,
|
|
43
|
+
connectionName: connection.displayName,
|
|
44
|
+
deliveryId: params.deliveryId,
|
|
45
|
+
receivedAt: new Date().toISOString(),
|
|
46
|
+
payload: normalizeIssuePayload(params.payload)
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The authoritative `installation.created` path. Whichever of webhook or browser
|
|
52
|
+
* arrives first exchanges the single-use code and persists a verified-unclaimed
|
|
53
|
+
* row; the other reconciles. Idempotent on the installation uuid. The exchange
|
|
54
|
+
* runs outside any DB transaction; a short transaction wraps only persist +
|
|
55
|
+
* delivery record. On an exchange failure the delivery is record-and-dropped
|
|
56
|
+
* (204) — the browser may still win, or the code expired. Never logs the raw code.
|
|
57
|
+
*/ export async function handleSentryInstallationCreated(params) {
|
|
58
|
+
const existing = await params.getSentryInstallation({
|
|
59
|
+
installationUuid: params.installationUuid
|
|
60
|
+
});
|
|
61
|
+
if (existing) {
|
|
62
|
+
logger().debug({
|
|
63
|
+
deliveryId: params.deliveryId,
|
|
64
|
+
installationUuid: params.installationUuid
|
|
65
|
+
}, 'sentry webhook: installation.created for an existing row, reconciling');
|
|
66
|
+
await params.recordDelivery(params.deliveryId);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!params.code) {
|
|
70
|
+
logger().warn({
|
|
71
|
+
deliveryId: params.deliveryId,
|
|
72
|
+
installationUuid: params.installationUuid
|
|
73
|
+
}, 'sentry webhook: installation.created without an authorization code, dropping');
|
|
74
|
+
await params.recordDelivery(params.deliveryId);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await verifyAndPersistUnclaimedInstallation({
|
|
79
|
+
sentry: params.sentry,
|
|
80
|
+
installationUuid: params.installationUuid,
|
|
81
|
+
code: params.code,
|
|
82
|
+
orgSlug: params.orgSlug,
|
|
83
|
+
verifyInstall: params.verifyInstall,
|
|
84
|
+
persistVerifiedUnclaimedInstallation: ({ installationUuid, orgSlug, codeHash })=>params.persistUnclaimedAndRecordDelivery({
|
|
85
|
+
installationUuid,
|
|
86
|
+
orgSlug,
|
|
87
|
+
codeHash,
|
|
88
|
+
deliveryId: params.deliveryId
|
|
89
|
+
})
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error instanceof SentryIntegrationProviderError) {
|
|
93
|
+
// The Sentry exchange (or org-slug lookup) failed, so we never durably
|
|
94
|
+
// spent the code on a row we own. `reason` distinguishes a transient/expected
|
|
95
|
+
// drop (access-denied: the code was already spent, e.g. the browser won) from
|
|
96
|
+
// a likely misconfiguration (a bad client id/secret/slug fails every install)
|
|
97
|
+
// so log-based alerting can fire on the sustained case. Never logs the raw code.
|
|
98
|
+
logger().warn({
|
|
99
|
+
deliveryId: params.deliveryId,
|
|
100
|
+
installationUuid: params.installationUuid,
|
|
101
|
+
reason: error.reason,
|
|
102
|
+
err: error
|
|
103
|
+
}, 'sentry webhook: installation.created exchange failed, dropping');
|
|
104
|
+
} else {
|
|
105
|
+
// The exchange succeeded (the single-use code is now spent) but the persist
|
|
106
|
+
// transaction failed, so no row exists and Sentry will not usefully
|
|
107
|
+
// re-deliver — the install is stranded until a reinstall mints a fresh uuid.
|
|
108
|
+
// Log at error so alerting catches it; still record-and-drop because retrying
|
|
109
|
+
// a spent code cannot recover the row. Never logs the raw code.
|
|
110
|
+
logger().error({
|
|
111
|
+
deliveryId: params.deliveryId,
|
|
112
|
+
installationUuid: params.installationUuid,
|
|
113
|
+
err: error
|
|
114
|
+
}, 'sentry webhook: installation.created persisted nothing after a successful exchange, install stranded until reinstall');
|
|
115
|
+
}
|
|
116
|
+
await params.recordDelivery(params.deliveryId);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Tombstones the install and disables its connection if one exists. An unknown
|
|
120
|
+
// uuid (never installed, or reinstall mints a fresh uuid) only records the
|
|
121
|
+
// delivery: there is no tombstone row to write. Runs entirely in the caller's
|
|
122
|
+
// transaction — no exchange is needed.
|
|
123
|
+
export async function handleSentryInstallationDeleted(params) {
|
|
124
|
+
const installation = await markSentryInstallationDeleted({
|
|
125
|
+
installationUuid: params.installationUuid
|
|
126
|
+
}, {
|
|
127
|
+
tx: params.tx
|
|
128
|
+
});
|
|
129
|
+
if (installation?.connectionId) {
|
|
130
|
+
await params.updateConnectionLifecycleStatus({
|
|
131
|
+
id: installation.connectionId,
|
|
132
|
+
lifecycleStatus: 'disabled'
|
|
133
|
+
}, {
|
|
134
|
+
tx: params.tx
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
await params.recordDeliveryOnly({
|
|
138
|
+
tx: params.tx,
|
|
139
|
+
provider: SENTRY_SOURCE,
|
|
140
|
+
deliveryId: params.deliveryId
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// A raw Sentry `ignored` action is normalized to `archived` before validation,
|
|
144
|
+
// so legacy ignore events still fire `issue.archived` workflows.
|
|
145
|
+
export function normalizeSentryIssueAction(parsedJson) {
|
|
146
|
+
if (typeof parsedJson !== 'object' || parsedJson === null) return parsedJson;
|
|
147
|
+
const obj = parsedJson;
|
|
148
|
+
if (obj.action === 'ignored') {
|
|
149
|
+
return {
|
|
150
|
+
...obj,
|
|
151
|
+
action: 'archived'
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return parsedJson;
|
|
155
|
+
}
|
|
156
|
+
function normalizeIssuePayload(payload) {
|
|
157
|
+
const issue = payload.data.issue;
|
|
158
|
+
return {
|
|
159
|
+
action: payload.action,
|
|
160
|
+
issueId: issue.id,
|
|
161
|
+
shortId: issue.shortId ?? null,
|
|
162
|
+
title: issue.title ?? DEFAULT_ISSUE_TITLE,
|
|
163
|
+
culprit: issue.culprit ?? null,
|
|
164
|
+
level: issue.level ?? null,
|
|
165
|
+
status: issue.status ?? null,
|
|
166
|
+
platform: issue.platform ?? null,
|
|
167
|
+
webUrl: issue.web_url ?? null,
|
|
168
|
+
issueUrl: issue.url ?? null,
|
|
169
|
+
projectUrl: issue.project_url ?? null,
|
|
170
|
+
firstSeenAt: issue.firstSeen ?? null,
|
|
171
|
+
lastSeenAt: issue.lastSeen ?? null
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
//# sourceMappingURL=webhook.js.map
|