From 5710b8e8d4963819646f0a8aa3b9d75011b8cded Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:23:13 +0000 Subject: [PATCH 1/6] feat(toolkit-core): Add the shared API request core createApiClientCore sends a request through the host's transport with the host's headers, turns network failures into readable errors (ConfigurationError for an unreachable host), maps error responses to the status's ApiError through readErrorResponse, and parses JSON bodies. The transport, headers, and logger are injected, so it stays browser-portable. It is the MCP's request path, moved as is; the next commit switches SentryApiService onto it. --- packages/toolkit-core/README.md | 8 + .../toolkit-core/src/api-client/core.test.ts | 250 ++++++++++++++++++ packages/toolkit-core/src/api-client/core.ts | 223 ++++++++++++++++ 3 files changed, 481 insertions(+) create mode 100644 packages/toolkit-core/src/api-client/core.test.ts create mode 100644 packages/toolkit-core/src/api-client/core.ts diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 840a605f5..ba125a3e5 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -37,6 +37,14 @@ the one exception. rate-limit wait, and each host maps that to its own errors. `readJsonResponse` parses a successful body after checking its Content-Type. +- `core` is the request core endpoint wrappers build on. + `createApiClientCore` takes the host's transport, per-request headers + (credentials, user agent, attribution), and logger. Its `request` sends + to `/api/0`, turns network failures into readable errors + (`ConfigurationError` for an unreachable host), and throws the status's + `ApiError` for an error response unless the caller allows that status. + `requestJson` also parses the body. The MCP's `SentryApiService` sends + every request through it. - `pagination`, `retry`, `transport`, and `response-cache` are described in the sections below. The `node:zlib` body codec stays outside, at `response-cache/codec`, since browsers lack it. diff --git a/packages/toolkit-core/src/api-client/core.test.ts b/packages/toolkit-core/src/api-client/core.test.ts new file mode 100644 index 000000000..fb097ecc5 --- /dev/null +++ b/packages/toolkit-core/src/api-client/core.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it, vi } from "vitest"; +import { type ApiRequestTransport, createApiClientCore } from "./core.js"; +import { + ApiNotFoundError, + ApiRateLimitError, + ApiServerError, + ConfigurationError, +} from "./errors.js"; + +const ORIGIN = "https://us.sentry.io"; + +function jsonResponse( + body: unknown, + init: { status?: number; headers?: Record } = {}, +): Response { + return new Response(JSON.stringify(body), { + status: init.status, + headers: { "content-type": "application/json", ...init.headers }, + }); +} + +/** A core over a transport that answers every request with `respond`. */ +function setup(respond: ApiRequestTransport["request"]) { + const transport = { request: vi.fn(respond) }; + const logger = { warn: vi.fn() }; + const core = createApiClientCore({ + transport, + headers: () => ({ Authorization: "Bearer token", "User-Agent": "test" }), + logger, + }); + return { core, transport, logger }; +} + +describe("createApiClientCore request", () => { + it("sends the host's headers to the API URL, replacing the caller's", async () => { + const { core, transport } = setup(async () => jsonResponse({})); + const signal = new AbortController().signal; + + await core.request(ORIGIN, "/organizations/acme/", { + method: "PUT", + body: "{}", + signal, + headers: { "X-Dropped": "1" }, + }); + + expect(transport.request).toHaveBeenCalledWith( + "https://us.sentry.io/api/0/organizations/acme/", + { + method: "PUT", + body: "{}", + signal, + headers: { + "Content-Type": "application/json", + Authorization: "Bearer token", + "User-Agent": "test", + }, + }, + ); + }); + + it("throws the host's header error before sending", async () => { + const transport = { request: vi.fn() }; + const core = createApiClientCore({ + transport, + headers: () => { + throw new ConfigurationError("Malformed authentication token"); + }, + logger: { warn: vi.fn() }, + }); + + await expect(core.request(ORIGIN, "/")).rejects.toThrow( + "Malformed authentication token", + ); + expect(transport.request).not.toHaveBeenCalled(); + }); + + it("throws the status's ApiError with the server's detail", async () => { + const { core, logger } = setup(async () => + jsonResponse({ detail: "Project not found" }, { status: 404 }), + ); + + const error = await core + .request(ORIGIN, "/projects/acme/x/") + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiNotFoundError); + expect(error).toMatchObject({ + message: "Project not found", + status: 404, + detail: "Project not found", + responseBody: { detail: "Project not found" }, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("returns an allowed error status instead of throwing", async () => { + const { core } = setup(async () => + jsonResponse({ detail: "Gone" }, { status: 404 }), + ); + + const response = await core.request(ORIGIN, "/x/", undefined, { + allowStatuses: [404], + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ detail: "Gone" }); + }); + + it("carries a rate limit's wait on the error", async () => { + const { core } = setup(async () => + jsonResponse( + { detail: "Rate limit exceeded" }, + { status: 429, headers: { "Retry-After": "3" } }, + ), + ); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiRateLimitError); + expect(error).toMatchObject({ retryAfterMs: 3000 }); + }); + + it("reports an HTML error page as a server error and logs it", async () => { + const { core, logger } = setup( + async () => + new Response("Bad gateway", { + status: 502, + statusText: "Bad Gateway", + }), + ); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiServerError); + expect((error as Error).message).toBe( + "Server error: Received HTML instead of JSON (502 Bad Gateway). This may indicate an invalid URL or server issue.", + ); + expect(logger.warn).toHaveBeenCalledWith( + "Received HTML error page instead of JSON", + expect.objectContaining({ + status: 502, + host: "us.sentry.io", + path: "/x/", + }), + ); + }); + + it("falls back to the status text and body for an unreadable error", async () => { + const { core, logger } = setup( + async () => + new Response("upstream timed out", { + status: 503, + statusText: "Service Unavailable", + }), + ); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ApiServerError); + expect((error as Error).message).toBe( + "API request failed: Service Unavailable\nupstream timed out", + ); + expect(logger.warn).toHaveBeenCalledWith( + "Failed to parse JSON error response", + expect.objectContaining({ bodyPreview: "upstream timed out" }), + ); + }); + + it("logs a JSON error body with no string detail", async () => { + const { core, logger } = setup(async () => + jsonResponse({ detail: { code: "x" } }, { status: 400 }), + ); + + await expect(core.request(ORIGIN, "/x/")).rejects.toThrow( + "API request failed", + ); + expect(logger.warn).toHaveBeenCalledWith( + "Failed to parse validated API error response", + expect.objectContaining({ + validationErrorMessage: "Error response has no string detail", + }), + ); + }); + + it("reports an unreachable host as a configuration error", async () => { + const { core } = setup(async () => { + throw new TypeError("fetch failed", { + cause: new Error("getaddrinfo ENOTFOUND us.sentry.io"), + }); + }); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as Error).message).toBe( + "Unable to connect to https://us.sentry.io/api/0/x/ - Hostname not found. Verify the URL is correct.", + ); + }); + + it("names the URL for other network failures", async () => { + const { core } = setup(async () => { + throw new TypeError("fetch failed", { + cause: new Error("read ECONNRESET"), + }); + }); + + const error = await core.request(ORIGIN, "/x/").catch((e: unknown) => e); + + expect(error).not.toBeInstanceOf(ConfigurationError); + expect((error as Error).message).toBe( + "Unable to connect to https://us.sentry.io/api/0/x/ - Connection reset. Try again in a moment.", + ); + }); + + it("rethrows the caller's abort unchanged", async () => { + const controller = new AbortController(); + const reason = new DOMException("stopped", "AbortError"); + const { core } = setup(async () => { + controller.abort(reason); + throw reason; + }); + + await expect( + core.request(ORIGIN, "/x/", { signal: controller.signal }), + ).rejects.toBe(reason); + }); +}); + +describe("createApiClientCore requestJson", () => { + it("returns the parsed body", async () => { + const { core } = setup(async () => jsonResponse([{ slug: "acme" }])); + + expect(await core.requestJson(ORIGIN, "/organizations/")).toEqual([ + { slug: "acme" }, + ]); + }); + + it("rejects a successful response that is not JSON", async () => { + const { core } = setup( + async () => + new Response("", { + headers: { "content-type": "text/html" }, + }), + ); + + await expect(core.requestJson(ORIGIN, "/organizations/")).rejects.toThrow( + "Expected JSON response but received HTML", + ); + }); +}); diff --git a/packages/toolkit-core/src/api-client/core.ts b/packages/toolkit-core/src/api-client/core.ts new file mode 100644 index 000000000..e55399d6e --- /dev/null +++ b/packages/toolkit-core/src/api-client/core.ts @@ -0,0 +1,223 @@ +/** + * The request core that endpoint wrappers build on. It sends a request + * through the host's transport, turns network failures and error responses + * into the shared errors, and reads JSON bodies. + * + * The host supplies the transport (retries, caching), the per-request + * headers (credentials, user agent, attribution), and logging. + */ + +import { buildSentryApiUrl } from "../api-request.js"; +import { type ApiError, ConfigurationError, createApiError } from "./errors.js"; +import { + type ErrorResponseBody, + readErrorResponse, + readJsonResponse, +} from "./response.js"; + +/** What the core needs from the host's transport. */ +export type ApiRequestTransport = { + request(url: string, init: RequestInit): Promise; +}; + +export type ApiClientLogger = { + warn(message: string, extra: Record): void; +}; + +export type ApiClientCoreOptions = { + transport: ApiRequestTransport; + /** + * Headers for every request after `Content-Type`, such as Authorization + * and User-Agent. Called once per request; may throw for a bad credential. + * They replace any headers the caller passes. + */ + headers: () => Record; + logger: ApiClientLogger; +}; + +export type ApiRequestOptions = { + /** Error statuses returned as the response instead of thrown. */ + allowStatuses?: number[]; +}; + +export type ApiClientCore = { + /** + * Send a request to `origin` + `/api/0` + `path`. + * + * @throws {ApiError} For an error status not in `allowStatuses` + * @throws {ConfigurationError} When the host is unreachable or misconfigured + * @throws {Error} For other network failures, with a readable message + */ + request( + origin: string, + path: string, + init?: RequestInit, + options?: ApiRequestOptions, + ): Promise; + /** {@link ApiClientCore.request}, then the parsed JSON body. */ + requestJson( + origin: string, + path: string, + init?: RequestInit, + ): Promise; +}; + +/** Readable messages for common network error codes. */ +const NETWORK_ERROR_MESSAGES: Record = { + EAI_AGAIN: "DNS temporarily unavailable. Check your internet connection.", + ENOTFOUND: "Hostname not found. Verify the URL is correct.", + ECONNREFUSED: "Connection refused. Ensure the service is accessible.", + ETIMEDOUT: "Connection timed out. Check network connectivity.", + ECONNRESET: "Connection reset. Try again in a moment.", +}; + +/** Network failures that mean the host is wrong or unreachable. */ +const CONFIGURATION_ERROR_CODES = new Set([ + "ENOTFOUND", + "EAI_AGAIN", + "ECONNREFUSED", + "ETIMEDOUT", +]); + +/** A failed fetch as a readable error naming the URL. */ +function networkError(url: string, error: unknown): Error { + let rootCause = error; + while (rootCause instanceof Error && rootCause.cause) { + rootCause = rootCause.cause; + } + const errorMessage = + rootCause instanceof Error ? rootCause.message : String(rootCause); + const errorCode = Object.keys(NETWORK_ERROR_MESSAGES).find((code) => + errorMessage.includes(code), + ); + const friendlyMessage = `Unable to connect to ${url} - ${ + errorCode ? NETWORK_ERROR_MESSAGES[errorCode] : errorMessage + }`; + if ( + (errorCode && CONFIGURATION_ERROR_CODES.has(errorCode)) || + errorMessage.includes("Connect Timeout Error") + ) { + return new ConfigurationError(friendlyMessage, { cause: error }); + } + return new Error(friendlyMessage, { cause: error }); +} + +/** A failed response's body as the matching `ApiError` subclass. */ +function errorFromResponse( + logger: ApiClientLogger, + response: Response, + body: ErrorResponseBody, + logExtra: Record, +): ApiError { + const { retryAfterMs } = body; + const errorText = body.text ?? ""; + const bodyPreview = + errorText.length > 256 ? `${errorText.slice(0, 253)}…` : errorText; + + if (body.parseError !== undefined) { + if (body.isHtml) { + logger.warn("Received HTML error page instead of JSON", { + ...logExtra, + parseErrorMessage: body.parseError, + }); + // An HTML page instead of JSON usually means a wrong URL or a proxy. + return createApiError( + `Server error: Received HTML instead of JSON (${response.status} ${response.statusText}). This may indicate an invalid URL or server issue.`, + response.status, + errorText, + undefined, + retryAfterMs, + ); + } + logger.warn("Failed to parse JSON error response", { + ...logExtra, + bodyPreview, + parseErrorMessage: body.parseError, + }); + } else if (body.json) { + if (body.detail !== undefined) { + return createApiError( + body.detail, + response.status, + body.detail, + body.json, + retryAfterMs, + ); + } + logger.warn("Failed to parse validated API error response", { + ...logExtra, + bodyPreview, + validationErrorMessage: "Error response has no string detail", + }); + } + + return createApiError( + `API request failed: ${response.statusText}\n${errorText}`, + response.status, + errorText, + undefined, + retryAfterMs, + ); +} + +export function createApiClientCore({ + transport, + headers, + logger, +}: ApiClientCoreOptions): ApiClientCore { + async function request( + origin: string, + path: string, + init: RequestInit = {}, + { allowStatuses }: ApiRequestOptions = {}, + ): Promise { + const url = buildSentryApiUrl(origin, path); + const requestHeaders = { + "Content-Type": "application/json", + ...headers(), + }; + + if (typeof globalThis.fetch === "undefined") { + throw new ConfigurationError( + "fetch is not available. Please use Node.js >= 18 or ensure fetch is available in your environment.", + ); + } + + let response: Response; + try { + response = await transport.request(url, { + ...init, + headers: requestHeaders, + }); + } catch (error) { + // An abort, including one during a retry wait, keeps the caller's reason. + if (init.signal?.aborted) { + throw error; + } + throw networkError(url, error); + } + + if (response.ok || allowStatuses?.includes(response.status)) { + return response; + } + throw errorFromResponse( + logger, + response, + await readErrorResponse(response), + { + status: response.status, + statusText: response.statusText, + host: new URL(origin).host, + path, + }, + ); + } + + return { + request, + + async requestJson(origin, path, init) { + return await readJsonResponse(await request(origin, path, init)); + }, + }; +} From fc6aa087567062eb400a4afa19bd7b21822e23de Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:23:13 +0000 Subject: [PATCH 2/6] ref(mcp): Send SentryApiService requests through the shared core request and requestJSON keep their signatures and delegate to createApiClientCore, so no endpoint method changes. SentryApiService supplies its headers (User-Agent, Authorization, MCP attribution), the shared transport, and logWarn under the api.client scope. Its copies of the network error messages and the error-response mapping are deleted. The one difference: the error-response warnings now log the host the request went to, rather than the service's default host, when a call overrides the host. --- docs/contributing/api-patterns.md | 10 +- packages/mcp-core/src/api-client/client.ts | 239 +++++---------------- 2 files changed, 54 insertions(+), 195 deletions(-) diff --git a/docs/contributing/api-patterns.md b/docs/contributing/api-patterns.md index c35b6d52c..8c84e662a 100644 --- a/docs/contributing/api-patterns.md +++ b/docs/contributing/api-patterns.md @@ -259,10 +259,12 @@ try { See error patterns in [common-patterns.md](common-patterns.md#error-handling). `ApiError` and its subclasses live in the shared client, -`@sentry/toolkit-core/api-client/errors`, and `SentryApiService` builds them -from `readErrorResponse` in `@sentry/toolkit-core/api-client/response`. The -CLI reads raw error responses with the same helper and maps them to its own -exit-coded `ApiError`. See +`@sentry/toolkit-core/api-client/errors`. `SentryApiService` sends requests +through the shared request core, `@sentry/toolkit-core/api-client/core`, +which builds them from `readErrorResponse` in +`@sentry/toolkit-core/api-client/response`. The CLI reads raw error +responses with the same helper and maps them to its own exit-coded +`ApiError`. See [`packages/toolkit-core/README.md`](../../packages/toolkit-core/README.md#api-client). ### Automatic Retries diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index ee799106a..ffda2320f 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -19,9 +19,9 @@ import { listProjectEventAttachments, parseSentryLinkHeader, } from "@sentry/api"; -import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request"; import { sentryBearerHeader } from "@sentry/toolkit-core/auth-token"; import type { RetryPolicy } from "@sentry/toolkit-core/api-client/retry"; +import { createApiClientCore } from "@sentry/toolkit-core/api-client/core"; import { createApiTransport } from "@sentry/toolkit-core/api-client/transport"; import { z } from "zod"; import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants"; @@ -211,11 +211,7 @@ import type { User, UserReportList, } from "./types"; -import { - type ErrorResponseBody, - readErrorResponse, - readJsonResponse, -} from "@sentry/toolkit-core/api-client/response"; +import { readJsonResponse } from "@sentry/toolkit-core/api-client/response"; // TODO: this is shared - so ideally, for safety, it uses @sentry/core, but currently // logger isnt exposed (or rather, it is, but its not the right logger) @@ -249,18 +245,6 @@ type ExplorerUrlParams = ExplorerAggregateParams & { end?: string; }; -/** - * Mapping of common network error codes to user-friendly messages. - * These help users understand and resolve connection issues. - */ -const NETWORK_ERROR_MESSAGES: Record = { - EAI_AGAIN: "DNS temporarily unavailable. Check your internet connection.", - ENOTFOUND: "Hostname not found. Verify the URL is correct.", - ECONNREFUSED: "Connection refused. Ensure the service is accessible.", - ETIMEDOUT: "Connection timed out. Check network connectivity.", - ECONNRESET: "Connection reset. Try again in a moment.", -}; - function normalizeStatsPeriod(statsPeriod?: string): string | undefined { const normalized = statsPeriod?.trim(); return normalized || undefined; @@ -622,6 +606,14 @@ export class SentryApiService { protected host: string; protected protocol: SentryProtocol; protected apiPrefix: string; + private readonly core = createApiClientCore({ + transport, + headers: () => this.requestHeaders(), + logger: { + warn: (message, extra) => + logWarn(message, { loggerScope: ["api", "client"], extra }), + }, + }); /** * Creates a new Sentry API service instance. @@ -786,80 +778,39 @@ export class SentryApiService { } } - /** Map a failed response's body to the matching `ApiError` subclass. */ - private errorFromResponse( - response: Response, - body: ErrorResponseBody, - path: string, - ): ApiError { - const { retryAfterMs } = body; - const errorText = body.text ?? ""; - const logExtra = { - status: response.status, - statusText: response.statusText, - host: this.host, - path, - }; - const bodyPreview = - errorText.length > 256 ? `${errorText.slice(0, 253)}…` : errorText; - - if (body.parseError !== undefined) { - if (body.isHtml) { - logWarn("Received HTML error page instead of JSON", { - loggerScope: ["api", "client"], - extra: { ...logExtra, parseErrorMessage: body.parseError }, - }); - // HTML response instead of JSON typically indicates a server configuration issue - return createApiError( - `Server error: Received HTML instead of JSON (${response.status} ${response.statusText}). This may indicate an invalid URL or server issue.`, - response.status, - errorText, - undefined, - retryAfterMs, - ); - } - logWarn("Failed to parse JSON error response", { - loggerScope: ["api", "client"], - extra: { ...logExtra, bodyPreview, parseErrorMessage: body.parseError }, - }); - } else if (body.json) { - if (body.detail !== undefined) { - return createApiError( - body.detail, - response.status, - body.detail, - body.json, - retryAfterMs, - ); + /** Headers for every request after `Content-Type`. */ + private requestHeaders(): Record { + const headers: Record = { "User-Agent": USER_AGENT }; + if (this.accessToken !== null) { + const authorization = sentryBearerHeader(this.accessToken); + if (authorization === null) { + throw new ConfigurationError("Malformed authentication token"); } - logWarn("Failed to parse validated API error response", { - loggerScope: ["api", "client"], - extra: { - ...logExtra, - bodyPreview, - validationErrorMessage: "Error response has no string detail", - }, - }); + headers.Authorization = authorization; } + if (this.clientId) { + headers["X-Sentry-MCP-Client-Id"] = this.clientId; + } + if (this.clientName) { + headers["X-Sentry-MCP-Client-Name"] = this.clientName; + } + if (this.clientFamily) { + headers["X-Sentry-MCP-Client-Family"] = this.clientFamily; + } + if (this.utmSource) { + headers["X-Sentry-MCP-Utm-Source"] = this.utmSource; + } + return headers; + } - // Use the error factory to create the appropriate error type based on status - return createApiError( - `API request failed: ${response.statusText}\n${errorText}`, - response.status, - errorText, - undefined, - retryAfterMs, - ); + private origin(host: string = this.host): string { + return `${this.protocol}://${host}`; } /** - * Makes an authenticated request to the Sentry API through the shared transport. - * - * Handles: - * - Bearer token authentication - * - Error response parsing and enhancement - * - Multi-region host overrides - * - Fetch availability validation + * Makes an authenticated request through the shared client core + * (`@sentry/toolkit-core/api-client/core`), which maps network failures + * and error responses to `ApiError` subclasses. * * Retries happen in the transport, never here, so they do not stack: a * rate-limited (429) request, or a GET that hits a 500/502/503/504 or a @@ -880,111 +831,14 @@ export class SentryApiService { options: RequestInit = {}, { host, allowStatuses }: { host?: string; allowStatuses?: number[] } = {}, ): Promise { - 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 authorization = sentryBearerHeader(this.accessToken); - if (authorization === null) { - throw new ConfigurationError("Malformed authentication token"); - } - headers.Authorization = authorization; - } - if (this.clientId) { - headers["X-Sentry-MCP-Client-Id"] = this.clientId; - } - if (this.clientName) { - headers["X-Sentry-MCP-Client-Name"] = this.clientName; - } - if (this.clientFamily) { - headers["X-Sentry-MCP-Client-Family"] = this.clientFamily; - } - if (this.utmSource) { - headers["X-Sentry-MCP-Utm-Source"] = this.utmSource; - } - - // Check if fetch is available, otherwise provide a helpful error message - if (typeof globalThis.fetch === "undefined") { - throw new ConfigurationError( - "fetch is not available. Please use Node.js >= 18 or ensure fetch is available in your environment.", - ); - } - - // logger.info(logger.fmt`[sentryApi] ${options.method || "GET"} ${url}`); - let response: Response; - try { - response = await transport.request(url, { - ...options, - headers, - }); - } catch (error) { - // An abort, including one during a retry wait, keeps the caller's reason. - if (options.signal?.aborted) { - throw error; - } - // Extract the root cause from the error chain - let rootCause = error; - while (rootCause instanceof Error && rootCause.cause) { - rootCause = rootCause.cause; - } - - const errorMessage = - rootCause instanceof Error ? rootCause.message : String(rootCause); - - let friendlyMessage = `Unable to connect to ${url}`; - - // Check if we have a specific message for this error - const errorCode = Object.keys(NETWORK_ERROR_MESSAGES).find((code) => - errorMessage.includes(code), - ); - - if (errorCode) { - friendlyMessage += ` - ${NETWORK_ERROR_MESSAGES[errorCode]}`; - } else { - friendlyMessage += ` - ${errorMessage}`; - } - - // DNS resolution failures and connection timeouts to custom hosts are configuration issues - if ( - errorCode === "ENOTFOUND" || - errorCode === "EAI_AGAIN" || - errorCode === "ECONNREFUSED" || - errorCode === "ETIMEDOUT" || - errorMessage.includes("Connect Timeout Error") - ) { - throw new ConfigurationError(friendlyMessage, { cause: error }); - } - - throw new Error(friendlyMessage, { cause: error }); - } - - // Handle error responses generically - if (!response.ok) { - if (allowStatuses?.includes(response.status)) { - return response; - } - throw this.errorFromResponse( - response, - await readErrorResponse(response), - path, - ); - } - - return response; + return await this.core.request(this.origin(host), path, options, { + allowStatuses, + }); } /** - * Makes a request to the Sentry API and parses the JSON response. - * - * This is the primary method for API calls that expect JSON responses. - * It automatically validates Content-Type and provides helpful error messages - * for common issues like authentication failures or server errors. + * Makes a request to the Sentry API and parses the JSON response, + * checking its Content-Type. * * @param path API endpoint path (without /api/0 prefix) * @param options Fetch options @@ -998,13 +852,16 @@ export class SentryApiService { options: RequestInit = {}, requestOptions?: { host?: string }, ): Promise { - const response = await this.request(path, options, requestOptions); - return await readJsonResponse(response); + return await this.core.requestJson( + this.origin(requestOptions?.host), + path, + options, + ); } /** Let SDK operations build paths and queries while MCP owns transport and errors. */ private sdkClient(expectedPath: string, host: string = this.host) { - const origin = new URL(`${this.protocol}://${host}`).origin; + const origin = new URL(this.origin(host)).origin; return createSentryClient({ baseUrl: origin, fetch: async (sdkRequest) => { From 6a05ee6db5775a8f18c4f678048551ea6a5e4a78 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:40:34 +0000 Subject: [PATCH 3/6] chore(toolkit-core): Depend on @sentry/api with the TypeScript the products use The shared client builds on @sentry/api operations next. toolkit-core pinned TypeScript through the ^5.8.3 catalog (5.8.3) while mcp-core and the CLI resolve 5.9.3, so adding @sentry/api resolved a second copy with other peers. Using ^5.9.3, as the CLI does, keeps one copy. TypeScript 5.9's DOM BodyInit takes only ArrayBuffer-backed views, so the cache policy now says so when it builds a Response from a decoded body. No runtime change. --- packages/toolkit-core/package.json | 3 +- .../src/api-client/response-cache/policy.ts | 13 ++++- pnpm-lock.yaml | 50 +++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 1e2bc943f..fcedd61ad 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -78,6 +78,7 @@ "tsc": "tsc --noEmit && tsc -p tsconfig.browser.json" }, "dependencies": { + "@sentry/api": "catalog:", "@sentry/search-syntax": "catalog:", "@standard-schema/spec": "1.1.0", "http-cache-semantics": "^4.2.0" @@ -85,7 +86,7 @@ "devDependencies": { "@types/http-cache-semantics": "^4.2.0", "fast-check": "^4.8.0", - "typescript": "catalog:", + "typescript": "^5.9.3", "valibot": "^1.4.2", "vitest": "catalog:", "zod": "catalog:" diff --git a/packages/toolkit-core/src/api-client/response-cache/policy.ts b/packages/toolkit-core/src/api-client/response-cache/policy.ts index c9e477f25..9ebd3ac5b 100644 --- a/packages/toolkit-core/src/api-client/response-cache/policy.ts +++ b/packages/toolkit-core/src/api-client/response-cache/policy.ts @@ -171,6 +171,15 @@ function buildResponseHeaders( return result; } +/** + * A decoded body as a `Response` body. TypeScript 5.9's DOM `BodyInit` + * takes only `ArrayBuffer`-backed views; stored bodies never sit on a + * `SharedArrayBuffer`. + */ +function responseBody(bytes: Uint8Array): Uint8Array { + return bytes as Uint8Array; +} + /** A `stale` lookup for an entry with an `ETag`, or `undefined` without one. */ function staleLookup( options: LookupOptions, @@ -213,7 +222,7 @@ function staleLookup( store .set(key, renewed, retentionMs(result.policy, entry.tier)) .catch((error: unknown) => onRenewError?.(error)); - return new Response(body, { + return new Response(responseBody(body), { status: entry.status, headers: buildResponseHeaders(result.policy, entry), }); @@ -253,7 +262,7 @@ export async function lookupResponse( } return { kind: "hit", - response: new Response(body, { + response: new Response(responseBody(body), { status: entry.status, headers: buildResponseHeaders(policy, entry), }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f79e03b94..81e549e7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1021,6 +1021,9 @@ importers: packages/toolkit-core: dependencies: + '@sentry/api': + specifier: 'catalog:' + version: 0.425.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/search-syntax': specifier: 'catalog:' version: 1.0.0 @@ -1038,14 +1041,14 @@ importers: specifier: ^4.8.0 version: 4.10.1 typescript: - specifier: 'catalog:' - version: 5.8.3 + specifier: ^5.9.3 + version: 5.9.3 valibot: specifier: ^1.4.2 - version: 1.4.2(typescript@5.8.3) + version: 1.4.2(typescript@5.9.3) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) zod: specifier: 'catalog:' version: 4.6.7 @@ -13552,6 +13555,15 @@ snapshots: msw: 2.10.2(@types/node@24.13.5)(typescript@5.9.3) vite: 6.4.5(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.13)(yaml@2.9.0) + '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.10.2(@types/node@24.13.5)(typescript@5.9.3) + vite: 8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.0 @@ -18724,6 +18736,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.13.5 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + jsdom: 28.1.0 + transitivePeerDependencies: + - msw + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 From b0475be85d325c6b590cb8102df49bef8d04fceb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:45:13 +0000 Subject: [PATCH 4/6] feat(toolkit-core): Give the request core an SDK client and a page walk sdkClient gives a @sentry/api operation a client that sends one GET to an expected path through the core's request, and rejects any other target or method. listPage walks a list endpoint up to a limit, asking for exactly the rows still missing, and validates each page with the host's Standard Schema. Both are the MCP's sdkClient and sdkListPage, moved as is apart from validation. --- .../toolkit-core/src/api-client/core.test.ts | 65 ++++++++- packages/toolkit-core/src/api-client/core.ts | 124 +++++++++++++++++- 2 files changed, 186 insertions(+), 3 deletions(-) diff --git a/packages/toolkit-core/src/api-client/core.test.ts b/packages/toolkit-core/src/api-client/core.test.ts index fb097ecc5..b002e63f4 100644 --- a/packages/toolkit-core/src/api-client/core.test.ts +++ b/packages/toolkit-core/src/api-client/core.test.ts @@ -1,11 +1,18 @@ import { describe, expect, it, vi } from "vitest"; -import { type ApiRequestTransport, createApiClientCore } from "./core.js"; +import { z } from "zod"; +import { + type ApiRequestTransport, + createApiClientCore, + listPage, +} from "./core.js"; import { ApiNotFoundError, ApiRateLimitError, ApiServerError, + ApiValidationError, ConfigurationError, } from "./errors.js"; +import { ApiResponseValidationError } from "./validate.js"; const ORIGIN = "https://us.sentry.io"; @@ -248,3 +255,59 @@ describe("createApiClientCore requestJson", () => { ); }); }); + +describe("listPage", () => { + const rows = z.array(z.object({ id: z.number() })); + + function page(ids: number[], nextCursor?: string) { + const headers: Record = nextCursor + ? { + link: `<${ORIGIN}/api/0/x/?cursor=${nextCursor}>; rel="next"; results="true"; cursor="${nextCursor}"`, + } + : {}; + return { + data: ids.map((id) => ({ id })), + response: new Response(null, { headers }), + }; + } + + it.each([0, 1.5, 5001])("rejects limit %s", async (limit) => { + await expect( + listPage(async () => page([]), rows, limit), + ).rejects.toBeInstanceOf(ApiValidationError); + }); + + it("rejects a page with more rows than requested", async () => { + await expect( + listPage(async () => page([1, 2, 3]), rows, 2), + ).rejects.toThrow("API returned more items than requested"); + }); + + it("rejects a repeated cursor", async () => { + await expect( + listPage(async () => page([1], "0:1:0"), rows, 5), + ).rejects.toThrow("API returned a repeated pagination cursor"); + }); + + it("validates each page with the schema", async () => { + await expect( + listPage( + async () => ({ data: [{ id: "x" }], response: new Response(null) }), + rows, + 5, + ), + ).rejects.toBeInstanceOf(ApiResponseValidationError); + }); +}); + +describe("createApiClientCore sdkClient", () => { + it("rejects an SDK request to another path", async () => { + const { core, transport } = setup(async () => jsonResponse([])); + const client = core.sdkClient(ORIGIN, "/organizations/"); + + await expect( + client.get({ url: "/api/0/projects/", throwOnError: true }), + ).rejects.toThrow("Unexpected SDK request target or method"); + expect(transport.request).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/toolkit-core/src/api-client/core.ts b/packages/toolkit-core/src/api-client/core.ts index e55399d6e..1d943b4f4 100644 --- a/packages/toolkit-core/src/api-client/core.ts +++ b/packages/toolkit-core/src/api-client/core.ts @@ -1,19 +1,33 @@ /** * The request core that endpoint wrappers build on. It sends a request * through the host's transport, turns network failures and error responses - * into the shared errors, and reads JSON bodies. + * into the shared errors, and reads JSON bodies. `@sentry/api` operations + * get a client that sends through the same path, and {@link listPage} walks + * their pages. * * The host supplies the transport (retries, caching), the per-request * headers (credentials, user agent, attribution), and logging. */ +import { + createSentryClient, + parseSentryLinkHeader, + type SentryClient, +} from "@sentry/api"; import { buildSentryApiUrl } from "../api-request.js"; -import { type ApiError, ConfigurationError, createApiError } from "./errors.js"; +import { + type ApiError, + ApiValidationError, + ConfigurationError, + createApiError, +} from "./errors.js"; +import { API_MAX_PER_PAGE } from "./pagination.js"; import { type ErrorResponseBody, readErrorResponse, readJsonResponse, } from "./response.js"; +import { type ResponseSchema, validateResponse } from "./validate.js"; /** What the core needs from the host's transport. */ export type ApiRequestTransport = { @@ -60,8 +74,23 @@ export type ApiClientCore = { path: string, init?: RequestInit, ): Promise; + /** + * A `@sentry/api` client for one GET to `expectedPath` on `origin`. The + * operation builds the path and query; the request goes through + * {@link ApiClientCore.request}. Any other target or method throws. + */ + sdkClient(origin: string, expectedPath: string): SentryClient["client"]; }; +/** Where an endpoint wrapper sends: the core, and the region or host origin. */ +export type ApiTarget = { core: ApiClientCore; origin: string }; + +/** Most pages {@link listPage} fetches for one call. */ +export const MAX_LIST_PAGES = 50; + +/** Rows from {@link listPage}, and the cursor for the next call. */ +export type ListPage = { items: T[]; nextCursor: string | null }; + /** Readable messages for common network error codes. */ const NETWORK_ERROR_MESSAGES: Record = { EAI_AGAIN: "DNS temporarily unavailable. Check your internet connection.", @@ -71,6 +100,8 @@ const NETWORK_ERROR_MESSAGES: Record = { ECONNRESET: "Connection reset. Try again in a moment.", }; +const API_PREFIX = "/api/0"; + /** Network failures that mean the host is wrong or unreachable. */ const CONFIGURATION_ERROR_CODES = new Set([ "ENOTFOUND", @@ -219,5 +250,94 @@ export function createApiClientCore({ async requestJson(origin, path, init) { return await readJsonResponse(await request(origin, path, init)); }, + + sdkClient(origin, expectedPath) { + const base = new URL(origin).origin; + return createSentryClient({ + baseUrl: base, + fetch: async (sdkRequest) => { + if (!(sdkRequest instanceof Request)) { + throw new ConfigurationError("Unexpected SDK request input"); + } + const url = new URL(sdkRequest.url); + if ( + url.origin !== base || + url.pathname !== API_PREFIX.concat(expectedPath) || + sdkRequest.method !== "GET" + ) { + throw new ConfigurationError( + "Unexpected SDK request target or method", + ); + } + const response = await request( + origin, + `${url.pathname.slice(API_PREFIX.length)}${url.search}`, + { method: "GET", signal: sdkRequest.signal }, + ); + const contentType = response.headers.get("content-type"); + if (!contentType?.includes("application/json")) { + await readJsonResponse(response); + } + return response; + }, + }).client; + }, }; } + +/** + * Fetch up to `limit` rows from a list endpoint, starting at `cursor`. + * + * Each page asks for exactly the rows still missing, up to + * {@link API_MAX_PER_PAGE}, and is validated with `schema`. The walk stops + * once it has `limit` rows or the server has no next page, and returns the + * server's cursor for the page after the last one fetched. + * + * @throws {ApiValidationError} When `limit` is not between 1 and + * `API_MAX_PER_PAGE * MAX_LIST_PAGES` + * @throws {ApiResponseValidationError} When a page does not match `schema` + * @throws {Error} When a page has more rows than requested, a cursor + * repeats, or the walk passes {@link MAX_LIST_PAGES} + */ +export async function listPage( + fetchPage: ( + perPage: number, + cursor: string | undefined, + ) => Promise<{ data: unknown; response: Response }>, + schema: ResponseSchema, + limit: number, + initialCursor?: string | null, +): Promise> { + if ( + !Number.isSafeInteger(limit) || + limit < 1 || + limit > API_MAX_PER_PAGE * MAX_LIST_PAGES + ) { + throw new ApiValidationError( + `limit must be an integer between 1 and ${API_MAX_PER_PAGE * MAX_LIST_PAGES}`, + ); + } + const items: T[] = []; + const seenCursors = new Set(); + let cursor = initialCursor ?? undefined; + for (let page = 0; page < MAX_LIST_PAGES; page += 1) { + const perPage = Math.min(API_MAX_PER_PAGE, limit - items.length); + const { data, response } = await fetchPage(perPage, cursor); + const rows = validateResponse(schema, data); + if (rows.length > perPage) { + throw new Error("API returned more items than requested"); + } + items.push(...rows); + const nextCursor = + parseSentryLinkHeader(response.headers.get("link")).nextCursor ?? null; + if (items.length >= limit || !nextCursor) { + return { items, nextCursor }; + } + if (seenCursors.has(nextCursor) || nextCursor === cursor) { + throw new Error("API returned a repeated pagination cursor"); + } + seenCursors.add(nextCursor); + cursor = nextCursor; + } + throw new Error("API pagination exceeded its page limit"); +} From 21c83030413939148417a13e5da6a91935b7050e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:45:56 +0000 Subject: [PATCH 5/6] ref(mcp): Call shared organization endpoints from SentryApiService endpoints/organizations in toolkit-core has getAuthenticatedUser, listOrganizations, and getOrganization. Each takes an ApiTarget (the request core and an origin) and the host's Standard Schema, and returns that schema's output. SentryApiService calls them with its Zod schemas and keeps choosing the host: the control host for the current user and the organization list on public SaaS. Its sdkClient and the teams and projects lists now use the core's sdkClient and listPage; its own copies are deleted, and so is getAuthenticatedUser's unused options argument. A response that fails its schema in these calls now throws the shared ApiResponseValidationError instead of a ZodError. Both reach the tool error handler as unexpected errors. --- docs/contributing/api-patterns.md | 6 +- packages/mcp-core/src/api-client/client.ts | 152 ++++------------- packages/toolkit-core/README.md | 12 +- .../endpoints/organizations.test.ts | 159 ++++++++++++++++++ .../src/api-client/endpoints/organizations.ts | 78 +++++++++ 5 files changed, 283 insertions(+), 124 deletions(-) create mode 100644 packages/toolkit-core/src/api-client/endpoints/organizations.test.ts create mode 100644 packages/toolkit-core/src/api-client/endpoints/organizations.ts diff --git a/docs/contributing/api-patterns.md b/docs/contributing/api-patterns.md index 8c84e662a..6a00c581e 100644 --- a/docs/contributing/api-patterns.md +++ b/docs/contributing/api-patterns.md @@ -264,7 +264,11 @@ through the shared request core, `@sentry/toolkit-core/api-client/core`, which builds them from `readErrorResponse` in `@sentry/toolkit-core/api-client/response`. The CLI reads raw error responses with the same helper and maps them to its own exit-coded -`ApiError`. See +`ApiError`. Endpoints that have moved to the shared client live in +`@sentry/toolkit-core/api-client/endpoints/*`; `SentryApiService` calls +them with its Zod schemas, and a malformed response throws +`ApiResponseValidationError` from +`@sentry/toolkit-core/api-client/validate`. See [`packages/toolkit-core/README.md`](../../packages/toolkit-core/README.md#api-client). ### Automatic Retries diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index ffda2320f..7b6ae29c1 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -3,7 +3,6 @@ import { isAggregateFieldOrEquation, } from "@sentry/toolkit-core/events-fields"; import { - createSentryClient, type GetOrganizationIssueEventData, getOrganizationIssue, getOrganizationIssueAutofixState, @@ -15,13 +14,21 @@ import { listOrganizationIssues, listOrganizationProjects, listOrganizationTeams, - listOrganizations, listProjectEventAttachments, parseSentryLinkHeader, } from "@sentry/api"; import { sentryBearerHeader } from "@sentry/toolkit-core/auth-token"; import type { RetryPolicy } from "@sentry/toolkit-core/api-client/retry"; -import { createApiClientCore } from "@sentry/toolkit-core/api-client/core"; +import { + type ApiTarget, + createApiClientCore, + listPage, +} from "@sentry/toolkit-core/api-client/core"; +import { + getAuthenticatedUser, + getOrganization, + listOrganizations, +} from "@sentry/toolkit-core/api-client/endpoints/organizations"; import { createApiTransport } from "@sentry/toolkit-core/api-client/transport"; import { z } from "zod"; import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants"; @@ -322,9 +329,6 @@ const REQUEST_RETRY_POLICY: RetryPolicy = { */ const transport = createApiTransport({ retry: REQUEST_RETRY_POLICY }); -const API_MAX_PER_PAGE = 100; -const MAX_LIST_PAGES = 50; - /** * Default cap for returning attachment bytes inline over the MCP transport. * A 30 MB attachment takes ~80 MB in memory once base64-encoded and serialized, @@ -859,80 +863,14 @@ export class SentryApiService { ); } - /** Let SDK operations build paths and queries while MCP owns transport and errors. */ + /** Let SDK operations build paths and queries while the core owns transport and errors. */ private sdkClient(expectedPath: string, host: string = this.host) { - const origin = new URL(this.origin(host)).origin; - return createSentryClient({ - baseUrl: origin, - fetch: async (sdkRequest) => { - if (!(sdkRequest instanceof Request)) { - throw new ConfigurationError("Unexpected SDK request input"); - } - const url = new URL(sdkRequest.url); - if ( - url.origin !== origin || - url.pathname !== "/api/0".concat(expectedPath) || - sdkRequest.method !== "GET" - ) { - throw new ConfigurationError( - "Unexpected SDK request target or method", - ); - } - const path = `${url.pathname.slice("/api/0".length)}${url.search}`; - const response = await this.request( - path, - { method: "GET", signal: sdkRequest.signal }, - { host }, - ); - const contentType = response.headers.get("content-type"); - if (!contentType?.includes("application/json")) { - await readJsonResponse(response); - } - return response; - }, - }).client; + return this.core.sdkClient(this.origin(host), expectedPath); } - private async sdkListPage( - fetchPage: ( - perPage: number, - cursor: string | undefined, - ) => Promise<{ data: unknown; response: Response }>, - schema: z.ZodType, - limit: number, - initialCursor?: string | null, - ): Promise<{ items: T[]; nextCursor: string | null }> { - if ( - !Number.isSafeInteger(limit) || - limit < 1 || - limit > API_MAX_PER_PAGE * MAX_LIST_PAGES - ) { - throw new ApiValidationError( - `limit must be an integer between 1 and ${API_MAX_PER_PAGE * MAX_LIST_PAGES}`, - ); - } - const items: T[] = []; - const seenCursors = new Set(); - let cursor = initialCursor ?? undefined; - for (let page = 0; page < MAX_LIST_PAGES; page += 1) { - const perPage = Math.min(API_MAX_PER_PAGE, limit - items.length); - const { data, response } = await fetchPage(perPage, cursor); - const rows = schema.parse(data); - if (rows.length > perPage) { - throw new Error("API returned more items than requested"); - } - items.push(...rows); - const nextCursor = getNextCursor(response.headers.get("link")); - if (items.length >= limit || !nextCursor) { - return { items, nextCursor }; - } - if (seenCursors.has(nextCursor) || nextCursor === cursor) { - throw new Error("API returned a repeated pagination cursor"); - } - seenCursors.add(nextCursor); - cursor = nextCursor; - } - throw new Error("API pagination exceeded its page limit"); + /** The core and origin a shared endpoint wrapper sends to. */ + private target(host?: string): ApiTarget { + return { core: this.core, origin: this.origin(host) }; } /** @@ -1516,24 +1454,16 @@ export class SentryApiService { /** * Retrieves the authenticated user's profile information. * - * @param opts Request options including host override * @returns User profile data * @throws {ApiError} If authentication fails or user not found */ - async getAuthenticatedUser(opts?: RequestOptions): Promise { - // User identity belongs to the deployment's control host. - let authHost: string | undefined; - - if (this.isPublicSaas()) { - authHost = "sentry.io"; - } - // Single-tenant and self-hosted deployments keep their configured host. - - const body = await this.requestJSON("/auth/", undefined, { - ...opts, - host: authHost, - }); - return UserSchema.parse(body); + async getAuthenticatedUser(): Promise { + // User identity belongs to the deployment's control host. Single-tenant + // and self-hosted deployments keep their configured host. + return await getAuthenticatedUser( + this.target(this.isPublicSaas() ? "sentry.io" : undefined), + UserSchema, + ); } /** @@ -1563,20 +1493,10 @@ export class SentryApiService { }): Promise<{ organizations: OrganizationList; nextCursor: string | null }> { // Public SaaS lists across regions on sentry.io; single-tenant instances // keep organization discovery on their configured host. - const client = this.sdkClient( - "/organizations/", - this.isPublicSaas() ? "sentry.io" : this.host, - ); - const { items, nextCursor } = await this.sdkListPage( - (perPage, cursor) => - listOrganizations({ - client, - query: { per_page: perPage, query: params?.query, cursor }, - throwOnError: true, - }), + const { items, nextCursor } = await listOrganizations( + this.target(this.isPublicSaas() ? "sentry.io" : this.host), + params ?? {}, OrganizationListSchema, - params?.limit ?? 25, - params?.cursor, ); return { organizations: items, @@ -1599,21 +1519,11 @@ export class SentryApiService { params?: { includeFeatureFlags?: boolean; detailed?: boolean }, opts?: RequestOptions, ) { - const queryParams = new URLSearchParams(); - if (params?.includeFeatureFlags) { - queryParams.set("include_feature_flags", "1"); - } - if (params?.detailed === false) { - queryParams.set("detailed", "0"); - } - const queryString = queryParams.toString(); - const organizationPath = apiPath`/organizations/${organizationSlug}/`; - const body = await this.requestJSON( - `${organizationPath}${queryString ? `?${queryString}` : ""}`, - undefined, - opts, + return await getOrganization( + this.target(opts?.host), + { organizationSlug, ...params }, + OrganizationSchema, ); - return OrganizationSchema.parse(body); } /** @@ -1634,7 +1544,7 @@ export class SentryApiService { ): Promise<{ teams: TeamList; nextCursor: string | null }> { const teamsPath = apiPath`/organizations/${organizationSlug}/teams/`; const client = this.sdkClient(teamsPath, opts?.host); - const { items, nextCursor } = await this.sdkListPage( + const { items, nextCursor } = await listPage( (perPage, cursor) => listOrganizationTeams({ client, @@ -1701,7 +1611,7 @@ export class SentryApiService { ): Promise<{ projects: ProjectList; nextCursor: string | null }> { const projectsPath = apiPath`/organizations/${organizationSlug}/projects/`; const client = this.sdkClient(projectsPath, opts?.host); - const { items, nextCursor } = await this.sdkListPage( + const { items, nextCursor } = await listPage( (perPage, cursor) => listOrganizationProjects({ client, diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index ba125a3e5..e3a043047 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -43,8 +43,16 @@ the one exception. to `/api/0`, turns network failures into readable errors (`ConfigurationError` for an unreachable host), and throws the status's `ApiError` for an error response unless the caller allows that status. - `requestJson` also parses the body. The MCP's `SentryApiService` sends - every request through it. + `requestJson` also parses the body, and `sdkClient` gives a `@sentry/api` + operation a client that sends one GET through `request`. `listPage` walks + a list endpoint's pages up to a limit and validates each page. The MCP's + `SentryApiService` sends every request through the core. +- `endpoints/` holds the endpoint wrappers by domain, on `@sentry/api` + operations where they exist. Each takes an `ApiTarget` (the core and an + origin) and the host's schema, and returns that schema's output. The host + picks the origin, such as the control host for the current user. + `endpoints/organizations` has `getAuthenticatedUser`, `listOrganizations`, + and `getOrganization`. - `pagination`, `retry`, `transport`, and `response-cache` are described in the sections below. The `node:zlib` body codec stays outside, at `response-cache/codec`, since browsers lack it. diff --git a/packages/toolkit-core/src/api-client/endpoints/organizations.test.ts b/packages/toolkit-core/src/api-client/endpoints/organizations.test.ts new file mode 100644 index 000000000..a087f4bd6 --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/organizations.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; +import * as v from "valibot"; +import { z } from "zod"; +import { type ApiTarget, createApiClientCore } from "../core.js"; +import { ApiResponseValidationError } from "../validate.js"; +import { + getAuthenticatedUser, + getOrganization, + listOrganizations, +} from "./organizations.js"; + +const ORIGIN = "https://us.sentry.io"; + +function jsonResponse(body: unknown, headers: Record = {}) { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json", ...headers }, + }); +} + +function nextLink(cursor: string) { + return { + link: `<${ORIGIN}/api/0/organizations/?cursor=${cursor}>; rel="next"; results="true"; cursor="${cursor}"`, + }; +} + +/** A target whose transport answers each request with the next response. */ +function target(...responses: Response[]) { + const request = vi.fn(async (_url: string, _init: RequestInit) => { + const response = responses.shift(); + if (!response) { + throw new Error("unexpected request"); + } + return response; + }); + const core = createApiClientCore({ + transport: { request }, + headers: () => ({}), + logger: { warn: vi.fn() }, + }); + const apiTarget: ApiTarget = { core, origin: ORIGIN }; + return { apiTarget, request }; +} + +const OrganizationZod = z.object({ slug: z.string() }); +const OrganizationValibot = v.object({ slug: v.string() }); + +describe("getAuthenticatedUser", () => { + it("reads /auth/ and returns the host schema's output", async () => { + const { apiTarget, request } = target( + jsonResponse({ id: "1", email: "jane@example.com", extra: true }), + ); + + const user = await getAuthenticatedUser( + apiTarget, + z.object({ id: z.string(), email: z.string() }), + ); + + expect(user).toEqual({ id: "1", email: "jane@example.com" }); + expect(request.mock.calls[0]?.[0]).toBe(`${ORIGIN}/api/0/auth/`); + }); + + it("throws the shared validation error for a mismatched body", async () => { + const { apiTarget } = target(jsonResponse({ id: 1 })); + + await expect( + getAuthenticatedUser(apiTarget, z.object({ id: z.string() })), + ).rejects.toBeInstanceOf(ApiResponseValidationError); + }); +}); + +describe("getOrganization", () => { + it.each([ + [{}, ""], + [{ includeFeatureFlags: true }, "?include_feature_flags=1"], + [{ detailed: false }, "?detailed=0"], + [{ detailed: true }, ""], + ])("sends %o as %s", async (params, query) => { + const { apiTarget, request } = target(jsonResponse({ slug: "acme" })); + + await getOrganization( + apiTarget, + { organizationSlug: "acme", ...params }, + OrganizationZod, + ); + + expect(request.mock.calls[0]?.[0]).toBe( + `${ORIGIN}/api/0/organizations/acme/${query}`, + ); + }); + + it("encodes the slug as a path segment", async () => { + const { apiTarget, request } = target(jsonResponse({ slug: "a/b" })); + + await getOrganization( + apiTarget, + { organizationSlug: "a/b" }, + OrganizationValibot, + ); + + expect(request.mock.calls[0]?.[0]).toBe( + `${ORIGIN}/api/0/organizations/a%2Fb/`, + ); + }); +}); + +describe("listOrganizations", () => { + it("follows next cursors until it has the limit", async () => { + const { apiTarget, request } = target( + jsonResponse([{ slug: "a" }, { slug: "b" }], nextLink("0:2:0")), + jsonResponse([{ slug: "c" }], nextLink("0:3:0")), + ); + + const page = await listOrganizations( + apiTarget, + { query: "ac", limit: 3 }, + z.array(OrganizationZod), + ); + + expect(page).toEqual({ + items: [{ slug: "a" }, { slug: "b" }, { slug: "c" }], + nextCursor: "0:3:0", + }); + const urls = request.mock.calls.map(([url]) => new URL(url)); + expect(urls.map((url) => url.pathname)).toEqual([ + "/api/0/organizations/", + "/api/0/organizations/", + ]); + expect(urls[0]?.searchParams.get("per_page")).toBe("3"); + expect(urls[0]?.searchParams.get("query")).toBe("ac"); + expect(urls[1]?.searchParams.get("per_page")).toBe("1"); + expect(urls[1]?.searchParams.get("cursor")).toBe("0:2:0"); + }); + + it("returns no cursor once the server has no next page", async () => { + const { apiTarget } = target(jsonResponse([{ slug: "a" }])); + + const page = await listOrganizations( + apiTarget, + {}, + v.array(OrganizationValibot), + ); + + expect(page).toEqual({ items: [{ slug: "a" }], nextCursor: null }); + }); + + it("starts from the given cursor", async () => { + const { apiTarget, request } = target(jsonResponse([])); + + await listOrganizations( + apiTarget, + { cursor: "0:25:0" }, + z.array(OrganizationZod), + ); + + expect( + new URL(request.mock.calls[0]?.[0] ?? "").searchParams.get("cursor"), + ).toBe("0:25:0"); + }); +}); diff --git a/packages/toolkit-core/src/api-client/endpoints/organizations.ts b/packages/toolkit-core/src/api-client/endpoints/organizations.ts new file mode 100644 index 000000000..69691c8ca --- /dev/null +++ b/packages/toolkit-core/src/api-client/endpoints/organizations.ts @@ -0,0 +1,78 @@ +/** + * Organization and current-user endpoints. Each wrapper validates the + * response with the schema the host passes, so the host's schema decides the + * result type. Hosts pick the origin: user identity and the organization + * list live on the control host, an organization on its region. + */ + +import { listOrganizations as listOrganizationsOperation } from "@sentry/api"; +import { apiPath } from "../api-path.js"; +import { type ApiTarget, type ListPage, listPage } from "../core.js"; +import { type ResponseSchema, validateResponse } from "../validate.js"; + +/** The user the request's credential belongs to (`GET /auth/`). */ +export async function getAuthenticatedUser( + { core, origin }: ApiTarget, + schema: ResponseSchema, +): Promise { + return validateResponse(schema, await core.requestJson(origin, "/auth/")); +} + +export type ListOrganizationsParams = { + /** Filters by name or slug. */ + query?: string; + /** Most organizations to return. Default: 25. */ + limit?: number; + /** Resumes from a previous call's `nextCursor`. */ + cursor?: string | null; +}; + +/** A page of the organizations the credential can access. */ +export async function listOrganizations( + { core, origin }: ApiTarget, + params: ListOrganizationsParams, + schema: ResponseSchema, +): Promise> { + const client = core.sdkClient(origin, "/organizations/"); + return await listPage( + (perPage, cursor) => + listOrganizationsOperation({ + client, + query: { per_page: perPage, query: params.query, cursor }, + throwOnError: true, + }), + schema, + params.limit ?? 25, + params.cursor, + ); +} + +export type GetOrganizationParams = { + organizationSlug: string; + /** Include `features`, which Sentry omits otherwise. */ + includeFeatureFlags?: boolean; + /** Set false to leave out projects and teams; Sentry defaults to true. */ + detailed?: boolean; +}; + +/** One organization by slug. */ +export async function getOrganization( + { core, origin }: ApiTarget, + params: GetOrganizationParams, + schema: ResponseSchema, +): Promise { + const query = new URLSearchParams(); + if (params.includeFeatureFlags) { + query.set("include_feature_flags", "1"); + } + if (params.detailed === false) { + query.set("detailed", "0"); + } + const queryString = query.toString(); + const path = apiPath`/organizations/${params.organizationSlug}/`; + const body = await core.requestJson( + origin, + `${path}${queryString ? `?${queryString}` : ""}`, + ); + return validateResponse(schema, body); +} From e4ce5c407eead8f095f48a03596f2fd2fd2bb068 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:54:15 +0000 Subject: [PATCH 6/6] chore(deps): Bump the catalog's TypeScript to ^5.9.3 Instead of pinning ^5.9.3 in toolkit-core alone, the pnpm catalog moves from ^5.8.3 to ^5.9.3, and toolkit-core and the CLI go back to `catalog:`. Every workspace package now resolves the same TypeScript 5.9.3, and 5.8.3 drops out of the lockfile. --- packages/cli/package.json | 2 +- packages/toolkit-core/package.json | 2 +- pnpm-lock.yaml | 221 ++++++----------------------- pnpm-workspace.yaml | 2 +- 4 files changed, 43 insertions(+), 184 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 0ae7c1743..d5f2cc2a2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -151,7 +151,7 @@ "string-width": "^8.2.1", "tinyglobby": "^0.2.17", "tsx": "^4.22.4", - "typescript": "^5.9.3", + "typescript": "catalog:", "uuidv7": "^1.2.1", "valibot": "^1.4.2", "vitest": "^4.1.11", diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index fcedd61ad..063aece6a 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -86,7 +86,7 @@ "devDependencies": { "@types/http-cache-semantics": "^4.2.0", "fast-check": "^4.8.0", - "typescript": "^5.9.3", + "typescript": "catalog:", "valibot": "^1.4.2", "vitest": "catalog:", "zod": "catalog:" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81e549e7a..c349635fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,8 +163,8 @@ catalogs: specifier: ^1.3.4 version: 1.3.5 typescript: - specifier: ^5.8.3 - version: 5.8.3 + specifier: ^5.9.3 + version: 5.9.3 valibot: specifier: ^1.4.2 version: 1.4.2 @@ -241,19 +241,19 @@ importers: version: 2.13.1 tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(typescript@5.9.3) tsx: specifier: 'catalog:' version: 4.20.3 typescript: specifier: 'catalog:' - version: 5.8.3 + version: 5.9.3 valibot: specifier: 'catalog:' - version: 1.4.2(typescript@5.8.3) + version: 1.4.2(typescript@5.9.3) vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) vitest-evals: specifier: 'catalog:' version: 0.4.0(tinyrainbow@3.1.0)(vitest@4.1.11) @@ -337,7 +337,7 @@ importers: dependencies: '@hookform/resolvers': specifier: ^5.9.1 - version: 5.9.1(@standard-schema/spec@1.1.0)(ajv@8.20.0)(react-hook-form@7.90.0(@types/react@19.3.0)(react@19.2.8))(valibot@1.4.2(typescript@5.8.3))(zod@4.6.5) + version: 5.9.1(@standard-schema/spec@1.1.0)(ajv@8.20.0)(react-hook-form@7.90.0(@types/react@19.3.0)(react@19.2.8))(valibot@1.4.2(typescript@5.9.3))(zod@4.6.5) '@radix-ui/react-dialog': specifier: ^1.1.23 version: 1.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -412,7 +412,7 @@ importers: version: 2.8.1 valibot: specifier: 'catalog:' - version: 1.4.2(typescript@5.8.3) + version: 1.4.2(typescript@5.9.3) devDependencies: '@spotlightjs/spotlight': specifier: ^4.11.7 @@ -443,13 +443,13 @@ importers: version: 1.87.0(oxlint-tsgolint@7.0.2004) typescript: specifier: 'catalog:' - version: 5.8.3 + version: 5.9.3 vite: specifier: ^8.3.3 version: 8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) vitest: specifier: ^4.1.9 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages/cli: devDependencies: @@ -616,7 +616,7 @@ importers: specifier: ^4.22.4 version: 4.23.13 typescript: - specifier: ^5.9.3 + specifier: 'catalog:' version: 5.9.3 uuidv7: specifier: ^1.2.1 @@ -887,16 +887,16 @@ importers: version: 22.16.0 tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(typescript@5.9.3) tsx: specifier: 'catalog:' version: 4.20.3 typescript: specifier: 'catalog:' - version: 5.8.3 + version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) packages/mcp-server-evals: dependencies: @@ -929,13 +929,13 @@ importers: version: 16.6.1 msw: specifier: 'catalog:' - version: 2.10.2(@types/node@24.13.5)(typescript@5.8.3) + version: 2.10.2(@types/node@24.13.5)(typescript@5.9.3) typescript: specifier: 'catalog:' - version: 5.8.3 + version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) vitest-evals: specifier: 'catalog:' version: 0.4.0(tinyrainbow@3.1.0)(vitest@4.1.11) @@ -999,16 +999,16 @@ importers: devDependencies: tsdown: specifier: 'catalog:' - version: 0.12.9(typescript@5.8.3) + version: 0.12.9(typescript@5.9.3) tsx: specifier: 'catalog:' version: 4.20.3 typescript: specifier: 'catalog:' - version: 5.8.3 + version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) packages/smoke-tests: devDependencies: @@ -1041,7 +1041,7 @@ importers: specifier: ^4.8.0 version: 4.10.1 typescript: - specifier: ^5.9.3 + specifier: 'catalog:' version: 5.9.3 valibot: specifier: ^1.4.2 @@ -8977,11 +8977,6 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} - engines: {node: '>=14.17'} - hasBin: true - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -11040,14 +11035,14 @@ snapshots: dependencies: hono: 4.13.13 - '@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv@8.20.0)(react-hook-form@7.90.0(@types/react@19.3.0)(react@19.2.8))(valibot@1.4.2(typescript@5.8.3))(zod@4.6.5)': + '@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv@8.20.0)(react-hook-form@7.90.0(@types/react@19.3.0)(react@19.2.8))(valibot@1.4.2(typescript@5.9.3))(zod@4.6.5)': dependencies: '@standard-schema/utils': 0.3.0 react-hook-form: 7.90.0(@types/react@19.3.0)(react@19.2.8) optionalDependencies: '@standard-schema/spec': 1.1.0 ajv: 8.20.0 - valibot: 1.4.2(typescript@5.8.3) + valibot: 1.4.2(typescript@5.9.3) zod: 4.6.5 '@img/colour@1.1.0': {} @@ -13481,7 +13476,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) '@vitest/expect@4.1.11': dependencies: @@ -13492,13 +13487,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.10.2(@types/node@22.16.0)(typescript@5.8.3) + msw: 2.10.2(@types/node@22.16.0)(typescript@5.9.3) vite: 8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0) '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': @@ -13528,32 +13523,23 @@ snapshots: msw: 2.10.2(@types/node@22.20.3)(typescript@5.9.3) vite: 8.3.3(@types/node@22.20.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) - '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0))': - dependencies: - '@vitest/spy': 4.1.11 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - msw: 2.10.2(@types/node@24.13.5)(typescript@5.8.3) - vite: 8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0) - - '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.4.5(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.10.2(@types/node@24.13.5)(typescript@5.8.3) - vite: 8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + msw: 2.10.2(@types/node@24.13.5)(typescript@5.9.3) + vite: 6.4.5(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.13)(yaml@2.9.0) - '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.4.5(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.13)(yaml@2.9.0))': + '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.10.2(@types/node@24.13.5)(typescript@5.9.3) - vite: 6.4.5(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.13)(yaml@2.9.0) + vite: 8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0) '@vitest/mocker@4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: @@ -16440,32 +16426,6 @@ snapshots: ms@2.1.3: {} - msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3): - dependencies: - '@bundled-es-modules/cookie': 2.0.1 - '@bundled-es-modules/statuses': 1.0.1 - '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 5.1.21(@types/node@22.16.0) - '@mswjs/interceptors': 0.39.8 - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/until': 2.1.0 - '@types/cookie': 0.6.0 - '@types/statuses': 2.0.6 - graphql: 16.14.2 - headers-polyfill: 4.0.3 - is-node-process: 1.2.0 - outvariant: 1.4.3 - path-to-regexp: 6.3.0 - picocolors: 1.1.1 - strict-event-emitter: 0.5.1 - type-fest: 4.41.0 - yargs: 17.7.3 - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - '@types/node' - optional: true - msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3): dependencies: '@bundled-es-modules/cookie': 2.0.1 @@ -16544,31 +16504,6 @@ snapshots: - '@types/node' optional: true - msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3): - dependencies: - '@bundled-es-modules/cookie': 2.0.1 - '@bundled-es-modules/statuses': 1.0.1 - '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 5.1.21(@types/node@24.13.5) - '@mswjs/interceptors': 0.39.8 - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/until': 2.1.0 - '@types/cookie': 0.6.0 - '@types/statuses': 2.0.6 - graphql: 16.14.2 - headers-polyfill: 4.0.3 - is-node-process: 1.2.0 - outvariant: 1.4.3 - path-to-regexp: 6.3.0 - picocolors: 1.1.1 - strict-event-emitter: 0.5.1 - type-fest: 4.41.0 - yargs: 17.7.3 - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - '@types/node' - msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3): dependencies: '@bundled-es-modules/cookie': 2.0.1 @@ -17433,23 +17368,6 @@ snapshots: rfdc@1.4.1: {} - rolldown-plugin-dts@0.13.13(rolldown@1.0.0-beta.23)(typescript@5.8.3): - dependencies: - '@babel/generator': 7.28.0 - '@babel/parser': 7.28.0 - '@babel/types': 7.28.0 - ast-kit: 2.1.1 - birpc: 2.4.0 - debug: 4.4.3 - dts-resolver: 2.1.1 - get-tsconfig: 4.10.1 - rolldown: 1.0.0-beta.23 - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - oxc-resolver - - supports-color - rolldown-plugin-dts@0.13.13(rolldown@1.0.0-beta.23)(typescript@5.9.3): dependencies: '@babel/generator': 7.28.0 @@ -18101,29 +18019,6 @@ snapshots: dependencies: typescript: 5.7.3 - tsdown@0.12.9(typescript@5.8.3): - dependencies: - ansis: 4.1.0 - cac: 6.7.14 - chokidar: 4.0.3 - debug: 4.4.3 - diff: 8.0.2 - empathic: 2.0.0 - hookable: 5.5.3 - rolldown: 1.0.0-beta.23 - rolldown-plugin-dts: 0.13.13(rolldown@1.0.0-beta.23)(typescript@5.8.3) - semver: 7.7.4 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 - unconfig: 7.3.2 - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - '@typescript/native-preview' - - oxc-resolver - - supports-color - - vue-tsc - tsdown@0.12.9(typescript@5.9.3): dependencies: ansis: 4.1.0 @@ -18182,8 +18077,6 @@ snapshots: typescript@5.7.3: {} - typescript@5.8.3: {} - typescript@5.9.3: {} uc.micro@2.1.0: {} @@ -18369,10 +18262,6 @@ snapshots: uuidv7@1.2.1: {} - valibot@1.4.2(typescript@5.8.3): - optionalDependencies: - typescript: 5.8.3 - valibot@1.4.2(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 @@ -18524,12 +18413,12 @@ snapshots: vitest-evals@0.4.0(tinyrainbow@3.1.0)(vitest@4.1.11): dependencies: tinyrainbow: 3.1.0 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@22.16.0)(typescript@5.8.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -18646,40 +18535,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.11 - '@vitest/runner': 4.1.11 - '@vitest/snapshot': 4.1.11 - '@vitest/spy': 4.1.11 - '@vitest/utils': 4.1.11 - es-module-lexer: 2.3.2 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.7 - std-env: 4.0.0 - tinybench: 2.9.0 - tinyexec: 1.3.1 - tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 - vite: 8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@types/node': 24.13.5 - '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) - jsdom: 28.1.0 - transitivePeerDependencies: - - msw - - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.4.5(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.8.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.4.5(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -18696,7 +18555,7 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) + vite: 6.4.5(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -18706,10 +18565,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.4.5(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.5)(@vitest/coverage-v8@4.1.11)(jsdom@28.1.0)(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@6.4.5(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/mocker': 4.1.11(msw@2.10.2(@types/node@24.13.5)(typescript@5.9.3))(vite@8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -18726,7 +18585,7 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.4.5(@types/node@24.13.5)(jiti@2.7.0)(lightningcss@1.33.0)(tsx@4.23.13)(yaml@2.9.0) + vite: 8.3.3(@types/node@24.13.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.20.3)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 698e7d363..515a820ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -94,7 +94,7 @@ catalog: tsdown: ^0.12.9 tsx: ^4.20.3 tw-animate-css: ^1.3.4 - typescript: ^5.8.3 + typescript: ^5.9.3 valibot: ^1.4.2 vite: ^6.4.5 vitest: ^4.1.11