diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index 7e1833930..a9f820353 100644 --- a/apps/cli-docs/src/fragments/commands/auth.md +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -62,7 +62,8 @@ that fits what they target, without changing the active account: account's organization list (cached at login and by `sentry org list`, which lists every stored account's organizations) but another stored account has used it, that account is used. This includes `oNNNN` IDs from - DSNs. + DSNs, and the organization in a `sentry api` path such as + `organizations/acme-corp/issues/`, among accounts on that request's server. - **Server** — `SENTRY_HOST`/`SENTRY_URL`, `.sentryclirc`, or a pasted Sentry URL for another account's server (for example a self-hosted issue link) uses the account stored for that server. diff --git a/packages/cli/src/commands/api.ts b/packages/cli/src/commands/api.ts index a456b1098..f8ab967fb 100644 --- a/packages/cli/src/commands/api.ts +++ b/packages/cli/src/commands/api.ts @@ -8,6 +8,7 @@ import { access, readFile } from "node:fs/promises"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node"; +import { parseApiPathScope } from "@sentry/api/client"; import type { SentryContext } from "../context.js"; import { appendSearchParams, @@ -262,23 +263,12 @@ function resolveApiTarget(endpoint: string): { }; } -/** - * Org/project slugs or numeric IDs in API paths. Rejects schema placeholders - * (`{organization_id_or_slug}`) and empty/dot segments so we never tag junk. - */ -const API_PATH_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; - -function isApiPathSlug(segment: string | undefined): segment is string { - return segment !== undefined && API_PATH_SLUG_RE.test(segment); -} - /** * Parse org (and project when present) from a Sentry API endpoint path. * * `sentry api` never goes through target resolve, so the slug lives only in - * the URL. `organizations/`, `projects/`, and `teams/` prefixes carry the - * org; `projects/` and `organizations/{org}/projects/{project}/` also carry - * a project when that segment is a real slug. + * the URL. The transport reads the same scope to pick the stored account + * that knows the org; see `parseApiPathScope` in `@sentry/api/client`. * * @param endpoint - Path relative to `/api/0/`, optionally with a query string * or a leading `/api/0/` prefix @@ -289,34 +279,13 @@ function isApiPathSlug(segment: string | undefined): segment is string { export function parseOrgProjectFromApiPath( endpoint: string, ): { org: string; project?: string } | undefined { - const path = endpoint.split("?", 1)[0] ?? ""; - const segments = path.split("/").filter((segment) => segment.length > 0); - - if (segments[0] === "api" && segments[1] === "0") { - segments.splice(0, 2); - } - - if (segments[0] === "organizations" && isApiPathSlug(segments[1])) { - const org = segments[1]; - if (segments[2] === "projects" && isApiPathSlug(segments[3])) { - return { org, project: segments[3] }; - } - return { org }; - } - - if (segments[0] === "projects" && isApiPathSlug(segments[1])) { - const org = segments[1]; - if (isApiPathSlug(segments[2])) { - return { org, project: segments[2] }; - } - return { org }; - } - - if (segments[0] === "teams" && isApiPathSlug(segments[1])) { - return { org: segments[1] }; + const scope = parseApiPathScope(endpoint); + if (!scope) { + return; } - - return; + return scope.project + ? { org: scope.organization, project: scope.project } + : { org: scope.organization }; } function tagOrgFromApiPath(endpoint: string): void { diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 5ccbe7407..3127db21c 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -996,8 +996,16 @@ function followDefaultAccountHost(host: string): void { * * An env token that knows the org wins over every stored account, the * active one included (see {@link getEnvAccount}). + * + * `server` limits candidates (the env token included) to that server, for a + * request whose URL is already built: switching servers would send it to + * the wrong one. */ -export function selectAccountForOrg(orgSlug: string, orgId?: string): void { +export function selectAccountForOrg( + orgSlug: string, + orgId?: string, + options: { server?: string } = {}, +): void { if (isEnvTokenForced()) { return; } @@ -1006,13 +1014,13 @@ export function selectAccountForOrg(orgSlug: string, orgId?: string): void { return; } try { - if (selectEnvAccountForOrg(orgSlug, orgId)) { + if (selectEnvAccountForOrg(orgSlug, orgId, options.server)) { return; } const current = getAuthRow(); const pick = findAccountForOrg(orgSlug, orgId, { current, - host: getExplicitServerUrl(), + host: getExplicitServerUrl() ?? options.server, }); if (!pick?.host || pick.account_id === current?.account_id) { return; @@ -1079,16 +1087,21 @@ export function findAccountForOrg( /** * Use the env token for the rest of this invocation when its org cache * shows it knows the org on its current server, and that server is the - * explicit one, if any. The - * env token wins over stored accounts that know the org too. + * explicit one and `server`, when set. The env token wins over stored + * accounts that know the org too. * * @returns whether the env token was chosen */ -function selectEnvAccountForOrg(orgSlug: string, orgId?: string): boolean { +function selectEnvAccountForOrg( + orgSlug: string, + orgId: string | undefined, + server: string | undefined, +): boolean { const envAccount = getEnvAccountInScope(); if ( !( envAccount && + (!server || isSameServer(envAccount.host, server)) && hasCachedOrgForIdentity( envAccount.identity, envAccount.host, diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index 1f8eb6373..edeed702c 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -13,6 +13,7 @@ import { getTraceData } from "@sentry/node"; import { computeInvalidationPrefixes, createApiTransport, + type CredentialRequest, extractUrlPath, getValidatedResponseSource, type ResponseBodyMode, @@ -40,6 +41,7 @@ import { getRemainingLifetimeRatio, REFRESH_THRESHOLD, refreshToken, + selectAccountForOrg, } from "./db/auth.js"; import { ApiError, @@ -317,6 +319,27 @@ async function withHttpSpan( ); } +/** Sentry org slugs are never entirely numeric, so digits are an org id. */ +const NUMERIC_ORG_ID = /^\d+$/; + +/** + * Use the stored account that knows the organization the request's path + * targets (`selectAccountForOrg`), so requests no command resolved an org + * for, such as `sentry api organizations//...`, still reach it. Only + * accounts on the request's server qualify: its URL is already built. + */ +function selectAccountForRequest({ url, scope }: CredentialRequest): void { + if (!scope) { + return; + } + const { organization } = scope; + selectAccountForOrg( + organization, + NUMERIC_ORG_ID.test(organization) ? organization : undefined, + { server: url }, + ); +} + /** * The CLI's transport options: stored credentials, host-scope checks, the * response cache, and the CLI's error classes. Retries, timeouts, the 401 @@ -325,7 +348,10 @@ async function withHttpSpan( function createCliTransport(credential: CredentialContext | undefined) { return createApiTransport({ credentials: { - current: async () => { + current: async (request) => { + if (!credential) { + selectAccountForRequest(request); + } const current = credential ?? getCredentialContext(); if (!current) { await refreshToken(); @@ -532,12 +558,15 @@ export function getSdkConfig( /** * Get SDK config for the default API (control silo or self-hosted). + * + * The credential is not pinned, so the transport can still pick the stored + * account that knows the organization a request's path names; it only picks + * among accounts on the server this base URL belongs to. */ export function getDefaultSdkConfig( options: Pick = {}, ) { - const credential = getCredentialContext(); - return getSdkConfig(getApiBaseUrl(credential), { ...options, credential }); + return getSdkConfig(getApiBaseUrl(), options); } /** diff --git a/packages/cli/test/lib/db/account-routing.test.ts b/packages/cli/test/lib/db/account-routing.test.ts index 1ef461024..a41783009 100644 --- a/packages/cli/test/lib/db/account-routing.test.ts +++ b/packages/cli/test/lib/db/account-routing.test.ts @@ -10,6 +10,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { rawApiRequest } from "../../../src/lib/api/infrastructure.js"; import { markPersistedDefaultUrl } from "../../../src/lib/constants.js"; import { getActiveAccountId, @@ -44,6 +45,7 @@ import { } from "../../../src/lib/sentryclirc.js"; import { mintSntrysToken, + mockFetch, resetHostScopingState, useEnvSandbox, useTestConfigDir, @@ -442,6 +444,61 @@ describe("organization-based selection", () => { }); }); +describe("requests whose path names an organization", () => { + let originalFetch: typeof globalThis.fetch; + let sent: Array; + + beforeEach(() => { + originalFetch = globalThis.fetch; + sent = []; + globalThis.fetch = mockFetch(async (input, init) => { + sent.push(new Request(input, init).headers.get("Authorization")); + return Response.json({}); + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("use the account that knows the org, like `sentry api`", async () => { + login("work-token", SAAS, "work"); + seeOrg("work-org", SAAS); + const personal = login("personal-token", SAAS, "personal"); + seeOrgList(SAAS, ["personal-org"]); + + await rawApiRequest("organizations/work-org/issues/"); + await rawApiRequest("projects/personal-org/web/"); + + // One invocation never mixes accounts. + expect(sent).toEqual(["Bearer work-token", "Bearer work-token"]); + expect(getActiveAccountId()).toBe(personal); + }); + + test("leave unscoped requests on the active account", async () => { + login("work-token", SAAS, "work"); + seeOrg("work-org", SAAS); + login("personal-token", SAAS, "personal"); + seeOrgList(SAAS, ["personal-org"]); + + await rawApiRequest("users/me/"); + + expect(sent).toEqual(["Bearer personal-token"]); + }); + + test("only consider accounts on the request's server", async () => { + login("acme-token", ACME, "acme"); + seeOrg("acme-org", ACME); + login("saas-token", SAAS, "saas"); + seeOrgList(SAAS, ["saas-org"]); + + await rawApiRequest("organizations/acme-org/"); + + expect(sent).toEqual(["Bearer saas-token"]); + expect(getEffectiveAccountRow()?.host).toBe(SAAS); + }); +}); + describe("the env token as an account", () => { // The env token's server is snapshotted once per env: start each case // from a fresh boot. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db7fa68aa..d627e66d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ catalogs: specifier: ^1.2.3 version: 1.2.3 '@sentry/api': - specifier: 1.20.0 - version: 1.20.0 + specifier: 1.26.0 + version: 1.26.0 '@sentry/cloudflare': specifier: 11.6.0 version: 11.6.0 @@ -511,7 +511,7 @@ importers: version: 1.46.0(ai@6.0.230(zod@4.6.7))(express@5.3.0)(zod@4.6.7) '@sentry/api': specifier: 'catalog:' - version: 1.20.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.26.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -857,7 +857,7 @@ importers: version: 2.4.0 '@sentry/api': specifier: 'catalog:' - version: 1.20.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.26.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -1036,7 +1036,7 @@ importers: dependencies: '@sentry/api': specifier: 'catalog:' - version: 1.20.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + version: 1.26.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 @@ -4300,8 +4300,8 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@sentry/api@1.20.0': - resolution: {integrity: sha512-sz8gIvpAgtymqhZVClftP8R2FnpsSkp1y9oM5ntNuj+ScY/iarUKCqdriM5L5UJc/Tlaz2/UUUt1pOiamUyLaQ==} + '@sentry/api@1.26.0': + resolution: {integrity: sha512-LkzMT2mn2Qrbnf2s+2t/mrXpN1/2o0GO2fodhds2Ntp8kRt/NkilWIETAGUU6vB7XKlLhPZloZT4Yv/HEzCmFA==} engines: {node: '>=22'} peerDependencies: typescript: '*' @@ -12262,7 +12262,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@sentry/api@1.20.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': + '@sentry/api@1.26.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7)': dependencies: http-cache-semantics: 4.2.0 optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0b84f03e9..d73b003d2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -52,7 +52,7 @@ catalog: "@modelcontextprotocol/server": 2.4.0 "@radix-ui/react-accordion": ^1.2.20 "@radix-ui/react-slot": ^1.2.3 - "@sentry/api": 1.20.0 + "@sentry/api": 1.26.0 "@sentry/cloudflare": 11.6.0 "@sentry/core": 11.6.0 "@sentry/node": 11.6.0 @@ -167,7 +167,7 @@ minimumReleaseAgeExclude: - "@oxlint/binding-win32-x64-msvc@1.87.0" - oxfmt@0.72.0 - oxlint@1.87.0 - - "@sentry/api@1.20.0" + - "@sentry/api@1.26.0" - miniflare@5.20261101.0-beta - wrangler@4.156.1 - "@sentry/symbolic@14.0.0-alpha.5"