@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.
Files changed (165) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +192 -0
  5. package/LICENSE +21 -0
  6. package/README.md +145 -0
  7. package/dist/api/client.d.ts +24 -0
  8. package/dist/api/client.d.ts.map +1 -0
  9. package/dist/api/client.js +101 -0
  10. package/dist/api/client.js.map +1 -0
  11. package/dist/config.d.ts +9 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +32 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/errors.d.ts +57 -0
  16. package/dist/core/errors.d.ts.map +1 -0
  17. package/dist/core/errors.js +67 -0
  18. package/dist/core/errors.js.map +1 -0
  19. package/dist/core/install.d.ts +63 -0
  20. package/dist/core/install.d.ts.map +1 -0
  21. package/dist/core/install.js +209 -0
  22. package/dist/core/install.js.map +1 -0
  23. package/dist/core/signature.d.ts +7 -0
  24. package/dist/core/signature.d.ts.map +1 -0
  25. package/dist/core/signature.js +6 -0
  26. package/dist/core/signature.js.map +1 -0
  27. package/dist/core/webhook.d.ts +49 -0
  28. package/dist/core/webhook.d.ts.map +1 -0
  29. package/dist/core/webhook.js +175 -0
  30. package/dist/core/webhook.js.map +1 -0
  31. package/dist/db/db.d.ts +327 -0
  32. package/dist/db/db.d.ts.map +1 -0
  33. package/dist/db/db.js +18 -0
  34. package/dist/db/db.js.map +1 -0
  35. package/dist/db/installations.d.ts +74 -0
  36. package/dist/db/installations.d.ts.map +1 -0
  37. package/dist/db/installations.js +120 -0
  38. package/dist/db/installations.js.map +1 -0
  39. package/dist/db/migrations.d.ts +2 -0
  40. package/dist/db/migrations.d.ts.map +1 -0
  41. package/dist/db/migrations.js +5 -0
  42. package/dist/db/migrations.js.map +1 -0
  43. package/dist/db/schema/common.d.ts +2 -0
  44. package/dist/db/schema/common.d.ts.map +1 -0
  45. package/dist/db/schema/common.js +4 -0
  46. package/dist/db/schema/common.js.map +1 -0
  47. package/dist/db/schema/installations.d.ts +165 -0
  48. package/dist/db/schema/installations.d.ts.map +1 -0
  49. package/dist/db/schema/installations.js +42 -0
  50. package/dist/db/schema/installations.js.map +1 -0
  51. package/dist/index.d.ts +36 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +64 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/presentation/dto/integrations.d.ts +15 -0
  56. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  57. package/dist/presentation/dto/integrations.js +9 -0
  58. package/dist/presentation/dto/integrations.js.map +1 -0
  59. package/dist/presentation/routes/errors.d.ts +2 -0
  60. package/dist/presentation/routes/errors.d.ts.map +1 -0
  61. package/dist/presentation/routes/errors.js +51 -0
  62. package/dist/presentation/routes/errors.js.map +1 -0
  63. package/dist/presentation/routes/install.d.ts +16 -0
  64. package/dist/presentation/routes/install.d.ts.map +1 -0
  65. package/dist/presentation/routes/install.js +76 -0
  66. package/dist/presentation/routes/install.js.map +1 -0
  67. package/dist/presentation/routes/installation-handler.d.ts +9 -0
  68. package/dist/presentation/routes/installation-handler.d.ts.map +1 -0
  69. package/dist/presentation/routes/installation-handler.js +70 -0
  70. package/dist/presentation/routes/installation-handler.js.map +1 -0
  71. package/dist/presentation/routes/issue-handler.d.ts +9 -0
  72. package/dist/presentation/routes/issue-handler.d.ts.map +1 -0
  73. package/dist/presentation/routes/issue-handler.js +50 -0
  74. package/dist/presentation/routes/issue-handler.js.map +1 -0
  75. package/dist/presentation/routes/webhook-context.d.ts +20 -0
  76. package/dist/presentation/routes/webhook-context.d.ts.map +1 -0
  77. package/dist/presentation/routes/webhook-context.js +10 -0
  78. package/dist/presentation/routes/webhook-context.js.map +1 -0
  79. package/dist/presentation/routes/webhook-delivery.d.ts +28 -0
  80. package/dist/presentation/routes/webhook-delivery.d.ts.map +1 -0
  81. package/dist/presentation/routes/webhook-delivery.js +56 -0
  82. package/dist/presentation/routes/webhook-delivery.js.map +1 -0
  83. package/dist/presentation/routes/webhook-request.d.ts +10 -0
  84. package/dist/presentation/routes/webhook-request.d.ts.map +1 -0
  85. package/dist/presentation/routes/webhook-request.js +56 -0
  86. package/dist/presentation/routes/webhook-request.js.map +1 -0
  87. package/dist/presentation/routes/webhooks.d.ts +5 -0
  88. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  89. package/dist/presentation/routes/webhooks.js +71 -0
  90. package/dist/presentation/routes/webhooks.js.map +1 -0
  91. package/dist/temporal/activities/index.d.ts +5 -0
  92. package/dist/temporal/activities/index.d.ts.map +1 -0
  93. package/dist/temporal/activities/index.js +8 -0
  94. package/dist/temporal/activities/index.js.map +1 -0
  95. package/dist/temporal/activities/prune-unclaimed-installations.d.ts +4 -0
  96. package/dist/temporal/activities/prune-unclaimed-installations.d.ts.map +1 -0
  97. package/dist/temporal/activities/prune-unclaimed-installations.js +11 -0
  98. package/dist/temporal/activities/prune-unclaimed-installations.js.map +1 -0
  99. package/dist/temporal/constants.d.ts +2 -0
  100. package/dist/temporal/constants.d.ts.map +1 -0
  101. package/dist/temporal/constants.js +3 -0
  102. package/dist/temporal/constants.js.map +1 -0
  103. package/dist/temporal/workflows/index.d.ts +2 -0
  104. package/dist/temporal/workflows/index.d.ts.map +1 -0
  105. package/dist/temporal/workflows/index.js +3 -0
  106. package/dist/temporal/workflows/index.js.map +1 -0
  107. package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts +2 -0
  108. package/dist/temporal/workflows/prune-unclaimed-installations-cron.d.ts.map +1 -0
  109. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js +14 -0
  110. package/dist/temporal/workflows/prune-unclaimed-installations-cron.js.map +1 -0
  111. package/dist/tsconfig.test.tsbuildinfo +1 -0
  112. package/drizzle/0000_initial.sql +14 -0
  113. package/drizzle/meta/_journal.json +13 -0
  114. package/drizzle.config.ts +7 -0
  115. package/package.json +70 -0
  116. package/src/api/client.test.ts +147 -0
  117. package/src/api/client.ts +147 -0
  118. package/src/config.test.ts +16 -0
  119. package/src/config.ts +35 -0
  120. package/src/connection-external-url.test.ts +79 -0
  121. package/src/core/errors.ts +81 -0
  122. package/src/core/install.test.ts +343 -0
  123. package/src/core/install.ts +305 -0
  124. package/src/core/signature.test.ts +50 -0
  125. package/src/core/signature.ts +11 -0
  126. package/src/core/webhook.test.ts +118 -0
  127. package/src/core/webhook.ts +246 -0
  128. package/src/db/db.ts +18 -0
  129. package/src/db/installations.test.ts +224 -0
  130. package/src/db/installations.ts +207 -0
  131. package/src/db/migrations.ts +4 -0
  132. package/src/db/schema/common.ts +3 -0
  133. package/src/db/schema/installations.ts +46 -0
  134. package/src/index.test.ts +18 -0
  135. package/src/index.ts +127 -0
  136. package/src/presentation/dto/integrations.ts +9 -0
  137. package/src/presentation/routes/errors.ts +48 -0
  138. package/src/presentation/routes/install.test.ts +325 -0
  139. package/src/presentation/routes/install.ts +102 -0
  140. package/src/presentation/routes/installation-handler.ts +76 -0
  141. package/src/presentation/routes/issue-handler.ts +52 -0
  142. package/src/presentation/routes/webhook-context.ts +25 -0
  143. package/src/presentation/routes/webhook-delivery.ts +61 -0
  144. package/src/presentation/routes/webhook-request.ts +66 -0
  145. package/src/presentation/routes/webhooks.test.ts +773 -0
  146. package/src/presentation/routes/webhooks.ts +59 -0
  147. package/src/temporal/activities/index.ts +7 -0
  148. package/src/temporal/activities/prune-unclaimed-installations.test.ts +52 -0
  149. package/src/temporal/activities/prune-unclaimed-installations.ts +11 -0
  150. package/src/temporal/constants.ts +1 -0
  151. package/src/temporal/workflows/index.ts +1 -0
  152. package/src/temporal/workflows/prune-unclaimed-installations-cron.ts +15 -0
  153. package/test/env.ts +11 -0
  154. package/test/factories/index.ts +9 -0
  155. package/test/factories/sentry-installation-webhook.ts +35 -0
  156. package/test/factories/sentry-installation.ts +44 -0
  157. package/test/factories/sentry-issue-webhook.ts +34 -0
  158. package/test/globalSetup.ts +14 -0
  159. package/test/index.ts +9 -0
  160. package/test/setup.ts +17 -0
  161. package/tsconfig.build.json +9 -0
  162. package/tsconfig.build.tsbuildinfo +1 -0
  163. package/tsconfig.json +3 -0
  164. package/tsconfig.test.json +9 -0
  165. 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,7 @@
1
+ export interface VerifySentrySignatureParams {
2
+ rawBody: string;
3
+ signature: string;
4
+ secret: string;
5
+ }
6
+ export declare function verifySentrySignature(params: VerifySentrySignatureParams): boolean;
7
+ //# sourceMappingURL=signature.d.ts.map
@@ -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,6 @@
1
+ import { verifyHexHmacSignature } from '@shipfox/node-fastify';
2
+ export function verifySentrySignature(params) {
3
+ return verifyHexHmacSignature(params);
4
+ }
5
+
6
+ //# sourceMappingURL=signature.js.map
@@ -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