v0.5.108: workflow input params in agent tools, bun upgrade, dropdown selectors for 14 blocks

This commit is contained in:
Vikhyath Mondreti
2026-03-06 21:02:25 -08:00
committed by GitHub
115 changed files with 5054 additions and 2170 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

@@ -1,4 +1,4 @@
FROM oven/bun:1.3.9-alpine
FROM oven/bun:1.3.10-alpine
# Install necessary packages for development
RUN apk add --no-cache \

View File

@@ -20,7 +20,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9
bun-version: 1.3.10
- name: Setup Node
uses: actions/setup-node@v4

View File

@@ -23,7 +23,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9
bun-version: 1.3.10
- name: Cache Bun dependencies
uses: actions/cache@v4
@@ -122,7 +122,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9
bun-version: 1.3.10
- name: Cache Bun dependencies
uses: actions/cache@v4

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9
bun-version: 1.3.10
- name: Cache Bun dependencies
uses: actions/cache@v4

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9
bun-version: 1.3.10
- name: Setup Node.js for npm publishing
uses: actions/setup-node@v4

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9
bun-version: 1.3.10
- name: Setup Node.js for npm publishing
uses: actions/setup-node@v4

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.9
bun-version: 1.3.10
- name: Setup Node
uses: actions/setup-node@v4

View File

@@ -1014,4 +1014,36 @@ Get Jira users. If an account ID is provided, returns a single user. Otherwise,
| `startAt` | number | Pagination start index |
| `maxResults` | number | Maximum results per page |
### `jira_search_users`
Search for Jira users by email address or display name. Returns matching users with their accountId, displayName, and emailAddress.
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
| `query` | string | Yes | A query string to search for users. Can be an email address, display name, or partial match. |
| `maxResults` | number | No | Maximum number of users to return \(default: 50, max: 1000\) |
| `startAt` | number | No | The index of the first user to return \(for pagination, default: 0\) |
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `ts` | string | ISO 8601 timestamp of the operation |
| `users` | array | Array of matching Jira users |
| ↳ `accountId` | string | Atlassian account ID of the user |
| ↳ `displayName` | string | Display name of the user |
| ↳ `active` | boolean | Whether the user account is active |
| ↳ `emailAddress` | string | Email address of the user |
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
| ↳ `timeZone` | string | User timezone |
| ↳ `self` | string | REST API URL for this user |
| `total` | number | Number of users returned in this page \(may be less than total matches\) |
| `startAt` | number | Pagination start index |
| `maxResults` | number | Maximum results per page |

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

@@ -0,0 +1,79 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('AirtableBasesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch('https://api.airtable.com/v0/meta/bases', {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Airtable bases', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Airtable bases', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const bases = (data.bases || []).map((base: { id: string; name: string }) => ({
id: base.id,
name: base.name,
}))
return NextResponse.json({ bases })
} catch (error) {
logger.error('Error processing Airtable bases request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Airtable bases', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,95 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAirtableId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('AirtableTablesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, baseId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (!baseId) {
logger.error('Missing baseId in request')
return NextResponse.json({ error: 'Base ID is required' }, { status: 400 })
}
const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId')
if (!baseIdValidation.isValid) {
logger.error('Invalid baseId', { error: baseIdValidation.error })
return NextResponse.json({ error: baseIdValidation.error }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch(
`https://api.airtable.com/v0/meta/bases/${baseIdValidation.sanitized}/tables`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Airtable tables', {
status: response.status,
error: errorData,
baseId,
})
return NextResponse.json(
{ error: 'Failed to fetch Airtable tables', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const tables = (data.tables || []).map((table: { id: string; name: string }) => ({
id: table.id,
name: table.name,
}))
return NextResponse.json({ tables })
} catch (error) {
logger.error('Error processing Airtable tables request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Airtable tables', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,79 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('AsanaWorkspacesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch('https://app.asana.com/api/1.0/workspaces', {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Asana workspaces', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Asana workspaces', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const workspaces = (data.data || []).map((workspace: { gid: string; name: string }) => ({
id: workspace.gid,
name: workspace.name,
}))
return NextResponse.json({ workspaces })
} catch (error) {
logger.error('Error processing Asana workspaces request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Asana workspaces', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,79 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('AttioListsAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch('https://api.attio.com/v2/lists', {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Attio lists', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Attio lists', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const lists = (data.data || []).map((list: { api_slug: string; name: string }) => ({
id: list.api_slug,
name: list.name,
}))
return NextResponse.json({ lists })
} catch (error) {
logger.error('Error processing Attio lists request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Attio lists', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,79 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('AttioObjectsAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch('https://api.attio.com/v2/objects', {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Attio objects', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Attio objects', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const objects = (data.data || []).map((obj: { api_slug: string; singular_noun: string }) => ({
id: obj.api_slug,
name: obj.singular_noun,
}))
return NextResponse.json({ objects })
} catch (error) {
logger.error('Error processing Attio objects request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Attio objects', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,83 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('CalcomEventTypesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch('https://api.cal.com/v2/event-types', {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'cal-api-version': '2024-06-14',
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Cal.com event types', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Cal.com event types', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const eventTypes = (data.data || []).map(
(eventType: { id: number; title: string; slug: string }) => ({
id: String(eventType.id),
title: eventType.title,
slug: eventType.slug,
})
)
return NextResponse.json({ eventTypes })
} catch (error) {
logger.error('Error processing Cal.com event types request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Cal.com event types', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,80 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('CalcomSchedulesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch('https://api.cal.com/v2/schedules', {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'cal-api-version': '2024-06-11',
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Cal.com schedules', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Cal.com schedules', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const schedules = (data.data || []).map((schedule: { id: number; name: string }) => ({
id: String(schedule.id),
name: schedule.name,
}))
return NextResponse.json({ schedules })
} catch (error) {
logger.error('Error processing Cal.com schedules request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Cal.com schedules', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,96 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
const logger = createLogger('ConfluenceSelectorSpacesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, domain } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const cloudId = await getConfluenceCloudId(domain, accessToken)
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/spaces?limit=250`
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => null)
logger.error('Confluence API error:', {
status: response.status,
statusText: response.statusText,
error: errorData,
})
const errorMessage =
errorData?.message || `Failed to list Confluence spaces (${response.status})`
return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
const spaces = (data.results || []).map((space: { id: string; name: string; key: string }) => ({
id: space.id,
name: space.name,
key: space.key,
}))
return NextResponse.json({ spaces })
} catch (error) {
logger.error('Error listing Confluence spaces:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,100 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleBigQueryDatasetsAPI')
export const dynamic = 'force-dynamic'
/**
* POST /api/tools/google_bigquery/datasets
*
* Fetches the list of BigQuery datasets for a given project using the caller's OAuth credential.
*
* @param request - Incoming request containing `credential`, `workflowId`, and `projectId` in the JSON body
* @returns JSON response with a `datasets` array, each entry containing `datasetReference` and optional `friendlyName`
*/
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, projectId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (!projectId) {
logger.error('Missing project ID in request')
return NextResponse.json({ error: 'Project ID is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch(
`https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets?maxResults=200`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch BigQuery datasets', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch BigQuery datasets', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const datasets = (data.datasets || []).map(
(ds: {
datasetReference: { datasetId: string; projectId: string }
friendlyName?: string
}) => ({
datasetReference: ds.datasetReference,
friendlyName: ds.friendlyName,
})
)
return NextResponse.json({ datasets })
} catch (error) {
logger.error('Error processing BigQuery datasets request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve BigQuery datasets', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,94 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleBigQueryTablesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, projectId, datasetId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (!projectId) {
logger.error('Missing project ID in request')
return NextResponse.json({ error: 'Project ID is required' }, { status: 400 })
}
if (!datasetId) {
logger.error('Missing dataset ID in request')
return NextResponse.json({ error: 'Dataset ID is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch(
`https://bigquery.googleapis.com/bigquery/v2/projects/${encodeURIComponent(projectId)}/datasets/${encodeURIComponent(datasetId)}/tables?maxResults=200`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch BigQuery tables', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch BigQuery tables', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const tables = (data.tables || []).map(
(t: { tableReference: { tableId: string }; friendlyName?: string }) => ({
tableReference: t.tableReference,
friendlyName: t.friendlyName,
})
)
return NextResponse.json({ tables })
} catch (error) {
logger.error('Error processing BigQuery tables request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve BigQuery tables', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,79 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('GoogleTasksTaskListsAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch('https://tasks.googleapis.com/tasks/v1/users/@me/lists', {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Google Tasks task lists', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Google Tasks task lists', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const taskLists = (data.items || []).map((list: { id: string; title: string }) => ({
id: list.id,
title: list.title,
}))
return NextResponse.json({ taskLists })
} catch (error) {
logger.error('Error processing Google Tasks task lists request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Google Tasks task lists', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,103 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
const logger = createLogger('JsmSelectorRequestTypesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, domain, serviceDeskId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
if (!serviceDeskId) {
return NextResponse.json({ error: 'Service Desk ID is required' }, { status: 400 })
}
const serviceDeskIdValidation = validateAlphanumericId(serviceDeskId, 'serviceDeskId')
if (!serviceDeskIdValidation.isValid) {
return NextResponse.json({ error: serviceDeskIdValidation.error }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const cloudId = await getJiraCloudId(domain, accessToken)
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!)
const url = `${baseUrl}/servicedesk/${serviceDeskIdValidation.sanitized}/requesttype?limit=100`
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}` },
{ status: response.status }
)
}
const data = await response.json()
const requestTypes = (data.values || []).map((rt: { id: string; name: string }) => ({
id: rt.id,
name: rt.name,
}))
return NextResponse.json({ requestTypes })
} catch (error) {
logger.error('Error listing JSM request types:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,94 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
const logger = createLogger('JsmSelectorServiceDesksAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, domain } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (!domain) {
return NextResponse.json({ error: 'Domain is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const cloudId = await getJiraCloudId(domain, accessToken)
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
}
const baseUrl = getJsmApiBaseUrl(cloudIdValidation.sanitized!)
const url = `${baseUrl}/servicedesk?limit=100`
const response = await fetch(url, {
method: 'GET',
headers: getJsmHeaders(accessToken),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('JSM API error:', {
status: response.status,
statusText: response.statusText,
error: errorText,
})
return NextResponse.json(
{ error: `JSM API error: ${response.status} ${response.statusText}` },
{ status: response.status }
)
}
const data = await response.json()
const serviceDesks = (data.values || []).map((sd: { id: string; projectName: string }) => ({
id: sd.id,
name: sd.projectName,
}))
return NextResponse.json({ serviceDesks })
} catch (error) {
logger.error('Error listing JSM service desks:', error)
return NextResponse.json(
{ error: (error as Error).message || 'Internal server error' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,72 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('MicrosoftPlannerPlansAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error(`[${requestId}] Missing credential in request`)
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)
return NextResponse.json(
{ error: 'Failed to obtain valid access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch('https://graph.microsoft.com/v1.0/me/planner/plans', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()
logger.error(`[${requestId}] Microsoft Graph API error:`, errorText)
return NextResponse.json(
{ error: 'Failed to fetch plans from Microsoft Graph' },
{ status: response.status }
)
}
const data = await response.json()
const plans = data.value || []
const filteredPlans = plans.map((plan: { id: string; title: string }) => ({
id: plan.id,
title: plan.title,
}))
return NextResponse.json({ plans: filteredPlans })
} catch (error) {
logger.error(`[${requestId}] Error fetching Microsoft Planner plans:`, error)
return NextResponse.json({ error: 'Failed to fetch plans' }, { status: 500 })
}
}

View File

@@ -1,38 +1,29 @@
import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { PlannerTask } from '@/tools/microsoft_planner/types'
const logger = createLogger('MicrosoftPlannerTasksAPI')
export async function GET(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const session = await getSession()
const body = await request.json()
const { credential, workflowId, planId } = body
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const planId = searchParams.get('planId')
if (!credentialId) {
logger.error(`[${requestId}] Missing credentialId parameter`)
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
if (!credential) {
logger.error(`[${requestId}] Missing credential in request`)
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
if (!planId) {
logger.error(`[${requestId}] Missing planId parameter`)
logger.error(`[${requestId}] Missing planId in request`)
return NextResponse.json({ error: 'Plan ID is required' }, { status: 400 })
}
@@ -42,52 +33,35 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: planIdValidation.error }, { status: 400 })
}
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
return NextResponse.json(
{ error: 'Failed to obtain valid access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch(`https://graph.microsoft.com/v1.0/planner/plans/${planId}/tasks`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
const response = await fetch(
`https://graph.microsoft.com/v1.0/planner/plans/${planIdValidation.sanitized}/tasks`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
if (!response.ok) {
const errorText = await response.text()

View File

@@ -0,0 +1,86 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { extractTitleFromItem } from '@/tools/notion/utils'
const logger = createLogger('NotionDatabasesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch('https://api.notion.com/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Notion-Version': '2022-06-28',
},
body: JSON.stringify({
filter: { value: 'database', property: 'object' },
page_size: 100,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Notion databases', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Notion databases', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const databases = (data.results || []).map((db: Record<string, unknown>) => ({
id: db.id as string,
name: extractTitleFromItem(db),
}))
return NextResponse.json({ databases })
} catch (error) {
logger.error('Error processing Notion databases request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Notion databases', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,86 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { extractTitleFromItem } from '@/tools/notion/utils'
const logger = createLogger('NotionPagesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch('https://api.notion.com/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Notion-Version': '2022-06-28',
},
body: JSON.stringify({
filter: { value: 'page', property: 'object' },
page_size: 100,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Notion pages', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Notion pages', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const pages = (data.results || []).map((page: Record<string, unknown>) => ({
id: page.id as string,
name: extractTitleFromItem(page),
}))
return NextResponse.json({ pages })
} catch (error) {
logger.error('Error processing Notion pages request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Notion pages', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,79 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('PipedrivePipelinesAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch('https://api.pipedrive.com/v1/pipelines', {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Pipedrive pipelines', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Pipedrive pipelines', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const pipelines = (data.data || []).map((pipeline: { id: number; name: string }) => ({
id: String(pipeline.id),
name: pipeline.name,
}))
return NextResponse.json({ pipelines })
} catch (error) {
logger.error('Error processing Pipedrive pipelines request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Pipedrive pipelines', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,91 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateSharePointSiteId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
export const dynamic = 'force-dynamic'
const logger = createLogger('SharePointListsAPI')
interface SharePointList {
id: string
displayName: string
description?: string
webUrl?: string
list?: {
hidden?: boolean
}
}
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId, siteId } = body
if (!credential) {
logger.error(`[${requestId}] Missing credential in request`)
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const siteIdValidation = validateSharePointSiteId(siteId)
if (!siteIdValidation.isValid) {
logger.error(`[${requestId}] Invalid siteId: ${siteIdValidation.error}`)
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error(`[${requestId}] Failed to obtain valid access token`)
return NextResponse.json(
{ error: 'Failed to obtain valid access token', authRequired: true },
{ status: 401 }
)
}
const url = `https://graph.microsoft.com/v1.0/sites/${siteIdValidation.sanitized}/lists?$select=id,displayName,description,webUrl&$expand=list($select=hidden)&$top=100`
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
return NextResponse.json(
{ error: errorData.error?.message || 'Failed to fetch lists from SharePoint' },
{ status: response.status }
)
}
const data = await response.json()
const lists = (data.value || [])
.filter((list: SharePointList) => list.list?.hidden !== true)
.map((list: SharePointList) => ({
id: list.id,
displayName: list.displayName,
}))
logger.info(`[${requestId}] Successfully fetched ${lists.length} SharePoint lists`)
return NextResponse.json({ lists }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching lists from SharePoint`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,79 +1,45 @@
import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import type { SharepointSite } from '@/tools/sharepoint/types'
export const dynamic = 'force-dynamic'
const logger = createLogger('SharePointSitesAPI')
/**
* Get SharePoint sites from Microsoft Graph API
*/
export async function GET(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
const body = await request.json()
const { credential, workflowId, query } = body
if (!credential) {
logger.error(`[${requestId}] Missing credential in request`)
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')
const query = searchParams.get('query') || ''
if (!credentialId) {
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const credentialIdValidation = validateAlphanumericId(credentialId, 'credentialId', 255)
if (!credentialIdValidation.isValid) {
logger.warn(`[${requestId}] Invalid credential ID`, { error: credentialIdValidation.error })
return NextResponse.json({ error: credentialIdValidation.error }, { status: 400 })
}
const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
logger.error(`[${requestId}] Failed to obtain valid access token`)
return NextResponse.json(
{ error: 'Failed to obtain valid access token', authRequired: true },
{ status: 401 }
)
}
const searchQuery = query || '*'

View File

@@ -0,0 +1,87 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('TrelloBoardsAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const apiKey = process.env.TRELLO_API_KEY
if (!apiKey) {
logger.error('Trello API key not configured')
return NextResponse.json({ error: 'Trello API key not configured' }, { status: 500 })
}
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch(
`https://api.trello.com/1/members/me/boards?key=${apiKey}&token=${accessToken}&fields=id,name,closed`,
{
headers: {
Accept: 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Trello boards', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Trello boards', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const boards = (data || []).map((board: { id: string; name: string; closed: boolean }) => ({
id: board.id,
name: board.name,
closed: board.closed,
}))
return NextResponse.json({ boards })
} catch (error) {
logger.error('Error processing Trello boards request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Trello boards', details: (error as Error).message },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,82 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
const logger = createLogger('ZoomMeetingsAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
try {
const body = await request.json()
const { credential, workflowId } = body
if (!credential) {
logger.error('Missing credential in request')
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
}
const authz = await authorizeCredentialUse(request as any, {
credentialId: credential,
workflowId,
})
if (!authz.ok || !authz.credentialOwnerUserId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
logger.error('Failed to get access token', {
credentialId: credential,
userId: authz.credentialOwnerUserId,
})
return NextResponse.json(
{ error: 'Could not retrieve access token', authRequired: true },
{ status: 401 }
)
}
const response = await fetch(
'https://api.zoom.us/v2/users/me/meetings?page_size=300&type=scheduled',
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
logger.error('Failed to fetch Zoom meetings', {
status: response.status,
error: errorData,
})
return NextResponse.json(
{ error: 'Failed to fetch Zoom meetings', details: errorData },
{ status: response.status }
)
}
const data = await response.json()
const meetings = (data.meetings || []).map((meeting: { id: number; topic: string }) => ({
id: String(meeting.id),
name: meeting.topic,
}))
return NextResponse.json({ meetings })
} catch (error) {
logger.error('Error processing Zoom meetings request:', error)
return NextResponse.json(
{ error: 'Failed to retrieve Zoom meetings', details: (error as Error).message },
{ status: 500 }
)
}
}

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

@@ -1969,9 +1969,8 @@ export const ToolInput = memo(function ToolInput({
}
if (useSubBlocks && displaySubBlocks.length > 0) {
const allBlockSubBlocks = toolBlock?.subBlocks || []
const coveredParamIds = new Set(
allBlockSubBlocks.flatMap((sb) => {
displaySubBlocks.flatMap((sb) => {
const ids = [sb.id]
if (sb.canonicalParamId) ids.push(sb.canonicalParamId)
const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]

View File

@@ -2,8 +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 { 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'
@@ -12,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)
@@ -29,53 +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)) 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,
}

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

@@ -549,21 +549,48 @@ const SubBlockRow = memo(function SubBlockRow({
return typeof option === 'string' ? option : option.label
}, [subBlock, rawValue])
const domainValue = getStringValue('domain')
const teamIdValue = getStringValue('teamId')
const projectIdValue = getStringValue('projectId')
const planIdValue = getStringValue('planId')
const resolveContextValue = useCallback(
(key: string): string | undefined => {
const resolved = resolveDependencyValue(
key,
rawValues,
canonicalIndex || buildCanonicalIndex([]),
canonicalModeOverrides
)
return typeof resolved === 'string' && resolved.length > 0 ? resolved : undefined
},
[rawValues, canonicalIndex, canonicalModeOverrides]
)
const domainValue = resolveContextValue('domain')
const teamIdValue = resolveContextValue('teamId')
const projectIdValue = resolveContextValue('projectId')
const planIdValue = resolveContextValue('planId')
const baseIdValue = resolveContextValue('baseId')
const datasetIdValue = resolveContextValue('datasetId')
const serviceDeskIdValue = resolveContextValue('serviceDeskId')
const siteIdValue = resolveContextValue('siteId')
const collectionIdValue = resolveContextValue('collectionId')
const spreadsheetIdValue = resolveContextValue('spreadsheetId')
const fileIdValue = resolveContextValue('fileId')
const { displayName: selectorDisplayName } = useSelectorDisplayName({
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,
projectId: projectIdValue,
planId: planIdValue,
baseId: baseIdValue,
datasetId: datasetIdValue,
serviceDeskId: serviceDeskIdValue,
siteId: siteIdValue,
collectionId: collectionIdValue,
spreadsheetId: spreadsheetIdValue,
fileId: fileIdValue,
})
const { knowledgeBase: kbForDisplayName } = useKnowledgeBase(

View File

@@ -667,15 +667,18 @@ describe.concurrent('Blocks Module', () => {
const errors: string[] = []
for (const block of blocks) {
const allSubBlockIds = new Set(block.subBlocks.map((sb) => sb.id))
// Exclude trigger-mode subBlocks — they operate in a separate rendering context
// and their IDs don't participate in canonical param resolution
const nonTriggerSubBlocks = block.subBlocks.filter((sb) => sb.mode !== 'trigger')
const allSubBlockIds = new Set(nonTriggerSubBlocks.map((sb) => sb.id))
const canonicalParamIds = new Set(
block.subBlocks.filter((sb) => sb.canonicalParamId).map((sb) => sb.canonicalParamId)
nonTriggerSubBlocks.filter((sb) => sb.canonicalParamId).map((sb) => sb.canonicalParamId)
)
for (const canonicalId of canonicalParamIds) {
if (allSubBlockIds.has(canonicalId!)) {
// Check if the matching subBlock also has a canonicalParamId pointing to itself
const matchingSubBlock = block.subBlocks.find(
const matchingSubBlock = nonTriggerSubBlocks.find(
(sb) => sb.id === canonicalId && !sb.canonicalParamId
)
if (matchingSubBlock) {
@@ -857,6 +860,10 @@ describe.concurrent('Blocks Module', () => {
if (typeof subBlock.condition === 'function') {
continue
}
// Skip trigger-mode subBlocks — they operate in a separate rendering context
if (subBlock.mode === 'trigger') {
continue
}
const conditionKey = serializeCondition(subBlock.condition)
if (!canonicalByCondition.has(subBlock.canonicalParamId)) {
canonicalByCondition.set(subBlock.canonicalParamId, new Set())

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,
},
@@ -57,21 +52,51 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'baseSelector',
title: 'Base',
type: 'project-selector',
canonicalParamId: 'baseId',
serviceId: 'airtable',
selectorKey: 'airtable.bases',
selectorAllowSearch: false,
placeholder: 'Select Airtable base',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'listBases', not: true },
required: { field: 'operation', value: 'listBases', not: true },
},
{
id: 'baseId',
title: 'Base ID',
type: 'short-input',
canonicalParamId: 'baseId',
placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'listBases', not: true },
required: { field: 'operation', value: 'listBases', not: true },
},
{
id: 'tableSelector',
title: 'Table',
type: 'file-selector',
canonicalParamId: 'tableId',
serviceId: 'airtable',
selectorKey: 'airtable.tables',
selectorAllowSearch: false,
placeholder: 'Select Airtable table',
dependsOn: ['credential', 'baseSelector'],
mode: 'basic',
condition: { field: 'operation', value: ['listBases', 'listTables'], not: true },
required: { field: 'operation', value: ['listBases', 'listTables'], not: true },
},
{
id: 'tableId',
title: 'Table ID',
type: 'short-input',
canonicalParamId: 'tableId',
placeholder: 'Enter table ID (e.g., tblXXXXXXXXXXXXXX)',
dependsOn: ['credential', 'baseId'],
mode: 'advanced',
condition: { field: 'operation', value: ['listBases', 'listTables'], not: true },
required: { field: 'operation', value: ['listBases', 'listTables'], not: 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',
},
{
@@ -48,12 +49,31 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'workspaceSelector',
title: 'Workspace',
type: 'project-selector',
canonicalParamId: 'workspace',
serviceId: 'asana',
selectorKey: 'asana.workspaces',
selectorAllowSearch: false,
placeholder: 'Select Asana workspace',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['create_task', 'get_projects', 'search_tasks'],
},
required: true,
},
{
id: 'workspace',
title: 'Workspace GID',
type: 'short-input',
canonicalParamId: 'workspace',
required: true,
placeholder: 'Enter Asana workspace GID',
mode: 'advanced',
condition: {
field: 'operation',
value: ['create_task', 'get_projects', 'search_tasks'],
@@ -81,11 +101,29 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
value: ['update_task', 'add_comment'],
},
},
{
id: 'getTasksWorkspaceSelector',
title: 'Workspace',
type: 'project-selector',
canonicalParamId: 'getTasks_workspace',
serviceId: 'asana',
selectorKey: 'asana.workspaces',
selectorAllowSearch: false,
placeholder: 'Select Asana workspace',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['get_task'],
},
},
{
id: 'getTasks_workspace',
title: 'Workspace GID',
type: 'short-input',
canonicalParamId: 'getTasks_workspace',
placeholder: 'Enter workspace GID',
mode: 'advanced',
condition: {
field: 'operation',
value: ['get_task'],

View File

@@ -86,11 +86,47 @@ export const AttioBlock: BlockConfig<AttioResponse> = {
},
// Record fields
{
id: 'objectTypeSelector',
title: 'Object Type',
type: 'project-selector',
canonicalParamId: 'objectType',
serviceId: 'attio',
selectorKey: 'attio.objects',
selectorAllowSearch: false,
placeholder: 'Select object type',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: [
'list_records',
'get_record',
'create_record',
'update_record',
'delete_record',
'assert_record',
],
},
required: {
field: 'operation',
value: [
'list_records',
'get_record',
'create_record',
'update_record',
'delete_record',
'assert_record',
],
},
},
{
id: 'objectType',
title: 'Object Type',
type: 'short-input',
canonicalParamId: 'objectType',
placeholder: 'e.g. people, companies',
mode: 'advanced',
condition: {
field: 'operation',
value: [
@@ -524,11 +560,49 @@ Return ONLY the JSON array. No explanations, no markdown, no extra text.
},
// List fields
{
id: 'listSelector',
title: 'List',
type: 'project-selector',
canonicalParamId: 'listIdOrSlug',
serviceId: 'attio',
selectorKey: 'attio.lists',
selectorAllowSearch: false,
placeholder: 'Select Attio list',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: [
'get_list',
'update_list',
'query_list_entries',
'get_list_entry',
'create_list_entry',
'update_list_entry',
'delete_list_entry',
],
},
required: {
field: 'operation',
value: [
'get_list',
'update_list',
'query_list_entries',
'get_list_entry',
'create_list_entry',
'update_list_entry',
'delete_list_entry',
],
},
},
{
id: 'listIdOrSlug',
title: 'List ID or Slug',
type: 'short-input',
canonicalParamId: 'listIdOrSlug',
placeholder: 'Enter the list ID or slug',
mode: 'advanced',
condition: {
field: 'operation',
value: [

View File

@@ -65,11 +65,30 @@ export const CalComBlock: BlockConfig<ToolResponse> = {
},
// === Create Booking fields ===
{
id: 'eventTypeSelector',
title: 'Event Type',
type: 'project-selector',
canonicalParamId: 'eventTypeId',
serviceId: 'calcom',
selectorKey: 'calcom.eventTypes',
selectorAllowSearch: false,
placeholder: 'Select event type',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['calcom_create_booking', 'calcom_get_slots'],
},
required: { field: 'operation', value: 'calcom_create_booking' },
},
{
id: 'eventTypeId',
title: 'Event Type ID',
type: 'short-input',
canonicalParamId: 'eventTypeId',
placeholder: 'Enter event type ID (number)',
mode: 'advanced',
condition: {
field: 'operation',
value: ['calcom_create_booking', 'calcom_get_slots'],
@@ -261,11 +280,33 @@ Return ONLY the IANA timezone string - no explanations or quotes.`,
},
// === Event Type fields ===
{
id: 'eventTypeParamSelector',
title: 'Event Type',
type: 'project-selector',
canonicalParamId: 'eventTypeIdParam',
serviceId: 'calcom',
selectorKey: 'calcom.eventTypes',
selectorAllowSearch: false,
placeholder: 'Select event type',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['calcom_get_event_type', 'calcom_update_event_type', 'calcom_delete_event_type'],
},
required: {
field: 'operation',
value: ['calcom_get_event_type', 'calcom_update_event_type', 'calcom_delete_event_type'],
},
},
{
id: 'eventTypeIdParam',
title: 'Event Type ID',
type: 'short-input',
canonicalParamId: 'eventTypeIdParam',
placeholder: 'Enter event type ID',
mode: 'advanced',
condition: {
field: 'operation',
value: ['calcom_get_event_type', 'calcom_update_event_type', 'calcom_delete_event_type'],
@@ -364,10 +405,27 @@ Return ONLY the IANA timezone string - no explanations or quotes.`,
},
mode: 'advanced',
},
{
id: 'eventTypeScheduleSelector',
title: 'Schedule',
type: 'project-selector',
canonicalParamId: 'eventTypeScheduleId',
serviceId: 'calcom',
selectorKey: 'calcom.schedules',
selectorAllowSearch: false,
placeholder: 'Select schedule',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['calcom_create_event_type', 'calcom_update_event_type'],
},
},
{
id: 'eventTypeScheduleId',
title: 'Schedule ID',
type: 'short-input',
canonicalParamId: 'eventTypeScheduleId',
placeholder: 'Assign to a specific schedule',
condition: {
field: 'operation',
@@ -388,11 +446,33 @@ Return ONLY the IANA timezone string - no explanations or quotes.`,
},
// === Schedule fields ===
{
id: 'scheduleSelector',
title: 'Schedule',
type: 'project-selector',
canonicalParamId: 'scheduleId',
serviceId: 'calcom',
selectorKey: 'calcom.schedules',
selectorAllowSearch: false,
placeholder: 'Select schedule',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['calcom_get_schedule', 'calcom_update_schedule', 'calcom_delete_schedule'],
},
required: {
field: 'operation',
value: ['calcom_get_schedule', 'calcom_update_schedule', 'calcom_delete_schedule'],
},
},
{
id: 'scheduleId',
title: 'Schedule ID',
type: 'short-input',
canonicalParamId: 'scheduleId',
placeholder: 'Enter schedule ID',
mode: 'advanced',
condition: {
field: 'operation',
value: ['calcom_get_schedule', 'calcom_update_schedule', 'calcom_delete_schedule'],
@@ -771,7 +851,10 @@ Return ONLY valid JSON - no explanations.`,
cancellationReason: { type: 'string', description: 'Reason for cancellation' },
reschedulingReason: { type: 'string', description: 'Reason for rescheduling' },
bookingStatus: { type: 'string', description: 'Filter by booking status' },
eventTypeIdParam: { type: 'number', description: 'Event type ID for get/update/delete' },
eventTypeIdParam: {
type: 'number',
description: 'Event type ID for get/update/delete',
},
title: { type: 'string', description: 'Event type title' },
slug: { type: 'string', description: 'URL-friendly slug' },
eventLength: { type: 'number', description: 'Event duration in minutes' },

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,37 +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',
'read:user:confluence',
],
requiredScopes: getScopesForService('confluence'),
placeholder: 'Select Confluence account',
required: true,
},
@@ -464,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,
},
@@ -645,11 +578,44 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
],
},
},
{
id: 'spaceSelector',
title: 'Space',
type: 'project-selector',
canonicalParamId: 'spaceId',
serviceId: 'confluence',
selectorKey: 'confluence.spaces',
selectorAllowSearch: false,
placeholder: 'Select Confluence space',
dependsOn: ['credential', 'domain'],
mode: 'basic',
required: true,
condition: {
field: 'operation',
value: [
'create',
'get_space',
'update_space',
'delete_space',
'list_pages_in_space',
'search_in_space',
'create_blogpost',
'list_blogposts_in_space',
'list_space_labels',
'list_space_permissions',
'list_space_properties',
'create_space_property',
'delete_space_property',
],
},
},
{
id: 'spaceId',
title: 'Space ID',
type: 'short-input',
canonicalParamId: 'spaceId',
placeholder: 'Enter Confluence space ID',
mode: 'advanced',
required: true,
condition: {
field: 'operation',
@@ -1250,7 +1216,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
...rest
} = params
// Use canonical param (serializer already handles basic/advanced mode)
const effectivePageId = pageId ? String(pageId).trim() : ''
if (operation === 'add_label') {
@@ -1511,7 +1476,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Confluence domain' },
oauthCredential: { type: 'string', description: 'Confluence access token' },
pageId: { type: 'string', description: 'Page identifier (canonical param)' },
pageId: { type: 'string', description: 'Page identifier' },
spaceId: { type: 'string', description: 'Space identifier' },
blogPostId: { type: 'string', description: 'Blog post identifier' },
versionNumber: { type: 'number', description: 'Page version number' },

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',
},
{
@@ -109,20 +110,52 @@ Return ONLY the SQL query - no explanations, no quotes, no extra text.`,
condition: { field: 'operation', value: 'query' },
},
{
id: 'datasetSelector',
title: 'Dataset',
type: 'project-selector',
canonicalParamId: 'datasetId',
serviceId: 'google-bigquery',
selectorKey: 'bigquery.datasets',
selectorAllowSearch: false,
placeholder: 'Select BigQuery dataset',
dependsOn: ['credential', 'projectId'],
mode: 'basic',
condition: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] },
required: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] },
},
{
id: 'datasetId',
title: 'Dataset ID',
type: 'short-input',
canonicalParamId: 'datasetId',
placeholder: 'Enter BigQuery dataset ID',
mode: 'advanced',
condition: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] },
required: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] },
},
{
id: 'tableSelector',
title: 'Table',
type: 'file-selector',
canonicalParamId: 'tableId',
serviceId: 'google-bigquery',
selectorKey: 'bigquery.tables',
selectorAllowSearch: false,
placeholder: 'Select BigQuery table',
dependsOn: ['credential', 'projectId', 'datasetSelector'],
mode: 'basic',
condition: { field: 'operation', value: ['get_table', 'insert_rows'] },
required: { field: 'operation', value: ['get_table', 'insert_rows'] },
},
{
id: 'tableId',
title: 'Table ID',
type: 'short-input',
canonicalParamId: 'tableId',
placeholder: 'Enter BigQuery table ID',
mode: 'advanced',
condition: { field: 'operation', value: ['get_table', 'insert_rows'] },
required: { field: 'operation', value: ['get_table', 'insert_rows'] },
},

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',
},
{
@@ -51,12 +52,27 @@ export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
required: true,
},
// Task List ID - shown for all task operations (not list_task_lists)
// Task List - shown for all task operations (not list_task_lists)
{
id: 'taskListSelector',
title: 'Task List',
type: 'project-selector',
canonicalParamId: 'taskListId',
serviceId: 'google-tasks',
selectorKey: 'google.tasks.lists',
selectorAllowSearch: false,
placeholder: 'Select task list',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'list_task_lists', not: true },
},
{
id: 'taskListId',
title: 'Task List ID',
type: 'short-input',
canonicalParamId: 'taskListId',
placeholder: 'Task list ID (leave empty for default list)',
mode: 'advanced',
condition: { field: 'operation', value: 'list_task_lists', not: true },
},
@@ -210,7 +226,9 @@ Return ONLY the timestamp - no explanations, no extra text.`,
params: (params) => {
const { oauthCredential, operation, showCompleted, maxResults, ...rest } = params
const processedParams: Record<string, unknown> = { ...rest }
const processedParams: Record<string, unknown> = {
...rest,
}
if (maxResults && typeof maxResults === 'string') {
processedParams.maxResults = Number.parseInt(maxResults, 10)

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'
@@ -46,6 +47,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
{ label: 'Add Watcher', id: 'add_watcher' },
{ label: 'Remove Watcher', id: 'remove_watcher' },
{ label: 'Get Users', id: 'get_users' },
{ label: 'Search Users', id: 'search_users' },
],
value: () => 'read',
},
@@ -64,38 +66,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',
},
{
@@ -703,6 +674,31 @@ Return ONLY the comment text - no explanations.`,
placeholder: 'Maximum users to return (default: 50)',
condition: { field: 'operation', value: 'get_users' },
},
// Search Users fields
{
id: 'searchUsersQuery',
title: 'Search Query',
type: 'short-input',
required: true,
placeholder: 'Enter email address or display name to search',
condition: { field: 'operation', value: 'search_users' },
},
{
id: 'searchUsersMaxResults',
title: 'Max Results',
type: 'short-input',
placeholder: 'Maximum users to return (default: 50)',
condition: { field: 'operation', value: 'search_users' },
mode: 'advanced',
},
{
id: 'searchUsersStartAt',
title: 'Start At',
type: 'short-input',
placeholder: 'Pagination start index (default: 0)',
condition: { field: 'operation', value: 'search_users' },
mode: 'advanced',
},
// Trigger SubBlocks
...getTrigger('jira_issue_created').subBlocks,
...getTrigger('jira_issue_updated').subBlocks,
@@ -737,6 +733,7 @@ Return ONLY the comment text - no explanations.`,
'jira_add_watcher',
'jira_remove_watcher',
'jira_get_users',
'jira_search_users',
],
config: {
tool: (params) => {
@@ -797,6 +794,8 @@ Return ONLY the comment text - no explanations.`,
return 'jira_remove_watcher'
case 'get_users':
return 'jira_get_users'
case 'search_users':
return 'jira_search_users'
default:
return 'jira_retrieve'
}
@@ -1053,6 +1052,18 @@ Return ONLY the comment text - no explanations.`,
: undefined,
}
}
case 'search_users': {
return {
...baseParams,
query: params.searchUsersQuery,
maxResults: params.searchUsersMaxResults
? Number.parseInt(params.searchUsersMaxResults)
: undefined,
startAt: params.searchUsersStartAt
? Number.parseInt(params.searchUsersStartAt)
: undefined,
}
}
default:
return baseParams
}
@@ -1132,6 +1143,13 @@ Return ONLY the comment text - no explanations.`,
},
usersStartAt: { type: 'string', description: 'Pagination start index for users' },
usersMaxResults: { type: 'string', description: 'Maximum users to return' },
// Search Users operation inputs
searchUsersQuery: {
type: 'string',
description: 'Search query (email address or display name)',
},
searchUsersMaxResults: { type: 'string', description: 'Maximum users to return from search' },
searchUsersStartAt: { type: 'string', description: 'Pagination start index for user search' },
},
outputs: {
// Common outputs across all Jira operations

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',
},
{
@@ -107,10 +73,16 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
required: true,
},
{
id: 'serviceDeskId',
title: 'Service Desk ID',
type: 'short-input',
placeholder: 'Enter service desk ID',
id: 'serviceDeskSelector',
title: 'Service Desk',
type: 'project-selector',
canonicalParamId: 'serviceDeskId',
serviceId: 'jira',
selectorKey: 'jsm.serviceDesks',
selectorAllowSearch: false,
placeholder: 'Select service desk',
dependsOn: ['credential', 'domain'],
mode: 'basic',
required: {
field: 'operation',
value: [
@@ -139,12 +111,63 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
],
},
},
{
id: 'serviceDeskId',
title: 'Service Desk ID',
type: 'short-input',
canonicalParamId: 'serviceDeskId',
placeholder: 'Enter service desk ID',
mode: 'advanced',
required: {
field: 'operation',
value: [
'get_request_types',
'create_request',
'get_customers',
'add_customer',
'get_organizations',
'add_organization',
'get_queues',
'get_request_type_fields',
],
},
condition: {
field: 'operation',
value: [
'get_request_types',
'create_request',
'get_customers',
'add_customer',
'get_organizations',
'add_organization',
'get_queues',
'get_requests',
'get_request_type_fields',
],
},
},
{
id: 'requestTypeSelector',
title: 'Request Type',
type: 'file-selector',
canonicalParamId: 'requestTypeId',
serviceId: 'jira',
selectorKey: 'jsm.requestTypes',
selectorAllowSearch: false,
placeholder: 'Select request type',
dependsOn: ['credential', 'domain', 'serviceDeskSelector'],
mode: 'basic',
required: true,
condition: { field: 'operation', value: ['create_request', 'get_request_type_fields'] },
},
{
id: 'requestTypeId',
title: 'Request Type ID',
type: 'short-input',
canonicalParamId: 'requestTypeId',
required: true,
placeholder: 'Enter request type ID',
mode: 'advanced',
condition: { field: 'operation', value: ['create_request', 'get_request_type_fields'] },
},
{

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',
},
{
@@ -84,12 +77,36 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
placeholder: 'Enter credential ID',
},
// Plan ID - for various operations
// Plan selector - basic mode
{
id: 'planSelector',
title: 'Plan',
type: 'project-selector',
canonicalParamId: 'planId',
serviceId: 'microsoft-planner',
selectorKey: 'microsoft.planner.plans',
selectorAllowSearch: false,
placeholder: 'Select a plan',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['create_task', 'read_task', 'read_plan', 'list_buckets', 'create_bucket'],
},
required: {
field: 'operation',
value: ['read_plan', 'list_buckets', 'create_bucket', 'create_task'],
},
},
// Plan ID - advanced mode
{
id: 'planId',
title: 'Plan ID',
type: 'short-input',
canonicalParamId: 'planId',
placeholder: 'Enter the plan ID',
mode: 'advanced',
condition: {
field: 'operation',
value: ['create_task', 'read_task', 'read_plan', 'list_buckets', 'create_bucket'],
@@ -110,7 +127,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
serviceId: 'microsoft-planner',
selectorKey: 'microsoft.planner',
condition: { field: 'operation', value: ['read_task'] },
dependsOn: ['credential', 'planId'],
dependsOn: ['credential', 'planSelector'],
mode: 'basic',
canonicalParamId: 'readTaskId',
},

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

@@ -53,15 +53,49 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
// Read/Write operation - Page ID
{
id: 'pageSelector',
title: 'Page',
type: 'file-selector',
canonicalParamId: 'pageId',
serviceId: 'notion',
selectorKey: 'notion.pages',
placeholder: 'Select Notion page',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['notion_read', 'notion_write'],
},
required: true,
},
{
id: 'pageId',
title: 'Page ID',
type: 'short-input',
canonicalParamId: 'pageId',
placeholder: 'Enter Notion page ID',
mode: 'advanced',
condition: {
field: 'operation',
value: 'notion_read',
value: ['notion_read', 'notion_write'],
},
required: true,
},
{
id: 'databaseSelector',
title: 'Database',
type: 'project-selector',
canonicalParamId: 'databaseId',
serviceId: 'notion',
selectorKey: 'notion.databases',
selectorAllowSearch: false,
placeholder: 'Select Notion database',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['notion_read_database', 'notion_query_database', 'notion_add_database_row'],
},
required: true,
},
@@ -69,31 +103,36 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
id: 'databaseId',
title: 'Database ID',
type: 'short-input',
canonicalParamId: 'databaseId',
placeholder: 'Enter Notion database ID',
mode: 'advanced',
condition: {
field: 'operation',
value: 'notion_read_database',
value: ['notion_read_database', 'notion_query_database', 'notion_add_database_row'],
},
required: true,
},
{
id: 'pageId',
title: 'Page ID',
type: 'short-input',
placeholder: 'Enter Notion page ID',
condition: {
field: 'operation',
value: 'notion_write',
},
id: 'parentSelector',
title: 'Parent Page',
type: 'file-selector',
canonicalParamId: 'parentId',
serviceId: 'notion',
selectorKey: 'notion.pages',
placeholder: 'Select parent page',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: ['notion_create_page', 'notion_create_database'] },
required: true,
},
// Create operation fields
{
id: 'parentId',
title: 'Parent Page ID',
type: 'short-input',
canonicalParamId: 'parentId',
placeholder: 'ID of parent page',
condition: { field: 'operation', value: 'notion_create_page' },
mode: 'advanced',
condition: { field: 'operation', value: ['notion_create_page', 'notion_create_database'] },
required: true,
},
{
@@ -148,14 +187,6 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
},
},
// Query Database Fields
{
id: 'databaseId',
title: 'Database ID',
type: 'short-input',
placeholder: 'Enter Notion database ID',
condition: { field: 'operation', value: 'notion_query_database' },
required: true,
},
{
id: 'filter',
title: 'Filter',
@@ -218,14 +249,6 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
condition: { field: 'operation', value: 'notion_search' },
},
// Create Database Fields
{
id: 'parentId',
title: 'Parent Page ID',
type: 'short-input',
placeholder: 'ID of parent page where database will be created',
condition: { field: 'operation', value: 'notion_create_database' },
required: true,
},
{
id: 'title',
title: 'Database Title',
@@ -256,14 +279,6 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
},
},
// Add Database Row Fields
{
id: 'databaseId',
title: 'Database ID',
type: 'short-input',
placeholder: 'Enter Notion database ID',
condition: { field: 'operation', value: 'notion_add_database_row' },
required: true,
},
{
id: 'properties',
title: 'Row Properties',
@@ -404,6 +419,7 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
}
// V2 Block with API-aligned outputs
export const NotionV2Block: BlockConfig<any> = {
type: 'notion_v2',
name: 'Notion',

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,
},
@@ -96,12 +89,35 @@ export const PipedriveBlock: BlockConfig<PipedriveResponse> = {
placeholder: 'Filter by organization ID',
condition: { field: 'operation', value: ['get_all_deals'] },
},
{
id: 'pipelineSelector',
title: 'Pipeline',
type: 'project-selector',
canonicalParamId: 'pipeline_id',
serviceId: 'pipedrive',
selectorKey: 'pipedrive.pipelines',
selectorAllowSearch: false,
placeholder: 'Select pipeline',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['get_all_deals', 'create_deal', 'get_pipeline_deals'],
},
required: { field: 'operation', value: 'get_pipeline_deals' },
},
{
id: 'pipeline_id',
title: 'Pipeline ID',
type: 'short-input',
placeholder: 'Filter by pipeline ID ',
condition: { field: 'operation', value: ['get_all_deals'] },
canonicalParamId: 'pipeline_id',
placeholder: 'Enter pipeline ID',
mode: 'advanced',
condition: {
field: 'operation',
value: ['get_all_deals', 'create_deal', 'get_pipeline_deals'],
},
required: { field: 'operation', value: 'get_pipeline_deals' },
},
{
id: 'updated_since',
@@ -174,13 +190,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Associated organization ID ',
condition: { field: 'operation', value: ['create_deal'] },
},
{
id: 'pipeline_id',
title: 'Pipeline ID',
type: 'short-input',
placeholder: 'Pipeline ID ',
condition: { field: 'operation', value: ['create_deal'] },
},
{
id: 'stage_id',
title: 'Stage ID',
@@ -329,14 +338,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
],
},
},
{
id: 'pipeline_id',
title: 'Pipeline ID',
type: 'short-input',
placeholder: 'Enter pipeline ID',
required: true,
condition: { field: 'operation', value: ['get_pipeline_deals'] },
},
{
id: 'stage_id',
title: 'Stage ID',

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'],
@@ -112,12 +98,26 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
mode: 'advanced',
},
{
id: 'listSelector',
title: 'List',
type: 'file-selector',
canonicalParamId: 'listId',
serviceId: 'sharepoint',
selectorKey: 'sharepoint.lists',
selectorAllowSearch: false,
placeholder: 'Select a list',
dependsOn: ['credential', 'siteSelector'],
mode: 'basic',
condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] },
},
{
id: 'listId',
title: 'List ID',
type: 'short-input',
placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.',
canonicalParamId: 'listId',
placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.',
mode: 'advanced',
condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] },
},
@@ -425,7 +425,9 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
includeColumns,
includeItems,
files, // canonical param from uploadFiles (basic) or files (advanced)
driveId, // canonical param from driveId
columnDefinitions,
listId,
...others
} = rest as any
@@ -457,7 +459,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
try {
logger.info('SharepointBlock list item param check', {
siteId: effectiveSiteId || undefined,
listId: (others as any)?.listId,
listId: listId,
listTitle: (others as any)?.listTitle,
itemId: sanitizedItemId,
hasItemFields: !!parsedItemFields && typeof parsedItemFields === 'object',
@@ -477,6 +479,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
pageSize: others.pageSize ? Number.parseInt(others.pageSize as string, 10) : undefined,
mimeType: mimeType,
...others,
...(listId ? { listId } : {}),
...(driveId ? { driveId } : {}),
itemId: sanitizedItemId,
listItemFields: parsedItemFields,
includeColumns: coerceBoolean(includeColumns),
@@ -517,10 +521,13 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
includeItems: { type: 'boolean', description: 'Include items in response' },
itemId: { type: 'string', description: 'List item ID (canonical param)' },
listItemFields: { type: 'string', description: 'List item fields (canonical param)' },
driveId: { type: 'string', description: 'Document library (drive) ID (canonical param)' },
driveId: {
type: 'string',
description: 'Document library (drive) ID',
},
folderPath: { type: 'string', description: 'Folder path for file upload' },
fileName: { type: 'string', description: 'File name override' },
files: { type: 'array', description: 'Files to upload (canonical param)' },
files: { type: 'array', description: 'Files to upload' },
},
outputs: {
sites: {

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,
},
@@ -59,26 +60,50 @@ export const TrelloBlock: BlockConfig<ToolResponse> = {
},
{
id: 'boardId',
id: 'boardSelector',
title: 'Board',
type: 'short-input',
placeholder: 'Enter board ID',
type: 'project-selector',
canonicalParamId: 'boardId',
serviceId: 'trello',
selectorKey: 'trello.boards',
selectorAllowSearch: false,
placeholder: 'Select Trello board',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: 'trello_list_lists',
value: [
'trello_list_lists',
'trello_list_cards',
'trello_create_card',
'trello_get_actions',
],
},
required: {
field: 'operation',
value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'],
},
required: true,
},
{
id: 'boardId',
title: 'Board',
title: 'Board ID',
type: 'short-input',
placeholder: 'Enter board ID or search for a board',
canonicalParamId: 'boardId',
placeholder: 'Enter board ID',
mode: 'advanced',
condition: {
field: 'operation',
value: 'trello_list_cards',
value: [
'trello_list_lists',
'trello_list_cards',
'trello_create_card',
'trello_get_actions',
],
},
required: {
field: 'operation',
value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'],
},
required: true,
},
{
id: 'listId',
@@ -90,17 +115,6 @@ export const TrelloBlock: BlockConfig<ToolResponse> = {
value: 'trello_list_cards',
},
},
{
id: 'boardId',
title: 'Board',
type: 'short-input',
placeholder: 'Enter board ID or search for a board',
condition: {
field: 'operation',
value: 'trello_create_card',
},
required: true,
},
{
id: 'listId',
title: 'List',
@@ -278,16 +292,6 @@ Return ONLY the date/timestamp string - no explanations, no quotes, no extra tex
},
},
{
id: 'boardId',
title: 'Board ID',
type: 'short-input',
placeholder: 'Enter board ID to get board actions',
condition: {
field: 'operation',
value: 'trello_get_actions',
},
},
{
id: 'cardId',
title: 'Card ID',

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,
},
@@ -77,12 +66,39 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
value: ['zoom_create_meeting', 'zoom_list_meetings', 'zoom_list_recordings'],
},
},
// Meeting ID for get/update/delete/invitation/recordings/participants operations
// Meeting selector for get/update/delete/invitation/recordings/participants operations
{
id: 'meetingSelector',
title: 'Meeting',
type: 'project-selector',
canonicalParamId: 'meetingId',
serviceId: 'zoom',
selectorKey: 'zoom.meetings',
selectorAllowSearch: true,
placeholder: 'Select Zoom meeting',
dependsOn: ['credential'],
mode: 'basic',
required: true,
condition: {
field: 'operation',
value: [
'zoom_get_meeting',
'zoom_update_meeting',
'zoom_delete_meeting',
'zoom_get_meeting_invitation',
'zoom_get_meeting_recordings',
'zoom_delete_recording',
'zoom_list_past_participants',
],
},
},
{
id: 'meetingId',
title: 'Meeting ID',
type: 'short-input',
canonicalParamId: 'meetingId',
placeholder: 'Enter meeting ID',
mode: 'advanced',
required: true,
condition: {
field: 'operation',
@@ -114,7 +130,6 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
title: 'Topic',
type: 'short-input',
placeholder: 'Meeting topic (optional)',
mode: 'advanced',
condition: {
field: 'operation',
value: ['zoom_update_meeting'],

File diff suppressed because it is too large Load Diff

View File

@@ -7,38 +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
}
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,
mimeType: subBlock.mimeType,
...context,
mimeType: subBlock.mimeType ?? context.mimeType,
},
allowSearch: subBlock.selectorAllowSearch ?? true,
}

View File

@@ -2,6 +2,26 @@ import type React from 'react'
import type { QueryKey } from '@tanstack/react-query'
export type SelectorKey =
| 'airtable.bases'
| 'airtable.tables'
| 'asana.workspaces'
| 'attio.lists'
| 'attio.objects'
| 'bigquery.datasets'
| 'bigquery.tables'
| 'calcom.eventTypes'
| 'calcom.schedules'
| 'confluence.spaces'
| 'google.tasks.lists'
| 'jsm.requestTypes'
| 'jsm.serviceDesks'
| 'microsoft.planner.plans'
| 'notion.databases'
| 'notion.pages'
| 'pipedrive.pipelines'
| 'sharepoint.lists'
| 'trello.boards'
| 'zoom.meetings'
| 'slack.channels'
| 'slack.users'
| 'gmail.labels'
@@ -41,7 +61,7 @@ export interface SelectorOption {
export interface SelectorContext {
workspaceId?: string
workflowId?: string
credentialId?: string
oauthCredential?: string
serviceId?: string
domain?: string
teamId?: string
@@ -54,6 +74,9 @@ export interface SelectorContext {
collectionId?: string
spreadsheetId?: string
excludeWorkflowId?: string
baseId?: string
datasetId?: string
serviceDeskId?: string
}
export interface SelectorQueryArgs {

View File

@@ -1,7 +1,9 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
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
@@ -29,14 +31,27 @@ 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(resolvedDetailId)
const baseEnabled =
Boolean(args.detailId) && definition.fetchById !== undefined
hasRealDetailId && definition.fetchById !== undefined
? definition.enabled
? definition.enabled(queryArgs)
: true
@@ -44,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,24 +12,38 @@ interface SelectorDisplayNameArgs {
subBlock?: SubBlockConfig
value: unknown
workflowId?: string
credentialId?: string
oauthCredential?: string
domain?: string
projectId?: string
planId?: string
teamId?: string
knowledgeBaseId?: string
baseId?: string
datasetId?: string
serviceDeskId?: string
siteId?: string
collectionId?: string
spreadsheetId?: string
fileId?: string
}
export function useSelectorDisplayName({
subBlock,
value,
workflowId,
credentialId,
oauthCredential,
domain,
projectId,
planId,
teamId,
knowledgeBaseId,
baseId,
datasetId,
serviceDeskId,
siteId,
collectionId,
spreadsheetId,
fileId,
}: SelectorDisplayNameArgs) {
const detailId = typeof value === 'string' && value.length > 0 ? value : undefined
@@ -37,23 +51,37 @@ export function useSelectorDisplayName({
if (!subBlock || !detailId) return null
return resolveSelectorForSubBlock(subBlock, {
workflowId,
credentialId,
oauthCredential,
domain,
projectId,
planId,
teamId,
knowledgeBaseId,
baseId,
datasetId,
serviceDeskId,
siteId,
collectionId,
spreadsheetId,
fileId,
})
}, [
subBlock,
detailId,
workflowId,
credentialId,
oauthCredential,
domain,
projectId,
planId,
teamId,
knowledgeBaseId,
baseId,
datasetId,
serviceDeskId,
siteId,
collectionId,
spreadsheetId,
fileId,
])
const key = resolution?.key

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

@@ -553,6 +553,51 @@ export function validateMicrosoftGraphId(
return { isValid: true, sanitized: value }
}
/**
* Validates SharePoint site IDs used in Microsoft Graph API.
*
* Site IDs are compound identifiers: `hostname,spsite-guid,spweb-guid`
* (e.g. `contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE`).
* The API also accepts partial forms like a single GUID or just a hostname.
*
* Allowed characters: alphanumeric, periods, hyphens, and commas.
*
* @param value - The SharePoint site ID to validate
* @param paramName - Name of the parameter for error messages
* @returns ValidationResult
*/
export function validateSharePointSiteId(
value: string | null | undefined,
paramName = 'siteId'
): ValidationResult {
if (value === null || value === undefined || value === '') {
return {
isValid: false,
error: `${paramName} is required`,
}
}
if (value.length > 512) {
return {
isValid: false,
error: `${paramName} exceeds maximum length`,
}
}
if (!/^[a-zA-Z0-9.\-,]+$/.test(value)) {
logger.warn('Invalid characters in SharePoint site ID', {
paramName,
value: value.substring(0, 100),
})
return {
isValid: false,
error: `${paramName} contains invalid characters`,
}
}
return { isValid: true, sanitized: value }
}
/**
* Validates Jira Cloud IDs (typically UUID format)
*

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([])
})
})

Some files were not shown because too many files have changed in this diff Show More