diff --git a/src/lib/api.ts b/src/lib/api.ts index ba478a82..1908b51e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -236,9 +236,12 @@ const IntegrationsResponseSchema = z.object({ /** * Check whether the project already has a Slack integration connected. - * Requires the `integration:read` scope. Throws on failure — callers - * (including the SlackConnectScreen poll) decide how to degrade and - * are responsible for capturing the error exactly once. + * Requires the `integration:read` scope. Throws an {@link ApiError} on + * failure (wrapped via {@link handleApiError} for a clean message and a + * `statusCode`) — callers (including the SlackConnectScreen poll) decide + * how to degrade and are responsible for reporting the error exactly once. + * Expected auth statuses (401/403) on the background poll are benign; the + * `statusCode` lets callers treat them as such rather than a real exception. */ export async function fetchSlackConnected( accessToken: string, @@ -246,19 +249,23 @@ export async function fetchSlackConnected( baseUrl: string, signal?: AbortSignal, ): Promise { - const response = await axios.get( - `${baseUrl}/api/projects/${projectId}/integrations/`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - 'User-Agent': WIZARD_USER_AGENT, + try { + const response = await axios.get( + `${baseUrl}/api/projects/${projectId}/integrations/`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': WIZARD_USER_AGENT, + }, + signal, }, - signal, - }, - ); - const parsed = IntegrationsResponseSchema.safeParse(response.data); - if (!parsed.success) return false; - return parsed.data.results.some((i) => i.kind === 'slack'); + ); + const parsed = IntegrationsResponseSchema.safeParse(response.data); + if (!parsed.success) return false; + return parsed.data.results.some((i) => i.kind === 'slack'); + } catch (error) { + throw handleApiError(error, 'check Slack connection'); + } } export function handleApiError(error: unknown, operation: string): ApiError { diff --git a/src/lib/oauth/program-scopes.ts b/src/lib/oauth/program-scopes.ts index 2102c93e..818ae1b6 100644 --- a/src/lib/oauth/program-scopes.ts +++ b/src/lib/oauth/program-scopes.ts @@ -193,11 +193,14 @@ export const WAREHOUSE_SOURCE_SCOPE_ADDITIONS = [ * * The step polls `/api/projects/:id/integrations/` (`fetchSlackConnected`) * to render the already-connected variant and to flip live once the user - * completes the Slack OAuth step in the browser. Without `integration:read` - * the first poll 403s, the screen stops polling, and an already-connected - * project is nagged with the connect nudge. Used by the default integration - * run (the step ends the run) and by the standalone `wizard slack` flow - * (the step is the whole program). + * completes the Slack OAuth step in the browser. If the poll can't read + * integrations — a missing `integration:read` scope (403) or an + * invalid/expired token (401) — the screen stops polling and an + * already-connected project is nagged with the connect nudge. Both auth + * statuses are treated as an expected degradation (a benign analytics + * event, not a captured exception). Used by the default integration run + * (the step ends the run) and by the standalone `wizard slack` flow (the + * step is the whole program). */ export const CONNECT_SLACK_SCOPE_ADDITIONS = ['integration:read'] as const; diff --git a/src/ui/tui/screens/SlackConnectScreen.tsx b/src/ui/tui/screens/SlackConnectScreen.tsx index 9db8f74d..ea78cc94 100644 --- a/src/ui/tui/screens/SlackConnectScreen.tsx +++ b/src/ui/tui/screens/SlackConnectScreen.tsx @@ -31,7 +31,7 @@ import { Colors, Icons } from '@ui/tui/styles'; import { PickerMenu, LoadingBox } from '@ui/tui/primitives/index'; import { useKeyBindings, KeyMatch } from '@ui/tui/hooks/useKeyBindings'; import { getSlackAppCard } from '@lib/mcp-role-prompts'; -import { fetchSlackConnected } from '@lib/api'; +import { ApiError, fetchSlackConnected } from '@lib/api'; import { Program } from '@lib/programs/program-registry'; import { getOrAskForProjectData } from '@utils/setup-utils'; import { analytics } from '@utils/analytics'; @@ -155,17 +155,29 @@ export const SlackConnectScreen = ({ store }: SlackConnectScreenProps) => { }) .catch((err: unknown) => { if (cancelled) return; - // Capture once and stop polling — repeating a failing call - // every tick would spam error tracking. The nudge copy is - // the fallback either way; a failed check counts as not - // connected so the screen doesn't sit on the loading state. + // Stop polling — repeating a failing call every tick would spam + // error tracking. The nudge copy is the fallback either way; a + // failed check counts as not connected so the screen doesn't sit + // on the loading state. if (store.session.slackConnected === null) { store.setSlackConnected(false); } - analytics.captureException( - err instanceof Error ? err : new Error(String(err)), - { step: 'slack_connected_check' }, - ); + // An expired/invalid token (401) or a missing `integration:read` + // scope (403) is an expected degradation here, not a bug — record + // it as a benign analytics event so it doesn't create error + // tracking noise. Anything else is a real exception worth capturing. + const status = err instanceof ApiError ? err.statusCode : undefined; + if (status === 401 || status === 403) { + analytics.wizardCapture('slack connect check unauthorized', { + role, + status, + }); + } else { + analytics.captureException( + err instanceof Error ? err : new Error(String(err)), + { step: 'slack_connected_check' }, + ); + } }); }; check();