@shipfox/api-integration-sentry 11.0.0 → 12.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 +4 -4
- package/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/dist/api/client.js +1 -1
- package/dist/api/client.js.map +1 -1
- package/dist/core/errors.d.ts +1 -1
- package/dist/core/errors.js +1 -1
- package/dist/core/errors.js.map +1 -1
- package/dist/core/install.js +1 -1
- package/dist/core/install.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/api/client.ts +1 -1
- package/src/core/errors.ts +1 -1
- package/src/core/install.test.ts +4 -4
- package/src/core/install.ts +1 -1
- package/src/presentation/routes/install.test.ts +1 -1
- package/test/factories/sentry-installation-webhook.ts +1 -1
- package/test/factories/sentry-issue-webhook.ts +1 -1
- package/tsconfig.build.tsbuildinfo +1 -1
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
$ shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js
|
|
2
|
-
Successfully compiled: 23 files with swc (
|
|
3
|
-
2026-
|
|
2
|
+
Successfully compiled: 23 files with swc (234.66ms)
|
|
3
|
+
2026-08-04T08:17:17.026Z [INFO] asset workflow-bundle-46314ad53629a6893a6d.js 3.17 MiB [emitted] [immutable] (name: main)
|
|
4
4
|
orphan modules 33.5 KiB [orphan] 21 modules
|
|
5
5
|
runtime modules 1.13 KiB 5 modules
|
|
6
6
|
modules by path ../../../../node_modules/.pnpm/ 970 KiB 196 modules
|
|
@@ -12,6 +12,6 @@ optional modules 30 bytes [optional]
|
|
|
12
12
|
__temporal_custom_payload_converter (ignored) 15 bytes [optional] [built] [code generated]
|
|
13
13
|
__temporal_custom_failure_converter (ignored) 15 bytes [optional] [built] [code generated]
|
|
14
14
|
../../../shared/node/temporal/dist/workflow-error-interceptor.js 1.21 KiB [built] [code generated]
|
|
15
|
-
webpack 5.107.2 compiled successfully in
|
|
16
|
-
2026-
|
|
15
|
+
webpack 5.107.2 compiled successfully in 3267 ms
|
|
16
|
+
2026-08-04T08:17:17.035Z [INFO] Workflow bundle created { size: '3.17MB' }
|
|
17
17
|
/home/runner/work/shipfox/shipfox/libs/api/integration/sentry/dist/temporal/workflows/index.js: 3323961 bytes
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @shipfox/api-integration-sentry
|
|
2
2
|
|
|
3
|
+
## 12.0.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f78740d: Remove Unicode dash punctuation from package prose and source comments.
|
|
8
|
+
- Updated dependencies [f78740d]
|
|
9
|
+
- Updated dependencies [24ef475]
|
|
10
|
+
- Updated dependencies [f13e8bb]
|
|
11
|
+
- Updated dependencies [869a792]
|
|
12
|
+
- Updated dependencies [54c820e]
|
|
13
|
+
- @shipfox/api-integration-sentry-dto@12.0.0
|
|
14
|
+
- @shipfox/node-fastify@0.4.1
|
|
15
|
+
- @shipfox/node-module@1.0.5
|
|
16
|
+
- @shipfox/api-integration-spi@1.0.0
|
|
17
|
+
- @shipfox/node-postgres@0.5.0
|
|
18
|
+
- @shipfox/api-auth-context@12.0.0
|
|
19
|
+
- @shipfox/node-drizzle@0.3.5
|
|
20
|
+
|
|
3
21
|
## 11.0.0
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ The provider mounts these authenticated routes (`AUTH_USER` bearer token):
|
|
|
76
76
|
|
|
77
77
|
Sentry has no `state` parameter in its install redirect, so the workspace is
|
|
78
78
|
taken from the request body (`workspace_id`) and authorized against the live
|
|
79
|
-
session. `POST /connect` accepts `{workspace_id, code, installation_id}` only
|
|
79
|
+
session. `POST /connect` accepts `{workspace_id, code, installation_id}` only;
|
|
80
80
|
the organization slug is derived from Sentry after the code exchange, never
|
|
81
81
|
trusted from the client. The exchanged token is used in-memory for the optional
|
|
82
82
|
verify-install call and then discarded; **no Sentry token is persisted**.
|
package/dist/api/client.js
CHANGED
|
@@ -54,7 +54,7 @@ export function createSentryApiClient() {
|
|
|
54
54
|
}
|
|
55
55
|
// The typed error deliberately drops Sentry's status and body so no token, code,
|
|
56
56
|
// or client secret can leak to the client or the logged error chain. That also
|
|
57
|
-
// strips the one detail a self-hoster needs to fix a misconfigured Sentry app
|
|
57
|
+
// strips the one detail a self-hoster needs to fix a misconfigured Sentry app.
|
|
58
58
|
// e.g. a 403 on `get-installation` means the app is missing "Organization: Read"
|
|
59
59
|
// (see README "Required permissions"). So we log the upstream status
|
|
60
60
|
// here, the only place it survives, keyed by the operation that failed.
|
package/dist/api/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/api/client.ts"],"sourcesContent":["import {logger} from '@shipfox/node-opentelemetry';\nimport ky, {HTTPError, TimeoutError} from 'ky';\nimport {config} from '#config.js';\nimport {SentryIntegrationProviderError} from '#core/errors.js';\n\nconst SENTRY_API_BASE = 'https://sentry.io/api/0';\n\nexport interface SentryAuthorization {\n token: string;\n refreshToken: string;\n expiresAt: string;\n}\n\nexport interface SentryInstallationDetails {\n orgSlug: string;\n}\n\nexport interface SentryApiClient {\n exchangeAuthorizationCode(input: {\n installationUuid: string;\n code: string;\n }): Promise<SentryAuthorization>;\n // Derived from Sentry so the org slug is never trusted from the client body.\n getInstallation(input: {\n installationUuid: string;\n token: string;\n }): Promise<SentryInstallationDetails>;\n verifyInstallation(input: {installationUuid: string; token: string}): Promise<void>;\n}\n\nexport function createSentryApiClient(): SentryApiClient {\n return {\n async exchangeAuthorizationCode(input) {\n // Encode the uuid: it originates from the request body / webhook payload, so\n // a path separator in it must not be able to alter the request path (the host\n // is already pinned to SENTRY_API_BASE).\n const body = await mapSentryError('exchange-authorization-code', () =>\n ky\n .post(\n `${SENTRY_API_BASE}/sentry-app-installations/${encodeURIComponent(input.installationUuid)}/authorizations/`,\n {\n json: {\n grant_type: 'authorization_code',\n client_id: config.SENTRY_APP_CLIENT_ID,\n client_secret: config.SENTRY_APP_CLIENT_SECRET,\n code: input.code,\n },\n },\n )\n .json<{token?: unknown; refreshToken?: unknown; expiresAt?: unknown}>(),\n );\n\n if (\n typeof body.token !== 'string' ||\n typeof body.refreshToken !== 'string' ||\n typeof body.expiresAt !== 'string'\n ) {\n throw new SentryIntegrationProviderError(\n 'malformed-provider-response',\n 'Sentry authorization response did not include a token',\n );\n }\n return {token: body.token, refreshToken: body.refreshToken, expiresAt: body.expiresAt};\n },\n\n async getInstallation(input) {\n const body = await mapSentryError('get-installation', () =>\n ky\n .get(\n `${SENTRY_API_BASE}/sentry-app-installations/${encodeURIComponent(input.installationUuid)}/`,\n {\n headers: {authorization: `Bearer ${input.token}`},\n },\n )\n .json<{organization?: {slug?: unknown}}>(),\n );\n\n const slug = body.organization?.slug;\n if (typeof slug !== 'string' || slug.length === 0) {\n throw new SentryIntegrationProviderError(\n 'malformed-provider-response',\n 'Sentry installation response did not include an organization slug',\n );\n }\n return {orgSlug: slug};\n },\n\n async verifyInstallation(input) {\n await mapSentryError('verify-installation', () =>\n ky\n .put(\n `${SENTRY_API_BASE}/sentry-app-installations/${encodeURIComponent(input.installationUuid)}/`,\n {\n headers: {authorization: `Bearer ${input.token}`},\n json: {status: 'installed'},\n },\n )\n .json<unknown>(),\n );\n },\n };\n}\n\n// The typed error deliberately drops Sentry's status and body so no token, code,\n// or client secret can leak to the client or the logged error chain. That also\n// strips the one detail a self-hoster needs to fix a misconfigured Sentry app
|
|
1
|
+
{"version":3,"sources":["../../src/api/client.ts"],"sourcesContent":["import {logger} from '@shipfox/node-opentelemetry';\nimport ky, {HTTPError, TimeoutError} from 'ky';\nimport {config} from '#config.js';\nimport {SentryIntegrationProviderError} from '#core/errors.js';\n\nconst SENTRY_API_BASE = 'https://sentry.io/api/0';\n\nexport interface SentryAuthorization {\n token: string;\n refreshToken: string;\n expiresAt: string;\n}\n\nexport interface SentryInstallationDetails {\n orgSlug: string;\n}\n\nexport interface SentryApiClient {\n exchangeAuthorizationCode(input: {\n installationUuid: string;\n code: string;\n }): Promise<SentryAuthorization>;\n // Derived from Sentry so the org slug is never trusted from the client body.\n getInstallation(input: {\n installationUuid: string;\n token: string;\n }): Promise<SentryInstallationDetails>;\n verifyInstallation(input: {installationUuid: string; token: string}): Promise<void>;\n}\n\nexport function createSentryApiClient(): SentryApiClient {\n return {\n async exchangeAuthorizationCode(input) {\n // Encode the uuid: it originates from the request body / webhook payload, so\n // a path separator in it must not be able to alter the request path (the host\n // is already pinned to SENTRY_API_BASE).\n const body = await mapSentryError('exchange-authorization-code', () =>\n ky\n .post(\n `${SENTRY_API_BASE}/sentry-app-installations/${encodeURIComponent(input.installationUuid)}/authorizations/`,\n {\n json: {\n grant_type: 'authorization_code',\n client_id: config.SENTRY_APP_CLIENT_ID,\n client_secret: config.SENTRY_APP_CLIENT_SECRET,\n code: input.code,\n },\n },\n )\n .json<{token?: unknown; refreshToken?: unknown; expiresAt?: unknown}>(),\n );\n\n if (\n typeof body.token !== 'string' ||\n typeof body.refreshToken !== 'string' ||\n typeof body.expiresAt !== 'string'\n ) {\n throw new SentryIntegrationProviderError(\n 'malformed-provider-response',\n 'Sentry authorization response did not include a token',\n );\n }\n return {token: body.token, refreshToken: body.refreshToken, expiresAt: body.expiresAt};\n },\n\n async getInstallation(input) {\n const body = await mapSentryError('get-installation', () =>\n ky\n .get(\n `${SENTRY_API_BASE}/sentry-app-installations/${encodeURIComponent(input.installationUuid)}/`,\n {\n headers: {authorization: `Bearer ${input.token}`},\n },\n )\n .json<{organization?: {slug?: unknown}}>(),\n );\n\n const slug = body.organization?.slug;\n if (typeof slug !== 'string' || slug.length === 0) {\n throw new SentryIntegrationProviderError(\n 'malformed-provider-response',\n 'Sentry installation response did not include an organization slug',\n );\n }\n return {orgSlug: slug};\n },\n\n async verifyInstallation(input) {\n await mapSentryError('verify-installation', () =>\n ky\n .put(\n `${SENTRY_API_BASE}/sentry-app-installations/${encodeURIComponent(input.installationUuid)}/`,\n {\n headers: {authorization: `Bearer ${input.token}`},\n json: {status: 'installed'},\n },\n )\n .json<unknown>(),\n );\n },\n };\n}\n\n// The typed error deliberately drops Sentry's status and body so no token, code,\n// or client secret can leak to the client or the logged error chain. That also\n// strips the one detail a self-hoster needs to fix a misconfigured Sentry app.\n// e.g. a 403 on `get-installation` means the app is missing \"Organization: Read\"\n// (see README \"Required permissions\"). So we log the upstream status\n// here, the only place it survives, keyed by the operation that failed.\nasync function mapSentryError<T>(operation: string, request: () => Promise<T>): Promise<T> {\n try {\n return await request();\n } catch (error) {\n if (error instanceof SentryIntegrationProviderError) throw error;\n if (error instanceof HTTPError) {\n const {status, statusText, headers} = error.response;\n logger().warn({operation, status, statusText}, 'Sentry API request rejected');\n if (status === 429) {\n throw new SentryIntegrationProviderError(\n 'rate-limited',\n 'Sentry request was rate limited',\n retryAfterSeconds(headers),\n );\n }\n if (status >= 500) {\n throw new SentryIntegrationProviderError('provider-unavailable', 'Sentry request failed');\n }\n throw new SentryIntegrationProviderError('access-denied', 'Sentry request was rejected');\n }\n if (error instanceof TimeoutError) {\n logger().warn({operation}, 'Sentry API request timed out');\n throw new SentryIntegrationProviderError('timeout', 'Sentry request timed out');\n }\n logger().warn(\n {operation, errName: error instanceof Error ? error.name : typeof error},\n 'Sentry API request failed',\n );\n throw new SentryIntegrationProviderError('provider-unavailable', 'Sentry request failed');\n }\n}\n\nfunction retryAfterSeconds(headers: Headers): number | undefined {\n const retryAfter = headers.get('retry-after');\n if (!retryAfter) return undefined;\n const parsed = Number.parseInt(retryAfter, 10);\n return Number.isNaN(parsed) ? undefined : parsed;\n}\n"],"names":["logger","ky","HTTPError","TimeoutError","config","SentryIntegrationProviderError","SENTRY_API_BASE","createSentryApiClient","exchangeAuthorizationCode","input","body","mapSentryError","post","encodeURIComponent","installationUuid","json","grant_type","client_id","SENTRY_APP_CLIENT_ID","client_secret","SENTRY_APP_CLIENT_SECRET","code","token","refreshToken","expiresAt","getInstallation","get","headers","authorization","slug","organization","length","orgSlug","verifyInstallation","put","status","operation","request","error","statusText","response","warn","retryAfterSeconds","errName","Error","name","retryAfter","undefined","parsed","Number","parseInt","isNaN"],"mappings":"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"}
|
package/dist/core/errors.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export declare class SentryVerificationInProgressError extends Error {
|
|
|
27
27
|
/**
|
|
28
28
|
* Base for issue deliveries we received and authenticated but deliberately do
|
|
29
29
|
* not publish. The webhook layer records them for dedup and acknowledges with a
|
|
30
|
-
* 204 rather than treating them as failures
|
|
30
|
+
* 204 rather than treating them as failures: a sustained non-2xx can make Sentry
|
|
31
31
|
* disable the webhook. Carries no HTTP concerns so workers/jobs can reuse it.
|
|
32
32
|
*/
|
|
33
33
|
export declare class SentryIssueDroppedError extends Error {
|
package/dist/core/errors.js
CHANGED
|
@@ -30,7 +30,7 @@ export class SentryInstallationAlreadyLinkedError extends Error {
|
|
|
30
30
|
/**
|
|
31
31
|
* Base for issue deliveries we received and authenticated but deliberately do
|
|
32
32
|
* not publish. The webhook layer records them for dedup and acknowledges with a
|
|
33
|
-
* 204 rather than treating them as failures
|
|
33
|
+
* 204 rather than treating them as failures: a sustained non-2xx can make Sentry
|
|
34
34
|
* disable the webhook. Carries no HTTP concerns so workers/jobs can reuse it.
|
|
35
35
|
*/ export class SentryIssueDroppedError extends Error {
|
|
36
36
|
}
|
package/dist/core/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/core/errors.ts"],"sourcesContent":["import {IntegrationProviderError} from '@shipfox/api-integration-spi';\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
|
|
1
|
+
{"version":3,"sources":["../../src/core/errors.ts"],"sourcesContent":["import {IntegrationProviderError} from '@shipfox/api-integration-spi';\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,+BAA+B;AAEtE,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"}
|
package/dist/core/install.js
CHANGED
|
@@ -149,7 +149,7 @@ async function claimBrowserFirst(params) {
|
|
|
149
149
|
return connection;
|
|
150
150
|
}
|
|
151
151
|
// The browser-first exchange got "already used" with no row visible at lookup: a
|
|
152
|
-
// concurrent webhook won the exchange. Re-read once
|
|
152
|
+
// concurrent webhook won the exchange. Re-read once: if its verified row is now
|
|
153
153
|
// visible we reconcile through the same proof rules; if it got claimed we resolve
|
|
154
154
|
// it; if it was tombstoned we surface that terminally (matching the top-level
|
|
155
155
|
// check); otherwise it is still mid-flight, so the claim is retryable.
|
package/dist/core/install.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/core/install.ts"],"sourcesContent":["import {createHash, timingSafeEqual} from 'node:crypto';\nimport type {IntegrationConnection} from '@shipfox/api-integration-spi';\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 verifySentryInstallationBestEffort({\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 if (!isVerifiedUnclaimed(install)) {\n throw new SentryVerificationInProgressError(params.installationUuid);\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: VerifiedUnclaimedSentryInstallation,\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 verifySentryInstallationBestEffort({\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 verifySentryInstallationBestEffort({\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\ntype VerifiedUnclaimedSentryInstallation = SentryInstallation & {\n connectionId: null;\n status: 'installed';\n orgSlug: string;\n codeHash: string;\n};\n\nfunction isVerifiedUnclaimed(\n install: SentryInstallation,\n): install is VerifiedUnclaimedSentryInstallation {\n return (\n install.connectionId === null && install.status === 'installed' && install.codeHash !== null\n );\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\nexport async function verifySentryInstallationBestEffort(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","verifySentryInstallationBestEffort","handleSentryConnect","install","getSentryInstallation","status","connectionId","resolveClaimedInstall","isVerifiedUnclaimed","claimVerifiedInstall","claimBrowserFirst","connection","getConnectionById","workspaceId","error","isCodeAlreadyUsed","bindClaim","id","result","reconcileConcurrentClaim","reread","binding","connectSentryInstallation","displayName","installerUserId","input","verifyInstallation","warn","err","reason"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/core/install.ts"],"sourcesContent":["import {createHash, timingSafeEqual} from 'node:crypto';\nimport type {IntegrationConnection} from '@shipfox/api-integration-spi';\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 verifySentryInstallationBestEffort({\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 if (!isVerifiedUnclaimed(install)) {\n throw new SentryVerificationInProgressError(params.installationUuid);\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: VerifiedUnclaimedSentryInstallation,\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 verifySentryInstallationBestEffort({\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 verifySentryInstallationBestEffort({\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\ntype VerifiedUnclaimedSentryInstallation = SentryInstallation & {\n connectionId: null;\n status: 'installed';\n orgSlug: string;\n codeHash: string;\n};\n\nfunction isVerifiedUnclaimed(\n install: SentryInstallation,\n): install is VerifiedUnclaimedSentryInstallation {\n return (\n install.connectionId === null && install.status === 'installed' && install.codeHash !== null\n );\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\nexport async function verifySentryInstallationBestEffort(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","verifySentryInstallationBestEffort","handleSentryConnect","install","getSentryInstallation","status","connectionId","resolveClaimedInstall","isVerifiedUnclaimed","claimVerifiedInstall","claimBrowserFirst","connection","getConnectionById","workspaceId","error","isCodeAlreadyUsed","bindClaim","id","result","reconcileConcurrentClaim","reread","binding","connectSentryInstallation","displayName","installerUserId","input","verifyInstallation","warn","err","reason"],"mappings":"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"}
|