- name
- connector-generation
- description
- Guide for creating new external service connectors in DEVS. Use this when asked to add a new OAuth app connector, API connector, or integration with external services like Slack, Trello, Linear, Asana, Dropbox, GitHub, etc.
# Connector Generation for DEVS
When creating new connectors for external services in DEVS, follow this comprehensive guide covering OAuth flow, provider implementation, registration, bridge proxy setup, and UI integration.
## Overview
DEVS connectors enable synchronizing content from external services into the Knowledge Base. The architecture consists of:
1. **Provider Implementation** - OAuth + API integration in `src/features/connectors/providers/apps/`
2. **Provider Registry** - Lazy loading registration in `provider-registry.ts`
3. **OAuth Gateway** - OAuth config for popup-based auth flow
4. **Bridge Server** - Server-side proxy for OAuth secrets and CORS (in `utils/devs-bridge/`)
5. **UI Configuration** - Icons and display metadata
## Step 1: Define Provider Type
Add the new provider to the type union in `src/features/connectors/types.ts`:
```typescript
// In types.ts
export type AppConnectorProvider =
| 'google-drive'
| 'gmail'
| 'google-calendar'
| 'google-meet'
| 'google-tasks'
| 'notion'
| 'dropbox'
| 'github'
| 'qonto'
| 'slack' // <-- Add new provider
```
Also add the config entry in `APP_CONNECTOR_CONFIGS`:
```typescript
// In types.ts - APP_CONNECTOR_CONFIGS
slack: {
id: 'slack',
category: 'app',
name: 'Slack',
icon: 'slack',
color: '#4A154B',
capabilities: ['read', 'search'],
supportedTypes: ['message', 'channel', 'file'],
maxFileSize: 10 * 1024 * 1024,
rateLimit: { requests: 50, windowSeconds: 60 },
},
```
## Step 2: Create Provider Implementation
Create a new file `src/features/connectors/providers/apps/{provider}.ts`:
```typescript
/**
* {Provider} Connector Provider
*
* Implements OAuth 2.0 authentication and API integration for {Provider}.
* Supports listing, reading, searching content, and delta sync.
*/
import { BRIDGE_URL } from '@/config/bridge'
import { BaseAppConnectorProvider } from '../../connector-provider'
import type {
Connector,
ConnectorProviderConfig,
OAuthResult,
TokenRefreshResult,
AccountInfo,
ListOptions,
ListResult,
ContentResult,
SearchResult,
ChangesResult,
ConnectorItem,
} from '../../types'
// =============================================================================
// Constants
// =============================================================================
/** API base URL - use gateway proxy to avoid CORS and keep secrets safe */
const API_BASE = `${BRIDGE_URL}/api/{provider}`
/** OAuth endpoints */
const AUTH_URL = 'https://{provider}.com/oauth/authorize'
const TOKEN_URL = 'https://{provider}.com/oauth/token'
const REVOKE_URL = 'https://{provider}.com/oauth/revoke'
const USERINFO_URL = 'https://{provider}.com/api/users/me'
// =============================================================================
// Types
// =============================================================================
// Define types for API responses (raw JSON from the provider's API)
interface RawItem {
id: string
name: string
// ... provider-specific fields
}
interface ListResponse {
items: RawItem[]
next_cursor?: string
}
interface TokenResponse {
access_token: string
refresh_token?: string
expires_in?: number
scope: string
token_type: string
}
interface UserInfoResponse {
id: string
email?: string
name?: string
avatar?: string
}
// =============================================================================
// Provider Implementation
// =============================================================================
export class {Provider}Provider extends BaseAppConnectorProvider {
readonly id = '{provider-id}' as const
readonly config: ConnectorProviderConfig = {
id: '{provider-id}',
category: 'app',
name: '{Provider Name}',
icon: '{provider-icon}',
color: '#HEXCOLOR',
capabilities: ['read', 'search'],
supportedTypes: ['*'],
maxFileSize: 10 * 1024 * 1024,
rateLimit: { requests: 100, windowSeconds: 60 },
}
/** Get the OAuth client ID from environment */
private get clientId(): string {
return import.meta.env.VITE_{PROVIDER}_CLIENT_ID || ''
}
/** Get the OAuth redirect URI */
private get redirectUri(): string {
return `${window.location.origin}/oauth/callback`
}
// ===========================================================================
// OAuth Methods
// ===========================================================================
/**
* Generate the OAuth authorization URL.
*
* @param state - State parameter for CSRF protection
* @param codeChallenge - PKCE code challenge (S256)
* @returns The full authorization URL
*/
getAuthUrl(state: string, codeChallenge: string): string {
const params = new URLSearchParams({
client_id: this.clientId,
redirect_uri: this.redirectUri,
response_type: 'code',
scope: 'read:user read:content', // Provider-specific scopes
state,
// Include PKCE if provider supports it
code_challenge: codeChallenge,
code_challenge_method: 'S256',
})
return `${AUTH_URL}?${params.toString()}`
}
/**
* Exchange an authorization code for access and refresh tokens.
*
* NOTE: Token exchange should go through BRIDGE_URL to inject client_secret
*/
async exchangeCode(code: string, codeVerifier: string): Promise<OAuthResult> {
const response = await fetch(`${BRIDGE_URL}/api/{provider}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
code,
code_verifier: codeVerifier,
grant_type: 'authorization_code',
redirect_uri: this.redirectUri,
// client_id and client_secret injected by bridge server
}),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Token exchange failed: ${response.status} ${errorText}`)
}
const data: TokenResponse = await response.json()
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
scope: data.scope,
tokenType: data.token_type,
}
}
/**
* Refresh an expired access token using the refresh token.
*/
async refreshToken(connector: Connector): Promise<TokenRefreshResult> {
const refreshToken = await this.getDecryptedRefreshToken(connector)
if (!refreshToken) {
throw new Error('No refresh token available')
}
const response = await fetch(`${BRIDGE_URL}/api/{provider}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
refresh_token: refreshToken,
grant_type: 'refresh_token',
// client_id and client_secret injected by bridge server
}),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Token refresh failed: ${response.status} ${errorText}`)
}
const data: TokenResponse = await response.json()
return {
accessToken: data.access_token,
expiresIn: data.expires_in,
}
}
/**
* Validate that a token is still valid.
*/
async validateToken(token: string): Promise<boolean> {
try {
const response = await fetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${token}` },
})
return response.ok
} catch {
return false
}
}
/**
* Revoke all access for a connector.
*/
async revokeAccess(connector: Connector): Promise<void> {
const token = await this.getDecryptedToken(connector)
const response = await fetch(REVOKE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${token}`,
},
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Token revocation failed: ${response.status} ${errorText}`)
}
}
/**
* Get account information for the authenticated user.
*/
async getAccountInfo(token: string): Promise<AccountInfo> {
const response = await fetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to get account info: ${response.status} ${errorText}`)
}
const data: UserInfoResponse = await response.json()
return {
id: data.id,
email: data.email,
name: data.name,
picture: data.avatar,
}
}
View on GitHub