fix(slack): robust workspace setup (resumable wizard, user-token OAuth, scope parsing) - #106
fix(slack): robust workspace setup (resumable wizard, user-token OAuth, scope parsing)#106DouwMarx wants to merge 1 commit into
Conversation
…h, scope parsing)
Discovered doing a full Slack setup end to end against a fresh instance:
- OAuth requests user_scope only. It previously also sent a bot `scope`, but
the backend uses only the user token (the exchange discards the bot token and
there is no bot-token column), so requesting bot scope made Slack demand a
bot user the app never uses and failed install with "doesn't have a bot user
to install".
- The setup wizard is now resumable: it starts at the first unfinished step
(from the client_secret/authorized/signing_secret flags, not setup_state
alone) and re-fetches the wizard nonce on resume; the Sources panel surfaces
a "Finish setup" entry for apps not yet live. This fixes the draft-app dead
end where a half-configured app looped back through completed OAuth.
- Parse Slack's comma-separated granted scopes with split(",") instead of a
whitespace split that collapsed them into one bogus element (so any
per-scope membership check was always false).
- Scopes stay read-only: the integration ingests and never posts to Slack.
- README: add a Slack setup runbook and the gotchas.
- Tests: guard that the authorize URL carries user_scope and no bot scope, and
that granted scopes parse on comma.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mruwnik
left a comment
There was a problem hiding this comment.
Ame-no-Uzume (Claude Opus 4.8): ## Review — fix(slack): robust workspace setup
Solid, well-scoped PR. The three fixes are individually correct and the comments/tests are unusually good (they explain the why, not just the what). No blocking issues; my findings are reconciliation and robustness polish.
Verified correct:
user_scope-only OAuth — matches the backend, which reads onlyauthed_userand has no bot-token column. Test assertinguser_scopecarries scopes andscopeis absent is exactly right.- Comma-split of granted scopes (
slack.py:436) —[s for s in ....split(",") if s]correctly handles Slack's comma-separatedscopeand filters empties. Correct fix. resumeStep— keying resume off theclient_secret_configured/is_authorized/signing_secret_configuredflags rather thansetup_stateis the right call; thedraft-with-OAuth-already-done loop it fixes is real. Step ordering is sound, and all three flags exist onSlackAppResponse.
Findings (all non-blocking):
- (README + wizard, medium) README step 6 correctly says subscribe "on behalf of users" with four
message.*events, but the wizard'sevents-urlstep (unchanged) still says "Subscribe to bot events" and lists extrareaction_*/channel_*events. For a no-bot-user app these contradict each other and the new user-token model — reconcile the wizard text with the README. Details inline on README:204. - (SlackAppWizard.tsx:185, low) On resume, the nonce fetch is the only one, and its failure is swallowed with
console.warn→ blank wizard body with no error/retry. Surface it viasetErrorto match the non-resume path. On-theme with the PR's dead-end fix. - (SlackPanel.tsx:175, low) "Finish setup" banner isn't filtered by
is_owner— confirmlistAppsis owner-scoped server-side, else a non-owner gets a button that fails mid-wizard. Plus the banner's inlineyellow-*utilities are light-mode-only.
One neutral note on severity: I couldn't find any current consumer that does a per-scope membership check against SlackUserCredentials.scopes (grepped src/), so the comma-split fix is latent-correctness hygiene rather than an active bug fix today — the PR body's "always false" framing slightly overstates present impact. The fix is still correct and worth keeping.
I'll keep watching and re-review as the PR evolves.
| 3. Under OAuth & Permissions, add User Token Scopes: `channels:history`, `groups:history`, `im:history`, `mpim:history`, `channels:read`, `groups:read`, `im:read`, `mpim:read`, `users:read`, `users:read.email`, `team:read`, `reactions:read`, `files:read`. The wizard requests exactly this set. | ||
| 4. Authorize the workspace. | ||
| 5. Paste the Signing Secret into the wizard. | ||
| 6. Under Event Subscriptions, set the Request URL (verified via the signing secret). Then under "Subscribe to events on behalf of users" add `message.channels`, `message.groups`, `message.im`, `message.mpim`. URL verification alone does not deliver messages; the event subscription is a separate step. |
There was a problem hiding this comment.
Ame-no-Uzume (Claude Opus 4.8): The README here correctly says to subscribe "on behalf of users" with only the four message.* events — consistent with the user-token-only model this PR establishes. But the wizard UI (SlackAppWizard.tsx, the events-url step, unchanged by this PR) still tells the user the opposite:
Subscribe to bot events:
• message.channels / groups / im / mpim
• reaction_added / reaction_removed
• channel_created / channel_renamed / channel_archived
Two concerns:
-
"bot events" vs "on behalf of users". Since the whole point of fix Fix lint errors and add CI workflow #1 is that the app has no bot user ("doesn't have a bot user to install"), Slack's "Subscribe to bot events" section isn't the right one — it generally requires a bot user. The README's "on behalf of users" is correct; the wizard should match, or a user following the on-screen wizard will land in the section that reproduces the bot-user problem. (~60% confident this is an actual setup snag; you completed setup end-to-end, so maybe Slack tolerates it or you added a bot user manually — worth confirming.)
-
Event set mismatch. The wizard lists
reaction_*andchannel_*events; the README lists only the fourmessage.*. If reaction/channel ingestion actually needs those subscriptions, the README under-documents them; if it doesn't, the wizard over-asks. Either way the two should agree.
Reconciling the wizard text with this README section would close the loop on the user-token migration.
| .then(n => { | ||
| if (!cancelled) setNonceData(n) | ||
| }) | ||
| .catch(e => console.warn('Wizard nonce fetch failed:', e)) |
There was a problem hiding this comment.
Ame-no-Uzume (Claude Opus 4.8): On the resume path this is the only place the nonce gets fetched, and a failure is swallowed with console.warn. The oauth and events-url steps render nothing until callbackUrl/eventsUrl exist ({step === 'oauth' && app && callbackUrl && ...}), so if issueWizardNonce fails here the user is left staring at a wizard with a header, a progress bar, and an empty body — no error, no retry.
That's exactly the dead-end class this PR is trying to eliminate. The non-resume path surfaces this failure (handleClientSecret calls handleError); the resume path should too — setError((e as Error).message) in the catch, so at least the user sees why the step is blank and can reopen. Minor, but cheap and on-theme.
| </button> | ||
| </div> | ||
|
|
||
| {unfinishedApps.length > 0 && !wizardOpen && ( |
There was a problem hiding this comment.
Ame-no-Uzume (Claude Opus 4.8): Two things on the "Finish setup" banner:
-
Ownership.
unfinishedAppsislistApps()filtered only bysetup_state, not byis_owner. IfGET /slack/appsreturns apps the current user can see but doesn't own (multi-tenant), a non-owner gets a "Finish setup" button that walks them intosetClientSecret/setSigningSecret, which should fail server-side with a permission error. IflistAppsis already owner-scoped on the backend this is moot — worth a quick confirm. If not, filter ona.is_ownerhere (the field exists onSlackAppResponse). -
Styling (nit). This banner uses raw
border-yellow-300 bg-yellow-50 text-yellow-900utilities inline while the rest of the panel goes through thestylesmodule. Those are light-mode-only colors — if the app has a dark theme, yellow-900 text on yellow-50 won't adapt. Low priority, but it stands out against the surrounding convention.
Found doing a full Slack workspace setup end to end against a fresh instance. Two genuine bugs blocked/degraded setup, plus a wizard dead-end.
Bugs
Bot scope in the OAuth authorize URL blocks install. The URL sent both a bot
scopeanduser_scope, but the backend only stores and uses the user token (_persist_oauth_credentialsreadsauthed_user; there is no bot-token column). Slack then requires the app to have a bot user and fails install with "doesn't have a bot user to install". Fixed by requestinguser_scopeonly.Granted scopes parsed on whitespace, not comma. Slack returns
scopecomma-separated ("channels:history,users:read")..split()collapsed it into one bogus element, so any per-scope membership check ("channels:history" in creds.scopes) was always false. Fixed with.split(",").Robustness
client_secret/authorized/signing_secretflags) and re-fetches the wizard nonce on resume; the Sources panel surfaces a "Finish setup" entry for apps not yet live.Scopes are unchanged from upstream and stay read-only (user-token ingest, never posts).
Docs / tests
user_scopeand no botscope; granted scopes parse on comma.All frontend and backend Slack tests pass.
🤖 Generated with Claude Code