Skip to content

Commit be67e5b

Browse files
betegonclaude
andcommitted
fix(init): enrich 401 Unauthorized errors with actionable guidance
403 errors already get actionable hints via enrich403Detail() — telling users to check token scopes or re-authenticate. 401 errors (invalid or expired token) were falling through with the raw HTTP status text ("Failed to list organizations: 401 Unauthorized"), which gives no indication of how to fix the problem. Adds enrich401Detail() that mirrors the 403 pattern: - env-var token path: directs to the token settings page - OAuth path: tells the user to run sentry auth login Also extends the resolveOrgSlug() catch in preflight.ts (which already handled 403) to also catch 401 and surface the enriched detail via the same { ok: false } return path — so the culprit in Sentry points to ensureOrg rather than the generic withPreflightHandling catch. Fixes: throwRawApiError's JSON-stringify fallback was not excluding 401 alongside 403, which would have produced noisy {"detail":null} prefixes in the enriched message for 401 responses with non-string detail fields. Also corrects a stale "sentry login" string (should be "sentry auth login") in the empty-orgs fallback path. Ref: CLI-1SD Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent ea8942e commit be67e5b

6 files changed

Lines changed: 279 additions & 111 deletions

File tree

.lore.md

Lines changed: 0 additions & 84 deletions
Large diffs are not rendered by default.

src/commands/issue/list.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1165,7 +1165,7 @@ function build403Detail(originalDetail: unknown): string {
11651165
: `Your ${getActiveEnvVarName()} token may lack the required scopes`;
11661166
lines.push(
11671167
` • ${leader} (${scopeList})`,
1168-
" • Check token scopes at: https://sentry.io/settings/auth-tokens/"
1168+
" • Check token scopes at: https://sentry.io/settings/account/api/auth-tokens/"
11691169
);
11701170
} else {
11711171
lines.push(" • Re-authenticate with: sentry auth login");

src/lib/api/infrastructure.ts

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function enrich403Detail(rawDetail: string | undefined): string {
6464
);
6565
}
6666
lines.push(
67-
"Check token scopes at: https://sentry.io/settings/auth-tokens/"
67+
"Check token scopes at: https://sentry.io/settings/account/api/auth-tokens/"
6868
);
6969
} else {
7070
lines.push(
@@ -75,6 +75,70 @@ function enrich403Detail(rawDetail: string | undefined): string {
7575
return lines.join("\n ");
7676
}
7777

78+
/**
79+
* Enrich a 401 Unauthorized error detail with actionable guidance.
80+
*
81+
* 401 means the token is missing, invalid, or expired — the identity cannot
82+
* be determined at all. Distinct from 403 (identity known, lacks permission).
83+
* Scope hints do not apply; the fix is always to re-authenticate or regenerate
84+
* the token.
85+
*
86+
* The Sentry API returns distinct `detail` strings we can branch on:
87+
* `"Token expired"` when the token is past its expiry date, `"Invalid token"`
88+
* when it is not found or malformed. We use this to give a more precise message
89+
* for env-var token users.
90+
*
91+
* For OAuth users the token lifecycle is transparent — `sentry-client.ts`
92+
* intercepts 401s and refreshes automatically. A 401 that reaches this function
93+
* means refresh failed and the user needs to re-authenticate via the browser.
94+
*
95+
* @see https://github.com/getsentry/sentry/blob/934f1473f198a62f9268d7140b80cd9ca1e59bb9/src/sentry/api/authentication.py#L536-L539
96+
*/
97+
function enrich401Detail(rawDetail: string | undefined): string {
98+
const lines: string[] = [];
99+
if (rawDetail) {
100+
lines.push(rawDetail, "");
101+
}
102+
if (isEnvTokenActive()) {
103+
const expired = rawDetail?.toLowerCase().includes("expired");
104+
lines.push(
105+
`Your ${getActiveEnvVarName()} token ${expired ? "has expired" : "is not recognized or has been revoked"}.`,
106+
"Create a new token at: https://sentry.io/settings/account/api/auth-tokens/"
107+
);
108+
} else {
109+
lines.push(
110+
"Not authenticated or your session has expired.",
111+
"Re-authenticate with: sentry auth login"
112+
);
113+
}
114+
return lines.join("\n ");
115+
}
116+
117+
/**
118+
* Select and apply status-specific detail enrichment.
119+
*
120+
* Extracted from {@link throwApiError} and {@link throwRawApiError} to keep
121+
* their cognitive complexity within the linter limit. 403 and 401 get
122+
* actionable guidance; all other statuses pass the raw detail through.
123+
*
124+
* `hasUsableDetail` controls whether the raw detail string is forwarded to
125+
* the enrichment functions — passing `undefined` when false lets them render
126+
* without a noisy `{"detail":null}` prefix.
127+
*/
128+
function enrichDetail(
129+
status: number,
130+
detail: string | undefined,
131+
hasUsableDetail: boolean
132+
): string | undefined {
133+
if (status === 403) {
134+
return enrich403Detail(hasUsableDetail ? detail : undefined);
135+
}
136+
if (status === 401) {
137+
return enrich401Detail(hasUsableDetail ? detail : undefined);
138+
}
139+
return detail;
140+
}
141+
78142
/**
79143
* Parse Sentry's RFC 5988 Link response header to extract pagination cursors.
80144
*
@@ -127,10 +191,9 @@ export function throwApiError(
127191
? (error as { detail: unknown }).detail
128192
: undefined;
129193
const hasUsableDetail = rawDetail !== null && rawDetail !== undefined;
130-
// When the API returns `{ detail: null }` or `{ detail: undefined }`,
131-
// fall back to stringifying the whole error object for non-403 errors
132-
// (useful for debugging). For 403s, pass undefined to enrich403Detail
133-
// so the enrichment stands alone without a noisy `{}` prefix.
194+
// Enrichment functions (enrich403Detail, enrich401Detail) render better
195+
// when rawDetail is undefined — they stand alone without a noisy `{}`
196+
// prefix. For all other statuses, stringify the full error as a debug aid.
134197
const detail = hasUsableDetail
135198
? stringifyUnknown(rawDetail)
136199
: stringifyUnknown(error);
@@ -139,7 +202,7 @@ export function throwApiError(
139202
throw new ApiError(
140203
`${context}: ${status} ${response.statusText ?? "Unknown"}`,
141204
status,
142-
is403 ? enrich403Detail(hasUsableDetail ? detail : undefined) : detail,
205+
enrichDetail(status, detail, hasUsableDetail),
143206
undefined,
144207
is403
145208
);
@@ -443,13 +506,13 @@ async function throwRawApiError(
443506
const text = await response.text();
444507
try {
445508
const parsed = JSON.parse(text) as { detail?: string };
446-
// Prefer the explicit `detail` field; fall back to the full JSON
447-
// for non-403 errors (useful for debugging). For 403s, pass
448-
// undefined so enrich403Detail stands alone without a noisy
449-
// `{"detail":null}` prefix.
509+
// Enriched statuses (403, 401) pass undefined when there is no
510+
// usable string detail so the enrichment renders without a noisy
511+
// `{"detail":null}` prefix. Other statuses get the full JSON as
512+
// a debug aid.
450513
if (typeof parsed.detail === "string") {
451514
detail = parsed.detail;
452-
} else if (response.status !== 403) {
515+
} else if (response.status !== 403 && response.status !== 401) {
453516
detail = JSON.stringify(parsed);
454517
}
455518
} catch {
@@ -476,7 +539,7 @@ async function throwRawApiError(
476539
throw new ApiError(
477540
`API request failed: ${response.status} ${response.statusText}`,
478541
response.status,
479-
is403 ? enrich403Detail(detail) : detail,
542+
enrichDetail(response.status, detail, detail !== undefined),
480543
endpoint,
481544
is403
482545
);

src/lib/init/preflight.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,38 @@ async function resolveTeam(
342342
}
343343
}
344344

345+
/**
346+
* Format a 403/401 ApiError from listOrganizations() into a { ok: false }
347+
* result, or re-throw if the error is something else.
348+
*
349+
* 403: token lacks org:read scope — user can bypass by supplying the org slug
350+
* directly. 401: token is invalid/expired — supplying an org won't help, only
351+
* re-authenticating will.
352+
*/
353+
function handleOrgListError(error: unknown): { ok: false; error: string } {
354+
if (error instanceof ApiError && error.status === 403) {
355+
const lines: string[] = ["Could not list organizations (403 Forbidden)."];
356+
if (error.detail) {
357+
lines.push(error.detail, "");
358+
}
359+
lines.push(
360+
"Specify the org on the command line: sentry init <org-slug>/",
361+
"Or set an environment variable: SENTRY_ORG=<org-slug> sentry init"
362+
);
363+
return { ok: false, error: lines.join("\n ") };
364+
}
365+
if (error instanceof ApiError && error.status === 401) {
366+
const lines: string[] = [
367+
"Could not list organizations (401 Unauthorized).",
368+
];
369+
if (error.detail) {
370+
lines.push(error.detail);
371+
}
372+
return { ok: false, error: lines.join("\n ") };
373+
}
374+
throw error;
375+
}
376+
345377
async function resolveOrgSlug(
346378
cwd: string,
347379
yes: boolean,
@@ -356,23 +388,12 @@ async function resolveOrgSlug(
356388
try {
357389
orgs = await listOrganizations();
358390
} catch (error) {
359-
if (error instanceof ApiError && error.status === 403) {
360-
const lines: string[] = ["Could not list organizations (403 Forbidden)."];
361-
if (error.detail) {
362-
lines.push(error.detail, "");
363-
}
364-
lines.push(
365-
"Specify the org on the command line: sentry init <org-slug>/",
366-
"Or set an environment variable: SENTRY_ORG=<org-slug> sentry init"
367-
);
368-
return { ok: false, error: lines.join("\n ") };
369-
}
370-
throw error;
391+
return handleOrgListError(error);
371392
}
372393
if (orgs.length === 0) {
373394
return {
374395
ok: false,
375-
error: "Not authenticated. Run 'sentry login' first.",
396+
error: "Not authenticated. Run 'sentry auth login' first.",
376397
};
377398
}
378399
if (orgs.length === 1 && orgs[0]) {

test/lib/api/infrastructure.test.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ describe("throwApiError", () => {
166166
);
167167
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
168168
expect(apiError.detail).toContain(
169-
"https://sentry.io/settings/auth-tokens/"
169+
"https://sentry.io/settings/account/api/auth-tokens/"
170170
);
171171
}
172172
});
@@ -336,4 +336,130 @@ describe("throwApiError", () => {
336336
});
337337
});
338338
});
339+
340+
describe("401 enrichment", () => {
341+
// Test preload sets SENTRY_AUTH_TOKEN, so isEnvTokenActive() returns true
342+
// by default in these tests.
343+
344+
test("uses 'not recognized or has been revoked' for invalid token", () => {
345+
const mockResponse = new Response("", {
346+
status: 401,
347+
statusText: "Unauthorized",
348+
});
349+
350+
try {
351+
throwApiError(
352+
{ detail: "Invalid token" },
353+
mockResponse,
354+
"Failed to list organizations"
355+
);
356+
} catch (error) {
357+
const apiError = error as ApiError;
358+
expect(apiError.status).toBe(401);
359+
expect(apiError.message).toBe(
360+
"Failed to list organizations: 401 Unauthorized"
361+
);
362+
expect(apiError.detail).toContain("Invalid token");
363+
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
364+
expect(apiError.detail).toContain("not recognized or has been revoked");
365+
expect(apiError.detail).toContain(
366+
"https://sentry.io/settings/account/api/auth-tokens/"
367+
);
368+
}
369+
});
370+
371+
test("uses 'has expired' for Token expired detail", () => {
372+
const mockResponse = new Response("", {
373+
status: 401,
374+
statusText: "Unauthorized",
375+
});
376+
377+
try {
378+
throwApiError(
379+
{ detail: "Token expired" },
380+
mockResponse,
381+
"Failed to list organizations"
382+
);
383+
} catch (error) {
384+
const apiError = error as ApiError;
385+
expect(apiError.status).toBe(401);
386+
expect(apiError.detail).toContain("Token expired");
387+
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
388+
expect(apiError.detail).toContain("has expired");
389+
expect(apiError.detail).not.toContain("not recognized");
390+
expect(apiError.detail).toContain(
391+
"https://sentry.io/settings/account/api/auth-tokens/"
392+
);
393+
}
394+
});
395+
396+
test("falls back to 'not recognized' when detail is absent", () => {
397+
const mockResponse = new Response("", {
398+
status: 401,
399+
statusText: "Unauthorized",
400+
});
401+
402+
try {
403+
throwApiError(
404+
{ detail: undefined },
405+
mockResponse,
406+
"Failed to list organizations"
407+
);
408+
} catch (error) {
409+
const apiError = error as ApiError;
410+
expect(apiError.status).toBe(401);
411+
expect(apiError.detail).toContain("SENTRY_AUTH_TOKEN");
412+
expect(apiError.detail).toContain("not recognized or has been revoked");
413+
expect(apiError.detail).not.toMatch(/^undefined/);
414+
expect(apiError.detail).not.toContain("{}");
415+
}
416+
});
417+
418+
describe("with OAuth token (no env var)", () => {
419+
let savedAuthToken: string | undefined;
420+
let savedToken: string | undefined;
421+
422+
beforeEach(() => {
423+
savedAuthToken = process.env.SENTRY_AUTH_TOKEN;
424+
savedToken = process.env.SENTRY_TOKEN;
425+
delete process.env.SENTRY_AUTH_TOKEN;
426+
delete process.env.SENTRY_TOKEN;
427+
});
428+
429+
afterEach(() => {
430+
if (savedAuthToken !== undefined) {
431+
process.env.SENTRY_AUTH_TOKEN = savedAuthToken;
432+
} else {
433+
delete process.env.SENTRY_AUTH_TOKEN;
434+
}
435+
if (savedToken !== undefined) {
436+
process.env.SENTRY_TOKEN = savedToken;
437+
} else {
438+
delete process.env.SENTRY_TOKEN;
439+
}
440+
});
441+
442+
test("suggests re-authentication for OAuth tokens", () => {
443+
const mockResponse = new Response("", {
444+
status: 401,
445+
statusText: "Unauthorized",
446+
});
447+
448+
try {
449+
throwApiError(
450+
{ detail: "Authentication credentials were not provided." },
451+
mockResponse,
452+
"Failed to list organizations"
453+
);
454+
} catch (error) {
455+
const apiError = error as ApiError;
456+
expect(apiError.status).toBe(401);
457+
expect(apiError.detail).toContain("session has expired");
458+
expect(apiError.detail).toContain("sentry auth login");
459+
// Should NOT mention SENTRY_AUTH_TOKEN
460+
expect(apiError.detail).not.toContain("SENTRY_AUTH_TOKEN");
461+
}
462+
});
463+
});
464+
});
339465
});

test/lib/init/preflight.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,48 @@ describe("resolveInitContext", () => {
317317
expect(feedbackOutcomes(calls)).toEqual(["cancelled"]);
318318
});
319319

320+
test("surfaces 403 guidance when listOrganizations is forbidden", async () => {
321+
resolveOrgPrefetchedSpy.mockResolvedValue(null);
322+
listOrganizationsSpy.mockRejectedValueOnce(
323+
new ApiError(
324+
"Failed to list organizations",
325+
403,
326+
"You do not have permission."
327+
)
328+
);
329+
330+
const { ui, calls } = createMockUI();
331+
await expect(
332+
resolveInitContext(makeOptions({ yes: true }), ui)
333+
).rejects.toThrow("403 Forbidden");
334+
335+
const errorCall = calls.find(
336+
(c): c is Extract<MockCall, { kind: "log.error" }> =>
337+
c.kind === "log.error"
338+
);
339+
expect(errorCall?.message).toContain("403 Forbidden");
340+
expect(errorCall?.message).toContain("sentry init <org-slug>/");
341+
});
342+
343+
test("surfaces 401 guidance when listOrganizations is unauthorized", async () => {
344+
resolveOrgPrefetchedSpy.mockResolvedValue(null);
345+
listOrganizationsSpy.mockRejectedValueOnce(
346+
new ApiError("Failed to list organizations", 401, "Token expired")
347+
);
348+
349+
const { ui, calls } = createMockUI();
350+
await expect(
351+
resolveInitContext(makeOptions({ yes: true }), ui)
352+
).rejects.toThrow("401 Unauthorized");
353+
354+
const errorCall = calls.find(
355+
(c): c is Extract<MockCall, { kind: "log.error" }> =>
356+
c.kind === "log.error"
357+
);
358+
expect(errorCall?.message).toContain("401 Unauthorized");
359+
expect(errorCall?.message).toContain("Token expired");
360+
});
361+
320362
test("includes the auth token in the resolved context", async () => {
321363
const { ui } = createMockUI();
322364
const context = await resolveInitContext(makeOptions(), ui);

0 commit comments

Comments
 (0)