diff --git a/packages/cli/src/lib/sentry-urls.ts b/packages/cli/src/lib/sentry-urls.ts index 96cbd35ee..242bfbc72 100644 --- a/packages/cli/src/lib/sentry-urls.ts +++ b/packages/cli/src/lib/sentry-urls.ts @@ -6,6 +6,7 @@ */ import { AsyncLocalStorage } from "node:async_hooks"; +import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; import { DEFAULT_SENTRY_HOST, DEFAULT_SENTRY_URL, @@ -84,10 +85,7 @@ export function isSentrySaasUrl(url: string): boolean { // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. try { const parsed = new URL(url); - return ( - parsed.hostname === DEFAULT_SENTRY_HOST || - parsed.hostname.endsWith(`.${DEFAULT_SENTRY_HOST}`) - ); + return isSentryHost(parsed.hostname); } catch { return false; } diff --git a/packages/cli/test/script/cli-startup.test.ts b/packages/cli/test/script/cli-startup.test.ts index a00114347..c089040af 100644 --- a/packages/cli/test/script/cli-startup.test.ts +++ b/packages/cli/test/script/cli-startup.test.ts @@ -26,7 +26,12 @@ test.each(["cli.ts", "index.ts"])( if (args.kind === "dynamic-import") { return { path: args.path, external: true }; } - if (args.path.startsWith("@sentry/")) { + // The hostname predicate is pure and has no startup side effects. + // Keep traversing its source to reject any future SDK imports. + if ( + args.path.startsWith("@sentry/") && + args.path !== "@sentry/toolkit-core/sentry-host" + ) { return { errors: [ { diff --git a/packages/mcp-core/src/utils/url-utils.ts b/packages/mcp-core/src/utils/url-utils.ts index c79401437..8e90b1e83 100644 --- a/packages/mcp-core/src/utils/url-utils.ts +++ b/packages/mcp-core/src/utils/url-utils.ts @@ -1,3 +1,4 @@ +import { isSentryHost as isSharedSentryHost } from "@sentry/toolkit-core/sentry-host"; import type { SentryProtocol } from "../types"; import { type EventsDataset, @@ -5,11 +6,9 @@ import { isProfilesDataset, } from "./events-datasets"; -/** - * Recognizes Sentry-owned hosts, including single-tenant deployments. - */ +/** Keep the MCP utility export for its existing consumers. */ export function isSentryHost(host: string): boolean { - return host === "sentry.io" || host.endsWith(".sentry.io"); + return isSharedSentryHost(host); } /** Hosts that use the public SaaS control host and organization web subdomains. */ diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 594751b45..a55a2499b 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -1,9 +1,10 @@ # Toolkit core -Pure authentication protocol helpers shared by the CLI and MCP. Both product +Pure protocol and hostname helpers shared by the CLI and MCP. Both product builds bundle this private workspace package into their artifacts. The shared code validates opaque bearer tokens, constructs OAuth device-flow -form bodies, and applies the RFC 8628 polling interval rule. Each product -retains its own credential storage, host trust checks, polling deadline, HTTP -transport, response validation, and user-facing error types. +form bodies, applies the RFC 8628 polling interval rule, and recognizes Sentry +hostnames. Each product retains its own credential storage, URL and host trust +checks, regional routing, polling deadline, HTTP transport, response validation, +and user-facing error types. diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index ed3304d15..afa5f17ca 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -18,6 +18,10 @@ "./oauth-poll": { "types": "./src/oauth-poll.ts", "default": "./src/oauth-poll.ts" + }, + "./sentry-host": { + "types": "./src/sentry-host.ts", + "default": "./src/sentry-host.ts" } }, "scripts": { diff --git a/packages/toolkit-core/src/sentry-host.test.ts b/packages/toolkit-core/src/sentry-host.test.ts new file mode 100644 index 000000000..22a1a416a --- /dev/null +++ b/packages/toolkit-core/src/sentry-host.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { isSentryHost } from "./sentry-host"; + +describe("isSentryHost", () => { + it.each(["sentry.io", "us.sentry.io", "tenant.my.sentry.io"])( + "recognizes %s as a Sentry hostname", + (host) => { + expect(isSentryHost(host)).toBe(true); + }, + ); + + it.each([ + "", + "sentry.io.example.com", + "notsentry.io", + "sentry.io.", + "SENTRY.IO", + ])("does not classify %s as a Sentry hostname", (host) => { + expect(isSentryHost(host)).toBe(false); + }); +}); diff --git a/packages/toolkit-core/src/sentry-host.ts b/packages/toolkit-core/src/sentry-host.ts new file mode 100644 index 000000000..a4fe3e940 --- /dev/null +++ b/packages/toolkit-core/src/sentry-host.ts @@ -0,0 +1,4 @@ +/** Classify a parsed hostname as Sentry-owned, including regional and tenant hosts. */ +export function isSentryHost(host: string): boolean { + return host === "sentry.io" || host.endsWith(".sentry.io"); +}