buster/slack-oauth-integration-prd.md

30 KiB

Slack OAuth Integration - Product Requirements Document

Executive Summary

This document outlines the requirements for implementing Slack OAuth integration in the Buster application, enabling users to connect their Slack workspaces and utilize Slack messaging capabilities within the platform.

Overview

Purpose

Enable Buster users to securely authenticate with Slack workspaces, allowing the application to:

  • Send messages to Slack channels
  • List available channels
  • Manage message threads
  • Track message delivery

Scope

  • OAuth 2.0 authentication flow
  • Token management and storage
  • Database schema for Slack integrations
  • API endpoints for OAuth flow
  • Security and compliance considerations

Technical Architecture

Components

1. @buster/slack Package (Existing)

  • Standalone Slack integration with no database dependencies
  • Provides OAuth flow management, channel operations, and messaging
  • Interface-based design requiring implementation of storage interfaces

2. @apps/server (To be implemented)

  • OAuth initiation endpoint
  • OAuth callback endpoint
  • Integration management endpoints

3. @packages/database (Schema additions required)

  • Slack integration records
  • Token storage (via Supabase Vault)
  • OAuth state management

Detailed Requirements

OAuth Flow

1. OAuth Initiation

Endpoint: POST /api/v2/slack/auth/init

Request Body (Optional):

{
  "metadata": {
    "returnUrl": "/settings/integrations",  // Where to redirect after OAuth completes
    "source": "settings_page",              // Analytics tracking
    "projectId": "uuid"                     // Optional project context
  }
}

Response:

{
  "authUrl": "https://slack.com/oauth/v2/authorize?...",
  "state": "secure-random-state"
}

Metadata Explained: The metadata object is completely optional and serves to maintain context through the OAuth flow:

  1. returnUrl - Where in your app to redirect after OAuth completes

    • Default: /settings/integrations
    • Example: /projects/123/settings to return to specific project
  2. source - Track where user initiated OAuth from

    • Examples: "onboarding", "settings_page", "project_view"
    • Useful for analytics and user flow optimization
  3. projectId - Associate integration with specific context

    • Optional: Since one org = one Slack integration
    • Useful if you want to track which project triggered the integration
  4. Custom fields - Any additional context your app needs

How it works:

// The metadata gets stored in the pending integration record
{
  id: "integration-id",
  oauthState: "secure-random-state",
  oauthMetadata: {
    returnUrl: "/settings/integrations",
    source: "settings_page",
    projectId: "project-123",
    // System adds:
    initiatedAt: "2024-01-01T00:00:00Z",
    ipAddress: "192.168.1.1"
  },
  status: "pending"
}

// After successful OAuth, use metadata to redirect:
const returnUrl = integration.oauthMetadata.returnUrl || '/settings';
return c.redirect(`${returnUrl}?integration=success`);

Requirements:

  • Metadata is entirely optional (endpoint works without it)
  • Stored in oauth_metadata JSONB column
  • Available during callback to restore user context
  • Cleared after successful OAuth
  • Size limit: 1KB to prevent abuse

2. OAuth Callback

Endpoint: GET /api/v2/slack/auth/callback

Authentication: This endpoint should be unauthenticated because:

  • Slack redirects here directly after OAuth approval
  • The user's session might have expired during the OAuth flow
  • We use the state parameter to map back to the user/organization

Query Parameters:

  • code: Authorization code from Slack
  • state: State parameter that maps to our pending integration

Flow:

  1. Look up pending integration by state parameter
  2. Verify state hasn't expired (15-minute window)
  3. Exchange code for access token with Slack
  4. Update integration record with Slack workspace info
  5. Store token in Supabase Vault
  6. Redirect to success page (from metadata.returnUrl)

Success Response (Redirect):

302 Redirect to: /settings/integrations?status=success&workspace=Acme%20Corp

Error Response (Redirect):

302 Redirect to: /settings/integrations?status=error&error=access_denied

Database Schema (Drizzle)

Add the following to @packages/database/src/schema.ts:

// Enum for Slack integration status
export const slackIntegrationStatusEnum = pgEnum('slack_integration_status_enum', [
  'pending',
  'active',
  'failed',
  'revoked'
]);

// Slack integrations table
export const slackIntegrations = pgTable(
  'slack_integrations',
  {
    id: uuid().defaultRandom().primaryKey().notNull(),
    organizationId: uuid('organization_id').notNull(),
    userId: uuid('user_id').notNull(),
    
    // OAuth state fields (for pending integrations)
    oauthState: varchar('oauth_state', { length: 255 }).unique(),
    oauthExpiresAt: timestamp('oauth_expires_at', { withTimezone: true, mode: 'string' }),
    oauthMetadata: jsonb('oauth_metadata').default({}),
    
    // Slack workspace info (populated after successful OAuth)
    teamId: varchar('team_id', { length: 255 }),
    teamName: varchar('team_name', { length: 255 }),
    teamDomain: varchar('team_domain', { length: 255 }),
    enterpriseId: varchar('enterprise_id', { length: 255 }),
    
    // Bot info
    botUserId: varchar('bot_user_id', { length: 255 }),
    scope: text(),
    
    // Token reference (actual token in Supabase Vault)
    tokenVaultKey: varchar('token_vault_key', { length: 255 }).unique(),
    
    // Metadata
    installedBySlackUserId: varchar('installed_by_slack_user_id', { length: 255 }),
    installedAt: timestamp('installed_at', { withTimezone: true, mode: 'string' }),
    lastUsedAt: timestamp('last_used_at', { withTimezone: true, mode: 'string' }),
    status: slackIntegrationStatusEnum().default('pending').notNull(),
    
    // Timestamps
    createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
      .defaultNow()
      .notNull(),
    updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'string' })
      .defaultNow()
      .notNull(),
    deletedAt: timestamp('deleted_at', { withTimezone: true, mode: 'string' }),
  },
  (table) => [
    foreignKey({
      columns: [table.organizationId],
      foreignColumns: [organizations.id],
      name: 'slack_integrations_organization_id_fkey',
    }).onDelete('cascade'),
    foreignKey({
      columns: [table.userId],
      foreignColumns: [users.id],
      name: 'slack_integrations_user_id_fkey',
    }),
    unique('slack_integrations_org_team_key').on(table.organizationId, table.teamId),
    index('idx_slack_integrations_org_id').using(
      'btree',
      table.organizationId.asc().nullsLast().op('uuid_ops')
    ),
    index('idx_slack_integrations_team_id').using(
      'btree',
      table.teamId.asc().nullsLast().op('text_ops')
    ),
    index('idx_slack_integrations_oauth_state').using(
      'btree',
      table.oauthState.asc().nullsLast().op('text_ops')
    ),
    index('idx_slack_integrations_oauth_expires').using(
      'btree',
      table.oauthExpiresAt.asc().nullsLast().op('timestamptz_ops')
    ),
    check(
      'slack_integrations_status_check',
      sql`(status = 'pending' AND oauth_state IS NOT NULL) OR (status != 'pending' AND team_id IS NOT NULL)`
    ),
  ]
);

// Slack message tracking table (optional)
export const slackMessageTracking = pgTable(
  'slack_message_tracking',
  {
    id: uuid().defaultRandom().primaryKey().notNull(),
    integrationId: uuid('integration_id').notNull(),
    
    // Internal reference
    internalMessageId: uuid('internal_message_id').notNull().unique(),
    
    // Slack references
    slackChannelId: varchar('slack_channel_id', { length: 255 }).notNull(),
    slackMessageTs: varchar('slack_message_ts', { length: 255 }).notNull(),
    slackThreadTs: varchar('slack_thread_ts', { length: 255 }),
    
    // Metadata
    messageType: varchar('message_type', { length: 50 }).notNull(), // 'message', 'reply', 'update'
    content: text(),
    senderInfo: jsonb('sender_info'),
    
    // Timestamps
    sentAt: timestamp('sent_at', { withTimezone: true, mode: 'string' }).notNull(),
    createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
      .defaultNow()
      .notNull(),
  },
  (table) => [
    foreignKey({
      columns: [table.integrationId],
      foreignColumns: [slackIntegrations.id],
      name: 'slack_message_tracking_integration_id_fkey',
    }).onDelete('cascade'),
    index('idx_message_tracking_integration').using(
      'btree',
      table.integrationId.asc().nullsLast().op('uuid_ops')
    ),
    index('idx_message_tracking_channel').using(
      'btree',
      table.slackChannelId.asc().nullsLast().op('text_ops')
    ),
    index('idx_message_tracking_thread').using(
      'btree',
      table.slackThreadTs.asc().nullsLast().op('text_ops')
    ),
  ]
);

API Implementation Structure

File Organization

@apps/server/src/api/v2/slack/
├── index.ts                          # Route definitions
├── index.test.ts                     # Route tests
├── handler.ts                        # Request handlers
├── handler.test.ts                   # Unit tests
├── handler.integration.test.ts       # Integration tests
└── services/                         # Business logic
    ├── slack-oauth-service.ts        # OAuth flow logic
    ├── slack-oauth-service.test.ts   # Service unit tests
    ├── slack-helpers.ts              # Utility functions
    └── slack-helpers.test.ts         # Helper tests

API Endpoints

Since each organization will only have one Slack integration, we need just these endpoints:

1. Initiate OAuth Flow POST /api/v2/slack/auth

  • Creates pending integration record
  • Generates OAuth URL with state

2. OAuth Callback GET /api/v2/slack/auth/callback

  • Handles redirect from Slack
  • Exchanges code for token
  • Updates integration to active

3. Get Current Integration GET /api/v2/slack/integration

  • Returns current integration status
  • Returns null if no integration exists

4. Remove Integration DELETE /api/v2/slack/integration

  • Revokes token from Supabase Vault
  • Soft deletes integration record

File Responsibilities & Separation of Concerns

index.ts - Pure routing, no business logic

// ONLY route definitions and middleware
const app = new Hono()
  .post('/auth', authMiddleware, handler.initiateOAuth)
  .get('/auth/callback', handler.handleOAuthCallback)
  .get('/integration', authMiddleware, handler.getIntegration)
  .delete('/integration', authMiddleware, handler.removeIntegration);

export default app;

handler.ts - Thin HTTP layer, delegates to services

// Each handler is a thin wrapper that:
// 1. Extracts and validates input
// 2. Calls the appropriate service function
// 3. Formats the response

export const initiateOAuth = async (c: Context) => {
  const { organizationId, userId } = c.get('auth');
  
  // Delegate to service - handler doesn't contain business logic
  const result = await slackOAuthService.initiateOAuth({
    organizationId,
    userId,
  });
  
  return c.json(result);
};

services/slack-oauth-service.ts - Business logic in pure, testable functions

// Pure functions that can be tested independently
export async function initiateOAuth(params: {
  organizationId: string;
  userId: string;
}): Promise<{ authUrl: string; state: string }> {
  // Check for existing integration
  const existing = await slackHelpers.getActiveIntegration(params.organizationId);
  if (existing) {
    throw new Error('Integration already exists');
  }
  
  // Generate OAuth URL
  const { authUrl, state } = await generateOAuthUrl(params);
  
  // Store pending integration
  await slackHelpers.createPendingIntegration({
    ...params,
    oauthState: state,
  });
  
  return { authUrl, state };
}

// Each function does ONE thing and is independently testable
export async function generateOAuthUrl(params: {
  organizationId: string;
  userId: string;
}): Promise<{ authUrl: string; state: string }> {
  // Pure OAuth URL generation logic
}

export async function exchangeCodeForToken(params: {
  code: string;
  state: string;
}): Promise<SlackTokenResponse> {
  // Pure token exchange logic
}

services/slack-helpers.ts - Database operations as pure functions

// Each helper is a focused, testable function
export async function getActiveIntegration(
  organizationId: string
): Promise<SlackIntegration | null> {
  return db
    .select()
    .from(slackIntegrations)
    .where(and(
      eq(slackIntegrations.organizationId, organizationId),
      eq(slackIntegrations.status, 'active'),
      isNull(slackIntegrations.deletedAt)
    ))
    .limit(1)
    .then(rows => rows[0] || null);
}

export async function createPendingIntegration(params: {
  organizationId: string;
  userId: string;
  oauthState: string;
}): Promise<string> {
  const [integration] = await db
    .insert(slackIntegrations)
    .values({
      ...params,
      status: 'pending',
      oauthExpiresAt: new Date(Date.now() + 15 * 60 * 1000),
    })
    .returning({ id: slackIntegrations.id });
    
  return integration.id;
}

export async function storeTokenInVault(
  integrationId: string,
  token: string
): Promise<string> {
  // Pure function for vault storage
  const vaultKey = `slack-token-${integrationId}`;
  await supabaseVault.store(vaultKey, token);
  return vaultKey;
}

Key Design Principles

  1. Single Responsibility - Each function does ONE thing
  2. Pure Functions - Functions return values based on inputs, minimal side effects
  3. Dependency Injection - Pass dependencies as parameters for easy mocking
  4. No Business Logic in Handlers - Handlers only handle HTTP concerns
  5. Testable in Isolation - Each function can be unit tested independently

Testing Structure

Unit Tests (*.test.ts)

  • Mock external dependencies
  • Test individual functions in isolation
  • Focus on business logic correctness
  • Mock database and Slack API calls

Integration Tests (*.integration.test.ts)

  • Use test database
  • Test full request/response cycle
  • Mock only external APIs (Slack)
  • Verify database state changes

Test Utilities

  • Shared test factories for creating test data
  • Mock implementations of interfaces
  • Test database setup/teardown helpers

Security Requirements

Token Storage

  • Access tokens stored in Supabase Vault
  • Vault key stored in database, not the token itself
  • Tokens encrypted at rest
  • Support for token rotation

Access Control

  • Integration scoped to organization
  • User must have appropriate permissions
  • Audit logging for all OAuth operations
  • Rate limiting on OAuth endpoints

OAuth Security

  • State parameter validation with 15-minute expiry
  • HTTPS required for all OAuth endpoints
  • Redirect URI whitelist validation
  • PKCE support for enhanced security (future)

Implementation Interfaces

ISlackTokenStorage Implementation

class DatabaseTokenStorage implements ISlackTokenStorage {
  async storeToken(key: string, token: string): Promise<void> {
    // Store in Supabase Vault
    // Update slack_integrations.token_vault_key
  }
  
  async getToken(key: string): Promise<string | null> {
    // Retrieve from Supabase Vault
  }
  
  async deleteToken(key: string): Promise<void> {
    // Remove from Vault
    // Clear slack_integrations.token_vault_key
  }
  
  async hasToken(key: string): Promise<boolean> {
    // Check if token exists in Vault
  }
}

ISlackOAuthStateStorage Implementation

class DatabaseOAuthStateStorage implements ISlackOAuthStateStorage {
  async storeState(state: string, data: SlackOAuthStateData): Promise<void> {
    // Create a pending integration record with OAuth state
    await db.insert(slackIntegrations).values({
      userId: data.metadata.userId,
      organizationId: data.metadata.organizationId,
      oauth_state: state,
      oauth_expires_at: new Date(data.expiresAt),
      oauth_metadata: data.metadata,
      status: 'pending'
    });
  }
  
  async getState(state: string): Promise<SlackOAuthStateData | null> {
    // Query slack_integrations for pending OAuth
    const integration = await db
      .select()
      .from(slackIntegrations)
      .where(and(
        eq(slackIntegrations.oauth_state, state),
        eq(slackIntegrations.status, 'pending'),
        gt(slackIntegrations.oauth_expires_at, new Date())
      ))
      .limit(1);
      
    if (!integration[0]) return null;
    
    return {
      expiresAt: integration[0].oauth_expires_at.getTime(),
      metadata: integration[0].oauth_metadata
    };
  }
  
  async deleteState(state: string): Promise<void> {
    // Clean up failed/expired OAuth attempts
    await db
      .delete(slackIntegrations)
      .where(and(
        eq(slackIntegrations.oauth_state, state),
        eq(slackIntegrations.status, 'pending')
      ));
  }
}

Slack App Configuration

Required OAuth Scopes

  • channels:read - List channels in workspace
  • chat:write - Send messages as bot
  • chat:write.public - Send to channels bot hasn't joined
  • channels:join - Join public channels
  • users:read - Read user information

OAuth Redirect URLs

  • Development: http://localhost:3000/api/v2/slack/auth/callback
  • Staging: https://staging.buster.so/api/v2/slack/auth/callback
  • Production: https://app.buster.so/api/v2/slack/auth/callback

Error Handling

OAuth Errors

  • OAUTH_ACCESS_DENIED - User denied authorization
  • OAUTH_INVALID_STATE - Invalid or expired state
  • OAUTH_TOKEN_EXCHANGE_FAILED - Failed to exchange code
  • INVALID_SCOPE - Required scopes not granted
  • WORKSPACE_LIMIT_REACHED - Organization workspace limit

Integration Errors

  • INTEGRATION_NOT_FOUND - Integration doesn't exist
  • INTEGRATION_INACTIVE - Integration has been deactivated
  • INVALID_CHANNEL - Channel not accessible
  • RATE_LIMITED - Slack API rate limit

Monitoring & Analytics

Metrics to Track

  • OAuth flow completion rate
  • Time to complete OAuth flow
  • Integration usage by organization
  • Message send success rate
  • Channel access patterns
  • Error rates by type

Audit Events

  • OAuth flow initiated
  • OAuth flow completed/failed
  • Integration created/deleted
  • Token refreshed
  • Message sent/failed
  • Permission changes

User Experience

Integration Flow

  1. User navigates to Integrations page
  2. Clicks "Connect Slack"
  3. Redirected to Slack OAuth page
  4. Approves permissions
  5. Redirected back to Buster
  6. See success message with workspace info
  7. Can immediately start using integration

Management Interface

  • List all connected workspaces
  • Show connection status
  • Last used timestamp
  • Quick actions (test, disconnect)
  • Channel browser
  • Message history viewer

Future Enhancements

Phase 2

  • Incoming webhooks support
  • Event subscriptions (message reactions, new messages)
  • Slash commands
  • Interactive components (buttons, modals)
  • Multiple workspace per organization

Phase 3

  • Scheduled messages
  • Message formatting builder
  • Template library
  • Bulk operations
  • Analytics dashboard

Testing Requirements

Test File Coverage

index.test.ts

  • Route registration verification
  • Middleware application checks
  • Route parameter validation

handler.test.ts - Test HTTP layer only

describe('SlackHandler', () => {
  // Mock the service layer
  const mockSlackOAuthService = {
    initiateOAuth: vi.fn(),
    handleCallback: vi.fn(),
    getIntegration: vi.fn(),
    removeIntegration: vi.fn(),
  };

  describe('initiateOAuth', () => {
    it('should extract auth context and call service');
    it('should return 200 with auth URL');
    it('should handle service errors appropriately');
  });
  
  describe('handleOAuthCallback', () => {
    it('should validate query parameters');
    it('should call service with code and state');
    it('should redirect on success');
    it('should handle errors with proper status codes');
  });
});

services/slack-oauth-service.test.ts - Test business logic

describe('SlackOAuthService', () => {
  // Mock only external dependencies
  const mockSlackHelpers = {
    getActiveIntegration: vi.fn(),
    createPendingIntegration: vi.fn(),
    updateIntegrationStatus: vi.fn(),
  };

  describe('initiateOAuth', () => {
    it('should throw if integration already exists');
    it('should generate valid OAuth URL');
    it('should create pending integration with state');
    it('should set 15-minute expiry on state');
  });
  
  describe('exchangeCodeForToken', () => {
    it('should validate state exists and not expired');
    it('should call Slack API with correct parameters');
    it('should store token in vault');
    it('should update integration to active status');
    it('should clear OAuth state after success');
  });
});

services/slack-helpers.test.ts - Test data layer

describe('SlackHelpers', () => {
  describe('getActiveIntegration', () => {
    it('should return active integration for org');
    it('should exclude deleted integrations');
    it('should return null if no integration');
  });
  
  describe('createPendingIntegration', () => {
    it('should create with pending status');
    it('should set OAuth expiry timestamp');
    it('should return integration ID');
  });
  
  describe('storeTokenInVault', () => {
    it('should generate correct vault key');
    it('should store encrypted token');
    it('should return vault key');
  });
});

handler.integration.test.ts

  • Full OAuth flow with database
  • State expiration handling
  • Organization isolation
  • Concurrent request handling

services/slack-oauth-service.test.ts

  • OAuth URL generation
  • State management
  • Token exchange logic
  • Vault integration mocking

services/slack-helpers.test.ts

  • Database query functions
  • Token encryption/decryption
  • Status checking logic
  • Data transformation utilities

Testing Best Practices

  1. Test Data Isolation

    • Each test creates its own organization/user
    • Clean up after each test
    • No shared state between tests
  2. Mock Strategy

    • Mock Slack API responses
    • Mock Supabase Vault operations
    • Use real database in integration tests
    • Mock time for expiration tests
  3. Error Scenarios

    • Network failures
    • Invalid OAuth codes
    • Expired states
    • Rate limiting
    • Database conflicts
  4. Security Testing

    • CSRF protection validation
    • Authorization checks
    • Token exposure prevention
    • Cross-organization access

Development & Testing Workflow

Local Development Setup

  1. Environment Configuration
# .env.local
SLACK_INTEGRATION_ENABLED=false  # Set to true when ready to test
SLACK_CLIENT_ID=your-dev-client-id
SLACK_CLIENT_SECRET=your-dev-client-secret
SLACK_REDIRECT_URI=http://localhost:3000/api/v2/slack/auth/callback
SUPABASE_VAULT_KEY=your-vault-key

Feature Flag Implementation:

// services/slack-oauth-service.ts
export async function initiateOAuth(params: {
  organizationId: string;
  userId: string;
}): Promise<{ authUrl: string; state: string }> {
  // Check if Slack integration is enabled
  if (!process.env.SLACK_INTEGRATION_ENABLED || process.env.SLACK_INTEGRATION_ENABLED === 'false') {
    throw new Error('Slack integration is not enabled');
  }
  
  // Regular OAuth flow continues...
}
  1. Testing with Real Slack

For local development, you'll need to set up a real Slack app and use ngrok to expose your localhost:

Slack App Setup:

  1. Go to https://api.slack.com/apps and create a new app
  2. Choose "From scratch" and select your workspace
  3. Add OAuth scopes under "OAuth & Permissions":
    • channels:read
    • chat:write
    • chat:write.public
  4. Add redirect URL (will update with ngrok URL)

Local Development with ngrok:

# Start your local server
pnpm run dev --filter=@buster-app/server

# In another terminal, expose your localhost
ngrok http 3000

# Copy the HTTPS URL from ngrok (e.g., https://abc123.ngrok.io)
# Update your Slack app's redirect URL to:
# https://abc123.ngrok.io/api/v2/slack/auth/callback

# Update your .env.local
SLACK_CLIENT_ID=your-slack-client-id
SLACK_CLIENT_SECRET=your-slack-client-secret
SLACK_REDIRECT_URI=https://abc123.ngrok.io/api/v2/slack/auth/callback

Testing Flow:

  1. Start your server and ngrok

  2. Navigate to your OAuth initiation endpoint

  3. You'll be redirected to real Slack OAuth

  4. Approve access in your test workspace

  5. Slack redirects back to your ngrok URL

  6. Your callback handler processes the real OAuth code

  7. Database Setup

# Run migrations
pnpm run db:migrate

# Seed test data
pnpm run db:seed:dev

Testing Workflow

1. Unit Tests (Fast Feedback)

# Run all unit tests (works without Slack credentials)
pnpm run test:unit --filter=@buster-app/server

# Run specific test file
pnpm run test apps/server/src/api/v2/slack/handler.test.ts

# Watch mode for development
pnpm run test:watch apps/server/src/api/v2/slack

Unit Test Mocking Strategy:

// handler.test.ts - Tests work without real Slack
describe('SlackHandler', () => {
  beforeEach(() => {
    // Mock environment check
    vi.stubEnv('SLACK_INTEGRATION_ENABLED', 'true');
    
    // Mock Slack API calls
    vi.mock('@buster/slack', () => ({
      SlackAuthService: vi.fn(() => ({
        generateAuthUrl: vi.fn().mockResolvedValue({
          authUrl: 'https://mock-url',
          state: 'mock-state'
        }),
        handleCallback: vi.fn().mockResolvedValue({
          teamId: 'T-MOCK',
          teamName: 'Mock Team'
        })
      }))
    }));
  });
  
  it('should handle disabled integration gracefully', async () => {
    vi.stubEnv('SLACK_INTEGRATION_ENABLED', 'false');
    
    const response = await app.request('/api/v2/slack/auth', {
      method: 'POST'
    });
    
    expect(response.status).toBe(503); // Service Unavailable
    expect(await response.json()).toEqual({
      error: 'Slack integration is not enabled'
    });
  });
});

2. Integration Tests (Conditional Execution)

# Integration tests skip Slack tests when disabled
pnpm run test:integration --filter=@buster-app/server
// handler.integration.test.ts
describe('Slack OAuth Integration', () => {
  const slackEnabled = process.env.SLACK_INTEGRATION_ENABLED === 'true';
  
  describe.skipIf(!slackEnabled)('with real Slack', () => {
    // These tests only run when SLACK_INTEGRATION_ENABLED=true
    it('should complete OAuth flow', async () => {
      // Real Slack OAuth test
    });
  });
  
  describe('with mocked Slack', () => {
    // These tests always run
    it('should handle database operations', async () => {
      // Test database without real Slack
    });
  });
});

3. Manual Testing Flow

# Start server
pnpm run dev --filter=@buster-app/server

# Test OAuth flow
curl -X POST http://localhost:3000/api/v2/slack/auth \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json"

# Response includes auth URL
# Visit URL in browser to complete OAuth

# Check integration status
curl http://localhost:3000/api/v2/slack/integration \
  -H "Authorization: Bearer $TOKEN"

# Remove integration
curl -X DELETE http://localhost:3000/api/v2/slack/integration \
  -H "Authorization: Bearer $TOKEN"

Verification Checklist

OAuth Flow Verification

  • Auth URL includes all required parameters
  • State is stored in database with expiry
  • Callback validates state correctly
  • Token exchange succeeds
  • Token stored in Supabase Vault
  • Integration record updated to 'active'
  • OAuth metadata cleared after success

Security Verification

  • State expires after 15 minutes
  • Invalid state returns error
  • Cross-organization access blocked
  • Tokens never exposed in API responses
  • Deleted integrations can't be accessed

Error Handling Verification

  • User denial handled gracefully
  • Network errors return appropriate status
  • Duplicate integration attempts blocked
  • Missing integration returns 404

CI/CD Considerations

Environment-Based Configuration

Local Development (default):

SLACK_INTEGRATION_ENABLED=false
# Slack credentials optional

CI/CD Pipeline:

SLACK_INTEGRATION_ENABLED=false
# Tests run with mocked Slack

Staging:

SLACK_INTEGRATION_ENABLED=true
SLACK_CLIENT_ID=staging-client-id
SLACK_CLIENT_SECRET=xxx (from secrets)
SLACK_REDIRECT_URI=https://staging.buster.so/api/v2/slack/auth/callback

Production:

SLACK_INTEGRATION_ENABLED=true
SLACK_CLIENT_ID=prod-client-id
SLACK_CLIENT_SECRET=xxx (from Supabase Vault)
SLACK_REDIRECT_URI=https://app.buster.so/api/v2/slack/auth/callback

API Response When Disabled

When SLACK_INTEGRATION_ENABLED=false, endpoints return:

{
  "error": "Slack integration is not enabled",
  "code": "INTEGRATION_DISABLED",
  "status": 503
}

This allows:

  • Frontend to conditionally show/hide Slack features
  • Tests to run without Slack credentials
  • Gradual rollout with feature flags

Deployment Considerations

Environment Variables

SLACK_INTEGRATION_ENABLED=true/false
SLACK_CLIENT_ID=xxx
SLACK_CLIENT_SECRET=xxx (store in Supabase Vault)
SLACK_REDIRECT_URI=https://app.buster.so/api/v2/slack/auth/callback
SLACK_OAUTH_SCOPES=channels:read,chat:write,chat:write.public

Migration Strategy

  1. Deploy database schema
  2. Configure Supabase Vault
  3. Deploy API endpoints
  4. Update Slack app configuration
  5. Enable feature flag
  6. Gradual rollout

Success Criteria

  1. Security: Zero OAuth-related security incidents
  2. Reliability: 99.9% uptime for OAuth endpoints
  3. Performance: OAuth flow completes in <3 seconds
  4. Adoption: 50% of organizations connect Slack within 30 days
  5. Retention: 90% of integrations remain active after 60 days

Dependencies

  • @buster/slack package (existing)
  • Supabase Vault for token storage
  • Slack API availability
  • Database migrations completed
  • Frontend integration UI

Timeline

  • Week 1-2: Database schema and migrations
  • Week 2-3: OAuth endpoints implementation
  • Week 3-4: Integration management APIs
  • Week 4-5: Frontend integration
  • Week 5-6: Testing and security review
  • Week 6-7: Documentation and deployment

Appendix

Slack API Reference

Internal References

  • @buster/slack package documentation
  • Supabase Vault documentation
  • Organization permissions model