Skip to content

feat: direct Slack OAuth integration via Thunder GraphQL#59

Merged
kelsonpw merged 17 commits intomainfrom
kelsonpw/slack-integration
Apr 11, 2026
Merged

feat: direct Slack OAuth integration via Thunder GraphQL#59
kelsonpw merged 17 commits intomainfrom
kelsonpw/slack-integration

Conversation

@kelsonpw
Copy link
Copy Markdown
Collaborator

@kelsonpw kelsonpw commented Apr 11, 2026

Summary

  • Adds fetchSlackInstallUrl() API call to get the direct Slack OAuth URL from Thunder's slackInstallUrl GraphQL query (action: profile) — currently blocked by Thunder returning 405 (bearer token auth not yet supported)
  • Adds fetchSlackConnectionStatus() to auto-detect existing Slack connections and skip the setup flow
  • Fixes broken settings URL: uses /analytics/org/:orgId/settings/profile path instead of URL-encoding org display names
  • Rewrites standalone pnpm try slack command to work without the full TUI (avoids tsx CJS/ESM interop issue) and requires login
  • All Thunder API calls gracefully fall back on failure — the settings-page flow works as the default path

Blocked on

  • Thunder needs bearer token support for the direct Slack OAuth URL to work (Ben Feigin is aware). Until then, the wizard opens the settings page.

Test plan

  • pnpm try slack → opens https://app.amplitude.com/analytics/org/{orgId}/settings/profile
  • pnpm try slack with no credentials → shows "No Amplitude session found" message
  • tail -f ~/.amplitude/wizard-debug.log | grep Slack shows Thunder 405 response (expected until backend support lands)
  • Full wizard flow → /slack → verify SlackScreen renders and opens settings URL

Jira: https://amplitude.atlassian.net/browse/AMP-XXXXX

🤖 Generated with Claude Code


Note

Medium Risk
Moderate risk because it changes which token type (access vs id token) is used for multiple API calls and switches Thunder endpoints/URLs, which could break activation checks or Slack setup if assumptions differ across regions/environments.

Overview
Adds Thunder-backed Slack setup support by introducing fetchSlackInstallUrl (direct Slack OAuth URL) and fetchSlackConnectionStatus (skip flow when already connected), with graceful fallback to opening the Amplitude settings page.

Standardizes Thunder GraphQL calls to use Bearer access_token (not id_token), updates activation-status fetching to require an orgId and always hit the org-scoped Thunder endpoint, and adjusts several bootstrap/CI credential paths to store and pass storedToken.accessToken consistently.

Updates URL plumbing to point at the new Thunder base (core.amplitude.* /t/graphql/org/) and fixes Slack settings deep-links to use /analytics/org/:orgId/settings/profile (no org-name encoding). The standalone slack CLI command is rewritten to avoid the TUI and handle CJS/ESM interop via a small dynamic-import normalizer.

Reviewed by Cursor Bugbot for commit 6fa3e10. Bugbot is set up for automated code reviews on this repo. Configure here.

Instead of sending users to the Amplitude Settings page to manually find
and click "Connect to Slack", the wizard now fetches the Slack OAuth
install URL directly from Thunder and opens the authorization page in
one step. Falls back to the settings page if the API call fails.

Also adds auto-detection of existing Slack connections on mount, and
fixes the settings URL to use orgId instead of orgName (which was
URL-encoding display names with spaces).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kelsonpw kelsonpw requested a review from a team April 11, 2026 17:42
@github-actions
Copy link
Copy Markdown
Contributor

🧙 Wizard CI

Run the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands:

Test all apps:

  • /wizard-ci all

Test all apps in a directory:

  • /wizard-ci django
  • /wizard-ci fastapi
  • /wizard-ci flask
  • /wizard-ci javascript-node
  • /wizard-ci javascript-web
  • /wizard-ci next-js
  • /wizard-ci python
  • /wizard-ci react-router
  • /wizard-ci vue

Test an individual app:

  • /wizard-ci django/django3-saas
  • /wizard-ci fastapi/fastapi3-ai-saas
  • /wizard-ci flask/flask3-social-media
Show more apps
  • /wizard-ci javascript-node/express-todo
  • /wizard-ci javascript-node/fastify-blog
  • /wizard-ci javascript-node/hono-links
  • /wizard-ci javascript-node/koa-notes
  • /wizard-ci javascript-node/native-http-contacts
  • /wizard-ci javascript-web/saas-dashboard
  • /wizard-ci next-js/15-app-router-saas
  • /wizard-ci next-js/15-app-router-todo
  • /wizard-ci next-js/15-pages-router-saas
  • /wizard-ci next-js/15-pages-router-todo
  • /wizard-ci python/meeting-summarizer
  • /wizard-ci react-router/react-router-v7-project
  • /wizard-ci react-router/rrv7-starter
  • /wizard-ci react-router/saas-template
  • /wizard-ci react-router/shopper
  • /wizard-ci vue/movies

Results will be posted here when complete.

The settings URL was missing the /analytics/ segment, producing URLs
like app.amplitude.com/settings/profile which don't resolve to a real
page. Fixed both the OUTBOUND_URLS builder and the bin.ts fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Auto-complete race can override user's skip choice
    • Added a cancelled flag set by the useEffect cleanup function, checked both when the fetchSlackConnectionStatus promise resolves and before the delayed markDone fires, preventing the race from overriding a user's skip.
  • ✅ Fixed: Unused resolvedOrgName state triggers unnecessary API call
    • Removed the resolvedOrgName state, the fetchAmplitudeUser call, and its import since they were dead code after slackSettings stopped accepting an orgName parameter.

Create PR

Or push these changes by commenting:

@cursor push 0c410db9c7
Preview (0c410db9c7)
diff --git a/src/ui/tui/screens/SlackScreen.tsx b/src/ui/tui/screens/SlackScreen.tsx
--- a/src/ui/tui/screens/SlackScreen.tsx
+++ b/src/ui/tui/screens/SlackScreen.tsx
@@ -16,7 +16,6 @@
 import { ConfirmationInput } from '../primitives/index.js';
 import { Colors } from '../styles.js';
 import {
-  fetchAmplitudeUser,
   fetchSlackInstallUrl,
   fetchSlackConnectionStatus,
 } from '../../../lib/api.js';
@@ -66,16 +65,15 @@
   );
 
   const [phase, setPhase] = useState<Phase>(Phase.Prompt);
-  const [resolvedOrgName, setResolvedOrgName] = useState<string | null>(
-    store.session.selectedOrgName,
-  );
 
   const region = store.session.region ?? 'us';
   const isEu = region === 'eu';
   const appName = isEu ? 'Amplitude - EU' : 'Amplitude';
 
-  // Fetch org name and check if Slack is already connected on mount.
+  // Check if Slack is already connected on mount — auto-complete if so.
   useEffect(() => {
+    let cancelled = false;
+
     const credentials = store.session.credentials;
     const token = credentials?.idToken ?? credentials?.accessToken;
     const orgId = store.session.selectedOrgId;
@@ -86,46 +84,28 @@
       } credentials=${credentials ? 'present' : 'null'} region=${region}`,
     );
 
-    // Resolve org name if missing (returning users, standalone /slack command).
-    if (!resolvedOrgName && credentials) {
-      logToFile(`[SlackScreen] fetching org name via API`);
-      void fetchAmplitudeUser(token!, region as AmplitudeZone)
-        .then((info) => {
-          const name = info.orgs[0]?.name ?? null;
-          logToFile(
-            `[SlackScreen] API returned orgs=${JSON.stringify(
-              info.orgs.map((o) => o.name),
-            )} using=${name}`,
-          );
-          setResolvedOrgName(name);
-        })
-        .catch((err: unknown) => {
-          logToFile(
-            `[SlackScreen] API fetch failed: ${
-              err instanceof Error ? err.message : String(err)
-            }`,
-          );
-        });
-    }
-
-    // Check if Slack is already connected — auto-complete if so.
     if (token && orgId) {
       void fetchSlackConnectionStatus(
         token,
         region as AmplitudeZone,
         orgId,
       ).then((isConnected) => {
+        if (cancelled) return;
         logToFile(`[SlackScreen] slackConnectionStatus=${isConnected}`);
         if (isConnected) {
           setPhase(Phase.Done);
-          setTimeout(
-            () =>
-              markDone(store, SlackOutcome.Configured, standalone, onComplete),
-            1500,
-          );
+          setTimeout(() => {
+            if (!cancelled) {
+              markDone(store, SlackOutcome.Configured, standalone, onComplete);
+            }
+          }, 1500);
         }
       });
     }
+
+    return () => {
+      cancelled = true;
+    };
   }, []);
 
   const zone = (region ?? 'us') as AmplitudeZone;

You can send follow-ups to the cloud agent here.

Comment thread src/ui/tui/screens/SlackScreen.tsx
Comment thread src/ui/tui/screens/SlackScreen.tsx Outdated
kelsonpw and others added 4 commits April 11, 2026 11:02
The `pnpm try slack` command was failing silently because tsx CJS/ESM
interop couldn't resolve named exports from wizard-session.js in the
full TUI import tree. Rewrote the standalone slack command to skip the
TUI entirely — it reads credentials from ~/.ampli.json, orgId from
ampli.json, tries the direct Slack OAuth URL via Thunder, and opens
the browser directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use action: "profile" per Ben's Thunder schema (not direct_install)
- Prompt user to login when no credentials or orgId found instead of
  opening a broken URL
- Remove dead catch fallback that produced URLs without orgId

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Thunder returns 405 (bearer token auth not yet supported). The
fallback to the settings page URL is working correctly. Debug logging
will help verify when Thunder adds bearer token support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Thunder requires `Bearer <token>` format while the data API expects a
raw JWT. Add the prefix to all Thunder calls (fetchOwnedDashboards,
fetchProjectActivationStatus, fetchSlackInstallUrl,
fetchSlackConnectionStatus) and keep raw tokens for data API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Browser opening blocked behind slow API call
    • Moved opn(settingsUrl) and the phase transition to Phase.Waiting outside the fetchSlackInstallUrl .then() callback so the browser opens immediately, with the direct Slack OAuth URL fetched in parallel and opened as an upgrade if available.

Create PR

Or push these changes by commenting:

@cursor push 3552b25757
Preview (3552b25757)
diff --git a/src/ui/tui/screens/SlackScreen.tsx b/src/ui/tui/screens/SlackScreen.tsx
--- a/src/ui/tui/screens/SlackScreen.tsx
+++ b/src/ui/tui/screens/SlackScreen.tsx
@@ -144,24 +144,22 @@
     const orgId = store.session.selectedOrgId;
     const token = credentials?.idToken ?? credentials?.accessToken;
 
+    // Open the settings URL immediately so the user sees browser activity.
+    logToFile(`[SlackScreen] opening settings URL`);
+    opn(settingsUrl, { wait: false }).catch(() => {});
+    setTimeout(() => setPhase(Phase.Waiting), 800);
+
+    // In parallel, attempt to fetch a direct Slack OAuth URL.
     if (token && orgId) {
       void fetchSlackInstallUrl(token, zone, orgId, settingsUrl).then(
         (directUrl) => {
-          const urlToOpen = directUrl ?? settingsUrl;
-          setOpenedUrl(urlToOpen);
-          logToFile(
-            `[SlackScreen] opening ${
-              directUrl ? 'direct Slack OAuth URL' : 'settings fallback'
-            }`,
-          );
-          opn(urlToOpen, { wait: false }).catch(() => {});
-          setTimeout(() => setPhase(Phase.Waiting), 800);
+          if (directUrl) {
+            setOpenedUrl(directUrl);
+            logToFile(`[SlackScreen] opening direct Slack OAuth URL`);
+            opn(directUrl, { wait: false }).catch(() => {});
+          }
         },
       );
-    } else {
-      logToFile(`[SlackScreen] no token/orgId — opening settings fallback`);
-      opn(settingsUrl, { wait: false }).catch(() => {});
-      setTimeout(() => setPhase(Phase.Waiting), 800);
     }
   };

You can send follow-ups to the cloud agent here.

Comment thread src/ui/tui/screens/SlackScreen.tsx
kelsonpw and others added 3 commits April 11, 2026 12:42
Thunder validates tokens via Hydra introspection, which only accepts
access_tokens — not id_tokens. All Thunder call sites were passing the
id_token, causing 401/1003 errors.

- Switch all Thunder callers to pass credentials.accessToken
- Rename api.ts Thunder function params from idToken to accessToken/token
- Add thunderUrl() helper with WIZARD_PROXY_THUNDER_URL env var override
  for local development against a local Thunder instance
- Keep data API calls using id_token (unchanged)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove dead resolvedOrgName state and fetchAmplitudeUser call
  (slackSettings no longer accepts orgName)
- Add cancellation guard to useEffect to prevent auto-complete race
  from overriding user's skip choice
- Open browser immediately on connect, fetch direct Slack OAuth URL
  in parallel instead of blocking on the API call

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lone command

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Access token sent to data API expecting id token
    • Introduced a separate dataApiToken variable using credentials.idToken ?? credentials.accessToken for the two fetchWorkspaceEventTypes calls that always hit the Data API, while keeping credentials.accessToken for fetchProjectActivationStatus which routes to Thunder.

Create PR

Or push these changes by commenting:

@cursor push 41354ee5dd
Preview (41354ee5dd)
diff --git a/src/ui/tui/screens/DataIngestionCheckScreen.tsx b/src/ui/tui/screens/DataIngestionCheckScreen.tsx
--- a/src/ui/tui/screens/DataIngestionCheckScreen.tsx
+++ b/src/ui/tui/screens/DataIngestionCheckScreen.tsx
@@ -67,6 +67,8 @@
     // fetchProjectActivationStatus routes to Thunder when orgId is present,
     // so always pass access_token — the function handles the fallback.
     const token = credentials.accessToken;
+    // fetchWorkspaceEventTypes always hits the data API which expects id_token.
+    const dataApiToken = credentials.idToken ?? credentials.accessToken;
 
     try {
       const status = await fetchProjectActivationStatus(
@@ -88,7 +90,7 @@
       // Fall back to the event catalog which includes all event types.
       if (session.selectedOrgId && session.selectedWorkspaceId) {
         const catalogEvents = await fetchWorkspaceEventTypes(
-          token,
+          dataApiToken,
           zone,
           session.selectedOrgId,
           session.selectedWorkspaceId,
@@ -111,7 +113,7 @@
       // Fetch cataloged event types from the data API as a proxy for "events arrived"
       if (session.selectedOrgId && session.selectedWorkspaceId) {
         fetchWorkspaceEventTypes(
-          token,
+          dataApiToken,
           zone,
           session.selectedOrgId,
           session.selectedWorkspaceId,

You can send follow-ups to the cloud agent here.

Comment thread src/ui/tui/screens/DataIngestionCheckScreen.tsx Outdated
kelsonpw and others added 3 commits April 11, 2026 12:59
credentials.accessToken was being set to storedToken.idToken everywhere
in bin.ts and setup-utils.ts. This meant Thunder calls (which validate
via Hydra introspection) received an id_token that Hydra rejects.

Fix all credential assembly sites to use the real OAuth access_token.
The agent-runner already worked around this by re-reading from
~/.ampli.json, but TUI screens relied on the broken credentials.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Change button from "Open settings" to "Connect"
- Update prompt to "Connect the Amplitude Slack app to your workspace?"
- Try direct OAuth URL before falling back to settings page
- Show "Browser opened to Slack for authorization" instead of dumping
  the long OAuth URL that breaks TUI rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
fetchWorkspaceEventTypes hits the Data API which expects id_tokens,
not Hydra access_tokens. Introduces dataApiToken (idToken ?? accessToken)
for those calls while keeping accessToken for Thunder calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Missed token migration in multi-environment path
    • Changed storedToken.idToken to storedToken.accessToken on line 364 of bin.ts to match the migration applied to all other token assignment paths.

Create PR

Or push these changes by commenting:

@cursor push b3644fcb69
Preview (b3644fcb69)
diff --git a/bin.ts b/bin.ts
--- a/bin.ts
+++ b/bin.ts
@@ -361,7 +361,7 @@
                         );
                         session.pendingOrgs = userInfo.orgs;
                         session.pendingAuthIdToken = storedToken.idToken;
-                        session.pendingAuthAccessToken = storedToken.idToken;
+                        session.pendingAuthAccessToken = storedToken.accessToken;
                       } else {
                         logToFile(
                           '[bin] no environments with API keys — showing apiKeyNotice',

You can send follow-ups to the cloud agent here.

Comment thread bin.ts
- Thunder prod URL is core.amplitude.com/t/graphql/org/:orgId (not
  amplitude.com/graphql/org/)
- Fix pendingAuthAccessToken using idToken instead of accessToken in
  multi-environment code path (bin.ts line 364)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Data API fallback receives wrong token type
    • Added optional dataApiToken parameter to fetchProjectActivationStatus and passed credentials.idToken ?? credentials.accessToken from DataIngestionCheckScreen, so the data API fallback now receives the correct id_token instead of the access_token.

Create PR

Or push these changes by commenting:

@cursor push af445ac193
Preview (af445ac193)
diff --git a/src/lib/api.ts b/src/lib/api.ts
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -544,18 +544,21 @@
  *
  * The query lives in Thunder (the main Amplitude app GraphQL server), served
  * at /graphql/org/:orgId.  orgId is required to construct the endpoint URL.
+ *
+ * @param token - access_token used for the Thunder (Hydra-validated) path.
+ * @param dataApiToken - raw id_token for the data API fallback when orgId is absent.
  */
 export async function fetchProjectActivationStatus(
   token: string,
   zone: AmplitudeZone,
   appId: number | string,
   orgId?: string | null,
+  dataApiToken?: string,
 ): Promise<ProjectActivationStatus> {
   const { dataApiUrl } = AMPLITUDE_ZONE_SETTINGS[zone];
   // Use the Thunder org-scoped endpoint when orgId is available; fall back to
   // the data API (which may not expose this field for all users).
-  // Thunder validates access_tokens via Hydra; data API accepts id_tokens.
-  // Callers should pass the access_token — it works for both paths.
+  // Thunder validates access_tokens via Hydra; data API accepts raw id_tokens.
   const isThunder = !!orgId;
   const url = isThunder ? thunderUrl(zone, orgId) : dataApiUrl;
   try {
@@ -564,7 +567,7 @@
       { query: ACTIVATION_STATUS_QUERY, variables: { appId: String(appId) } },
       {
         headers: {
-          Authorization: isThunder ? `Bearer ${token}` : token,
+          Authorization: isThunder ? `Bearer ${token}` : dataApiToken ?? token,
           'Content-Type': 'application/json',
           'User-Agent': WIZARD_USER_AGENT,
         },

diff --git a/src/ui/tui/screens/DataIngestionCheckScreen.tsx b/src/ui/tui/screens/DataIngestionCheckScreen.tsx
--- a/src/ui/tui/screens/DataIngestionCheckScreen.tsx
+++ b/src/ui/tui/screens/DataIngestionCheckScreen.tsx
@@ -76,6 +76,7 @@
         zone,
         appId,
         session.selectedOrgId,
+        dataApiToken,
       );
       logToFile(
         `[DataIngestionCheck] poll result: hasAnyEvents=${status.hasAnyEvents} hasDetSource=${status.hasDetSource}`,

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 1e7f0e7. Configure here.

Comment thread src/lib/api.ts Outdated
kelsonpw and others added 4 commits April 11, 2026 13:52
…nStatus

When orgId is null, fetchProjectActivationStatus falls back to the Data
API which expects idToken, not accessToken. Add optional dataApiToken
param so callers can provide the correct token for that path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…t opts

Replace implicit orgId-based routing with an explicit options object
that always routes to Thunder. The Data API fallback path was dead code
— both callers always had orgId available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kelsonpw kelsonpw merged commit 626ad8a into main Apr 11, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant