# CompleteSlackWorkspaceInstall

## Permission Scope

slackWorkspaceIntegration

## Overview

completeSlackWorkspaceInstall finishes the Slack OAuth handshake started by `beginSlackWorkspaceInstall`. The input is `(state, code, redirectUri, clientId, clientSecret, signingSecret)`. It verifies the state's HMAC-SHA256 signature with `signingSecret` (the same value `beginSlackWorkspaceInstall` signed with), checks the token's expiry, requires the state's pinned `userId` to equal the calling actor, and — when the begin step pinned a `redirectUri` — requires the supplied `redirectUri` to match it. Only then does it exchange the returned `code` for a bot token via `oauth.v2.access` and upsert the tenant's singleton `SlackWorkspaceIntegration` to `status = ACTIVE`. The plaintext bot token is returned to the caller (the resolver handles secret manager storage).

## Business Rules

- The `state` signature must verify against the supplied `signingSecret`; a forged, tampered, or wrongly-signed token yields `INVALID_STATE`
- The `state` must be unexpired; a garbage or expired token yields `INVALID_STATE`
- The state's pinned `userId` must equal the calling actor (`ctx.actorId`); a mismatch yields `INVALID_STATE` — the actor completing the install must be the actor who began it
- When the begin step pinned a `redirectUri` into the state, the supplied `redirectUri` must match it exactly; a mismatch yields `INVALID_STATE`
- A blank `code`, a failed exchange call, or an exchange result that is not ok / missing `access_token` / missing `team.id` yields `OAUTH_EXCHANGE_FAILED`
- The tenant holds at most one `SlackWorkspaceIntegration` row; if an existing `ACTIVE` connection is bound to a different `teamId`, the install is rejected with `TEAM_ALREADY_CONNECTED`
- The singleton is an application-level guarantee: the existing-row lookup is `orderBy(createdAt)` so the picked row is deterministic (oldest wins) if multiple rows ever exist; two concurrent installs against an empty table can still both insert (TailorDB cannot express a constant-field unique index), while the `teamId` unique constraint still dedupes same-team double installs
- The bot token is returned to the caller (plaintext); the resolver is responsible for secret manager storage
- Install is an upsert of the tenant's singleton connection: an existing row (including a `REVOKED` one) is updated to `ACTIVE` with `lastValidatedAt = now` and `revokedAt = null`; otherwise a new row is inserted with `installedAt = now`
- The OAuth exchange is injectable as a dependency so tests run without network access

## Process Flow

```mermaid
flowchart TD
    A[Receive completeSlackWorkspaceInstall] --> B{state unexpired?}
    B -->|No| C[Return INVALID_STATE]
    B -->|Yes| B2{state.userId == calling actor?}
    B2 -->|No| C
    B2 -->|Yes| B3{state.redirectUri unset or equal to supplied redirectUri?}
    B3 -->|No| C
    B3 -->|Yes| D{code present?}
    D -->|No| E[Return OAUTH_EXCHANGE_FAILED]
    D -->|Yes| F[Exchange code via oauth.v2.access]
    F --> G{ok and access_token and team.id present?}
    G -->|No| E
    G -->|Yes| H{existing ACTIVE connection bound to a different teamId?}
    H -->|Yes| I[Return TEAM_ALREADY_CONNECTED]
    H -->|No| J{existing connection row?}
    J -->|Yes| K[Update row: status=ACTIVE, lastValidatedAt, revokedAt=null]
    J -->|No| L[Insert row: status=ACTIVE, installedAt=now]
    K --> M[Return connection and plaintext bot token]
    L --> M
```

## External Dependencies

- Slack `oauth.v2.access` endpoint - exchanges the OAuth code for a bot token (injectable for tests)
- [SlackWorkspaceIntegration](../model/SlackWorkspaceIntegration.md) - the upserted connection row (metadata only; the bot token is returned to the resolver for secret-manager storage, not stored here)

## Error Scenarios

- **INVALID_STATE**: the `state` token is missing, expired, or tampered
- **OAUTH_EXCHANGE_FAILED**: the `code` is blank, the exchange call threw, or the result is not ok / lacks an access token or team id
- **TEAM_ALREADY_CONNECTED**: an existing `ACTIVE` connection is bound to a different `teamId`

## Test Cases

- stores one ACTIVE SlackWorkspaceIntegration and returns the bot token on first install
- updates the existing connection and returns the new bot token on re-install
- rejects a garbage state with INVALID_STATE
- rejects a state whose signature does not verify against the supplied signingSecret with INVALID_STATE
- rejects an expired state with INVALID_STATE
- rejects a state whose userId does not match the calling actor with INVALID_STATE
- rejects a redirectUri that differs from the one pinned in the state with INVALID_STATE
- rejects when the OAuth exchange returns not-ok with OAUTH_EXCHANGE_FAILED
- rejects a different teamId while an ACTIVE connection exists with TEAM_ALREADY_CONNECTED
- reactivates a previously REVOKED connection
- deterministically updates the oldest row (orderBy createdAt) when multiple connection rows exist
