@@ -11,6 +11,8 @@ import { parseSentryLinkHeader } from "@sentry/api";
1111import * as Sentry from "@sentry/node-core/light" ;
1212import type { z } from "zod" ;
1313
14+ import { extractRequiredScopes } from "../api-scope.js" ;
15+ import { getActiveEnvVarName , isEnvTokenActive } from "../db/auth.js" ;
1416import { getEnv } from "../env.js" ;
1517import { ApiError , AuthError , stringifyUnknown } from "../errors.js" ;
1618import { resolveOrgRegion } from "../region.js" ;
@@ -20,6 +22,47 @@ import {
2022 getSdkConfig ,
2123} from "../sentry-client.js" ;
2224
25+ /**
26+ * Enrich a 403 Forbidden error detail with actionable guidance.
27+ *
28+ * For env-var tokens (SENTRY_AUTH_TOKEN / SENTRY_TOKEN): extracts the specific
29+ * missing scope from the API response when available, otherwise suggests
30+ * checking token scopes. Includes a link to the token settings page.
31+ *
32+ * For OAuth tokens: suggests the user may lack access and should re-authenticate.
33+ *
34+ * @param rawDetail - The original detail string from the API 403 response
35+ * @returns Enriched detail string with actionable suggestions
36+ */
37+ function enrich403Detail ( rawDetail : string | undefined ) : string {
38+ const lines : string [ ] = [ ] ;
39+ if ( rawDetail ) {
40+ lines . push ( rawDetail , "" ) ;
41+ }
42+
43+ if ( isEnvTokenActive ( ) ) {
44+ const scopes = extractRequiredScopes ( rawDetail ) ;
45+ if ( scopes . length > 0 ) {
46+ lines . push (
47+ `Your ${ getActiveEnvVarName ( ) } token is missing the required scope(s) '${ scopes . join ( "', '" ) } '.`
48+ ) ;
49+ } else {
50+ lines . push (
51+ `Your ${ getActiveEnvVarName ( ) } token may lack the required scope for this operation.`
52+ ) ;
53+ }
54+ lines . push (
55+ "Check token scopes at: https://sentry.io/settings/auth-tokens/"
56+ ) ;
57+ } else {
58+ lines . push (
59+ "You may not have access to this resource." ,
60+ "Re-authenticate with: sentry auth login"
61+ ) ;
62+ }
63+ return lines . join ( "\n " ) ;
64+ }
65+
2366/**
2467 * Parse Sentry's RFC 5988 Link response header to extract pagination cursors.
2568 *
@@ -67,15 +110,26 @@ export function throwApiError(
67110 }
68111
69112 const status = response . status ;
70- const detail =
113+ const rawDetail =
71114 error && typeof error === "object" && "detail" in error
72- ? stringifyUnknown ( ( error as { detail : unknown } ) . detail )
73- : stringifyUnknown ( error ) ;
74-
115+ ? ( error as { detail : unknown } ) . detail
116+ : undefined ;
117+ const hasUsableDetail = rawDetail !== null && rawDetail !== undefined ;
118+ // When the API returns `{ detail: null }` or `{ detail: undefined }`,
119+ // fall back to stringifying the whole error object for non-403 errors
120+ // (useful for debugging). For 403s, pass undefined to enrich403Detail
121+ // so the enrichment stands alone without a noisy `{}` prefix.
122+ const detail = hasUsableDetail
123+ ? stringifyUnknown ( rawDetail )
124+ : stringifyUnknown ( error ) ;
125+
126+ const is403 = status === 403 ;
75127 throw new ApiError (
76128 `${ context } : ${ status } ${ response . statusText ?? "Unknown" } ` ,
77129 status ,
78- detail
130+ is403 ? enrich403Detail ( hasUsableDetail ? detail : undefined ) : detail ,
131+ undefined ,
132+ is403
79133 ) ;
80134}
81135
@@ -303,38 +357,7 @@ export async function apiRequestToRegion<T>(
303357 } ) ;
304358
305359 if ( ! response . ok ) {
306- let detail : string | undefined ;
307- try {
308- const text = await response . text ( ) ;
309- try {
310- const parsed = JSON . parse ( text ) as { detail ?: string } ;
311- detail = parsed . detail ?? JSON . stringify ( parsed ) ;
312- } catch {
313- detail = text ;
314- }
315- } catch {
316- detail = response . statusText ;
317- }
318- // Attach a small allowlisted subset of response headers to the Sentry
319- // event as context. This lets us distinguish Sentry-app 4xx/5xx (which
320- // ship a `{"detail": "..."}` JSON body and `content-type: application/json`)
321- // from CDN / WAF / edge 4xx (Cloudflare / proxy) that return empty or HTML
322- // bodies — a gap that previously made empty-`detail` events like CLI-1AZ
323- // impossible to triage without user-side repro.
324- Sentry . setContext ( "api_response_headers" , {
325- "content-type" : response . headers . get ( "content-type" ) ,
326- "content-length" : response . headers . get ( "content-length" ) ,
327- server : response . headers . get ( "server" ) ,
328- "cf-ray" : response . headers . get ( "cf-ray" ) ,
329- "x-sentry-error" : response . headers . get ( "x-sentry-error" ) ,
330- "www-authenticate" : response . headers . get ( "www-authenticate" ) ,
331- } ) ;
332- throw new ApiError (
333- `API request failed: ${ response . status } ${ response . statusText } ` ,
334- response . status ,
335- detail ,
336- endpoint
337- ) ;
360+ await throwRawApiError ( response , endpoint ) ;
338361 }
339362
340363 // 204 No Content / 205 Reset Content have no body by spec — calling
@@ -377,6 +400,61 @@ export async function apiRequestToRegion<T>(
377400 return { data : data as T , headers : response . headers } ;
378401}
379402
403+ /**
404+ * Extract error detail from a failed HTTP response, attach diagnostic
405+ * headers to the Sentry scope, and throw an enriched {@link ApiError}.
406+ *
407+ * Extracted from `apiRequestToRegion` to keep the main function's
408+ * cognitive complexity under the lint threshold.
409+ */
410+ async function throwRawApiError (
411+ response : Response ,
412+ endpoint : string
413+ ) : Promise < never > {
414+ let detail : string | undefined ;
415+ try {
416+ const text = await response . text ( ) ;
417+ try {
418+ const parsed = JSON . parse ( text ) as { detail ?: string } ;
419+ // Prefer the explicit `detail` field; fall back to the full JSON
420+ // for non-403 errors (useful for debugging). For 403s, pass
421+ // undefined so enrich403Detail stands alone without a noisy
422+ // `{"detail":null}` prefix.
423+ if ( typeof parsed . detail === "string" ) {
424+ detail = parsed . detail ;
425+ } else if ( response . status !== 403 ) {
426+ detail = JSON . stringify ( parsed ) ;
427+ }
428+ } catch {
429+ detail = text || undefined ;
430+ }
431+ } catch {
432+ detail = response . statusText ;
433+ }
434+ // Attach a small allowlisted subset of response headers to the Sentry
435+ // event as context. This lets us distinguish Sentry-app 4xx/5xx (which
436+ // ship a `{"detail": "..."}` JSON body and `content-type: application/json`)
437+ // from CDN / WAF / edge 4xx (Cloudflare / proxy) that return empty or HTML
438+ // bodies — a gap that previously made empty-`detail` events like CLI-1AZ
439+ // impossible to triage without user-side repro.
440+ Sentry . setContext ( "api_response_headers" , {
441+ "content-type" : response . headers . get ( "content-type" ) ,
442+ "content-length" : response . headers . get ( "content-length" ) ,
443+ server : response . headers . get ( "server" ) ,
444+ "cf-ray" : response . headers . get ( "cf-ray" ) ,
445+ "x-sentry-error" : response . headers . get ( "x-sentry-error" ) ,
446+ "www-authenticate" : response . headers . get ( "www-authenticate" ) ,
447+ } ) ;
448+ const is403 = response . status === 403 ;
449+ throw new ApiError (
450+ `API request failed: ${ response . status } ${ response . statusText } ` ,
451+ response . status ,
452+ is403 ? enrich403Detail ( detail ) : detail ,
453+ endpoint ,
454+ is403
455+ ) ;
456+ }
457+
380458/**
381459 * Make an authenticated request to the default Sentry API.
382460 *
0 commit comments