Skip to main content

Runbook: Slack "Add to Slack" OAuth Install (per environment)

Scope. Stand up the Slack OAuth 2.0 install flow (issue #1159, shipped in PR #1426) for one environment, then run an end-to-end install for one org. Worked example uses dev. Implementation: api/src/channels/slack/slack-oauth.service.ts, slack-oauth.controller.ts, slack-channel-binding.service.ts. Architecture: channels.md.

This replaces the old "paste a bot token into env" flow with a standard Add to Slack authorize/callback cycle. After install, the bot token is stored encrypted per org in integration_credentials (Org Γ— slack), and inbound + outbound Slack work immediately. Pulling the bot into a channel auto-binds that channel to the org's primary Specialist (no @mention needed thereafter).

Hard rule: one Slack app per environment. A Slack app's Events request_url is single-valued, so local / dev / staging / prod each need their own Slack app and their own SLACK_CLIENT_ID / SLACK_CLIENT_SECRET. Do not share one app across environments.


Part A β€” 事前 (one-time, per environment)​

Do this once per environment. For dev, do it against the dev API/frontend hosts.

A1. Know your URLs​

For dev you need two public HTTPS origins:

Thingdev valueUsed for
API originhttps://api.h852.work (derived from HWORK_DOMAIN, see getPublicApiUrl())Slack Events request_url, OAuth callback
Frontend originhttps://dev.h852.work (FRONTEND_URL)post-install redirect target

Two derived URLs you'll register on the Slack app:

  • Events request URL β†’ https://api.{HWORK_DOMAIN}/channels/slack/events (see slack.controller.ts:69)
  • OAuth redirect URL β†’ https://api.{HWORK_DOMAIN}/channels/slack/oauth/callback (derived by getPublicApiUrl() in slack-oauth.service.ts; never the raw deployment host)

A2. Create the Slack app from the manifest​

Create the app from the manifest below β€” it already carries the redirect URL, the full scope set, and the channel-bind events, so there is nothing to hand-fix afterwards (the in-app GET /orgs/:orgId/channels/slack/manifest generator and manifest.ts are incomplete β€” they omit redirect_urls, the extra scopes, and member_joined_channel/member_left_channel β€” so prefer this file).

  1. https://api.slack.com/apps β†’ Create New App β†’ From a manifest β†’ pick the workspace β†’ YAML β†’ paste the manifest below β†’ Create.

This is the dev manifest, ready to use as-is. For another environment, change only: display_information.name, bot_user.display_name, and the two hostnames in redirect_urls / event_subscriptions.request_url (β†’ that env's API origin from A1).

display_information:
name: Humanwork (Dev)
description: AI specialist for your team β€” dev/test app
background_color: "#1a1a2e"
features:
app_home:
home_tab_enabled: false
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: HumanworkDev
always_online: true
oauth_config:
redirect_urls:
- https://api.h852.work/channels/slack/oauth/callback
scopes:
bot:
- app_mentions:read
- channels:history
- channels:read
- groups:history
- groups:read
- mpim:history
- mpim:read
- im:history
- im:read
- im:write
- chat:write
- chat:write.customize
- chat:write.public
- files:read
- files:write
- users:read
- users:read.email
- users.profile:read
- team:read
- reactions:read
- reactions:write
pkce_enabled: false
settings:
event_subscriptions:
request_url: https://api.h852.work/channels/slack/events
bot_events:
- app_mention
- app_uninstalled
- member_joined_channel
- member_left_channel
- message.channels
- message.groups
- message.im
- message.mpim
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
is_mcp_enabled: false

The bot scopes above match SLACK_OAUTH_SCOPES exactly (all 21) β€” that's required, since the authorize call requests precisely that set and Slack rejects any scope the app doesn't have.

A3. Verify the app + one optional add​

Creating from A2's manifest leaves nothing mandatory to fix. Just confirm:

  • Event Subscriptions request URL shows "Verified" (the API must already be deployed and reachable β€” see A7). If it doesn't verify, fix the API origin/reachability first, then re-paste/re-verify.
  • App Home β†’ Messages tab enabled (already in the manifest).

Optional β€” read receipts (#1362): the manifest does not subscribe reaction_added, so the delivery "read" receipt path (handleReactionAdded) stays dormant even though reactions:read/write are granted. To enable it, add reaction_added under Event Subscriptions β†’ bot events and reinstall. Not needed for the core install/binding flow.

A4. Collect credentials from the app​

From Settings β†’ Basic Information β†’ App Credentials:

  • Client ID β†’ SLACK_CLIENT_ID
  • Client Secret β†’ SLACK_CLIENT_SECRET
  • Signing Secret β†’ SLACK_SIGNING_SECRET (used for inbound webhook signature verification; also stored per-install so prod can verify per-org β€” see completeInstall)

Generate the OAuth state secret:

openssl rand -hex 32   # β†’ SLACK_OAUTH_STATE_SECRET

A5. Set environment variables​

Set on the dev api service (and ensure FRONTEND_URL points at the dev frontend so the callback lands on a real page β€” see appBaseUrl()). Reference: .env.example:85-102.

SLACK_CLIENT_ID=...                 # A4
SLACK_CLIENT_SECRET=... # A4
SLACK_SIGNING_SECRET=... # A4
SLACK_OAUTH_STATE_SECRET=... # A4 (openssl rand -hex 32)
PUBLIC_API_URL=https://api.h852.work # canonical API origin (also used by Telegram, webhook-urls)
# FRONTEND_URL / APP_BASE_URL must resolve to the dev frontend (not the API host)

Notes:

  • The OAuth redirect_uri is derived from PUBLIC_API_URL, or https://api.{apex of HWORK_DOMAIN} whenever HWORK_DOMAIN is set. This includes the dev env, whose APP_ENV=development and HWORK_DOMAIN=dev.h852.work: the derivation is gated on HWORK_DOMAIN (not IS_DEPLOYED) and strips the dev./staging. portal-subdomain prefix to the apex, so dev resolves to https://api.h852.work β€” never http://localhost:3000 and never https://api.dev.h852.work. There is no separate SLACK_OAUTH_REDIRECT_URL env var. The Slack app's redirect_urls allowlist (A2) must include the canonical URL produced by getPublicApiUrl() (#3923).
  • SLACK_OAUTH_STATE_SECRET is validated lazily at call time, not at boot. In dev it has an insecure fallback if unset, so the app still boots; prod throws if missing (getStateSecret). Set a real one in dev anyway to mirror prod.
  • SLACK_BOT_TOKEN in .env.example is the legacy single-token path. With OAuth install you don't set it β€” the per-org token is written by the install flow. Leave it empty.
  • MASTER_ENCRYPTION_KEY: if set, the stored bot token is AES-256-GCM encrypted; if unset (typical dev), encryptConfig is a passthrough. Either works for dev.

A6. Database table​

The flow needs slack_channel_bindings.

  • dev / local / test: TypeORM synchronize: true (non-prod) auto-creates it from the SlackChannelBinding entity β€” no action needed.

  • staging / prod: run the migration (synchronize is off):

    cd api && npm run migrations:run   # applies 1780600000000-AddSlackChannelBindings

    The data source already registers the entity (typeorm-data-source.config.ts); CI's api-postgres-migrations job verifies the forward migration applies cleanly.

A7. Restart & sanity-check​

Restart the dev api so the new env vars load. Then verify the Events URL is reachable and the OAuth endpoints exist:

# Slack URL verification handshake (the controller answers the challenge):
curl -sS -X POST https://<API_ORIGIN>/channels/slack/events \
-H 'Content-Type: application/json' \
-d '{"type":"url_verification","challenge":"ping123"}'
# expect 200 with JSON: {"challenge":"ping123"} (see slack.controller.ts:81)

Part B β€” δΊ‹δΈ­ (per org, the actual install + verification)​

You're an org member/admin on the dev frontend. (The start endpoint is gated by JwtAuthGuard + org-membership, so you must be logged in to that org β€” controller.)

B1. Kick off the install​

  1. Log in to the dev frontend as a member of the target org.
  2. Go to Settings β†’ Channels (/client/settings/channels).
  3. Click Add to Slack. The frontend calls GET /channels/slack/oauth/start?orgId=… with your Bearer token, gets { url }, and navigates to Slack's authorize screen (startSlackInstall, page.tsx:164).
  4. On Slack, pick the workspace and Allow.

B2. Confirm the callback succeeded​

Slack redirects to …/oauth/callback, which exchanges the code and redirects to /client/settings/channels?slack=connected&team=…. The page shows a post-install banner and a πŸ’¬ Message on Slack deep link.

Possible banner states (callback):

Query paramMeaningLikely cause
?slack=connectedsuccessβ€”
?slack=denieduser clicked Cancel on consentretry
?slack=errortoken exchange failedbad SLACK_CLIENT_SECRET, redirect URL mismatch, or missing scope (check api logs: Slack oauth.v2.access not ok: <error>)
Invalid state parameter (plain 400)CSRF state failed/expired (>10 min)restart from B1; check SLACK_OAUTH_STATE_SECRET didn't change mid-flow

DB check:

SELECT org_id, integration_type, status, metadata
FROM integration_credentials
WHERE integration_type = 'slack' AND org_id = '<org_id>';
-- expect status='active', metadata has team_id / team_name / app_id / bot_user_id

B3. Verify inbound + channel binding​

  1. In the connected Slack workspace, invite the bot to a channel (/invite @<bot>). This fires member_joined_channel β†’ the bot auto-binds the channel to the org's primary Specialist and posts a welcome message (slack.service.ts:106).

    If you see no welcome message, the org has no assigned Specialist β€” the bind is skipped with a warn (Slack channel bind skipped: org=… has no assigned Specialist). Assign a primary Specialist to the org first (AM Setup), then re-invite.

    SELECT org_id, external_channel_id, specialist_id, bound_by_slack_user
    FROM slack_channel_bindings WHERE org_id = '<org_id>';
  2. Post a plain message in the bound channel (no @mention). It should route to the Specialist and produce a draft β†’ Expert queue item (default autoRespondThreshold = 101, always HITL).

  3. DM the bot (Messages tab). DMs route directly too.

  4. Remove the bot from the channel (/remove @<bot>) β†’ member_left_channel β†’ binding row is deleted (slack.service.ts:130).

B4. Outbound​

Have an Expert send a reply from the workspace queue. The dispatcher reads the per-org bot_token from the stored credential and posts back to the Slack thread (three-layer protection: withCircuit β†’ withRetry β†’ channel_failures DLQ).


Quick failure map​

SymptomCheck
Add to Slack β†’ 403You're not a member of orgId in your JWT; log in to the right org.
Authorize screen errors on scopesApp's bot scopes don't match SLACK_OAUTH_SCOPES β€” recreate from the A2 manifest (or add the missing scope + reinstall).
?slack=error after AllowSLACK_CLIENT_SECRET wrong, or the derived redirect URI (getPublicApiUrl() + /channels/slack/oauth/callback) isn't an exact match for a redirect_urls entry on the app (A2). Check api log line Slack oauth.v2.access not ok.
Callback lands on API 404, no bannerFRONTEND_URL not set / points at API host β€” fix A5 (appBaseUrl).
Events URL won't verify on SlackAPI not publicly reachable, or signature/raw-body issue; confirm A7 handshake returns the challenge.
Inbound messages ignored in a channelChannel not bound and no @mention; either invite the bot (auto-bind) or @mention it.
No welcome on inviteOrg has no primary Specialist β€” assign one, re-invite.
Works in dev, breaks in prod bootSLACK_OAUTH_STATE_SECRET unset (prod throws); set a real secret.

Promoting to staging / prod​

Repeat Part A with a separate Slack app and that environment's URLs/secrets, and run the migration (A6) since synchronize is off. Everything else is identical.


Troubleshooting an existing install (prod)​

Once installed, when a specific org's Slack misbehaves in prod ("installed but nothing happens", connect fails, replies don't return), use the companion runbook β€” concrete log keywords + DB queries + Sentry filters keyed on org_id / team_id: slack-prod-troubleshooting.md.