From 9605320c7fe9a7c7e739d19f49d8ff3c5818365f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 7 Oct 2026 19:54:16 +0000 Subject: [PATCH 1/2] feat(core): Share API URLs and bearer headers Co-Authored-By: GPT-6 Sol --- packages/cli/src/lib/api/infrastructure.ts | 22 ++++---------- packages/cli/src/lib/auth-header.ts | 7 ++++- .../cli/test/lib/api/infrastructure.test.ts | 22 ++++++++++++++ packages/mcp-core/src/api-client/client.ts | 16 +++++----- packages/toolkit-core/README.md | 11 +++---- packages/toolkit-core/package.json | 4 +++ packages/toolkit-core/src/api-request.test.ts | 30 +++++++++++++++++++ packages/toolkit-core/src/api-request.ts | 8 +++++ packages/toolkit-core/src/auth-token.test.ts | 14 ++++++++- packages/toolkit-core/src/auth-token.ts | 6 ++++ 10 files changed, 110 insertions(+), 30 deletions(-) create mode 100644 packages/toolkit-core/src/api-request.test.ts create mode 100644 packages/toolkit-core/src/api-request.ts diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 3885a319e..9a7d3cfc5 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -9,6 +9,7 @@ import { promisify } from "node:util"; import { zstdCompress as zstdCompressCb } from "node:zlib"; import { parseSentryLinkHeader } from "@sentry/api"; +import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; import { type GenericSchema, safeParse } from "valibot"; @@ -592,12 +593,8 @@ export async function apiRequestToRegion( } = options; const config = getSdkConfig(regionUrl, { credential, validatedRedirects }); - const normalizedEndpoint = endpoint.startsWith("/") - ? endpoint.slice(1) - : endpoint; - const endpointWithParams = appendSearchParams(normalizedEndpoint, params); - // getSdkConfig.baseUrl is the plain region URL; add /api/0/ for raw requests - const url = `${config.baseUrl}/api/0/${endpointWithParams}`; + const endpointWithParams = appendSearchParams(endpoint, params); + const url = buildSentryApiUrl(config.baseUrl, endpointWithParams); const fetchFn = config.fetch; const headers: Record = { @@ -749,11 +746,8 @@ export async function apiRequestToRegionNoContent( const config = getSdkConfig(regionUrl); const searchParams = buildSearchParams(params); - const normalizedEndpoint = endpoint.startsWith("/") - ? endpoint.slice(1) - : endpoint; const queryString = searchParams ? `?${searchParams.toString()}` : ""; - const url = `${config.baseUrl}/api/0/${normalizedEndpoint}${queryString}`; + const url = buildSentryApiUrl(config.baseUrl, `${endpoint}${queryString}`); const fetchFn = config.fetch; const headers: Record = { @@ -878,12 +872,8 @@ export async function rawApiRequest( // enforces isRequestOriginTrusted() before attaching Authorization. const config = baseUrl ? getSdkConfig(baseUrl) : getDefaultSdkConfig(); - const normalizedEndpoint = endpoint.startsWith("/") - ? endpoint.slice(1) - : endpoint; - const endpointWithParams = appendSearchParams(normalizedEndpoint, params); - // getSdkConfig.baseUrl is the plain region URL; add /api/0/ for raw requests - const url = `${config.baseUrl}/api/0/${endpointWithParams}`; + const endpointWithParams = appendSearchParams(endpoint, params); + const url = buildSentryApiUrl(config.baseUrl, endpointWithParams); // Build request headers and body. // String bodies: no Content-Type unless the caller explicitly provides one. diff --git a/packages/cli/src/lib/auth-header.ts b/packages/cli/src/lib/auth-header.ts index ddfaaba81..7edc82517 100644 --- a/packages/cli/src/lib/auth-header.ts +++ b/packages/cli/src/lib/auth-header.ts @@ -2,6 +2,7 @@ import { normalizeAuthToken as parseAuthToken, + sentryBearerHeader, trimAuthToken as trimSharedAuthToken, } from "@sentry/toolkit-core/auth-token"; import { MalformedAuthTokenError } from "./errors.js"; @@ -22,5 +23,9 @@ export function normalizeAuthToken(token: string): string { /** Normalize and validate a credential before constructing its Authorization value. */ export function formatAuthHeader(token: string): string { - return `Bearer ${normalizeAuthToken(token)}`; + const header = sentryBearerHeader(token); + if (header === null) { + throw new MalformedAuthTokenError(); + } + return header; } diff --git a/packages/cli/test/lib/api/infrastructure.test.ts b/packages/cli/test/lib/api/infrastructure.test.ts index 9dac96c12..a1fb52534 100644 --- a/packages/cli/test/lib/api/infrastructure.test.ts +++ b/packages/cli/test/lib/api/infrastructure.test.ts @@ -606,6 +606,28 @@ describe("rawApiRequest binary handling", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + test("keeps a trusted self-hosted path and bearer credential on raw requests", async () => { + setAuthToken(" \tvalid-token\x7f ", undefined, undefined, { + host: "https://sentry.example.com", + }); + globalThis.fetch = mockFetch(async (input, init) => { + expect(input).toBe( + "https://sentry.example.com/sentry/api/0/organizations/acme/?cursor=a%3Ab", + ); + expect(new Headers(init?.headers).get("Authorization")).toBe( + "Bearer valid-token", + ); + return new Response("{}", { + headers: { "content-type": "application/json" }, + }); + }); + + await rawApiRequest("/organizations/acme/", { + baseUrl: "https://sentry.example.com/sentry", + params: { cursor: "a:b" }, + }); + }); + test("returns Uint8Array for image/png without UTF-8 corruption", async () => { // Real PNG signature: 89 50 4e 47 0d 0a 1a 0a — the leading 0x89 is not // valid UTF-8 and would become EF BF BD if response.text() were used. diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index b8d7a0004..0ba4fa005 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -1,5 +1,6 @@ import { parseSentryLinkHeader } from "@sentry/api"; -import { normalizeAuthToken } from "@sentry/toolkit-core/auth-token"; +import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request"; +import { sentryBearerHeader } from "@sentry/toolkit-core/auth-token"; import { z } from "zod"; import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants"; import { ConfigurationError } from "../errors"; @@ -768,20 +769,21 @@ export class SentryApiService { options: RequestInit = {}, { host, allowStatuses }: { host?: string; allowStatuses?: number[] } = {}, ): Promise { - const url = host - ? `${this.protocol}://${host}/api/0${path}` - : `${this.apiPrefix}${path}`; + const url = buildSentryApiUrl( + `${this.protocol}://${host ?? this.host}`, + path, + ); const headers: Record = { "Content-Type": "application/json", "User-Agent": USER_AGENT, }; if (this.accessToken !== null) { - const token = normalizeAuthToken(this.accessToken); - if (token === null) { + const authorization = sentryBearerHeader(this.accessToken); + if (authorization === null) { throw new ConfigurationError("Malformed authentication token"); } - headers.Authorization = `Bearer ${token}`; + headers.Authorization = authorization; } if (this.clientId) { headers["X-Sentry-MCP-Client-Id"] = this.clientId; diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 0244c8fb4..4ba95eeba 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -3,8 +3,9 @@ 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, classifies RFC 8628 polling responses, advances retry intervals, -recognizes Sentry hostnames, and encodes API path identifiers. Each product -retains its own credential storage, URL and host trust checks, regional routing, -polling deadline, HTTP transport, response validation, and user-facing errors. +The shared code validates and formats upstream bearer tokens, assembles Sentry +API URLs, constructs OAuth device-flow form bodies, classifies RFC 8628 polling +responses, advances retry intervals, recognizes Sentry hostnames, and encodes +API path identifiers. Each product retains its own credential storage, host +trust checks, regional routing, polling deadline, HTTP transport, response +validation, and user-facing errors. diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 7b8a8df37..2ea47a939 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -7,6 +7,10 @@ "node": ">=22.13" }, "exports": { + "./api-request": { + "types": "./src/api-request.ts", + "default": "./src/api-request.ts" + }, "./api-path-segment": { "types": "./src/api-path-segment.ts", "default": "./src/api-path-segment.ts" diff --git a/packages/toolkit-core/src/api-request.test.ts b/packages/toolkit-core/src/api-request.test.ts new file mode 100644 index 000000000..009c66699 --- /dev/null +++ b/packages/toolkit-core/src/api-request.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { buildSentryApiUrl } from "./api-request.js"; + +describe("buildSentryApiUrl", () => { + it.each([ + [ + "https://sentry.io", + "/organizations/org/", + "https://sentry.io/api/0/organizations/org/", + ], + [ + "https://us.sentry.io/", + "organizations/org/?cursor=a%3Ab", + "https://us.sentry.io/api/0/organizations/org/?cursor=a%3Ab", + ], + [ + "https://self-hosted.example/sentry/", + "/organizations/org/", + "https://self-hosted.example/sentry/api/0/organizations/org/", + ], + ])("assembles %s with %s", (baseUrl, endpoint, expected) => { + expect(buildSentryApiUrl(baseUrl, endpoint)).toBe(expected); + }); + + it("preserves encoded identifiers without decoding them", () => { + expect(buildSentryApiUrl("https://sentry.io", "issues/a%2Fb/")).toBe( + "https://sentry.io/api/0/issues/a%2Fb/", + ); + }); +}); diff --git a/packages/toolkit-core/src/api-request.ts b/packages/toolkit-core/src/api-request.ts new file mode 100644 index 000000000..81f5d45e1 --- /dev/null +++ b/packages/toolkit-core/src/api-request.ts @@ -0,0 +1,8 @@ +/** + * Assemble an API URL from a product-validated base and a relative endpoint. + * This does not decide which host is trusted or which region an org uses. + */ +export function buildSentryApiUrl(baseUrl: string, endpoint: string): string { + const path = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; + return `${baseUrl.replace(/\/+$/, "")}/api/0/${path}`; +} diff --git a/packages/toolkit-core/src/auth-token.test.ts b/packages/toolkit-core/src/auth-token.test.ts index 05d905e41..69d4e57ae 100644 --- a/packages/toolkit-core/src/auth-token.test.ts +++ b/packages/toolkit-core/src/auth-token.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { normalizeAuthToken, trimAuthToken } from "./auth-token"; +import { + normalizeAuthToken, + sentryBearerHeader, + trimAuthToken, +} from "./auth-token"; describe("Sentry bearer credentials", () => { it("preserves every visible ASCII byte through edge normalization", () => { @@ -19,4 +23,12 @@ describe("Sentry bearer credentials", () => { expect(normalizeAuthToken(" \t\x00 ")).toBeNull(); expect(normalizeAuthToken("é")).toBeNull(); }); + + it("formats a bearer header only for a valid upstream token", () => { + expect(sentryBearerHeader(" \tvalid-token\x7f ")).toBe( + "Bearer valid-token", + ); + expect(sentryBearerHeader("valid\nsecret")).toBeNull(); + expect(sentryBearerHeader(" \t ")).toBeNull(); + }); }); diff --git a/packages/toolkit-core/src/auth-token.ts b/packages/toolkit-core/src/auth-token.ts index c4118cba2..eafd66ca2 100644 --- a/packages/toolkit-core/src/auth-token.ts +++ b/packages/toolkit-core/src/auth-token.ts @@ -23,3 +23,9 @@ export function normalizeAuthToken(token: string): string | null { ? normalized : null; } + +/** Format a validated upstream credential; callers own their auth errors. */ +export function sentryBearerHeader(token: string): string | null { + const normalized = normalizeAuthToken(token); + return normalized === null ? null : `Bearer ${normalized}`; +} From c0213043fce9d27bce99ec87197244cd5061fc17 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 7 Oct 2026 20:37:48 +0000 Subject: [PATCH 2/2] fix(core): Strip API base slashes in linear time Co-Authored-By: GPT-6 Sol --- packages/toolkit-core/src/api-request.test.ts | 5 +++++ packages/toolkit-core/src/api-request.ts | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/toolkit-core/src/api-request.test.ts b/packages/toolkit-core/src/api-request.test.ts index 009c66699..8d56d5132 100644 --- a/packages/toolkit-core/src/api-request.test.ts +++ b/packages/toolkit-core/src/api-request.test.ts @@ -18,6 +18,11 @@ describe("buildSentryApiUrl", () => { "/organizations/org/", "https://self-hosted.example/sentry/api/0/organizations/org/", ], + [ + "https://self-hosted.example/sentry////", + "organizations/org/", + "https://self-hosted.example/sentry/api/0/organizations/org/", + ], ])("assembles %s with %s", (baseUrl, endpoint, expected) => { expect(buildSentryApiUrl(baseUrl, endpoint)).toBe(expected); }); diff --git a/packages/toolkit-core/src/api-request.ts b/packages/toolkit-core/src/api-request.ts index 81f5d45e1..82b7a9da1 100644 --- a/packages/toolkit-core/src/api-request.ts +++ b/packages/toolkit-core/src/api-request.ts @@ -4,5 +4,9 @@ */ export function buildSentryApiUrl(baseUrl: string, endpoint: string): string { const path = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; - return `${baseUrl.replace(/\/+$/, "")}/api/0/${path}`; + let end = baseUrl.length; + while (end > 0 && baseUrl.charAt(end - 1) === "/") { + end -= 1; + } + return `${baseUrl.slice(0, end)}/api/0/${path}`; }