Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/cli-docs/src/content/docs/self-hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ The CLI works with self-hosted Sentry instances. Set the `SENTRY_HOST` (or `SENT
export SENTRY_HOST=https://sentry.example.com
```

If you use an `sntrys_` organization token, the CLI uses the instance URL in its token claim when neither URL variable is set. A stored login also keeps its instance URL. An explicit URL takes precedence, but the CLI rejects requests outside the active credential's trusted host.

## Authenticating

### With OAuth (Sentry 26.1.0+)
Expand Down
27 changes: 24 additions & 3 deletions packages/cli/src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import * as Sentry from "@sentry/node-core/light";
import { type GenericSchema, safeParse } from "valibot";

import { extractRequiredScopes } from "../api-scope.js";
import { getActiveEnvVarName, isEnvTokenActive } from "../db/auth.js";
import {
type CredentialContext,
getActiveEnvVarName,
isEnvTokenActive,
} from "../db/auth.js";
import { getEnv } from "../env.js";
import { ApiError, AuthError, stringifyUnknown } from "../errors.js";
import { logger } from "../logger.js";
Expand Down Expand Up @@ -191,6 +195,10 @@ export type ApiRequestOptions<T = unknown> = {
params?: Record<string, string | number | boolean | string[] | undefined>;
/** Optional valibot schema for runtime validation of response data */
schema?: GenericSchema<unknown, T>;
/** Internal immutable credential for a multi-request operation. */
credential?: CredentialContext;
/** Manually validate each redirect before forwarding credentials. */
validatedRedirects?: boolean;
};

/**
Expand Down Expand Up @@ -302,6 +310,9 @@ export function unwrapPaginatedResult<T>(
response?.headers.get("link") ?? null
);
const out: PaginatedResponse<T> = { data };
if (response) {
out.response = response;
}
if (nextCursor !== undefined) {
out.nextCursor = nextCursor;
}
Expand Down Expand Up @@ -415,6 +426,8 @@ export type PaginatedResponse<T> = {
nextCursor?: string;
/** Cursor for the previous page (undefined on the first page) */
prevCursor?: string;
/** Exact validated response, for provenance-sensitive callers. */
response?: Response;
};

/**
Expand Down Expand Up @@ -524,8 +537,16 @@ export async function apiRequestToRegion<T>(
endpoint: string,
options: ApiRequestOptions<T> = {}
): Promise<{ data: T; headers: Headers }> {
const { method = "GET", body, bodyEncoding, params, schema } = options;
const config = getSdkConfig(regionUrl);
const {
method = "GET",
body,
bodyEncoding,
params,
schema,
credential,
validatedRedirects,
} = options;
const config = getSdkConfig(regionUrl, { credential, validatedRedirects });

const normalizedEndpoint = endpoint.startsWith("/")
? endpoint.slice(1)
Expand Down
147 changes: 109 additions & 38 deletions packages/cli/src/lib/api/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,36 @@
type UserRegionsResponse,
UserRegionsResponseSchema,
} from "../../types/index.js";

import { ApiError } from "../errors.js";
import { getControlSiloUrl, getSdkConfig } from "../sentry-client.js";
import { type CredentialContext, getCredentialContext } from "../db/auth.js";
import { ApiError, AuthError } from "../errors.js";
import {
getControlSiloUrl,
getResponseCredentialIdentity,
getResponseRequestOrigin,
getSdkConfig,
} from "../sentry-client.js";
import { normalizeRegionBaseUrl } from "../sentry-urls.js";

import {
API_MAX_PER_PAGE,
apiRequestToRegion,
autoPaginate,
getOrgSdkConfig,
MAX_PAGINATION_PAGES,
type PaginatedResponse,
unwrapPaginatedResult,
unwrapResult,
} from "./infrastructure.js";

function normalizeOrganizationRegion(
raw: string | undefined,
responseOrigin: string
): string | undefined {
if (!raw) {
return responseOrigin;
}
return normalizeRegionBaseUrl(raw, responseOrigin);
}

Check warning on line 46 in packages/cli/src/lib/api/organizations.ts

View check run for this annotation

@sentry/warden / warden: code-review

Missing region URL fallback drops a configured self-hosted path

When a path-bearing `SENTRY_URL` or `SENTRY_HOST` is configured and organization discovery returns a missing or empty `links.regionUrl`, the CLI caches only the response origin. Later org-scoped requests use that cached root URL instead of the installation path and can fail.
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Get the list of regions the user has organization membership in.
* This endpoint is on the control silo (sentry.io) and returns all regions.
Expand All @@ -41,11 +56,12 @@
* @returns Array of regions with name and URL
*/
export async function getUserRegions(): Promise<Region[]> {
const credential = getCredentialContext();
// /users/me/regions/ is an internal endpoint - use raw request
const { data } = await apiRequestToRegion<UserRegionsResponse>(
getControlSiloUrl(),
getControlSiloUrl(credential),
"/users/me/regions/",
{ schema: UserRegionsResponseSchema }
{ schema: UserRegionsResponseSchema, credential, validatedRedirects: true }
);
return data.regions;
}
Expand All @@ -63,9 +79,13 @@
*/
export async function listOrganizationsPage(
baseUrl: string,
options: { cursor?: string; perPage?: number } = {}
options: { cursor?: string; perPage?: number } = {},
credential = getCredentialContext()
): Promise<PaginatedResponse<SentryOrganization[]>> {
const config = getSdkConfig(baseUrl);
const config = getSdkConfig(baseUrl, {
credential,
validatedRedirects: true,
});

const result = await sdkListOrganizations({
...config,
Expand Down Expand Up @@ -104,9 +124,12 @@
* should use {@link listOrganizationsUncached} instead.
*/
export async function listOrganizations(): Promise<SentryOrganization[]> {
const credential = getCredentialContext();
const { getCachedOrganizations } = await import("../db/regions.js");

const cached = getCachedOrganizations();
const cached = credential
? getCachedOrganizations(getControlSiloUrl(credential), credential.identity)
: [];
if (cached.length > 0) {
return cached.map((org) => ({
id: org.id,
Expand All @@ -117,7 +140,72 @@
}

// Cache miss — fetch from API (also populates cache for next time)
return listOrganizationsUncached();
return listOrganizationsUncached(credential);
}

type OrganizationPageContext = {
baseUrl: string;
credential: CredentialContext;
setOrgRegions: typeof import("../db/regions.js").setOrgRegions;
organizations?: SentryOrganization[];
cursor?: string;
pageIndex?: number;
};

async function listOrganizationPages({
baseUrl,
credential,
setOrgRegions,
organizations = [],
cursor,
pageIndex = 0,
}: OrganizationPageContext): Promise<SentryOrganization[]> {
if (pageIndex >= MAX_PAGINATION_PAGES) {
return organizations;
}
const page = await listOrganizationsPage(
baseUrl,
{ cursor, perPage: API_MAX_PER_PAGE },
credential
);
const responseOrigin =
page.response && getResponseRequestOrigin(page.response);
const responseIdentity =
page.response && getResponseCredentialIdentity(page.response);
if (responseOrigin && responseIdentity === credential.identity) {
const entries = page.data.flatMap((org) => {
const region = normalizeOrganizationRegion(
org.links?.regionUrl,
responseOrigin
);
return region
? [
{
slug: org.slug,
regionUrl: region,
sourceOrigin: responseOrigin,
cacheOrigin: baseUrl,
identity: credential.identity,
orgId: org.id,
orgName: org.name,
...(org.orgRole ? { orgRole: org.orgRole } : {}),
},
]
: [];
});
setOrgRegions(entries);
}

Check warning on line 197 in packages/cli/src/lib/api/organizations.ts

View check run for this annotation

@sentry/warden / warden: code-review

Failed multi-page org fetch leaves partial cache treated as complete

Each org page is written to org_regions before pagination finishes, so a later page failure leaves a non-empty partial cache that listOrganizations() returns as the full org list for up to 7 days.

Check warning on line 197 in packages/cli/src/lib/api/organizations.ts

View check run for this annotation

@sentry/warden / warden: code-review

HTTP cache hits skip org_regions population due to missing provenance

listOrganizationsUncached only persists regions when WeakMap provenance is present, so HTTP response-cache hits never call setOrgRegions and leave org/region routing cache empty (including after schema v17 drops those rows).
Comment thread
BYK marked this conversation as resolved.
Outdated
Comment thread
BYK marked this conversation as resolved.
Outdated
const accumulated = [...organizations, ...page.data];
return page.nextCursor
? await listOrganizationPages({
baseUrl,
credential,
setOrgRegions,
organizations: accumulated,
cursor: page.nextCursor,
pageIndex: pageIndex + 1,
})
: accumulated;
}

/**
Expand All @@ -136,35 +224,18 @@
* Use this when you need guaranteed-fresh data (e.g., `org list`, `auth status`).
* Most callers should use {@link listOrganizations} instead.
*/
export async function listOrganizationsUncached(): Promise<
SentryOrganization[]
> {
export async function listOrganizationsUncached(
credential = getCredentialContext()
): Promise<SentryOrganization[]> {
const { setOrgRegions } = await import("../db/regions.js");

const controlSiloUrl = getControlSiloUrl();

const { data: orgs } = await autoPaginate(
(cursor) =>
listOrganizationsPage(controlSiloUrl, {
cursor,
perPage: API_MAX_PER_PAGE,
}),
MAX_PAGINATION_PAGES * API_MAX_PER_PAGE
);

const regionEntries = orgs.map((org) => ({
slug: org.slug,
// Each org carries its own regionUrl (added to the control serializer
// in getsentry/sentry#115513); fall back to the control silo URL for
// any older/self-hosted response that omits it.
regionUrl: org.links?.regionUrl ?? controlSiloUrl,
orgId: org.id,
orgName: org.name,
orgRole: org.orgRole,
}));
setOrgRegions(regionEntries);

return orgs;
if (!credential) {
throw new AuthError("not_authenticated");
}
return await listOrganizationPages({
baseUrl: getControlSiloUrl(credential),
credential,
setOrgRegions,
});
}

/**
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/lib/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* Exit: 0 on success (even if no completions)
*/

import { getConfiguredSentryUrl } from "./constants.js";
import { getCredentialContext } from "./db/auth.js";
import { queueCompletionTelemetry } from "./db/completion-telemetry.js";
import { getProjectAliases } from "./db/project-aliases.js";
import { getCachedProjectsForOrg } from "./db/project-cache.js";
Expand All @@ -24,6 +26,17 @@ import { COMMON_PLATFORMS, VALID_PLATFORMS } from "./platforms.js";

const WHITESPACE_RE = /\s/;

/** Never suggest organizations cached under another credential or lookup host. */
function getCompletionOrganizations() {
const credential = getCredentialContext();
return credential
? getCachedOrganizations(
getConfiguredSentryUrl() ?? credential.host,
credential.identity
)
: [];
}

/**
* Completion result with optional description for rich shell display.
* Shells that support descriptions (zsh, fish) use both fields.
Expand Down Expand Up @@ -242,7 +255,7 @@ export function completeProjectCreateSpec(partial: string): Completion[] {
* @returns Completions with org names as descriptions
*/
export function completeOrgSlugs(partial: string, suffix = ""): Completion[] {
const orgs = getCachedOrganizations();
const orgs = getCompletionOrganizations();
if (orgs.length === 0) {
return [];
}
Expand Down Expand Up @@ -347,7 +360,7 @@ export function completeProjectSlugs(
* @returns The resolved org slug, or undefined if no match
*/
function fuzzyResolveOrg(orgPart: string): string | undefined {
const orgs = getCachedOrganizations();
const orgs = getCompletionOrganizations();
if (orgs.length === 0) {
return;
}
Expand Down
30 changes: 28 additions & 2 deletions packages/cli/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { getEnv } from "./env.js";
import { ConfigError } from "./errors.js";

/** Build-time constant injected by esbuild/bun */
declare const SENTRY_CLI_VERSION: string | undefined;
Expand Down Expand Up @@ -34,6 +35,7 @@ export const NODE_MODULES_DIRNAME = "node_modules";

/** Matches strings that already start with http:// or https:// */
const HAS_PROTOCOL_RE = /^https?:\/\//i;
const EXPLICIT_SCHEME_RE = /^([a-z][a-z\d+.-]*):\/\//i;

/**
* Normalize a URL string by ensuring it has a protocol prefix.
Expand Down Expand Up @@ -71,8 +73,32 @@ export function normalizeUrl(url: string | undefined): string | undefined {
* with `https://` to prevent invalid URL construction downstream.
*/
export function getConfiguredSentryUrl(): string | undefined {
const raw = getEnv().SENTRY_HOST || getEnv().SENTRY_URL || undefined;
return normalizeUrl(raw);
const env = getEnv();
const raw = env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim();
if (!raw) {
return;
}
const scheme = raw.match(EXPLICIT_SCHEME_RE)?.[1]?.toLowerCase();
const normalized = normalizeUrl(raw);
try {
if (scheme && scheme !== "http" && scheme !== "https") {
throw new TypeError("Unsupported URL scheme");
}
const parsed = new URL(normalized as string);
if (
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
!parsed.hostname ||
parsed.username ||
parsed.password
) {
throw new TypeError("Invalid Sentry URL");
}
return normalized;
} catch {
throw new ConfigError(
"SENTRY_HOST/SENTRY_URL is not a valid URL; use a credential-free HTTP(S) URL."
);
}
}

/** CLI version string, available for help output and other uses */
Expand Down
Loading
Loading