Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
84 changes: 0 additions & 84 deletions .lore.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1165,7 +1165,7 @@ function build403Detail(originalDetail: unknown): string {
: `Your ${getActiveEnvVarName()} token may lack the required scopes`;
lines.push(
` • ${leader} (${scopeList})`,
" • Check token scopes at: https://sentry.io/settings/auth-tokens/"
" • Check token scopes at: https://sentry.io/settings/account/api/auth-tokens/"
);
} else {
lines.push(" • Re-authenticate with: sentry auth login");
Expand Down
87 changes: 75 additions & 12 deletions src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function enrich403Detail(rawDetail: string | undefined): string {
);
}
lines.push(
"Check token scopes at: https://sentry.io/settings/auth-tokens/"
"Check token scopes at: https://sentry.io/settings/account/api/auth-tokens/"
Comment thread
cursor[bot] marked this conversation as resolved.
);
} else {
lines.push(
Expand All @@ -75,6 +75,70 @@ function enrich403Detail(rawDetail: string | undefined): string {
return lines.join("\n ");
}

/**
* Enrich a 401 Unauthorized error detail with actionable guidance.
*
* 401 means the token is missing, invalid, or expired — the identity cannot
* be determined at all. Distinct from 403 (identity known, lacks permission).
* Scope hints do not apply; the fix is always to re-authenticate or regenerate
* the token.
*
* The Sentry API returns distinct `detail` strings we can branch on:
* `"Token expired"` when the token is past its expiry date, `"Invalid token"`
* when it is not found or malformed. We use this to give a more precise message
* for env-var token users.
*
* For OAuth users the token lifecycle is transparent — `sentry-client.ts`
* intercepts 401s and refreshes automatically. A 401 that reaches this function
* means refresh failed and the user needs to re-authenticate via the browser.
*
* @see https://github.com/getsentry/sentry/blob/934f1473f198a62f9268d7140b80cd9ca1e59bb9/src/sentry/api/authentication.py#L536-L539
*/
function enrich401Detail(rawDetail: string | undefined): string {
const lines: string[] = [];
if (rawDetail) {
lines.push(rawDetail, "");
}
if (isEnvTokenActive()) {
const expired = rawDetail?.toLowerCase().includes("expired");
lines.push(
`Your ${getActiveEnvVarName()} token ${expired ? "has expired" : "is not recognized or has been revoked"}.`,
"Create a new token at: https://sentry.io/settings/account/api/auth-tokens/"
);
} else {
lines.push(
"Not authenticated or your session has expired.",
"Re-authenticate with: sentry auth login"
);
}
return lines.join("\n ");
}

/**
* Select and apply status-specific detail enrichment.
*
* Extracted from {@link throwApiError} and {@link throwRawApiError} to keep
* their cognitive complexity within the linter limit. 403 and 401 get
* actionable guidance; all other statuses pass the raw detail through.
*
* `hasUsableDetail` controls whether the raw detail string is forwarded to
* the enrichment functions — passing `undefined` when false lets them render
* without a noisy `{"detail":null}` prefix.
*/
function enrichDetail(
status: number,
detail: string | undefined,
hasUsableDetail: boolean
): string | undefined {
if (status === 403) {
return enrich403Detail(hasUsableDetail ? detail : undefined);
}
if (status === 401) {
return enrich401Detail(hasUsableDetail ? detail : undefined);
}
return detail;
}

/**
* Parse Sentry's RFC 5988 Link response header to extract pagination cursors.
*
Expand Down Expand Up @@ -127,10 +191,9 @@ export function throwApiError(
? (error as { detail: unknown }).detail
: undefined;
const hasUsableDetail = rawDetail !== null && rawDetail !== undefined;
// When the API returns `{ detail: null }` or `{ detail: undefined }`,
// fall back to stringifying the whole error object for non-403 errors
// (useful for debugging). For 403s, pass undefined to enrich403Detail
// so the enrichment stands alone without a noisy `{}` prefix.
// Enrichment functions (enrich403Detail, enrich401Detail) render better
// when rawDetail is undefined — they stand alone without a noisy `{}`
// prefix. For all other statuses, stringify the full error as a debug aid.
const detail = hasUsableDetail
? stringifyUnknown(rawDetail)
: stringifyUnknown(error);
Expand All @@ -139,7 +202,7 @@ export function throwApiError(
throw new ApiError(
`${context}: ${status} ${response.statusText ?? "Unknown"}`,
status,
is403 ? enrich403Detail(hasUsableDetail ? detail : undefined) : detail,
enrichDetail(status, detail, hasUsableDetail),
undefined,
is403
);
Expand Down Expand Up @@ -443,13 +506,13 @@ async function throwRawApiError(
const text = await response.text();
try {
const parsed = JSON.parse(text) as { detail?: string };
// Prefer the explicit `detail` field; fall back to the full JSON
// for non-403 errors (useful for debugging). For 403s, pass
// undefined so enrich403Detail stands alone without a noisy
// `{"detail":null}` prefix.
// Enriched statuses (403, 401) pass undefined when there is no
// usable string detail so the enrichment renders without a noisy
// `{"detail":null}` prefix. Other statuses get the full JSON as
// a debug aid.
if (typeof parsed.detail === "string") {
detail = parsed.detail;
} else if (response.status !== 403) {
} else if (response.status !== 403 && response.status !== 401) {
detail = JSON.stringify(parsed);
}
} catch {
Expand All @@ -476,7 +539,7 @@ async function throwRawApiError(
throw new ApiError(
`API request failed: ${response.status} ${response.statusText}`,
response.status,
is403 ? enrich403Detail(detail) : detail,
enrichDetail(response.status, detail, detail !== undefined),
endpoint,
is403
);
Expand Down
47 changes: 34 additions & 13 deletions src/lib/init/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,38 @@ async function resolveTeam(
}
}

/**
* Format a 403/401 ApiError from listOrganizations() into a { ok: false }
* result, or re-throw if the error is something else.
*
* 403: token lacks org:read scope — user can bypass by supplying the org slug
* directly. 401: token is invalid/expired — supplying an org won't help, only
* re-authenticating will.
*/
function handleOrgListError(error: unknown): { ok: false; error: string } {
if (error instanceof ApiError && error.status === 403) {
const lines: string[] = ["Could not list organizations (403 Forbidden)."];
if (error.detail) {
lines.push(error.detail, "");
}
lines.push(
"Specify the org on the command line: sentry init <org-slug>/",
"Or set an environment variable: SENTRY_ORG=<org-slug> sentry init"
);
return { ok: false, error: lines.join("\n ") };
}
if (error instanceof ApiError && error.status === 401) {
const lines: string[] = [
"Could not list organizations (401 Unauthorized).",
];
if (error.detail) {
lines.push(error.detail);
}
return { ok: false, error: lines.join("\n ") };
}
throw error;
}

async function resolveOrgSlug(
cwd: string,
yes: boolean,
Expand All @@ -356,23 +388,12 @@ async function resolveOrgSlug(
try {
orgs = await listOrganizations();
} catch (error) {
if (error instanceof ApiError && error.status === 403) {
const lines: string[] = ["Could not list organizations (403 Forbidden)."];
if (error.detail) {
lines.push(error.detail, "");
}
lines.push(
"Specify the org on the command line: sentry init <org-slug>/",
"Or set an environment variable: SENTRY_ORG=<org-slug> sentry init"
);
return { ok: false, error: lines.join("\n ") };
}
throw error;
return handleOrgListError(error);
}
if (orgs.length === 0) {
return {
ok: false,
error: "Not authenticated. Run 'sentry login' first.",
error: "Not authenticated. Run 'sentry auth login' first.",
};
}
if (orgs.length === 1 && orgs[0]) {
Expand Down
128 changes: 127 additions & 1 deletion test/lib/api/infrastructure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ describe("throwApiError", () => {
);
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
expect(apiError.detail).toContain(
"https://sentry.io/settings/auth-tokens/"
"https://sentry.io/settings/account/api/auth-tokens/"
);
}
});
Expand Down Expand Up @@ -336,4 +336,130 @@ describe("throwApiError", () => {
});
});
});

describe("401 enrichment", () => {
// Test preload sets SENTRY_AUTH_TOKEN, so isEnvTokenActive() returns true
// by default in these tests.

test("uses 'not recognized or has been revoked' for invalid token", () => {
const mockResponse = new Response("", {
status: 401,
statusText: "Unauthorized",
});

try {
throwApiError(
{ detail: "Invalid token" },
mockResponse,
"Failed to list organizations"
);
} catch (error) {
const apiError = error as ApiError;
expect(apiError.status).toBe(401);
expect(apiError.message).toBe(
"Failed to list organizations: 401 Unauthorized"
);
expect(apiError.detail).toContain("Invalid token");
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
expect(apiError.detail).toContain("not recognized or has been revoked");
expect(apiError.detail).toContain(
"https://sentry.io/settings/account/api/auth-tokens/"
);
}
});

test("uses 'has expired' for Token expired detail", () => {
const mockResponse = new Response("", {
status: 401,
statusText: "Unauthorized",
});

try {
throwApiError(
{ detail: "Token expired" },
mockResponse,
"Failed to list organizations"
);
} catch (error) {
const apiError = error as ApiError;
expect(apiError.status).toBe(401);
expect(apiError.detail).toContain("Token expired");
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
expect(apiError.detail).toContain("has expired");
expect(apiError.detail).not.toContain("not recognized");
expect(apiError.detail).toContain(
"https://sentry.io/settings/account/api/auth-tokens/"
);
}
});

test("falls back to 'not recognized' when detail is absent", () => {
const mockResponse = new Response("", {
status: 401,
statusText: "Unauthorized",
});

try {
throwApiError(
{ detail: undefined },
mockResponse,
"Failed to list organizations"
);
} catch (error) {
const apiError = error as ApiError;
expect(apiError.status).toBe(401);
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
expect(apiError.detail).toContain("not recognized or has been revoked");
expect(apiError.detail).not.toMatch(/^undefined/);
expect(apiError.detail).not.toContain("{}");
}
});

describe("with OAuth token (no env var)", () => {
let savedAuthToken: string | undefined;
let savedToken: string | undefined;

beforeEach(() => {
savedAuthToken = process.env.SENTRY_AUTH_TOKEN;
savedToken = process.env.SENTRY_TOKEN;
delete process.env.SENTRY_AUTH_TOKEN;
delete process.env.SENTRY_TOKEN;
});

afterEach(() => {
if (savedAuthToken !== undefined) {
process.env.SENTRY_AUTH_TOKEN = savedAuthToken;
} else {
delete process.env.SENTRY_AUTH_TOKEN;
}
if (savedToken !== undefined) {
process.env.SENTRY_TOKEN = savedToken;
} else {
delete process.env.SENTRY_TOKEN;
}
});

test("suggests re-authentication for OAuth tokens", () => {
const mockResponse = new Response("", {
status: 401,
statusText: "Unauthorized",
});

try {
throwApiError(
{ detail: "Authentication credentials were not provided." },
mockResponse,
"Failed to list organizations"
);
} catch (error) {
const apiError = error as ApiError;
expect(apiError.status).toBe(401);
expect(apiError.detail).toContain("session has expired");
expect(apiError.detail).toContain("sentry auth login");
// Should NOT mention SENTRY_AUTH_TOKEN
expect(apiError.detail).not.toContain("SENTRY_AUTH_TOKEN");
}
});
});
});
});
42 changes: 42 additions & 0 deletions test/lib/init/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,48 @@ describe("resolveInitContext", () => {
expect(feedbackOutcomes(calls)).toEqual(["cancelled"]);
});

test("surfaces 403 guidance when listOrganizations is forbidden", async () => {
resolveOrgPrefetchedSpy.mockResolvedValue(null);
listOrganizationsSpy.mockRejectedValueOnce(
new ApiError(
"Failed to list organizations",
403,
"You do not have permission."
)
);

const { ui, calls } = createMockUI();
await expect(
resolveInitContext(makeOptions({ yes: true }), ui)
).rejects.toThrow("403 Forbidden");

const errorCall = calls.find(
(c): c is Extract<MockCall, { kind: "log.error" }> =>
c.kind === "log.error"
);
expect(errorCall?.message).toContain("403 Forbidden");
expect(errorCall?.message).toContain("sentry init <org-slug>/");
});

test("surfaces 401 guidance when listOrganizations is unauthorized", async () => {
resolveOrgPrefetchedSpy.mockResolvedValue(null);
listOrganizationsSpy.mockRejectedValueOnce(
new ApiError("Failed to list organizations", 401, "Token expired")
);

const { ui, calls } = createMockUI();
await expect(
resolveInitContext(makeOptions({ yes: true }), ui)
).rejects.toThrow("401 Unauthorized");

const errorCall = calls.find(
(c): c is Extract<MockCall, { kind: "log.error" }> =>
c.kind === "log.error"
);
expect(errorCall?.message).toContain("401 Unauthorized");
expect(errorCall?.message).toContain("Token expired");
});

test("includes the auth token in the resolved context", async () => {
const { ui } = createMockUI();
const context = await resolveInitContext(makeOptions(), ui);
Expand Down
Loading