diff --git a/.lore.md b/.lore.md index 81c3f41a9..6765aafa0 100644 --- a/.lore.md +++ b/.lore.md @@ -1,86 +1,2 @@ -## Long-term Knowledge - -### Architecture - - -* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: (architecture) Auth token precedence: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. npm bundle requires Node.js >= 22 due to \`node:sqlite\` polyfill. Runtime version guard in esbuild banner catches early. Double-escape in TS template literals: \`\ \` in TS → \`\n\` in output → newline at runtime. - - -* **Completion fast-path skips Sentry SDK via SENTRY\_CLI\_NO\_TELEMETRY and SQLite telemetry queue**: (architecture) Completion fast-path + SDK overhead: Shell completions set \`SENTRY\_CLI\_NO\_TELEMETRY=1\` before imports, skipping \`createTracedDatabase\` and \`@sentry/node-core/light\` load (~85ms). Timing queued to \`completion\_telemetry\_queue\` SQLite table; normal runs drain via \`DELETE...RETURNING\`. Target: ~60ms dev/~140ms CI within 200ms budget. SDK uses \`@sentry/node-core/light\` (not \`@sentry/bun\`) to avoid OTel overhead. \`@sentry/core\` barrel patched via \`bun patch\`. \`LightNodeClient\` hardcodes \`runtime:{name:'node'}\` AFTER spreading options — fix by patching \`client.getOptions().runtime\` post-init. Always import from \`@sentry/node-core/light\`; root barrel pulls uninstalled @opentelemetry/instrumentation. When bumping SDK: remove patches, install, patch, edit, commit. - - -* **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: (architecture) Consola is the CLI logger with Sentry \`createConsolaReporter\` integration. Two reporters: FancyReporter (stderr) + Sentry structured logs. Level via \`SENTRY\_LOG\_LEVEL\`. \`buildCommand\` injects hidden \`--log-level\`/\`--verbose\` flags. \`withTag()\` creates independent instances; \`setLogLevel()\` propagates via registry. All user-facing output must use consola, not raw stderr. \`HandlerContext\` intentionally omits stderr. Telemetry opt-out priority: \`SENTRY\_CLI\_NO\_TELEMETRY=1\` > \`DO\_NOT\_TRACK=1\` > \`metadata.defaults.telemetry\` > default on. Schema v13 merged \`defaults\` table into \`metadata\` KV with keys \`defaults.{org,project,telemetry,url}\`; getters/setters in \`src/lib/db/defaults.ts\`. - - -* **DSN cache invalidation uses two-level mtime tracking (sourceMtimes + dirMtimes)**: (architecture) DSN cache invalidation + repo cache: Two-level mtime tracking: \`sourceMtimes\` (DSN-bearing files) + \`dirMtimes\` (walked dirs) + root mtime fast-path + 24h TTL. Dropping either map is a correctness regression. \`processMatch\` must record mtime for EVERY file with host-validated DSN via \`fileHadValidDsn\` flag. \`scanDirectory\` catch MUST return empty \`dirMtimes: {}\`, not partial. \`repo\_cache\` SQLite table (schema v14) with 7-day TTL. \`listAllRepositories(org)\` paginates via \`listRepositoriesPaginated\` using \`API\_MAX\_PER\_PAGE\` — never use unpaginated \`listRepositories\` (silently caps at ~25). \`listRepositoriesCached\` wraps with cache-first + try/catch for read-only DBs. - - -* **Grep worker pool: binary-transferable matches + streaming dispatch in src/lib/scan/**: (architecture) Grep worker pool + scan traps (\`src/lib/scan/\`): Worker pool: lazy singleton size \`min(8,max(2,availableParallelism()))\`. Matches as \`Uint32Array\` quads transferred via \`postMessage\` (~40% faster). Worker via \`Blob+URL.createObjectURL\`; \`new Worker(new URL(...))\` HANGS in compiled binaries. FIFO \`pending\` queue per worker. \`ref()\`/\`unref()\` are NOT refcounted — unref only when \`inflight===0\`. Disable via \`SENTRY\_SCAN\_DISABLE\_WORKERS=1\`. Scan traps: (1) Early-exit via \`mapFilesConcurrent.onResult\` wins over whole-buffer regex over many files. (2) Literal prefilter is file-level gate; per-line verify breaks cross-newline patterns. (3) \`mapFilesConcurrent\` filters \`null\` not \`\[]\` — return \`null\` for no-op files. (4) \`collectGlob\`/\`collectGrep\` must NOT forward \`maxResults\` to iterator. - - -* **Host-scoped token model: auth.host column + three-layer enforcement**: (architecture) Host-scoped token model (PR #844): every token bound to issuing host via \`auth.host\` column (schema v16). Trust established ONLY via \`sentry auth login --url\` or shell-exported \`SENTRY\_HOST\`/\`SENTRY\_URL\` at boot — \`.sentryclirc\` URL never a trust source. Three enforcement layers: (1) \`applySentryUrlContext\` throws on URL-arg mismatch; (2) \`applySentryCliRcEnvShim\` throws on rc-url mismatch; (3) fetch-layer \`isRequestOriginTrusted\`. Region trust: in-process Set in \`db/regions.ts\`, auto-synced by \`setOrgRegion(s)\`. \`clearTrustedHostState\` must NOT clear login anchor. \`HostScopeError\` has overloads \`(message)\` and \`(source, destinationUrl, tokenHost)\`. E2E: pass \`--url ${ctx.serverUrl}\` to \`auth login --token\`. \`isSentrySaasUrl\`: hostname-only; \`isSaaSTrustOrigin\`: also requires \`https:\` + default port. - - -* **Issue resolve --in grammar: release + @next + @commit sentinels**: \`sentry issue resolve --in\` grammar: (a) omitted→immediate resolve, (b) \`\\`→\`inRelease\` (monorepo \`spotlight@1.2.3\` pass-through), (c) \`@next\`→\`inNextRelease\`, (d) \`@commit\`→auto-detect git HEAD + match Sentry repos, (e) \`@commit:\@\\`→explicit. Sentinel matching case-insensitive; unknown \`@\`-prefixed tokens throw \`ValidationError\`. \`parseResolveSpec\` splits on LAST \`@\` to handle scoped names like \`@acme/web\`. \`resolveCommitSpec\` uses \`getHeadCommit\`/\`getRepositoryName\` from \`src/lib/git.ts\`, matching Sentry repo \`externalSlug\` or \`name\` via \`listRepositoriesCached\`. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA. - - -* **Magic @ selectors resolve issues dynamically via sort-based list API queries**: Magic @ selectors resolve issues dynamically: \`@latest\`, \`@most\_frequent\` in \`parseIssueArg\` detected before \`validateResourceId\` (@ not in forbidden charset). \`SELECTOR\_MAP\` provides case-insensitive matching. \`resolveSelector\` maps to \`IssueSort\` values, calls \`listIssuesPaginated\` with \`perPage: 1\`, \`query: 'is:unresolved'\`. Supports org-prefixed: \`sentry/@latest\`. Unrecognized \`@\`-prefixed strings fall through. \`ParsedIssueArg\` union includes \`{ type: 'selector' }\`. - - -* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: Seer trial prompt via error middleware layering: \`bin.ts\` chain is \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (\`no\_budget\`/\`not\_enabled\`) caught by inner wrapper; auth errors bubble to outer. Trial API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start: \`PUT /api/0/customers/{org}/product-trial/\`. SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` excluded. \`startSeerTrial\` accepts \`category\` from trial object — don't hardcode. - - -* **Sentry CLI authenticated fetch architecture with response caching**: (architecture) Authenticated fetch + response cache: \`createAuthenticatedFetch\`: auth headers, 30s timeout, max 2 retries, 401 refresh, span tracing. \`buildAttemptFactory\` clones \`Request\`; do NOT materialize FormData (strips boundary). Per-endpoint timeout overrides (e.g. \`/autofix/\` 120s). Response cache RFC 7234 at \`~/.sentry/cache/responses/\`, GET 2xx only. TTL tiers: stable=5min, volatile=60s, immutable=24h. \`@sentry/api\` SDK passes Request with no init — undefined init → empty headers stripping Content-Type (HTTP 415). Fix: fall back to \`input.headers\` when init undefined. Guard \`Array.isArray(data)\` before \`.map()\` (SDK returns \`{}\` for 204/empty). Tests mocking fetch MUST call \`useTestConfigDir()\` + \`setAuthToken()\` + \`resetCacheState()\` + \`disableResponseCache()\` + \`resetAuthenticatedFetch()\` in beforeEach — filesystem cache will serve prior test responses otherwise. - - -* **Sentry CLI resolve-target cascade has 5 priority levels with env var support**: (architecture) Resolve-target cascade: (1) CLI flags, (2) SENTRY\_ORG/SENTRY\_PROJECT env vars, (3) SQLite defaults, (4) DSN auto-detection, (5) directory name inference. SENTRY\_PROJECT supports \`org/project\` combo — SENTRY\_ORG ignored if set. Malformed combos discarded. \`resolveFromEnvVars()\` injected into all four resolution functions. Prefer dedicated SQLite tables + migrations over \`metadata\` KV for non-trivial caches — dedicated tables give clearer schema, proper indexes, simpler bulk-clear. \`metadata\` KV fine for small scalars (defaults.\*, install.\*). Example: \`issue\_org\_cache\` (v15) replaced \`metadata\` keys. - - -* **Sentry log IDs are UUIDv7 — enables deterministic retention checks**: (architecture) Sentry log IDs are UUIDv7 — enables deterministic retention checks. \`decodeUuidV7Timestamp()\` and \`ageInDaysFromUuidV7()\` in \`src/lib/hex-id.ts\` return null for non-v7, safe to call unconditionally. \`RETENTION\_DAYS.log = 90\` in \`src/lib/retention.ts\`; traces/events are \`null\` (plan-dependent). \`LOG\_RETENTION\_PERIOD\` is DERIVED as \`\` \`${RETENTION\_DAYS.log}d\` \`\` — never hardcode \`'90d'\`. Shared hex primitives (\`HEX\_ID\_RE\`, \`SPAN\_ID\_RE\`, \`UUID\_DASH\_RE\`) live in \`hex-id.ts\`. Three Sentry span APIs: (1) \`/trace/{traceId}/\` — hierarchical tree with \`additional\_attributes\`. (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span with ALL attributes. (3) \`/events/?dataset=spans\` — list/search. \`meta.fields\` order is non-deterministic — derive column order from user's \`--field\` list via \`orderFieldNames()\` in \`explore.ts\`. - - -* **Zod schema on OutputConfig enables self-documenting JSON fields in help and SKILL.md**: (architecture) Zod schema on OutputConfig enables self-documenting JSON fields: List commands register \`schema?: ZodType\` on \`OutputConfig\\`. \`extractSchemaFields()\` produces \`SchemaFieldInfo\[]\`. \`buildFieldsFlag()\` enriches \`--fields\` brief; \`enrichDocsWithSchema()\` appends fields to \`fullDescription\`. Schema exposed as \`\_\_jsonSchema\` on built commands — \`introspect.ts\` reads it into \`CommandInfo.jsonFields\`. For \`buildOrgListCommand\`/\`dispatchOrgScopedList\`, pass \`schema\` via \`OrgListConfig\`. Markdown output: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` for rendered-mode assertions. - -### Decision - - -* **All view subcommands should use \ \ positional pattern**: All \`\* view\` subcommands use \`\ \\` positional pattern (Intent-First Correction UX): target is optional \`org/project\`. Use opportunistic arg swapping with \`log.warn()\` when args are wrong order — when intent is unambiguous, do what they meant. Normalize at command level, keep parsers pure. Model after \`gh\` CLI. Exception: \`auth\` uses \`defaultCommand: "status"\` (no viewable entity). Routes without defaults: \`cli\`, \`sourcemap\`, \`repo\`, \`team\`, \`trial\`, \`release\`, \`dashboard/widget\`. - - -* **Issue list global limit with fair per-project distribution and representation guarantees**: \`issue list --limit\` is a global total across all detected projects. \`fetchWithBudget\` Phase 1 divides evenly, Phase 2 redistributes surplus via cursor resume. \`trimWithProjectGuarantee\` ensures ≥1 issue per project before filling remaining slots. JSON output wraps in \`{ data, hasMore }\` with optional \`errors\` array. Compound cursor (pipe-separated) enables \`-c last\` for multi-target pagination, keyed by sorted target fingerprint. - -### Gotcha - - -* **Biome noUselessUndefined also rejects () => {} empty arrow callbacks**: (gotcha) Biome lint traps (run \`bun run lint\` not \`lint:fix\` before pushing): (1) \`noUselessUndefined\`+\`noEmptyBlockStatements\` reject \`()=>undefined\` and \`()=>{}\` — use \`function noop():void{}\`. (2) \`noExcessiveCognitiveComplexity\` caps at 15 — extract helpers. (3) \`noPrecisionLoss\` on int >2^53 — use \`Number(string)\`. (4) \`noIncrementDecrement\` — use \`i+=1\`. (5) \`useYield\` on \`async \*func()\` needs \`biome-ignore\`. (6) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. (7) \`noMisplacedAssertion\` fires on helper functions — use inline \`biome-ignore\` above each \`expect()\`, NOT file-level. (8) \`lint:fix\` hides CI issues. Node polyfill (\`script/node-polyfills.ts\`) is INCOMPLETE — prefer \`node:fs/promises\` for file ops; \`execSync\` for shell. When extending, alias Node functions via \`bind\` not wrapper closures. - - -* **process.stdin.isTTY unreliable in Bun — use isatty(0) and backfill for clack**: (gotcha) TTY + stdin traps in Bun: (1) \`process.stdin.isTTY\` unreliable — use \`isatty(0)\` from \`node:tty\`; backfill \`process.stdin.isTTY=true\` when confirmed (clack gates \`setRawMode\` on \`input.isTTY\`). (2) Bun 1.1.x macOS: \`process.stdin\` via kqueue fails on reopened non-session-leader TTY fd — workaround: \`openSync('/dev/tty','r') + new tty.ReadStream(fd)\` in \`src/lib/init/stdin-reopen.ts\`, darwin-gated. Leaks libuv handle — safety net: \`setTimeout(process.exit,100).unref()\` in init.ts. Skip under \`NODE\_ENV=test\`. Diagnostics: \`src/lib/init/tty-diagnostics.ts\` \`dumpTtyDiagnostics()\`, no-op unless \`SENTRY\_INIT\_DIAGNOSTICS=1\`. - - -* **runInteractiveLogin swallows errors and sets process.exitCode = 1**: \`runInteractiveLogin\` in \`src/lib/interactive-login.ts\` catches OAuth flow errors internally (device-code fetch failures, timeout, etc.) and returns falsy on failure. The login command then sets \`process.exitCode = 1\` and returns normally — the wrapped command function resolves, NOT rejects. Tests that mock fetch to throw and expect \`rejects.toThrow()\` will fail with \`resolved: Promise { \ }\`. Assert behavior via fetch-call inspection (\`fetchCalls.length > 0\`, header content) instead. \`requestDeviceCode\` requires \`SENTRY\_CLIENT\_ID\` env var — unset in tests means it throws \`ConfigError\` before any fetch fires. - - -* **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: (gotcha) Stricli flag parsing traps: (1) Unknown \`--flag\`s rejected — global flags parsed in \`bin.ts\` MUST be spliced from argv (both \`--flag value\` and \`--flag=value\` forms). (2) \`FLAG\_NAME\_PATTERN\` requires 2+ chars after \`--\`; single-char flags silently become positionals — use aliases. (3) Hidden \`--log-level\`/\`--verbose\` via \`FlagDef.hidden\`. (4) \`api.ts\`: plain \`Error\` inside \`func()\` bypasses \`CliError\` handling — use \`ValidationError\` for user-input errors. (5) \`AuthError(reason, message?)\` — easy to swap args; \`reason\` is \`'not\_authenticated'|'expired'|'invalid'\`. Tests aren't type-checked so TypeScript won't catch swapped args. - -### Pattern - - -* **Grouped widget --limit auto-default via applyGroupLimitAutoDefault helper**: Dashboard widget flag normalization: (1) Dataset aliases (errors→error-events) normalize ONCE at top of \`func()\` via \`normalizeDataset()\` in \`src/commands/dashboard/resolve.ts\`. In \`edit.ts\`, pass \`normalizedFlags\` to \`buildReplacement\` — \`validateAggregateNames\` reads \`flags.dataset\` and rejects valid aggregates like \`failure\_rate\` if it sees raw alias. (2) Grouped widgets need \`limit\` (API rejects). \`applyGroupLimitAutoDefault\` defaults to \`DEFAULT\_GROUP\_BY\_LIMIT=5\` only when user passed \`--group-by\` without \`--limit\`; skip for auto-defaulted columns like \`\["issue"]\`. (3) Tests asserting \`--limit\` >10 survives into PUT body must use \`display: "line"\` — \`prepareWidgetQueries\` clamps bar/table to max=10. - - -* **Merging mock.module() test files with static-import counterparts**: (pattern) Bun test mocking traps: (1) \`mock.module()\` for CJS built-ins needs \`default\` re-export + named exports, declared top-level BEFORE \`await import()\`. (2) Convert code-under-test to \`await import()\` when merging mocks — static imports won't re-bind. (3) \`Bun.mmap()\` always PROT\_WRITE — use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` for read-only. (4) \`mock.module()\` pollutes registry — use \`test/isolated/\`. (5) Tests aren't type-checked but ARE lint-checked. (6) \`buildCommand\` wrapper: \`cmd.loader()\` returns wrapped async fn; call \`func.call(ctx, flags, ...args)\` as a promise. Auth guard runs first; \`test/preload.ts\` sets fake \`SENTRY\_AUTH\_TOKEN\`. (7) Test glob: \`test:unit\` only picks up \`test/lib\`, \`test/commands\`, \`test/types\` — tests under \`test/fixtures/\`, \`test/scripts/\`, \`test/script/\` are NOT run by CI. - - -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: (pattern) Pagination infrastructure + org flag injection: Bidirectional pagination via cursor stack in \`src/lib/db/pagination.ts\`. \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/first/last) to \`{cursor, direction}\`. \`advancePaginationState\` manages stack — back-then-forward truncates stale entries. \`paginationHint()\` builds nav strings. Critical: \`resolveCursor()\` must be called INSIDE \`org-all\` override closures, not before \`dispatchOrgScopedList\`. Hidden global \`--org\`/\`--project\` flags accept old \`sentry-cli\` syntax via \`mergeGlobalFlags()\` in command.ts; \`applyOrgProjectFlags()\` writes to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` before auth guard. No short aliases (\`-p\` conflicts). The helper extraction keeps \`buildCommand\` under Biome's complexity limit of 15. - - -* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: (pattern) Telemetry instrumentation + command bypass: Use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans (\`onlyIfParent: true\` — no-op without active transaction) and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. User-visible fallbacks use \`log.warn()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts). \`ENV\_VAR\_REGISTRY\` in \`src/lib/env-registry.ts\` is the single source for all honored env vars; \`topLevel: true\` + \`briefDescription\` surfaces in \`--help\`. Add new env vars here with \`installOnly: true\` if install-script-only. - - -* **Tests calling setAuthToken must pass {host} matching the mock URL**: (pattern) Host-scoping test gotchas: (1) Tests mocking fetch with non-SaaS URLs must pass \`{host}\` to \`setAuthToken(token, ttl, {host})\` — defaults to SaaS via \`captureEnvTokenHost\`, causing \`HostScopeError\`. (2) \`assertRcUrlTrusted\` tests: sequence is \`resetEnvTokenHostForTesting()\` → delete env vars → \`captureEnvTokenHost()\` → \`applySentryCliRcEnvShim()\` → assert. (3) E2E: parent \`createE2EContext\` must \`setAuthToken(token, ttl, {host: serverUrl})\`; multi-region tests need \`registerTrustedRegionUrls\` before fan-out. \`resetHostScopingState()\` bundles reset of env-token-host + login-trust-anchor + trusted-region-urls. \`mintSntrysToken(payload)\` produces test tokens. Always reset together in beforeEach. - - -* **Token-type classification via literal prefix match (classifySentryToken)**: (pattern) Token classification and host claims: \`classifySentryToken(token)\` returns \`'org-auth-token'\` (\`sntrys\_\` prefix), \`'user-auth-token'\` (\`sntryu\_\` prefix), or \`'oauth-or-legacy'\`. Use to short-circuit commands where token type is semantically invalid before a confusing API failure. \`sntrys\_\` payload is unsigned/unverifiable — UX hint only. \`parseSntrysClaim\` requires exactly 2 underscores, 2KB cap, fail-open. \`captureEnvTokenHost\` uses claim url first for \`sntrys\_\` (defends against \`$GITHUB\_ENV\` poisoning). \`prepareHeaders\` refuses bearer attach if request origin doesn't match claim url. diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 42b87070f..13831ecbe 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -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"); diff --git a/src/lib/api/infrastructure.ts b/src/lib/api/infrastructure.ts index 155922124..fc2de1f27 100644 --- a/src/lib/api/infrastructure.ts +++ b/src/lib/api/infrastructure.ts @@ -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/" ); } else { lines.push( @@ -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. * @@ -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); @@ -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 ); @@ -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 { @@ -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 ); diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts index 8d32639d9..0d1d84681 100644 --- a/src/lib/init/preflight.ts +++ b/src/lib/init/preflight.ts @@ -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 /", + "Or set an environment variable: SENTRY_ORG= 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, @@ -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 /", - "Or set an environment variable: SENTRY_ORG= 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]) { diff --git a/test/lib/api/infrastructure.test.ts b/test/lib/api/infrastructure.test.ts index cddbd34b8..30878a617 100644 --- a/test/lib/api/infrastructure.test.ts +++ b/test/lib/api/infrastructure.test.ts @@ -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/" ); } }); @@ -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"); + } + }); + }); + }); }); diff --git a/test/lib/init/preflight.test.ts b/test/lib/init/preflight.test.ts index 9a2ebdde8..8ce038e27 100644 --- a/test/lib/init/preflight.test.ts +++ b/test/lib/init/preflight.test.ts @@ -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 => + c.kind === "log.error" + ); + expect(errorCall?.message).toContain("403 Forbidden"); + expect(errorCall?.message).toContain("sentry init /"); + }); + + 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 => + 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);