# slack-workspace-integration

## Overview

Workspace-scoped Slack integration that powers the `SLACK` DESTINATION channel. The feature owns the tenant-level Slack app installation (`SlackWorkspaceIntegration` carrying workspace metadata and a unique `teamId`; the bot token itself lives in the host app's secret manager, never on the row) and the revocation / health state the Slack DestinationAdapter consults before every post. It does **not** own per-target channel routing — which Slack channel a given source entity posts to is the generalized `ChannelRoutingBinding(targetType, targetId, channelId = "SLACK", externalChannelRef)`, resolved by the dispatcher's DESTINATION stage. The tenant (the whole deployment) holds at most one Slack workspace connection.

**OAuth state is HMAC-SHA256 signed.** `beginSlackWorkspaceInstall` issues a state token of the form `base64url(payloadJson).base64url(signature)`, where the payload carries the acting `userId`, the optional pinned `redirectUri`, and a 10-minute `expiresAt`, signed with the same `signingSecret` the complete step receives (one shared secret input for both halves of the handshake). `completeSlackWorkspaceInstall` verifies the signature (constant-time, Web Crypto), checks the expiry, requires `state.userId == ctx.actorId` (the actor completing the install must be the actor who began it), and — when a `redirectUri` was pinned — requires the supplied `redirectUri` to match it before exchanging the OAuth code.

**`app_uninstalled` is signature-verified.** `handleSlackAppUninstalled` takes the raw webhook material (`rawBody`, `timestamp`, `signature`, `signingSecret`) and verifies Slack's `v0=HMAC-SHA256(signingSecret, "v0:{timestamp}:{rawBody}")` request signature with a ±5-minute timestamp tolerance (replay protection) before acting; the `teamId` is parsed from the verified body only, never from a separately supplied field.

**Singleton is an application-level guarantee.** TailorDB cannot express a constant-field unique index, so "at most one connection row" is enforced by the install command, not the schema: the existing-row lookup is `orderBy(createdAt)` (deterministic, oldest row wins if multiple ever exist), and an `ACTIVE` row bound to a different `teamId` rejects the install. A residual race remains: two concurrent installs against an **empty** table can both insert (the `teamId` unique constraint still dedupes same-team double installs). Reads mirror the same deterministic pick.

The feature owns only the workspace install lifecycle; the per-target channel routing lives on the notification module's `ChannelRoutingBinding`. The bot token is **not stored on the connection row**: `completeSlackWorkspaceInstall` returns the freshly exchanged plaintext token to the calling resolver, which persists it in the host app's secret manager (keyed by `teamId`); the Slack DestinationAdapter reads it back from secret management at send time. The connection row holds only workspace metadata, and the model's **GraphQL auto-CRUD is closed entirely** (create/read/update/delete all disabled) so even that metadata is reachable only through the redacted `getSlackWorkspaceIntegration` projection — there is no token column on the row to leak.

**Bot tokens are assumed non-expiring (token rotation disabled).** The module stores the bot token without a refresh token or expiry: it assumes the Slack app uses classic non-expiring bot tokens, which is Slack's default when token rotation is not enabled. `SlackWorkspaceIntegration` carries no `refreshToken` / `expiresAt` and there is no refresh path. If the Slack app later opts into token rotation (`oauth.v2.access` returning a refresh token plus a 12-hour access token), this module must add those fields and a refresh command — a deliberate future change, called out here so the assumption is explicit rather than silent.

**Slack DestinationAdapter send behavior and error taxonomy.** The notification module owns the concrete adapter (`createSlackDestinationAdapter`) and **auto-wires it as `adapters.destination` when the `slack` option is enabled**: the module supplies the `loadConnection` / `markConnectionRevoked` lookups against its own `SlackWorkspaceIntegration` row (read through the executor db handle) and the `secretManager` from the `slack` option (the host app does not compose the adapter itself). On each `send` it resolves the tenant's `ACTIVE` `SlackWorkspaceIntegration`, reads the bot token from the host secret manager (keyed by `teamId`), and calls `chat.postMessage(externalChannelRef, text)` with a **10-second fetch timeout** (`AbortSignal.timeout`). **The adapter posts plain `text` only** — Block Kit blocks, threading, and attachments are a deliberate MVP exclusion (see the README's Out of Scope); the `send` contract carries a rendered `text` string and nothing richer; adding Block Kit would be an additive change to the adapter contract rather than a behavioral change to the core. A missing / `REVOKED` connection fails fast (`workspace_not_connected`) without an API call. The adapter also fails fast with `ChannelMismatch` when invoked with a `channelId` other than `SLACK`; this is a **defensive guard, not the channel-selection gate** — the dispatcher's DESTINATION stage already filters bindings to the SLACK channel before invoking the adapter (the primary gate), so `ChannelMismatch` is belt-and-suspenders that should not fire in normal dispatch. Failures are classified into normalized error classes: `SlackTokenRevoked` (Slack reported `invalid_auth` / `token_revoked` / `account_inactive` — additionally fires the module-wired `markConnectionRevoked` hook, best-effort, so subsequent sends fail fast on a dead token), `SlackRateLimited` (HTTP 429, with the `retry_after=<seconds>` hint preserved in `failureReason`), `SlackTimeout` (the 10s fetch deadline elapsed), `SlackPostThrew` (network/transport throw), `SlackChannelUnreachable` (Slack reported `not_in_channel` / `channel_not_found` — the bot is not a member of the target channel or the `externalChannelRef` is invalid; split out from the generic class because the operator action is specific and common: invite the bot to the channel or fix the binding), and `SlackApiError` (any other Slack `error`). The adapter returns the normalized outcome plus a **redacted** `providerResponse` string. The engine's `DestinationAdapter` contract (see [notification-destination-delivery](./notification-destination-delivery.md)) treats this value as opaque and adapter-defined; **this feature is the authoritative definition of the Slack redaction shape**, which is `{ ok, ts, channel, error }` only — the posted text and Slack internal metadata are never stored. The normalized `failureReason` values written to `DestinationDeliveryLog` for SLACK-channel rows are likewise **enumerated by this taxonomy** (`workspace_not_connected`, `ChannelMismatch`, `SlackTokenRevoked`, `SlackRateLimited`, `SlackTimeout`, `SlackPostThrew`, `SlackChannelUnreachable`, `SlackApiError`); the notification core persists them opaquely and does not define SLACK reason codes itself.

## Business Purpose

- Provide the external, proactive half of notification delivery: the in-app feed is guaranteed but passive, while a Slack post reaches a team while they are away from the app
- Keep the workspace install state in the same module as dispatch so a missing Slack post has a single place to diagnose ("is the workspace connected, is the token live, did the post fail at the provider?")
- Protect the bot token: keep it in the host's secret manager (never on a gateway-readable row), and never expose it through any query or command read surface
- Enforce the singleton one-workspace-per-tenant connection so the tenant never silently holds tokens for two workspaces
- Fail fast and audibly: a missing or revoked connection produces a `FAILED` DestinationDeliveryLog row with a normalized reason instead of a silent drop

## Process Flow

```mermaid
flowchart TD
    A[Tenant admin clicks Connect Slack] --> B[beginSlackWorkspaceInstall]
    B --> C[Build Slack OAuth authorize URL with HMAC-signed state token carrying userId, redirectUri, expiresAt]
    C --> D[Slack redirects back with code + state]
    D --> E[completeSlackWorkspaceInstall]
    E --> F{state signature valid, unexpired, userId == actor, redirectUri matches pin?}
    F -->|No| G[Reject: INVALID_STATE]
    F -->|Yes| H[Exchange code for bot token via oauth.v2.access]
    H --> I{exchange ok?}
    I -->|No| J[Reject: OAUTH_EXCHANGE_FAILED]
    I -->|Yes| K{existing ACTIVE connection bound to a different teamId?}
    K -->|Yes| L[Reject: TEAM_ALREADY_CONNECTED]
    K -->|No| M[Upsert SlackWorkspaceIntegration status=ACTIVE, return plaintext token to resolver for secret-manager storage]

    N[Dispatch DESTINATION stage posts to a Slack binding] --> O[Slack DestinationAdapter loads the tenant SlackWorkspaceIntegration]
    O --> P{connection ACTIVE?}
    P -->|No| Q[FAILED DestinationDeliveryLog reason workspace_not_connected]
    P -->|Yes| R[Read bot token from secret manager, call chat.postMessage to externalChannelRef]
    R --> S{provider response}
    S -->|ok| T[SENT DestinationDeliveryLog with provider ts + redacted response]
    S -->|error| U[FAILED DestinationDeliveryLog with normalized error + redacted response]

    V[Slack sends app_uninstalled] --> W[handleSlackAppUninstalled]
    W --> W1{v0 HMAC signature valid and timestamp within 5 minutes?}
    W1 -->|No| W2[Reject: SIGNATURE_INVALID]
    W1 -->|Yes| X[Parse team_id from verified body, mark SlackWorkspaceIntegration REVOKED, stamp revokedAt]
    X --> Y[Future posts fail fast until reinstalled]
```

## Scenario Patterns

- **Workspace install succeeds**: tenant admin completes Slack OAuth; the module exchanges the code via `oauth.v2.access`, stores the `teamId` + workspace metadata on `SlackWorkspaceIntegration` with `status = ACTIVE`, and returns the plaintext bot token to the resolver, which writes it to the host secret manager (keyed by `teamId`)
- **Conflicting workspace install rejected**: an install completes while the existing `ACTIVE` connection is bound to a different `teamId`; the install is rejected with `TEAM_ALREADY_CONNECTED` so the tenant never silently swaps workspaces — the current workspace must be uninstalled first
- **State tamper rejected**: a callback whose `state` token is forged, signed with a different key, expired, pinned to a different actor, or paired with a mismatched `redirectUri` is rejected with `INVALID_STATE`
- **OAuth exchange fails**: Slack returns a non-ok exchange or no token; the install is rejected with `OAUTH_EXCHANGE_FAILED` and no row is written
- **Re-install / token refresh**: re-running install for the same workspace updates the existing row's metadata and returns the fresh plaintext token for the resolver to re-store in secret management, instead of creating a duplicate row
- **Concurrent install race (documented residual)**: two installs racing against an empty table can each insert a row — the singleton is enforced at the application level, and the deterministic `orderBy(createdAt)` pick plus the `teamId` unique constraint bound the damage; subsequent installs converge on the oldest row
- **Forged `app_uninstalled` rejected**: a webhook whose signature is missing, stale (outside the ±5-minute window), or fails HMAC verification is rejected with `SIGNATURE_INVALID` and no connection is touched
- **Workspace revoked after install**: Slack sends a signature-verified `app_uninstalled`; the module marks the connection `REVOKED` and stamps `revokedAt`; all future posts fail fast with `workspace_not_connected`. The same flip can happen lazily at send time via the adapter's `markConnectionRevoked` hook when Slack reports the token revoked
- **Token revoked mid-flight**: Slack answers `token_revoked` / `invalid_auth` / `account_inactive` on a post; the adapter records `SlackTokenRevoked` and invokes the optional `markConnectionRevoked` hook so the host can flip the stored connection to `REVOKED` and make subsequent sends fail fast as `workspace_not_connected`
- **Rate limited**: Slack answers HTTP 429; the adapter records `SlackRateLimited` with the `retry_after=<seconds>` hint in `failureReason`; the post is not retried per the engine's at-most-once stance (operators/hosts decide how to react)
- **Channel unreachable**: Slack answers `not_in_channel` (bot not a member of the target channel) or `channel_not_found` (invalid / inaccessible `externalChannelRef`); the adapter records `SlackChannelUnreachable` rather than the generic `SlackApiError`, so the operator's fix is signalled — invite the bot to the channel or correct the binding
- **Slack API hangs**: the 10-second fetch timeout aborts the call; the adapter records `SlackTimeout` rather than misclassifying it as an application error
- **Workspace reinstalled after revocation**: admin reconnects; the existing row flips back to `ACTIVE` with a fresh token; existing `ChannelRoutingBinding` rows remain reusable when `teamId` is unchanged
- **Post with no Slack binding**: an event whose `(sourceType, sourceId)` has no active SLACK `ChannelRoutingBinding` produces zero Slack posts (handled by the dispatcher's DESTINATION stage; not an error)
- **Token never exposed through a read surface**: the bot token is not stored on the connection row at all (it lives in the host secret manager), the model's GraphQL auto-CRUD is fully closed, and `getSlackWorkspaceIntegration` returns only metadata (status, teamId, teamName, timestamps) — there is no token column on the row to read

## Test Cases

- Completing workspace install stores one `SlackWorkspaceIntegration` row with `status = ACTIVE` and returns the plaintext bot token to the caller for the resolver to persist in secret management
- Completing workspace install while the existing `ACTIVE` connection is bound to a different `teamId` is rejected with `TEAM_ALREADY_CONNECTED` and does not overwrite the existing connection
- Re-running workspace install for the same workspace updates the stored token instead of creating a duplicate row
- `beginSlackWorkspaceInstall` without the install permission is rejected with `INSTALL_FORBIDDEN`
- `beginSlackWorkspaceInstall` produces an authorize URL carrying a signed `state` value that round-trips through the callback
- `completeSlackWorkspaceInstall` with a tampered, wrongly-keyed, or expired `state` is rejected with `INVALID_STATE`
- `completeSlackWorkspaceInstall` whose `state.userId` differs from the calling actor, or whose `redirectUri` differs from the pinned one, is rejected with `INVALID_STATE`
- `completeSlackWorkspaceInstall` whose OAuth exchange returns not-ok or no token is rejected with `OAUTH_EXCHANGE_FAILED` and persists no row
- Handling a signature-verified Slack `app_uninstalled` marks the matching `SlackWorkspaceIntegration` as `REVOKED` and stamps `revokedAt`
- Handling `app_uninstalled` with a missing, stale, or invalid signature is rejected with `SIGNATURE_INVALID`
- Handling `app_uninstalled` for an unknown `teamId` is a no-op success (revoked = false)
- The Slack DestinationAdapter posting through an `ACTIVE` connection decrypts the bot token, calls `chat.postMessage`, and reports SENT with the provider `ts`
- The Slack DestinationAdapter against a missing or `REVOKED` connection fails fast with `workspace_not_connected` without calling the Slack API
- The Slack DestinationAdapter invoked with a non-SLACK `channelId` (e.g. a TEAMS binding routed to it bare) fails fast with `ChannelMismatch` without resolving the connection or calling the Slack API
- The Slack DestinationAdapter classifies a `token_revoked` / `invalid_auth` / `account_inactive` response as `SlackTokenRevoked` and fires the optional `markConnectionRevoked` hook
- The Slack DestinationAdapter classifies an HTTP 429 as `SlackRateLimited`, preserving the `retry_after=<seconds>` hint in `failureReason`
- The Slack DestinationAdapter aborts a hung `chat.postMessage` call at the 10-second fetch deadline and classifies it as `SlackTimeout`
- The Slack DestinationAdapter classifies a `not_in_channel` / `channel_not_found` response as `SlackChannelUnreachable` (distinct from the generic `SlackApiError`)
- The Slack DestinationAdapter classifies a network/transport throw as `SlackPostThrew` and any other Slack `error` as `SlackApiError`
- The Slack DestinationAdapter redacts the provider response to `{ ok, ts, channel, error }` only — never the posted message text or Slack internal metadata
- Bot token plaintext is never returned by `getSlackWorkspaceIntegration` (it returns only metadata); the install command intentionally returns it once to the calling resolver, which stores it in the host secret manager

## Reference Links

- [SlackWorkspaceIntegration model](../model/SlackWorkspaceIntegration.md)
- [DestinationDeliveryLog model](../model/DestinationDeliveryLog.md)
- [notification-destination-delivery feature](./notification-destination-delivery.md)
- [Slack OAuth for apps](https://docs.slack.dev/authentication/installing-with-oauth)
- [Slack Events API: `app_uninstalled`](https://docs.slack.dev/reference/events/app_uninstalled)
- [Slack messaging: `chat.postMessage`](https://docs.slack.dev/messaging/sending-and-scheduling-messages)
