diff --git a/apps/cli-docs/src/content/docs/self-hosted.md b/apps/cli-docs/src/content/docs/self-hosted.md index 0de9b5b48..04a7604b5 100644 --- a/apps/cli-docs/src/content/docs/self-hosted.md +++ b/apps/cli-docs/src/content/docs/self-hosted.md @@ -9,6 +9,8 @@ The CLI works with self-hosted Sentry instances. Set the `SENTRY_HOST` (or `SENT export SENTRY_HOST=https://sentry.example.com ``` +If you use an `sntrys_` organization token, the CLI uses the instance URL in its token claim when neither URL variable is set. A stored login also keeps its instance URL. An explicit URL takes precedence, but the CLI rejects requests outside the active credential's trusted host. + ## Authenticating ### With OAuth (Sentry 26.1.0+) diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 30fceeb72..6b015b47b 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: sentry-cli -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Guide for using the Sentry CLI to interact with Sentry from the command line. Use when the user asks about viewing issues, events, projects, organizations, making API calls, or authenticating with Sentry via CLI. requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md index ca7e7bb2a..0ba3bdab8 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md @@ -1,6 +1,6 @@ --- name: sentry-cli-agent-conversation -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: List and view agent conversations requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md index 7ce0e7ef7..e742f6276 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -1,6 +1,6 @@ --- name: sentry-cli-alert -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Manage Sentry alert rules requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/api.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/api.md index 9cee33e99..6fe3ff6e3 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/api.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/api.md @@ -1,6 +1,6 @@ --- name: sentry-cli-api -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Make an authenticated API request requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md index 4d930876f..43fbe9eee 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md @@ -1,6 +1,6 @@ --- name: sentry-cli-auth -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Authenticate with Sentry requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/build.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/build.md index 7ad9177be..4064f1e2a 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/build.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/build.md @@ -1,6 +1,6 @@ --- name: sentry-cli-build -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Manage mobile build artifacts requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md index 0f67cf10f..8798683ce 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md @@ -1,6 +1,6 @@ --- name: sentry-cli-cli -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: CLI-related commands requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/code-mappings.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/code-mappings.md index 220d58afe..dd8b92ed6 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/code-mappings.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/code-mappings.md @@ -1,6 +1,6 @@ --- name: sentry-cli-code-mappings -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Manage code mappings for stack trace linking requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md index 12e3a2025..5179747fa 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md @@ -1,6 +1,6 @@ --- name: sentry-cli-dart-symbol-map -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Work with Dart/Flutter symbol maps requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index a750cc782..64660543a 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -1,6 +1,6 @@ --- name: sentry-cli-dashboard -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Manage Sentry dashboards requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md index 7fa5f931f..06ad6b1dd 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/debug-files.md @@ -1,6 +1,6 @@ --- name: sentry-cli-debug-files -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Work with debug information files requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/docs.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/docs.md index 28ef7d857..0c523242d 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/docs.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/docs.md @@ -1,6 +1,6 @@ --- name: sentry-cli-docs -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Search and query current Sentry documentation requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dsn.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dsn.md index 840f93395..042f65e72 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dsn.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dsn.md @@ -1,6 +1,6 @@ --- name: sentry-cli-dsn -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Find Sentry DSNs requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md index 9150610c0..0c5da357d 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -1,6 +1,6 @@ --- name: sentry-cli-event -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: View, list, and send Sentry events requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md index 4644b453b..745ab082f 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md @@ -1,6 +1,6 @@ --- name: sentry-cli-explore -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Query aggregate event data (Explore) requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/feedback.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/feedback.md index 4b5715ed1..f6822c432 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/feedback.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/feedback.md @@ -1,6 +1,6 @@ --- name: sentry-cli-feedback -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Manage User Feedback requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/info.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/info.md index 62a6f7771..3d81d9d71 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/info.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/info.md @@ -1,6 +1,6 @@ --- name: sentry-cli-info -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Print configuration and verify authentication requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md index c2e487800..491f3c1c3 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md @@ -1,6 +1,6 @@ --- name: sentry-cli-init -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Initialize Sentry in your project (experimental) requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 865f8906d..057719eae 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -1,6 +1,6 @@ --- name: sentry-cli-issue -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Manage Sentry issues requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md index 9e173e55c..df92efaab 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/local.md @@ -1,6 +1,6 @@ --- name: sentry-cli-local -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Sentry for local development requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md index aeb34e043..c49af07c6 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -1,6 +1,6 @@ --- name: sentry-cli-log -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: View Sentry logs requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md index 4b13cbcc1..5288bd644 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/monitor.md @@ -1,6 +1,6 @@ --- name: sentry-cli-monitor -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Work with Sentry cron monitors requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md index c6f52b78c..285a19849 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md @@ -1,6 +1,6 @@ --- name: sentry-cli-org -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Work with Sentry organizations requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/platform.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/platform.md index 461689158..c684a5a08 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/platform.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/platform.md @@ -1,6 +1,6 @@ --- name: sentry-cli-platform -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: List valid Sentry platform identifiers requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md index 8e8e08a0b..98d82f876 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/proguard.md @@ -1,6 +1,6 @@ --- name: sentry-cli-proguard -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Work with ProGuard/R8 mapping files requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md index d0d83dc54..4609b61ec 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/project.md @@ -1,6 +1,6 @@ --- name: sentry-cli-project -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Work with Sentry projects requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/react-native.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/react-native.md index e3d808e24..3ab79f766 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/react-native.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/react-native.md @@ -1,6 +1,6 @@ --- name: sentry-cli-react-native -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Upload React Native sourcemaps from build steps requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md index 244b27a20..51b33b3d3 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/release.md @@ -1,6 +1,6 @@ --- name: sentry-cli-release -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Work with Sentry releases requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/replay.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/replay.md index 39bf76aa7..ebb72a753 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/replay.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/replay.md @@ -1,6 +1,6 @@ --- name: sentry-cli-replay -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Search and inspect Session Replays requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md index 6621aaeba..03ff3b504 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/repo.md @@ -1,6 +1,6 @@ --- name: sentry-cli-repo -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Work with Sentry repositories requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md index c56b69d73..4d4f4abb8 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/schema.md @@ -1,6 +1,6 @@ --- name: sentry-cli-schema -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Browse the Sentry API schema requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/snapshots.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/snapshots.md index 43f584ec1..332f5b68a 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/snapshots.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/snapshots.md @@ -1,6 +1,6 @@ --- name: sentry-cli-snapshots -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Manage and compare snapshots requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md index 749e45d3d..0998bdd9a 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md @@ -1,6 +1,6 @@ --- name: sentry-cli-sourcemap -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Manage sourcemaps requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/span.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/span.md index b204a11c1..3e9b03334 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -1,6 +1,6 @@ --- name: sentry-cli-span -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: List and view spans in projects or traces requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md index 452742129..86ce11bcd 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/status.md @@ -1,6 +1,6 @@ --- name: sentry-cli-status -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Check Sentry service status requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md index dac91fe16..a074f709b 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/team.md @@ -1,6 +1,6 @@ --- name: sentry-cli-team -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Work with Sentry teams requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md index 65d392b5d..67112896a 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -1,6 +1,6 @@ --- name: sentry-cli-trace -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: View distributed traces requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trial.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trial.md index 333cafd7c..3b857dddc 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trial.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trial.md @@ -1,6 +1,6 @@ --- name: sentry-cli-trial -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Manage product trials requires: bins: ["sentry"] diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/wasm-split.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/wasm-split.md index 3ed78dca8..33179e2cb 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/wasm-split.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/wasm-split.md @@ -1,6 +1,6 @@ --- name: sentry-cli-wasm-split -version: 0.47.0-dev.0 +version: 0.48.0-dev.0 description: Add build ids to WebAssembly modules and split out debug data requires: bins: ["sentry"] diff --git a/packages/cli/src/commands/alert/issues/list.ts b/packages/cli/src/commands/alert/issues/list.ts index 63d278da9..78070efe4 100644 --- a/packages/cli/src/commands/alert/issues/list.ts +++ b/packages/cli/src/commands/alert/issues/list.ts @@ -71,7 +71,7 @@ import { type ResolvedTarget, resolveProjectBoundTargets, } from "../../../lib/resolve-target.js"; -import { buildIssueAlertsUrl } from "../../../lib/sentry-urls.js"; +import { buildIssueAlertsUrl } from "../../../lib/sentry-web-urls.js"; import type { ProjectAliasEntry, Writer } from "../../../types/index.js"; import { assertAlertListLimit, diff --git a/packages/cli/src/commands/alert/issues/view.ts b/packages/cli/src/commands/alert/issues/view.ts index dd89fd49d..077127d05 100644 --- a/packages/cli/src/commands/alert/issues/view.ts +++ b/packages/cli/src/commands/alert/issues/view.ts @@ -5,7 +5,7 @@ import { buildCommand } from "../../../lib/command.js"; import { ContextError } from "../../../lib/errors.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; import { resolveProjectBoundTargets } from "../../../lib/resolve-target.js"; -import { buildIssueAlertsUrl } from "../../../lib/sentry-urls.js"; +import { buildIssueAlertsUrl } from "../../../lib/sentry-web-urls.js"; import { type IssueRuleResolution, parseIssueRuleArg, diff --git a/packages/cli/src/commands/alert/metrics/list.ts b/packages/cli/src/commands/alert/metrics/list.ts index f329ad03e..d91f81d00 100644 --- a/packages/cli/src/commands/alert/metrics/list.ts +++ b/packages/cli/src/commands/alert/metrics/list.ts @@ -64,7 +64,7 @@ import { type ResolvedTarget, resolveProjectBoundTargets, } from "../../../lib/resolve-target.js"; -import { buildMetricAlertsUrl } from "../../../lib/sentry-urls.js"; +import { buildMetricAlertsUrl } from "../../../lib/sentry-web-urls.js"; import type { Writer } from "../../../types/index.js"; import { assertAlertListLimit, diff --git a/packages/cli/src/commands/alert/metrics/view.ts b/packages/cli/src/commands/alert/metrics/view.ts index fcf9fd575..35ed7aed6 100644 --- a/packages/cli/src/commands/alert/metrics/view.ts +++ b/packages/cli/src/commands/alert/metrics/view.ts @@ -4,7 +4,7 @@ import { openInBrowser } from "../../../lib/browser.js"; import { buildCommand } from "../../../lib/command.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; import { resolveOrgOnlyFromArg } from "../../../lib/resolve-target.js"; -import { buildMetricAlertsUrl } from "../../../lib/sentry-urls.js"; +import { buildMetricAlertsUrl } from "../../../lib/sentry-web-urls.js"; import { type MetricRuleResolution, parseMetricRuleArg, diff --git a/packages/cli/src/commands/dashboard/create.ts b/packages/cli/src/commands/dashboard/create.ts index 16670561f..674016021 100644 --- a/packages/cli/src/commands/dashboard/create.ts +++ b/packages/cli/src/commands/dashboard/create.ts @@ -21,7 +21,7 @@ import { resolveProjectBoundSlug, toNumericId, } from "../../lib/resolve-target.js"; -import { buildDashboardUrl } from "../../lib/sentry-urls.js"; +import { buildDashboardUrl } from "../../lib/sentry-web-urls.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import type { DashboardDetail } from "../../types/dashboard.js"; import { enrichDashboardError } from "./resolve.js"; diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index 53373949b..d3ffff11f 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -34,7 +34,7 @@ import { withProgress } from "../../lib/polling.js"; import { buildDashboardsListUrl, buildDashboardUrl, -} from "../../lib/sentry-urls.js"; +} from "../../lib/sentry-web-urls.js"; import type { DashboardListItem } from "../../types/dashboard.js"; import type { Writer } from "../../types/index.js"; import { diff --git a/packages/cli/src/commands/dashboard/restore.ts b/packages/cli/src/commands/dashboard/restore.ts index f1203f68b..92637738e 100644 --- a/packages/cli/src/commands/dashboard/restore.ts +++ b/packages/cli/src/commands/dashboard/restore.ts @@ -13,7 +13,7 @@ import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; import { withProgress } from "../../lib/polling.js"; -import { buildDashboardUrl } from "../../lib/sentry-urls.js"; +import { buildDashboardUrl } from "../../lib/sentry-web-urls.js"; import type { DashboardDetail } from "../../types/dashboard.js"; import { enrichDashboardError, diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index 834f0b7ad..b3ae2e99b 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -29,7 +29,7 @@ import { paginationHint, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; -import { buildDashboardUrl } from "../../lib/sentry-urls.js"; +import { buildDashboardUrl } from "../../lib/sentry-web-urls.js"; import type { DashboardRevision } from "../../types/dashboard.js"; import type { Writer } from "../../types/index.js"; import { diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index a039a2ef6..d9f6fc3c5 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -21,7 +21,7 @@ import { import { logger } from "../../lib/logger.js"; import { withProgress } from "../../lib/polling.js"; import { resolveOrgRegion } from "../../lib/region.js"; -import { buildDashboardUrl } from "../../lib/sentry-urls.js"; +import { buildDashboardUrl } from "../../lib/sentry-web-urls.js"; import type { GraphicsRendererPreference } from "../../lib/sixel.js"; import { formatTimeRangeFlag, diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index 2ab790d3a..b50062b18 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.ts @@ -11,7 +11,7 @@ import { buildCommand, numberParser } from "../../../lib/command.js"; import { ValidationError } from "../../../lib/errors.js"; import { formatWidgetAdded } from "../../../lib/formatters/human.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; -import { buildDashboardUrl } from "../../../lib/sentry-urls.js"; +import { buildDashboardUrl } from "../../../lib/sentry-web-urls.js"; import { assignDefaultLayout, type DashboardDetail, diff --git a/packages/cli/src/commands/dashboard/widget/delete.ts b/packages/cli/src/commands/dashboard/widget/delete.ts index 1d47aaab1..d1f78b98f 100644 --- a/packages/cli/src/commands/dashboard/widget/delete.ts +++ b/packages/cli/src/commands/dashboard/widget/delete.ts @@ -18,7 +18,7 @@ import { ValidationError } from "../../../lib/errors.js"; import { formatWidgetDeleted } from "../../../lib/formatters/human.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; import { buildDeleteCommand } from "../../../lib/mutate-command.js"; -import { buildDashboardUrl } from "../../../lib/sentry-urls.js"; +import { buildDashboardUrl } from "../../../lib/sentry-web-urls.js"; import { type DashboardDetail, prepareDashboardForUpdate, diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts index 64831ca25..301d319ba 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -11,7 +11,7 @@ import { buildCommand, numberParser } from "../../../lib/command.js"; import { ValidationError } from "../../../lib/errors.js"; import { formatWidgetEdited } from "../../../lib/formatters/human.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; -import { buildDashboardUrl } from "../../../lib/sentry-urls.js"; +import { buildDashboardUrl } from "../../../lib/sentry-web-urls.js"; import { type DashboardDetail, type DashboardWidget, diff --git a/packages/cli/src/commands/event/view.ts b/packages/cli/src/commands/event/view.ts index bb4dc069a..7ab74d398 100644 --- a/packages/cli/src/commands/event/view.ts +++ b/packages/cli/src/commands/event/view.ts @@ -68,7 +68,7 @@ import { applySentryUrlContext, parseSentryUrl, } from "../../lib/sentry-url-parser.js"; -import { buildEventSearchUrl } from "../../lib/sentry-urls.js"; +import { buildEventSearchUrl } from "../../lib/sentry-web-urls.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import { isAllDigits } from "../../lib/utils.js"; diff --git a/packages/cli/src/commands/issue/list.ts b/packages/cli/src/commands/issue/list.ts index 25ef1aa10..189276dbc 100644 --- a/packages/cli/src/commands/issue/list.ts +++ b/packages/cli/src/commands/issue/list.ts @@ -86,7 +86,7 @@ import { SEARCH_SYNTAX_REFERENCE, sanitizeQuery, } from "../../lib/search-query.js"; -import { isSaaS } from "../../lib/sentry-urls.js"; +import { isSaaS } from "../../lib/sentry-web-urls.js"; import { appendPeriodHint, formatTimeRangeFlag, diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index 1699db391..d7fb60610 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -32,7 +32,7 @@ import { import { muted } from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; -import { buildIssueUrl } from "../../lib/sentry-urls.js"; +import { buildIssueUrl } from "../../lib/sentry-web-urls.js"; import type { SentryIssue } from "../../types/index.js"; import { resolveIssue } from "./utils.js"; diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index 55b7e0ba7..91924d58a 100644 --- a/packages/cli/src/commands/issue/utils.ts +++ b/packages/cli/src/commands/issue/utils.ts @@ -51,7 +51,7 @@ import { resolveOrgAndProject, } from "../../lib/resolve-target.js"; import { parseSentryUrl } from "../../lib/sentry-url-parser.js"; -import { buildIssueUrl } from "../../lib/sentry-urls.js"; +import { buildIssueUrl } from "../../lib/sentry-web-urls.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import { isAllDigits } from "../../lib/utils.js"; import type { SentryIssue } from "../../types/index.js"; diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index 40933f2a3..ac232be53 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -49,7 +49,7 @@ import { resolveProjectBoundSlug, } from "../../lib/resolve-target.js"; import { RETENTION_DAYS } from "../../lib/retention.js"; -import { buildLogsUrl } from "../../lib/sentry-urls.js"; +import { buildLogsUrl } from "../../lib/sentry-web-urls.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import { isAllDigits } from "../../lib/utils.js"; import type { DetailedSentryLog, TraceItemDetail } from "../../types/index.js"; diff --git a/packages/cli/src/commands/org/view.ts b/packages/cli/src/commands/org/view.ts index 1c2e3e0d3..37cdb993f 100644 --- a/packages/cli/src/commands/org/view.ts +++ b/packages/cli/src/commands/org/view.ts @@ -17,7 +17,7 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { resolveOrg } from "../../lib/resolve-target.js"; -import { buildOrgUrl } from "../../lib/sentry-urls.js"; +import { buildOrgUrl } from "../../lib/sentry-web-urls.js"; type ViewFlags = { readonly json: boolean; diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts index 0e4438dfb..62323260c 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -40,7 +40,7 @@ import { requireExplicitTarget, } from "../../lib/mutate-command.js"; import { resolveProjectBoundTarget } from "../../lib/resolve-target.js"; -import { buildProjectUrl } from "../../lib/sentry-urls.js"; +import { buildProjectUrl } from "../../lib/sentry-web-urls.js"; const log = logger.withTag("project.delete"); diff --git a/packages/cli/src/commands/project/view.ts b/packages/cli/src/commands/project/view.ts index b2caec740..4c4b86d40 100644 --- a/packages/cli/src/commands/project/view.ts +++ b/packages/cli/src/commands/project/view.ts @@ -32,7 +32,7 @@ import { resolveAllTargets, resolveProjectBoundSlug, } from "../../lib/resolve-target.js"; -import { buildProjectUrl } from "../../lib/sentry-urls.js"; +import { buildProjectUrl } from "../../lib/sentry-web-urls.js"; import type { SentryProject } from "../../types/index.js"; type ViewFlags = { diff --git a/packages/cli/src/commands/release/delete.ts b/packages/cli/src/commands/release/delete.ts index 6f57a2c45..bbfb19f97 100644 --- a/packages/cli/src/commands/release/delete.ts +++ b/packages/cli/src/commands/release/delete.ts @@ -17,7 +17,7 @@ import { confirmByTyping, isConfirmationBypassed, } from "../../lib/mutate-command.js"; -import { buildReleaseUrl } from "../../lib/sentry-urls.js"; +import { buildReleaseUrl } from "../../lib/sentry-web-urls.js"; import { resolveReleaseTarget } from "./parse.js"; const USAGE_HINT = "sentry release delete [/]"; diff --git a/packages/cli/src/commands/release/list.ts b/packages/cli/src/commands/release/list.ts index ef8af753d..b63013ca9 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -44,7 +44,7 @@ import { resolveAllTargets, toNumericId, } from "../../lib/resolve-target.js"; -import { buildReleaseUrl } from "../../lib/sentry-urls.js"; +import { buildReleaseUrl } from "../../lib/sentry-web-urls.js"; import type { SentryRelease } from "../../types/index.js"; import { fmtCrashFree } from "./view.js"; diff --git a/packages/cli/src/commands/replay/view.ts b/packages/cli/src/commands/replay/view.ts index 69ec5a6de..97aa3e49d 100644 --- a/packages/cli/src/commands/replay/view.ts +++ b/packages/cli/src/commands/replay/view.ts @@ -44,7 +44,7 @@ import { applySentryUrlContext, parseSentryUrl, } from "../../lib/sentry-url-parser.js"; -import { buildReplayUrl } from "../../lib/sentry-urls.js"; +import { buildReplayUrl } from "../../lib/sentry-web-urls.js"; import type { ReplayActivityEvent, ReplayDetails, diff --git a/packages/cli/src/commands/trace/logs.ts b/packages/cli/src/commands/trace/logs.ts index fef39e3b1..cc661c005 100644 --- a/packages/cli/src/commands/trace/logs.ts +++ b/packages/cli/src/commands/trace/logs.ts @@ -25,7 +25,7 @@ import { } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; import { sanitizeQuery } from "../../lib/search-query.js"; -import { buildTraceUrl } from "../../lib/sentry-urls.js"; +import { buildTraceUrl } from "../../lib/sentry-web-urls.js"; import { formatTimeRangeFlag, PERIOD_BRIEF, diff --git a/packages/cli/src/commands/trace/view.ts b/packages/cli/src/commands/trace/view.ts index 19c7981d3..065068537 100644 --- a/packages/cli/src/commands/trace/view.ts +++ b/packages/cli/src/commands/trace/view.ts @@ -40,7 +40,7 @@ import { } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; import { resolveOrg, toNumericId } from "../../lib/resolve-target.js"; -import { buildTraceUrl } from "../../lib/sentry-urls.js"; +import { buildTraceUrl } from "../../lib/sentry-web-urls.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import { parseTraceTargetWithRecovery, diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index 02d3b8679..e44faae0a 100644 --- a/packages/cli/src/commands/trial/start.ts +++ b/packages/cli/src/commands/trial/start.ts @@ -26,7 +26,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { logger as log } from "../../lib/logger.js"; import { generateQRCode } from "../../lib/qrcode.js"; import { resolveOrg } from "../../lib/resolve-target.js"; -import { buildBillingUrl } from "../../lib/sentry-urls.js"; +import { buildBillingUrl } from "../../lib/sentry-web-urls.js"; import { findAvailableTrial, getDisplayNameForTrialName, diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 0a58de528..ee330335b 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -14,7 +14,11 @@ import * as Sentry from "@sentry/node-core/light"; import { type GenericSchema, safeParse } from "valibot"; import { extractRequiredScopes } from "../api-scope.js"; -import { getActiveEnvVarName, isEnvTokenActive } from "../db/auth.js"; +import { + type CredentialContext, + getActiveEnvVarName, + isEnvTokenActive, +} from "../db/auth.js"; import { getEnv } from "../env.js"; import { ApiError, AuthError, stringifyUnknown } from "../errors.js"; import { logger } from "../logger.js"; @@ -191,6 +195,10 @@ export type ApiRequestOptions = { params?: Record; /** Optional valibot schema for runtime validation of response data */ schema?: GenericSchema; + /** Internal immutable credential for a multi-request operation. */ + credential?: CredentialContext; + /** Manually validate each redirect before forwarding credentials. */ + validatedRedirects?: boolean; }; /** @@ -302,6 +310,9 @@ export function unwrapPaginatedResult( response?.headers.get("link") ?? null ); const out: PaginatedResponse = { data }; + if (response) { + out.response = response; + } if (nextCursor !== undefined) { out.nextCursor = nextCursor; } @@ -415,6 +426,8 @@ export type PaginatedResponse = { nextCursor?: string; /** Cursor for the previous page (undefined on the first page) */ prevCursor?: string; + /** Exact validated response, for provenance-sensitive callers. */ + response?: Response; }; /** @@ -524,8 +537,16 @@ export async function apiRequestToRegion( endpoint: string, options: ApiRequestOptions = {} ): Promise<{ data: T; headers: Headers }> { - const { method = "GET", body, bodyEncoding, params, schema } = options; - const config = getSdkConfig(regionUrl); + const { + method = "GET", + body, + bodyEncoding, + params, + schema, + credential, + validatedRedirects, + } = options; + const config = getSdkConfig(regionUrl, { credential, validatedRedirects }); const normalizedEndpoint = endpoint.startsWith("/") ? endpoint.slice(1) diff --git a/packages/cli/src/lib/api/organizations.ts b/packages/cli/src/lib/api/organizations.ts index 616ff00f4..949f92c55 100644 --- a/packages/cli/src/lib/api/organizations.ts +++ b/packages/cli/src/lib/api/organizations.ts @@ -15,14 +15,20 @@ import { type UserRegionsResponse, UserRegionsResponseSchema, } from "../../types/index.js"; - -import { ApiError } from "../errors.js"; -import { getControlSiloUrl, getSdkConfig } from "../sentry-client.js"; +import { type CredentialContext, getCredentialContext } from "../db/auth.js"; +import type { OrgRegionEntry } from "../db/regions.js"; +import { ApiError, AuthError } from "../errors.js"; +import { + getControlSiloUrl, + getResponseCredentialIdentity, + getResponseRequestOrigin, + getSdkConfig, +} from "../sentry-client.js"; +import { normalizeHttpOrigin, normalizeRegionBaseUrl } from "../sentry-urls.js"; import { API_MAX_PER_PAGE, apiRequestToRegion, - autoPaginate, getOrgSdkConfig, MAX_PAGINATION_PAGES, type PaginatedResponse, @@ -30,6 +36,20 @@ import { unwrapResult, } from "./infrastructure.js"; +function normalizeOrganizationRegion( + raw: string | undefined, + responseOrigin: string, + baseUrl: string +): string | undefined { + if (!raw) { + if (normalizeHttpOrigin(baseUrl) === responseOrigin) { + return normalizeRegionBaseUrl(baseUrl, responseOrigin) ?? responseOrigin; + } + return responseOrigin; + } + return normalizeRegionBaseUrl(raw, responseOrigin); +} + /** * Get the list of regions the user has organization membership in. * This endpoint is on the control silo (sentry.io) and returns all regions. @@ -41,11 +61,12 @@ import { * @returns Array of regions with name and URL */ export async function getUserRegions(): Promise { + const credential = getCredentialContext(); // /users/me/regions/ is an internal endpoint - use raw request const { data } = await apiRequestToRegion( - getControlSiloUrl(), + getControlSiloUrl(credential), "/users/me/regions/", - { schema: UserRegionsResponseSchema } + { schema: UserRegionsResponseSchema, credential, validatedRedirects: true } ); return data.regions; } @@ -63,9 +84,13 @@ export async function getUserRegions(): Promise { */ export async function listOrganizationsPage( baseUrl: string, - options: { cursor?: string; perPage?: number } = {} + options: { cursor?: string; perPage?: number } = {}, + credential = getCredentialContext() ): Promise> { - const config = getSdkConfig(baseUrl); + const config = getSdkConfig(baseUrl, { + credential, + validatedRedirects: true, + }); const result = await sdkListOrganizations({ ...config, @@ -104,9 +129,12 @@ export async function listOrganizationsPage( * should use {@link listOrganizationsUncached} instead. */ export async function listOrganizations(): Promise { + const credential = getCredentialContext(); const { getCachedOrganizations } = await import("../db/regions.js"); - const cached = getCachedOrganizations(); + const cached = credential + ? getCachedOrganizations(getControlSiloUrl(credential), credential.identity) + : []; if (cached.length > 0) { return cached.map((org) => ({ id: org.id, @@ -117,7 +145,80 @@ export async function listOrganizations(): Promise { } // Cache miss — fetch from API (also populates cache for next time) - return listOrganizationsUncached(); + return listOrganizationsUncached(credential); +} + +type OrganizationPageContext = { + baseUrl: string; + credential: CredentialContext; + organizations?: SentryOrganization[]; + regions?: OrgRegionEntry[]; + cursor?: string; + pageIndex?: number; +}; + +async function listOrganizationPages({ + baseUrl, + credential, + organizations = [], + regions = [], + cursor, + pageIndex = 0, +}: OrganizationPageContext): Promise<{ + organizations: SentryOrganization[]; + regions: OrgRegionEntry[]; +}> { + if (pageIndex >= MAX_PAGINATION_PAGES) { + throw new ApiError( + "Failed to list organizations: pagination limit exceeded", + 0 + ); + } + const page = await listOrganizationsPage( + baseUrl, + { cursor, perPage: API_MAX_PER_PAGE }, + credential + ); + const responseOrigin = + page.response && getResponseRequestOrigin(page.response); + const responseIdentity = + page.response && getResponseCredentialIdentity(page.response); + const entries: OrgRegionEntry[] = + responseOrigin && responseIdentity === credential.identity + ? page.data.flatMap((org) => { + const region = normalizeOrganizationRegion( + org.links?.regionUrl, + responseOrigin, + baseUrl + ); + return region + ? [ + { + slug: org.slug, + regionUrl: region, + sourceOrigin: responseOrigin, + cacheOrigin: baseUrl, + identity: credential.identity, + orgId: org.id, + orgName: org.name, + ...(org.orgRole ? { orgRole: org.orgRole } : {}), + }, + ] + : []; + }) + : []; + const accumulated = [...organizations, ...page.data]; + const accumulatedRegions = [...regions, ...entries]; + return page.nextCursor + ? await listOrganizationPages({ + baseUrl, + credential, + organizations: accumulated, + regions: accumulatedRegions, + cursor: page.nextCursor, + pageIndex: pageIndex + 1, + }) + : { organizations: accumulated, regions: accumulatedRegions }; } /** @@ -136,35 +237,31 @@ export async function listOrganizations(): Promise { * Use this when you need guaranteed-fresh data (e.g., `org list`, `auth status`). * Most callers should use {@link listOrganizations} instead. */ -export async function listOrganizationsUncached(): Promise< - SentryOrganization[] -> { - const { setOrgRegions } = await import("../db/regions.js"); - - const controlSiloUrl = getControlSiloUrl(); - - const { data: orgs } = await autoPaginate( - (cursor) => - listOrganizationsPage(controlSiloUrl, { - cursor, - perPage: API_MAX_PER_PAGE, - }), - MAX_PAGINATION_PAGES * API_MAX_PER_PAGE +export async function listOrganizationsUncached( + credential = getCredentialContext() +): Promise { + const { invalidateCachedOrganizations, setOrgRegions } = await import( + "../db/regions.js" ); - - const regionEntries = orgs.map((org) => ({ - slug: org.slug, - // Each org carries its own regionUrl (added to the control serializer - // in getsentry/sentry#115513); fall back to the control silo URL for - // any older/self-hosted response that omits it. - regionUrl: org.links?.regionUrl ?? controlSiloUrl, - orgId: org.id, - orgName: org.name, - orgRole: org.orgRole, - })); - setOrgRegions(regionEntries); - - return orgs; + if (!credential) { + throw new AuthError("not_authenticated"); + } + const baseUrl = getControlSiloUrl(credential); + const result = await listOrganizationPages({ + baseUrl, + credential, + }); + if ( + result.organizations.length > 0 && + result.regions.length === result.organizations.length + ) { + setOrgRegions(result.regions); + } else { + // A skipped or unverified region has no safe route. Existing scoped rows + // may still route requests, but cannot represent the complete org list. + invalidateCachedOrganizations(baseUrl, credential.identity); + } + return result.organizations; } /** diff --git a/packages/cli/src/lib/api/projects.ts b/packages/cli/src/lib/api/projects.ts index 86107d10d..68e6d1dc1 100644 --- a/packages/cli/src/lib/api/projects.ts +++ b/packages/cli/src/lib/api/projects.ts @@ -33,7 +33,7 @@ import { reportCliError } from "../error-reporting.js"; import { type AuthGuardSuccess, withAuthGuard } from "../errors.js"; import { logger } from "../logger.js"; import { getApiBaseUrl } from "../sentry-client.js"; -import { buildProjectUrl } from "../sentry-urls.js"; +import { buildProjectUrl } from "../sentry-web-urls.js"; import { isAllDigits } from "../utils.js"; import { diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index af455962d..f2e989b55 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -15,6 +15,8 @@ * Exit: 0 on success (even if no completions) */ +import { getConfiguredSentryUrl } from "./constants.js"; +import { getCredentialContext } from "./db/auth.js"; import { queueCompletionTelemetry } from "./db/completion-telemetry.js"; import { getProjectAliases } from "./db/project-aliases.js"; import { getCachedProjectsForOrg } from "./db/project-cache.js"; @@ -24,6 +26,17 @@ import { COMMON_PLATFORMS, VALID_PLATFORMS } from "./platforms.js"; const WHITESPACE_RE = /\s/; +/** Never suggest organizations cached under another credential or lookup host. */ +function getCompletionOrganizations() { + const credential = getCredentialContext(); + return credential + ? getCachedOrganizations( + getConfiguredSentryUrl() ?? credential.host, + credential.identity + ) + : []; +} + /** * Completion result with optional description for rich shell display. * Shells that support descriptions (zsh, fish) use both fields. @@ -242,7 +255,7 @@ export function completeProjectCreateSpec(partial: string): Completion[] { * @returns Completions with org names as descriptions */ export function completeOrgSlugs(partial: string, suffix = ""): Completion[] { - const orgs = getCachedOrganizations(); + const orgs = getCompletionOrganizations(); if (orgs.length === 0) { return []; } @@ -347,7 +360,7 @@ export function completeProjectSlugs( * @returns The resolved org slug, or undefined if no match */ function fuzzyResolveOrg(orgPart: string): string | undefined { - const orgs = getCachedOrganizations(); + const orgs = getCompletionOrganizations(); if (orgs.length === 0) { return; } diff --git a/packages/cli/src/lib/constants.ts b/packages/cli/src/lib/constants.ts index 4ad411301..d45a7ae17 100644 --- a/packages/cli/src/lib/constants.ts +++ b/packages/cli/src/lib/constants.ts @@ -3,6 +3,7 @@ */ import { getEnv } from "./env.js"; +import { ConfigError } from "./errors.js"; /** Build-time constant injected by esbuild/bun */ declare const SENTRY_CLI_VERSION: string | undefined; @@ -34,6 +35,7 @@ export const NODE_MODULES_DIRNAME = "node_modules"; /** Matches strings that already start with http:// or https:// */ const HAS_PROTOCOL_RE = /^https?:\/\//i; +const EXPLICIT_SCHEME_RE = /^([a-z][a-z\d+.-]*):\/\//i; /** * Normalize a URL string by ensuring it has a protocol prefix. @@ -71,8 +73,32 @@ export function normalizeUrl(url: string | undefined): string | undefined { * with `https://` to prevent invalid URL construction downstream. */ export function getConfiguredSentryUrl(): string | undefined { - const raw = getEnv().SENTRY_HOST || getEnv().SENTRY_URL || undefined; - return normalizeUrl(raw); + const env = getEnv(); + const raw = env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim(); + if (!raw) { + return; + } + const scheme = raw.match(EXPLICIT_SCHEME_RE)?.[1]?.toLowerCase(); + const normalized = normalizeUrl(raw); + try { + if (scheme && scheme !== "http" && scheme !== "https") { + throw new TypeError("Unsupported URL scheme"); + } + const parsed = new URL(normalized as string); + if ( + (parsed.protocol !== "http:" && parsed.protocol !== "https:") || + !parsed.hostname || + parsed.username || + parsed.password + ) { + throw new TypeError("Invalid Sentry URL"); + } + return normalized; + } catch { + throw new ConfigError( + "SENTRY_HOST/SENTRY_URL is not a valid URL; use a credential-free HTTP(S) URL." + ); + } } /** CLI version string, available for help output and other uses */ diff --git a/packages/cli/src/lib/custom-headers.ts b/packages/cli/src/lib/custom-headers.ts index 29edb6e68..3d1057516 100644 --- a/packages/cli/src/lib/custom-headers.ts +++ b/packages/cli/src/lib/custom-headers.ts @@ -25,13 +25,17 @@ * ``` */ +import { AsyncLocalStorage } from "node:async_hooks"; import { getConfiguredSentryUrl } from "./constants.js"; import { getDefaultHeaders } from "./db/defaults.js"; import { getEnv } from "./env.js"; import { ConfigError } from "./errors.js"; import { logger } from "./logger.js"; import { isSentrySaasUrl } from "./sentry-urls.js"; -import { isRequestOriginTrustedForCustomHeaders } from "./token-host.js"; +import { + getActiveTokenHost, + isRequestOriginTrustedForCustomHeaders, +} from "./token-host.js"; const log = logger.withTag("custom-headers"); @@ -79,6 +83,9 @@ let untrustedDestinationWarningLogged = false; * `undefined` = not set, fall through to the env var / SQLite defaults. */ let overrideHeaders: readonly [string, string][] | undefined; +const scopedHeadersOverride = new AsyncLocalStorage<{ + value: readonly [string, string][] | undefined; +}>(); /** * Validate a header name against RFC 7230 token rules and the reserved list. @@ -160,11 +167,10 @@ export function parseCustomHeaders(raw: string): readonly [string, string][] { * @param headers - Header name/value map from `SentryOptions.headers` * @throws {ConfigError} On invalid or reserved header names */ -export function setCustomHeadersOverride( +function validateCustomHeadersOverride( headers: Record | undefined -): void { +): readonly [string, string][] | undefined { if (headers === undefined) { - overrideHeaders = undefined; return; } @@ -179,22 +185,37 @@ export function setCustomHeadersOverride( assertValidHeaderName(name, "SentryOptions.headers"); entries.push([name, rawValue.trim()]); } - overrideHeaders = entries; + return entries; +} + +export function setCustomHeadersOverride( + headers: Record | undefined +): void { + overrideHeaders = validateCustomHeadersOverride(headers); +} + +export function withCustomHeadersOverride( + headers: Record | undefined, + callback: () => T +): T { + return scopedHeadersOverride.run( + { value: validateCustomHeadersOverride(headers) }, + callback + ); } /** * Check whether the current target is a self-hosted Sentry instance. * - * Self-hosted = `SENTRY_HOST` or `SENTRY_URL` is set to a non-SaaS URL. - * Returns false if no custom URL is configured (implying SaaS) or if the - * configured URL points to `*.sentry.io`. + * The explicit URL wins; otherwise use the active credential's host. A + * claim-routed self-hosted token needs proxy headers even without URL vars. */ function isSelfHosted(): boolean { - const configured = getConfiguredSentryUrl(); - if (!configured) { + const target = getConfiguredSentryUrl() ?? getActiveTokenHost(); + if (!target) { return false; } - return !isSentrySaasUrl(configured); + return !isSentrySaasUrl(target); } /** @@ -242,10 +263,10 @@ function passesSelfHostedGuard(): boolean { * because `SENTRY_HOST` can be set dynamically by URL argument parsing. */ export function getCustomHeaders(): readonly [string, string][] { - if (overrideHeaders !== undefined) { - return overrideHeaders.length > 0 && passesSelfHostedGuard() - ? overrideHeaders - : []; + const scoped = scopedHeadersOverride.getStore(); + const effective = scoped ? scoped.value : overrideHeaders; + if (effective !== undefined) { + return effective.length > 0 && passesSelfHostedGuard() ? effective : []; } const raw = resolveRawHeaders(); @@ -284,14 +305,15 @@ export function getCustomHeaders(): readonly [string, string][] { */ export function applyCustomHeaders( headers: Headers, - requestUrl: string | URL | Request + requestUrl: string | URL | Request, + isTrusted = isRequestOriginTrustedForCustomHeaders(requestUrl) ): void { const customHeaders = getCustomHeaders(); if (customHeaders.length === 0) { return; } - if (!isRequestOriginTrustedForCustomHeaders(requestUrl)) { + if (!isTrusted) { if (!untrustedDestinationWarningLogged) { untrustedDestinationWarningLogged = true; log.warn( diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 891c2d351..5c7d2cd33 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -6,9 +6,13 @@ import { createHash } from "node:crypto"; import { normalizeAuthToken, trimAuthToken } from "../auth-header.js"; import { DEFAULT_SENTRY_URL, getConfiguredSentryUrl } from "../constants.js"; import { getEnv } from "../env.js"; -import { getEnvTokenHost } from "../env-token-host.js"; +import { + getBootConfiguredSentryUrl, + getEnvTokenHost, +} from "../env-token-host.js"; +import { ConfigError } from "../errors.js"; import { logger } from "../logger.js"; -import { normalizeOrigin } from "../sentry-urls.js"; +import { normalizeHttpOrigin } from "../sentry-urls.js"; import { withDbSpan } from "../telemetry.js"; import { getDatabase } from "./index.js"; import { clearAllIssueOrgCache } from "./issue-org-cache.js"; @@ -48,7 +52,7 @@ function getAuthRow(): AuthRow | undefined { /** * Lazy migration for rows created before schema v16 (NULL `host`). * - * Uses the BOOT-TIME env snapshot (`getEnvTokenHost`), captured before the + * Uses only the BOOT-TIME explicit URL snapshot, captured before the * `.sentryclirc` shim could mutate env. Reading the current env directly * would either default self-hosted users to SaaS (when the shim hasn't run * yet) or migrate to a poisoned rc URL (when it has). @@ -58,9 +62,10 @@ function getAuthRow(): AuthRow | undefined { * (never NULL on return). */ function migrateNullHost(row: AuthRow): string { - const bootHost = getEnvTokenHost(); - const migratedHost = normalizeOrigin(bootHost); - const host = migratedHost ?? DEFAULT_SENTRY_URL; + const bootHost = getBootConfiguredSentryUrl(); + const host = bootHost + ? normalizeCredentialHost(bootHost) + : DEFAULT_SENTRY_URL; // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { withDbSpan("migrateAuthHost", () => { @@ -77,6 +82,16 @@ function migrateNullHost(row: AuthRow): string { return host; } +function normalizeCredentialHost(host: string): string { + const origin = normalizeHttpOrigin(host); + if (!origin) { + throw new ConfigError( + "Stored credential host must be a credential-free HTTP(S) URL." + ); + } + return origin; +} + /** Prefix for environment variable auth sources in {@link AuthSource} */ export const ENV_SOURCE_PREFIX = "env:"; @@ -285,8 +300,10 @@ export function getUsableStoredTokenHost(): string | undefined { } } -/** Memoized token. Wrapper distinguishes "not cached" from "cached as undefined". */ -let cachedAuthToken: { value: string | undefined } | undefined; +const authCacheState = { + tokens: new WeakMap(), + fingerprints: new WeakMap(), +}; /** * Get the active auth token. @@ -295,11 +312,13 @@ let cachedAuthToken: { value: string | undefined } | undefined; * With `SENTRY_FORCE_ENV_TOKEN=1`: checks env vars first (old behavior). */ export function getAuthToken(): string | undefined { - if (cachedAuthToken !== undefined) { - return cachedAuthToken.value; + const env = getEnv(); + const cached = authCacheState.tokens.get(env); + if (cached !== undefined) { + return cached.value; } const value = computeAuthToken(); - cachedAuthToken = { value }; + authCacheState.tokens.set(env, { value }); return value; } @@ -339,13 +358,13 @@ function computeAuthToken(): string | undefined { /** Reset the memoized auth token. Tests only — call between auth-state mutations. */ export function resetAuthTokenCache(): void { - cachedAuthToken = undefined; + authCacheState.tokens = new WeakMap(); } -/** Memoized result for {@link hasStoredAuthCredentials}. Same wrapper contract as {@link cachedAuthToken}. */ +/** Memoized result for {@link hasStoredAuthCredentials}. */ let cachedHasStoredCreds: { value: boolean } | undefined; -/** Memoized full auth row for {@link refreshToken}. Same wrapper contract as {@link cachedAuthToken}. */ +/** Memoized full auth row for {@link refreshToken}. */ let cachedAuthRow: { value: AuthRow | undefined } | undefined; function getCachedAuthRow(): AuthRow | undefined { @@ -398,7 +417,7 @@ export function setAuthToken( // 2. Existing row's `host` (refresh flow preserves the original scope) // 3. Currently-configured host (getConfiguredSentryUrl) // 4. SaaS default - // Always normalized to scheme+host[+port] via normalizeOrigin. + // Always normalized to scheme+host[+port]. const existingHost = ( db.query("SELECT host FROM auth WHERE id = 1").get() as | { host: string | null } @@ -409,7 +428,7 @@ export function setAuthToken( existingHost ?? getConfiguredSentryUrl() ?? DEFAULT_SENTRY_URL; - const host = normalizeOrigin(rawHost) ?? DEFAULT_SENTRY_URL; + const host = normalizeCredentialHost(rawHost); runUpsert( db, @@ -429,6 +448,7 @@ export function setAuthToken( // Auth row changed — drop memoized fingerprint, token, row, and // stored-credentials flag so the next read reflects the new row. resetIdentityFingerprintCache(); + refreshIdentityAliases.clear(); resetAuthTokenCache(); resetAuthRowCache(); resetHasStoredCredsCache(); @@ -447,6 +467,7 @@ export async function clearAuth(): Promise { clearAllIssueOrgCache(); }); resetIdentityFingerprintCache(); + refreshIdentityAliases.clear(); resetAuthTokenCache(); resetAuthRowCache(); resetHasStoredCredsCache(); @@ -471,9 +492,6 @@ export function isAuthenticated(): boolean { /** Fingerprint returned when no token is present (logged out, no env var). */ export const ANON_IDENTITY = ""; -/** Memoized fingerprint. Identity doesn't change within a single CLI run. */ -let cachedFingerprint: string | undefined; - /** * Opaque fingerprint of the active bearer identity, used to namespace * response-cache keys so entries never leak across accounts. Mirrors @@ -490,15 +508,19 @@ let cachedFingerprint: string | undefined; * {@link resetIdentityFingerprintCache}. */ export function getIdentityFingerprint(): string { - if (cachedFingerprint === undefined) { - cachedFingerprint = computeIdentityFingerprint(); + const env = getEnv(); + const cached = authCacheState.fingerprints.get(env); + if (cached !== undefined) { + return cached; } - return cachedFingerprint; + const fingerprint = computeIdentityFingerprint(); + authCacheState.fingerprints.set(env, fingerprint); + return fingerprint; } /** Reset the memoized fingerprint. Tests only — call between auth-state mutations. */ export function resetIdentityFingerprintCache(): void { - cachedFingerprint = undefined; + authCacheState.fingerprints = new WeakMap(); } function computeIdentityFingerprint(): string { @@ -539,15 +561,79 @@ function computeIdentityFingerprint(): string { } /** - * 16-char MD5 hex of `kind|secret`. Not used for auth — just a cheap - * cache namespace. Collisions are benign (identities would share a - * cache slot, same as the anonymous case). + * Stable SHA-256 namespace for high-entropy OAuth and organization tokens. + * A collision would mix credentials' cached responses, so retain the full + * digest. This fingerprint is never a password verifier or a bearer token. */ function hashIdentity(kind: string, secret: string): string { - return createHash("md5") - .update(`${kind}|${secret}`) - .digest("hex") - .slice(0, 16); + return createHash("sha256") + .update(kind) + .update("\0") + .update(secret) + .digest("hex"); +} + +/** Immutable token, host, and namespace captured from one auth state. */ +export type CredentialContext = Readonly<{ + token: string; + host: string; + identity: string; + source: AuthSource; + refreshable: boolean; + expiresAt?: number; + issuedAt?: number; +}>; + +function envCredentialContext(): CredentialContext | undefined { + const token = getEnvToken(); + if (!token) { + return; + } + return Object.freeze({ + token: token.token, + host: getEnvTokenHost(), + identity: hashIdentity("env", token.token), + source: token.source, + refreshable: false, + }); +} + +export function getCredentialContext(): CredentialContext | undefined { + if (getEnv().SENTRY_FORCE_ENV_TOKEN?.trim()) { + const forced = envCredentialContext(); + if (forced) { + return forced; + } + } + const row = withDbSpan("getCredentialContext", getAuthRow); + if ( + row?.token && + (!row.expires_at || Date.now() <= row.expires_at || row.refresh_token) + ) { + const host = row.host ?? migrateNullHost(row); + const origin = normalizeHttpOrigin(host); + if (!origin) { + throw new ConfigError( + "Stored credential host must be a credential-free HTTP(S) URL." + ); + } + return Object.freeze({ + token: row.token, + host: origin, + identity: row.refresh_token + ? hashIdentity("oauth", row.refresh_token) + : hashIdentity("oauth-access", row.token), + source: "oauth", + refreshable: Boolean(row.refresh_token), + ...(row.expires_at ? { expiresAt: row.expires_at } : {}), + ...(row.issued_at ? { issuedAt: row.issued_at } : {}), + }); + } + return envCredentialContext(); +} + +export function getActiveAuthHost(): string | undefined { + return getCredentialContext()?.host; } /** @@ -586,29 +672,160 @@ export function hasStoredAuthCredentials(): boolean { export type RefreshTokenOptions = { /** Bypass threshold check and always refresh */ force?: boolean; + expectedCredential?: Pick; }; export type RefreshTokenResult = { token: string; refreshed: boolean; + host: string; + identity: string; + source: AuthSource; + refreshable: boolean; expiresAt?: number; expiresIn?: number; }; -let refreshPromise: Promise | null = null; +type StoredCredentialSnapshot = { + token: string; + refreshToken: string; + expiresAt: number | null; + issuedAt: number | null; + updatedAt: number; + host: string; + identity: string; +}; + +const refreshPromises = new Map>(); +// Only successful rotations can link a pinned in-flight request to the next +// refresh-token identity. A new login or logout clears every link. +const refreshIdentityAliases = new Map(); +const MAX_REFRESH_IDENTITY_ALIASES = 128; + +function rememberRefreshIdentity( + host: string, + previous: string, + next: string +): void { + if (previous === next) { + return; + } + refreshIdentityAliases.set(`${host}\0${previous}`, next); + if (refreshIdentityAliases.size > MAX_REFRESH_IDENTITY_ALIASES) { + const oldest = refreshIdentityAliases.keys().next().value; + if (oldest !== undefined) { + refreshIdentityAliases.delete(oldest); + } + } +} + +function matchesRefreshIdentity( + host: string, + previous: string, + current: string +): boolean { + const seen = new Set(); + const start = `${host}\0${previous}`; + for (let key = start; !seen.has(key); ) { + seen.add(key); + const next = refreshIdentityAliases.get(key); + if (!next) { + return false; + } + if (next === current) { + return true; + } + key = `${host}\0${next}`; + } + return false; +} + +function rowMatchesCredential( + row: AuthRow | undefined, + credential: StoredCredentialSnapshot +): boolean { + return ( + row?.token === credential.token && + row.refresh_token === credential.refreshToken && + row.expires_at === credential.expiresAt && + row.issued_at === credential.issuedAt && + row.updated_at === credential.updatedAt && + row.host === credential.host + ); +} + +function assertExpectedCredential( + result: Pick, + expected: RefreshTokenOptions["expectedCredential"] +): void { + if ( + expected && + (result.host !== expected.host || + (result.identity !== expected.identity && + !matchesRefreshIdentity( + result.host, + expected.identity, + result.identity + ))) + ) { + throw new ConfigError( + "Active credentials changed while the request was in flight. Retry the request." + ); + } +} + +function persistRefreshedCredential( + credential: StoredCredentialSnapshot, + token: string, + nextRefreshToken: string, + expiresIn: number +): boolean { + const saved = withDbSpan("persistRefreshedCredential", () => { + const db = getDatabase(); + return db.transaction(() => { + if (!rowMatchesCredential(getAuthRow(), credential)) { + return false; + } + const now = Date.now(); + db.query( + "UPDATE auth SET token = ?, refresh_token = ?, expires_at = ?, issued_at = ?, updated_at = ?, host = ? WHERE id = 1" + ).run( + token, + nextRefreshToken, + now + expiresIn * 1000, + now, + now, + credential.host + ); + return true; + })(); + }); + if (saved) { + resetIdentityFingerprintCache(); + resetAuthTokenCache(); + resetAuthRowCache(); + resetHasStoredCredsCache(); + } + return saved; +} async function performTokenRefresh( - storedRefreshToken: string + credential: StoredCredentialSnapshot ): Promise { const { refreshAccessToken } = await import("../oauth.js"); const { AuthError } = await import("../errors.js"); let tokenResponse: Awaited>; try { - tokenResponse = await refreshAccessToken(storedRefreshToken); + tokenResponse = await refreshAccessToken(credential.refreshToken, { + credentialHost: credential.host, + }); } catch (error) { // Only clear auth on explicit rejection, not network errors - if (error instanceof AuthError) { + if ( + error instanceof AuthError && + rowMatchesCredential(getAuthRow(), credential) + ) { await clearAuth(); } throw error; @@ -617,34 +834,79 @@ async function performTokenRefresh( // Validate before SQLite can truncate NUL-containing credentials or replace // the stored credentials with a malformed response. Leave those values unchanged. const token = normalizeAuthToken(tokenResponse.access_token); + const nextRefreshToken = + tokenResponse.refresh_token ?? credential.refreshToken; const now = Date.now(); const expiresAt = now + tokenResponse.expires_in * 1000; - await setAuthToken( - token, - tokenResponse.expires_in, - tokenResponse.refresh_token ?? storedRefreshToken - ); + if ( + !persistRefreshedCredential( + credential, + token, + nextRefreshToken, + tokenResponse.expires_in + ) + ) { + throw new ConfigError( + "Active credentials changed while the request was in flight. Retry the request." + ); + } + + const identity = hashIdentity("oauth", nextRefreshToken); + rememberRefreshIdentity(credential.host, credential.identity, identity); return { token, refreshed: true, + host: credential.host, + identity, + source: "oauth", + refreshable: true, expiresAt, expiresIn: tokenResponse.expires_in, }; } +function getEnvRefreshResult( + expected: RefreshTokenOptions["expectedCredential"] +): RefreshTokenResult | undefined { + const credential = envCredentialContext(); + if (!credential) { + return; + } + assertExpectedCredential(credential, expected); + return { ...credential, refreshed: false }; +} + +async function refreshStoredCredential( + credential: StoredCredentialSnapshot +): Promise { + const key = `${credential.identity}\0${credential.host}\0${hashIdentity("oauth-access", credential.token)}\0${credential.updatedAt}`; + const existing = refreshPromises.get(key); + if (existing) { + return await existing; + } + const pending = performTokenRefresh(credential); + refreshPromises.set(key, pending); + try { + return await pending; + } finally { + if (refreshPromises.get(key) === pending) { + refreshPromises.delete(key); + } + } +} + /** Get a valid token, refreshing if needed. Use force=true after 401 responses. */ export async function refreshToken( options: RefreshTokenOptions = {} ): Promise { // With SENTRY_FORCE_ENV_TOKEN, env token takes priority (no refresh needed). - const forceEnv = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim(); - if (forceEnv) { - const envToken = getEnvToken(); - if (envToken) { - return { token: envToken.token, refreshed: false }; - } + const forced = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim() + ? getEnvRefreshResult(options.expectedCredential) + : undefined; + if (forced) { + return forced; } const { force = false } = options; @@ -654,18 +916,35 @@ export async function refreshToken( if (!row?.token) { // No stored token — try env token as fallback - const envToken = getEnvToken(); - if (envToken) { - return { token: envToken.token, refreshed: false }; + const fallback = getEnvRefreshResult(options.expectedCredential); + if (fallback) { + return fallback; } throw new AuthError("not_authenticated"); } const now = Date.now(); const expiresAt = row.expires_at; + const host = row.host ?? migrateNullHost(row); + const origin = normalizeHttpOrigin(host); + if (!origin) { + throw new ConfigError( + "Stored credential host must be a credential-free HTTP(S) URL." + ); + } + const identity = row.refresh_token + ? hashIdentity("oauth", row.refresh_token) + : hashIdentity("oauth-access", row.token); + const baseResult = { + host: origin, + identity, + source: "oauth" as const, + refreshable: Boolean(row.refresh_token), + }; + assertExpectedCredential(baseResult, options.expectedCredential); if (!expiresAt) { - return { token: row.token, refreshed: false }; + return { ...baseResult, token: row.token, refreshed: false }; } const issuedAt = row.issued_at ?? expiresAt - DEFAULT_TOKEN_LIFETIME_MS; @@ -676,6 +955,7 @@ export async function refreshToken( if (!force && remainingRatio > REFRESH_THRESHOLD && now < expiresAt) { return { + ...baseResult, token: row.token, refreshed: false, expiresAt, @@ -686,9 +966,9 @@ export async function refreshToken( if (!row.refresh_token) { await clearAuth(); // Fall back to env token if available (consistent with getAuthToken/getAuthConfig) - const envToken = getEnvToken(); - if (envToken) { - return { token: envToken.token, refreshed: false }; + const fallback = getEnvRefreshResult(options.expectedCredential); + if (fallback) { + return fallback; } throw new AuthError( "expired", @@ -696,14 +976,14 @@ export async function refreshToken( ); } - if (refreshPromise) { - return refreshPromise; - } - - refreshPromise = performTokenRefresh(row.refresh_token); - try { - return await refreshPromise; - } finally { - refreshPromise = null; - } + const credential: StoredCredentialSnapshot = { + token: row.token, + refreshToken: row.refresh_token, + expiresAt: row.expires_at, + issuedAt: row.issued_at, + updatedAt: row.updated_at, + host: origin, + identity, + }; + return await refreshStoredCredential(credential); } diff --git a/packages/cli/src/lib/db/regions.ts b/packages/cli/src/lib/db/regions.ts index d79596954..5736b8042 100644 --- a/packages/cli/src/lib/db/regions.ts +++ b/packages/cli/src/lib/db/regions.ts @@ -10,15 +10,27 @@ * look up by `org_id = '1081365'` → get the slug). */ +import { DEFAULT_SENTRY_URL, getConfiguredSentryUrl } from "../constants.js"; import { logger } from "../logger.js"; -import { normalizeOrigin } from "../sentry-urls.js"; +import { normalizeHttpOrigin } from "../sentry-urls.js"; import { recordCacheHit } from "../telemetry.js"; +import { getCredentialContext, getIdentityFingerprint } from "./auth.js"; import { getDatabase } from "./index.js"; import { runUpsert } from "./utils.js"; const log = logger.withTag("db.regions"); const TABLE = "org_regions"; +const MAX_TRUST_GRAPH_ORIGINS = 1024; +const TRAILING_SLASHES_RE = /\/+$/; + +function getActiveSourceOrigin(): string { + return ( + getConfiguredSentryUrl() ?? + getCredentialContext()?.host ?? + DEFAULT_SENTRY_URL + ); +} /** * Process-local trust extension: origins that were vouched for by the @@ -31,26 +43,85 @@ const TABLE = "org_regions"; * start (with cached orgs from a previous CLI invocation) we don't * re-fetch regions just to extend trust. */ -const trustedRegionOrigins = new Set(); -let trustedRegionOriginsSeeded = false; +const trustedRegionOrigins = new Map>(); +const seededTrustScopes = new Set(); + +function trustScopeKey(identity: string, sourceOrigin: string): string { + return `${identity}\0${sourceOrigin}`; +} + +function requireOrigin(url: string, name: string): string { + const origin = normalizeHttpOrigin(url); + if (!origin) { + throw new Error(`${name} must be a credential-free HTTP(S) URL`); + } + return origin; +} -function seedTrustedRegionOriginsIfNeeded(): void { - if (trustedRegionOriginsSeeded) { +/** Keep a self-hosted installation path while storing only validated URLs. */ +function requireRegionBaseUrl(url: string): string { + const origin = requireOrigin(url, "Organization region URL"); + const parsed = new URL(url); + if (parsed.search || parsed.hash) { + throw new Error( + "Organization region URL must not contain a query or fragment" + ); + } + const path = parsed.pathname.replace(TRAILING_SLASHES_RE, ""); + return `${origin}${path}`; +} + +function registerTrustedOrigins( + identity: string, + sourceOrigin: string, + urls: readonly string[] +): void { + const key = trustScopeKey(identity, sourceOrigin); + if ( + !trustedRegionOrigins.has(key) && + trustedRegionOrigins.size >= MAX_TRUST_GRAPH_ORIGINS + ) { + const oldest = trustedRegionOrigins.keys().next().value; + if (oldest) { + trustedRegionOrigins.delete(oldest); + seededTrustScopes.delete(oldest); + } + } + const origins = trustedRegionOrigins.get(key) ?? new Set(); + for (const url of urls) { + const origin = requireOrigin(url, "Organization region URL"); + if (origins.size < MAX_TRUST_GRAPH_ORIGINS) { + origins.add(origin); + } + } + trustedRegionOrigins.set(key, origins); +} + +function seedTrustedOrigins(identity: string, sourceOrigin: string): void { + const key = trustScopeKey(identity, sourceOrigin); + if (seededTrustScopes.has(key)) { return; } - trustedRegionOriginsSeeded = true; + seededTrustScopes.add(key); try { - const db = getDatabase(); - const rows = db - .query(`SELECT DISTINCT region_url FROM ${TABLE}`) - .all() as Pick[]; + const rows = getDatabase() + .query( + `SELECT DISTINCT source_origin, response_origin, region_url FROM ${TABLE} WHERE credential_identity = ? AND (source_origin = ? OR response_origin = ?)` + ) + .all(identity, sourceOrigin, sourceOrigin) as Pick< + OrgRegionRow, + "source_origin" | "response_origin" | "region_url" + >[]; for (const row of rows) { - const origin = normalizeOrigin(row.region_url); - if (origin) { - trustedRegionOrigins.add(origin); + if (row.source_origin === sourceOrigin) { + registerTrustedOrigins(identity, sourceOrigin, [row.response_origin]); + } + if (row.response_origin === sourceOrigin) { + registerTrustedOrigins(identity, sourceOrigin, [row.region_url]); } } } catch (error) { + seededTrustScopes.delete(key); log.debug("Failed to seed trusted region origins from DB", error); } } @@ -63,22 +134,52 @@ function seedTrustedRegionOriginsIfNeeded(): void { * Also called automatically by {@link setOrgRegion} and * {@link setOrgRegions} so persistent and in-process state stay in sync. */ -export function registerTrustedRegionUrls(urls: readonly string[]): void { - for (const url of urls) { - const origin = normalizeOrigin(url); - if (origin) { - trustedRegionOrigins.add(origin); - } - } +export function registerTrustedRegionUrls( + urls: readonly string[], + sourceOrigin = getActiveSourceOrigin(), + identity = getIdentityFingerprint() +): void { + registerTrustedOrigins( + identity, + requireOrigin(sourceOrigin, "Region source origin"), + urls + ); } /** * Whether `origin` was vouched for by the active token's issuing host. * Lazy-seeds from `org_regions` on first call. */ -export function isTrustedRegionOrigin(origin: string): boolean { - seedTrustedRegionOriginsIfNeeded(); - return trustedRegionOrigins.has(origin); +export function isTrustedRegionOrigin( + origin: string, + sourceOrigin = getActiveSourceOrigin(), + identity = getIdentityFingerprint() +): boolean { + const candidate = normalizeHttpOrigin(origin); + const source = normalizeHttpOrigin(sourceOrigin); + if (!(candidate && source)) { + return false; + } + const pending = [source]; + const visited = new Set(); + while (pending.length > 0 && visited.size < MAX_TRUST_GRAPH_ORIGINS) { + const current = pending.shift(); + if (!current || visited.has(current)) { + continue; + } + visited.add(current); + seedTrustedOrigins(identity, current); + const trusted = trustedRegionOrigins.get(trustScopeKey(identity, current)); + if (trusted?.has(candidate)) { + return true; + } + for (const next of trusted ?? []) { + if (!visited.has(next)) { + pending.push(next); + } + } + } + return false; } /** @@ -91,15 +192,12 @@ export function isTrustedRegionOrigin(origin: string): boolean { */ export function clearTrustedHostState(): void { trustedRegionOrigins.clear(); - // Force re-seed on next read in case the caller deletes table rows - // (clearOrgRegions) between this clear and the next access. - trustedRegionOriginsSeeded = false; + seededTrustScopes.clear(); } /** @internal exported for testing */ export function resetTrustedRegionUrlsForTesting(): void { - trustedRegionOrigins.clear(); - trustedRegionOriginsSeeded = false; + clearTrustedHostState(); } /** When true, getCachedOrganizations() returns empty (forces API fetch). */ @@ -121,6 +219,9 @@ type OrgRegionRow = { org_name: string | null; org_role: string | null; region_url: string; + credential_identity: string; + source_origin: string; + response_origin: string; updated_at: number; }; @@ -128,6 +229,9 @@ type OrgRegionRow = { export type OrgRegionEntry = { slug: string; regionUrl: string; + sourceOrigin?: string; + cacheOrigin?: string; + identity?: string; orgId?: string; orgName?: string; /** The authenticated user's role in this organization (e.g., "member", "admin", "owner"). */ @@ -140,11 +244,24 @@ export type OrgRegionEntry = { * @param orgSlug - The organization slug * @returns The region URL if cached, undefined otherwise */ -export function getOrgRegion(orgSlug: string): string | undefined { +export function getOrgRegion( + orgSlug: string, + sourceOrigin = getActiveSourceOrigin(), + identity = getIdentityFingerprint() +): string | undefined { + const source = normalizeHttpOrigin(sourceOrigin); + if (!source) { + recordCacheHit("region", false); + return; + } const db = getDatabase(); const row = db - .query(`SELECT region_url FROM ${TABLE} WHERE org_slug = ?`) - .get(orgSlug) as Pick | undefined; + .query( + `SELECT region_url FROM ${TABLE} WHERE org_slug = ? AND source_origin = ? AND credential_identity = ?` + ) + .get(orgSlug, source, identity) as + | Pick + | undefined; recordCacheHit("region", !!row); return row?.region_url; @@ -160,12 +277,20 @@ export function getOrgRegion(orgSlug: string): string | undefined { * @returns The org slug and region URL if found, undefined otherwise */ export function getOrgByNumericId( - numericId: string + numericId: string, + sourceOrigin = getActiveSourceOrigin(), + identity = getIdentityFingerprint() ): { slug: string; regionUrl: string } | undefined { + const source = normalizeHttpOrigin(sourceOrigin); + if (!source) { + return; + } const db = getDatabase(); const row = db - .query(`SELECT org_slug, region_url FROM ${TABLE} WHERE org_id = ?`) - .get(numericId) as + .query( + `SELECT org_slug, region_url FROM ${TABLE} WHERE org_id = ? AND source_origin = ? AND credential_identity = ?` + ) + .get(numericId, source, identity) as | Pick | undefined; @@ -181,17 +306,35 @@ export function getOrgByNumericId( * @param orgSlug - The organization slug * @param regionUrl - The region URL (e.g., https://us.sentry.io) */ -export function setOrgRegion(orgSlug: string, regionUrl: string): void { +// biome-ignore lint/nursery/useMaxParams: provenance fields are explicit at the persistence boundary. +export function setOrgRegion( + orgSlug: string, + regionUrl: string, + responseOrigin = getActiveSourceOrigin(), + cacheOrigin = responseOrigin, + identity = getIdentityFingerprint() +): void { const db = getDatabase(); const now = Date.now(); + const region = requireRegionBaseUrl(regionUrl); + const response = requireOrigin(responseOrigin, "Region response origin"); + const source = requireOrigin(cacheOrigin, "Region lookup origin"); runUpsert( db, TABLE, - { org_slug: orgSlug, region_url: regionUrl, updated_at: now }, - ["org_slug"] + { + org_slug: orgSlug, + region_url: region, + credential_identity: identity, + source_origin: source, + response_origin: response, + updated_at: now, + }, + ["credential_identity", "source_origin", "org_slug"] ); - registerTrustedRegionUrls([regionUrl]); + registerTrustedOrigins(identity, source, [response]); + registerTrustedOrigins(identity, response, [region]); } /** @@ -208,14 +351,35 @@ export function setOrgRegions(entries: OrgRegionEntry[]): void { return; } + const normalized = entries.map((entry) => { + const response = requireOrigin( + entry.sourceOrigin ?? getActiveSourceOrigin(), + "Region response origin" + ); + const source = requireOrigin( + entry.cacheOrigin ?? response, + "Region lookup origin" + ); + return { + entry, + identity: entry.identity ?? getIdentityFingerprint(), + region: requireRegionBaseUrl(entry.regionUrl), + response, + source, + }; + }); const db = getDatabase(); const now = Date.now(); db.transaction(() => { - for (const entry of entries) { + for (const item of normalized) { + const { entry } = item; const row: Record = { org_slug: entry.slug, - region_url: entry.regionUrl, + region_url: item.region, + credential_identity: item.identity, + source_origin: item.source, + response_origin: item.response, updated_at: now, }; if (entry.orgId) { @@ -227,10 +391,30 @@ export function setOrgRegions(entries: OrgRegionEntry[]): void { if (entry.orgRole) { row.org_role = entry.orgRole; } - runUpsert(db, TABLE, row, ["org_slug"]); + runUpsert(db, TABLE, row, [ + "credential_identity", + "source_origin", + "org_slug", + ]); } })(); - registerTrustedRegionUrls(entries.map((e) => e.regionUrl)); + for (const item of normalized) { + registerTrustedOrigins(item.identity, item.source, [item.response]); + registerTrustedOrigins(item.identity, item.response, [item.region]); + } +} + +/** Keep trusted region routes but make an incomplete org list a cache miss. */ +export function invalidateCachedOrganizations( + sourceOrigin: string, + identity: string +): void { + const source = requireOrigin(sourceOrigin, "Region lookup origin"); + getDatabase() + .query( + `UPDATE ${TABLE} SET org_id = NULL, org_name = NULL, org_role = NULL WHERE source_origin = ? AND credential_identity = ?` + ) + .run(source, identity); } /** @@ -249,11 +433,20 @@ export function clearOrgRegions(): void { * * @returns Map of org slug to region URL */ -export function getAllOrgRegions(): Map { +export function getAllOrgRegions( + sourceOrigin = getActiveSourceOrigin(), + identity = getIdentityFingerprint() +): Map { + const source = normalizeHttpOrigin(sourceOrigin); + if (!source) { + return new Map(); + } const db = getDatabase(); const rows = db - .query(`SELECT org_slug, region_url FROM ${TABLE}`) - .all() as Pick[]; + .query( + `SELECT org_slug, region_url FROM ${TABLE} WHERE source_origin = ? AND credential_identity = ?` + ) + .all(source, identity) as Pick[]; return new Map(rows.map((row) => [row.org_slug, row.region_url])); } @@ -288,18 +481,26 @@ const ORG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; * * @returns Array of cached org entries, or empty if cache is cold/stale/disabled/incomplete */ -export function getCachedOrganizations(): CachedOrg[] { +export function getCachedOrganizations( + sourceOrigin = getActiveSourceOrigin(), + identity = getIdentityFingerprint() +): CachedOrg[] { if (orgCacheDisabled) { return []; } + const source = normalizeHttpOrigin(sourceOrigin); + if (!source) { + return []; + } + const db = getDatabase(); const cutoff = Date.now() - ORG_CACHE_TTL_MS; const rows = db .query( - `SELECT org_slug, org_id, org_name, org_role FROM ${TABLE} WHERE org_id IS NOT NULL AND org_name IS NOT NULL AND updated_at > ?` + `SELECT org_slug, org_id, org_name, org_role FROM ${TABLE} WHERE source_origin = ? AND credential_identity = ? AND org_id IS NOT NULL AND org_name IS NOT NULL AND updated_at > ?` ) - .all(cutoff) as Pick< + .all(source, identity, cutoff) as Pick< OrgRegionRow, "org_slug" | "org_id" | "org_name" | "org_role" >[]; @@ -322,14 +523,24 @@ export function getCachedOrganizations(): CachedOrg[] { * @param orgSlug - The organization slug * @returns The user's role (e.g., "member", "admin", "owner"), or undefined if not cached */ -export function getCachedOrgRole(orgSlug: string): string | undefined { +export function getCachedOrgRole( + orgSlug: string, + sourceOrigin = getActiveSourceOrigin(), + identity = getIdentityFingerprint() +): string | undefined { + const source = normalizeHttpOrigin(sourceOrigin); + if (!source) { + return; + } const db = getDatabase(); const cutoff = Date.now() - ORG_CACHE_TTL_MS; const row = db .query( - `SELECT org_role FROM ${TABLE} WHERE org_slug = ? AND org_role IS NOT NULL AND updated_at > ?` + `SELECT org_role FROM ${TABLE} WHERE org_slug = ? AND source_origin = ? AND credential_identity = ? AND org_role IS NOT NULL AND updated_at > ?` ) - .get(orgSlug, cutoff) as Pick | undefined; + .get(orgSlug, source, identity, cutoff) as + | Pick + | undefined; return row?.org_role ?? undefined; } diff --git a/packages/cli/src/lib/db/schema.ts b/packages/cli/src/lib/db/schema.ts index ed13e44ca..7c43eb3d3 100644 --- a/packages/cli/src/lib/db/schema.ts +++ b/packages/cli/src/lib/db/schema.ts @@ -19,7 +19,7 @@ import type { Database } from "./sqlite.js"; const _require = createRequire(import.meta.url); -export const CURRENT_SCHEMA_VERSION = 16; +export const CURRENT_SCHEMA_VERSION = 17; /** Environment variable to disable auto-repair */ const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR"; @@ -163,17 +163,21 @@ export const TABLE_SCHEMAS: Record = { }, org_regions: { columns: { - org_slug: { type: "TEXT", primaryKey: true }, + org_slug: { type: "TEXT", notNull: true }, org_id: { type: "TEXT", addedInVersion: 8 }, org_name: { type: "TEXT", addedInVersion: 9 }, org_role: { type: "TEXT", addedInVersion: 10 }, region_url: { type: "TEXT", notNull: true }, + credential_identity: { type: "TEXT", notNull: true, addedInVersion: 17 }, + source_origin: { type: "TEXT", notNull: true, addedInVersion: 17 }, + response_origin: { type: "TEXT", notNull: true, addedInVersion: 17 }, updated_at: { type: "INTEGER", notNull: true, default: "(unixepoch() * 1000)", }, }, + compositePrimaryKey: ["credential_identity", "source_origin", "org_slug"], }, user_info: { columns: { @@ -863,14 +867,22 @@ export function runMigrations(db: Database): void { } // Migration 15 -> 16: Add host column to auth table for host-scoped tokens. - // The column is NULL for existing rows; getAuthConfig lazily backfills it - // with the currently-configured host on first access after upgrade, so - // users who already have SENTRY_HOST/SENTRY_URL set at upgrade time are - // migrated cleanly to the host-scoped model. + // The column is NULL for existing rows; the first credential read backfills + // it from the boot-time explicit host (or SaaS), before later config shims + // can change SENTRY_URL. if (currentVersion < 16) { addColumnIfMissing(db, "auth", "host", "TEXT"); } + // Legacy region rows have no credential or response provenance. Rebuild the + // reconstructible cache rather than assigning foreign data to a new login. + if (currentVersion < 17) { + db.transaction(() => { + db.exec("DROP TABLE IF EXISTS org_regions"); + db.exec(EXPECTED_TABLES.org_regions as string); + })(); + } + if (currentVersion < CURRENT_SCHEMA_VERSION) { db.query("UPDATE schema_version SET version = ?").run( CURRENT_SCHEMA_VERSION diff --git a/packages/cli/src/lib/env-token-host.ts b/packages/cli/src/lib/env-token-host.ts index 5880c8245..a3e9701ee 100644 --- a/packages/cli/src/lib/env-token-host.ts +++ b/packages/cli/src/lib/env-token-host.ts @@ -34,14 +34,72 @@ * 4. getDefaultUrl() fallback ← may write env.SENTRY_URL */ -import { DEFAULT_SENTRY_URL } from "./constants.js"; +import { DEFAULT_SENTRY_URL, normalizeUrl } from "./constants.js"; import { getRawEnvToken } from "./db/auth.js"; import { getEnv } from "./env.js"; -import { normalizeUserInputToOrigin } from "./sentry-urls.js"; +import { ConfigError } from "./errors.js"; import { parseSntrysClaim } from "./token-claims.js"; -/** Pinned host. `undefined` means not yet captured. */ -let pinnedHost: string | undefined; +type HostSnapshot = { + host: string; + configuredHost: string | null; + claimError?: ConfigError; +}; +const EXPLICIT_SCHEME_RE = /^([a-z][a-z\d+.-]*):\/\//i; + +function normalizeHost( + input: string | undefined, + source: string +): string | undefined { + if (!input) { + return; + } + const scheme = input.trim().match(EXPLICIT_SCHEME_RE)?.[1]?.toLowerCase(); + try { + if (scheme && scheme !== "http" && scheme !== "https") { + throw new TypeError("Unsupported URL scheme"); + } + const parsed = new URL(normalizeUrl(input) as string); + if ( + (parsed.protocol !== "http:" && parsed.protocol !== "https:") || + !parsed.hostname || + parsed.username || + parsed.password + ) { + throw new TypeError("Invalid URL"); + } + return parsed.origin; + } catch { + throw new ConfigError(`${source} must be a credential-free HTTP(S) URL.`); + } +} + +function captureClaimHost(token: string | undefined): { + host?: string; + error?: ConfigError; +} { + if (!token?.startsWith("sntrys_")) { + return {}; + } + const claim = parseSntrysClaim(token); + if (!claim) { + // Opaque tokens, including legacy sntrys_ strings without a usable claim, + // still use the configured URL or SaaS. No claimed host was trusted. + return {}; + } + try { + return { host: normalizeHost(claim.url, "The active token URL claim") }; + } catch (error) { + if (error instanceof ConfigError) { + return { error }; + } + throw error; + } +} + +const snapshotState = { + byEnv: new WeakMap(), +}; /** * Snapshot the env-token's scoping host. Idempotent — second and subsequent @@ -56,23 +114,22 @@ let pinnedHost: string | undefined; * 3. `DEFAULT_SENTRY_URL` (SaaS). */ export function captureEnvTokenHost(): void { - if (pinnedHost !== undefined) { + const env = getEnv(); + if (snapshotState.byEnv.has(env)) { return; } + const configuredHost = + normalizeHost( + env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim(), + env.SENTRY_HOST?.trim() ? "SENTRY_HOST" : "SENTRY_URL" + ) ?? null; // Claim first: for sntrys_ tokens, the embedded url is authoritative. - const claimHost = normalizeUserInputToOrigin( - parseSntrysClaim(getRawEnvToken())?.url - ); - if (claimHost) { - pinnedHost = claimHost; - return; - } - // Env fallback: for non-sntrys_ tokens (no claim available). - const env = getEnv(); - const envHost = normalizeUserInputToOrigin( - env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim() - ); - pinnedHost = envHost ?? DEFAULT_SENTRY_URL; + const claim = captureClaimHost(getRawEnvToken()); + snapshotState.byEnv.set(env, { + configuredHost, + host: claim.host ?? configuredHost ?? DEFAULT_SENTRY_URL, + ...(claim.error ? { claimError: claim.error } : {}), + }); } /** @@ -81,13 +138,27 @@ export function captureEnvTokenHost(): void { * auto-capture covers library-mode callers that bypass the boot. */ export function getEnvTokenHost(): string { - if (pinnedHost === undefined) { + const env = getEnv(); + if (!snapshotState.byEnv.has(env)) { + captureEnvTokenHost(); + } + const snapshot = snapshotState.byEnv.get(env); + if (snapshot?.claimError) { + throw snapshot.claimError; + } + return snapshot?.host ?? DEFAULT_SENTRY_URL; +} + +/** Only the explicit URL captured at boot may migrate a legacy stored login. */ +export function getBootConfiguredSentryUrl(): string | undefined { + const env = getEnv(); + if (!snapshotState.byEnv.has(env)) { captureEnvTokenHost(); } - return pinnedHost ?? DEFAULT_SENTRY_URL; + return snapshotState.byEnv.get(env)?.configuredHost ?? undefined; } /** @internal */ export function resetEnvTokenHostForTesting(): void { - pinnedHost = undefined; + snapshotState.byEnv = new WeakMap(); } diff --git a/packages/cli/src/lib/env.ts b/packages/cli/src/lib/env.ts index 6723f99ca..fec3f0c28 100644 --- a/packages/cli/src/lib/env.ts +++ b/packages/cli/src/lib/env.ts @@ -6,14 +6,22 @@ * `process.env` is never mutated. */ -let _env: NodeJS.ProcessEnv = process.env; +import { AsyncLocalStorage } from "node:async_hooks"; + +const invocationEnvironments = new AsyncLocalStorage(); +const envState: { defaultEnv: NodeJS.ProcessEnv } = { defaultEnv: process.env }; /** Get the active environment. Library mode overrides this; CLI uses process.env. */ export function getEnv(): NodeJS.ProcessEnv { - return _env; + return invocationEnvironments.getStore() ?? envState.defaultEnv; } /** Set the active environment for this invocation. */ export function setEnv(env: NodeJS.ProcessEnv): void { - _env = env; + envState.defaultEnv = env; +} + +/** Isolate overlapping SDK invocations without changing the process environment. */ +export function withEnv(env: NodeJS.ProcessEnv, callback: () => T): T { + return invocationEnvironments.run(env, callback); } diff --git a/packages/cli/src/lib/error-reporting.ts b/packages/cli/src/lib/error-reporting.ts index 29101826c..3087e1815 100644 --- a/packages/cli/src/lib/error-reporting.ts +++ b/packages/cli/src/lib/error-reporting.ts @@ -45,7 +45,7 @@ import { ValidationError, WizardError, } from "./errors.js"; -import { isSaaS } from "./sentry-urls.js"; +import { isSaaS } from "./sentry-web-urls.js"; // --------------------------------------------------------------------------- // Silencing diff --git a/packages/cli/src/lib/formatters/log.ts b/packages/cli/src/lib/formatters/log.ts index 529629745..7e5cbb1a3 100644 --- a/packages/cli/src/lib/formatters/log.ts +++ b/packages/cli/src/lib/formatters/log.ts @@ -9,7 +9,7 @@ import type { SentryLog, TraceItemAttribute, } from "../../types/index.js"; -import { buildTraceUrl } from "../sentry-urls.js"; +import { buildTraceUrl } from "../sentry-web-urls.js"; import { colorTag, escapeMarkdownCell, diff --git a/packages/cli/src/lib/formatters/replay.ts b/packages/cli/src/lib/formatters/replay.ts index 9c069306e..615db3232 100644 --- a/packages/cli/src/lib/formatters/replay.ts +++ b/packages/cli/src/lib/formatters/replay.ts @@ -13,7 +13,7 @@ import type { ReplayRelatedTrace, } from "../../types/index.js"; import { getReplayUserLabel } from "../replay-search.js"; -import { buildReplayUrl } from "../sentry-urls.js"; +import { buildReplayUrl } from "../sentry-web-urls.js"; import { escapeMarkdownCell, escapeMarkdownInline, diff --git a/packages/cli/src/lib/init/existing-project.ts b/packages/cli/src/lib/init/existing-project.ts index 438f72ebd..c6242b58f 100644 --- a/packages/cli/src/lib/init/existing-project.ts +++ b/packages/cli/src/lib/init/existing-project.ts @@ -1,6 +1,6 @@ import { getProject, tryGetPrimaryDsn } from "../api-client.js"; import { ApiError } from "../errors.js"; -import { buildProjectUrl } from "../sentry-urls.js"; +import { buildProjectUrl } from "../sentry-web-urls.js"; import type { ExistingProjectData } from "./types.js"; /** diff --git a/packages/cli/src/lib/init/formatters.ts b/packages/cli/src/lib/init/formatters.ts index 715b1019f..d310eaedc 100644 --- a/packages/cli/src/lib/init/formatters.ts +++ b/packages/cli/src/lib/init/formatters.ts @@ -18,11 +18,11 @@ import { basename } from "node:path"; import { terminalLink } from "../formatters/colors.js"; import { stripAnsi } from "../formatters/plain-detect.js"; +import { parseOrgProjectFromSettingsUrl } from "../sentry-urls.js"; import { buildEventSearchUrl, buildProjectIssuesUrl, - parseOrgProjectFromSettingsUrl, -} from "../sentry-urls.js"; +} from "../sentry-web-urls.js"; import { featureLabel, sortFeatures } from "./clack-utils.js"; import { EXIT_DEPENDENCY_INSTALL_FAILED, diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index a5ccd798f..6322ddb6e 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -31,9 +31,9 @@ import { ValidationError, } from "./errors.js"; import { logger } from "./logger.js"; -import { normalizeOrigin } from "./sentry-urls.js"; +import { normalizeHttpOrigin } from "./sentry-urls.js"; import { withHttpSpan } from "./telemetry.js"; -import { getActiveTokenHost, isRequestOriginTrusted } from "./token-host.js"; +import { getActiveTokenHost } from "./token-host.js"; /** * Get the Sentry instance URL for OAuth endpoints. @@ -195,12 +195,13 @@ function sleep(ms: number): Promise { */ async function fetchWithConnectionError( url: string, - init: RequestInit + init: RequestInit, + customHeadersTrusted?: boolean ): Promise { // Inject custom headers for self-hosted proxies (IAP, mTLS, etc.) — // URL-scoped so they don't leak to untrusted hosts. const merged = new Headers(init.headers); - applyCustomHeaders(merged, url); + applyCustomHeaders(merged, url, customHeadersTrusted); const effectiveInit: RequestInit = { ...init, headers: merged }; try { @@ -214,11 +215,12 @@ async function fetchWithConnectionError( if (!(error instanceof Error)) { throw error; } + const targetOrigin = new URL(url).origin; // TLS certificate errors — give actionable guidance if (isTlsCertError(error)) { throw new ApiError( - `TLS certificate error connecting to ${getSentryUrl()}`, + `TLS certificate error connecting to ${targetOrigin}`, 0, buildTlsErrorDetail(error) ); @@ -231,7 +233,7 @@ async function fetchWithConnectionError( if (isConnectionError) { throw new ApiError( - `Cannot connect to Sentry at ${getSentryUrl()}`, + `Cannot connect to Sentry at ${targetOrigin}`, 0, "Check your network connection and SENTRY_URL configuration" ); @@ -245,15 +247,16 @@ async function fetchWithConnectionError( * token's scope. Defense-in-depth for the rare case where SENTRY_HOST/URL * was mutated without going through the URL-arg / rc-shim guards. */ -function assertRefreshHostTrusted(): void { - const refreshUrl = getSentryUrl(); - if (!isRequestOriginTrusted(refreshUrl)) { +function assertRefreshHostTrusted(refreshUrl: string): string { + const origin = normalizeHttpOrigin(refreshUrl); + if (!origin) { throw new HostScopeError( "OAuth refresh token", - normalizeOrigin(refreshUrl) ?? "", + "", getActiveTokenHost() ); } + return origin; } /** @@ -509,42 +512,52 @@ export async function setApiToken(token: string): Promise { /** Refresh an access token using a refresh token. */ export function refreshAccessToken( - refreshToken: string + refreshToken: string, + options: { credentialHost: string } ): Promise { const clientId = getClientId(); - assertRefreshHostTrusted(); + const credentialHost = assertRefreshHostTrusted(options.credentialHost); return withHttpSpan("POST", "/oauth/token/", async () => { const response = await fetchWithConnectionError( - `${getSentryUrl()}/oauth/token/`, + `${credentialHost}/oauth/token/`, { method: "POST", + // Never replay a refresh credential to a server-selected destination. + redirect: "error", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, grant_type: "refresh_token", refresh_token: refreshToken, }), - } + }, + true ); if (!response.ok) { - let errorDetail = "Token refresh failed"; - // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. + let rejected = false; try { - const errorData = await response.json(); - const errorResult = safeParse(TokenErrorResponseSchema, errorData); - if (errorResult.success) { - errorDetail = - errorResult.output.error_description ?? errorResult.output.error; - } - } catch { - // Ignore JSON parse errors + const errorResult = safeParse( + TokenErrorResponseSchema, + await response.json() + ); + rejected = + errorResult.success && errorResult.output.error === "invalid_grant"; + } catch (error) { + logger.debug("Failed to parse token refresh error response", error); } - - throw new AuthError( - "expired", - `Session expired: ${errorDetail}. Run 'sentry auth login' to re-authenticate.` + if (rejected) { + throw new AuthError( + "expired", + "Session expired because the refresh credential was rejected. Run 'sentry auth login' to re-authenticate." + ); + } + throw new ApiError( + "Token refresh failed", + response.status, + "The refresh endpoint returned an unexpected failure.", + "/oauth/token/" ); } diff --git a/packages/cli/src/lib/region.ts b/packages/cli/src/lib/region.ts index 275fd4ac1..05583150f 100644 --- a/packages/cli/src/lib/region.ts +++ b/packages/cli/src/lib/region.ts @@ -6,13 +6,22 @@ */ import { getOrganization } from "@sentry/api"; -import { getConfiguredSentryUrl } from "./constants.js"; +import { type CredentialContext, getCredentialContext } from "./db/auth.js"; import { getOrgByNumericId, getOrgRegion, setOrgRegion } from "./db/regions.js"; import { stripDsnOrgPrefix } from "./dsn/index.js"; -import { withAuthGuard } from "./errors.js"; +import { AuthError, withAuthGuard } from "./errors.js"; import { logger } from "./logger.js"; -import { getSdkConfig } from "./sentry-client.js"; -import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js"; +import { + getApiBaseUrl, + getResponseCredentialIdentity, + getResponseRequestOrigin, + getSdkConfig, +} from "./sentry-client.js"; +import { + isSentrySaasUrl, + normalizeHttpOrigin, + normalizeRegionBaseUrl, +} from "./sentry-urls.js"; /** * Promise cache for org region resolution, keyed by orgSlug. @@ -24,7 +33,8 @@ import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js"; * Rejected promises (e.g., AuthError) are automatically evicted so that * retries after re-authentication can succeed without restarting the CLI. */ -const regionCache = new Map>(); +type RegionResolution = { cacheable: boolean; url: string }; +const regionCache = new Map>(); /** * Resolve the region URL for an organization. @@ -45,18 +55,28 @@ const regionCache = new Map>(); * @returns The region URL for the organization */ export function resolveOrgRegion(orgSlug: string): Promise { - const existing = regionCache.get(orgSlug); + const credential = getCredentialContext(); + if (!credential) { + return Promise.reject(new AuthError("not_authenticated")); + } + const baseUrl = getApiBaseUrl(credential); + const key = `${credential.identity}\0${baseUrl}\0${orgSlug}`; + const existing = regionCache.get(key); if (existing) { - return existing; + return existing.then((resolution) => resolution.url); } - const promise = resolveOrgRegionUncached(orgSlug); - regionCache.set(orgSlug, promise); - // Evict on rejection (AuthError) so retries after re-login work. - // Non-auth errors already resolve to baseUrl fallback, so only - // AuthError re-throws can leave a rejected promise in the cache. - promise.catch(() => regionCache.delete(orgSlug)); - return promise; + const promise = resolveOrgRegionUncached(orgSlug, credential, baseUrl); + regionCache.set(key, promise); + promise.then( + (resolution) => { + if (!resolution.cacheable) { + regionCache.delete(key); + } + }, + () => regionCache.delete(key) + ); + return promise.then((resolution) => resolution.url); } /** @@ -67,37 +87,49 @@ export function resolveOrgRegion(orgSlug: string): Promise { * a relative value against baseUrl so it becomes absolute instead of being * discarded; an already-absolute value is returned unchanged. */ -function toAbsoluteRegionUrl(rawRegionUrl: string, baseUrl: string): string { - // Already absolute — use verbatim. - if (URL.canParse(rawRegionUrl)) { - return rawRegionUrl; - } +function normalizeRegionUrl( + raw: string, + responseOrigin: string +): string | undefined { + return normalizeRegionBaseUrl(raw, responseOrigin); +} - // Relative (e.g. "/") — resolve against baseUrl to get an absolute origin. - if (URL.canParse(rawRegionUrl, baseUrl)) { - return new URL(rawRegionUrl, baseUrl).origin; +function getResolvedRegionUrl( + raw: string | undefined, + responseOrigin: string | undefined, + baseUrl: string +): string | undefined { + if (!responseOrigin) { + return; } - - logger.debug( - `regionUrl "${rawRegionUrl}" from API could not be resolved to an absolute URL; falling back to baseUrl` - ); - return baseUrl; + if (raw) { + return normalizeRegionUrl(raw, responseOrigin); + } + return normalizeHttpOrigin(baseUrl) === responseOrigin + ? baseUrl + : responseOrigin; } /** * Resolve org region from SQLite cache or API. * Called at most once per orgSlug per process lifetime. */ -async function resolveOrgRegionUncached(orgSlug: string): Promise { +async function resolveOrgRegionUncached( + orgSlug: string, + credential: CredentialContext, + baseUrl: string +): Promise { // 1. Check SQLite cache first - const cached = getOrgRegion(orgSlug); + const cached = getOrgRegion(orgSlug, baseUrl, credential.identity); if (cached) { - return cached; + return { cacheable: true, url: cached }; } // 2. Fetch org details via SDK to discover the region URL - const baseUrl = getSentryBaseUrl(); - const config = getSdkConfig(baseUrl); + const config = getSdkConfig(baseUrl, { + credential, + validatedRedirects: true, + }); const result = await withAuthGuard(async () => { const response = await getOrganization({ @@ -115,22 +147,43 @@ async function resolveOrgRegionUncached(orgSlug: string): Promise { // is truthy but would break fetch calls that depend on an absolute base // URL. Resolve it against baseUrl so a relative value becomes absolute // instead of being discarded; keep an already-absolute value as-is. + const rawResponse = (response as { response?: Response }).response; + const responseOrigin = rawResponse + ? getResponseRequestOrigin(rawResponse) + : undefined; + const responseIdentity = rawResponse + ? getResponseCredentialIdentity(rawResponse) + : undefined; const rawRegionUrl = response.data?.links?.regionUrl; - const regionUrl = rawRegionUrl - ? toAbsoluteRegionUrl(rawRegionUrl, baseUrl) - : baseUrl; + const regionUrl = getResolvedRegionUrl( + rawRegionUrl, + responseOrigin, + baseUrl + ); + if (!(responseOrigin && regionUrl)) { + return { cacheable: false, url: responseOrigin ?? baseUrl }; + } + if (responseIdentity !== credential.identity) { + return { cacheable: false, url: regionUrl }; + } // Cache for future use. setOrgRegion also extends the in-process // trust class so the subsequent request to this region passes the // fetch-layer guard without needing a separate registration call. - setOrgRegion(orgSlug, regionUrl); + setOrgRegion( + orgSlug, + regionUrl, + responseOrigin, + baseUrl, + credential.identity + ); - return regionUrl; + return { cacheable: true, url: regionUrl }; }); // Other errors (network, 404, etc.) fall back to default // This handles self-hosted instances without multi-region - return result.ok ? result.value : baseUrl; + return result.ok ? result.value : { cacheable: false, url: baseUrl }; } /** @@ -138,12 +191,9 @@ async function resolveOrgRegionUncached(orgSlug: string): Promise { * Returns false for self-hosted instances that don't have regional URLs. */ export function isMultiRegionEnabled(): boolean { - // Self-hosted instances (custom SENTRY_HOST/SENTRY_URL) typically don't have multi-region - const baseUrl = getConfiguredSentryUrl(); - if (baseUrl && !isSentrySaasUrl(baseUrl)) { - return false; - } - return true; + // Self-hosted instances, including those selected by the active token's + // host, typically do not have multiple regions. + return isSentrySaasUrl(getApiBaseUrl()); } /** @@ -156,8 +206,13 @@ export function isMultiRegionEnabled(): boolean { * @returns The resolved slug if found in cache, `undefined` on cache miss */ function resolveOrgFromCache(orgSlug: string): string | undefined { + const credential = getCredentialContext(); + if (!credential) { + return; + } + const sourceOrigin = getApiBaseUrl(credential); // Check if slug is directly cached - const cached = getOrgRegion(orgSlug); + const cached = getOrgRegion(orgSlug, sourceOrigin, credential.identity); if (cached) { return orgSlug; } @@ -165,7 +220,11 @@ function resolveOrgFromCache(orgSlug: string): string | undefined { // Try DSN-style numeric ID lookup (e.g., `o1081365` → `1081365` → slug) const numericId = stripDsnOrgPrefix(orgSlug); if (numericId !== orgSlug) { - const match = getOrgByNumericId(numericId); + const match = getOrgByNumericId( + numericId, + sourceOrigin, + credential.identity + ); if (match) { return match.slug; } diff --git a/packages/cli/src/lib/resolve-team.ts b/packages/cli/src/lib/resolve-team.ts index f32fc92f4..5a55d6ad5 100644 --- a/packages/cli/src/lib/resolve-team.ts +++ b/packages/cli/src/lib/resolve-team.ts @@ -32,7 +32,7 @@ import { } from "./errors.js"; import { logger } from "./logger.js"; import { resolveEffectiveOrg } from "./region.js"; -import { getSentryBaseUrl } from "./sentry-urls.js"; +import { getSentryBaseUrl } from "./sentry-web-urls.js"; const log = logger.withTag("resolve-team"); diff --git a/packages/cli/src/lib/response-cache.ts b/packages/cli/src/lib/response-cache.ts index f9246ee78..521679180 100644 --- a/packages/cli/src/lib/response-cache.ts +++ b/packages/cli/src/lib/response-cache.ts @@ -14,7 +14,7 @@ * @module */ -import { createHash, randomUUID } from "node:crypto"; +import { createHmac, randomUUID } from "node:crypto"; import { mkdir, readdir, @@ -119,11 +119,13 @@ export function classifyUrl(url: string): TtlTier { * * @internal Exported for testing */ -export function buildCacheKey(method: string, url: string): string { +export function buildCacheKey( + method: string, + url: string, + identity = getIdentityFingerprint() +): string { const normalized = normalizeUrl(method, url); - return createHash("sha256") - .update(`${getIdentityFingerprint()}|${normalized}`) - .digest("hex"); + return createHmac("sha256", identity).update(normalized).digest("hex"); } /** @@ -426,7 +428,8 @@ function isCacheWriteDisabled(): boolean { export async function getCachedResponse( method: string, url: string, - requestHeaders: Record + requestHeaders: Record, + identity = getIdentityFingerprint() ): Promise { if ( method !== "GET" || @@ -439,7 +442,7 @@ export async function getCachedResponse( let key: string; // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { - key = buildCacheKey(method, url); + key = buildCacheKey(method, url, identity); } catch { // Malformed URL — skip cache lookup. The request itself will surface // any real URL error. @@ -539,11 +542,13 @@ async function readCacheEntry(key: string): Promise { * @param requestHeaders - Request headers * @param response - The fetch Response to cache (must be cloned before passing) */ +// biome-ignore lint/nursery/useMaxParams: preserve the public cache API; the final identity pins an in-flight request. export async function storeCachedResponse( method: string, url: string, requestHeaders: Record, - response: Response + response: Response, + identity = getIdentityFingerprint() ): Promise { if ( method !== "GET" || @@ -557,7 +562,7 @@ export async function storeCachedResponse( let key: string; // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { - key = buildCacheKey(method, url); + key = buildCacheKey(method, url, identity); } catch { // Malformed URL — skip caching this response return; @@ -571,7 +576,7 @@ export async function storeCachedResponse( async (span) => { const size = await writeResponseToCache({ key, - identity: getIdentityFingerprint(), + identity, url, requestHeaders, response, @@ -699,7 +704,8 @@ async function writeResponseToCache(req: WriteRequest): Promise { * `identity` field and are treated as foreign. */ export async function invalidateCachedResponsesMatching( - prefix: string + prefix: string, + identity = getIdentityFingerprint() ): Promise { // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { @@ -710,18 +716,13 @@ export async function invalidateCachedResponsesMatching( return; } - const currentIdentity = getIdentityFingerprint(); - await cacheIO.map(jsonFiles, async (file) => { const filePath = join(cacheDir, file); // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { const raw = await readFile(filePath, "utf-8"); const entry = JSON.parse(raw) as CacheEntry; - if ( - entry.identity === currentIdentity && - entry.url?.startsWith(prefix) - ) { + if (entry.identity === identity && entry.url?.startsWith(prefix)) { // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. await unlink(filePath).catch(() => { /* another process may have deleted it */ diff --git a/packages/cli/src/lib/sdk-invoke.ts b/packages/cli/src/lib/sdk-invoke.ts index 7bcc3fdc7..e8a99d993 100644 --- a/packages/cli/src/lib/sdk-invoke.ts +++ b/packages/cli/src/lib/sdk-invoke.ts @@ -18,7 +18,7 @@ import { homedir } from "node:os"; import type { Span } from "@sentry/core"; import type { Writer } from "../types/index.js"; import { type AsyncChannel, createAsyncChannel } from "./async-channel.js"; -import { setEnv } from "./env.js"; +import { withEnv } from "./env.js"; import { SentryError, type SentryOptions } from "./sdk-types.js"; /** CLI flag names/aliases that trigger infinite streaming output. */ @@ -193,19 +193,6 @@ export function applyFlagDefaults( // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequences use ESC (0x1b) const ANSI_RE = /\x1b\[[0-9;]*m/g; -/** - * Install the structured `headers` option for this invocation. - * - * Lazy import: `custom-headers.ts` pulls in the SQLite defaults module, which - * must not load when the SDK is merely imported. - */ -async function applyHeadersOption( - headers: Record | undefined -): Promise { - const { setCustomHeadersOverride } = await import("./custom-headers.js"); - setCustomHeadersOverride(headers); -} - /** Flush Sentry telemetry (no beforeExit handler in library mode). */ async function flushTelemetry(): Promise { // biome-ignore lint/plugin: grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. @@ -415,51 +402,48 @@ async function executeWithCapture( ): Promise { const env = buildIsolatedEnv(options); const cwd = options?.cwd ?? process.cwd(); - setEnv(env); - - try { - await applyHeadersOption(options?.headers); - const captureCtx = await buildCaptureContext(env, cwd); - const { withTelemetry } = await import("./telemetry.js"); + return await withEnv(env, async () => { + const { withCustomHeadersOverride } = await import("./custom-headers.js"); + return await withCustomHeadersOverride(options?.headers, async () => { + const captureCtx = await buildCaptureContext(env, cwd); + const { withTelemetry } = await import("./telemetry.js"); - try { - await withTelemetry(async (span) => executor(captureCtx, span), { - libraryMode: true, - }); - } catch (thrown) { - await flushTelemetry(); + try { + await withTelemetry(async (span) => executor(captureCtx, span), { + libraryMode: true, + }); + } catch (thrown) { + await flushTelemetry(); + + // OutputError: data was already rendered (captured) before the throw. + // Return it despite the non-zero exit code — this is the "HTTP 404 body" + // pattern where the data is useful even though the operation "failed". + const captured = captureCtx.getCapturedResult(); + if (captured !== undefined) { + return captured as T; + } - // OutputError: data was already rendered (captured) before the throw. - // Return it despite the non-zero exit code — this is the "HTTP 404 body" - // pattern where the data is useful even though the operation "failed". - const captured = captureCtx.getCapturedResult(); - if (captured !== undefined) { - return captured as T; + const exitCode = + extractExitCode(thrown) || captureCtx.context.process.exitCode || 1; + throw buildSdkError(captureCtx.stderrChunks, exitCode, thrown); } - const exitCode = - extractExitCode(thrown) || captureCtx.context.process.exitCode || 1; - throw buildSdkError(captureCtx.stderrChunks, exitCode, thrown); - } + await flushTelemetry(); - await flushTelemetry(); + // Check exit code (Stricli sets it without throwing for some errors) + if (captureCtx.context.process.exitCode !== 0) { + throw buildSdkError( + captureCtx.stderrChunks, + captureCtx.context.process.exitCode + ); + } - // Check exit code (Stricli sets it without throwing for some errors) - if (captureCtx.context.process.exitCode !== 0) { - throw buildSdkError( - captureCtx.stderrChunks, - captureCtx.context.process.exitCode + return parseOutput( + captureCtx.getCapturedResult(), + captureCtx.stdoutChunks ); - } - - return parseOutput( - captureCtx.getCapturedResult(), - captureCtx.stdoutChunks - ); - } finally { - await applyHeadersOption(undefined); - setEnv(process.env); - } + }); + }); } /** @@ -495,47 +479,48 @@ function executeWithStream( }); // Fire-and-forget — command runs in background - (async () => { - const env = buildIsolatedEnv(options); + const env = buildIsolatedEnv(options); + const invocation = withEnv(env, async () => { const cwd = options?.cwd ?? process.cwd(); - setEnv(env); let captureCtx: CaptureContext | undefined; try { - await applyHeadersOption(options?.headers); - captureCtx = await buildCaptureContext(env, cwd, { - channel: channel as AsyncChannel, - abortSignal: controller.signal, - }); - - const { withTelemetry } = await import("./telemetry.js"); - - // biome-ignore lint/style/noNonNullAssertion: captureCtx is assigned on the line above - await withTelemetry(async (span) => executor(captureCtx!, span), { - libraryMode: true, - }); - - // Check exit code — Stricli sets it without throwing for some errors - if (captureCtx.context.process.exitCode !== 0) { - channel.error( - buildSdkError( - captureCtx.stderrChunks, - captureCtx.context.process.exitCode - ) - ); - } else { - // Drain any raw stdout the command wrote directly (via stdout.write) - // instead of yielding via captureObject — e.g. a binary Uint8Array - // body. Without this, those bytes accumulate in stdoutChunks and are - // dropped when the channel closes. No streaming-capable command emits - // binary today, but this keeps the streaming path faithful to the - // capture path (see parseOutput) if one ever does. - const trailing = parseOutput(undefined, captureCtx.stdoutChunks); - if (trailing !== undefined) { - channel.push(trailing); + const { withCustomHeadersOverride } = await import("./custom-headers.js"); + await withCustomHeadersOverride(options?.headers, async () => { + captureCtx = await buildCaptureContext(env, cwd, { + channel: channel as AsyncChannel, + abortSignal: controller.signal, + }); + + const { withTelemetry } = await import("./telemetry.js"); + + // biome-ignore lint/style/noNonNullAssertion: captureCtx is assigned on the line above + await withTelemetry(async (span) => executor(captureCtx!, span), { + libraryMode: true, + }); + + // Check exit code — Stricli sets it without throwing for some errors + if (captureCtx.context.process.exitCode !== 0) { + channel.error( + buildSdkError( + captureCtx.stderrChunks, + captureCtx.context.process.exitCode + ) + ); + } else { + // Drain any raw stdout the command wrote directly (via stdout.write) + // instead of yielding via captureObject — e.g. a binary Uint8Array + // body. Without this, those bytes accumulate in stdoutChunks and are + // dropped when the channel closes. No streaming-capable command emits + // binary today, but this keeps the streaming path faithful to the + // capture path (see parseOutput) if one ever does. + const trailing = parseOutput(undefined, captureCtx.stdoutChunks); + if (trailing !== undefined) { + channel.push(trailing); + } + channel.close(); } - channel.close(); - } + }); } catch (thrown) { const stderrChunks = captureCtx?.stderrChunks ?? []; const exitCode = @@ -547,10 +532,11 @@ function executeWithStream( channel.error(err); } finally { await flushTelemetry(); - await applyHeadersOption(undefined); - setEnv(process.env); } - })(); + }); + invocation.catch((error: unknown) => { + channel.error(error instanceof Error ? error : buildSdkError([], 1, error)); + }); return channel; } diff --git a/packages/cli/src/lib/seer-trial.ts b/packages/cli/src/lib/seer-trial.ts index e84c3d791..63c00b908 100644 --- a/packages/cli/src/lib/seer-trial.ts +++ b/packages/cli/src/lib/seer-trial.ts @@ -15,7 +15,7 @@ import { isatty } from "node:tty"; import { getProductTrials, startProductTrial } from "./api-client.js"; import { SeerError, type SeerErrorReason } from "./errors.js"; import { logger } from "./logger.js"; -import { buildBillingUrl } from "./sentry-urls.js"; +import { buildBillingUrl } from "./sentry-web-urls.js"; import { findAvailableTrial } from "./trials.js"; /** Seer error reasons eligible for trial prompt */ diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 893b459d8..93ee236e8 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -29,7 +29,13 @@ import { warnIfSaasWithEnvCa, } from "./custom-ca.js"; import { applyCustomHeaders } from "./custom-headers.js"; -import { getAuthToken, refreshToken } from "./db/auth.js"; +import { + type CredentialContext, + DEFAULT_TOKEN_LIFETIME_MS, + getCredentialContext, + REFRESH_THRESHOLD, + refreshToken, +} from "./db/auth.js"; import { ApiError, HostScopeError, @@ -43,13 +49,12 @@ import { invalidateCachedResponsesMatching, storeCachedResponse, } from "./response-cache.js"; -import { normalizeOrigin } from "./sentry-urls.js"; +import { normalizeHttpOrigin, normalizeOrigin } from "./sentry-urls.js"; import { withTracingSpan } from "./telemetry.js"; import { parseSntrysClaim } from "./token-claims.js"; import { - getActiveTokenHost, isHostTrustedForClaim, - isRequestOriginTrusted, + isRequestOriginTrustedForContext, } from "./token-host.js"; const log = logger.withTag("http"); @@ -86,6 +91,22 @@ const RETRY_MARKER_HEADER = "x-sentry-cli-retry"; /** Stamped on thrown errors caused by our own per-request timeout. */ const INTERNAL_TIMEOUT_MARKER = Symbol("sentry-cli:internal-timeout"); +const MAX_REDIRECTS = 20; +const responseRequestOrigins = new WeakMap(); +const responseCredentialIdentities = new WeakMap(); + +/** Provenance attached only after manually validated discovery requests. */ +export function getResponseRequestOrigin( + response: Response +): string | undefined { + return responseRequestOrigins.get(response); +} + +export function getResponseCredentialIdentity( + response: Response +): string | undefined { + return responseCredentialIdentities.get(response); +} /** Calculate exponential backoff delay, capped at MAX_BACKOFF_MS */ function backoffDelay(attempt: number): number { @@ -123,16 +144,16 @@ function isUserAbort(error: unknown, signal?: AbortSignal | null): boolean { function prepareHeaders( input: Request | string | URL, init: RequestInit | undefined, - token: string + credential: CredentialContext ): Headers { // Host-scoping guard (defense in depth). Primary rejection happens at the // URL-arg / rc-shim entry points; this catches any code path that mutated // SENTRY_HOST/SENTRY_URL without going through those guards. - if (!isRequestOriginTrusted(input)) { + if (!isRequestOriginTrustedForContext(input, credential)) { throw new HostScopeError( "Credentials", normalizeOrigin(input) ?? "", - getActiveTokenHost() + credential.host ); } @@ -140,9 +161,12 @@ function prepareHeaders( // multiple Sentry instances. The claim is unsigned (see token-claims.ts); // fail-open on parse errors. Uses isHostTrustedForClaim so multi-region // fan-out via the control silo's region URLs still works. - const normalizedToken = normalizeAuthToken(token); + const normalizedToken = normalizeAuthToken(credential.token); const claimUrl = parseSntrysClaim(normalizedToken)?.url; - if (claimUrl && !isHostTrustedForClaim(input, claimUrl)) { + if ( + claimUrl && + !isHostTrustedForClaim(input, claimUrl, credential.identity) + ) { throw new HostScopeError( "Credentials", normalizeOrigin(input) ?? "", @@ -175,7 +199,11 @@ function prepareHeaders( // Inject user-configured custom headers for self-hosted proxies (IAP, // mTLS, etc.) — scoped to the request URL. - applyCustomHeaders(headers, input); + applyCustomHeaders( + headers, + input, + isRequestOriginTrustedForContext(input, credential) + ); return headers; } @@ -184,7 +212,10 @@ function prepareHeaders( * Handle 401 response by refreshing the token. * @returns true if the token was refreshed and request should be retried */ -async function handleUnauthorized(headers: Headers): Promise { +async function handleUnauthorized( + headers: Headers, + credential: CredentialContext +): Promise { if (headers.get(RETRY_MARKER_HEADER)) { return false; } @@ -194,7 +225,10 @@ async function handleUnauthorized(headers: Headers): Promise { // no refresh token), `refreshed` is false and the 401 propagates. let newToken: string; try { - const result = await refreshToken({ force: true }); + const result = await refreshToken({ + force: true, + expectedCredential: credential, + }); if (!result.refreshed) { return false; } @@ -320,14 +354,20 @@ type AttemptResult = async function handleResponse( response: Response, headers: Headers, + credential: CredentialContext, isLastAttempt: boolean ): Promise { if (response.status === 401 && !isLastAttempt) { - const refreshed = await handleUnauthorized(headers); - return refreshed ? { action: "retry" } : { action: "done", response }; + const refreshed = await handleUnauthorized(headers, credential); + if (refreshed) { + await cancelResponseBody(response); + return { action: "retry" }; + } + return { action: "done", response }; } if (RETRYABLE_STATUS_CODES.includes(response.status) && !isLastAttempt) { + await cancelResponseBody(response); return { action: "retry" }; } @@ -345,6 +385,9 @@ function handleFetchError( signal: AbortSignal | undefined | null, isLastAttempt: boolean ): AttemptResult { + if (error instanceof HostScopeError) { + return { action: "throw", error }; + } if (isUserAbort(error, signal)) { return { action: "throw", error }; } @@ -411,12 +454,13 @@ function extractUrlPath(input: Request | string | URL): string { async function tryCacheHit( method: string, fullUrl: string, - requestHeaders: Record + requestHeaders: Record, + identity: string ): Promise { if (method !== "GET") { return; } - return await getCachedResponse(method, fullUrl, requestHeaders); + return await getCachedResponse(method, fullUrl, requestHeaders, identity); } /** @@ -427,11 +471,15 @@ async function tryCacheHit( * for future `Vary`-aware freshness checks. */ function cacheResponse( - method: string, - fullUrl: string, - requestHeaders: Record, + request: { + method: string; + fullUrl: string; + requestHeaders: Record; + identity: string; + }, response: Response ): void { + const { method, fullUrl, requestHeaders, identity } = request; if (method !== "GET" || !response.ok) { return; } @@ -441,7 +489,8 @@ function cacheResponse( method, fullUrl, requestHeaders, - response.clone() as Response + response.clone() as Response, + identity ).catch((error) => { log.debug("Response cache write failed", error); }); @@ -460,7 +509,8 @@ function cacheResponse( async function invalidateAfterMutation( method: string, fullUrl: string, - response: Response + response: Response, + identity: string ): Promise { if (method === "GET" || !response.ok) { return; @@ -468,7 +518,9 @@ async function invalidateAfterMutation( try { const prefixes = computeInvalidationPrefixes(fullUrl, getApiBaseUrl()); await Promise.all( - prefixes.map((prefix) => invalidateCachedResponsesMatching(prefix)) + prefixes.map((prefix) => + invalidateCachedResponsesMatching(prefix, identity) + ) ); } catch (error) { log.debug("Post-mutation cache invalidation failed", error); @@ -525,14 +577,45 @@ async function buildAttemptFactory( * Refreshes the auth token, then retries the request up to `MAX_RETRIES` times * with exponential backoff on transient errors. */ -async function fetchWithRetry( - input: Request | string | URL, - init: RequestInit | undefined, - method: string, - fullUrl: string -): Promise { - const { token } = await refreshToken(); - const headers = prepareHeaders(input, init, token); +type RetryContext = { + input: Request | string | URL; + init: RequestInit | undefined; + method: string; + fullUrl: string; + credential: CredentialContext; + validatedRedirects: boolean; +}; + +async function fetchWithRetry({ + input, + init, + method, + fullUrl, + credential, + validatedRedirects, +}: RetryContext): Promise { + const issuedAt = + credential.issuedAt ?? + (credential.expiresAt + ? credential.expiresAt - DEFAULT_TOKEN_LIFETIME_MS + : undefined); + const shouldRefresh = Boolean( + credential.refreshable && + credential.expiresAt && + (Date.now() >= credential.expiresAt || + (issuedAt && + (credential.expiresAt - Date.now()) / + (credential.expiresAt - issuedAt) <= + REFRESH_THRESHOLD)) + ); + const refreshed = shouldRefresh + ? await refreshToken({ expectedCredential: credential }) + : { token: credential.token }; + const effectiveCredential: CredentialContext = Object.freeze({ + ...credential, + token: refreshed.token, + }); + const headers = prepareHeaders(input, init, effectiveCredential); const attemptFactory = await buildAttemptFactory(input, init); const timeoutMs = resolveTimeoutMs(fullUrl); @@ -543,20 +626,28 @@ async function fetchWithRetry( input: attemptInput, init: attemptInit, headers, + credential: effectiveCredential, isLastAttempt, timeoutMs, + validatedRedirects, }); if (result.action === "done") { - // Use getAuthToken() instead of captured `token` — after a 401 refresh, - // handleUnauthorized stores a new token in the DB cacheResponse( + { + method, + fullUrl, + requestHeaders: { authorization: headers.get("Authorization") ?? "" }, + identity: credential.identity, + }, + result.response + ); + await invalidateAfterMutation( method, fullUrl, - authHeaders(getAuthToken()), - result.response + result.response, + credential.identity ); - await invalidateAfterMutation(method, fullUrl, result.response); return result.response; } if (result.action === "throw") { @@ -593,10 +684,14 @@ async function fetchWithRetry( * * @returns A fetch-compatible function for use with @sentry/api SDK functions */ -function createAuthenticatedFetch(): ( - input: Request | string | URL, - init?: RequestInit -) => Promise { +type AuthenticatedFetchOptions = { + credential?: CredentialContext; + validatedRedirects?: boolean; +}; + +function createAuthenticatedFetch( + options: AuthenticatedFetchOptions = {} +): (input: Request | string | URL, init?: RequestInit) => Promise { return function authenticatedFetch( input: Request | string | URL, init?: RequestInit @@ -625,11 +720,23 @@ function createAuthenticatedFetch(): ( // Check cache before auth/retry for GET requests. // Uses current token (no refresh) so lookups are fast but Vary-correct. - const cached = await tryCacheHit( - method, - fullUrl, - authHeaders(getAuthToken()) - ); + const credential = options.credential ?? getCredentialContext(); + if (!credential) { + await refreshToken(); + throw new Error( + "Authentication state was not available after refresh" + ); + } + // A synthetic HTTP cache response has no validated final-origin or + // credential provenance. Discovery must obtain both from the network. + const cached = options.validatedRedirects + ? undefined + : await tryCacheHit( + method, + fullUrl, + authHeaders(credential.token), + credential.identity + ); if (cached) { span.setAttribute("http.response.status_code", cached.status); log.debug( @@ -638,7 +745,14 @@ function createAuthenticatedFetch(): ( return cached; } - const response = await fetchWithRetry(input, init, method, fullUrl); + const response = await fetchWithRetry({ + input, + init, + method, + fullUrl, + credential, + validatedRedirects: options.validatedRedirects === true, + }); span.setAttribute("http.response.status_code", response.status); if (!response.ok) { span.setStatus({ code: 2, message: `${response.status}` }); @@ -660,26 +774,218 @@ type ExecuteAttemptArgs = { input: Request | string | URL; init: RequestInit | undefined; headers: Headers; + credential: CredentialContext; isLastAttempt: boolean; timeoutMs: number; + validatedRedirects: boolean; }; +async function cancelResponseBody(response: Response): Promise { + if (response.body && !response.bodyUsed) { + try { + await response.body.cancel(); + } catch (error) { + log.debug("Failed to cancel unused response body", error); + } + } +} + +function isRedirectStatus(status: number): boolean { + return ( + status === 301 || + status === 302 || + status === 303 || + status === 307 || + status === 308 + ); +} + +function redirectedMethod(method: string, status: number): string { + if (status === 303 && method !== "HEAD") { + return "GET"; + } + if ((status === 301 || status === 302) && method === "POST") { + return "GET"; + } + return method; +} + +function redirectLoopKey(url: URL): string { + const key = new URL(url); + key.hash = ""; + return key.href; +} + +async function rejectRedirect( + response: Response, + destination: string, + credential: CredentialContext +): Promise { + await cancelResponseBody(response); + throw new HostScopeError( + "Redirect destination", + destination, + credential.host + ); +} + +async function validatedRedirectTarget({ + response, + request, + credential, + visited, + hops, +}: { + response: Response; + request: Request; + credential: CredentialContext; + visited: Set; + hops: number; +}): Promise { + const location = response.headers.get("location"); + if (!location) { + return await rejectRedirect(response, "", credential); + } + if (hops >= MAX_REDIRECTS) { + return await rejectRedirect(response, "", credential); + } + let next: URL; + try { + next = new URL(location, request.url); + } catch (error) { + log.debug( + "Rejected malformed redirect location", + error instanceof Error ? error.name : "unknown error" + ); + return await rejectRedirect(response, "", credential); + } + if (!isRequestOriginTrustedForContext(next, credential)) { + return await rejectRedirect( + response, + normalizeOrigin(next) ?? "", + credential + ); + } + const key = redirectLoopKey(next); + if (visited.has(key)) { + return await rejectRedirect(response, "", credential); + } + visited.add(key); + return next; +} + +async function buildRedirectRequest( + request: Request, + requestHeaders: Headers, + next: URL, + status: number +): Promise<{ request: Request; headers: Headers }> { + const method = redirectedMethod(request.method.toUpperCase(), status); + const headers = new Headers(requestHeaders); + if (method !== request.method.toUpperCase()) { + headers.delete("content-length"); + headers.delete("content-type"); + headers.delete("transfer-encoding"); + } + const body = + method === request.method.toUpperCase() && request.body + ? await request.clone().arrayBuffer() + : undefined; + return { request: new Request(next, { method, headers, body }), headers }; +} + +/** Manually validate every hop before forwarding the pinned bearer and headers. */ +async function fetchFollowingValidatedRedirects({ + input, + init, + headers, + credential, + timeoutMs, +}: Omit< + ExecuteAttemptArgs, + "isLastAttempt" | "validatedRedirects" +>): Promise { + const original = new Request(input, init); + const visited = new Set([redirectLoopKey(new URL(original.url))]); + + async function follow( + request: Request, + requestHeaders: Headers, + hops: number + ): Promise { + if (!isRequestOriginTrustedForContext(request, credential)) { + throw new HostScopeError( + "Credentials", + normalizeOrigin(request) ?? "", + credential.host + ); + } + const response = await fetchWithTimeout({ + input: request.clone() as unknown as Request, + init: { headers: requestHeaders, redirect: "manual" }, + headers: requestHeaders, + externalSignal: init?.signal ?? original.signal, + timeoutMs, + }); + if (!isRedirectStatus(response.status)) { + const origin = normalizeHttpOrigin(request.url); + if (origin) { + responseRequestOrigins.set(response, origin); + responseCredentialIdentities.set(response, credential.identity); + } + return response; + } + const next = await validatedRedirectTarget({ + response, + request, + credential, + visited, + hops, + }); + + try { + const redirected = await buildRedirectRequest( + request, + requestHeaders, + next, + response.status + ); + await cancelResponseBody(response); + return await follow(redirected.request, redirected.headers, hops + 1); + } finally { + await cancelResponseBody(response); + } + } + + return await follow(original, new Headers(headers), 0); +} + async function executeAttempt({ input, init, headers, + credential, isLastAttempt, timeoutMs, + validatedRedirects, }: ExecuteAttemptArgs): Promise { try { - const response = await fetchWithTimeout({ - input, - init, - headers, - externalSignal: init?.signal, - timeoutMs, - }); - return handleResponse(response, headers, isLastAttempt); + const response = validatedRedirects + ? await fetchFollowingValidatedRedirects({ + input, + init, + headers, + credential, + timeoutMs, + }) + : await fetchWithTimeout({ + input, + init, + headers, + externalSignal: init?.signal, + timeoutMs, + }); + return handleResponse(response, headers, credential, isLastAttempt); } catch (error) { return handleFetchError(error, init?.signal, isLastAttempt); } @@ -692,9 +998,14 @@ let cachedFetch: typeof fetch | null = null; * Get the shared authenticated fetch instance. * Cast to `typeof fetch` for compatibility with @sentry/api SDK options. */ -function getAuthenticatedFetch(): typeof fetch { +function getAuthenticatedFetch( + options: AuthenticatedFetchOptions = {} +): typeof fetch { + if (options.credential || options.validatedRedirects) { + return createAuthenticatedFetch(options) as unknown as typeof fetch; + } if (!cachedFetch) { - cachedFetch = createAuthenticatedFetch() as unknown as typeof fetch; + cachedFetch = createAuthenticatedFetch(options) as unknown as typeof fetch; } return cachedFetch; } @@ -703,8 +1014,8 @@ function getAuthenticatedFetch(): typeof fetch { * Get the Sentry API base URL. * Supports self-hosted instances via SENTRY_URL env var. */ -export function getApiBaseUrl(): string { - return getConfiguredSentryUrl() ?? DEFAULT_SENTRY_URL; +export function getApiBaseUrl(credential = getCredentialContext()): string { + return getConfiguredSentryUrl() ?? credential?.host ?? DEFAULT_SENTRY_URL; } /** @@ -714,8 +1025,8 @@ export function getApiBaseUrl(): string { * Read lazily (not at module load) so that SENTRY_URL set after import * (e.g., from URL argument parsing for self-hosted instances) is respected. */ -export function getControlSiloUrl(): string { - return getConfiguredSentryUrl() ?? DEFAULT_SENTRY_URL; +export function getControlSiloUrl(credential = getCredentialContext()): string { + return getApiBaseUrl(credential); } /** @@ -735,7 +1046,10 @@ export function getControlSiloUrl(): string { * const result = await listOrganizations({ ...config }); * ``` */ -export function getSdkConfig(regionUrl: string) { +export function getSdkConfig( + regionUrl: string, + options: AuthenticatedFetchOptions = {} +) { const normalizedBase = regionUrl.endsWith("/") ? regionUrl.slice(0, -1) : regionUrl; @@ -744,7 +1058,7 @@ export function getSdkConfig(regionUrl: string) { // SDK functions already include /api/0/ in their URL paths, // so baseUrl should be the plain region URL without /api/0. baseUrl: normalizedBase, - fetch: getAuthenticatedFetch(), + fetch: getAuthenticatedFetch(options), throwOnError: false as const, }; } @@ -753,7 +1067,8 @@ export function getSdkConfig(regionUrl: string) { * Get SDK config for the default API (control silo or self-hosted). */ export function getDefaultSdkConfig() { - return getSdkConfig(getApiBaseUrl()); + const credential = getCredentialContext(); + return getSdkConfig(getApiBaseUrl(credential), { credential }); } /** @@ -761,7 +1076,8 @@ export function getDefaultSdkConfig() { * Used for endpoints that are always on the control silo (OAuth, user accounts, regions). */ export function getControlSdkConfig() { - return getSdkConfig(getControlSiloUrl()); + const credential = getCredentialContext(); + return getSdkConfig(getControlSiloUrl(credential), { credential }); } /** diff --git a/packages/cli/src/lib/sentry-urls.ts b/packages/cli/src/lib/sentry-urls.ts index ea950ded5..7fb27f6de 100644 --- a/packages/cli/src/lib/sentry-urls.ts +++ b/packages/cli/src/lib/sentry-urls.ts @@ -5,6 +5,7 @@ * Supports self-hosted instances via SENTRY_URL environment variable. */ +import { AsyncLocalStorage } from "node:async_hooks"; import { DEFAULT_SENTRY_HOST, DEFAULT_SENTRY_URL, @@ -12,12 +13,23 @@ import { normalizeUrl, } from "./constants.js"; +const HTTP_URL_RE = /^https?:\/\//i; +const TRAILING_SLASHES_RE = /\/+$/; +const scopedBaseUrl = new AsyncLocalStorage(); + +/** Render one web URL against the same credential host used for API calls. */ +export function withSentryBaseUrl(baseUrl: string, build: () => T): T { + return scopedBaseUrl.run(baseUrl, build); +} + /** * Get the Sentry web base URL. * Supports self-hosted instances via SENTRY_URL env var. */ export function getSentryBaseUrl(): string { - return getConfiguredSentryUrl() ?? DEFAULT_SENTRY_URL; + return ( + scopedBaseUrl.getStore() ?? getConfiguredSentryUrl() ?? DEFAULT_SENTRY_URL + ); } /** @@ -139,6 +151,52 @@ export function normalizeOrigin( } } +/** Normalize only credential-free HTTP(S) origins; allow root-relative URLs with a base. */ +export function normalizeHttpOrigin( + input: string | undefined | null, + base?: string +): string | undefined { + if (!input) { + return; + } + // biome-ignore lint/plugin: malformed external URLs are rejected with undefined by design. + try { + const parsed = base ? new URL(input, base) : new URL(input); + if ( + (parsed.protocol !== "http:" && parsed.protocol !== "https:") || + !parsed.hostname || + parsed.username || + parsed.password + ) { + return; + } + return parsed.origin; + } catch { + return; + } +} + +/** Validate an API-provided region URL while retaining an installation path. */ +export function normalizeRegionBaseUrl( + raw: string, + responseOrigin: string +): string | undefined { + if (!(raw.startsWith("/") || HTTP_URL_RE.test(raw))) { + return; + } + // biome-ignore lint/plugin: reject malformed region metadata without making discovery fail. + try { + const parsed = new URL(raw, responseOrigin); + const origin = normalizeHttpOrigin(parsed.href); + if (!origin || parsed.search || parsed.hash) { + return; + } + return `${origin}${parsed.pathname.replace(TRAILING_SLASHES_RE, "")}`; + } catch { + return; + } +} + /** * Normalize a user-supplied string (env var, CLI flag, rc file value) to a * canonical origin. Accepts bare hostnames (`sentry.acme.com`) by prefixing diff --git a/packages/cli/src/lib/sentry-web-urls.ts b/packages/cli/src/lib/sentry-web-urls.ts new file mode 100644 index 000000000..d61b58854 --- /dev/null +++ b/packages/cli/src/lib/sentry-web-urls.ts @@ -0,0 +1,113 @@ +/** Credential-aware web links without importing auth into the pure URL helpers. */ +import { getApiBaseUrl } from "./sentry-client.js"; +import { + buildBillingUrl as buildBillingUrlPure, + buildDashboardsListUrl as buildDashboardsListUrlPure, + buildDashboardUrl as buildDashboardUrlPure, + buildEventSearchUrl as buildEventSearchUrlPure, + buildIssueAlertsUrl as buildIssueAlertsUrlPure, + buildIssueUrl as buildIssueUrlPure, + buildLogsUrl as buildLogsUrlPure, + buildMetricAlertsUrl as buildMetricAlertsUrlPure, + buildOrgSettingsUrl as buildOrgSettingsUrlPure, + buildOrgUrl as buildOrgUrlPure, + buildProjectIssuesUrl as buildProjectIssuesUrlPure, + buildProjectUrl as buildProjectUrlPure, + buildReleaseUrl as buildReleaseUrlPure, + buildReplayUrl as buildReplayUrlPure, + buildSeerSettingsUrl as buildSeerSettingsUrlPure, + buildTraceUrl as buildTraceUrlPure, + getOrgBaseUrl as getOrgBaseUrlPure, + getSentryBaseUrl as getSentryBaseUrlPure, + isSaaS as isSaaSPure, + withSentryBaseUrl, +} from "./sentry-urls.js"; + +function render(build: () => T): T { + return withSentryBaseUrl(getApiBaseUrl(), build); +} + +export function getSentryBaseUrl(): string { + return render(getSentryBaseUrlPure); +} + +export function isSaaS(): boolean { + return render(isSaaSPure); +} + +export function getOrgBaseUrl(orgSlug: string): string { + return render(() => getOrgBaseUrlPure(orgSlug)); +} + +export function buildOrgUrl(orgSlug: string): string { + return render(() => buildOrgUrlPure(orgSlug)); +} + +export function buildProjectUrl(orgSlug: string, projectSlug: string): string { + return render(() => buildProjectUrlPure(orgSlug, projectSlug)); +} + +export function buildIssueUrl(orgSlug: string, issueId: string): string { + return render(() => buildIssueUrlPure(orgSlug, issueId)); +} + +export function buildEventSearchUrl(orgSlug: string, eventId: string): string { + return render(() => buildEventSearchUrlPure(orgSlug, eventId)); +} + +export function buildProjectIssuesUrl( + orgSlug: string, + projectId?: string +): string { + return render(() => buildProjectIssuesUrlPure(orgSlug, projectId)); +} + +export function buildOrgSettingsUrl(orgSlug: string, hash?: string): string { + return render(() => buildOrgSettingsUrlPure(orgSlug, hash)); +} + +export function buildSeerSettingsUrl(orgSlug: string): string { + return render(() => buildSeerSettingsUrlPure(orgSlug)); +} + +export function buildBillingUrl(orgSlug: string, product?: string): string { + return render(() => buildBillingUrlPure(orgSlug, product)); +} + +export function buildLogsUrl(orgSlug: string, logId?: string): string { + return render(() => buildLogsUrlPure(orgSlug, logId)); +} + +export function buildReplayUrl(orgSlug: string, replayId: string): string { + return render(() => buildReplayUrlPure(orgSlug, replayId)); +} + +export function buildDashboardsListUrl(orgSlug: string): string { + return render(() => buildDashboardsListUrlPure(orgSlug)); +} + +export function buildDashboardUrl( + orgSlug: string, + dashboardId: string +): string { + return render(() => buildDashboardUrlPure(orgSlug, dashboardId)); +} + +export function buildTraceUrl(orgSlug: string, traceId: string): string { + return render(() => buildTraceUrlPure(orgSlug, traceId)); +} + +export function buildIssueAlertsUrl( + orgSlug: string, + projectSlug?: string +): string { + return render(() => buildIssueAlertsUrlPure(orgSlug, projectSlug)); +} + +export function buildMetricAlertsUrl(orgSlug: string): string { + return render(() => buildMetricAlertsUrlPure(orgSlug)); +} + +export function buildReleaseUrl(orgSlug: string, version: string): string { + return render(() => buildReleaseUrlPure(orgSlug, version)); +} diff --git a/packages/cli/src/lib/token-host.ts b/packages/cli/src/lib/token-host.ts index efc753937..a76535a4f 100644 --- a/packages/cli/src/lib/token-host.ts +++ b/packages/cli/src/lib/token-host.ts @@ -15,10 +15,12 @@ * `sentry.acme.evil.com`). */ -import { getRawEnvToken, getUsableStoredTokenHost } from "./db/auth.js"; +import { + getActiveAuthHost, + getCredentialContext, + getIdentityFingerprint, +} from "./db/auth.js"; import { isTrustedRegionOrigin } from "./db/regions.js"; -import { getEnv } from "./env.js"; -import { getEnvTokenHost } from "./env-token-host.js"; import { isSaaSTrustOrigin, normalizeOrigin } from "./sentry-urls.js"; /** @@ -55,16 +57,7 @@ export function isHostTrusted( * token unless `SENTRY_FORCE_ENV_TOKEN` is set. */ export function getActiveTokenHost(): string | undefined { - const hasEnvToken = !!getRawEnvToken(); - const forceEnv = hasEnvToken && !!getEnv().SENTRY_FORCE_ENV_TOKEN?.trim(); - - if (!forceEnv) { - const storedHost = getUsableStoredTokenHost(); - if (storedHost) { - return storedHost; - } - } - return hasEnvToken ? getEnvTokenHost() : undefined; + return getActiveAuthHost(); } /** @@ -110,13 +103,17 @@ export function resetLoginTrustAnchorForTesting(): void { */ function isOriginTrustedFor( requestInput: string | URL | Request | undefined | null, - anchorHost: string + anchorHost: string, + identity: string ): boolean { if (isHostTrusted(requestInput, anchorHost)) { return true; } const requestOrigin = normalizeOrigin(requestInput); - return requestOrigin !== undefined && isTrustedRegionOrigin(requestOrigin); + return ( + requestOrigin !== undefined && + isTrustedRegionOrigin(requestOrigin, anchorHost, identity) + ); } /** @@ -126,11 +123,19 @@ function isOriginTrustedFor( export function isRequestOriginTrusted( requestInput: string | URL | Request | undefined | null ): boolean { - const tokenHost = getActiveTokenHost(); - if (!tokenHost) { + const credential = getCredentialContext(); + if (!credential) { return true; } - return isOriginTrustedFor(requestInput, tokenHost); + return isOriginTrustedFor(requestInput, credential.host, credential.identity); +} + +/** Evaluate the host trust against the credential captured for this request. */ +export function isRequestOriginTrustedForContext( + requestInput: string | URL | Request | undefined | null, + context: { host: string; identity: string } +): boolean { + return isOriginTrustedFor(requestInput, context.host, context.identity); } /** @@ -139,9 +144,10 @@ export function isRequestOriginTrusted( */ export function isHostTrustedForClaim( requestInput: string | URL | Request | undefined | null, - claimUrl: string + claimUrl: string, + identity = getIdentityFingerprint() ): boolean { - return isOriginTrustedFor(requestInput, claimUrl); + return isOriginTrustedFor(requestInput, claimUrl, identity); } /** diff --git a/packages/cli/test/commands/event/view.test.ts b/packages/cli/test/commands/event/view.test.ts index be1f7eead..7cd1bbf2b 100644 --- a/packages/cli/test/commands/event/view.test.ts +++ b/packages/cli/test/commands/event/view.test.ts @@ -1348,7 +1348,9 @@ describe("viewCommand.func", () => { traceId: null, success: false, }); - setOrgRegion("test-org", DEFAULT_SENTRY_URL); + // Resolution looks up the raw slug before the command reports its normalized form. + // Seed it so the warning test never attempts an unrelated network lookup. + setOrgRegion("test_org", DEFAULT_SENTRY_URL); const { context } = createMockContext(); const func = await viewCommand.loader(); diff --git a/packages/cli/test/lib/api/organizations-context.focused.test.ts b/packages/cli/test/lib/api/organizations-context.focused.test.ts new file mode 100644 index 000000000..5eddda392 --- /dev/null +++ b/packages/cli/test/lib/api/organizations-context.focused.test.ts @@ -0,0 +1,446 @@ +import { createHash } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + listOrganizations, + listOrganizationsUncached, +} from "../../../src/lib/api/organizations.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { + clearOrgRegions, + getCachedOrganizations, + getOrgRegion, + setOrgRegions, +} from "../../../src/lib/db/regions.js"; +import { + disableResponseCache, + resetCacheState, + storeCachedResponse, +} from "../../../src/lib/response-cache.js"; +import { resetAuthenticatedFetch } from "../../../src/lib/sentry-client.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("organizations-context-focused-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_CLIENT_ID", + "SENTRY_HOST", + "SENTRY_URL", +]); +const originalFetch = globalThis.fetch; + +function identity(token: string): string { + return createHash("sha256") + .update("oauth-access") + .update("\0") + .update(token) + .digest("hex"); +} + +describe("organization discovery credential context", () => { + beforeEach(() => { + for (const key of [ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_CLIENT_ID", + "SENTRY_HOST", + "SENTRY_URL", + ]) { + delete process.env[key]; + } + disableResponseCache(); + clearOrgRegions(); + resetAuthenticatedFetch(); + }); + afterEach(() => { + globalThis.fetch = originalFetch; + resetCacheState(); + resetAuthenticatedFetch(); + }); + + test("pins one credential across every pagination page", async () => { + setAuthToken("first-page-token", undefined, undefined, { + host: "https://sentry.io", + }); + const authorization: Array = []; + const requests = { count: 0 }; + globalThis.fetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + authorization.push(request.headers.get("authorization")); + requests.count += 1; + if (requests.count === 1) { + setAuthToken("concurrent-login-token", undefined, undefined, { + host: "https://sentry.io", + }); + return Response.json( + [ + { + id: "1", + slug: "page-one", + name: "Page One", + links: { regionUrl: "https://us.sentry.io" }, + }, + ], + { + headers: { + Link: '; rel="next"; results="true"; cursor="next"', + }, + } + ); + } + return Response.json([ + { + id: "2", + slug: "page-two", + name: "Page Two", + links: { regionUrl: "https://de.sentry.io" }, + }, + ]); + } + ); + + const organizations = await listOrganizationsUncached(); + expect(organizations.map((org) => org.slug)).toEqual([ + "page-one", + "page-two", + ]); + expect(authorization).toEqual([ + "Bearer first-page-token", + "Bearer first-page-token", + ]); + expect( + getOrgRegion( + "page-two", + "https://sentry.io", + identity("first-page-token") + ) + ).toBe("https://de.sentry.io"); + expect( + getOrgRegion( + "page-two", + "https://sentry.io", + identity("concurrent-login-token") + ) + ).toBeUndefined(); + }); + + test("keeps organization pages pinned through rotating OAuth refresh tokens", async () => { + process.env.SENTRY_CLIENT_ID = "test-client-id"; + setAuthToken("initial-access", 3600, "initial-refresh", { + host: "https://sentry.io", + }); + const requests = { refreshes: 0, pages: 0 }; + globalThis.fetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/oauth/token/")) { + requests.refreshes += 1; + return Response.json({ + access_token: `rotated-access-${requests.refreshes}`, + refresh_token: `rotated-refresh-${requests.refreshes}`, + expires_in: 3600, + token_type: "bearer", + }); + } + if (request.headers.get("authorization") === "Bearer initial-access") { + return new Response(null, { status: 401 }); + } + requests.pages += 1; + expect(request.headers.get("authorization")).toBe( + `Bearer rotated-access-${requests.refreshes}` + ); + return Response.json( + [ + { + id: String(requests.pages), + slug: `rotating-org-${requests.pages}`, + name: "Rotating", + }, + ], + requests.pages === 1 + ? { + headers: { + Link: '; rel="next"; results="true"; cursor="next"', + }, + } + : undefined + ); + } + ); + + const orgs = await listOrganizationsUncached(); + expect(orgs.map((org) => org.slug)).toEqual([ + "rotating-org-1", + "rotating-org-2", + ]); + expect(requests).toEqual({ refreshes: 2, pages: 2 }); + }); + + test("captures credentials before the initial cache lookup", async () => { + setAuthToken("first", undefined, undefined, { host: "https://sentry.io" }); + const authorization: Array = []; + globalThis.fetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + authorization.push( + new Request(input, init).headers.get("authorization") + ); + return Response.json([]); + } + ); + const pending = listOrganizations(); + setAuthToken("second", undefined, undefined, { host: "https://sentry.io" }); + await expect(pending).resolves.toEqual([]); + expect(authorization).toEqual(["Bearer first"]); + }); + + test.each([ + "ftp://region.example.com", + "https://user:password@region.example.com", + "not a URL", + ])("never persists malformed region metadata %s", async (regionUrl) => { + const token = `malformed-${createHash("sha256").update(regionUrl).digest("hex")}`; + setAuthToken(token, undefined, undefined, { host: "https://sentry.io" }); + globalThis.fetch = vi.fn(async () => + Response.json([ + { + id: "3", + slug: "malformed-region", + name: "Malformed Region", + links: { regionUrl }, + }, + ]) + ); + await listOrganizationsUncached(); + expect( + getOrgRegion("malformed-region", "https://sentry.io", identity(token)) + ).toBeUndefined(); + }); + + test("an invalid region among valid siblings never makes a partial org list authoritative", async () => { + const token = "mixed-region-token"; + setAuthToken(token, undefined, undefined, { host: "https://sentry.io" }); + const requests = { count: 0 }; + globalThis.fetch = vi.fn(async () => { + requests.count += 1; + return Response.json( + requests.count === 1 + ? [{ id: "11", slug: "valid-org", name: "Valid" }] + : [ + { id: "11", slug: "valid-org", name: "Valid" }, + { + id: "12", + slug: "invalid-org", + name: "Invalid Region", + links: { regionUrl: "ftp://region.example.com" }, + }, + ] + ); + }); + + expect((await listOrganizationsUncached()).map((org) => org.slug)).toEqual([ + "valid-org", + ]); + setOrgRegions([ + { + slug: "foreign-org", + regionUrl: "https://sentry.io", + cacheOrigin: "https://sentry.io", + identity: identity("another-token"), + orgId: "99", + orgName: "Other", + }, + ]); + expect((await listOrganizationsUncached()).map((org) => org.slug)).toEqual([ + "valid-org", + "invalid-org", + ]); + expect( + getOrgRegion("invalid-org", "https://sentry.io", identity(token)) + ).toBeUndefined(); + expect( + getOrgRegion("valid-org", "https://sentry.io", identity(token)) + ).toBe("https://sentry.io"); + expect( + getCachedOrganizations("https://sentry.io", identity(token)) + ).toEqual([]); + expect( + getCachedOrganizations( + "https://sentry.io", + identity("another-token") + ).map((org) => org.slug) + ).toEqual(["foreign-org"]); + expect((await listOrganizations()).map((org) => org.slug)).toEqual([ + "valid-org", + "invalid-org", + ]); + expect(requests.count).toBe(3); + }); + + test("an empty live org list invalidates previously cached membership", async () => { + const token = "empty-region-token"; + setAuthToken(token, undefined, undefined, { host: "https://sentry.io" }); + const requests = { count: 0 }; + globalThis.fetch = vi.fn(async () => { + requests.count += 1; + return Response.json( + requests.count === 1 + ? [{ id: "13", slug: "former-org", name: "Former" }] + : [] + ); + }); + + await listOrganizationsUncached(); + expect( + getCachedOrganizations("https://sentry.io", identity(token)) + ).toHaveLength(1); + await expect(listOrganizationsUncached()).resolves.toEqual([]); + await expect(listOrganizations()).resolves.toEqual([]); + expect(requests.count).toBe(3); + }); + + test("persists the exact validated final response origin", async () => { + setAuthToken("redirect-provenance-token", undefined, undefined, { + host: "https://sentry.io", + }); + const requests = { count: 0 }; + globalThis.fetch = vi.fn(async () => { + requests.count += 1; + return requests.count === 1 + ? Response.redirect("https://de.sentry.io/api/0/organizations/", 307) + : Response.json([ + { + id: "4", + slug: "redirected-org", + name: "Redirected Organization", + links: { regionUrl: "https://eu.sentry.io" }, + }, + ]); + }); + await listOrganizationsUncached(); + expect( + getDatabase() + .query( + "SELECT source_origin, response_origin, region_url FROM org_regions WHERE credential_identity = ? AND org_slug = ?" + ) + .get(identity("redirect-provenance-token"), "redirected-org") + ).toEqual({ + source_origin: "https://sentry.io", + response_origin: "https://de.sentry.io", + region_url: "https://eu.sentry.io", + }); + }); + + test("keeps a self-hosted installation path in organization region metadata", async () => { + setAuthToken("path-region-token", undefined, undefined, { + host: "https://sentry.example.com", + }); + globalThis.fetch = vi.fn(async () => + Response.json([ + { + id: "5", + slug: "path-region-org", + name: "Path Region", + links: { regionUrl: "https://sentry.example.com/sentry/" }, + }, + ]) + ); + + await listOrganizationsUncached(); + expect( + getOrgRegion( + "path-region-org", + "https://sentry.example.com", + identity("path-region-token") + ) + ).toBe("https://sentry.example.com/sentry"); + }); + + test("keeps the installation path when organization metadata omits regionUrl", async () => { + process.env.SENTRY_URL = "https://sentry.example.com/sentry"; + setAuthToken("pathless-region-token", undefined, undefined, { + host: "https://sentry.example.com", + }); + globalThis.fetch = vi.fn(async () => + Response.json([{ id: "6", slug: "pathless-org", name: "Pathless" }]) + ); + + await listOrganizationsUncached(); + expect( + getOrgRegion( + "pathless-org", + "https://sentry.example.com", + identity("pathless-region-token") + ) + ).toBe("https://sentry.example.com/sentry"); + }); + + test("a failed later page never makes an incomplete organization cache authoritative", async () => { + setAuthToken("partial-page-token", undefined, undefined, { + host: "https://sentry.io", + }); + const requests = { count: 0 }; + globalThis.fetch = vi.fn(async () => { + requests.count += 1; + if (requests.count === 1) { + return Response.json( + [{ id: "7", slug: "partial-org", name: "Partial" }], + { + headers: { + Link: '; rel="next"; results="true"; cursor="next"', + }, + } + ); + } + if (requests.count <= 4) { + throw new Error("later page failed"); + } + return Response.json([ + { id: "8", slug: "complete-org", name: "Complete" }, + ]); + }); + + await expect(listOrganizationsUncached()).rejects.toThrow( + "Failed to list organizations" + ); + expect( + getOrgRegion( + "partial-org", + "https://sentry.io", + identity("partial-page-token") + ) + ).toBeUndefined(); + expect((await listOrganizations()).map((org) => org.slug)).toEqual([ + "complete-org", + ]); + expect(requests.count).toBe(5); + }); + + test("discovery fetches a fresh response instead of trusting an unstamped HTTP cache hit", async () => { + const token = "http-cache-discovery-token"; + setAuthToken(token, undefined, undefined, { host: "https://sentry.io" }); + resetCacheState(); + await storeCachedResponse( + "GET", + "https://sentry.io/api/0/organizations/?per_page=100", + { authorization: `Bearer ${token}` }, + Response.json([{ id: "9", slug: "cached-org", name: "Cached" }], { + headers: { "Cache-Control": "public, max-age=300" }, + }), + identity(token) + ); + globalThis.fetch = vi.fn(async () => + Response.json([{ id: "10", slug: "fresh-org", name: "Fresh" }]) + ); + + const organizations = await listOrganizationsUncached(); + expect(organizations.map((org) => org.slug)).toEqual(["fresh-org"]); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect( + getOrgRegion("fresh-org", "https://sentry.io", identity(token)) + ).toBe("https://sentry.io"); + }); +}); diff --git a/packages/cli/test/lib/complete-context.focused.test.ts b/packages/cli/test/lib/complete-context.focused.test.ts new file mode 100644 index 000000000..9a292726d --- /dev/null +++ b/packages/cli/test/lib/complete-context.focused.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, expect, test } from "vitest"; +import { completeOrgSlugs } from "../../src/lib/complete.js"; +import { getCredentialContext, setAuthToken } from "../../src/lib/db/auth.js"; +import { clearOrgRegions, setOrgRegions } from "../../src/lib/db/regions.js"; +import { useEnvSandbox, useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("complete-context-focused-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + +beforeEach(() => { + for (const key of [ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + ]) { + delete process.env[key]; + } + clearOrgRegions(); +}); +afterEach(() => { + clearOrgRegions(); +}); + +test("suggests only organizations of the active credential on the lookup origin", () => { + setAuthToken("completion-token-a", undefined, undefined, { + host: "https://control.example.com", + }); + const identityA = getCredentialContext()?.identity; + setAuthToken("completion-token-b", undefined, undefined, { + host: "https://control.example.com", + }); + const identityB = getCredentialContext()?.identity; + if (!(identityA && identityB)) { + throw new Error("Test requires stored credentials"); + } + setOrgRegions([ + { + slug: "org-a", + regionUrl: "https://region-a.example.com", + sourceOrigin: "https://control.example.com", + cacheOrigin: "https://control.example.com", + identity: identityA, + orgId: "1", + orgName: "Organization A", + }, + { + slug: "org-b", + regionUrl: "https://region-b.example.com", + sourceOrigin: "https://control.example.com", + cacheOrigin: "https://control.example.com", + identity: identityB, + orgId: "2", + orgName: "Organization B", + }, + ]); + setAuthToken("completion-token-a", undefined, undefined, { + host: "https://control.example.com", + }); + expect(completeOrgSlugs("").map((completion) => completion.value)).toEqual([ + "org-a", + ]); +}); diff --git a/packages/cli/test/lib/custom-headers.test.ts b/packages/cli/test/lib/custom-headers.test.ts index 722e0d779..ec5f87ef7 100644 --- a/packages/cli/test/lib/custom-headers.test.ts +++ b/packages/cli/test/lib/custom-headers.test.ts @@ -18,7 +18,8 @@ import { setCustomHeadersOverride, } from "../../src/lib/custom-headers.js"; import { setDefaultHeaders } from "../../src/lib/db/defaults.js"; -import { useTestConfigDir } from "../helpers.js"; +import { resetEnvTokenHostForTesting } from "../../src/lib/env-token-host.js"; +import { mintSntrysToken, useTestConfigDir } from "../helpers.js"; // --------------------------------------------------------------------------- // parseCustomHeaders — parsing logic @@ -221,6 +222,32 @@ describe("getCustomHeaders", () => { expect(getCustomHeaders()).toEqual([["X-IAP-Token", "abc123"]]); }); + test("sends custom headers to a claim-routed self-hosted instance", () => { + const previousToken = process.env.SENTRY_AUTH_TOKEN; + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1, + url: "https://sentry.example.com", + }); + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: scoped-value"; + resetEnvTokenHostForTesting(); + try { + const trusted = new Headers(); + applyCustomHeaders(trusted, "https://sentry.example.com/api/0/"); + expect(trusted.get("X-IAP-Token")).toBe("scoped-value"); + + const other = new Headers(); + applyCustomHeaders(other, "https://other.example.com/api/0/"); + expect(other.get("X-IAP-Token")).toBeNull(); + } finally { + if (previousToken === undefined) { + delete process.env.SENTRY_AUTH_TOKEN; + } else { + process.env.SENTRY_AUTH_TOKEN = previousToken; + } + resetEnvTokenHostForTesting(); + } + }); + test("env var takes priority over SQLite defaults", () => { process.env.SENTRY_HOST = "https://sentry.example.com"; process.env.SENTRY_CUSTOM_HEADERS = "X-Env: from-env"; @@ -350,9 +377,6 @@ describe("applyCustomHeaders", () => { savedHeaders = process.env.SENTRY_CUSTOM_HEADERS; savedHost = process.env.SENTRY_HOST; _resetCustomHeadersCache(); - const { resetEnvTokenHostForTesting } = await import( - "../../src/lib/env-token-host.js" - ); resetEnvTokenHostForTesting(); }); @@ -368,9 +392,6 @@ describe("applyCustomHeaders", () => { delete process.env.SENTRY_HOST; } _resetCustomHeadersCache(); - const { resetEnvTokenHostForTesting } = await import( - "../../src/lib/env-token-host.js" - ); resetEnvTokenHostForTesting(); }); diff --git a/packages/cli/test/lib/db/auth-concurrency.focused.test.ts b/packages/cli/test/lib/db/auth-concurrency.focused.test.ts new file mode 100644 index 000000000..8c91d4a6d --- /dev/null +++ b/packages/cli/test/lib/db/auth-concurrency.focused.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + clearAuth, + getAuthConfig, + getCredentialContext, + refreshToken, + setAuthToken, +} from "../../../src/lib/db/auth.js"; +import { ConfigError } from "../../../src/lib/errors.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("auth-refresh-cas-"); +useEnvSandbox([ + "SENTRY_CLIENT_ID", + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", +]); +const originalFetch = globalThis.fetch; + +function deferred() { + const state: { resolve?: (value: T) => void } = {}; + const promise = new Promise((resolve) => { + state.resolve = resolve; + }); + return { promise, resolve: (value: T) => state.resolve?.(value) }; +} + +beforeEach(async () => { + process.env.SENTRY_CLIENT_ID = "test-client-id"; + delete process.env.SENTRY_AUTH_TOKEN; + delete process.env.SENTRY_TOKEN; + delete process.env.SENTRY_FORCE_ENV_TOKEN; + await clearAuth(); +}); +afterEach(async () => { + globalThis.fetch = originalFetch; + await clearAuth(); +}); + +function startPausedRefresh(response: ReturnType>) { + const started = deferred(); + globalThis.fetch = async (input) => { + started.resolve(new URL(new Request(input).url)); + return await response.promise; + }; + setAuthToken("old-access", 3600, "old-refresh", { + host: "https://old.example.com", + }); + return { pending: refreshToken({ force: true }), started: started.promise }; +} + +describe("OAuth refresh compare-and-swap", () => { + test("refresh-token rotation keeps a pinned request in its session without accepting a new login", async () => { + setAuthToken("first-access", 3600, "first-refresh", { + host: "https://control.example.com", + }); + const pinned = getCredentialContext(); + expect(pinned).toBeDefined(); + const requests = { count: 0 }; + globalThis.fetch = async () => { + requests.count += 1; + return Response.json({ + access_token: `rotated-access-${requests.count}`, + refresh_token: `rotated-refresh-${requests.count}`, + expires_in: 3600, + token_type: "bearer", + }); + }; + + await refreshToken({ force: true, expectedCredential: pinned }); + await expect( + refreshToken({ force: true, expectedCredential: pinned }) + ).resolves.toMatchObject({ + token: "rotated-access-2", + refreshed: true, + }); + expect(requests.count).toBe(2); + + setAuthToken("other-access", 3600, "other-refresh", { + host: "https://control.example.com", + }); + await expect( + refreshToken({ force: true, expectedCredential: pinned }) + ).rejects.toThrow("Active credentials changed"); + expect(requests.count).toBe(2); + }); + + test("a late successful refresh never overwrites a newer login", async () => { + const response = deferred(); + const { pending, started } = startPausedRefresh(response); + expect((await started).origin).toBe("https://old.example.com"); + setAuthToken("new-access", 3600, "new-refresh", { + host: "https://new.example.com", + }); + response.resolve( + Response.json({ + access_token: "late-access", + refresh_token: "late-refresh", + expires_in: 3600, + token_type: "bearer", + }) + ); + await expect(pending).rejects.toThrow("Active credentials changed"); + expect(getAuthConfig()).toMatchObject({ + token: "new-access", + refreshToken: "new-refresh", + }); + expect(getCredentialContext()?.host).toBe("https://new.example.com"); + }); + + test("a late invalid_grant never clears a newer login", async () => { + const response = deferred(); + const { pending, started } = startPausedRefresh(response); + await started; + setAuthToken("new-access", 3600, "new-refresh", { + host: "https://new.example.com", + }); + response.resolve( + Response.json( + { error: "invalid_grant", error_description: "Refresh rejected" }, + { status: 400 } + ) + ); + await expect(pending).rejects.toThrow("refresh credential was rejected"); + expect(getAuthConfig()).toMatchObject({ + token: "new-access", + refreshToken: "new-refresh", + }); + expect(getCredentialContext()?.host).toBe("https://new.example.com"); + }); + + test("an unexpected refresh failure retains the stored credential", async () => { + setAuthToken("current-access", 3600, "current-refresh", { + host: "https://control.example.com", + }); + globalThis.fetch = async (input, init) => { + expect(new Request(input).url).toBe( + "https://control.example.com/oauth/token/" + ); + expect(init?.redirect).toBe("error"); + return new Response("temporary server failure", { status: 503 }); + }; + + await expect(refreshToken({ force: true })).rejects.toThrow( + "Token refresh failed" + ); + expect(getAuthConfig()).toMatchObject({ + token: "current-access", + refreshToken: "current-refresh", + }); + }); + + test("an invalid explicit credential host never falls back to SaaS", () => { + setAuthToken("current-access", undefined, undefined, { + host: "https://control.example.com", + }); + expect(() => + setAuthToken("replacement-access", undefined, undefined, { + host: "https://user:password@evil.example.net", + }) + ).toThrow(ConfigError); + expect(getAuthConfig()?.token).toBe("current-access"); + expect(getCredentialContext()?.host).toBe("https://control.example.com"); + }); +}); diff --git a/packages/cli/test/lib/db/auth.host.test.ts b/packages/cli/test/lib/db/auth.host.test.ts index 1cab411fd..5f96236ff 100644 --- a/packages/cli/test/lib/db/auth.host.test.ts +++ b/packages/cli/test/lib/db/auth.host.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest"; import { + getIdentityFingerprint, getStoredAuthHost, hasUsableStoredToken, setAuthToken, @@ -22,6 +23,13 @@ describe("db/auth host scoping", () => { expect(getStoredAuthHost()).toBe("https://sentry.acme.com"); }); + test("uses a full collision-resistant credential namespace fingerprint", () => { + setAuthToken("opaque-access", undefined, undefined, { + host: "https://sentry.example.com", + }); + expect(getIdentityFingerprint()).toMatch(/^[0-9a-f]{64}$/); + }); + test("setAuthToken normalizes host (lowercases + strips trailing slash)", () => { setAuthToken("tok-1", undefined, undefined, { host: "https://SENTRY.Acme.com/", diff --git a/packages/cli/test/lib/db/auth.test.ts b/packages/cli/test/lib/db/auth.test.ts index a06d48bff..f2cfa6b5f 100644 --- a/packages/cli/test/lib/db/auth.test.ts +++ b/packages/cli/test/lib/db/auth.test.ts @@ -251,11 +251,11 @@ describe("getIdentityFingerprint", () => { expect(getIdentityFingerprint()).toBe(ANON_IDENTITY); }); - test("returns a stable 16-char hex fingerprint for a given env token", () => { + test("returns a stable full SHA-256 fingerprint for a given env token", () => { process.env.SENTRY_AUTH_TOKEN = "sntrys_alice"; const fp1 = getIdentityFingerprint(); const fp2 = getIdentityFingerprint(); - expect(fp1).toMatch(/^[0-9a-f]{16}$/); + expect(fp1).toMatch(/^[0-9a-f]{64}$/); expect(fp1).toBe(fp2); }); diff --git a/packages/cli/test/lib/db/model-based.test.ts b/packages/cli/test/lib/db/model-based.test.ts index 1091f2b8d..a5340a092 100644 --- a/packages/cli/test/lib/db/model-based.test.ts +++ b/packages/cli/test/lib/db/model-based.test.ts @@ -35,10 +35,12 @@ import { clearAuth, getAuthConfig, getAuthToken, + getIdentityFingerprint, isAuthenticated, isEnvTokenActive, resetAuthRowCache, resetAuthTokenCache, + resetIdentityFingerprintCache, setAuthToken, } from "../../../src/lib/db/auth.js"; import { @@ -82,7 +84,7 @@ type DbModel = { envAuthToken: string | null; /** Simulated SENTRY_TOKEN env var (null = unset) */ envSentryToken: string | null; - regions: Map; + regions: Map>; aliases: { entries: Map; fingerprint: string | null; @@ -305,6 +307,7 @@ class SetEnvAuthTokenCommand implements AsyncCommand { // Env mutation bypasses setAuthToken's invalidation. resetAuthTokenCache(); resetAuthRowCache(); + resetIdentityFingerprintCache(); // Model stores trimmed value — matches real getEnvToken() which trims const trimmed = this.token.trim(); model.envAuthToken = trimmed || null; @@ -320,6 +323,7 @@ class ClearEnvAuthTokenCommand implements AsyncCommand { delete process.env.SENTRY_AUTH_TOKEN; resetAuthTokenCache(); resetAuthRowCache(); + resetIdentityFingerprintCache(); model.envAuthToken = null; } @@ -339,6 +343,7 @@ class SetEnvSentryTokenCommand implements AsyncCommand { process.env.SENTRY_TOKEN = this.token; resetAuthTokenCache(); resetAuthRowCache(); + resetIdentityFingerprintCache(); // Model stores trimmed value — matches real getEnvToken() which trims const trimmed = this.token.trim(); model.envSentryToken = trimmed || null; @@ -354,6 +359,7 @@ class ClearEnvSentryTokenCommand implements AsyncCommand { delete process.env.SENTRY_TOKEN; resetAuthTokenCache(); resetAuthRowCache(); + resetIdentityFingerprintCache(); model.envSentryToken = null; } @@ -375,6 +381,13 @@ class IsEnvTokenActiveCommand implements AsyncCommand { // Region Commands +function activeModelRegions(model: DbModel): Map { + const identity = getIdentityFingerprint(); + const regions = model.regions.get(identity) ?? new Map(); + model.regions.set(identity, regions); + return regions; +} + class SetOrgRegionCommand implements AsyncCommand { readonly orgSlug: string; readonly regionUrl: string; @@ -388,7 +401,7 @@ class SetOrgRegionCommand implements AsyncCommand { async run(model: DbModel, _real: RealDb): Promise { setOrgRegion(this.orgSlug, this.regionUrl); - model.regions.set(this.orgSlug, this.regionUrl); + activeModelRegions(model).set(this.orgSlug, this.regionUrl); } toString(): string { @@ -407,7 +420,7 @@ class GetOrgRegionCommand implements AsyncCommand { async run(model: DbModel, _real: RealDb): Promise { const realRegion = getOrgRegion(this.orgSlug); - const expectedRegion = model.regions.get(this.orgSlug); + const expectedRegion = activeModelRegions(model).get(this.orgSlug); expect(realRegion).toBe(expectedRegion); } @@ -432,7 +445,7 @@ class SetOrgRegionsCommand implements AsyncCommand { ); for (const [orgSlug, regionUrl] of this.entries) { - model.regions.set(orgSlug, regionUrl); + activeModelRegions(model).set(orgSlug, regionUrl); } } @@ -446,10 +459,11 @@ class GetAllOrgRegionsCommand implements AsyncCommand { async run(model: DbModel, _real: RealDb): Promise { const realRegions = getAllOrgRegions(); + const expectedRegions = activeModelRegions(model); - expect(realRegions.size).toBe(model.regions.size); + expect(realRegions.size).toBe(expectedRegions.size); - for (const [orgSlug, regionUrl] of model.regions) { + for (const [orgSlug, regionUrl] of expectedRegions) { expect(realRegions.get(orgSlug)).toBe(regionUrl); } } @@ -790,6 +804,7 @@ describe("model-based: database layer", () => { delete process.env.SENTRY_TOKEN; resetAuthTokenCache(); resetAuthRowCache(); + resetIdentityFingerprintCache(); try { const setup = () => ({ model: createEmptyModel(), diff --git a/packages/cli/test/lib/db/regions.focused.test.ts b/packages/cli/test/lib/db/regions.focused.test.ts new file mode 100644 index 000000000..eb41a562e --- /dev/null +++ b/packages/cli/test/lib/db/regions.focused.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; +import { + getOrgRegion, + isTrustedRegionOrigin, + resetTrustedRegionUrlsForTesting, + setOrgRegion, +} from "../../../src/lib/db/regions.js"; +import { useTestConfigDir } from "../../helpers.js"; + +const SOURCE_A = "https://control-a.example.com"; +const SOURCE_B = "https://control-b.example.com"; +const RESPONSE_B = "https://response-b.example.com"; +const REGION_A = "https://region-a.example.com"; +const REGION_B = "https://region-b.example.com"; +const REGION_C = "https://region-c.example.com"; +const IDENTITY_A = "identity-a"; +const IDENTITY_B = "identity-b"; + +useTestConfigDir("regions-focused-"); + +describe("credential and origin scoped organization regions", () => { + test("keeps identical slugs separate by lookup origin and credential", () => { + setOrgRegion("shared", REGION_A, SOURCE_A, SOURCE_A, IDENTITY_A); + setOrgRegion("shared", REGION_B, SOURCE_B, SOURCE_B, IDENTITY_A); + setOrgRegion("shared", REGION_C, SOURCE_A, SOURCE_A, IDENTITY_B); + expect(getOrgRegion("shared", SOURCE_A, IDENTITY_A)).toBe(REGION_A); + expect(getOrgRegion("shared", SOURCE_B, IDENTITY_A)).toBe(REGION_B); + expect(getOrgRegion("shared", SOURCE_A, IDENTITY_B)).toBe(REGION_C); + }); + + test("keeps self-hosted installation paths without extending origin trust", () => { + setOrgRegion( + "subpath-org", + `${REGION_A}/sentry/`, + SOURCE_A, + SOURCE_A, + IDENTITY_A + ); + expect(getOrgRegion("subpath-org", SOURCE_A, IDENTITY_A)).toBe( + `${REGION_A}/sentry` + ); + expect(isTrustedRegionOrigin(REGION_A, SOURCE_A, IDENTITY_A)).toBe(true); + expect(isTrustedRegionOrigin(REGION_A, SOURCE_A, IDENTITY_B)).toBe(false); + }); + + test("retains lookup to response to region trust for only the right credential", () => { + setOrgRegion("bridge", RESPONSE_B, SOURCE_A, SOURCE_A, IDENTITY_A); + setOrgRegion("target", REGION_C, RESPONSE_B, SOURCE_A, IDENTITY_A); + resetTrustedRegionUrlsForTesting(); + expect(isTrustedRegionOrigin(RESPONSE_B, SOURCE_A, IDENTITY_A)).toBe(true); + expect(isTrustedRegionOrigin(REGION_C, RESPONSE_B, IDENTITY_A)).toBe(true); + expect(isTrustedRegionOrigin(REGION_C, SOURCE_A, IDENTITY_A)).toBe(true); + expect(isTrustedRegionOrigin(REGION_C, SOURCE_A, IDENTITY_B)).toBe(false); + }); + + test.each([ + "ftp://region.example.com", + "https://user:password@region.example.com", + "not a URL", + ])("rejects untrusted region URL %s", (regionUrl) => { + expect(() => + setOrgRegion("invalid", regionUrl, SOURCE_A, SOURCE_A, IDENTITY_A) + ).toThrow(); + }); +}); diff --git a/packages/cli/test/lib/db/schema-v17.focused.test.ts b/packages/cli/test/lib/db/schema-v17.focused.test.ts new file mode 100644 index 000000000..b2df386e9 --- /dev/null +++ b/packages/cli/test/lib/db/schema-v17.focused.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "vitest"; +import { closeDatabase, getDatabase } from "../../../src/lib/db/index.js"; +import { CURRENT_SCHEMA_VERSION } from "../../../src/lib/db/schema.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("schema-v17-focused-"); + +describe("schema v17 organization region provenance", () => { + test("uses a strict credential, lookup-origin, and slug primary key", () => { + expect(CURRENT_SCHEMA_VERSION).toBe(17); + const columns = getDatabase() + .query("PRAGMA table_info(org_regions)") + .all() as Array<{ + name: string; + notnull: number; + pk: number; + }>; + const byName = new Map(columns.map((column) => [column.name, column])); + expect(byName.get("credential_identity")).toMatchObject({ + notnull: 1, + pk: 1, + }); + expect(byName.get("source_origin")).toMatchObject({ notnull: 1, pk: 2 }); + expect(byName.get("org_slug")).toMatchObject({ notnull: 1, pk: 3 }); + expect(byName.get("response_origin")).toMatchObject({ notnull: 1, pk: 0 }); + }); + + test("discards v16 rows whose provenance is unknowable", () => { + const db = getDatabase(); + db.exec("DROP TABLE org_regions"); + db.exec(`CREATE TABLE org_regions ( + org_slug TEXT PRIMARY KEY, region_url TEXT NOT NULL, org_id TEXT, + org_name TEXT, org_role TEXT, updated_at INTEGER NOT NULL + )`); + db.query( + "INSERT INTO org_regions (org_slug, region_url, updated_at) VALUES (?, ?, ?)" + ).run("legacy", "https://legacy.example.com", Date.now()); + db.query("UPDATE schema_version SET version = 16").run(); + closeDatabase(); + const migrated = getDatabase(); + expect( + migrated.query("SELECT COUNT(*) AS count FROM org_regions").get() + ).toEqual({ count: 0 }); + expect(migrated.query("SELECT version FROM schema_version").get()).toEqual({ + version: 17, + }); + }); +}); diff --git a/packages/cli/test/lib/issue-1568-routing.focused.test.ts b/packages/cli/test/lib/issue-1568-routing.focused.test.ts new file mode 100644 index 000000000..3fcd1da95 --- /dev/null +++ b/packages/cli/test/lib/issue-1568-routing.focused.test.ts @@ -0,0 +1,47 @@ +import { Buffer } from "node:buffer"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import { getConfiguredSentryUrl } from "../../src/lib/constants.js"; +import { + getEnvTokenHost, + resetEnvTokenHostForTesting, +} from "../../src/lib/env-token-host.js"; +import { ConfigError } from "../../src/lib/errors.js"; +import { useEnvSandbox } from "../helpers.js"; + +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + +beforeEach(() => { + for (const key of [ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + ]) { + delete process.env[key]; + } + resetEnvTokenHostForTesting(); +}); +afterEach(() => { + resetEnvTokenHostForTesting(); +}); + +function orgToken(url: string): string { + const claim = Buffer.from(JSON.stringify({ iat: 1, url })).toString("base64"); + return `sntrys_${claim}_secret`; +} + +test("invalid SENTRY_HOST never falls through to a lower-priority URL", () => { + process.env.SENTRY_HOST = "https://user:password@example.com"; + process.env.SENTRY_URL = "https://lower-priority.example.com"; + expect(() => getConfiguredSentryUrl()).toThrow(ConfigError); +}); + +test("an invalid active token claim never falls through to SaaS", () => { + process.env.SENTRY_AUTH_TOKEN = orgToken("ftp://invalid.example.com"); + expect(() => getEnvTokenHost()).toThrow(ConfigError); +}); diff --git a/packages/cli/test/lib/sdk-invoke-isolation.focused.test.ts b/packages/cli/test/lib/sdk-invoke-isolation.focused.test.ts new file mode 100644 index 000000000..9dea3f4f2 --- /dev/null +++ b/packages/cli/test/lib/sdk-invoke-isolation.focused.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +type HandlerContext = { stdout: { captureObject?: (value: unknown) => void } }; +type Barrier = ReturnType>; +const invocationState = vi.hoisted(() => ({ + handler: undefined as + | (( + context: HandlerContext, + flags: Record + ) => Promise) + | undefined, +})); + +vi.mock("../../src/app.js", () => { + const command = { + parameters: { flags: {} }, + loader: async () => + async function focusedCommand( + this: HandlerContext, + flags: Record + ): Promise { + if (!invocationState.handler) { + throw new Error("Missing invocation handler"); + } + await invocationState.handler(this, flags); + }, + }; + const route = { + getRoutingTargetForInput: (segment: string) => + segment === "probe" ? command : undefined, + }; + return { + routes: { + getRoutingTargetForInput: (segment: string) => + segment === "focused" ? route : undefined, + }, + }; +}); +vi.mock("../../src/lib/telemetry.js", () => ({ + setCommandSpanName: vi.fn(), + withTelemetry: async ( + callback: (span: undefined) => Promise + ): Promise => await callback(undefined), +})); +vi.mock("@sentry/node-core/light", () => ({ getClient: () => null })); + +import { getCustomHeaders } from "../../src/lib/custom-headers.js"; +import { getEnv } from "../../src/lib/env.js"; +import { buildInvoker } from "../../src/lib/sdk-invoke.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("sdk-invoke-isolation-focused-"); + +type InvocationResult = { header?: string; host?: string; token?: string }; + +describe("overlapping SDK invocation isolation", () => { + beforeEach(() => { + invocationState.handler = undefined; + }); + afterEach(() => { + invocationState.handler = undefined; + }); + + test("keeps environment and structured headers invocation-local", async () => { + const started = new Map(); + const release = new Map(); + for (const id of ["first", "second"]) { + started.set(id, Promise.withResolvers()); + release.set(id, Promise.withResolvers()); + } + invocationState.handler = async (context, flags) => { + const id = String(flags.id); + started.get(id)?.resolve(); + await release.get(id)?.promise; + context.stdout.captureObject?.({ + header: getCustomHeaders().find( + ([name]) => name === "X-Invocation" + )?.[1], + host: getEnv().SENTRY_HOST, + token: getEnv().SENTRY_AUTH_TOKEN, + } satisfies InvocationResult); + }; + + const first = buildInvoker({ + token: "token-first", + url: "https://first.example.com", + headers: { "X-Invocation": "first" }, + })(["focused", "probe"], { id: "first" }, []) as Promise; + await started.get("first")?.promise; + const second = buildInvoker({ + token: "token-second", + url: "https://second.example.com", + headers: { "X-Invocation": "second" }, + })(["focused", "probe"], { id: "second" }, []) as Promise; + await started.get("second")?.promise; + release.get("first")?.resolve(); + expect(await first).toEqual({ + header: "first", + host: "https://first.example.com", + token: "token-first", + }); + release.get("second")?.resolve(); + expect(await second).toEqual({ + header: "second", + host: "https://second.example.com", + token: "token-second", + }); + }); +}); diff --git a/packages/cli/test/lib/security/refresh-token-poison.test.ts b/packages/cli/test/lib/security/refresh-token-poison.test.ts index f3f6608ec..d2203a1c7 100644 --- a/packages/cli/test/lib/security/refresh-token-poison.test.ts +++ b/packages/cli/test/lib/security/refresh-token-poison.test.ts @@ -5,13 +5,14 @@ * `env.SENTRY_URL` before the next OAuth refresh fires, the refresh token * would previously be POSTed to the attacker's `/oauth/token/` endpoint. * - * Fix: `refreshAccessToken` calls `assertRefreshHostTrusted()` before - * building the request body, which throws `CliError` on mismatch. + * Fix: `refreshAccessToken` uses the captured credential host, never the + * mutable environment URL. Fetch refuses redirects of the refresh request. */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { captureEnvTokenHost, + getEnvTokenHost, resetEnvTokenHostForTesting, } from "../../../src/lib/env-token-host.js"; import { refreshAccessToken } from "../../../src/lib/oauth.js"; @@ -44,33 +45,25 @@ describe("CVE defense-in-depth: refresh token", () => { globalThis.fetch = originalFetch; }); - test("refreshAccessToken throws before fetch when env.SENTRY_URL is poisoned after boot", async () => { + test("refreshAccessToken never sends a refresh token to an env-poisoned URL", async () => { // Step 1: simulate boot — capture env-token-host with no SENTRY_URL set // (defaults to SaaS, matching a user who got SENTRY_AUTH_TOKEN from their // shell without configuring SENTRY_HOST). resetEnvTokenHostForTesting(); captureEnvTokenHost(); // snapshots → SaaS default + const credentialHost = getEnvTokenHost(); // Step 2: simulate the bypass — something writes env.SENTRY_URL AFTER // the snapshot. This is the attack shape: env got poisoned by a // code path that skipped the URL-arg / rc-shim guards. process.env.SENTRY_URL = "https://evil.com"; - // `refreshAccessToken` throws synchronously from its host-scope guard - // (before returning the promise from withHttpSpan). Handle both shapes. - let thrown: unknown; - try { - await refreshAccessToken("fake-refresh-token"); - } catch (err) { - thrown = err; - } - expect(thrown).toBeInstanceOf(Error); - expect((thrown as Error).message).toMatch( - /does not match|sentry auth login --url/ - ); + await expect( + refreshAccessToken("fake-refresh-token", { credentialHost }) + ).rejects.toThrow(/unexpected fetch|Cannot connect|fetch failed/); - // Critical: zero outbound requests to evil.com (or anywhere). - expect(fetchCalls).toEqual([]); + // The captured host remains the only destination; evil.com sees nothing. + expect(fetchCalls).toEqual(["https://sentry.io/oauth/token/"]); }); test("refreshAccessToken proceeds when URL matches token scope", async () => { @@ -84,12 +77,36 @@ describe("CVE defense-in-depth: refresh token", () => { // Should NOT throw at the host-assertion; the actual fetch will fail // with the mock "test: unexpected fetch" error, which is fine — the // important thing is that the pre-fetch assertion let us through. - await expect(refreshAccessToken("fake-refresh-token")).rejects.toThrow( - /unexpected fetch|Cannot connect|fetch failed/ - ); + await expect( + refreshAccessToken("fake-refresh-token", { + credentialHost: getEnvTokenHost(), + }) + ).rejects.toThrow(/unexpected fetch|Cannot connect|fetch failed/); // A request was attempted, and it went to the correct host expect(fetchCalls).toHaveLength(1); expect(fetchCalls[0]).toBe("https://sentry.example.com/oauth/token/"); }); + + test.each([ + ["network", new Error("fetch failed"), "Cannot connect to Sentry at"], + [ + "TLS", + new Error("unable to verify the first certificate"), + "TLS certificate error connecting to", + ], + ])("%s refresh failure names the credential host", async (_, failure, prefix) => { + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + const credentialHost = "https://sentry.example.com:8443"; + globalThis.fetch = (async (input: RequestInfo | URL) => { + fetchCalls.push(extractFetchUrl(input)); + throw failure; + }) as typeof fetch; + + await expect( + refreshAccessToken("fake-refresh-token", { credentialHost }) + ).rejects.toThrow(`${prefix} ${credentialHost}`); + expect(fetchCalls).toEqual([`${credentialHost}/oauth/token/`]); + }); }); diff --git a/packages/cli/test/lib/sentry-client-refresh-cache.focused.test.ts b/packages/cli/test/lib/sentry-client-refresh-cache.focused.test.ts new file mode 100644 index 000000000..34df4af64 --- /dev/null +++ b/packages/cli/test/lib/sentry-client-refresh-cache.focused.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { getIdentityFingerprint, setAuthToken } from "../../src/lib/db/auth.js"; +import { + getCachedResponse, + resetCacheState, +} from "../../src/lib/response-cache.js"; +import { + getSdkConfig, + resetAuthenticatedFetch, +} from "../../src/lib/sentry-client.js"; +import { useEnvSandbox, useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("refresh-cache-focused-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_NO_CACHE", +]); +const originalFetch = globalThis.fetch; +const url = "https://sentry.io/api/0/organizations/refresh-cache-focused/"; + +beforeEach(() => { + for (const key of [ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_NO_CACHE", + ]) { + delete process.env[key]; + } + resetCacheState(); + resetAuthenticatedFetch(); + setAuthToken("old-access", 3600, "stable-refresh", { + host: "https://sentry.io", + }); +}); +afterEach(() => { + globalThis.fetch = originalFetch; + resetCacheState(); + resetAuthenticatedFetch(); +}); + +test("401 refresh caches GET under the bearer actually sent on retry", async () => { + const requests: string[] = []; + globalThis.fetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/oauth/token/")) { + return Response.json({ + access_token: "new-access", + refresh_token: "stable-refresh", + expires_in: 3600, + token_type: "bearer", + }); + } + requests.push(request.headers.get("authorization") ?? ""); + if (requests.length === 1) { + return Response.json({ detail: "expired" }, { status: 401 }); + } + return Response.json( + { id: "refresh-cache-focused" }, + { + headers: { + "cache-control": "private, max-age=60", + vary: "authorization", + }, + } + ); + } + ); + const response = await getSdkConfig("https://sentry.io").fetch(url); + expect(await response.json()).toEqual({ id: "refresh-cache-focused" }); + expect(requests).toEqual(["Bearer old-access", "Bearer new-access"]); + await vi.waitFor(async () => { + const cached = await getCachedResponse( + "GET", + url, + { authorization: "Bearer new-access" }, + getIdentityFingerprint() + ); + expect(cached).toBeDefined(); + }); +}); diff --git a/packages/cli/test/lib/sentry-client.base-url.test.ts b/packages/cli/test/lib/sentry-client.base-url.test.ts new file mode 100644 index 000000000..37a34a733 --- /dev/null +++ b/packages/cli/test/lib/sentry-client.base-url.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { DEFAULT_SENTRY_URL } from "../../src/lib/constants.js"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { getDatabase } from "../../src/lib/db/index.js"; +import { resetEnvTokenHostForTesting } from "../../src/lib/env-token-host.js"; +import { ConfigError } from "../../src/lib/errors.js"; +import { resolveOrgRegion } from "../../src/lib/region.js"; +import { + getApiBaseUrl, + getControlSiloUrl, +} from "../../src/lib/sentry-client.js"; +import { + mintSntrysToken, + mockFetch, + useEnvSandbox, + useTestConfigDir, +} from "../helpers.js"; + +describe("API base URL for org-auth credentials", () => { + useTestConfigDir("org-auth-base-url-"); + useEnvSandbox([ + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + ]); + beforeEach(resetEnvTokenHostForTesting); + afterEach(resetEnvTokenHostForTesting); + + const claimHost = "http://localhost:8000"; + const orgAuthToken = mintSntrysToken({ + iat: 1, + org: "synthetic-org", + url: claimHost, + }); + + test("uses the active org-auth claim host when no URL is configured", () => { + process.env.SENTRY_AUTH_TOKEN = orgAuthToken; + + expect(getApiBaseUrl()).toBe(claimHost); + expect(getControlSiloUrl()).toBe(claimHost); + }); + + test("discovers the org region through the claim host", async () => { + process.env.SENTRY_AUTH_TOKEN = orgAuthToken; + const originalFetch = globalThis.fetch; + const requests: Request[] = []; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + requests.push(request); + return new Response( + JSON.stringify({ + id: "1568", + slug: "claim-host-region-org", + name: "Claim Host Region Org", + links: { regionUrl: "/" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }); + + try { + await expect(resolveOrgRegion("claim-host-region-org")).resolves.toBe( + claimHost + ); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe( + `${claimHost}/api/0/organizations/claim-host-region-org/` + ); + expect(requests[0]?.headers.get("authorization")).toBe( + `Bearer ${orgAuthToken}` + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("prefers an explicit URL to the claim host", () => { + process.env.SENTRY_AUTH_TOKEN = orgAuthToken; + process.env.SENTRY_URL = "https://configured.example.com"; + + expect(getApiBaseUrl()).toBe("https://configured.example.com"); + expect(getControlSiloUrl()).toBe("https://configured.example.com"); + + process.env.SENTRY_HOST = "https://host.example.com"; + expect(getApiBaseUrl()).toBe("https://host.example.com"); + }); + + test("uses the stored login host when an inactive env token has a claim", () => { + process.env.SENTRY_AUTH_TOKEN = orgAuthToken; + setAuthToken("stored-token", undefined, undefined, { + host: "https://stored.example.com", + }); + + expect(getApiBaseUrl()).toBe("https://stored.example.com"); + expect(getControlSiloUrl()).toBe("https://stored.example.com"); + + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + expect(getApiBaseUrl()).toBe(claimHost); + }); + + test("does not migrate a legacy stored login to an inactive token's claim", () => { + process.env.SENTRY_AUTH_TOKEN = orgAuthToken; + const db = getDatabase(); + db.query( + "INSERT OR REPLACE INTO auth (id, token, refresh_token, host, updated_at) VALUES (1, 'stored-token', 'refresh-token', NULL, ?)" + ).run(Date.now()); + + expect(getApiBaseUrl()).toBe(DEFAULT_SENTRY_URL); + expect( + (db.query("SELECT host FROM auth WHERE id = 1").get() as { host: string }) + .host + ).toBe(DEFAULT_SENTRY_URL); + }); + + test("rejects an invalid active org-auth URL claim instead of sending it to SaaS", () => { + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1, + url: "ftp://invalid.example.com", + }); + + expect(() => getApiBaseUrl()).toThrow(ConfigError); + expect(() => getControlSiloUrl()).toThrow(ConfigError); + }); + + test("does not validate an inactive environment claim during stored login migration", () => { + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1, + url: "ftp://invalid.example.com", + }); + const db = getDatabase(); + db.query( + "INSERT OR REPLACE INTO auth (id, token, refresh_token, host, updated_at) VALUES (1, 'stored-token', 'refresh-token', NULL, ?)" + ).run(Date.now()); + + expect(getApiBaseUrl()).toBe(DEFAULT_SENTRY_URL); + expect( + (db.query("SELECT host FROM auth WHERE id = 1").get() as { host: string }) + .host + ).toBe(DEFAULT_SENTRY_URL); + }); + + test("keeps SaaS as the fallback without an active custom host", () => { + expect(getApiBaseUrl()).toBe(DEFAULT_SENTRY_URL); + + process.env.SENTRY_AUTH_TOKEN = "non-org-token"; + expect(getApiBaseUrl()).toBe(DEFAULT_SENTRY_URL); + expect(getControlSiloUrl()).toBe(DEFAULT_SENTRY_URL); + }); +}); diff --git a/packages/cli/test/lib/sentry-client.redirect.focused.test.ts b/packages/cli/test/lib/sentry-client.redirect.focused.test.ts new file mode 100644 index 000000000..4be8141e7 --- /dev/null +++ b/packages/cli/test/lib/sentry-client.redirect.focused.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { HostScopeError } from "../../src/lib/errors.js"; +import { + disableResponseCache, + resetCacheState, +} from "../../src/lib/response-cache.js"; +import { + getSdkConfig, + resetAuthenticatedFetch, +} from "../../src/lib/sentry-client.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("redirect-focused-"); +const originalFetch = globalThis.fetch; + +describe("authenticated discovery redirects", () => { + beforeEach(() => { + disableResponseCache(); + resetAuthenticatedFetch(); + setAuthToken("redirect-token", undefined, undefined, { + host: "https://sentry.io", + }); + }); + afterEach(() => { + globalThis.fetch = originalFetch; + resetCacheState(); + resetAuthenticatedFetch(); + }); + + test("validates and follows a trusted hop with the pinned bearer", async () => { + const requests: Request[] = []; + globalThis.fetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + requests.push(request); + return requests.length === 1 + ? Response.redirect("https://de.sentry.io/api/0/organizations/", 307) + : Response.json([]); + } + ); + const response = await getSdkConfig("https://sentry.io", { + validatedRedirects: true, + }).fetch("https://sentry.io/api/0/organizations/"); + expect(response.status).toBe(200); + expect(requests.map((request) => request.url)).toEqual([ + "https://sentry.io/api/0/organizations/", + "https://de.sentry.io/api/0/organizations/", + ]); + expect( + requests.every( + (request) => + request.redirect === "manual" && + request.headers.get("authorization") === "Bearer redirect-token" + ) + ).toBe(true); + }); + + test("rejects an untrusted hop before sending credentials", async () => { + setAuthToken("self-hosted-token", undefined, undefined, { + host: "https://control.example.com", + }); + const destinations: string[] = []; + globalThis.fetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + destinations.push(new Request(input, init).url); + return Response.redirect("https://evil.example.net/steal", 302); + } + ); + await expect( + getSdkConfig("https://control.example.com", { + validatedRedirects: true, + }).fetch("https://control.example.com/api/0/organizations/") + ).rejects.toBeInstanceOf(HostScopeError); + expect(destinations).toEqual([ + "https://control.example.com/api/0/organizations/", + ]); + }); + + test("restarts a retry from the original request after a redirect", async () => { + const destinations: string[] = []; + globalThis.fetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + destinations.push(new Request(input, init).url); + if (destinations.length === 1 || destinations.length === 3) { + return Response.redirect( + "https://de.sentry.io/api/0/organizations/", + 307 + ); + } + return destinations.length === 2 + ? new Response("retry", { status: 503 }) + : Response.json([]); + } + ); + const response = await getSdkConfig("https://sentry.io", { + validatedRedirects: true, + }).fetch("https://sentry.io/api/0/organizations/"); + expect(response.status).toBe(200); + expect(destinations).toEqual([ + "https://sentry.io/api/0/organizations/", + "https://de.sentry.io/api/0/organizations/", + "https://sentry.io/api/0/organizations/", + "https://de.sentry.io/api/0/organizations/", + ]); + }); + + test("rejects a redirect loop before repeating the request", async () => { + const destinations: string[] = []; + globalThis.fetch = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + destinations.push(request.url); + return Response.redirect(request.url, 307); + } + ); + await expect( + getSdkConfig("https://sentry.io", { validatedRedirects: true }).fetch( + "https://sentry.io/api/0/organizations/" + ) + ).rejects.toBeInstanceOf(HostScopeError); + expect(destinations).toEqual(["https://sentry.io/api/0/organizations/"]); + }); + + test.each([ + { status: 303, expectedMethod: "GET", expectedBody: "" }, + { status: 307, expectedMethod: "POST", expectedBody: "payload" }, + ])("preserves fetch redirect semantics for $status", async ({ + status, + expectedMethod, + expectedBody, + }) => { + const requests: Array<{ + method: string; + body: string; + contentType: string | null; + }> = []; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const request = new Request(input); + requests.push({ + method: request.method, + body: request.body ? await request.text() : "", + contentType: request.headers.get("content-type"), + }); + return requests.length === 1 + ? Response.redirect("https://sentry.io/api/0/redirected/", status) + : Response.json({ ok: true }); + }); + + const response = await getSdkConfig("https://sentry.io", { + validatedRedirects: true, + }).fetch("https://sentry.io/api/0/organizations/", { + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: "payload", + }); + + expect(response.status).toBe(200); + expect(requests[1]).toEqual({ + method: expectedMethod, + body: expectedBody, + contentType: status === 303 ? null : "text/plain", + }); + }); + + test("cancels an untrusted redirect body without masking the trust error", async () => { + setAuthToken("self-hosted-token", undefined, undefined, { + host: "https://control.example.com", + }); + const cancellation = { observed: false }; + globalThis.fetch = vi.fn( + async () => + new Response( + new ReadableStream({ + cancel: () => { + cancellation.observed = true; + }, + }), + { + status: 302, + headers: { Location: "https://evil.example.net/steal" }, + } + ) + ); + + await expect( + getSdkConfig("https://control.example.com", { + validatedRedirects: true, + }).fetch("https://control.example.com/api/0/organizations/") + ).rejects.toBeInstanceOf(HostScopeError); + expect(cancellation.observed).toBe(true); + }); +}); diff --git a/packages/cli/test/lib/sentry-urls.credential.test.ts b/packages/cli/test/lib/sentry-urls.credential.test.ts new file mode 100644 index 000000000..b6f52dda2 --- /dev/null +++ b/packages/cli/test/lib/sentry-urls.credential.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, test } from "vitest"; +import { setAuthToken } from "../../src/lib/db/auth.js"; +import { resetEnvTokenHostForTesting } from "../../src/lib/env-token-host.js"; +import { + buildOrgUrl, + buildProjectUrl, + getSentryBaseUrl, + isSaaS, +} from "../../src/lib/sentry-web-urls.js"; +import { + mintSntrysToken, + useEnvSandbox, + useTestConfigDir, +} from "../helpers.js"; + +describe("web URLs for active credential hosts", () => { + useTestConfigDir("web-urls-credential-"); + useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + ]); + + beforeEach(() => { + resetEnvTokenHostForTesting(); + }); + + test("uses a self-hosted token claim for web links and URL shape", () => { + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1, + url: "https://sentry.example.com", + }); + expect(getSentryBaseUrl()).toBe("https://sentry.example.com"); + expect(isSaaS()).toBe(false); + expect(buildOrgUrl("acme")).toBe( + "https://sentry.example.com/organizations/acme/" + ); + expect(buildProjectUrl("acme", "site")).toBe( + "https://sentry.example.com/settings/acme/projects/site/" + ); + }); + + test("stored OAuth host wins over an inactive environment token", () => { + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1, + url: "https://inactive.example.com", + }); + setAuthToken("stored-access", 3600, "stored-refresh", { + host: "https://stored.example.com", + }); + expect(getSentryBaseUrl()).toBe("https://stored.example.com"); + }); + + test("explicit URLs still take priority for web links", () => { + process.env.SENTRY_URL = "https://configured.example.com/sentry"; + process.env.SENTRY_AUTH_TOKEN = mintSntrysToken({ + iat: 1, + url: "https://token.example.com", + }); + expect(getSentryBaseUrl()).toBe("https://configured.example.com/sentry"); + expect(buildOrgUrl("acme")).toBe( + "https://configured.example.com/sentry/organizations/acme/" + ); + }); +});