Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .claude/commands/add-block.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ When the user asks you to create a block:
import { {ServiceName}Icon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { getScopesForService } from '@/lib/oauth/utils'

export const {ServiceName}Block: BlockConfig = {
type: '{service}', // snake_case identifier
Expand Down Expand Up @@ -115,12 +116,17 @@ export const {ServiceName}Block: BlockConfig = {
id: 'credential',
title: 'Account',
type: 'oauth-input',
serviceId: '{service}', // Must match OAuth provider
serviceId: '{service}', // Must match OAuth provider service key
requiredScopes: getScopesForService('{service}'), // Import from @/lib/oauth/utils
placeholder: 'Select account',
required: true,
}
```

**Scopes:** Always use `getScopesForService(serviceId)` from `@/lib/oauth/utils` for `requiredScopes`. Never hardcode scope arrays — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.

**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.

### Selectors (with dynamic options)
```typescript
// Channel selector (Slack, Discord, etc.)
Expand Down Expand Up @@ -624,6 +630,7 @@ export const registry: Record<string, BlockConfig> = {
import { ServiceIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { getScopesForService } from '@/lib/oauth/utils'

export const ServiceBlock: BlockConfig = {
type: 'service',
Expand Down Expand Up @@ -654,6 +661,7 @@ export const ServiceBlock: BlockConfig = {
title: 'Service Account',
type: 'oauth-input',
serviceId: 'service',
requiredScopes: getScopesForService('service'),
placeholder: 'Select account',
required: true,
},
Expand Down Expand Up @@ -792,7 +800,8 @@ All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MU
- [ ] Conditions use correct syntax (field, value, not, and)
- [ ] DependsOn set for fields that need other values
- [ ] Required fields marked correctly (boolean or condition)
- [ ] OAuth inputs have correct `serviceId`
- [ ] OAuth inputs have correct `serviceId` and `requiredScopes: getScopesForService(serviceId)`
- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes
- [ ] Tools.access lists all tool IDs (snake_case)
- [ ] Tools.config.tool returns correct tool ID (snake_case)
- [ ] Outputs match tool outputs
Expand Down
31 changes: 30 additions & 1 deletion .claude/commands/add-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export const {service}{Action}Tool: ToolConfig<Params, Response> = {
import { {Service}Icon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { getScopesForService } from '@/lib/oauth/utils'

export const {Service}Block: BlockConfig = {
type: '{service}',
Expand Down Expand Up @@ -144,6 +145,7 @@ export const {Service}Block: BlockConfig = {
title: '{Service} Account',
type: 'oauth-input',
serviceId: '{service}',
requiredScopes: getScopesForService('{service}'),
required: true,
},
// Conditional fields per operation
Expand Down Expand Up @@ -409,7 +411,7 @@ If creating V2 versions (API-aligned outputs):
### Block
- [ ] Created `blocks/blocks/{service}.ts`
- [ ] Defined operation dropdown with all operations
- [ ] Added credential field (oauth-input or short-input)
- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')`
- [ ] Added conditional fields per operation
- [ ] Set up dependsOn for cascading selectors
- [ ] Configured tools.access with all tool IDs
Expand All @@ -419,6 +421,12 @@ If creating V2 versions (API-aligned outputs):
- [ ] If triggers: set `triggers.enabled` and `triggers.available`
- [ ] If triggers: spread trigger subBlocks with `getTrigger()`

### OAuth Scopes (if OAuth service)
- [ ] Defined scopes in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS`
- [ ] Added scope descriptions in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode)
- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode)

### Icon
- [ ] Asked user to provide SVG
- [ ] Added icon to `components/icons.tsx`
Expand Down Expand Up @@ -717,6 +725,25 @@ Use `wandConfig` for fields that are hard to fill out manually:
}
```

### OAuth Scopes (Centralized System)

Scopes are maintained in a single source of truth and reused everywhere:

1. **Define scopes** in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
2. **Add descriptions** in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for the OAuth modal UI
3. **Reference in auth.ts** using `getCanonicalScopesForProvider(providerId)` from `@/lib/oauth/utils`
4. **Reference in blocks** using `getScopesForService(serviceId)` from `@/lib/oauth/utils`

**Never hardcode scope arrays** in `auth.ts` or block `requiredScopes`. Always import from the centralized source.

```typescript
// In auth.ts (Better Auth config)
scopes: getCanonicalScopesForProvider('{service}'),

// In block credential sub-block
requiredScopes: getScopesForService('{service}'),
```

### Common Gotchas

1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration
Expand All @@ -729,3 +756,5 @@ Use `wandConfig` for fields that are hard to fill out manually:
8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility
9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields
10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled
11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts
12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
22 changes: 14 additions & 8 deletions .claude/commands/validate-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ apps/sim/blocks/blocks/{service}.ts # Block definition
apps/sim/tools/registry.ts # Tool registry entries for this service
apps/sim/blocks/registry.ts # Block registry entry for this service
apps/sim/components/icons.tsx # Icon definition
apps/sim/lib/auth/auth.ts # OAuth scopes (if OAuth service)
apps/sim/lib/oauth/oauth.ts # OAuth provider config (if OAuth service)
apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider()
apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes
apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI
```

## Step 2: Pull API Documentation
Expand Down Expand Up @@ -199,11 +200,14 @@ For **each tool** in `tools.access`:

## Step 5: Validate OAuth Scopes (if OAuth service)

- [ ] `auth.ts` scopes include ALL scopes needed by ALL tools in the integration
- [ ] `oauth.ts` provider config scopes match `auth.ts` scopes
- [ ] Block `requiredScopes` (if defined) matches `auth.ts` scopes
Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.

- [ ] Scopes defined in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
- [ ] `auth.ts` uses `getCanonicalScopesForProvider(providerId)` — NOT a hardcoded array
- [ ] Block `requiredScopes` uses `getScopesForService(serviceId)` — NOT a hardcoded array
- [ ] No hardcoded scope arrays in `auth.ts` or block files (should all use utility functions)
- [ ] Each scope has a human-readable description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
- [ ] No excess scopes that aren't needed by any tool
- [ ] Each scope has a human-readable description in `oauth-required-modal.tsx`'s `SCOPE_DESCRIPTIONS`

## Step 6: Validate Pagination Consistency

Expand Down Expand Up @@ -244,7 +248,8 @@ Group findings by severity:
- Missing `.trim()` on ID fields in request URLs
- Missing `?? null` on nullable response fields
- Block condition array missing an operation that uses that field
- Missing scope description in `oauth-required-modal.tsx`
- Hardcoded scope arrays instead of using `getScopesForService()` / `getCanonicalScopesForProvider()`
- Missing scope description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`

**Suggestion** (minor improvements):
- Better description text
Expand Down Expand Up @@ -273,7 +278,8 @@ After fixing, confirm:
- [ ] Validated wandConfig on timestamps and complex inputs
- [ ] Validated tools.config mapping, tool selector, and type coercions
- [ ] Validated block outputs match what tools return, with typed JSON where possible
- [ ] Validated OAuth scopes alignment across auth.ts, oauth.ts, block, and modal (if OAuth)
- [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays
- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
- [ ] Validated pagination consistency across tools and block
- [ ] Validated error handling (error checks, meaningful messages)
- [ ] Validated registry entries (tools and block, alphabetical, correct imports)
Expand Down
70 changes: 26 additions & 44 deletions apps/sim/app/api/auth/oauth/connections/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,33 @@
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockGetSession,
mockDb,
mockLogger,
mockParseProvider,
mockEvaluateScopeCoverage,
mockJwtDecode,
mockEq,
} = vi.hoisted(() => {
const db = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn(),
const { mockGetSession, mockDb, mockLogger, mockParseProvider, mockJwtDecode, mockEq } = vi.hoisted(
() => {
const db = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn(),
}
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
}
return {
mockGetSession: vi.fn(),
mockDb: db,
mockLogger: logger,
mockParseProvider: vi.fn(),
mockJwtDecode: vi.fn(),
mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
}
}
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
}
return {
mockGetSession: vi.fn(),
mockDb: db,
mockLogger: logger,
mockParseProvider: vi.fn(),
mockEvaluateScopeCoverage: vi.fn(),
mockJwtDecode: vi.fn(),
mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
}
})
)

vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
Expand All @@ -66,7 +59,6 @@ vi.mock('@sim/logger', () => ({

vi.mock('@/lib/oauth/utils', () => ({
parseProvider: mockParseProvider,
evaluateScopeCoverage: mockEvaluateScopeCoverage,
}))

import { GET } from '@/app/api/auth/oauth/connections/route'
Expand All @@ -83,16 +75,6 @@ describe('OAuth Connections API Route', () => {
baseProvider: providerId.split('-')[0] || providerId,
featureType: providerId.split('-')[1] || 'default',
}))

mockEvaluateScopeCoverage.mockImplementation(
(_providerId: string, _grantedScopes: string[]) => ({
canonicalScopes: ['email', 'profile'],
grantedScopes: ['email', 'profile'],
missingScopes: [],
extraScopes: [],
requiresReauthorization: false,
})
)
})

it('should return connections successfully', async () => {
Expand Down
29 changes: 4 additions & 25 deletions apps/sim/app/api/auth/oauth/connections/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import type { OAuthProvider } from '@/lib/oauth'
import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth'
import { parseProvider } from '@/lib/oauth'

const logger = createLogger('OAuthConnectionsAPI')

Expand Down Expand Up @@ -49,8 +49,7 @@ export async function GET(request: NextRequest) {

for (const acc of accounts) {
const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider)
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []

if (baseProvider) {
// Try multiple methods to get a user-friendly display name
Expand Down Expand Up @@ -96,10 +95,6 @@ export async function GET(request: NextRequest) {
const accountSummary = {
id: acc.id,
name: displayName,
scopes: scopeEvaluation.grantedScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}

if (existingConnection) {
Expand All @@ -108,20 +103,8 @@ export async function GET(request: NextRequest) {
existingConnection.accounts.push(accountSummary)

existingConnection.scopes = Array.from(
new Set([...(existingConnection.scopes || []), ...scopeEvaluation.grantedScopes])
new Set([...(existingConnection.scopes || []), ...scopes])
)
existingConnection.missingScopes = Array.from(
new Set([...(existingConnection.missingScopes || []), ...scopeEvaluation.missingScopes])
)
existingConnection.extraScopes = Array.from(
new Set([...(existingConnection.extraScopes || []), ...scopeEvaluation.extraScopes])
)
existingConnection.canonicalScopes =
existingConnection.canonicalScopes && existingConnection.canonicalScopes.length > 0
? existingConnection.canonicalScopes
: scopeEvaluation.canonicalScopes
existingConnection.requiresReauthorization =
existingConnection.requiresReauthorization || scopeEvaluation.requiresReauthorization

const existingTimestamp = existingConnection.lastConnected
? new Date(existingConnection.lastConnected).getTime()
Expand All @@ -138,11 +121,7 @@ export async function GET(request: NextRequest) {
baseProvider,
featureType,
isConnected: true,
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
scopes,
lastConnected: acc.updatedAt.toISOString(),
accounts: [accountSummary],
})
Expand Down
17 changes: 1 addition & 16 deletions apps/sim/app/api/auth/oauth/credentials/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger } = vi.hoisted(() => {
const { mockCheckSessionOrInternalAuth, mockLogger } = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
Expand All @@ -19,7 +19,6 @@ const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger }
}
return {
mockCheckSessionOrInternalAuth: vi.fn(),
mockEvaluateScopeCoverage: vi.fn(),
mockLogger: logger,
}
})
Expand All @@ -28,10 +27,6 @@ vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))

vi.mock('@/lib/oauth', () => ({
evaluateScopeCoverage: mockEvaluateScopeCoverage,
}))

vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('mock-request-id'),
}))
Expand Down Expand Up @@ -87,16 +82,6 @@ describe('OAuth Credentials API Route', () => {

beforeEach(() => {
vi.clearAllMocks()

mockEvaluateScopeCoverage.mockImplementation(
(_providerId: string, grantedScopes: string[]) => ({
canonicalScopes: grantedScopes,
grantedScopes,
missingScopes: [],
extraScopes: [],
requiresReauthorization: false,
})
)
})

it('should handle unauthenticated user', async () => {
Expand Down
Loading