Compare commits

...

7 Commits

Author SHA1 Message Date
Vikhyath Mondreti
fc5df60d8f remove dead code 2026-03-06 18:37:32 -08:00
Vikhyath Mondreti
adea9db89d another workflowid pass through 2026-03-06 18:25:36 -08:00
Vikhyath Mondreti
94abc424be fix resolve values fallback 2026-03-06 18:18:25 -08:00
Vikhyath Mondreti
c1c6ed66d1 improvement(selectors): simplify selectorContext + add tests 2026-03-06 18:03:40 -08:00
Waleed
a71304200e improvement(oauth): centralize scopes and remove dead scope evaluation code (#3449)
* improvement(oauth): centralize scopes and remove dead scope evaluation code

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(oauth): fix stale scope-descriptions.ts references and add test coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:08:25 -08:00
Vikhyath Mondreti
a4d581c76f improvement(canonical): backfill for canonical modes on config changes (#3447)
* improvement(canonical): backfill for canonical modes on config changes

* persist data changes to db
2026-03-06 16:17:14 -08:00
Waleed
f1efc598d1 fix(selectors): resolve env var references at design time for selector context (#3446)
* fix(selectors): resolve env var references at design time for selector context

Selectors now resolve {{ENV_VAR}} references before building context and
returning dependency values to consumers, enabling env-var-based credentials
(e.g. {{SLACK_BOT_TOKEN}}) to work with selector dropdowns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(selectors): prevent unresolved env var templates from leaking into context

- Fall back to undefined instead of raw template string when env var is
  missing from store, so the null-check in the context loop discards it
- Use resolvedDetailId in query cache key so React Query refetches when
  the underlying env var value changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(selectors): use || for consistent empty-string env var handling

Align use-selector-setup.ts with use-selector-query.ts by using || instead
of ?? so empty-string env var values are treated as unset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:53:00 -08:00
71 changed files with 1474 additions and 1984 deletions

View File

@@ -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
@@ -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.)
@@ -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',
@@ -654,6 +661,7 @@ export const ServiceBlock: BlockConfig = {
title: 'Service Account',
type: 'oauth-input',
serviceId: 'service',
requiredScopes: getScopesForService('service'),
placeholder: 'Select account',
required: true,
},
@@ -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

View File

@@ -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}',
@@ -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
@@ -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
@@ -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`
@@ -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
@@ -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`

View File

@@ -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
@@ -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
@@ -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
@@ -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)

View File

@@ -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,
@@ -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'
@@ -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 () => {

View File

@@ -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')
@@ -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
@@ -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) {
@@ -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()
@@ -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],
})

View File

@@ -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(),
@@ -19,7 +19,6 @@ const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger }
}
return {
mockCheckSessionOrInternalAuth: vi.fn(),
mockEvaluateScopeCoverage: vi.fn(),
mockLogger: logger,
}
})
@@ -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'),
}))
@@ -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 () => {

View File

@@ -7,7 +7,6 @@ import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { evaluateScopeCoverage } from '@/lib/oauth'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -39,8 +38,7 @@ function toCredentialResponse(
scope: string | null
) {
const storedScope = scope?.trim()
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes)
const scopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const [_, featureType = 'default'] = providerId.split('-')
return {
@@ -49,11 +47,7 @@ function toCredentialResponse(
provider: providerId,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
scopes,
}
}

View File

@@ -15,6 +15,7 @@ import {
import { client } from '@/lib/auth/auth-client'
import {
getProviderIdFromServiceId,
getScopeDescription,
OAUTH_PROVIDERS,
type OAuthProvider,
parseProvider,
@@ -33,318 +34,6 @@ export interface OAuthRequiredModalProps {
onConnect?: () => Promise<void> | void
}
const SCOPE_DESCRIPTIONS: Record<string, string> = {
'https://www.googleapis.com/auth/gmail.send': 'Send emails',
'https://www.googleapis.com/auth/gmail.labels': 'View and manage email labels',
'https://www.googleapis.com/auth/gmail.modify': 'View and manage email messages',
'https://www.googleapis.com/auth/drive.file': 'View and manage Google Drive files',
'https://www.googleapis.com/auth/drive': 'Access all Google Drive files',
'https://www.googleapis.com/auth/calendar': 'View and manage calendar',
'https://www.googleapis.com/auth/contacts': 'View and manage Google Contacts',
'https://www.googleapis.com/auth/tasks': 'Create, read, update, and delete Google Tasks',
'https://www.googleapis.com/auth/userinfo.email': 'View email address',
'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info',
'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms',
'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms',
'https://www.googleapis.com/auth/bigquery': 'View and manage data in Google BigQuery',
'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery',
'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage',
'https://www.googleapis.com/auth/admin.directory.group': 'Manage Google Workspace groups',
'https://www.googleapis.com/auth/admin.directory.group.member':
'Manage Google Workspace group memberships',
'https://www.googleapis.com/auth/admin.directory.group.readonly': 'View Google Workspace groups',
'https://www.googleapis.com/auth/admin.directory.group.member.readonly':
'View Google Workspace group memberships',
'https://www.googleapis.com/auth/meetings.space.created':
'Create and manage Google Meet meeting spaces',
'https://www.googleapis.com/auth/meetings.space.readonly':
'View Google Meet meeting space details',
'https://www.googleapis.com/auth/cloud-platform':
'Full access to Google Cloud resources for Vertex AI',
'read:confluence-content.all': 'Read all Confluence content',
'read:confluence-space.summary': 'Read Confluence space information',
'read:space:confluence': 'View Confluence spaces',
'read:space-details:confluence': 'View detailed Confluence space information',
'write:confluence-content': 'Create and edit Confluence pages',
'write:confluence-space': 'Manage Confluence spaces',
'write:confluence-file': 'Upload files to Confluence',
'read:content:confluence': 'Read Confluence content',
'read:page:confluence': 'View Confluence pages',
'write:page:confluence': 'Create and update Confluence pages',
'read:comment:confluence': 'View comments on Confluence pages',
'write:comment:confluence': 'Create and update comments',
'delete:comment:confluence': 'Delete comments from Confluence pages',
'read:attachment:confluence': 'View attachments on Confluence pages',
'write:attachment:confluence': 'Upload and manage attachments',
'delete:attachment:confluence': 'Delete attachments from Confluence pages',
'delete:page:confluence': 'Delete Confluence pages',
'read:label:confluence': 'View labels on Confluence content',
'write:label:confluence': 'Add and remove labels',
'search:confluence': 'Search Confluence content',
'readonly:content.attachment:confluence': 'View attachments',
'read:blogpost:confluence': 'View Confluence blog posts',
'write:blogpost:confluence': 'Create and update Confluence blog posts',
'read:content.property:confluence': 'View properties on Confluence content',
'write:content.property:confluence': 'Create and manage content properties',
'read:hierarchical-content:confluence': 'View page hierarchy (children and ancestors)',
'read:content.metadata:confluence': 'View content metadata (required for ancestors)',
'read:user:confluence': 'View Confluence user profiles',
'read:task:confluence': 'View Confluence inline tasks',
'write:task:confluence': 'Update Confluence inline tasks',
'delete:blogpost:confluence': 'Delete Confluence blog posts',
'write:space:confluence': 'Create and update Confluence spaces',
'delete:space:confluence': 'Delete Confluence spaces',
'read:space.property:confluence': 'View Confluence space properties',
'write:space.property:confluence': 'Create and manage space properties',
'read:space.permission:confluence': 'View Confluence space permissions',
'read:me': 'Read profile information',
'database.read': 'Read database',
'database.write': 'Write to database',
'projects.read': 'Read projects',
offline_access: 'Access account when not using the application',
repo: 'Access repositories',
workflow: 'Manage repository workflows',
'read:user': 'Read public user information',
'user:email': 'Access email address',
'tweet.read': 'Read tweets and timeline',
'tweet.write': 'Post and delete tweets',
'tweet.moderate.write': 'Hide and unhide replies to tweets',
'users.read': 'Read user profiles and account information',
'follows.read': 'View followers and following lists',
'follows.write': 'Follow and unfollow users',
'bookmark.read': 'View bookmarked tweets',
'bookmark.write': 'Add and remove bookmarks',
'like.read': 'View liked tweets and liking users',
'like.write': 'Like and unlike tweets',
'block.read': 'View blocked users',
'block.write': 'Block and unblock users',
'mute.read': 'View muted users',
'mute.write': 'Mute and unmute users',
'offline.access': 'Access account when not using the application',
'data.records:read': 'Read records',
'data.records:write': 'Write to records',
'schema.bases:read': 'View bases and tables',
'webhook:manage': 'Manage webhooks',
'page.read': 'Read Notion pages',
'page.write': 'Write to Notion pages',
'workspace.content': 'Read Notion content',
'workspace.name': 'Read Notion workspace name',
'workspace.read': 'Read Notion workspace',
'workspace.write': 'Write to Notion workspace',
'user.email:read': 'Read email address',
'read:jira-user': 'Read Jira user',
'read:jira-work': 'Read Jira work',
'write:jira-work': 'Write to Jira work',
'manage:jira-webhook': 'Register and manage Jira webhooks',
'read:webhook:jira': 'View Jira webhooks',
'write:webhook:jira': 'Create and update Jira webhooks',
'delete:webhook:jira': 'Delete Jira webhooks',
'read:issue-event:jira': 'Read Jira issue events',
'write:issue:jira': 'Write to Jira issues',
'read:project:jira': 'Read Jira projects',
'read:issue-type:jira': 'Read Jira issue types',
'read:issue-meta:jira': 'Read Jira issue meta',
'read:issue-security-level:jira': 'Read Jira issue security level',
'read:issue.vote:jira': 'Read Jira issue votes',
'read:issue.changelog:jira': 'Read Jira issue changelog',
'read:avatar:jira': 'Read Jira avatar',
'read:issue:jira': 'Read Jira issues',
'read:status:jira': 'Read Jira status',
'read:user:jira': 'Read Jira user',
'read:field-configuration:jira': 'Read Jira field configuration',
'read:issue-details:jira': 'Read Jira issue details',
'read:field:jira': 'Read Jira field configurations',
'read:jql:jira': 'Use JQL to filter Jira issues',
'read:comment.property:jira': 'Read Jira comment properties',
'read:issue.property:jira': 'Read Jira issue properties',
'delete:issue:jira': 'Delete Jira issues',
'write:comment:jira': 'Add and update comments on Jira issues',
'read:comment:jira': 'Read comments on Jira issues',
'delete:comment:jira': 'Delete comments from Jira issues',
'read:attachment:jira': 'Read attachments from Jira issues',
'delete:attachment:jira': 'Delete attachments from Jira issues',
'write:issue-worklog:jira': 'Add and update worklog entries on Jira issues',
'read:issue-worklog:jira': 'Read worklog entries from Jira issues',
'delete:issue-worklog:jira': 'Delete worklog entries from Jira issues',
'write:issue-link:jira': 'Create links between Jira issues',
'delete:issue-link:jira': 'Delete links between Jira issues',
'User.Read': 'Read Microsoft user',
'Chat.Read': 'Read Microsoft chats',
'Chat.ReadWrite': 'Write to Microsoft chats',
'Chat.ReadBasic': 'Read Microsoft chats',
'ChatMessage.Send': 'Send chat messages',
'Channel.ReadBasic.All': 'Read Microsoft channels',
'ChannelMessage.Send': 'Write to Microsoft channels',
'ChannelMessage.Read.All': 'Read Microsoft channels',
'ChannelMessage.ReadWrite': 'Read and write to Microsoft channels',
'ChannelMember.Read.All': 'Read team channel members',
'Group.Read.All': 'Read Microsoft groups',
'Group.ReadWrite.All': 'Write to Microsoft groups',
'Team.ReadBasic.All': 'Read Microsoft teams',
'TeamMember.Read.All': 'Read team members',
'Mail.ReadWrite': 'Write to Microsoft emails',
'Mail.ReadBasic': 'Read Microsoft emails',
'Mail.Read': 'Read Microsoft emails',
'Mail.Send': 'Send emails',
'Files.Read': 'Read OneDrive files',
'Files.ReadWrite': 'Read and write OneDrive files',
'Tasks.ReadWrite': 'Read and manage Planner tasks',
'Sites.Read.All': 'Read Sharepoint sites',
'Sites.ReadWrite.All': 'Read and write Sharepoint sites',
'Sites.Manage.All': 'Manage Sharepoint sites',
openid: 'Standard authentication',
profile: 'Access profile information',
email: 'Access email address',
identify: 'Read Discord user',
bot: 'Read Discord bot',
'messages.read': 'Read Discord messages',
guilds: 'Read Discord guilds',
'guilds.members.read': 'Read Discord guild members',
identity: 'Access Reddit identity',
submit: 'Submit posts and comments',
vote: 'Vote on posts and comments',
save: 'Save and unsave posts and comments',
edit: 'Edit posts and comments',
subscribe: 'Subscribe and unsubscribe from subreddits',
history: 'Access Reddit history',
privatemessages: 'Access inbox and send private messages',
account: 'Update account preferences and settings',
mysubreddits: 'Access subscribed and moderated subreddits',
flair: 'Manage user and post flair',
report: 'Report posts and comments for rule violations',
modposts: 'Approve, remove, and moderate posts in moderated subreddits',
modflair: 'Manage flair in moderated subreddits',
modmail: 'Access and respond to moderator mail',
login: 'Access Wealthbox account',
data: 'Access Wealthbox data',
read: 'Read access to workspace',
write: 'Write access to Linear workspace',
'channels:read': 'View public channels',
'channels:history': 'Read channel messages',
'groups:read': 'View private channels',
'groups:history': 'Read private messages',
'chat:write': 'Send messages',
'chat:write.public': 'Post to public channels',
'im:write': 'Send direct messages',
'im:history': 'Read direct message history',
'im:read': 'View direct message channels',
'users:read': 'View workspace users',
'files:write': 'Upload files',
'files:read': 'Download and read files',
'canvases:write': 'Create canvas documents',
'reactions:write': 'Add emoji reactions to messages',
'sites:read': 'View Webflow sites',
'sites:write': 'Manage webhooks and site settings',
'cms:read': 'View CMS content',
'cms:write': 'Manage CMS content',
'crm.objects.contacts.read': 'Read HubSpot contacts',
'crm.objects.contacts.write': 'Create and update HubSpot contacts',
'crm.objects.companies.read': 'Read HubSpot companies',
'crm.objects.companies.write': 'Create and update HubSpot companies',
'crm.objects.deals.read': 'Read HubSpot deals',
'crm.objects.deals.write': 'Create and update HubSpot deals',
'crm.objects.owners.read': 'Read HubSpot object owners',
'crm.objects.users.read': 'Read HubSpot users',
'crm.objects.users.write': 'Create and update HubSpot users',
'crm.objects.marketing_events.read': 'Read HubSpot marketing events',
'crm.objects.marketing_events.write': 'Create and update HubSpot marketing events',
'crm.objects.line_items.read': 'Read HubSpot line items',
'crm.objects.line_items.write': 'Create and update HubSpot line items',
'crm.objects.quotes.read': 'Read HubSpot quotes',
'crm.objects.quotes.write': 'Create and update HubSpot quotes',
'crm.objects.appointments.read': 'Read HubSpot appointments',
'crm.objects.appointments.write': 'Create and update HubSpot appointments',
'crm.objects.carts.read': 'Read HubSpot shopping carts',
'crm.objects.carts.write': 'Create and update HubSpot shopping carts',
'crm.import': 'Import data into HubSpot',
'crm.lists.read': 'Read HubSpot lists',
'crm.lists.write': 'Create and update HubSpot lists',
tickets: 'Manage HubSpot tickets',
api: 'Access Salesforce API',
refresh_token: 'Maintain long-term access to Salesforce account',
default: 'Access Asana workspace',
base: 'Basic access to Pipedrive account',
'deals:read': 'Read Pipedrive deals',
'deals:full': 'Full access to manage Pipedrive deals',
'contacts:read': 'Read Pipedrive contacts',
'contacts:full': 'Full access to manage Pipedrive contacts',
'leads:read': 'Read Pipedrive leads',
'leads:full': 'Full access to manage Pipedrive leads',
'activities:read': 'Read Pipedrive activities',
'activities:full': 'Full access to manage Pipedrive activities',
'mail:read': 'Read Pipedrive emails',
'mail:full': 'Full access to manage Pipedrive emails',
'projects:read': 'Read Pipedrive projects',
'projects:full': 'Full access to manage Pipedrive projects',
'webhooks:read': 'Read Pipedrive webhooks',
'webhooks:full': 'Full access to manage Pipedrive webhooks',
w_member_social: 'Access LinkedIn profile',
// Box scopes
root_readwrite: 'Read and write all files and folders in Box account',
root_readonly: 'Read all files and folders in Box account',
// Shopify scopes (write_* implicitly includes read access)
write_products: 'Read and manage Shopify products',
write_orders: 'Read and manage Shopify orders',
write_customers: 'Read and manage Shopify customers',
write_inventory: 'Read and manage Shopify inventory levels',
read_locations: 'View store locations',
write_merchant_managed_fulfillment_orders: 'Create fulfillments for orders',
// Zoom scopes
'user:read:user': 'View Zoom profile information',
'meeting:write:meeting': 'Create Zoom meetings',
'meeting:read:meeting': 'View Zoom meeting details',
'meeting:read:list_meetings': 'List Zoom meetings',
'meeting:update:meeting': 'Update Zoom meetings',
'meeting:delete:meeting': 'Delete Zoom meetings',
'meeting:read:invitation': 'View Zoom meeting invitations',
'meeting:read:list_past_participants': 'View past meeting participants',
'cloud_recording:read:list_user_recordings': 'List Zoom cloud recordings',
'cloud_recording:read:list_recording_files': 'View recording files',
'cloud_recording:delete:recording_file': 'Delete cloud recordings',
// Dropbox scopes
'account_info.read': 'View Dropbox account information',
'files.metadata.read': 'View file and folder names, sizes, and dates',
'files.metadata.write': 'Modify file and folder metadata',
'files.content.read': 'Download and read Dropbox files',
'files.content.write': 'Upload, copy, move, and delete files in Dropbox',
'sharing.read': 'View shared files and folders',
'sharing.write': 'Share files and folders with others',
// WordPress.com scopes
global: 'Full access to manage WordPress.com sites, posts, pages, media, and settings',
// Spotify scopes
'user-read-private': 'View Spotify account details',
'user-read-email': 'View email address on Spotify',
'user-library-read': 'View saved tracks and albums',
'user-library-modify': 'Save and remove tracks and albums from library',
'playlist-read-private': 'View private playlists',
'playlist-read-collaborative': 'View collaborative playlists',
'playlist-modify-public': 'Create and manage public playlists',
'playlist-modify-private': 'Create and manage private playlists',
'user-read-playback-state': 'View current playback state',
'user-modify-playback-state': 'Control playback on Spotify devices',
'user-read-currently-playing': 'View currently playing track',
'user-read-recently-played': 'View recently played tracks',
'user-top-read': 'View top artists and tracks',
'user-follow-read': 'View followed artists and users',
'user-follow-modify': 'Follow and unfollow artists and users',
'user-read-playback-position': 'View playback position in podcasts',
'ugc-image-upload': 'Upload images to Spotify playlists',
// Attio
'record_permission:read-write': 'Read and write CRM records',
'object_configuration:read-write': 'Read and manage object schemas',
'list_configuration:read-write': 'Read and manage list configurations',
'list_entry:read-write': 'Read and write list entries',
'note:read-write': 'Read and write notes',
'task:read-write': 'Read and write tasks',
'comment:read-write': 'Read and write comments and threads',
'user_management:read': 'View workspace members',
'webhook:read-write': 'Manage webhooks',
}
function getScopeDescription(scope: string): string {
return SCOPE_DESCRIPTIONS[scope] || scope
}
export function OAuthRequiredModal({
isOpen,
onClose,

View File

@@ -15,6 +15,7 @@ import {
type OAuthProvider,
parseProvider,
} from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
@@ -25,7 +26,6 @@ import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useOrganizations } from '@/hooks/queries/organization'
import { useSubscriptionData } from '@/hooks/queries/subscription'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const isBillingEnabled = isTruthy(getEnv('NEXT_PUBLIC_BILLING_ENABLED'))

View File

@@ -12,10 +12,10 @@ import {
type OAuthService,
parseProvider,
} from '@/lib/oauth'
import { getMissingRequiredScopes } from '@/lib/oauth/utils'
import { OAuthRequiredModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/credential-selector/components/oauth-required-modal'
import { useOAuthCredentials } from '@/hooks/queries/oauth-credentials'
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
import { getMissingRequiredScopes } from '@/hooks/use-oauth-scope-status'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const getProviderIcon = (providerName: OAuthProvider) => {

View File

@@ -2,9 +2,11 @@
import { useMemo } from 'react'
import { useParams } from 'next/navigation'
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
import type { SubBlockConfig } from '@/blocks/types'
import { isEnvVarReference, isReference } from '@/executor/constants'
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import { useEnvironmentStore } from '@/stores/settings/environment'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useDependsOnGate } from './use-depends-on-gate'
@@ -13,8 +15,7 @@ import { useDependsOnGate } from './use-depends-on-gate'
*
* Builds a `SelectorContext` by mapping each `dependsOn` entry through the
* canonical index to its `canonicalParamId`, which maps directly to
* `SelectorContext` field names (e.g. `siteId`, `teamId`, `collectionId`).
* The one special case is `oauthCredential` which maps to `credentialId`.
* `SelectorContext` field names (e.g. `siteId`, `teamId`, `oauthCredential`).
*
* @param blockId - The block containing the selector sub-block
* @param subBlock - The sub-block config (must have `selectorKey` set)
@@ -30,57 +31,58 @@ export function useSelectorSetup(
const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId)
const workflowId = (params?.workflowId as string) || activeWorkflowId || ''
const envVariables = useEnvironmentStore((s) => s.variables)
const { finalDisabled, dependencyValues, canonicalIndex } = useDependsOnGate(
blockId,
subBlock,
opts
)
const resolvedDependencyValues = useMemo(() => {
const resolved: Record<string, unknown> = {}
for (const [key, value] of Object.entries(dependencyValues)) {
if (value === null || value === undefined) {
resolved[key] = value
continue
}
const str = String(value)
if (isEnvVarReference(str)) {
const varName = extractEnvVarName(str)
resolved[key] = envVariables[varName]?.value || undefined
} else {
resolved[key] = value
}
}
return resolved
}, [dependencyValues, envVariables])
const selectorContext = useMemo<SelectorContext>(() => {
const context: SelectorContext = {
workflowId,
mimeType: subBlock.mimeType,
}
for (const [depKey, value] of Object.entries(dependencyValues)) {
for (const [depKey, value] of Object.entries(resolvedDependencyValues)) {
if (value === null || value === undefined) continue
const strValue = String(value)
if (!strValue) continue
if (isReference(strValue) || isEnvVarReference(strValue)) continue
if (isReference(strValue)) continue
const canonicalParamId = canonicalIndex.canonicalIdBySubBlockId[depKey] ?? depKey
if (canonicalParamId === 'oauthCredential') {
context.credentialId = strValue
} else if (canonicalParamId in CONTEXT_FIELD_SET) {
;(context as Record<string, unknown>)[canonicalParamId] = strValue
if (SELECTOR_CONTEXT_FIELDS.has(canonicalParamId as keyof SelectorContext)) {
context[canonicalParamId as keyof SelectorContext] = strValue
}
}
return context
}, [dependencyValues, canonicalIndex, workflowId, subBlock.mimeType])
}, [resolvedDependencyValues, canonicalIndex, workflowId, subBlock.mimeType])
return {
selectorKey: (subBlock.selectorKey ?? null) as SelectorKey | null,
selectorContext,
allowSearch: subBlock.selectorAllowSearch ?? true,
disabled: finalDisabled || !subBlock.selectorKey,
dependencyValues,
dependencyValues: resolvedDependencyValues,
}
}
const CONTEXT_FIELD_SET: Record<string, true> = {
credentialId: true,
domain: true,
teamId: true,
projectId: true,
knowledgeBaseId: true,
planId: true,
siteId: true,
collectionId: true,
spreadsheetId: true,
fileId: true,
baseId: true,
datasetId: true,
serviceDeskId: true,
}

View File

@@ -57,9 +57,9 @@ import { useWebhookManagement } from '@/hooks/use-webhook-management'
const SLACK_OVERRIDES: SelectorOverrides = {
transformContext: (context, deps) => {
const authMethod = deps.authMethod as string
const credentialId =
const oauthCredential =
authMethod === 'bot_token' ? String(deps.botToken ?? '') : String(deps.credential ?? '')
return { ...context, credentialId }
return { ...context, oauthCredential }
},
}

View File

@@ -578,7 +578,7 @@ const SubBlockRow = memo(function SubBlockRow({
subBlock,
value: rawValue,
workflowId,
credentialId: typeof credentialId === 'string' ? credentialId : undefined,
oauthCredential: typeof credentialId === 'string' ? credentialId : undefined,
knowledgeBaseId: typeof knowledgeBaseId === 'string' ? knowledgeBaseId : undefined,
domain: domainValue,
teamId: teamIdValue,

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { AgentIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { getApiKeyCondition, getModelOptions } from '@/blocks/utils'
@@ -128,7 +129,7 @@ Return ONLY the JSON array.`,
serviceId: 'vertex-ai',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
requiredScopes: getScopesForService('vertex-ai'),
placeholder: 'Select Google Cloud account',
required: true,
condition: {

View File

@@ -1,4 +1,5 @@
import { AirtableIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { AirtableResponse } from '@/tools/airtable/types'
@@ -38,13 +39,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'airtable',
requiredScopes: [
'data.records:read',
'data.records:write',
'schema.bases:read',
'user.email:read',
'webhook:manage',
],
requiredScopes: getScopesForService('airtable'),
placeholder: 'Select Airtable account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { AsanaIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { AsanaResponse } from '@/tools/asana/types'
@@ -36,7 +37,7 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
mode: 'basic',
required: true,
serviceId: 'asana',
requiredScopes: ['default'],
requiredScopes: getScopesForService('asana'),
placeholder: 'Select Asana account',
},
{

View File

@@ -1,4 +1,5 @@
import { ConfluenceIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -55,36 +56,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence',
requiredScopes: [
'read:confluence-content.all',
'read:confluence-space.summary',
'read:space:confluence',
'read:space-details:confluence',
'write:confluence-content',
'write:confluence-space',
'write:confluence-file',
'read:content:confluence',
'read:page:confluence',
'write:page:confluence',
'read:comment:confluence',
'write:comment:confluence',
'delete:comment:confluence',
'read:attachment:confluence',
'write:attachment:confluence',
'delete:attachment:confluence',
'delete:page:confluence',
'read:label:confluence',
'write:label:confluence',
'search:confluence',
'read:me',
'offline_access',
'read:blogpost:confluence',
'write:blogpost:confluence',
'read:content.property:confluence',
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
],
requiredScopes: getScopesForService('confluence'),
placeholder: 'Select Confluence account',
required: true,
},
@@ -463,45 +435,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence',
requiredScopes: [
'read:confluence-content.all',
'read:confluence-space.summary',
'read:space:confluence',
'read:space-details:confluence',
'write:confluence-content',
'write:confluence-space',
'write:confluence-file',
'read:content:confluence',
'read:page:confluence',
'write:page:confluence',
'read:comment:confluence',
'write:comment:confluence',
'delete:comment:confluence',
'read:attachment:confluence',
'write:attachment:confluence',
'delete:attachment:confluence',
'delete:page:confluence',
'read:label:confluence',
'write:label:confluence',
'search:confluence',
'read:me',
'offline_access',
'read:blogpost:confluence',
'write:blogpost:confluence',
'read:content.property:confluence',
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
'read:user:confluence',
'read:task:confluence',
'write:task:confluence',
'delete:blogpost:confluence',
'write:space:confluence',
'delete:space:confluence',
'read:space.property:confluence',
'write:space.property:confluence',
'read:space.permission:confluence',
],
requiredScopes: getScopesForService('confluence'),
placeholder: 'Select Confluence account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { DropboxIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -41,15 +42,7 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'dropbox',
requiredScopes: [
'account_info.read',
'files.metadata.read',
'files.metadata.write',
'files.content.read',
'files.content.write',
'sharing.read',
'sharing.write',
],
requiredScopes: getScopesForService('dropbox'),
placeholder: 'Select Dropbox account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { GmailIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { createVersionedToolSelector, normalizeFileInput } from '@/blocks/utils'
@@ -79,11 +80,7 @@ export const GmailBlock: BlockConfig<GmailToolResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'gmail',
requiredScopes: [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.labels',
],
requiredScopes: getScopesForService('gmail'),
placeholder: 'Select Gmail account',
required: true,
},
@@ -222,7 +219,7 @@ Return ONLY the email body - no explanations, no extra text.`,
canonicalParamId: 'folder',
serviceId: 'gmail',
selectorKey: 'gmail.labels',
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
requiredScopes: getScopesForService('gmail'),
placeholder: 'Select Gmail label/folder',
dependsOn: ['credential'],
mode: 'basic',
@@ -303,7 +300,7 @@ Return ONLY the search query - no explanations, no extra text.`,
canonicalParamId: 'addLabelIds',
serviceId: 'gmail',
selectorKey: 'gmail.labels',
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
requiredScopes: getScopesForService('gmail'),
placeholder: 'Select destination label',
dependsOn: ['credential'],
mode: 'basic',
@@ -329,7 +326,7 @@ Return ONLY the search query - no explanations, no extra text.`,
canonicalParamId: 'removeLabelIds',
serviceId: 'gmail',
selectorKey: 'gmail.labels',
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
requiredScopes: getScopesForService('gmail'),
placeholder: 'Select label to remove',
dependsOn: ['credential'],
mode: 'basic',
@@ -382,7 +379,7 @@ Return ONLY the search query - no explanations, no extra text.`,
canonicalParamId: 'manageLabelId',
serviceId: 'gmail',
selectorKey: 'gmail.labels',
requiredScopes: ['https://www.googleapis.com/auth/gmail.labels'],
requiredScopes: getScopesForService('gmail'),
placeholder: 'Select label',
dependsOn: ['credential'],
mode: 'basic',

View File

@@ -1,4 +1,5 @@
import { GoogleBigQueryIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
@@ -36,7 +37,7 @@ export const GoogleBigQueryBlock: BlockConfig = {
mode: 'basic',
required: true,
serviceId: 'google-bigquery',
requiredScopes: ['https://www.googleapis.com/auth/bigquery'],
requiredScopes: getScopesForService('google-bigquery'),
placeholder: 'Select Google account',
},
{

View File

@@ -1,4 +1,5 @@
import { GoogleCalendarIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
@@ -43,7 +44,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-calendar',
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
requiredScopes: getScopesForService('google-calendar'),
placeholder: 'Select Google Calendar account',
},
{
@@ -64,7 +65,7 @@ export const GoogleCalendarBlock: BlockConfig<GoogleCalendarResponse> = {
serviceId: 'google-calendar',
selectorKey: 'google.calendar',
selectorAllowSearch: false,
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
requiredScopes: getScopesForService('google-calendar'),
placeholder: 'Select calendar',
dependsOn: ['credential'],
mode: 'basic',
@@ -330,7 +331,7 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
serviceId: 'google-calendar',
selectorKey: 'google.calendar',
selectorAllowSearch: false,
requiredScopes: ['https://www.googleapis.com/auth/calendar'],
requiredScopes: getScopesForService('google-calendar'),
placeholder: 'Select destination calendar',
dependsOn: ['credential'],
condition: { field: 'operation', value: 'move' },

View File

@@ -1,4 +1,5 @@
import { GoogleContactsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { GoogleContactsResponse } from '@/tools/google_contacts/types'
@@ -37,7 +38,7 @@ export const GoogleContactsBlock: BlockConfig<GoogleContactsResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-contacts',
requiredScopes: ['https://www.googleapis.com/auth/contacts'],
requiredScopes: getScopesForService('google-contacts'),
placeholder: 'Select Google account',
},
{

View File

@@ -1,4 +1,5 @@
import { GoogleDocsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { GoogleDocsResponse } from '@/tools/google_docs/types'
@@ -36,10 +37,7 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-docs',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-docs'),
placeholder: 'Select Google account',
},
{

View File

@@ -1,4 +1,5 @@
import { GoogleDriveIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -48,10 +49,7 @@ export const GoogleDriveBlock: BlockConfig<GoogleDriveResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
placeholder: 'Select Google Drive account',
},
{
@@ -138,10 +136,7 @@ Return ONLY the file content - no explanations, no markdown code blocks, no extr
canonicalParamId: 'uploadFolderId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a parent folder',
mode: 'basic',
@@ -211,10 +206,7 @@ Return ONLY the file content - no explanations, no markdown code blocks, no extr
canonicalParamId: 'createFolderParentId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a parent folder',
mode: 'basic',
@@ -239,10 +231,7 @@ Return ONLY the file content - no explanations, no markdown code blocks, no extr
canonicalParamId: 'listFolderId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select a folder to list files from',
mode: 'basic',
@@ -299,10 +288,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
canonicalParamId: 'downloadFileId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
placeholder: 'Select a file to download',
mode: 'basic',
dependsOn: ['credential'],
@@ -361,10 +347,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
canonicalParamId: 'getFileId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
placeholder: 'Select a file to get info for',
mode: 'basic',
dependsOn: ['credential'],
@@ -389,10 +372,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
canonicalParamId: 'copyFileId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
placeholder: 'Select a file to copy',
mode: 'basic',
dependsOn: ['credential'],
@@ -423,10 +403,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
canonicalParamId: 'copyDestFolderId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
mimeType: 'application/vnd.google-apps.folder',
placeholder: 'Select destination folder (optional)',
mode: 'basic',
@@ -450,10 +427,7 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
canonicalParamId: 'updateFileId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
placeholder: 'Select a file to update',
mode: 'basic',
dependsOn: ['credential'],
@@ -529,10 +503,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
canonicalParamId: 'trashFileId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
placeholder: 'Select a file to move to trash',
mode: 'basic',
dependsOn: ['credential'],
@@ -557,10 +528,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
canonicalParamId: 'deleteFileId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
placeholder: 'Select a file to permanently delete',
mode: 'basic',
dependsOn: ['credential'],
@@ -585,10 +553,7 @@ Return ONLY the description text - no explanations, no quotes, no extra text.`,
canonicalParamId: 'shareFileId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
placeholder: 'Select a file to share',
mode: 'basic',
dependsOn: ['credential'],
@@ -700,10 +665,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
canonicalParamId: 'unshareFileId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
placeholder: 'Select a file to remove sharing from',
mode: 'basic',
dependsOn: ['credential'],
@@ -736,10 +698,7 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
canonicalParamId: 'listPermissionsFileId',
serviceId: 'google-drive',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
placeholder: 'Select a file to list permissions for',
mode: 'basic',
dependsOn: ['credential'],

View File

@@ -1,4 +1,5 @@
import { GoogleFormsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { getTrigger } from '@/triggers'
@@ -38,13 +39,7 @@ export const GoogleFormsBlock: BlockConfig = {
mode: 'basic',
required: true,
serviceId: 'google-forms',
requiredScopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/forms.body',
'https://www.googleapis.com/auth/forms.responses.readonly',
],
requiredScopes: getScopesForService('google-forms'),
placeholder: 'Select Google account',
},
{

View File

@@ -1,4 +1,5 @@
import { GoogleGroupsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
@@ -46,10 +47,7 @@ export const GoogleGroupsBlock: BlockConfig = {
mode: 'basic',
required: true,
serviceId: 'google-groups',
requiredScopes: [
'https://www.googleapis.com/auth/admin.directory.group',
'https://www.googleapis.com/auth/admin.directory.group.member',
],
requiredScopes: getScopesForService('google-groups'),
placeholder: 'Select Google Workspace account',
},
{

View File

@@ -1,4 +1,5 @@
import { GoogleMeetIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { GoogleMeetResponse } from '@/tools/google_meet/types'
@@ -37,10 +38,7 @@ export const GoogleMeetBlock: BlockConfig<GoogleMeetResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-meet',
requiredScopes: [
'https://www.googleapis.com/auth/meetings.space.created',
'https://www.googleapis.com/auth/meetings.space.readonly',
],
requiredScopes: getScopesForService('google-meet'),
placeholder: 'Select Google Meet account',
},
{

View File

@@ -1,4 +1,5 @@
import { GoogleSheetsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
@@ -40,10 +41,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-sheets',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-sheets'),
placeholder: 'Select Google account',
},
{
@@ -63,10 +61,7 @@ export const GoogleSheetsBlock: BlockConfig<GoogleSheetsResponse> = {
canonicalParamId: 'spreadsheetId',
serviceId: 'google-sheets',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-sheets'),
mimeType: 'application/vnd.google-apps.spreadsheet',
placeholder: 'Select a spreadsheet',
dependsOn: ['credential'],
@@ -339,10 +334,7 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
mode: 'basic',
required: true,
serviceId: 'google-sheets',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-sheets'),
placeholder: 'Select Google account',
},
{
@@ -362,10 +354,7 @@ export const GoogleSheetsV2Block: BlockConfig<GoogleSheetsV2Response> = {
canonicalParamId: 'spreadsheetId',
serviceId: 'google-sheets',
selectorKey: 'google.drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-sheets'),
mimeType: 'application/vnd.google-apps.spreadsheet',
placeholder: 'Select a spreadsheet',
dependsOn: ['credential'],

View File

@@ -1,4 +1,5 @@
import { GoogleSlidesIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import { resolveHttpsUrlFromFileInput } from '@/lib/uploads/utils/file-utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
@@ -50,10 +51,7 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-drive',
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
requiredScopes: getScopesForService('google-drive'),
placeholder: 'Select Google account',
},
{

View File

@@ -1,4 +1,5 @@
import { GoogleTasksIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { GoogleTasksResponse } from '@/tools/google_tasks/types'
@@ -38,7 +39,7 @@ export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-tasks',
requiredScopes: ['https://www.googleapis.com/auth/tasks'],
requiredScopes: getScopesForService('google-tasks'),
placeholder: 'Select Google Tasks account',
},
{

View File

@@ -1,4 +1,5 @@
import { GoogleVaultIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
@@ -38,10 +39,7 @@ export const GoogleVaultBlock: BlockConfig = {
mode: 'basic',
required: true,
serviceId: 'google-vault',
requiredScopes: [
'https://www.googleapis.com/auth/ediscovery',
'https://www.googleapis.com/auth/devstorage.read_only',
],
requiredScopes: getScopesForService('google-vault'),
placeholder: 'Select Google Vault account',
},
{

View File

@@ -1,4 +1,5 @@
import { HubspotIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { HubSpotResponse } from '@/tools/hubspot/types'
@@ -42,31 +43,7 @@ export const HubSpotBlock: BlockConfig<HubSpotResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'hubspot',
requiredScopes: [
'crm.objects.contacts.read',
'crm.objects.contacts.write',
'crm.objects.companies.read',
'crm.objects.companies.write',
'crm.objects.deals.read',
'crm.objects.deals.write',
'crm.objects.owners.read',
'crm.objects.users.read',
'crm.objects.users.write',
'crm.objects.marketing_events.read',
'crm.objects.marketing_events.write',
'crm.objects.line_items.read',
'crm.objects.line_items.write',
'crm.objects.quotes.read',
'crm.objects.quotes.write',
'crm.objects.appointments.read',
'crm.objects.appointments.write',
'crm.objects.carts.read',
'crm.objects.carts.write',
'crm.import',
'crm.lists.read',
'crm.lists.write',
'tickets',
],
requiredScopes: getScopesForService('hubspot'),
placeholder: 'Select HubSpot account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { JiraIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -64,38 +65,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
mode: 'basic',
required: true,
serviceId: 'jira',
requiredScopes: [
'read:jira-work',
'read:jira-user',
'write:jira-work',
'read:issue-event:jira',
'write:issue:jira',
'read:project:jira',
'read:issue-type:jira',
'read:me',
'offline_access',
'read:issue-meta:jira',
'read:issue-security-level:jira',
'read:issue.vote:jira',
'read:issue.changelog:jira',
'read:avatar:jira',
'read:issue:jira',
'read:status:jira',
'read:user:jira',
'read:field-configuration:jira',
'read:issue-details:jira',
'delete:issue:jira',
'write:comment:jira',
'read:comment:jira',
'delete:comment:jira',
'read:attachment:jira',
'delete:attachment:jira',
'write:issue-worklog:jira',
'read:issue-worklog:jira',
'delete:issue-worklog:jira',
'write:issue-link:jira',
'delete:issue-link:jira',
],
requiredScopes: getScopesForService('jira'),
placeholder: 'Select Jira account',
},
{

View File

@@ -1,4 +1,5 @@
import { JiraServiceManagementIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { JsmResponse } from '@/tools/jsm/types'
@@ -59,42 +60,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
mode: 'basic',
required: true,
serviceId: 'jira',
requiredScopes: [
'read:jira-user',
'read:jira-work',
'write:jira-work',
'read:project:jira',
'read:me',
'offline_access',
'read:issue:jira',
'read:status:jira',
'read:user:jira',
'read:issue-details:jira',
'write:comment:jira',
'read:comment:jira',
'read:servicedesk:jira-service-management',
'read:requesttype:jira-service-management',
'read:request:jira-service-management',
'write:request:jira-service-management',
'read:request.comment:jira-service-management',
'write:request.comment:jira-service-management',
'read:customer:jira-service-management',
'write:customer:jira-service-management',
'read:servicedesk.customer:jira-service-management',
'write:servicedesk.customer:jira-service-management',
'read:organization:jira-service-management',
'write:organization:jira-service-management',
'read:servicedesk.organization:jira-service-management',
'write:servicedesk.organization:jira-service-management',
'read:queue:jira-service-management',
'read:request.sla:jira-service-management',
'read:request.status:jira-service-management',
'write:request.status:jira-service-management',
'read:request.participant:jira-service-management',
'write:request.participant:jira-service-management',
'read:request.approval:jira-service-management',
'write:request.approval:jira-service-management',
],
requiredScopes: getScopesForService('jira'),
placeholder: 'Select Jira account',
},
{

View File

@@ -1,4 +1,5 @@
import { LinearIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -132,7 +133,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'linear',
requiredScopes: ['read', 'write'],
requiredScopes: getScopesForService('linear'),
placeholder: 'Select Linear account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { LinkedInIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { LinkedInResponse } from '@/tools/linkedin/types'
@@ -35,7 +36,7 @@ export const LinkedInBlock: BlockConfig<LinkedInResponse> = {
serviceId: 'linkedin',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['profile', 'openid', 'email', 'w_member_social'],
requiredScopes: getScopesForService('linkedin'),
placeholder: 'Select LinkedIn account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { MicrosoftDataverseIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -46,13 +47,7 @@ export const MicrosoftDataverseBlock: BlockConfig<DataverseResponse> = {
title: 'Microsoft Account',
type: 'oauth-input',
serviceId: 'microsoft-dataverse',
requiredScopes: [
'openid',
'profile',
'email',
'https://dynamics.microsoft.com/user_impersonation',
'offline_access',
],
requiredScopes: getScopesForService('microsoft-dataverse'),
placeholder: 'Select Microsoft account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { MicrosoftExcelIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { createVersionedToolSelector } from '@/blocks/utils'
@@ -39,14 +40,7 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-excel',
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('microsoft-excel'),
placeholder: 'Select Microsoft account',
required: true,
},
@@ -366,14 +360,7 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-excel',
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('microsoft-excel'),
placeholder: 'Select Microsoft account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { MicrosoftPlannerIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { MicrosoftPlannerResponse } from '@/tools/microsoft_planner/types'
@@ -64,15 +65,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-planner',
requiredScopes: [
'openid',
'profile',
'email',
'Group.ReadWrite.All',
'Group.Read.All',
'Tasks.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('microsoft-planner'),
placeholder: 'Select Microsoft account',
},
{

View File

@@ -1,4 +1,5 @@
import { MicrosoftTeamsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -47,28 +48,7 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-teams',
requiredScopes: [
'openid',
'profile',
'email',
'User.Read',
'Chat.Read',
'Chat.ReadWrite',
'Chat.ReadBasic',
'ChatMessage.Send',
'Channel.ReadBasic.All',
'ChannelMessage.Send',
'ChannelMessage.Read.All',
'ChannelMessage.ReadWrite',
'ChannelMember.Read.All',
'Group.Read.All',
'Group.ReadWrite.All',
'Team.ReadBasic.All',
'TeamMember.Read.All',
'offline_access',
'Files.Read',
'Sites.Read.All',
],
requiredScopes: getScopesForService('microsoft-teams'),
placeholder: 'Select Microsoft account',
required: true,
},

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { MicrosoftOneDriveIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -42,14 +43,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'onedrive',
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('onedrive'),
placeholder: 'Select Microsoft account',
},
{
@@ -156,14 +150,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
canonicalParamId: 'uploadFolderId',
serviceId: 'onedrive',
selectorKey: 'onedrive.folders',
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('onedrive'),
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a parent folder',
dependsOn: ['credential'],
@@ -194,14 +181,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
canonicalParamId: 'createFolderParentId',
serviceId: 'onedrive',
selectorKey: 'onedrive.folders',
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('onedrive'),
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a parent folder',
dependsOn: ['credential'],
@@ -227,14 +207,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
canonicalParamId: 'listFolderId',
serviceId: 'onedrive',
selectorKey: 'onedrive.folders',
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('onedrive'),
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a folder to list files from',
dependsOn: ['credential'],
@@ -274,14 +247,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
canonicalParamId: 'downloadFileId',
serviceId: 'onedrive',
selectorKey: 'onedrive.files',
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('onedrive'),
mimeType: 'file', // Exclude folders, show only files
placeholder: 'Select a file to download',
mode: 'basic',
@@ -315,14 +281,7 @@ export const OneDriveBlock: BlockConfig<OneDriveResponse> = {
canonicalParamId: 'deleteFileId',
serviceId: 'onedrive',
selectorKey: 'onedrive.files',
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('onedrive'),
mimeType: 'file', // Exclude folders, show only files
placeholder: 'Select a file to delete',
mode: 'basic',

View File

@@ -1,4 +1,5 @@
import { OutlookIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -42,16 +43,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'outlook',
requiredScopes: [
'Mail.ReadWrite',
'Mail.ReadBasic',
'Mail.Read',
'Mail.Send',
'offline_access',
'openid',
'profile',
'email',
],
requiredScopes: getScopesForService('outlook'),
placeholder: 'Select Microsoft account',
required: true,
},
@@ -188,7 +180,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
canonicalParamId: 'folder',
serviceId: 'outlook',
selectorKey: 'outlook.folders',
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
requiredScopes: getScopesForService('outlook'),
placeholder: 'Select Outlook folder',
dependsOn: ['credential'],
mode: 'basic',
@@ -234,7 +226,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
canonicalParamId: 'destinationId',
serviceId: 'outlook',
selectorKey: 'outlook.folders',
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
requiredScopes: getScopesForService('outlook'),
placeholder: 'Select destination folder',
dependsOn: ['credential'],
mode: 'basic',
@@ -281,7 +273,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
canonicalParamId: 'copyDestinationId',
serviceId: 'outlook',
selectorKey: 'outlook.folders',
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
requiredScopes: getScopesForService('outlook'),
placeholder: 'Select destination folder',
dependsOn: ['credential'],
mode: 'basic',

View File

@@ -1,4 +1,5 @@
import { PipedriveIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { PipedriveResponse } from '@/tools/pipedrive/types'
@@ -48,15 +49,7 @@ export const PipedriveBlock: BlockConfig<PipedriveResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'pipedrive',
requiredScopes: [
'base',
'deals:full',
'contacts:full',
'leads:full',
'activities:full',
'mail:full',
'projects:full',
],
requiredScopes: getScopesForService('pipedrive'),
placeholder: 'Select Pipedrive account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { RedditIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { RedditResponse } from '@/tools/reddit/types'
@@ -49,24 +50,7 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
serviceId: 'reddit',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: [
'identity',
'read',
'submit',
'vote',
'save',
'edit',
'subscribe',
'history',
'privatemessages',
'account',
'mysubreddits',
'flair',
'report',
'modposts',
'modflair',
'modmail',
],
requiredScopes: getScopesForService('reddit'),
placeholder: 'Select Reddit account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { SalesforceIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { SalesforceResponse } from '@/tools/salesforce/types'
@@ -65,7 +66,7 @@ export const SalesforceBlock: BlockConfig<SalesforceResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'salesforce',
requiredScopes: ['api', 'refresh_token', 'openid', 'offline_access'],
requiredScopes: getScopesForService('salesforce'),
placeholder: 'Select Salesforce account',
required: true,
},

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { MicrosoftSharepointIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -41,15 +42,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'sharepoint',
requiredScopes: [
'openid',
'profile',
'email',
'Sites.Read.All',
'Sites.ReadWrite.All',
'Sites.Manage.All',
'offline_access',
],
requiredScopes: getScopesForService('sharepoint'),
placeholder: 'Select Microsoft account',
},
{
@@ -68,14 +61,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
canonicalParamId: 'siteId',
serviceId: 'sharepoint',
selectorKey: 'sharepoint.sites',
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('sharepoint'),
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a site',
dependsOn: ['credential'],

View File

@@ -1,4 +1,5 @@
import { ShopifyIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
@@ -63,14 +64,7 @@ export const ShopifyBlock: BlockConfig<ShopifyResponse> = {
serviceId: 'shopify',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: [
'write_products',
'write_orders',
'write_customers',
'write_inventory',
'read_locations',
'write_merchant_managed_fulfillment_orders',
],
requiredScopes: getScopesForService('shopify'),
placeholder: 'Select Shopify account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { SlackIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -82,22 +83,7 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'slack',
requiredScopes: [
'channels:read',
'channels:history',
'groups:read',
'groups:history',
'chat:write',
'chat:write.public',
'im:write',
'im:history',
'im:read',
'users:read',
'files:write',
'files:read',
'canvases:write',
'reactions:write',
],
requiredScopes: getScopesForService('slack'),
placeholder: 'Select Slack workspace',
dependsOn: ['authMethod'],
condition: {

View File

@@ -1,4 +1,5 @@
import { TrelloIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { ToolResponse } from '@/tools/types'
@@ -44,7 +45,7 @@ export const TrelloBlock: BlockConfig<ToolResponse> = {
serviceId: 'trello',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['read', 'write'],
requiredScopes: getScopesForService('trello'),
placeholder: 'Select Trello account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { WealthboxIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { WealthboxResponse } from '@/tools/wealthbox/types'
@@ -36,7 +37,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'wealthbox',
requiredScopes: ['login', 'data'],
requiredScopes: getScopesForService('wealthbox'),
placeholder: 'Select Wealthbox account',
required: true,
},
@@ -62,7 +63,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
type: 'file-selector',
serviceId: 'wealthbox',
selectorKey: 'wealthbox.contacts',
requiredScopes: ['login', 'data'],
requiredScopes: getScopesForService('wealthbox'),
placeholder: 'Enter Contact ID',
mode: 'basic',
canonicalParamId: 'contactId',

View File

@@ -1,4 +1,5 @@
import { WebflowIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { WebflowResponse } from '@/tools/webflow/types'
@@ -37,7 +38,7 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'webflow',
requiredScopes: ['sites:read', 'sites:write', 'cms:read', 'cms:write'],
requiredScopes: getScopesForService('webflow'),
placeholder: 'Select Webflow account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { WordpressIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -68,7 +69,7 @@ export const WordPressBlock: BlockConfig<WordPressResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'wordpress',
requiredScopes: ['global'],
requiredScopes: getScopesForService('wordpress'),
placeholder: 'Select WordPress account',
required: true,
},

View File

@@ -1,4 +1,5 @@
import { xIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
@@ -66,23 +67,7 @@ export const XBlock: BlockConfig = {
serviceId: 'x',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: [
'tweet.read',
'tweet.write',
'tweet.moderate.write',
'users.read',
'follows.read',
'follows.write',
'bookmark.read',
'bookmark.write',
'like.read',
'like.write',
'block.read',
'block.write',
'mute.read',
'mute.write',
'offline.access',
],
requiredScopes: getScopesForService('x'),
placeholder: 'Select X account',
},
{

View File

@@ -1,4 +1,5 @@
import { ZoomIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { ZoomResponse } from '@/tools/zoom/types'
@@ -40,19 +41,7 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
serviceId: 'zoom',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: [
'user:read:user',
'meeting:write:meeting',
'meeting:read:meeting',
'meeting:read:list_meetings',
'meeting:update:meeting',
'meeting:delete:meeting',
'meeting:read:invitation',
'meeting:read:list_past_participants',
'cloud_recording:read:list_user_recordings',
'cloud_recording:read:list_recording_files',
'cloud_recording:delete:recording_file',
],
requiredScopes: getScopesForService('zoom'),
placeholder: 'Select Zoom account',
required: true,
},

View File

@@ -39,10 +39,10 @@ type FolderResponse = { id: string; name: string }
type PlannerTask = { id: string; title: string }
const ensureCredential = (context: SelectorContext, key: SelectorKey): string => {
if (!context.credentialId) {
if (!context.oauthCredential) {
throw new Error(`Missing credential for selector ${key}`)
}
return context.credentialId
return context.oauthCredential
}
const ensureDomain = (context: SelectorContext, key: SelectorKey): string => {
@@ -66,9 +66,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'airtable.bases',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'airtable.bases')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -104,10 +104,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'airtable.tables',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.baseId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.baseId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.baseId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'airtable.tables')
if (!context.baseId) {
@@ -151,9 +151,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'asana.workspaces',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'asana.workspaces')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -182,9 +182,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'attio.objects',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'attio.objects')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -216,9 +216,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'attio.lists',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'attio.lists')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -250,10 +250,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'bigquery.datasets',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.projectId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.projectId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.projectId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'bigquery.datasets')
if (!context.projectId) throw new Error('Missing project ID for bigquery.datasets selector')
@@ -298,12 +298,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'bigquery.tables',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.projectId ?? 'none',
context.datasetId ?? 'none',
],
enabled: ({ context }) =>
Boolean(context.credentialId && context.projectId && context.datasetId),
Boolean(context.oauthCredential && context.projectId && context.datasetId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'bigquery.tables')
if (!context.projectId) throw new Error('Missing project ID for bigquery.tables selector')
@@ -347,9 +347,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'calcom.eventTypes',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'calcom.eventTypes')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -381,9 +381,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'calcom.schedules',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'calcom.schedules')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -415,10 +415,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'confluence.spaces',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'confluence.spaces')
const domain = ensureDomain(context, 'confluence.spaces')
@@ -460,10 +460,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'jsm.serviceDesks',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jsm.serviceDesks')
const domain = ensureDomain(context, 'jsm.serviceDesks')
@@ -505,12 +505,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'jsm.requestTypes',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
context.serviceDeskId ?? 'none',
],
enabled: ({ context }) =>
Boolean(context.credentialId && context.domain && context.serviceDeskId),
Boolean(context.oauthCredential && context.domain && context.serviceDeskId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jsm.requestTypes')
const domain = ensureDomain(context, 'jsm.requestTypes')
@@ -556,9 +556,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'google.tasks.lists',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.tasks.lists')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -587,9 +587,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.planner.plans',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.planner.plans')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -618,9 +618,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'notion.databases',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'notion.databases')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -652,9 +652,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'notion.pages',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'notion.pages')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -686,9 +686,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'pipedrive.pipelines',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'pipedrive.pipelines')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -720,10 +720,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'sharepoint.lists',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.siteId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.siteId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'sharepoint.lists')
if (!context.siteId) throw new Error('Missing site ID for sharepoint.lists selector')
@@ -761,9 +761,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'trello.boards',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'trello.boards')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -794,9 +794,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'zoom.meetings',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'zoom.meetings')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -828,12 +828,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'slack.channels',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({
credential: context.credentialId,
credential: context.oauthCredential,
workflowId: context.workflowId,
})
const data = await fetchJson<{ channels: SlackChannel[] }>('/api/tools/slack/channels', {
@@ -852,12 +852,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'slack.users',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({
credential: context.credentialId,
credential: context.oauthCredential,
workflowId: context.workflowId,
})
const data = await fetchJson<{ users: SlackUser[] }>('/api/tools/slack/users', {
@@ -876,12 +876,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'gmail.labels',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ labels: FolderResponse[] }>('/api/tools/gmail/labels', {
searchParams: { credentialId: context.credentialId },
searchParams: { credentialId: context.oauthCredential },
})
return (data.labels || []).map((label) => ({
id: label.id,
@@ -895,12 +895,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'outlook.folders',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ folders: FolderResponse[] }>('/api/tools/outlook/folders', {
searchParams: { credentialId: context.credentialId },
searchParams: { credentialId: context.oauthCredential },
})
return (data.folders || []).map((folder) => ({
id: folder.id,
@@ -914,13 +914,13 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'google.calendar',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ calendars: { id: string; summary: string }[] }>(
'/api/tools/google_calendar/calendars',
{ searchParams: { credentialId: context.credentialId } }
{ searchParams: { credentialId: context.oauthCredential } }
)
return (data.calendars || []).map((calendar) => ({
id: calendar.id,
@@ -934,11 +934,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.teams',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({ credential: context.credentialId })
const body = JSON.stringify({ credential: context.oauthCredential })
const data = await fetchJson<{ teams: { id: string; displayName: string }[] }>(
'/api/tools/microsoft-teams/teams',
{ method: 'POST', body }
@@ -955,11 +955,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.chats',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({ credential: context.credentialId })
const body = JSON.stringify({ credential: context.oauthCredential })
const data = await fetchJson<{ chats: { id: string; displayName: string }[] }>(
'/api/tools/microsoft-teams/chats',
{ method: 'POST', body }
@@ -976,13 +976,13 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.channels',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.teamId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.teamId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const body = JSON.stringify({
credential: context.credentialId,
credential: context.oauthCredential,
teamId: context.teamId,
})
const data = await fetchJson<{ channels: { id: string; displayName: string }[] }>(
@@ -1001,14 +1001,14 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'wealthbox.contacts',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const data = await fetchJson<{ items: { id: string; name: string }[] }>(
'/api/tools/wealthbox/items',
{
searchParams: { credentialId: context.credentialId, type: 'contact' },
searchParams: { credentialId: context.oauthCredential, type: 'contact' },
}
)
return (data.items || []).map((item) => ({
@@ -1023,9 +1023,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'sharepoint.sites',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'sharepoint.sites')
const body = JSON.stringify({
@@ -1069,10 +1069,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.planner',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.planId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.planId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.planId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.planner')
const body = JSON.stringify({
@@ -1112,11 +1112,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'jira.projects',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
@@ -1171,12 +1171,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'jira.issues',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
context.projectId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
@@ -1235,9 +1235,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'linear.teams',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'linear.teams')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -1260,10 +1260,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'linear.projects',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.teamId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.teamId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.teamId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'linear.projects')
const body = JSON.stringify({
@@ -1290,11 +1290,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'confluence.pages',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.domain ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId && context.domain),
enabled: ({ context }) => Boolean(context.oauthCredential && context.domain),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
@@ -1343,9 +1343,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'onedrive.files',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'onedrive.files')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
@@ -1366,9 +1366,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'onedrive.folders',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'onedrive.folders')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
@@ -1389,12 +1389,12 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'google.drive',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.mimeType ?? 'any',
context.fileId ?? 'root',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.drive')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
@@ -1438,10 +1438,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'google.sheets',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.spreadsheetId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.spreadsheetId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'google.sheets')
if (!context.spreadsheetId) {
@@ -1469,10 +1469,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'microsoft.excel.sheets',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.spreadsheetId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.spreadsheetId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.spreadsheetId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.excel.sheets')
if (!context.spreadsheetId) {
@@ -1500,10 +1500,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'microsoft.excel',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.excel')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
@@ -1528,10 +1528,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'microsoft.word',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'microsoft.word')
const data = await fetchJson<{ files: { id: string; name: string }[] }>(
@@ -1596,9 +1596,9 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'webflow.sites',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId),
enabled: ({ context }) => Boolean(context.oauthCredential),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'webflow.sites')
const body = JSON.stringify({ credential: credentialId, workflowId: context.workflowId })
@@ -1621,10 +1621,10 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context }: SelectorQueryArgs) => [
'selectors',
'webflow.collections',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.siteId ?? 'none',
],
enabled: ({ context }) => Boolean(context.credentialId && context.siteId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.siteId),
fetchList: async ({ context }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'webflow.collections')
if (!context.siteId) {
@@ -1654,11 +1654,11 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
getQueryKey: ({ context, search }: SelectorQueryArgs) => [
'selectors',
'webflow.items',
context.credentialId ?? 'none',
context.oauthCredential ?? 'none',
context.collectionId ?? 'none',
search ?? '',
],
enabled: ({ context }) => Boolean(context.credentialId && context.collectionId),
enabled: ({ context }) => Boolean(context.oauthCredential && context.collectionId),
fetchList: async ({ context, search }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'webflow.items')
if (!context.collectionId) {

View File

@@ -7,46 +7,16 @@ export interface SelectorResolution {
allowSearch: boolean
}
export interface SelectorResolutionArgs {
workflowId?: string
credentialId?: string
domain?: string
projectId?: string
planId?: string
teamId?: string
knowledgeBaseId?: string
siteId?: string
collectionId?: string
spreadsheetId?: string
fileId?: string
baseId?: string
datasetId?: string
serviceDeskId?: string
}
export function resolveSelectorForSubBlock(
subBlock: SubBlockConfig,
args: SelectorResolutionArgs
context: SelectorContext
): SelectorResolution | null {
if (!subBlock.selectorKey) return null
return {
key: subBlock.selectorKey,
context: {
workflowId: args.workflowId,
credentialId: args.credentialId,
domain: args.domain,
projectId: args.projectId,
planId: args.planId,
teamId: args.teamId,
knowledgeBaseId: args.knowledgeBaseId,
siteId: args.siteId,
collectionId: args.collectionId,
spreadsheetId: args.spreadsheetId,
fileId: args.fileId,
baseId: args.baseId,
datasetId: args.datasetId,
serviceDeskId: args.serviceDeskId,
mimeType: subBlock.mimeType,
...context,
mimeType: subBlock.mimeType ?? context.mimeType,
},
allowSearch: subBlock.selectorAllowSearch ?? true,
}

View File

@@ -61,7 +61,7 @@ export interface SelectorOption {
export interface SelectorContext {
workspaceId?: string
workflowId?: string
credentialId?: string
oauthCredential?: string
serviceId?: string
domain?: string
teamId?: string

View File

@@ -1,8 +1,9 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { isEnvVarReference, isReference } from '@/executor/constants'
import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants'
import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry'
import type { SelectorKey, SelectorOption, SelectorQueryArgs } from '@/hooks/selectors/types'
import { useEnvironmentStore } from '@/stores/settings/environment'
interface SelectorHookArgs extends Omit<SelectorQueryArgs, 'key'> {
search?: string
@@ -30,14 +31,25 @@ export function useSelectorOptionDetail(
key: SelectorKey,
args: SelectorHookArgs & { detailId?: string }
) {
const envVariables = useEnvironmentStore((s) => s.variables)
const definition = getSelectorDefinition(key)
const resolvedDetailId = useMemo(() => {
if (!args.detailId) return undefined
if (isReference(args.detailId)) return undefined
if (isEnvVarReference(args.detailId)) {
const varName = extractEnvVarName(args.detailId)
return envVariables[varName]?.value || undefined
}
return args.detailId
}, [args.detailId, envVariables])
const queryArgs: SelectorQueryArgs = {
key,
context: args.context,
detailId: args.detailId,
detailId: resolvedDetailId,
}
const hasRealDetailId =
Boolean(args.detailId) && !isReference(args.detailId!) && !isEnvVarReference(args.detailId!)
const hasRealDetailId = Boolean(resolvedDetailId)
const baseEnabled =
hasRealDetailId && definition.fetchById !== undefined
? definition.enabled
@@ -47,7 +59,7 @@ export function useSelectorOptionDetail(
const enabled = args.enabled ?? baseEnabled
const query = useQuery<SelectorOption | null>({
queryKey: [...definition.getQueryKey(queryArgs), 'detail', args.detailId ?? 'none'],
queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'],
queryFn: () => definition.fetchById!(queryArgs),
enabled,
staleTime: definition.staleTime ?? 300_000,

View File

@@ -1,92 +0,0 @@
'use client'
import type { Credential } from '@/lib/oauth'
export interface OAuthScopeStatus {
requiresReauthorization: boolean
missingScopes: string[]
extraScopes: string[]
canonicalScopes: string[]
grantedScopes: string[]
}
/**
* Extract scope status from a credential
*/
export function getCredentialScopeStatus(credential: Credential): OAuthScopeStatus {
return {
requiresReauthorization: credential.requiresReauthorization || false,
missingScopes: credential.missingScopes || [],
extraScopes: credential.extraScopes || [],
canonicalScopes: credential.canonicalScopes || [],
grantedScopes: credential.scopes || [],
}
}
/**
* Check if a credential needs reauthorization
*/
export function credentialNeedsReauth(credential: Credential): boolean {
return credential.requiresReauthorization || false
}
/**
* Check if any credentials in a list need reauthorization
*/
export function anyCredentialNeedsReauth(credentials: Credential[]): boolean {
return credentials.some(credentialNeedsReauth)
}
/**
* Get all credentials that need reauthorization
*/
export function getCredentialsNeedingReauth(credentials: Credential[]): Credential[] {
return credentials.filter(credentialNeedsReauth)
}
/**
* Scopes that control token behavior but are not returned in OAuth token responses.
* These should be ignored when validating credential scopes.
*/
const IGNORED_SCOPES = new Set([
'offline_access', // Microsoft - requests refresh token
'refresh_token', // Salesforce - requests refresh token
'offline.access', // Airtable - requests refresh token (note: dot not underscore)
])
/**
* Compute which of the provided requiredScopes are NOT granted by the credential.
* Note: Ignores special OAuth scopes that control token behavior (like offline_access)
* as they are not returned in the token response's scope list even when granted.
*/
export function getMissingRequiredScopes(
credential: Credential | undefined,
requiredScopes: string[] = []
): string[] {
if (!credential) {
// Filter out ignored scopes from required scopes as they're not returned by OAuth providers
return requiredScopes.filter((s) => !IGNORED_SCOPES.has(s))
}
const granted = new Set((credential.scopes || []).map((s) => s))
const missing: string[] = []
for (const s of requiredScopes) {
// Skip ignored scopes as providers don't return them in the scope list even when granted
if (IGNORED_SCOPES.has(s)) continue
if (!granted.has(s)) missing.push(s)
}
return missing
}
/**
* Whether a credential needs an upgrade specifically for the provided required scopes.
*/
export function needsUpgradeForRequiredScopes(
credential: Credential | undefined,
requiredScopes: string[] = []
): boolean {
return getMissingRequiredScopes(credential, requiredScopes).length > 0
}

View File

@@ -12,7 +12,7 @@ interface SelectorDisplayNameArgs {
subBlock?: SubBlockConfig
value: unknown
workflowId?: string
credentialId?: string
oauthCredential?: string
domain?: string
projectId?: string
planId?: string
@@ -31,7 +31,7 @@ export function useSelectorDisplayName({
subBlock,
value,
workflowId,
credentialId,
oauthCredential,
domain,
projectId,
planId,
@@ -51,7 +51,7 @@ export function useSelectorDisplayName({
if (!subBlock || !detailId) return null
return resolveSelectorForSubBlock(subBlock, {
workflowId,
credentialId,
oauthCredential,
domain,
projectId,
planId,
@@ -69,7 +69,7 @@ export function useSelectorDisplayName({
subBlock,
detailId,
workflowId,
credentialId,
oauthCredential,
domain,
projectId,
planId,

View File

@@ -77,6 +77,7 @@ import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
const logger = createLogger('Auth')
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
import { getCanonicalScopesForProvider } from '@/lib/oauth/utils'
const validStripeKey = env.STRIPE_SECRET_KEY
@@ -762,7 +763,7 @@ export const auth = betterAuth({
prompt: 'consent',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scopes: ['user:email', 'repo', 'read:user', 'workflow'],
scopes: getCanonicalScopesForProvider('github-repo'),
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/github-repo`,
getUserInfo: async (tokens) => {
try {
@@ -837,13 +838,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.labels',
],
scopes: getCanonicalScopesForProvider('google-email'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-email`,
getUserInfo: async (tokens) => {
@@ -879,11 +874,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/calendar',
],
scopes: getCanonicalScopesForProvider('google-calendar'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-calendar`,
getUserInfo: async (tokens) => {
@@ -919,12 +910,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
scopes: getCanonicalScopesForProvider('google-drive'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-drive`,
getUserInfo: async (tokens) => {
@@ -960,12 +946,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
scopes: getCanonicalScopesForProvider('google-docs'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-docs`,
getUserInfo: async (tokens) => {
@@ -1001,12 +982,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
scopes: getCanonicalScopesForProvider('google-sheets'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-sheets`,
getUserInfo: async (tokens) => {
@@ -1043,11 +1019,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/contacts',
],
scopes: getCanonicalScopesForProvider('google-contacts'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-contacts`,
getUserInfo: async (tokens) => {
@@ -1083,13 +1055,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/forms.body',
'https://www.googleapis.com/auth/forms.responses.readonly',
],
scopes: getCanonicalScopesForProvider('google-forms'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-forms`,
getUserInfo: async (tokens) => {
@@ -1125,11 +1091,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/bigquery',
],
scopes: getCanonicalScopesForProvider('google-bigquery'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-bigquery`,
getUserInfo: async (tokens) => {
@@ -1166,12 +1128,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/ediscovery',
'https://www.googleapis.com/auth/devstorage.read_only',
],
scopes: getCanonicalScopesForProvider('google-vault'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-vault`,
getUserInfo: async (tokens) => {
@@ -1208,12 +1165,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/admin.directory.group',
'https://www.googleapis.com/auth/admin.directory.group.member',
],
scopes: getCanonicalScopesForProvider('google-groups'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-groups`,
getUserInfo: async (tokens) => {
@@ -1250,12 +1202,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/meetings.space.created',
'https://www.googleapis.com/auth/meetings.space.readonly',
],
scopes: getCanonicalScopesForProvider('google-meet'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-meet`,
getUserInfo: async (tokens) => {
@@ -1291,11 +1238,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/tasks',
],
scopes: getCanonicalScopesForProvider('google-tasks'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-tasks`,
getUserInfo: async (tokens) => {
@@ -1332,11 +1275,7 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cloud-platform',
],
scopes: getCanonicalScopesForProvider('vertex-ai'),
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/vertex-ai`,
getUserInfo: async (tokens) => {
@@ -1374,28 +1313,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
scopes: [
'openid',
'profile',
'email',
'User.Read',
'Chat.Read',
'Chat.ReadWrite',
'Chat.ReadBasic',
'ChatMessage.Send',
'Channel.ReadBasic.All',
'ChannelMessage.Send',
'ChannelMessage.Read.All',
'ChannelMessage.ReadWrite',
'ChannelMember.Read.All',
'Group.Read.All',
'Group.ReadWrite.All',
'Team.ReadBasic.All',
'TeamMember.Read.All',
'offline_access',
'Files.Read',
'Sites.Read.All',
],
scopes: getCanonicalScopesForProvider('microsoft-teams'),
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1435,7 +1353,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'],
scopes: getCanonicalScopesForProvider('microsoft-excel'),
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1474,13 +1392,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
scopes: [
'openid',
'profile',
'email',
'https://dynamics.microsoft.com/user_impersonation',
'offline_access',
],
scopes: getCanonicalScopesForProvider('microsoft-dataverse'),
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1522,15 +1434,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
scopes: [
'openid',
'profile',
'email',
'Group.ReadWrite.All',
'Group.Read.All',
'Tasks.ReadWrite',
'offline_access',
],
scopes: getCanonicalScopesForProvider('microsoft-planner'),
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1570,16 +1474,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
scopes: [
'openid',
'profile',
'email',
'Mail.ReadWrite',
'Mail.ReadBasic',
'Mail.Read',
'Mail.Send',
'offline_access',
],
scopes: getCanonicalScopesForProvider('outlook'),
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1619,7 +1514,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'],
scopes: getCanonicalScopesForProvider('onedrive'),
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1659,15 +1554,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
scopes: [
'openid',
'profile',
'email',
'Sites.Read.All',
'Sites.ReadWrite.All',
'Sites.Manage.All',
'offline_access',
],
scopes: getCanonicalScopesForProvider('sharepoint'),
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1707,7 +1594,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://app.crmworkspace.com/oauth/authorize',
tokenUrl: 'https://app.crmworkspace.com/oauth/token',
userInfoUrl: 'https://dummy-not-used.wealthbox.com', // Dummy URL since no user info endpoint exists
scopes: ['login', 'data'],
scopes: getCanonicalScopesForProvider('wealthbox'),
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wealthbox`,
getUserInfo: async (_tokens) => {
@@ -1740,15 +1627,7 @@ export const auth = betterAuth({
tokenUrl: 'https://oauth.pipedrive.com/oauth/token',
userInfoUrl: 'https://api.pipedrive.com/v1/users/me',
prompt: 'consent',
scopes: [
'base',
'deals:full',
'contacts:full',
'leads:full',
'activities:full',
'mail:full',
'projects:full',
],
scopes: getCanonicalScopesForProvider('pipedrive'),
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/pipedrive`,
getUserInfo: async (tokens) => {
@@ -1797,31 +1676,7 @@ export const auth = betterAuth({
tokenUrl: 'https://api.hubapi.com/oauth/v1/token',
userInfoUrl: 'https://api.hubapi.com/oauth/v1/access-tokens',
prompt: 'consent',
scopes: [
'crm.objects.contacts.read',
'crm.objects.contacts.write',
'crm.objects.companies.read',
'crm.objects.companies.write',
'crm.objects.deals.read',
'crm.objects.deals.write',
'crm.objects.owners.read',
'crm.objects.users.read',
'crm.objects.users.write',
'crm.objects.marketing_events.read',
'crm.objects.marketing_events.write',
'crm.objects.line_items.read',
'crm.objects.line_items.write',
'crm.objects.quotes.read',
'crm.objects.quotes.write',
'crm.objects.appointments.read',
'crm.objects.appointments.write',
'crm.objects.carts.read',
'crm.objects.carts.write',
'crm.import',
'crm.lists.read',
'crm.lists.write',
'tickets',
],
scopes: getCanonicalScopesForProvider('hubspot'),
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/hubspot`,
getUserInfo: async (tokens) => {
try {
@@ -1893,7 +1748,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize',
tokenUrl: 'https://login.salesforce.com/services/oauth2/token',
userInfoUrl: 'https://login.salesforce.com/services/oauth2/userinfo',
scopes: ['api', 'refresh_token', 'openid', 'offline_access'],
scopes: getCanonicalScopesForProvider('salesforce'),
pkce: true,
prompt: 'consent',
accessType: 'offline',
@@ -1944,23 +1799,7 @@ export const auth = betterAuth({
tokenUrl: 'https://api.x.com/2/oauth2/token',
userInfoUrl: 'https://api.x.com/2/users/me',
accessType: 'offline',
scopes: [
'tweet.read',
'tweet.write',
'tweet.moderate.write',
'users.read',
'follows.read',
'follows.write',
'bookmark.read',
'bookmark.write',
'like.read',
'like.write',
'block.read',
'block.write',
'mute.read',
'mute.write',
'offline.access',
],
scopes: getCanonicalScopesForProvider('x'),
pkce: true,
responseType: 'code',
prompt: 'consent',
@@ -2019,45 +1858,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://auth.atlassian.com/authorize',
tokenUrl: 'https://auth.atlassian.com/oauth/token',
userInfoUrl: 'https://api.atlassian.com/me',
scopes: [
'read:confluence-content.all',
'read:confluence-space.summary',
'read:space:confluence',
'read:space-details:confluence',
'write:confluence-content',
'write:confluence-space',
'write:confluence-file',
'read:page:confluence',
'write:page:confluence',
'read:comment:confluence',
'read:content:confluence',
'write:comment:confluence',
'delete:comment:confluence',
'read:attachment:confluence',
'write:attachment:confluence',
'delete:attachment:confluence',
'delete:page:confluence',
'read:label:confluence',
'write:label:confluence',
'search:confluence',
'read:me',
'offline_access',
'read:blogpost:confluence',
'write:blogpost:confluence',
'read:content.property:confluence',
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
'read:user:confluence',
'read:task:confluence',
'write:task:confluence',
'delete:blogpost:confluence',
'write:space:confluence',
'delete:space:confluence',
'read:space.property:confluence',
'write:space.property:confluence',
'read:space.permission:confluence',
],
scopes: getCanonicalScopesForProvider('confluence'),
responseType: 'code',
pkce: true,
accessType: 'offline',
@@ -2109,67 +1910,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://auth.atlassian.com/authorize',
tokenUrl: 'https://auth.atlassian.com/oauth/token',
userInfoUrl: 'https://api.atlassian.com/me',
scopes: [
'read:jira-user',
'read:jira-work',
'write:jira-work',
'write:issue:jira',
'read:project:jira',
'read:issue-type:jira',
'read:me',
'offline_access',
'read:issue-meta:jira',
'read:issue-security-level:jira',
'read:issue.vote:jira',
'read:issue.changelog:jira',
'read:avatar:jira',
'read:issue:jira',
'read:status:jira',
'read:user:jira',
'read:field-configuration:jira',
'read:issue-details:jira',
'read:issue-event:jira',
'delete:issue:jira',
'write:comment:jira',
'read:comment:jira',
'delete:comment:jira',
'read:attachment:jira',
'delete:attachment:jira',
'write:issue-worklog:jira',
'read:issue-worklog:jira',
'delete:issue-worklog:jira',
'write:issue-link:jira',
'delete:issue-link:jira',
// Jira Service Management scopes
'read:servicedesk:jira-service-management',
'read:requesttype:jira-service-management',
'read:request:jira-service-management',
'write:request:jira-service-management',
'read:request.comment:jira-service-management',
'write:request.comment:jira-service-management',
'read:customer:jira-service-management',
'write:customer:jira-service-management',
'read:servicedesk.customer:jira-service-management',
'write:servicedesk.customer:jira-service-management',
'read:organization:jira-service-management',
'write:organization:jira-service-management',
'read:servicedesk.organization:jira-service-management',
'write:servicedesk.organization:jira-service-management',
'read:organization.user:jira-service-management',
'write:organization.user:jira-service-management',
'read:organization.property:jira-service-management',
'write:organization.property:jira-service-management',
'read:organization.profile:jira-service-management',
'write:organization.profile:jira-service-management',
'read:queue:jira-service-management',
'read:request.sla:jira-service-management',
'read:request.status:jira-service-management',
'write:request.status:jira-service-management',
'read:request.participant:jira-service-management',
'write:request.participant:jira-service-management',
'read:request.approval:jira-service-management',
'write:request.approval:jira-service-management',
],
scopes: getCanonicalScopesForProvider('jira'),
responseType: 'code',
pkce: true,
accessType: 'offline',
@@ -2221,13 +1962,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://airtable.com/oauth2/v1/authorize',
tokenUrl: 'https://airtable.com/oauth2/v1/token',
userInfoUrl: 'https://api.airtable.com/v0/meta/whoami',
scopes: [
'data.records:read',
'data.records:write',
'schema.bases:read',
'user.email:read',
'webhook:manage',
],
scopes: getCanonicalScopesForProvider('airtable'),
responseType: 'code',
pkce: true,
accessType: 'offline',
@@ -2327,24 +2062,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://www.reddit.com/api/v1/authorize?duration=permanent',
tokenUrl: 'https://www.reddit.com/api/v1/access_token',
userInfoUrl: 'https://oauth.reddit.com/api/v1/me',
scopes: [
'identity',
'read',
'submit',
'vote',
'save',
'edit',
'subscribe',
'history',
'privatemessages',
'account',
'mysubreddits',
'flair',
'report',
'modposts',
'modflair',
'modmail',
],
scopes: getCanonicalScopesForProvider('reddit'),
responseType: 'code',
pkce: false,
accessType: 'offline',
@@ -2394,7 +2112,7 @@ export const auth = betterAuth({
clientSecret: env.LINEAR_CLIENT_SECRET as string,
authorizationUrl: 'https://linear.app/oauth/authorize',
tokenUrl: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
scopes: getCanonicalScopesForProvider('linear'),
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linear`,
pkce: true,
@@ -2466,17 +2184,7 @@ export const auth = betterAuth({
clientSecret: env.ATTIO_CLIENT_SECRET as string,
authorizationUrl: 'https://app.attio.com/authorize',
tokenUrl: 'https://app.attio.com/oauth/token',
scopes: [
'record_permission:read-write',
'object_configuration:read-write',
'list_configuration:read-write',
'list_entry:read-write',
'note:read-write',
'task:read-write',
'comment:read-write',
'user_management:read',
'webhook:read-write',
],
scopes: getCanonicalScopesForProvider('attio'),
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/attio`,
getUserInfo: async (tokens) => {
@@ -2529,15 +2237,7 @@ export const auth = betterAuth({
clientSecret: env.DROPBOX_CLIENT_SECRET as string,
authorizationUrl: 'https://www.dropbox.com/oauth2/authorize',
tokenUrl: 'https://api.dropboxapi.com/oauth2/token',
scopes: [
'account_info.read',
'files.metadata.read',
'files.metadata.write',
'files.content.read',
'files.content.write',
'sharing.read',
'sharing.write',
],
scopes: getCanonicalScopesForProvider('dropbox'),
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/dropbox`,
pkce: true,
@@ -2593,7 +2293,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://app.asana.com/-/oauth_authorize',
tokenUrl: 'https://app.asana.com/-/oauth_token',
userInfoUrl: 'https://app.asana.com/api/1.0/users/me',
scopes: ['default'],
scopes: getCanonicalScopesForProvider('asana'),
responseType: 'code',
pkce: false,
accessType: 'offline',
@@ -2646,23 +2346,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
tokenUrl: 'https://slack.com/api/oauth.v2.access',
userInfoUrl: 'https://slack.com/api/users.identity',
scopes: [
// Bot token scopes only - app acts as a bot user
'channels:read',
'channels:history',
'groups:read',
'groups:history',
'chat:write',
'chat:write.public',
'im:write',
'im:history',
'im:read',
'users:read',
'files:write',
'files:read',
'canvases:write',
'reactions:write',
],
scopes: getCanonicalScopesForProvider('slack'),
responseType: 'code',
accessType: 'offline',
prompt: 'consent',
@@ -2722,7 +2406,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://webflow.com/oauth/authorize',
tokenUrl: 'https://api.webflow.com/oauth/access_token',
userInfoUrl: 'https://api.webflow.com/v2/token/introspect',
scopes: ['sites:read', 'sites:write', 'cms:read', 'cms:write', 'forms:read'],
scopes: getCanonicalScopesForProvider('webflow'),
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/webflow`,
getUserInfo: async (tokens) => {
@@ -2772,7 +2456,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization',
tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken',
userInfoUrl: 'https://api.linkedin.com/v2/userinfo',
scopes: ['profile', 'openid', 'email', 'w_member_social'],
scopes: getCanonicalScopesForProvider('linkedin'),
responseType: 'code',
accessType: 'offline',
prompt: 'consent',
@@ -2822,19 +2506,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://zoom.us/oauth/authorize',
tokenUrl: 'https://zoom.us/oauth/token',
userInfoUrl: 'https://api.zoom.us/v2/users/me',
scopes: [
'user:read:user',
'meeting:write:meeting',
'meeting:read:meeting',
'meeting:read:list_meetings',
'meeting:update:meeting',
'meeting:delete:meeting',
'meeting:read:invitation',
'meeting:read:list_past_participants',
'cloud_recording:read:list_user_recordings',
'cloud_recording:read:list_recording_files',
'cloud_recording:delete:recording_file',
],
scopes: getCanonicalScopesForProvider('zoom'),
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -2886,25 +2558,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://accounts.spotify.com/authorize',
tokenUrl: 'https://accounts.spotify.com/api/token',
userInfoUrl: 'https://api.spotify.com/v1/me',
scopes: [
'user-read-private',
'user-read-email',
'user-library-read',
'user-library-modify',
'playlist-read-private',
'playlist-read-collaborative',
'playlist-modify-public',
'playlist-modify-private',
'user-read-playback-state',
'user-modify-playback-state',
'user-read-currently-playing',
'user-read-recently-played',
'user-top-read',
'user-follow-read',
'user-follow-modify',
'user-read-playback-position',
'ugc-image-upload',
],
scopes: getCanonicalScopesForProvider('spotify'),
responseType: 'code',
authentication: 'basic',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/spotify`,
@@ -2953,7 +2607,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://public-api.wordpress.com/oauth2/authorize',
tokenUrl: 'https://public-api.wordpress.com/oauth2/token',
userInfoUrl: 'https://public-api.wordpress.com/rest/v1.1/me',
scopes: ['global'],
scopes: getCanonicalScopesForProvider('wordpress'),
responseType: 'code',
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wordpress`,
@@ -3000,7 +2654,7 @@ export const auth = betterAuth({
clientId: env.CALCOM_CLIENT_ID as string,
authorizationUrl: 'https://app.cal.com/auth/oauth2/authorize',
tokenUrl: 'https://app.cal.com/api/auth/oauth/token',
scopes: [],
scopes: getCanonicalScopesForProvider('calcom'),
responseType: 'code',
pkce: true,
accessType: 'offline',

View File

@@ -63,6 +63,8 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
icon: GmailIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.labels',
@@ -75,6 +77,8 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
icon: GoogleDriveIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
@@ -86,6 +90,8 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
icon: GoogleDocsIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
@@ -97,6 +103,8 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
icon: GoogleSheetsIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
@@ -121,7 +129,11 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-calendar',
icon: GoogleCalendarIcon,
baseProviderIcon: GoogleIcon,
scopes: ['https://www.googleapis.com/auth/calendar'],
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/calendar',
],
},
'google-contacts': {
name: 'Google Contacts',
@@ -129,7 +141,11 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-contacts',
icon: GoogleContactsIcon,
baseProviderIcon: GoogleIcon,
scopes: ['https://www.googleapis.com/auth/contacts'],
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/contacts',
],
},
'google-bigquery': {
name: 'Google BigQuery',
@@ -137,7 +153,11 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-bigquery',
icon: GoogleBigQueryIcon,
baseProviderIcon: GoogleIcon,
scopes: ['https://www.googleapis.com/auth/bigquery'],
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/bigquery',
],
},
'google-tasks': {
name: 'Google Tasks',
@@ -145,7 +165,11 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-tasks',
icon: GoogleTasksIcon,
baseProviderIcon: GoogleIcon,
scopes: ['https://www.googleapis.com/auth/tasks'],
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/tasks',
],
},
'google-vault': {
name: 'Google Vault',
@@ -154,6 +178,8 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
icon: GoogleIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/ediscovery',
'https://www.googleapis.com/auth/devstorage.read_only',
],
@@ -165,6 +191,8 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
icon: GoogleGroupsIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/admin.directory.group',
'https://www.googleapis.com/auth/admin.directory.group.member',
],
@@ -176,6 +204,8 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
icon: GoogleMeetIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/meetings.space.created',
'https://www.googleapis.com/auth/meetings.space.readonly',
],
@@ -186,7 +216,11 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'vertex-ai',
icon: VertexIcon,
baseProviderIcon: VertexIcon,
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cloud-platform',
],
},
},
defaultService: 'gmail',
@@ -671,7 +705,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'webflow',
icon: WebflowIcon,
baseProviderIcon: WebflowIcon,
scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write'],
scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write', 'forms:read'],
},
},
defaultService: 'webflow',

View File

@@ -122,14 +122,6 @@ export interface OAuthServiceMetadata {
baseProvider: string
}
export interface ScopeEvaluation {
canonicalScopes: string[]
grantedScopes: string[]
missingScopes: string[]
extraScopes: string[]
requiresReauthorization: boolean
}
export interface Credential {
id: string
name: string
@@ -138,10 +130,6 @@ export interface Credential {
lastUsed?: string
isDefault?: boolean
scopes?: string[]
canonicalScopes?: string[]
missingScopes?: string[]
extraScopes?: string[]
requiresReauthorization?: boolean
}
export interface ProviderConfig {

View File

@@ -1,13 +1,13 @@
import { describe, expect, it } from 'vitest'
import type { OAuthProvider, OAuthServiceMetadata } from './types'
import {
evaluateScopeCoverage,
getAllOAuthServices,
getCanonicalScopesForProvider,
getMissingRequiredScopes,
getProviderIdFromServiceId,
getScopesForService,
getServiceByProviderAndId,
getServiceConfigByProviderId,
normalizeScopes,
parseProvider,
} from './utils'
@@ -361,209 +361,6 @@ describe('getCanonicalScopesForProvider', () => {
})
})
describe('normalizeScopes', () => {
it.concurrent('should remove duplicates from scope array', () => {
const scopes = ['scope1', 'scope2', 'scope1', 'scope3', 'scope2']
const normalized = normalizeScopes(scopes)
expect(normalized.length).toBe(3)
expect(normalized).toContain('scope1')
expect(normalized).toContain('scope2')
expect(normalized).toContain('scope3')
})
it.concurrent('should trim whitespace from scopes', () => {
const scopes = [' scope1 ', 'scope2', ' scope3 ']
const normalized = normalizeScopes(scopes)
expect(normalized).toEqual(['scope1', 'scope2', 'scope3'])
})
it.concurrent('should remove empty strings', () => {
const scopes = ['scope1', '', 'scope2', ' ', 'scope3']
const normalized = normalizeScopes(scopes)
expect(normalized.length).toBe(3)
expect(normalized).toEqual(['scope1', 'scope2', 'scope3'])
})
it.concurrent('should handle empty array', () => {
const normalized = normalizeScopes([])
expect(Array.isArray(normalized)).toBe(true)
expect(normalized.length).toBe(0)
})
it.concurrent('should handle array with only empty strings', () => {
const normalized = normalizeScopes(['', ' ', ' '])
expect(Array.isArray(normalized)).toBe(true)
expect(normalized.length).toBe(0)
})
it.concurrent('should preserve order of first occurrence', () => {
const scopes = ['scope3', 'scope1', 'scope2', 'scope1', 'scope3']
const normalized = normalizeScopes(scopes)
expect(normalized).toEqual(['scope3', 'scope1', 'scope2'])
})
it.concurrent('should handle scopes with special characters', () => {
const scopes = [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.send',
]
const normalized = normalizeScopes(scopes)
expect(normalized.length).toBe(2)
expect(normalized).toContain('https://www.googleapis.com/auth/gmail.send')
expect(normalized).toContain('https://www.googleapis.com/auth/gmail.modify')
})
it.concurrent('should handle single scope', () => {
const normalized = normalizeScopes(['scope1'])
expect(normalized).toEqual(['scope1'])
})
it.concurrent('should handle scopes with mixed whitespace', () => {
const scopes = ['scope1', '\tscope2\t', '\nscope3\n', ' scope1 ']
const normalized = normalizeScopes(scopes)
expect(normalized.length).toBe(3)
expect(normalized).toContain('scope1')
expect(normalized).toContain('scope2')
expect(normalized).toContain('scope3')
})
})
describe('evaluateScopeCoverage', () => {
it.concurrent('should identify missing scopes', () => {
const evaluation = evaluateScopeCoverage('google-email', [
'https://www.googleapis.com/auth/gmail.send',
])
expect(evaluation.missingScopes.length).toBeGreaterThan(0)
expect(evaluation.missingScopes).toContain('https://www.googleapis.com/auth/gmail.modify')
expect(evaluation.requiresReauthorization).toBe(true)
})
it.concurrent('should identify extra scopes', () => {
const evaluation = evaluateScopeCoverage('google-email', [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.labels',
'https://www.googleapis.com/auth/calendar',
])
expect(evaluation.extraScopes.length).toBe(1)
expect(evaluation.extraScopes).toContain('https://www.googleapis.com/auth/calendar')
})
it.concurrent('should return no missing scopes when all are present', () => {
const evaluation = evaluateScopeCoverage('google-email', [
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.labels',
])
expect(evaluation.missingScopes.length).toBe(0)
expect(evaluation.requiresReauthorization).toBe(false)
})
it.concurrent('should normalize granted scopes before evaluation', () => {
const evaluation = evaluateScopeCoverage('google-email', [
' https://www.googleapis.com/auth/gmail.send ',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/gmail.labels',
'https://www.googleapis.com/auth/gmail.send',
])
expect(evaluation.grantedScopes.length).toBe(3)
expect(evaluation.missingScopes.length).toBe(0)
expect(evaluation.requiresReauthorization).toBe(false)
})
it.concurrent('should handle empty granted scopes', () => {
const evaluation = evaluateScopeCoverage('google-email', [])
expect(evaluation.grantedScopes.length).toBe(0)
expect(evaluation.missingScopes.length).toBeGreaterThan(0)
expect(evaluation.requiresReauthorization).toBe(true)
})
it.concurrent('should return correct structure', () => {
const evaluation = evaluateScopeCoverage('google-email', [
'https://www.googleapis.com/auth/gmail.send',
])
expect(evaluation).toHaveProperty('canonicalScopes')
expect(evaluation).toHaveProperty('grantedScopes')
expect(evaluation).toHaveProperty('missingScopes')
expect(evaluation).toHaveProperty('extraScopes')
expect(evaluation).toHaveProperty('requiresReauthorization')
expect(Array.isArray(evaluation.canonicalScopes)).toBe(true)
expect(Array.isArray(evaluation.grantedScopes)).toBe(true)
expect(Array.isArray(evaluation.missingScopes)).toBe(true)
expect(Array.isArray(evaluation.extraScopes)).toBe(true)
expect(typeof evaluation.requiresReauthorization).toBe('boolean')
})
it.concurrent('should handle provider with no scopes', () => {
const evaluation = evaluateScopeCoverage('notion', [])
expect(evaluation.canonicalScopes.length).toBe(0)
expect(evaluation.missingScopes.length).toBe(0)
expect(evaluation.requiresReauthorization).toBe(false)
})
it.concurrent('should handle provider with no scopes but granted scopes present', () => {
const evaluation = evaluateScopeCoverage('notion', ['some.scope', 'another.scope'])
expect(evaluation.canonicalScopes.length).toBe(0)
expect(evaluation.missingScopes.length).toBe(0)
expect(evaluation.extraScopes.length).toBe(2)
expect(evaluation.extraScopes).toContain('some.scope')
expect(evaluation.extraScopes).toContain('another.scope')
expect(evaluation.requiresReauthorization).toBe(false)
})
it.concurrent('should handle invalid provider', () => {
const evaluation = evaluateScopeCoverage('invalid-provider', ['scope1', 'scope2'])
expect(evaluation.canonicalScopes.length).toBe(0)
expect(evaluation.grantedScopes.length).toBe(2)
expect(evaluation.missingScopes.length).toBe(0)
expect(evaluation.extraScopes.length).toBe(2)
expect(evaluation.requiresReauthorization).toBe(false)
})
it.concurrent('should work with Microsoft services', () => {
const evaluation = evaluateScopeCoverage('outlook', [
'openid',
'profile',
'email',
'Mail.ReadWrite',
'Mail.Send',
])
expect(evaluation.canonicalScopes.length).toBeGreaterThan(0)
expect(evaluation.missingScopes.length).toBeGreaterThan(0)
expect(evaluation.requiresReauthorization).toBe(true)
})
it.concurrent('should handle exact match with no extra or missing scopes', () => {
const canonicalScopes = getCanonicalScopesForProvider('linear')
const evaluation = evaluateScopeCoverage('linear', [...canonicalScopes])
expect(evaluation.missingScopes.length).toBe(0)
expect(evaluation.extraScopes.length).toBe(0)
expect(evaluation.requiresReauthorization).toBe(false)
})
})
describe('parseProvider', () => {
it.concurrent('should parse simple provider without hyphen', () => {
const config = parseProvider('slack' as OAuthProvider)
@@ -802,3 +599,111 @@ describe('parseProvider', () => {
expect(config.featureType).toBe('sharepoint')
})
})
describe('getScopesForService', () => {
it.concurrent('should return scopes for a valid serviceId', () => {
const scopes = getScopesForService('gmail')
expect(Array.isArray(scopes)).toBe(true)
expect(scopes.length).toBeGreaterThan(0)
expect(scopes).toContain('https://www.googleapis.com/auth/gmail.send')
})
it.concurrent('should return empty array for unknown serviceId', () => {
const scopes = getScopesForService('nonexistent-service')
expect(Array.isArray(scopes)).toBe(true)
expect(scopes.length).toBe(0)
})
it.concurrent('should return new array instance (not reference)', () => {
const scopes1 = getScopesForService('gmail')
const scopes2 = getScopesForService('gmail')
expect(scopes1).not.toBe(scopes2)
expect(scopes1).toEqual(scopes2)
})
it.concurrent('should work for Microsoft services', () => {
const scopes = getScopesForService('outlook')
expect(scopes.length).toBeGreaterThan(0)
expect(scopes).toContain('Mail.ReadWrite')
})
it.concurrent('should return empty array for empty string', () => {
const scopes = getScopesForService('')
expect(Array.isArray(scopes)).toBe(true)
expect(scopes.length).toBe(0)
})
})
describe('getMissingRequiredScopes', () => {
it.concurrent('should return empty array when all scopes are granted', () => {
const credential = { scopes: ['read', 'write'] }
const missing = getMissingRequiredScopes(credential, ['read', 'write'])
expect(missing).toEqual([])
})
it.concurrent('should return missing scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'write'])
expect(missing).toEqual(['write'])
})
it.concurrent('should return all required scopes when credential is undefined', () => {
const missing = getMissingRequiredScopes(undefined, ['read', 'write'])
expect(missing).toEqual(['read', 'write'])
})
it.concurrent('should return all required scopes when credential has undefined scopes', () => {
const missing = getMissingRequiredScopes({ scopes: undefined }, ['read', 'write'])
expect(missing).toEqual(['read', 'write'])
})
it.concurrent('should ignore offline_access in required scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'offline_access'])
expect(missing).toEqual([])
})
it.concurrent('should ignore refresh_token in required scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'refresh_token'])
expect(missing).toEqual([])
})
it.concurrent('should ignore offline.access in required scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'offline.access'])
expect(missing).toEqual([])
})
it.concurrent('should filter ignored scopes even when credential is undefined', () => {
const missing = getMissingRequiredScopes(undefined, ['read', 'offline_access', 'refresh_token'])
expect(missing).toEqual(['read'])
})
it.concurrent('should return empty array when requiredScopes is empty', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, [])
expect(missing).toEqual([])
})
it.concurrent('should return empty array when requiredScopes defaults to empty', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential)
expect(missing).toEqual([])
})
})

View File

@@ -4,9 +4,411 @@ import type {
OAuthServiceConfig,
OAuthServiceMetadata,
ProviderConfig,
ScopeEvaluation,
} from './types'
/**
* Centralized human-readable descriptions for OAuth scopes.
* Used by the OAuth Required Modal and available for any UI that needs to display scope info.
*/
export const SCOPE_DESCRIPTIONS: Record<string, string> = {
// Google scopes
'https://www.googleapis.com/auth/gmail.send': 'Send emails',
'https://www.googleapis.com/auth/gmail.labels': 'View and manage email labels',
'https://www.googleapis.com/auth/gmail.modify': 'View and manage email messages',
'https://www.googleapis.com/auth/drive.file': 'View and manage Google Drive files',
'https://www.googleapis.com/auth/drive': 'Access all Google Drive files',
'https://www.googleapis.com/auth/calendar': 'View and manage calendar',
'https://www.googleapis.com/auth/contacts': 'View and manage Google Contacts',
'https://www.googleapis.com/auth/tasks': 'Create, read, update, and delete Google Tasks',
'https://www.googleapis.com/auth/userinfo.email': 'View email address',
'https://www.googleapis.com/auth/userinfo.profile': 'View basic profile info',
'https://www.googleapis.com/auth/forms.body': 'View and manage Google Forms',
'https://www.googleapis.com/auth/forms.responses.readonly': 'View responses to Google Forms',
'https://www.googleapis.com/auth/bigquery': 'View and manage data in Google BigQuery',
'https://www.googleapis.com/auth/ediscovery': 'Access Google Vault for eDiscovery',
'https://www.googleapis.com/auth/devstorage.read_only': 'Read files from Google Cloud Storage',
'https://www.googleapis.com/auth/admin.directory.group': 'Manage Google Workspace groups',
'https://www.googleapis.com/auth/admin.directory.group.member':
'Manage Google Workspace group memberships',
'https://www.googleapis.com/auth/admin.directory.group.readonly': 'View Google Workspace groups',
'https://www.googleapis.com/auth/admin.directory.group.member.readonly':
'View Google Workspace group memberships',
'https://www.googleapis.com/auth/meetings.space.created':
'Create and manage Google Meet meeting spaces',
'https://www.googleapis.com/auth/meetings.space.readonly':
'View Google Meet meeting space details',
'https://www.googleapis.com/auth/cloud-platform':
'Full access to Google Cloud resources for Vertex AI',
// Confluence scopes
'read:confluence-content.all': 'Read all Confluence content',
'read:confluence-space.summary': 'Read Confluence space information',
'read:space:confluence': 'View Confluence spaces',
'read:space-details:confluence': 'View detailed Confluence space information',
'write:confluence-content': 'Create and edit Confluence pages',
'write:confluence-space': 'Manage Confluence spaces',
'write:confluence-file': 'Upload files to Confluence',
'read:content:confluence': 'Read Confluence content',
'read:page:confluence': 'View Confluence pages',
'write:page:confluence': 'Create and update Confluence pages',
'read:comment:confluence': 'View comments on Confluence pages',
'write:comment:confluence': 'Create and update comments',
'delete:comment:confluence': 'Delete comments from Confluence pages',
'read:attachment:confluence': 'View attachments on Confluence pages',
'write:attachment:confluence': 'Upload and manage attachments',
'delete:attachment:confluence': 'Delete attachments from Confluence pages',
'delete:page:confluence': 'Delete Confluence pages',
'read:label:confluence': 'View labels on Confluence content',
'write:label:confluence': 'Add and remove labels',
'search:confluence': 'Search Confluence content',
'readonly:content.attachment:confluence': 'View attachments',
'read:blogpost:confluence': 'View Confluence blog posts',
'write:blogpost:confluence': 'Create and update Confluence blog posts',
'read:content.property:confluence': 'View properties on Confluence content',
'write:content.property:confluence': 'Create and manage content properties',
'read:hierarchical-content:confluence': 'View page hierarchy (children and ancestors)',
'read:content.metadata:confluence': 'View content metadata (required for ancestors)',
'read:user:confluence': 'View Confluence user profiles',
'read:task:confluence': 'View Confluence inline tasks',
'write:task:confluence': 'Update Confluence inline tasks',
'delete:blogpost:confluence': 'Delete Confluence blog posts',
'write:space:confluence': 'Create and update Confluence spaces',
'delete:space:confluence': 'Delete Confluence spaces',
'read:space.property:confluence': 'View Confluence space properties',
'write:space.property:confluence': 'Create and manage space properties',
'read:space.permission:confluence': 'View Confluence space permissions',
// Common scopes
'read:me': 'Read profile information',
offline_access: 'Access account when not using the application',
openid: 'Standard authentication',
profile: 'Access profile information',
email: 'Access email address',
// Notion scopes
'database.read': 'Read database',
'database.write': 'Write to database',
'projects.read': 'Read projects',
'page.read': 'Read Notion pages',
'page.write': 'Write to Notion pages',
'workspace.content': 'Read Notion content',
'workspace.name': 'Read Notion workspace name',
'workspace.read': 'Read Notion workspace',
'workspace.write': 'Write to Notion workspace',
'user.email:read': 'Read email address',
// GitHub scopes
repo: 'Access repositories',
workflow: 'Manage repository workflows',
'read:user': 'Read public user information',
'user:email': 'Access email address',
// X (Twitter) scopes
'tweet.read': 'Read tweets and timeline',
'tweet.write': 'Post and delete tweets',
'tweet.moderate.write': 'Hide and unhide replies to tweets',
'users.read': 'Read user profiles and account information',
'follows.read': 'View followers and following lists',
'follows.write': 'Follow and unfollow users',
'bookmark.read': 'View bookmarked tweets',
'bookmark.write': 'Add and remove bookmarks',
'like.read': 'View liked tweets and liking users',
'like.write': 'Like and unlike tweets',
'block.read': 'View blocked users',
'block.write': 'Block and unblock users',
'mute.read': 'View muted users',
'mute.write': 'Mute and unmute users',
'offline.access': 'Access account when not using the application',
// Airtable scopes
'data.records:read': 'Read records',
'data.records:write': 'Write to records',
'schema.bases:read': 'View bases and tables',
'webhook:manage': 'Manage webhooks',
// Jira scopes
'read:jira-user': 'Read Jira user',
'read:jira-work': 'Read Jira work',
'write:jira-work': 'Write to Jira work',
'manage:jira-webhook': 'Register and manage Jira webhooks',
'read:webhook:jira': 'View Jira webhooks',
'write:webhook:jira': 'Create and update Jira webhooks',
'delete:webhook:jira': 'Delete Jira webhooks',
'read:issue-event:jira': 'Read Jira issue events',
'write:issue:jira': 'Write to Jira issues',
'read:project:jira': 'Read Jira projects',
'read:issue-type:jira': 'Read Jira issue types',
'read:issue-meta:jira': 'Read Jira issue meta',
'read:issue-security-level:jira': 'Read Jira issue security level',
'read:issue.vote:jira': 'Read Jira issue votes',
'read:issue.changelog:jira': 'Read Jira issue changelog',
'read:avatar:jira': 'Read Jira avatar',
'read:issue:jira': 'Read Jira issues',
'read:status:jira': 'Read Jira status',
'read:user:jira': 'Read Jira user',
'read:field-configuration:jira': 'Read Jira field configuration',
'read:issue-details:jira': 'Read Jira issue details',
'read:field:jira': 'Read Jira field configurations',
'read:jql:jira': 'Use JQL to filter Jira issues',
'read:comment.property:jira': 'Read Jira comment properties',
'read:issue.property:jira': 'Read Jira issue properties',
'delete:issue:jira': 'Delete Jira issues',
'write:comment:jira': 'Add and update comments on Jira issues',
'read:comment:jira': 'Read comments on Jira issues',
'delete:comment:jira': 'Delete comments from Jira issues',
'read:attachment:jira': 'Read attachments from Jira issues',
'delete:attachment:jira': 'Delete attachments from Jira issues',
'write:issue-worklog:jira': 'Add and update worklog entries on Jira issues',
'read:issue-worklog:jira': 'Read worklog entries from Jira issues',
'delete:issue-worklog:jira': 'Delete worklog entries from Jira issues',
'write:issue-link:jira': 'Create links between Jira issues',
'delete:issue-link:jira': 'Delete links between Jira issues',
// Jira Service Management scopes
'read:servicedesk:jira-service-management': 'View service desks and their settings',
'read:requesttype:jira-service-management': 'View request types available in service desks',
'read:request:jira-service-management': 'View customer requests in service desks',
'write:request:jira-service-management': 'Create customer requests in service desks',
'read:request.comment:jira-service-management': 'View comments on customer requests',
'write:request.comment:jira-service-management': 'Add comments to customer requests',
'read:customer:jira-service-management': 'View customer information',
'write:customer:jira-service-management': 'Create and manage customers',
'read:servicedesk.customer:jira-service-management': 'View customers linked to service desks',
'write:servicedesk.customer:jira-service-management':
'Add and remove customers from service desks',
'read:organization:jira-service-management': 'View organizations',
'write:organization:jira-service-management': 'Create and manage organizations',
'read:servicedesk.organization:jira-service-management':
'View organizations linked to service desks',
'write:servicedesk.organization:jira-service-management':
'Add and remove organizations from service desks',
'read:organization.user:jira-service-management': 'View users in organizations',
'write:organization.user:jira-service-management': 'Add and remove users from organizations',
'read:organization.property:jira-service-management': 'View organization properties',
'write:organization.property:jira-service-management':
'Create and manage organization properties',
'read:organization.profile:jira-service-management': 'View organization profiles',
'write:organization.profile:jira-service-management': 'Update organization profiles',
'read:queue:jira-service-management': 'View service desk queues and their issues',
'read:request.sla:jira-service-management': 'View SLA information for customer requests',
'read:request.status:jira-service-management': 'View status of customer requests',
'write:request.status:jira-service-management': 'Transition customer request status',
'read:request.participant:jira-service-management': 'View participants on customer requests',
'write:request.participant:jira-service-management':
'Add and remove participants from customer requests',
'read:request.approval:jira-service-management': 'View approvals on customer requests',
'write:request.approval:jira-service-management': 'Approve or decline customer requests',
// Microsoft scopes
'User.Read': 'Read Microsoft user',
'Chat.Read': 'Read Microsoft chats',
'Chat.ReadWrite': 'Write to Microsoft chats',
'Chat.ReadBasic': 'Read Microsoft chats',
'ChatMessage.Send': 'Send chat messages',
'Channel.ReadBasic.All': 'Read Microsoft channels',
'ChannelMessage.Send': 'Write to Microsoft channels',
'ChannelMessage.Read.All': 'Read Microsoft channels',
'ChannelMessage.ReadWrite': 'Read and write to Microsoft channels',
'ChannelMember.Read.All': 'Read team channel members',
'Group.Read.All': 'Read Microsoft groups',
'Group.ReadWrite.All': 'Write to Microsoft groups',
'Team.ReadBasic.All': 'Read Microsoft teams',
'TeamMember.Read.All': 'Read team members',
'Mail.ReadWrite': 'Write to Microsoft emails',
'Mail.ReadBasic': 'Read Microsoft emails',
'Mail.Read': 'Read Microsoft emails',
'Mail.Send': 'Send emails',
'Files.Read': 'Read OneDrive files',
'Files.ReadWrite': 'Read and write OneDrive files',
'Tasks.ReadWrite': 'Read and manage Planner tasks',
'Sites.Read.All': 'Read Sharepoint sites',
'Sites.ReadWrite.All': 'Read and write Sharepoint sites',
'Sites.Manage.All': 'Manage Sharepoint sites',
'https://dynamics.microsoft.com/user_impersonation': 'Access Microsoft Dataverse on your behalf',
// Discord scopes
identify: 'Read Discord user',
bot: 'Read Discord bot',
'messages.read': 'Read Discord messages',
guilds: 'Read Discord guilds',
'guilds.members.read': 'Read Discord guild members',
// Reddit scopes
identity: 'Access Reddit identity',
submit: 'Submit posts and comments',
vote: 'Vote on posts and comments',
save: 'Save and unsave posts and comments',
edit: 'Edit posts and comments',
subscribe: 'Subscribe and unsubscribe from subreddits',
history: 'Access Reddit history',
privatemessages: 'Access inbox and send private messages',
account: 'Update account preferences and settings',
mysubreddits: 'Access subscribed and moderated subreddits',
flair: 'Manage user and post flair',
report: 'Report posts and comments for rule violations',
modposts: 'Approve, remove, and moderate posts in moderated subreddits',
modflair: 'Manage flair in moderated subreddits',
modmail: 'Access and respond to moderator mail',
// Wealthbox scopes
login: 'Access Wealthbox account',
data: 'Access Wealthbox data',
// Linear scopes
read: 'Read access to workspace',
write: 'Write access to Linear workspace',
// Slack scopes
'channels:read': 'View public channels',
'channels:history': 'Read channel messages',
'groups:read': 'View private channels',
'groups:history': 'Read private messages',
'chat:write': 'Send messages',
'chat:write.public': 'Post to public channels',
'im:write': 'Send direct messages',
'im:history': 'Read direct message history',
'im:read': 'View direct message channels',
'users:read': 'View workspace users',
'files:write': 'Upload files',
'files:read': 'Download and read files',
'canvases:write': 'Create canvas documents',
'reactions:write': 'Add emoji reactions to messages',
// Webflow scopes
'sites:read': 'View Webflow sites',
'sites:write': 'Manage webhooks and site settings',
'cms:read': 'View CMS content',
'cms:write': 'Manage CMS content',
'forms:read': 'View form submissions',
// HubSpot scopes
'crm.objects.contacts.read': 'Read HubSpot contacts',
'crm.objects.contacts.write': 'Create and update HubSpot contacts',
'crm.objects.companies.read': 'Read HubSpot companies',
'crm.objects.companies.write': 'Create and update HubSpot companies',
'crm.objects.deals.read': 'Read HubSpot deals',
'crm.objects.deals.write': 'Create and update HubSpot deals',
'crm.objects.owners.read': 'Read HubSpot object owners',
'crm.objects.users.read': 'Read HubSpot users',
'crm.objects.users.write': 'Create and update HubSpot users',
'crm.objects.marketing_events.read': 'Read HubSpot marketing events',
'crm.objects.marketing_events.write': 'Create and update HubSpot marketing events',
'crm.objects.line_items.read': 'Read HubSpot line items',
'crm.objects.line_items.write': 'Create and update HubSpot line items',
'crm.objects.quotes.read': 'Read HubSpot quotes',
'crm.objects.quotes.write': 'Create and update HubSpot quotes',
'crm.objects.appointments.read': 'Read HubSpot appointments',
'crm.objects.appointments.write': 'Create and update HubSpot appointments',
'crm.objects.carts.read': 'Read HubSpot shopping carts',
'crm.objects.carts.write': 'Create and update HubSpot shopping carts',
'crm.import': 'Import data into HubSpot',
'crm.lists.read': 'Read HubSpot lists',
'crm.lists.write': 'Create and update HubSpot lists',
tickets: 'Manage HubSpot tickets',
// Salesforce scopes
api: 'Access Salesforce API',
refresh_token: 'Maintain long-term access to Salesforce account',
// Asana scopes
default: 'Access Asana workspace',
// Pipedrive scopes
base: 'Basic access to Pipedrive account',
'deals:read': 'Read Pipedrive deals',
'deals:full': 'Full access to manage Pipedrive deals',
'contacts:read': 'Read Pipedrive contacts',
'contacts:full': 'Full access to manage Pipedrive contacts',
'leads:read': 'Read Pipedrive leads',
'leads:full': 'Full access to manage Pipedrive leads',
'activities:read': 'Read Pipedrive activities',
'activities:full': 'Full access to manage Pipedrive activities',
'mail:read': 'Read Pipedrive emails',
'mail:full': 'Full access to manage Pipedrive emails',
'projects:read': 'Read Pipedrive projects',
'projects:full': 'Full access to manage Pipedrive projects',
'webhooks:read': 'Read Pipedrive webhooks',
'webhooks:full': 'Full access to manage Pipedrive webhooks',
// LinkedIn scopes
w_member_social: 'Access LinkedIn profile',
// Box scopes
root_readwrite: 'Read and write all files and folders in Box account',
root_readonly: 'Read all files and folders in Box account',
// Shopify scopes
write_products: 'Read and manage Shopify products',
write_orders: 'Read and manage Shopify orders',
write_customers: 'Read and manage Shopify customers',
write_inventory: 'Read and manage Shopify inventory levels',
read_locations: 'View store locations',
write_merchant_managed_fulfillment_orders: 'Create fulfillments for orders',
// Zoom scopes
'user:read:user': 'View Zoom profile information',
'meeting:write:meeting': 'Create Zoom meetings',
'meeting:read:meeting': 'View Zoom meeting details',
'meeting:read:list_meetings': 'List Zoom meetings',
'meeting:update:meeting': 'Update Zoom meetings',
'meeting:delete:meeting': 'Delete Zoom meetings',
'meeting:read:invitation': 'View Zoom meeting invitations',
'meeting:read:list_past_participants': 'View past meeting participants',
'cloud_recording:read:list_user_recordings': 'List Zoom cloud recordings',
'cloud_recording:read:list_recording_files': 'View recording files',
'cloud_recording:delete:recording_file': 'Delete cloud recordings',
// Dropbox scopes
'account_info.read': 'View Dropbox account information',
'files.metadata.read': 'View file and folder names, sizes, and dates',
'files.metadata.write': 'Modify file and folder metadata',
'files.content.read': 'Download and read Dropbox files',
'files.content.write': 'Upload, copy, move, and delete files in Dropbox',
'sharing.read': 'View shared files and folders',
'sharing.write': 'Share files and folders with others',
// WordPress.com scopes
global: 'Full access to manage WordPress.com sites, posts, pages, media, and settings',
// Spotify scopes
'user-read-private': 'View Spotify account details',
'user-read-email': 'View email address on Spotify',
'user-library-read': 'View saved tracks and albums',
'user-library-modify': 'Save and remove tracks and albums from library',
'playlist-read-private': 'View private playlists',
'playlist-read-collaborative': 'View collaborative playlists',
'playlist-modify-public': 'Create and manage public playlists',
'playlist-modify-private': 'Create and manage private playlists',
'user-read-playback-state': 'View current playback state',
'user-modify-playback-state': 'Control playback on Spotify devices',
'user-read-currently-playing': 'View currently playing track',
'user-read-recently-played': 'View recently played tracks',
'user-top-read': 'View top artists and tracks',
'user-follow-read': 'View followed artists and users',
'user-follow-modify': 'Follow and unfollow artists and users',
'user-read-playback-position': 'View playback position in podcasts',
'ugc-image-upload': 'Upload images to Spotify playlists',
// Attio scopes
'record_permission:read-write': 'Read and write CRM records',
'object_configuration:read-write': 'Read and manage object schemas',
'list_configuration:read-write': 'Read and manage list configurations',
'list_entry:read-write': 'Read and write list entries',
'note:read-write': 'Read and write notes',
'task:read-write': 'Read and write tasks',
'comment:read-write': 'Read and write comments and threads',
'user_management:read': 'View workspace members',
'webhook:read-write': 'Manage webhooks',
}
/**
* Get a human-readable description for a scope.
* Falls back to the raw scope string if no description is found.
*/
export function getScopeDescription(scope: string): string {
return SCOPE_DESCRIPTIONS[scope] || scope
}
/**
* Returns a flat list of all available OAuth services with metadata.
* This is safe to use on the server as it doesn't include React components.
@@ -76,37 +478,53 @@ export function getCanonicalScopesForProvider(providerId: string): string[] {
return service?.scopes ? [...service.scopes] : []
}
export function normalizeScopes(scopes: string[]): string[] {
const seen = new Set<string>()
for (const scope of scopes) {
const trimmed = scope.trim()
if (trimmed && !seen.has(trimmed)) {
seen.add(trimmed)
/**
* Get canonical scopes for a service by its serviceId key in OAUTH_PROVIDERS.
* Useful for block definitions to reference scopes from the single source of truth.
*/
export function getScopesForService(serviceId: string): string[] {
for (const provider of Object.values(OAUTH_PROVIDERS)) {
const service = provider.services[serviceId]
if (service) {
return [...service.scopes]
}
}
return Array.from(seen)
return []
}
export function evaluateScopeCoverage(
providerId: string,
grantedScopes: string[]
): ScopeEvaluation {
const canonicalScopes = getCanonicalScopesForProvider(providerId)
const normalizedGranted = normalizeScopes(grantedScopes)
/**
* Scopes that control token behavior but are not returned in OAuth token responses.
* These should be ignored when validating credential scopes.
*/
const IGNORED_SCOPES = new Set([
'offline_access', // Microsoft - requests refresh token
'refresh_token', // Salesforce - requests refresh token
'offline.access', // Airtable - requests refresh token (note: dot not underscore)
])
const canonicalSet = new Set(canonicalScopes)
const grantedSet = new Set(normalizedGranted)
const missingScopes = canonicalScopes.filter((scope) => !grantedSet.has(scope))
const extraScopes = normalizedGranted.filter((scope) => !canonicalSet.has(scope))
return {
canonicalScopes,
grantedScopes: normalizedGranted,
missingScopes,
extraScopes,
requiresReauthorization: missingScopes.length > 0,
/**
* Compute which of the provided requiredScopes are NOT granted by the credential.
* Note: Ignores special OAuth scopes that control token behavior (like offline_access)
* as they are not returned in the token response's scope list even when granted.
*/
export function getMissingRequiredScopes(
credential: { scopes?: string[] } | undefined,
requiredScopes: string[] = []
): string[] {
if (!credential) {
return requiredScopes.filter((s) => !IGNORED_SCOPES.has(s))
}
const granted = new Set(credential.scopes || [])
const missing: string[] = []
for (const s of requiredScopes) {
if (IGNORED_SCOPES.has(s)) continue
if (!granted.has(s)) missing.push(s)
}
return missing
}
/**

View File

@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { buildSelectorContextFromBlock } from '@/lib/workflows/subblocks/context'
import { getBlock } from '@/blocks/registry'
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
import { CREDENTIAL_SET, isUuid } from '@/executor/constants'
@@ -6,7 +7,7 @@ import { fetchCredentialSetById } from '@/hooks/queries/credential-sets'
import { fetchOAuthCredentialDetail } from '@/hooks/queries/oauth-credentials'
import { getSelectorDefinition } from '@/hooks/selectors/registry'
import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution'
import type { SelectorKey } from '@/hooks/selectors/types'
import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('ResolveValues')
@@ -39,74 +40,8 @@ interface ResolutionContext {
blockId?: string
}
/**
* Extended context extracted from block subBlocks for selector resolution
*/
interface ExtendedSelectorContext {
credentialId?: string
domain?: string
projectId?: string
planId?: string
teamId?: string
knowledgeBaseId?: string
siteId?: string
collectionId?: string
spreadsheetId?: string
baseId?: string
datasetId?: string
serviceDeskId?: string
}
function getSemanticFallback(subBlockId: string, subBlockConfig?: SubBlockConfig): string {
if (subBlockConfig?.title) {
return subBlockConfig.title.toLowerCase()
}
const patterns: Record<string, string> = {
credential: 'credential',
channel: 'channel',
channelId: 'channel',
user: 'user',
userId: 'user',
workflow: 'workflow',
workflowId: 'workflow',
file: 'file',
fileId: 'file',
folder: 'folder',
folderId: 'folder',
project: 'project',
projectId: 'project',
team: 'team',
teamId: 'team',
sheet: 'sheet',
sheetId: 'sheet',
document: 'document',
documentId: 'document',
knowledgeBase: 'knowledge base',
knowledgeBaseId: 'knowledge base',
server: 'server',
serverId: 'server',
tool: 'tool',
toolId: 'tool',
calendar: 'calendar',
calendarId: 'calendar',
label: 'label',
labelId: 'label',
site: 'site',
siteId: 'site',
collection: 'collection',
collectionId: 'collection',
item: 'item',
itemId: 'item',
contact: 'contact',
contactId: 'contact',
task: 'task',
taskId: 'task',
chat: 'chat',
chatId: 'chat',
}
return patterns[subBlockId] || 'value'
function getSemanticFallback(subBlockConfig: SubBlockConfig): string {
return (subBlockConfig.title ?? subBlockConfig.id).toLowerCase()
}
async function resolveCredential(credentialId: string, workflowId: string): Promise<string | null> {
@@ -150,26 +85,10 @@ async function resolveWorkflow(workflowId: string): Promise<string | null> {
async function resolveSelectorValue(
value: string,
selectorKey: SelectorKey,
extendedContext: ExtendedSelectorContext,
workflowId: string
selectorContext: SelectorContext
): Promise<string | null> {
try {
const definition = getSelectorDefinition(selectorKey)
const selectorContext = {
workflowId,
credentialId: extendedContext.credentialId,
domain: extendedContext.domain,
projectId: extendedContext.projectId,
planId: extendedContext.planId,
teamId: extendedContext.teamId,
knowledgeBaseId: extendedContext.knowledgeBaseId,
siteId: extendedContext.siteId,
collectionId: extendedContext.collectionId,
spreadsheetId: extendedContext.spreadsheetId,
baseId: extendedContext.baseId,
datasetId: extendedContext.datasetId,
serviceDeskId: extendedContext.serviceDeskId,
}
if (definition.fetchById) {
const result = await definition.fetchById({
@@ -219,37 +138,14 @@ export function formatValueForDisplay(value: unknown): string {
return String(value)
}
/**
* Extracts extended context from a block's subBlocks for selector resolution.
* This mirrors the context extraction done in the UI components.
*/
function extractExtendedContext(
function extractSelectorContext(
blockId: string,
currentState: WorkflowState
): ExtendedSelectorContext {
currentState: WorkflowState,
workflowId: string
): SelectorContext {
const block = currentState.blocks?.[blockId]
if (!block?.subBlocks) return {}
const getStringValue = (id: string): string | undefined => {
const subBlock = block.subBlocks[id] as { value?: unknown } | undefined
const val = subBlock?.value
return typeof val === 'string' ? val : undefined
}
return {
credentialId: getStringValue('credential'),
domain: getStringValue('domain'),
projectId: getStringValue('projectId'),
planId: getStringValue('planId'),
teamId: getStringValue('teamId'),
knowledgeBaseId: getStringValue('knowledgeBaseId'),
siteId: getStringValue('siteId'),
collectionId: getStringValue('collectionId'),
spreadsheetId: getStringValue('spreadsheetId') || getStringValue('fileId'),
baseId: getStringValue('baseId') || getStringValue('baseSelector'),
datasetId: getStringValue('datasetId') || getStringValue('datasetSelector'),
serviceDeskId: getStringValue('serviceDeskId') || getStringValue('serviceDeskSelector'),
}
if (!block?.subBlocks) return { workflowId }
return buildSelectorContextFromBlock(block.type, block.subBlocks, { workflowId })
}
/**
@@ -275,11 +171,14 @@ export async function resolveValueForDisplay(
const blockConfig = getBlock(context.blockType)
const subBlockConfig = blockConfig?.subBlocks.find((sb) => sb.id === context.subBlockId)
const semanticFallback = getSemanticFallback(context.subBlockId, subBlockConfig)
if (!subBlockConfig) {
return { original: value, displayLabel: formatValueForDisplay(value), resolved: false }
}
const semanticFallback = getSemanticFallback(subBlockConfig)
const extendedContext = context.blockId
? extractExtendedContext(context.blockId, context.currentState)
: {}
const selectorCtx = context.blockId
? extractSelectorContext(context.blockId, context.currentState, context.workflowId)
: { workflowId: context.workflowId }
// Credential fields (oauth-input or credential subBlockId)
const isCredentialField =
@@ -311,29 +210,10 @@ export async function resolveValueForDisplay(
// Selector types that require hydration (file-selector, sheet-selector, etc.)
// These support external service IDs like Google Drive file IDs
if (subBlockConfig && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlockConfig.type)) {
const resolution = resolveSelectorForSubBlock(subBlockConfig, {
workflowId: context.workflowId,
credentialId: extendedContext.credentialId,
domain: extendedContext.domain,
projectId: extendedContext.projectId,
planId: extendedContext.planId,
teamId: extendedContext.teamId,
knowledgeBaseId: extendedContext.knowledgeBaseId,
siteId: extendedContext.siteId,
collectionId: extendedContext.collectionId,
spreadsheetId: extendedContext.spreadsheetId,
baseId: extendedContext.baseId,
datasetId: extendedContext.datasetId,
serviceDeskId: extendedContext.serviceDeskId,
})
const resolution = resolveSelectorForSubBlock(subBlockConfig, selectorCtx)
if (resolution?.key) {
const label = await resolveSelectorValue(
value,
resolution.key,
extendedContext,
context.workflowId
)
const label = await resolveSelectorValue(value, resolution.key, selectorCtx)
if (label) {
return { original: value, displayLabel: label, resolved: true }
}

View File

@@ -1,9 +1,12 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { migrateSubblockIds } from './subblock-migrations'
vi.unmock('@/blocks/registry')
import { backfillCanonicalModes, migrateSubblockIds } from './subblock-migrations'
function makeBlock(overrides: Partial<BlockState> & { type: string }): BlockState {
return {
@@ -181,3 +184,185 @@ describe('migrateSubblockIds', () => {
expect(migrated).toBe(false)
})
})
describe('backfillCanonicalModes', () => {
it('should add missing canonicalModes entry for knowledge block with basic value', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
data: {},
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-uuid',
},
},
}),
}
const { blocks, migrated } = backfillCanonicalModes(input)
expect(migrated).toBe(true)
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
expect(modes.knowledgeBaseId).toBe('basic')
})
it('should resolve to advanced when only the advanced value is set', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
data: {},
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
manualKnowledgeBaseId: {
id: 'manualKnowledgeBaseId',
type: 'short-input',
value: 'kb-uuid-manual',
},
},
}),
}
const { blocks, migrated } = backfillCanonicalModes(input)
expect(migrated).toBe(true)
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
expect(modes.knowledgeBaseId).toBe('advanced')
})
it('should not overwrite existing canonicalModes entries', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
data: { canonicalModes: { knowledgeBaseId: 'advanced' } },
subBlocks: {
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-uuid',
},
},
}),
}
const { blocks, migrated } = backfillCanonicalModes(input)
expect(migrated).toBe(false)
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
expect(modes.knowledgeBaseId).toBe('advanced')
})
it('should skip blocks with no canonical pairs in their config', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'function',
data: {},
subBlocks: {
code: { id: 'code', type: 'code', value: '' },
},
}),
}
const { migrated } = backfillCanonicalModes(input)
expect(migrated).toBe(false)
})
it('should not mutate the input blocks', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
data: {},
subBlocks: {
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-uuid',
},
},
}),
}
const { blocks } = backfillCanonicalModes(input)
expect(input.b1.data?.canonicalModes).toBeUndefined()
expect((blocks.b1.data?.canonicalModes as Record<string, string>).knowledgeBaseId).toBe('basic')
expect(blocks).not.toBe(input)
})
it('should resolve correctly when existing field became the basic variant', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
data: {},
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-uuid',
},
manualKnowledgeBaseId: {
id: 'manualKnowledgeBaseId',
type: 'short-input',
value: '',
},
},
}),
}
const { blocks, migrated } = backfillCanonicalModes(input)
expect(migrated).toBe(true)
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
expect(modes.knowledgeBaseId).toBe('basic')
})
it('should resolve correctly when existing field became the advanced variant', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
data: {},
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: '',
},
manualKnowledgeBaseId: {
id: 'manualKnowledgeBaseId',
type: 'short-input',
value: 'manually-entered-kb-id',
},
},
}),
}
const { blocks, migrated } = backfillCanonicalModes(input)
expect(migrated).toBe(true)
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
expect(modes.knowledgeBaseId).toBe('advanced')
})
it('should default to basic when neither value is set', () => {
const input: Record<string, BlockState> = {
b1: makeBlock({
type: 'knowledge',
data: {},
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
},
}),
}
const { blocks, migrated } = backfillCanonicalModes(input)
expect(migrated).toBe(true)
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
expect(modes.knowledgeBaseId).toBe('basic')
})
})

View File

@@ -1,4 +1,11 @@
import { createLogger } from '@sim/logger'
import {
buildCanonicalIndex,
buildSubBlockValues,
isCanonicalPair,
resolveCanonicalMode,
} from '@/lib/workflows/subblocks/visibility'
import { getBlock } from '@/blocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
const logger = createLogger('SubblockMigrations')
@@ -88,3 +95,59 @@ export function migrateSubblockIds(blocks: Record<string, BlockState>): {
return { blocks: result, migrated: anyMigrated }
}
/**
* Backfills missing `canonicalModes` entries in block data.
*
* When a canonical pair is added to a block definition, existing blocks
* won't have the entry in `data.canonicalModes`. Without it the editor
* toggle may not render correctly. This resolves the correct mode based
* on which subblock value is populated and adds the missing entry.
*/
export function backfillCanonicalModes(blocks: Record<string, BlockState>): {
blocks: Record<string, BlockState>
migrated: boolean
} {
let anyMigrated = false
const result: Record<string, BlockState> = {}
for (const [blockId, block] of Object.entries(blocks)) {
const blockConfig = getBlock(block.type)
if (!blockConfig?.subBlocks || !block.subBlocks) {
result[blockId] = block
continue
}
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
const pairs = Object.values(canonicalIndex.groupsById).filter(isCanonicalPair)
if (pairs.length === 0) {
result[blockId] = block
continue
}
const existing = (block.data?.canonicalModes ?? {}) as Record<string, 'basic' | 'advanced'>
let patched: Record<string, 'basic' | 'advanced'> | null = null
const values = buildSubBlockValues(block.subBlocks)
for (const group of pairs) {
if (existing[group.canonicalId] != null) continue
const resolved = resolveCanonicalMode(group, values)
if (!patched) patched = { ...existing }
patched[group.canonicalId] = resolved
}
if (patched) {
anyMigrated = true
result[blockId] = {
...block,
data: { ...(block.data ?? {}), canonicalModes: patched },
}
} else {
result[blockId] = block
}
}
return { blocks: result, migrated: anyMigrated }
}

View File

@@ -14,7 +14,10 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm'
import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid'
import type { DbOrTx } from '@/lib/db/types'
import { migrateSubblockIds } from '@/lib/workflows/migrations/subblock-migrations'
import {
backfillCanonicalModes,
migrateSubblockIds,
} from '@/lib/workflows/migrations/subblock-migrations'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types'
@@ -176,6 +179,11 @@ const applyBlockMigrations = createMigrationPipeline([
const { blocks, migrated } = migrateSubblockIds(ctx.blocks)
return { ...ctx, blocks, migrated: ctx.migrated || migrated }
},
(ctx) => {
const { blocks, migrated } = backfillCanonicalModes(ctx.blocks)
return { ...ctx, blocks, migrated: ctx.migrated || migrated }
},
])
/**
@@ -410,10 +418,14 @@ export async function loadWorkflowFromNormalizedTables(
Promise.resolve().then(async () => {
try {
for (const [blockId, block] of Object.entries(finalBlocks)) {
if (block.subBlocks !== blocksMap[blockId]?.subBlocks) {
if (block !== blocksMap[blockId]) {
await db
.update(workflowBlocks)
.set({ subBlocks: block.subBlocks, updatedAt: new Date() })
.set({
subBlocks: block.subBlocks,
data: block.data,
updatedAt: new Date(),
})
.where(
and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))
)

View File

@@ -0,0 +1,125 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.unmock('@/blocks/registry')
import { getAllBlocks } from '@/blocks/registry'
import { buildSelectorContextFromBlock, SELECTOR_CONTEXT_FIELDS } from './context'
import { buildCanonicalIndex, isCanonicalPair } from './visibility'
describe('buildSelectorContextFromBlock', () => {
it('should extract knowledgeBaseId from knowledgeBaseSelector via canonical mapping', () => {
const ctx = buildSelectorContextFromBlock('knowledge', {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-uuid-123',
},
})
expect(ctx.knowledgeBaseId).toBe('kb-uuid-123')
})
it('should extract knowledgeBaseId from manualKnowledgeBaseId via canonical mapping', () => {
const ctx = buildSelectorContextFromBlock('knowledge', {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
manualKnowledgeBaseId: {
id: 'manualKnowledgeBaseId',
type: 'short-input',
value: 'manual-kb-id',
},
})
expect(ctx.knowledgeBaseId).toBe('manual-kb-id')
})
it('should skip null/empty values', () => {
const ctx = buildSelectorContextFromBlock('knowledge', {
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: '',
},
})
expect(ctx.knowledgeBaseId).toBeUndefined()
})
it('should return empty context for unknown block types', () => {
const ctx = buildSelectorContextFromBlock('nonexistent_block', {
foo: { id: 'foo', type: 'short-input', value: 'bar' },
})
expect(ctx).toEqual({})
})
it('should pass through workflowId from opts', () => {
const ctx = buildSelectorContextFromBlock(
'knowledge',
{ operation: { id: 'operation', type: 'dropdown', value: 'search' } },
{ workflowId: 'wf-123' }
)
expect(ctx.workflowId).toBe('wf-123')
})
it('should ignore subblock keys not in SELECTOR_CONTEXT_FIELDS', () => {
const ctx = buildSelectorContextFromBlock('knowledge', {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
query: { id: 'query', type: 'short-input', value: 'some search query' },
})
expect((ctx as Record<string, unknown>).query).toBeUndefined()
expect((ctx as Record<string, unknown>).operation).toBeUndefined()
})
})
describe('SELECTOR_CONTEXT_FIELDS validation', () => {
it('every entry must be a canonicalParamId (if a canonical pair exists) or a direct subblock ID', () => {
const allCanonicalParamIds = new Set<string>()
const allSubBlockIds = new Set<string>()
const idsInCanonicalPairs = new Set<string>()
for (const block of getAllBlocks()) {
const index = buildCanonicalIndex(block.subBlocks)
for (const sb of block.subBlocks) {
allSubBlockIds.add(sb.id)
if (sb.canonicalParamId) {
allCanonicalParamIds.add(sb.canonicalParamId)
}
}
for (const group of Object.values(index.groupsById)) {
if (!isCanonicalPair(group)) continue
if (group.basicId) idsInCanonicalPairs.add(group.basicId)
for (const advId of group.advancedIds) idsInCanonicalPairs.add(advId)
}
}
const errors: string[] = []
for (const field of SELECTOR_CONTEXT_FIELDS) {
const f = field as string
if (allCanonicalParamIds.has(f)) continue
if (idsInCanonicalPairs.has(f)) {
errors.push(
`"${f}" is a member subblock ID inside a canonical pair — use the canonicalParamId instead`
)
continue
}
if (!allSubBlockIds.has(f)) {
errors.push(`"${f}" is not a canonicalParamId or subblock ID in any block definition`)
}
}
if (errors.length > 0) {
throw new Error(`SELECTOR_CONTEXT_FIELDS validation failed:\n${errors.join('\n')}`)
}
})
})

View File

@@ -0,0 +1,60 @@
import { getBlock } from '@/blocks'
import type { SelectorContext } from '@/hooks/selectors/types'
import type { SubBlockState } from '@/stores/workflows/workflow/types'
import { buildCanonicalIndex } from './visibility'
/**
* Canonical param IDs (or raw subblock IDs) that correspond to SelectorContext fields.
* A subblock's resolved canonical key is set on the context only if it appears here.
*/
export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
'oauthCredential',
'domain',
'teamId',
'projectId',
'knowledgeBaseId',
'planId',
'siteId',
'collectionId',
'spreadsheetId',
'fileId',
'baseId',
'datasetId',
'serviceDeskId',
])
/**
* Builds a SelectorContext from a block's subBlocks using the canonical index.
*
* Iterates all subblocks, resolves each through canonicalIdBySubBlockId to get
* the canonical key, then checks it against SELECTOR_CONTEXT_FIELDS.
* This avoids hardcoding subblock IDs and automatically handles basic/advanced
* renames.
*/
export function buildSelectorContextFromBlock(
blockType: string,
subBlocks: Record<string, SubBlockState | { value?: unknown }>,
opts?: { workflowId?: string }
): SelectorContext {
const context: SelectorContext = {}
if (opts?.workflowId) context.workflowId = opts.workflowId
const blockConfig = getBlock(blockType)
if (!blockConfig) return context
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
const val = subBlock?.value
if (val === null || val === undefined) continue
const strValue = typeof val === 'string' ? val : String(val)
if (!strValue) continue
const canonicalKey = canonicalIndex.canonicalIdBySubBlockId[subBlockId] ?? subBlockId
if (SELECTOR_CONTEXT_FIELDS.has(canonicalKey as keyof SelectorContext)) {
context[canonicalKey as keyof SelectorContext] = strValue
}
}
return context
}