Compare commits

..

17 Commits

Author SHA1 Message Date
Waleed
6586c5ce40 v0.5.107: new reddit, slack tools 2026-03-05 22:48:20 -08:00
Vikhyath Mondreti
3ce947566d v0.5.106: condition block and legacy kbs fixes, GPT 5.4 2026-03-05 17:30:05 -08:00
Waleed
70c36cb7aa v0.5.105: slack remove reaction, nested subflow locks fix, servicenow pagination, memory improvements 2026-03-04 22:38:26 -08:00
Waleed
f1ec5fe824 v0.5.104: memory improvements, nested subflows, careers page redirect, brandfetch, google meet 2026-03-03 23:45:29 -08:00
Waleed
e07e3c34cc v0.5.103: memory util instrumentation, API docs, amplitude, google pagespeed insights, pagerduty 2026-03-01 23:27:02 -08:00
Waleed
0d2e6ff31d v0.5.102: new integrations, new tools, ci speedups, memory leak instrumentation 2026-02-28 12:48:10 -08:00
Waleed
4fd0989264 v0.5.101: circular dependency mitigation, confluence enhancements, google tasks and bigquery integrations, workflow lock 2026-02-26 15:04:53 -08:00
Waleed
67f8a687f6 v0.5.100: multiple credentials, 40% speedup, gong, attio, audit log improvements 2026-02-25 00:28:25 -08:00
Waleed
af592349d3 v0.5.99: local dev improvements, live workflow logs in terminal 2026-02-23 00:24:49 -08:00
Waleed
0d86ea01f0 v0.5.98: change detection improvements, rate limit and code execution fixes, removed retired models, hex integration 2026-02-21 18:07:40 -08:00
Waleed
115f04e989 v0.5.97: oidc discovery for copilot mcp 2026-02-21 02:06:25 -08:00
Waleed
34d92fae89 v0.5.96: sim oauth provider, slack ephemeral message tool and blockkit support 2026-02-20 18:22:20 -08:00
Waleed
67aa4bb332 v0.5.95: gemini 3.1 pro, cloudflare, dataverse, revenuecat, redis, upstash, algolia tools; isolated-vm robustness improvements, tables backend (#3271)
* feat(tools): advanced fields for youtube, vercel; added cloudflare and dataverse tools (#3257)

* refactor(vercel): mark optional fields as advanced mode

Move optional/power-user fields behind the advanced toggle:
- List Deployments: project filter, target, state
- Create Deployment: project ID override, redeploy from, target
- List Projects: search
- Create/Update Project: framework, build/output/install commands
- Env Vars: variable type
- Webhooks: project IDs filter
- Checks: path, details URL
- Team Members: role filter
- All operations: team ID scope

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

* style(youtube): mark optional params as advanced mode

Hide pagination, sort order, and filter fields behind the advanced
toggle for a cleaner default UX across all YouTube operations.

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

* added advanced fields for vercel and youtube, added cloudflare and dataverse block

* addded desc for dataverse

* add more tools

* ack comment

* more

* ops

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* feat(tables): added tables (#2867)

* updates

* required

* trashy table viewer

* updates

* updates

* filtering ui

* updates

* updates

* updates

* one input mode

* format

* fix lints

* improved errors

* updates

* updates

* chages

* doc strings

* breaking down file

* update comments with ai

* updates

* comments

* changes

* revert

* updates

* dedupe

* updates

* updates

* updates

* refactoring

* renames & refactors

* refactoring

* updates

* undo

* update db

* wand

* updates

* fix comments

* fixes

* simplify comments

* u[dates

* renames

* better comments

* validation

* updates

* updates

* updates

* fix sorting

* fix appearnce

* updating prompt to make it user sort

* rm

* updates

* rename

* comments

* clean comments

* simplicifcaiton

* updates

* updates

* refactor

* reduced type confusion

* undo

* rename

* undo changes

* undo

* simplify

* updates

* updates

* revert

* updates

* db updates

* type fix

* fix

* fix error handling

* updates

* docs

* docs

* updates

* rename

* dedupe

* revert

* uncook

* updates

* fix

* fix

* fix

* fix

* prepare merge

* readd migrations

* add back missed code

* migrate enrichment logic to general abstraction

* address bugbot concerns

* adhere to size limits for tables

* remove conflicting migration

* add back migrations

* fix tables auth

* fix permissive auth

* fix lint

* reran migrations

* migrate to use tanstack query for all server state

* update table-selector

* update names

* added tables to permission groups, updated subblock types

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: waleed <walif6@gmail.com>

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running (#3259)

* fix(snapshot): changed insert to upsert when concurrent identical child workflows are running

* fixed ci tests failing

* fix(workflows): disallow duplicate workflow names at the same folder level (#3260)

* feat(tools): added redis, upstash, algolia, and revenuecat (#3261)

* feat(tools): added redis, upstash, algolia, and revenuecat

* ack comment

* feat(models): add gemini-3.1-pro-preview and update gemini-3-pro thinking levels (#3263)

* fix(audit-log): lazily resolve actor name/email when missing (#3262)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params (#3264)

* fix(blocks): move type coercions from tools.config.tool to tools.config.params

Number() coercions in tools.config.tool ran at serialization time before
variable resolution, destroying dynamic references like <block.result.count>
by converting them to NaN/null. Moved all coercions to tools.config.params
which runs at execution time after variables are resolved.

Fixed in 15 blocks: exa, arxiv, sentry, incidentio, wikipedia, ahrefs,
posthog, elasticsearch, dropbox, hunter, lemlist, spotify, youtube, grafana,
parallel. Also added mode: 'advanced' to optional exa fields.

Closes #3258

* fix(blocks): address PR review — move remaining param mutations from tool() to params()

- Moved field mappings from tool() to params() in grafana, posthog,
  lemlist, spotify, dropbox (same dynamic reference bug)
- Fixed parallel.ts excerpts/full_content boolean logic
- Fixed parallel.ts search_queries empty case (must set undefined)
- Fixed elasticsearch.ts timeout not included when already ends with 's'
- Restored dropbox.ts tool() switch for proper default fallback

* fix(blocks): restore field renames to tool() for serialization-time validation

Field renames (e.g. personalApiKey→apiKey) must be in tool() because
validateRequiredFieldsBeforeExecution calls selectToolId()→tool() then
checks renamed field names on params. Only type coercions (Number(),
boolean) stay in params() to avoid destroying dynamic variable references.

* improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266)

* fix(blocks): add required constraint for serviceDeskId in JSM block (#3268)

* fix(blocks): add required constraint for serviceDeskId in JSM block

* fix(blocks): rename custom field values to request field values in JSM create request

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* fix(tables): hide tables from sidebar and block registry (#3270)

* fix(tables): hide tables from sidebar and block registry

* fix(trigger): add isolated-vm support to trigger.dev container builds (#3269)

Scheduled workflow executions running in trigger.dev containers were
failing to spawn isolated-vm workers because the native module wasn't
available in the container. This caused loop condition evaluation to
silently fail and exit after one iteration.

- Add isolated-vm to build.external and additionalPackages in trigger config
- Include isolated-vm-worker.cjs via additionalFiles for child process spawning
- Add fallback path resolution for worker file in trigger.dev environment

* lint

* fix(trigger): update node version to align with main app (#3272)

* fix(build): fix corrupted sticky disk cache on blacksmith (#3273)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
2026-02-20 13:43:07 -08:00
Waleed
15ace5e63f v0.5.94: vercel integration, folder insertion, migrated tracking redirects to rewrites 2026-02-18 16:53:34 -08:00
Waleed
fdca73679d v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs 2026-02-18 12:10:05 -08:00
Waleed
da46a387c9 v0.5.92: shortlinks, copilot scrolling stickiness, pagination 2026-02-17 15:13:21 -08:00
Waleed
b7e377ec4b v0.5.91: docs i18n, turborepo upgrade 2026-02-16 00:36:05 -08:00
110 changed files with 2162 additions and 4760 deletions

View File

@@ -20,7 +20,6 @@ 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
@@ -116,17 +115,12 @@ export const {ServiceName}Block: BlockConfig = {
id: 'credential',
title: 'Account',
type: 'oauth-input',
serviceId: '{service}', // Must match OAuth provider service key
requiredScopes: getScopesForService('{service}'), // Import from @/lib/oauth/utils
serviceId: '{service}', // Must match OAuth provider
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.)
@@ -630,7 +624,6 @@ 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',
@@ -661,7 +654,6 @@ export const ServiceBlock: BlockConfig = {
title: 'Service Account',
type: 'oauth-input',
serviceId: 'service',
requiredScopes: getScopesForService('service'),
placeholder: 'Select account',
required: true,
},
@@ -800,8 +792,7 @@ 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` and `requiredScopes: getScopesForService(serviceId)`
- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes
- [ ] OAuth inputs have correct `serviceId`
- [ ] Tools.access lists all tool IDs (snake_case)
- [ ] Tools.config.tool returns correct tool ID (snake_case)
- [ ] Outputs match tool outputs

View File

@@ -114,7 +114,6 @@ 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}',
@@ -145,7 +144,6 @@ export const {Service}Block: BlockConfig = {
title: '{Service} Account',
type: 'oauth-input',
serviceId: '{service}',
requiredScopes: getScopesForService('{service}'),
required: true,
},
// Conditional fields per operation
@@ -411,7 +409,7 @@ If creating V2 versions (API-aligned outputs):
### Block
- [ ] Created `blocks/blocks/{service}.ts`
- [ ] Defined operation dropdown with all operations
- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')`
- [ ] Added credential field (oauth-input or short-input)
- [ ] Added conditional fields per operation
- [ ] Set up dependsOn for cascading selectors
- [ ] Configured tools.access with all tool IDs
@@ -421,12 +419,6 @@ 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`
@@ -725,25 +717,6 @@ 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
@@ -756,5 +729,3 @@ requiredScopes: getScopesForService('{service}'),
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,9 +26,8 @@ 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 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
apps/sim/lib/auth/auth.ts # OAuth scopes (if OAuth service)
apps/sim/lib/oauth/oauth.ts # OAuth provider config (if OAuth service)
```
## Step 2: Pull API Documentation
@@ -200,14 +199,11 @@ For **each tool** in `tools.access`:
## Step 5: Validate OAuth Scopes (if OAuth service)
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`
- [ ] `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
- [ ] 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
@@ -248,8 +244,7 @@ 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
- Hardcoded scope arrays instead of using `getScopesForService()` / `getCanonicalScopesForProvider()`
- Missing scope description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
- Missing scope description in `oauth-required-modal.tsx`
**Suggestion** (minor improvements):
- Better description text
@@ -278,8 +273,7 @@ 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 use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays
- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
- [ ] Validated OAuth scopes alignment across auth.ts, oauth.ts, block, and modal (if OAuth)
- [ ] 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.10-alpine
FROM oven/bun:1.3.9-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.10
bun-version: 1.3.9
- 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.10
bun-version: 1.3.9
- 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.10
bun-version: 1.3.9
- 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.10
bun-version: 1.3.9
- 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.10
bun-version: 1.3.9
- 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.10
bun-version: 1.3.9
- 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.10
bun-version: 1.3.9
- name: Setup Node
uses: actions/setup-node@v4

View File

@@ -6,33 +6,40 @@
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
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 {
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 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,
@@ -59,6 +66,7 @@ vi.mock('@sim/logger', () => ({
vi.mock('@/lib/oauth/utils', () => ({
parseProvider: mockParseProvider,
evaluateScopeCoverage: mockEvaluateScopeCoverage,
}))
import { GET } from '@/app/api/auth/oauth/connections/route'
@@ -75,6 +83,16 @@ 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 { parseProvider } from '@/lib/oauth'
import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth'
const logger = createLogger('OAuthConnectionsAPI')
@@ -49,7 +49,8 @@ export async function GET(request: NextRequest) {
for (const acc of accounts) {
const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider)
const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
if (baseProvider) {
// Try multiple methods to get a user-friendly display name
@@ -95,6 +96,10 @@ 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) {
@@ -103,8 +108,20 @@ export async function GET(request: NextRequest) {
existingConnection.accounts.push(accountSummary)
existingConnection.scopes = Array.from(
new Set([...(existingConnection.scopes || []), ...scopes])
new Set([...(existingConnection.scopes || []), ...scopeEvaluation.grantedScopes])
)
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()
@@ -121,7 +138,11 @@ export async function GET(request: NextRequest) {
baseProvider,
featureType,
isConnected: true,
scopes,
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
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, mockLogger } = vi.hoisted(() => {
const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger } = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
@@ -19,6 +19,7 @@ const { mockCheckSessionOrInternalAuth, mockLogger } = vi.hoisted(() => {
}
return {
mockCheckSessionOrInternalAuth: vi.fn(),
mockEvaluateScopeCoverage: vi.fn(),
mockLogger: logger,
}
})
@@ -27,6 +28,10 @@ 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'),
}))
@@ -82,6 +87,16 @@ 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,6 +7,7 @@ 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'
@@ -38,7 +39,8 @@ function toCredentialResponse(
scope: string | null
) {
const storedScope = scope?.trim()
const scopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const grantedScopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(providerId, grantedScopes)
const [_, featureType = 'default'] = providerId.split('-')
return {
@@ -47,7 +49,11 @@ function toCredentialResponse(
provider: providerId,
lastUsed: updatedAt.toISOString(),
isDefault: featureType === 'default',
scopes,
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
}

View File

@@ -1,79 +0,0 @@
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

@@ -1,95 +0,0 @@
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

@@ -1,79 +0,0 @@
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

@@ -1,79 +0,0 @@
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

@@ -1,79 +0,0 @@
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

@@ -1,83 +0,0 @@
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

@@ -1,80 +0,0 @@
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

@@ -1,96 +0,0 @@
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

@@ -1,100 +0,0 @@
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

@@ -1,94 +0,0 @@
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

@@ -1,79 +0,0 @@
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

@@ -1,103 +0,0 @@
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

@@ -1,94 +0,0 @@
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

@@ -1,72 +0,0 @@
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,29 +1,38 @@
import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import type { PlannerTask } from '@/tools/microsoft_planner/types'
const logger = createLogger('MicrosoftPlannerTasksAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
const requestId = generateRequestId()
export async function GET(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
const body = await request.json()
const { credential, workflowId, planId } = body
const session = await getSession()
if (!credential) {
logger.error(`[${requestId}] Missing credential in request`)
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
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 (!planId) {
logger.error(`[${requestId}] Missing planId in request`)
logger.error(`[${requestId}] Missing planId parameter`)
return NextResponse.json({ error: 'Plan ID is required' }, { status: 400 })
}
@@ -33,35 +42,52 @@ export async function POST(request: Request) {
return NextResponse.json({ error: planIdValidation.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 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) {
logger.warn(`[${requestId}] Credential not found`, { credentialId })
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
const accountRow = credentials[0]
const accessToken = await refreshAccessTokenIfNeeded(
credential,
authz.credentialOwnerUserId,
resolved.accountId,
accountRow.userId,
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 }
)
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
const response = await fetch(
`https://graph.microsoft.com/v1.0/planner/plans/${planIdValidation.sanitized}/tasks`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
)
const response = await fetch(`https://graph.microsoft.com/v1.0/planner/plans/${planId}/tasks`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorText = await response.text()

View File

@@ -1,86 +0,0 @@
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

@@ -1,86 +0,0 @@
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

@@ -1,79 +0,0 @@
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

@@ -1,91 +0,0 @@
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,45 +1,79 @@
import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
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 { 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 type { SharepointSite } from '@/tools/sharepoint/types'
export const dynamic = 'force-dynamic'
const logger = createLogger('SharePointSitesAPI')
export async function POST(request: Request) {
const requestId = generateRequestId()
/**
* Get SharePoint sites from Microsoft Graph API
*/
export async function GET(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
try {
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 session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
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 { 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 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(
credential,
authz.credentialOwnerUserId,
resolved.accountId,
accountRow.userId,
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 }
)
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
const searchQuery = query || '*'

View File

@@ -1,87 +0,0 @@
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

@@ -1,82 +0,0 @@
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,7 +15,6 @@ import {
import { client } from '@/lib/auth/auth-client'
import {
getProviderIdFromServiceId,
getScopeDescription,
OAUTH_PROVIDERS,
type OAuthProvider,
parseProvider,
@@ -34,6 +33,318 @@ 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,7 +15,6 @@ 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'
@@ -26,6 +25,7 @@ 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,8 +1969,9 @@ export const ToolInput = memo(function ToolInput({
}
if (useSubBlocks && displaySubBlocks.length > 0) {
const allBlockSubBlocks = toolBlock?.subBlocks || []
const coveredParamIds = new Set(
displaySubBlocks.flatMap((sb) => {
allBlockSubBlocks.flatMap((sb) => {
const ids = [sb.id]
if (sb.canonicalParamId) ids.push(sb.canonicalParamId)
const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id]

View File

@@ -2,11 +2,8 @@
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'
@@ -15,7 +12,8 @@ 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`, `oauthCredential`).
* `SelectorContext` field names (e.g. `siteId`, `teamId`, `collectionId`).
* The one special case is `oauthCredential` which maps to `credentialId`.
*
* @param blockId - The block containing the selector sub-block
* @param subBlock - The sub-block config (must have `selectorKey` set)
@@ -31,58 +29,53 @@ 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(resolvedDependencyValues)) {
for (const [depKey, value] of Object.entries(dependencyValues)) {
if (value === null || value === undefined) continue
const strValue = String(value)
if (!strValue) continue
if (isReference(strValue)) continue
const canonicalParamId = canonicalIndex.canonicalIdBySubBlockId[depKey] ?? depKey
if (SELECTOR_CONTEXT_FIELDS.has(canonicalParamId as keyof SelectorContext)) {
context[canonicalParamId as keyof SelectorContext] = strValue
if (canonicalParamId === 'oauthCredential') {
context.credentialId = strValue
} else if (canonicalParamId in CONTEXT_FIELD_SET) {
;(context as Record<string, unknown>)[canonicalParamId] = strValue
}
}
return context
}, [resolvedDependencyValues, canonicalIndex, workflowId, subBlock.mimeType])
}, [dependencyValues, canonicalIndex, workflowId, subBlock.mimeType])
return {
selectorKey: (subBlock.selectorKey ?? null) as SelectorKey | null,
selectorContext,
allowSearch: subBlock.selectorAllowSearch ?? true,
disabled: finalDisabled || !subBlock.selectorKey,
dependencyValues: resolvedDependencyValues,
dependencyValues,
}
}
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 oauthCredential =
const credentialId =
authMethod === 'bot_token' ? String(deps.botToken ?? '') : String(deps.credential ?? '')
return { ...context, oauthCredential }
return { ...context, credentialId }
},
}

View File

@@ -549,48 +549,21 @@ const SubBlockRow = memo(function SubBlockRow({
return typeof option === 'string' ? option : option.label
}, [subBlock, rawValue])
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 domainValue = getStringValue('domain')
const teamIdValue = getStringValue('teamId')
const projectIdValue = getStringValue('projectId')
const planIdValue = getStringValue('planId')
const { displayName: selectorDisplayName } = useSelectorDisplayName({
subBlock,
value: rawValue,
workflowId,
oauthCredential: typeof credentialId === 'string' ? credentialId : undefined,
credentialId: 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,18 +667,15 @@ describe.concurrent('Blocks Module', () => {
const errors: string[] = []
for (const block of blocks) {
// 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 allSubBlockIds = new Set(block.subBlocks.map((sb) => sb.id))
const canonicalParamIds = new Set(
nonTriggerSubBlocks.filter((sb) => sb.canonicalParamId).map((sb) => sb.canonicalParamId)
block.subBlocks.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 = nonTriggerSubBlocks.find(
const matchingSubBlock = block.subBlocks.find(
(sb) => sb.id === canonicalId && !sb.canonicalParamId
)
if (matchingSubBlock) {
@@ -860,10 +857,6 @@ 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,6 +1,5 @@
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'
@@ -129,7 +128,7 @@ Return ONLY the JSON array.`,
serviceId: 'vertex-ai',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: getScopesForService('vertex-ai'),
requiredScopes: ['https://www.googleapis.com/auth/cloud-platform'],
placeholder: 'Select Google Cloud account',
required: true,
condition: {

View File

@@ -1,5 +1,4 @@
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'
@@ -39,7 +38,13 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'airtable',
requiredScopes: getScopesForService('airtable'),
requiredScopes: [
'data.records:read',
'data.records:write',
'schema.bases:read',
'user.email:read',
'webhook:manage',
],
placeholder: 'Select Airtable account',
required: true,
},
@@ -52,51 +57,21 @@ 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)',
mode: 'advanced',
dependsOn: ['credential'],
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)',
mode: 'advanced',
dependsOn: ['credential', 'baseId'],
condition: { field: 'operation', value: ['listBases', 'listTables'], not: true },
required: { field: 'operation', value: ['listBases', 'listTables'], not: true },
},

View File

@@ -1,5 +1,4 @@
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'
@@ -37,7 +36,7 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
mode: 'basic',
required: true,
serviceId: 'asana',
requiredScopes: getScopesForService('asana'),
requiredScopes: ['default'],
placeholder: 'Select Asana account',
},
{
@@ -49,31 +48,12 @@ 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'],
@@ -101,29 +81,11 @@ 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,47 +86,11 @@ 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: [
@@ -560,49 +524,11 @@ 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,30 +65,11 @@ 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'],
@@ -280,33 +261,11 @@ 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'],
@@ -405,27 +364,10 @@ 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',
@@ -446,33 +388,11 @@ 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'],
@@ -851,10 +771,7 @@ 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,5 +1,4 @@
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'
@@ -56,7 +55,37 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence',
requiredScopes: getScopesForService('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',
],
placeholder: 'Select Confluence account',
required: true,
},
@@ -435,7 +464,45 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence',
requiredScopes: getScopesForService('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',
],
placeholder: 'Select Confluence account',
required: true,
},
@@ -578,44 +645,11 @@ 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',
@@ -1216,6 +1250,7 @@ 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') {
@@ -1476,7 +1511,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' },
pageId: { type: 'string', description: 'Page identifier (canonical param)' },
spaceId: { type: 'string', description: 'Space identifier' },
blogPostId: { type: 'string', description: 'Blog post identifier' },
versionNumber: { type: 'number', description: 'Page version number' },

View File

@@ -1,5 +1,4 @@
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'
@@ -42,7 +41,15 @@ export const DropboxBlock: BlockConfig<DropboxResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'dropbox',
requiredScopes: getScopesForService('dropbox'),
requiredScopes: [
'account_info.read',
'files.metadata.read',
'files.metadata.write',
'files.content.read',
'files.content.write',
'sharing.read',
'sharing.write',
],
placeholder: 'Select Dropbox account',
required: true,
},

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
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'
@@ -38,7 +37,7 @@ export const GoogleContactsBlock: BlockConfig<GoogleContactsResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-contacts',
requiredScopes: getScopesForService('google-contacts'),
requiredScopes: ['https://www.googleapis.com/auth/contacts'],
placeholder: 'Select Google account',
},
{

View File

@@ -1,5 +1,4 @@
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'
@@ -37,7 +36,10 @@ export const GoogleDocsBlock: BlockConfig<GoogleDocsResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-docs',
requiredScopes: getScopesForService('google-docs'),
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
placeholder: 'Select Google account',
},
{

View File

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

View File

@@ -1,5 +1,4 @@
import { GoogleFormsIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { getTrigger } from '@/triggers'
@@ -39,7 +38,13 @@ export const GoogleFormsBlock: BlockConfig = {
mode: 'basic',
required: true,
serviceId: 'google-forms',
requiredScopes: getScopesForService('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',
],
placeholder: 'Select Google account',
},
{

View File

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

View File

@@ -1,5 +1,4 @@
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'
@@ -38,7 +37,10 @@ export const GoogleMeetBlock: BlockConfig<GoogleMeetResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-meet',
requiredScopes: getScopesForService('google-meet'),
requiredScopes: [
'https://www.googleapis.com/auth/meetings.space.created',
'https://www.googleapis.com/auth/meetings.space.readonly',
],
placeholder: 'Select Google Meet account',
},
{

View File

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

View File

@@ -1,5 +1,4 @@
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'
@@ -51,7 +50,10 @@ export const GoogleSlidesBlock: BlockConfig<GoogleSlidesResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-drive',
requiredScopes: getScopesForService('google-drive'),
requiredScopes: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive',
],
placeholder: 'Select Google account',
},
{

View File

@@ -1,5 +1,4 @@
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'
@@ -39,7 +38,7 @@ export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-tasks',
requiredScopes: getScopesForService('google-tasks'),
requiredScopes: ['https://www.googleapis.com/auth/tasks'],
placeholder: 'Select Google Tasks account',
},
{
@@ -52,27 +51,12 @@ export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
required: true,
},
// 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 },
},
// Task List ID - shown for all task operations (not list_task_lists)
{
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 },
},
@@ -226,9 +210,7 @@ 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,5 +1,4 @@
import { GoogleVaultIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
@@ -39,7 +38,10 @@ export const GoogleVaultBlock: BlockConfig = {
mode: 'basic',
required: true,
serviceId: 'google-vault',
requiredScopes: getScopesForService('google-vault'),
requiredScopes: [
'https://www.googleapis.com/auth/ediscovery',
'https://www.googleapis.com/auth/devstorage.read_only',
],
placeholder: 'Select Google Vault account',
},
{

View File

@@ -1,5 +1,4 @@
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'
@@ -43,7 +42,31 @@ export const HubSpotBlock: BlockConfig<HubSpotResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'hubspot',
requiredScopes: getScopesForService('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',
],
placeholder: 'Select HubSpot account',
required: true,
},

View File

@@ -1,5 +1,4 @@
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'
@@ -65,7 +64,38 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
mode: 'basic',
required: true,
serviceId: 'jira',
requiredScopes: getScopesForService('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',
],
placeholder: 'Select Jira account',
},
{

View File

@@ -1,5 +1,4 @@
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'
@@ -60,7 +59,42 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
mode: 'basic',
required: true,
serviceId: 'jira',
requiredScopes: getScopesForService('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',
],
placeholder: 'Select Jira account',
},
{
@@ -72,52 +106,11 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
{
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: [
'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: 'serviceDeskId',
title: 'Service Desk ID',
type: 'short-input',
canonicalParamId: 'serviceDeskId',
placeholder: 'Enter service desk ID',
mode: 'advanced',
required: {
field: 'operation',
value: [
@@ -146,28 +139,12 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
],
},
},
{
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,5 +1,4 @@
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'
@@ -133,7 +132,7 @@ export const LinearBlock: BlockConfig<LinearResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'linear',
requiredScopes: getScopesForService('linear'),
requiredScopes: ['read', 'write'],
placeholder: 'Select Linear account',
required: true,
},

View File

@@ -1,5 +1,4 @@
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'
@@ -36,7 +35,7 @@ export const LinkedInBlock: BlockConfig<LinkedInResponse> = {
serviceId: 'linkedin',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: getScopesForService('linkedin'),
requiredScopes: ['profile', 'openid', 'email', 'w_member_social'],
placeholder: 'Select LinkedIn account',
required: true,
},

View File

@@ -1,5 +1,4 @@
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'
@@ -47,7 +46,13 @@ export const MicrosoftDataverseBlock: BlockConfig<DataverseResponse> = {
title: 'Microsoft Account',
type: 'oauth-input',
serviceId: 'microsoft-dataverse',
requiredScopes: getScopesForService('microsoft-dataverse'),
requiredScopes: [
'openid',
'profile',
'email',
'https://dynamics.microsoft.com/user_impersonation',
'offline_access',
],
placeholder: 'Select Microsoft account',
required: true,
},

View File

@@ -1,5 +1,4 @@
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'
@@ -40,7 +39,14 @@ export const MicrosoftExcelBlock: BlockConfig<MicrosoftExcelResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-excel',
requiredScopes: getScopesForService('microsoft-excel'),
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
placeholder: 'Select Microsoft account',
required: true,
},
@@ -360,7 +366,14 @@ export const MicrosoftExcelV2Block: BlockConfig<MicrosoftExcelV2Response> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-excel',
requiredScopes: getScopesForService('microsoft-excel'),
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
placeholder: 'Select Microsoft account',
required: true,
},

View File

@@ -1,5 +1,4 @@
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'
@@ -65,7 +64,15 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-planner',
requiredScopes: getScopesForService('microsoft-planner'),
requiredScopes: [
'openid',
'profile',
'email',
'Group.ReadWrite.All',
'Group.Read.All',
'Tasks.ReadWrite',
'offline_access',
],
placeholder: 'Select Microsoft account',
},
{
@@ -77,36 +84,12 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
placeholder: 'Enter credential ID',
},
// 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
// Plan ID - for various operations
{
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'],
@@ -127,7 +110,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
serviceId: 'microsoft-planner',
selectorKey: 'microsoft.planner',
condition: { field: 'operation', value: ['read_task'] },
dependsOn: ['credential', 'planSelector'],
dependsOn: ['credential', 'planId'],
mode: 'basic',
canonicalParamId: 'readTaskId',
},

View File

@@ -1,5 +1,4 @@
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'
@@ -48,7 +47,28 @@ export const MicrosoftTeamsBlock: BlockConfig<MicrosoftTeamsResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-teams',
requiredScopes: getScopesForService('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',
],
placeholder: 'Select Microsoft account',
required: true,
},

View File

@@ -53,49 +53,15 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
{
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,
},
// Read/Write operation - Page ID
{
id: 'pageId',
title: 'Page ID',
type: 'short-input',
canonicalParamId: 'pageId',
placeholder: 'Enter Notion page ID',
mode: 'advanced',
condition: {
field: 'operation',
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'],
value: 'notion_read',
},
required: true,
},
@@ -103,36 +69,31 @@ 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', 'notion_query_database', 'notion_add_database_row'],
value: 'notion_read_database',
},
required: true,
},
{
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'] },
id: 'pageId',
title: 'Page ID',
type: 'short-input',
placeholder: 'Enter Notion page ID',
condition: {
field: 'operation',
value: 'notion_write',
},
required: true,
},
// Create operation fields
{
id: 'parentId',
title: 'Parent Page ID',
type: 'short-input',
canonicalParamId: 'parentId',
placeholder: 'ID of parent page',
mode: 'advanced',
condition: { field: 'operation', value: ['notion_create_page', 'notion_create_database'] },
condition: { field: 'operation', value: 'notion_create_page' },
required: true,
},
{
@@ -187,6 +148,14 @@ 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',
@@ -249,6 +218,14 @@ 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',
@@ -279,6 +256,14 @@ 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',
@@ -419,7 +404,6 @@ export const NotionBlock: BlockConfig<NotionResponse> = {
}
// V2 Block with API-aligned outputs
export const NotionV2Block: BlockConfig<any> = {
type: 'notion_v2',
name: 'Notion',

View File

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

View File

@@ -1,5 +1,4 @@
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'
@@ -43,7 +42,16 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'outlook',
requiredScopes: getScopesForService('outlook'),
requiredScopes: [
'Mail.ReadWrite',
'Mail.ReadBasic',
'Mail.Read',
'Mail.Send',
'offline_access',
'openid',
'profile',
'email',
],
placeholder: 'Select Microsoft account',
required: true,
},
@@ -180,7 +188,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
canonicalParamId: 'folder',
serviceId: 'outlook',
selectorKey: 'outlook.folders',
requiredScopes: getScopesForService('outlook'),
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
placeholder: 'Select Outlook folder',
dependsOn: ['credential'],
mode: 'basic',
@@ -226,7 +234,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
canonicalParamId: 'destinationId',
serviceId: 'outlook',
selectorKey: 'outlook.folders',
requiredScopes: getScopesForService('outlook'),
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
placeholder: 'Select destination folder',
dependsOn: ['credential'],
mode: 'basic',
@@ -273,7 +281,7 @@ export const OutlookBlock: BlockConfig<OutlookResponse> = {
canonicalParamId: 'copyDestinationId',
serviceId: 'outlook',
selectorKey: 'outlook.folders',
requiredScopes: getScopesForService('outlook'),
requiredScopes: ['Mail.ReadWrite', 'Mail.ReadBasic', 'Mail.Read'],
placeholder: 'Select destination folder',
dependsOn: ['credential'],
mode: 'basic',

View File

@@ -1,5 +1,4 @@
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'
@@ -49,7 +48,15 @@ export const PipedriveBlock: BlockConfig<PipedriveResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'pipedrive',
requiredScopes: getScopesForService('pipedrive'),
requiredScopes: [
'base',
'deals:full',
'contacts:full',
'leads:full',
'activities:full',
'mail:full',
'projects:full',
],
placeholder: 'Select Pipedrive account',
required: true,
},
@@ -89,35 +96,12 @@ 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',
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' },
placeholder: 'Filter by pipeline ID ',
condition: { field: 'operation', value: ['get_all_deals'] },
},
{
id: 'updated_since',
@@ -190,6 +174,13 @@ 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',
@@ -338,6 +329,14 @@ 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,5 +1,4 @@
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'
@@ -50,7 +49,24 @@ export const RedditBlock: BlockConfig<RedditResponse> = {
serviceId: 'reddit',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: getScopesForService('reddit'),
requiredScopes: [
'identity',
'read',
'submit',
'vote',
'save',
'edit',
'subscribe',
'history',
'privatemessages',
'account',
'mysubreddits',
'flair',
'report',
'modposts',
'modflair',
'modmail',
],
placeholder: 'Select Reddit account',
required: true,
},

View File

@@ -1,5 +1,4 @@
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'
@@ -66,7 +65,7 @@ export const SalesforceBlock: BlockConfig<SalesforceResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'salesforce',
requiredScopes: getScopesForService('salesforce'),
requiredScopes: ['api', 'refresh_token', 'openid', 'offline_access'],
placeholder: 'Select Salesforce account',
required: true,
},

View File

@@ -1,6 +1,5 @@
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'
@@ -42,7 +41,15 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'sharepoint',
requiredScopes: getScopesForService('sharepoint'),
requiredScopes: [
'openid',
'profile',
'email',
'Sites.Read.All',
'Sites.ReadWrite.All',
'Sites.Manage.All',
'offline_access',
],
placeholder: 'Select Microsoft account',
},
{
@@ -61,7 +68,14 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
canonicalParamId: 'siteId',
serviceId: 'sharepoint',
selectorKey: 'sharepoint.sites',
requiredScopes: getScopesForService('sharepoint'),
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a site',
dependsOn: ['credential'],
@@ -98,26 +112,12 @@ 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',
canonicalParamId: 'listId',
placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.',
mode: 'advanced',
canonicalParamId: 'listId',
condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] },
},
@@ -425,9 +425,7 @@ 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
@@ -459,7 +457,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: listId,
listId: (others as any)?.listId,
listTitle: (others as any)?.listTitle,
itemId: sanitizedItemId,
hasItemFields: !!parsedItemFields && typeof parsedItemFields === 'object',
@@ -479,8 +477,6 @@ 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),
@@ -521,13 +517,10 @@ 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',
},
driveId: { type: 'string', description: 'Document library (drive) ID (canonical param)' },
folderPath: { type: 'string', description: 'Folder path for file upload' },
fileName: { type: 'string', description: 'File name override' },
files: { type: 'array', description: 'Files to upload' },
files: { type: 'array', description: 'Files to upload (canonical param)' },
},
outputs: {
sites: {

View File

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

View File

@@ -1,5 +1,4 @@
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'
@@ -83,7 +82,22 @@ export const SlackBlock: BlockConfig<SlackResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'slack',
requiredScopes: getScopesForService('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',
],
placeholder: 'Select Slack workspace',
dependsOn: ['authMethod'],
condition: {

View File

@@ -1,5 +1,4 @@
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'
@@ -45,7 +44,7 @@ export const TrelloBlock: BlockConfig<ToolResponse> = {
serviceId: 'trello',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: getScopesForService('trello'),
requiredScopes: ['read', 'write'],
placeholder: 'Select Trello account',
required: true,
},
@@ -60,50 +59,26 @@ export const TrelloBlock: BlockConfig<ToolResponse> = {
},
{
id: 'boardSelector',
id: 'boardId',
title: 'Board',
type: 'project-selector',
canonicalParamId: 'boardId',
serviceId: 'trello',
selectorKey: 'trello.boards',
selectorAllowSearch: false,
placeholder: 'Select Trello board',
dependsOn: ['credential'],
mode: 'basic',
type: 'short-input',
placeholder: 'Enter board ID',
condition: {
field: 'operation',
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'],
value: 'trello_list_lists',
},
required: true,
},
{
id: 'boardId',
title: 'Board ID',
title: 'Board',
type: 'short-input',
canonicalParamId: 'boardId',
placeholder: 'Enter board ID',
mode: 'advanced',
placeholder: 'Enter board ID or search for a board',
condition: {
field: 'operation',
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'],
value: 'trello_list_cards',
},
required: true,
},
{
id: 'listId',
@@ -115,6 +90,17 @@ 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',
@@ -292,6 +278,16 @@ 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,5 +1,4 @@
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'
@@ -37,7 +36,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'wealthbox',
requiredScopes: getScopesForService('wealthbox'),
requiredScopes: ['login', 'data'],
placeholder: 'Select Wealthbox account',
required: true,
},
@@ -63,7 +62,7 @@ export const WealthboxBlock: BlockConfig<WealthboxResponse> = {
type: 'file-selector',
serviceId: 'wealthbox',
selectorKey: 'wealthbox.contacts',
requiredScopes: getScopesForService('wealthbox'),
requiredScopes: ['login', 'data'],
placeholder: 'Enter Contact ID',
mode: 'basic',
canonicalParamId: 'contactId',

View File

@@ -1,5 +1,4 @@
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'
@@ -38,7 +37,7 @@ export const WebflowBlock: BlockConfig<WebflowResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'webflow',
requiredScopes: getScopesForService('webflow'),
requiredScopes: ['sites:read', 'sites:write', 'cms:read', 'cms:write'],
placeholder: 'Select Webflow account',
required: true,
},

View File

@@ -1,5 +1,4 @@
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'
@@ -69,7 +68,7 @@ export const WordPressBlock: BlockConfig<WordPressResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'wordpress',
requiredScopes: getScopesForService('wordpress'),
requiredScopes: ['global'],
placeholder: 'Select WordPress account',
required: true,
},

View File

@@ -1,5 +1,4 @@
import { xIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
@@ -67,7 +66,23 @@ export const XBlock: BlockConfig = {
serviceId: 'x',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: getScopesForService('x'),
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',
],
placeholder: 'Select X account',
},
{

View File

@@ -1,5 +1,4 @@
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'
@@ -41,7 +40,19 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
serviceId: 'zoom',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: getScopesForService('zoom'),
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',
],
placeholder: 'Select Zoom account',
required: true,
},
@@ -66,39 +77,12 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
value: ['zoom_create_meeting', 'zoom_list_meetings', 'zoom_list_recordings'],
},
},
// 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',
],
},
},
// Meeting ID for get/update/delete/invitation/recordings/participants operations
{
id: 'meetingId',
title: 'Meeting ID',
type: 'short-input',
canonicalParamId: 'meetingId',
placeholder: 'Enter meeting ID',
mode: 'advanced',
required: true,
condition: {
field: 'operation',
@@ -130,6 +114,7 @@ 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,16 +7,38 @@ 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,
context: SelectorContext
args: SelectorResolutionArgs
): SelectorResolution | null {
if (!subBlock.selectorKey) return null
return {
key: subBlock.selectorKey,
context: {
...context,
mimeType: subBlock.mimeType ?? context.mimeType,
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,
},
allowSearch: subBlock.selectorAllowSearch ?? true,
}

View File

@@ -2,26 +2,6 @@ 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'
@@ -61,7 +41,7 @@ export interface SelectorOption {
export interface SelectorContext {
workspaceId?: string
workflowId?: string
oauthCredential?: string
credentialId?: string
serviceId?: string
domain?: string
teamId?: string
@@ -74,9 +54,6 @@ export interface SelectorContext {
collectionId?: string
spreadsheetId?: string
excludeWorkflowId?: string
baseId?: string
datasetId?: string
serviceDeskId?: string
}
export interface SelectorQueryArgs {

View File

@@ -1,9 +1,7 @@
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
@@ -31,27 +29,14 @@ 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: resolvedDetailId,
detailId: args.detailId,
}
const hasRealDetailId = Boolean(resolvedDetailId)
const baseEnabled =
hasRealDetailId && definition.fetchById !== undefined
Boolean(args.detailId) && definition.fetchById !== undefined
? definition.enabled
? definition.enabled(queryArgs)
: true
@@ -59,7 +44,7 @@ export function useSelectorOptionDetail(
const enabled = args.enabled ?? baseEnabled
const query = useQuery<SelectorOption | null>({
queryKey: [...definition.getQueryKey(queryArgs), 'detail', resolvedDetailId ?? 'none'],
queryKey: [...definition.getQueryKey(queryArgs), 'detail', args.detailId ?? 'none'],
queryFn: () => definition.fetchById!(queryArgs),
enabled,
staleTime: definition.staleTime ?? 300_000,

View File

@@ -0,0 +1,92 @@
'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,38 +12,24 @@ interface SelectorDisplayNameArgs {
subBlock?: SubBlockConfig
value: unknown
workflowId?: string
oauthCredential?: string
credentialId?: 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,
oauthCredential,
credentialId,
domain,
projectId,
planId,
teamId,
knowledgeBaseId,
baseId,
datasetId,
serviceDeskId,
siteId,
collectionId,
spreadsheetId,
fileId,
}: SelectorDisplayNameArgs) {
const detailId = typeof value === 'string' && value.length > 0 ? value : undefined
@@ -51,37 +37,23 @@ export function useSelectorDisplayName({
if (!subBlock || !detailId) return null
return resolveSelectorForSubBlock(subBlock, {
workflowId,
oauthCredential,
credentialId,
domain,
projectId,
planId,
teamId,
knowledgeBaseId,
baseId,
datasetId,
serviceDeskId,
siteId,
collectionId,
spreadsheetId,
fileId,
})
}, [
subBlock,
detailId,
workflowId,
oauthCredential,
credentialId,
domain,
projectId,
planId,
teamId,
knowledgeBaseId,
baseId,
datasetId,
serviceDeskId,
siteId,
collectionId,
spreadsheetId,
fileId,
])
const key = resolution?.key

View File

@@ -77,7 +77,6 @@ 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
@@ -763,7 +762,7 @@ export const auth = betterAuth({
prompt: 'consent',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scopes: getCanonicalScopesForProvider('github-repo'),
scopes: ['user:email', 'repo', 'read:user', 'workflow'],
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/github-repo`,
getUserInfo: async (tokens) => {
try {
@@ -838,7 +837,13 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-email'),
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',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-email`,
getUserInfo: async (tokens) => {
@@ -874,7 +879,11 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-calendar'),
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/calendar',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-calendar`,
getUserInfo: async (tokens) => {
@@ -910,7 +919,12 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-drive'),
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',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-drive`,
getUserInfo: async (tokens) => {
@@ -946,7 +960,12 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-docs'),
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',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-docs`,
getUserInfo: async (tokens) => {
@@ -982,7 +1001,12 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-sheets'),
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',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-sheets`,
getUserInfo: async (tokens) => {
@@ -1019,7 +1043,11 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-contacts'),
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/contacts',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-contacts`,
getUserInfo: async (tokens) => {
@@ -1055,7 +1083,13 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-forms'),
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',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-forms`,
getUserInfo: async (tokens) => {
@@ -1091,7 +1125,11 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-bigquery'),
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/bigquery',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-bigquery`,
getUserInfo: async (tokens) => {
@@ -1128,7 +1166,12 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-vault'),
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',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-vault`,
getUserInfo: async (tokens) => {
@@ -1165,7 +1208,12 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-groups'),
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',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-groups`,
getUserInfo: async (tokens) => {
@@ -1202,7 +1250,12 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-meet'),
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',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-meet`,
getUserInfo: async (tokens) => {
@@ -1238,7 +1291,11 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('google-tasks'),
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/tasks',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/google-tasks`,
getUserInfo: async (tokens) => {
@@ -1275,7 +1332,11 @@ export const auth = betterAuth({
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('vertex-ai'),
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cloud-platform',
],
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/vertex-ai`,
getUserInfo: async (tokens) => {
@@ -1313,7 +1374,28 @@ 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: getCanonicalScopesForProvider('microsoft-teams'),
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',
],
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1353,7 +1435,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: getCanonicalScopesForProvider('microsoft-excel'),
scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'],
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1392,7 +1474,13 @@ 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: getCanonicalScopesForProvider('microsoft-dataverse'),
scopes: [
'openid',
'profile',
'email',
'https://dynamics.microsoft.com/user_impersonation',
'offline_access',
],
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1434,7 +1522,15 @@ 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: getCanonicalScopesForProvider('microsoft-planner'),
scopes: [
'openid',
'profile',
'email',
'Group.ReadWrite.All',
'Group.Read.All',
'Tasks.ReadWrite',
'offline_access',
],
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1474,7 +1570,16 @@ 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: getCanonicalScopesForProvider('outlook'),
scopes: [
'openid',
'profile',
'email',
'Mail.ReadWrite',
'Mail.ReadBasic',
'Mail.Read',
'Mail.Send',
'offline_access',
],
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1514,7 +1619,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: getCanonicalScopesForProvider('onedrive'),
scopes: ['openid', 'profile', 'email', 'Files.Read', 'Files.ReadWrite', 'offline_access'],
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1554,7 +1659,15 @@ 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: getCanonicalScopesForProvider('sharepoint'),
scopes: [
'openid',
'profile',
'email',
'Sites.Read.All',
'Sites.ReadWrite.All',
'Sites.Manage.All',
'offline_access',
],
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -1594,7 +1707,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: getCanonicalScopesForProvider('wealthbox'),
scopes: ['login', 'data'],
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wealthbox`,
getUserInfo: async (_tokens) => {
@@ -1627,7 +1740,15 @@ export const auth = betterAuth({
tokenUrl: 'https://oauth.pipedrive.com/oauth/token',
userInfoUrl: 'https://api.pipedrive.com/v1/users/me',
prompt: 'consent',
scopes: getCanonicalScopesForProvider('pipedrive'),
scopes: [
'base',
'deals:full',
'contacts:full',
'leads:full',
'activities:full',
'mail:full',
'projects:full',
],
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/pipedrive`,
getUserInfo: async (tokens) => {
@@ -1676,7 +1797,31 @@ export const auth = betterAuth({
tokenUrl: 'https://api.hubapi.com/oauth/v1/token',
userInfoUrl: 'https://api.hubapi.com/oauth/v1/access-tokens',
prompt: 'consent',
scopes: getCanonicalScopesForProvider('hubspot'),
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',
],
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/hubspot`,
getUserInfo: async (tokens) => {
try {
@@ -1748,7 +1893,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: getCanonicalScopesForProvider('salesforce'),
scopes: ['api', 'refresh_token', 'openid', 'offline_access'],
pkce: true,
prompt: 'consent',
accessType: 'offline',
@@ -1799,7 +1944,23 @@ export const auth = betterAuth({
tokenUrl: 'https://api.x.com/2/oauth2/token',
userInfoUrl: 'https://api.x.com/2/users/me',
accessType: 'offline',
scopes: getCanonicalScopesForProvider('x'),
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',
],
pkce: true,
responseType: 'code',
prompt: 'consent',
@@ -1858,7 +2019,45 @@ export const auth = betterAuth({
authorizationUrl: 'https://auth.atlassian.com/authorize',
tokenUrl: 'https://auth.atlassian.com/oauth/token',
userInfoUrl: 'https://api.atlassian.com/me',
scopes: getCanonicalScopesForProvider('confluence'),
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',
],
responseType: 'code',
pkce: true,
accessType: 'offline',
@@ -1910,7 +2109,67 @@ export const auth = betterAuth({
authorizationUrl: 'https://auth.atlassian.com/authorize',
tokenUrl: 'https://auth.atlassian.com/oauth/token',
userInfoUrl: 'https://api.atlassian.com/me',
scopes: getCanonicalScopesForProvider('jira'),
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',
],
responseType: 'code',
pkce: true,
accessType: 'offline',
@@ -1962,7 +2221,13 @@ 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: getCanonicalScopesForProvider('airtable'),
scopes: [
'data.records:read',
'data.records:write',
'schema.bases:read',
'user.email:read',
'webhook:manage',
],
responseType: 'code',
pkce: true,
accessType: 'offline',
@@ -2062,7 +2327,24 @@ 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: getCanonicalScopesForProvider('reddit'),
scopes: [
'identity',
'read',
'submit',
'vote',
'save',
'edit',
'subscribe',
'history',
'privatemessages',
'account',
'mysubreddits',
'flair',
'report',
'modposts',
'modflair',
'modmail',
],
responseType: 'code',
pkce: false,
accessType: 'offline',
@@ -2112,7 +2394,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: getCanonicalScopesForProvider('linear'),
scopes: ['read', 'write'],
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/linear`,
pkce: true,
@@ -2184,7 +2466,17 @@ 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: getCanonicalScopesForProvider('attio'),
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',
],
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/attio`,
getUserInfo: async (tokens) => {
@@ -2237,7 +2529,15 @@ 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: getCanonicalScopesForProvider('dropbox'),
scopes: [
'account_info.read',
'files.metadata.read',
'files.metadata.write',
'files.content.read',
'files.content.write',
'sharing.read',
'sharing.write',
],
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/dropbox`,
pkce: true,
@@ -2293,7 +2593,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: getCanonicalScopesForProvider('asana'),
scopes: ['default'],
responseType: 'code',
pkce: false,
accessType: 'offline',
@@ -2346,7 +2646,23 @@ 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: getCanonicalScopesForProvider('slack'),
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',
],
responseType: 'code',
accessType: 'offline',
prompt: 'consent',
@@ -2406,7 +2722,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: getCanonicalScopesForProvider('webflow'),
scopes: ['sites:read', 'sites:write', 'cms:read', 'cms:write', 'forms:read'],
responseType: 'code',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/webflow`,
getUserInfo: async (tokens) => {
@@ -2456,7 +2772,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: getCanonicalScopesForProvider('linkedin'),
scopes: ['profile', 'openid', 'email', 'w_member_social'],
responseType: 'code',
accessType: 'offline',
prompt: 'consent',
@@ -2506,7 +2822,19 @@ 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: getCanonicalScopesForProvider('zoom'),
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',
],
responseType: 'code',
accessType: 'offline',
authentication: 'basic',
@@ -2558,7 +2886,25 @@ 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: getCanonicalScopesForProvider('spotify'),
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',
],
responseType: 'code',
authentication: 'basic',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/spotify`,
@@ -2607,7 +2953,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: getCanonicalScopesForProvider('wordpress'),
scopes: ['global'],
responseType: 'code',
prompt: 'consent',
redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/wordpress`,
@@ -2654,7 +3000,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: getCanonicalScopesForProvider('calcom'),
scopes: [],
responseType: 'code',
pkce: true,
accessType: 'offline',

View File

@@ -553,51 +553,6 @@ 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,8 +63,6 @@ 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',
@@ -77,8 +75,6 @@ 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',
],
@@ -90,8 +86,6 @@ 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',
],
@@ -103,8 +97,6 @@ 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',
],
@@ -129,11 +121,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-calendar',
icon: GoogleCalendarIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/calendar',
],
scopes: ['https://www.googleapis.com/auth/calendar'],
},
'google-contacts': {
name: 'Google Contacts',
@@ -141,11 +129,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-contacts',
icon: GoogleContactsIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/contacts',
],
scopes: ['https://www.googleapis.com/auth/contacts'],
},
'google-bigquery': {
name: 'Google BigQuery',
@@ -153,11 +137,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-bigquery',
icon: GoogleBigQueryIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/bigquery',
],
scopes: ['https://www.googleapis.com/auth/bigquery'],
},
'google-tasks': {
name: 'Google Tasks',
@@ -165,11 +145,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'google-tasks',
icon: GoogleTasksIcon,
baseProviderIcon: GoogleIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/tasks',
],
scopes: ['https://www.googleapis.com/auth/tasks'],
},
'google-vault': {
name: 'Google Vault',
@@ -178,8 +154,6 @@ 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',
],
@@ -191,8 +165,6 @@ 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',
],
@@ -204,8 +176,6 @@ 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',
],
@@ -216,11 +186,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'vertex-ai',
icon: VertexIcon,
baseProviderIcon: VertexIcon,
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cloud-platform',
],
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
},
},
defaultService: 'gmail',
@@ -705,7 +671,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
providerId: 'webflow',
icon: WebflowIcon,
baseProviderIcon: WebflowIcon,
scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write', 'forms:read'],
scopes: ['cms:read', 'cms:write', 'sites:read', 'sites:write'],
},
},
defaultService: 'webflow',

View File

@@ -122,6 +122,14 @@ 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
@@ -130,6 +138,10 @@ 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,6 +361,209 @@ 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)
@@ -599,111 +802,3 @@ describe('parseProvider', () => {
expect(config.featureType).toBe('sharepoint')
})
})
describe('getScopesForService', () => {
it.concurrent('should return scopes for a valid serviceId', () => {
const scopes = getScopesForService('gmail')
expect(Array.isArray(scopes)).toBe(true)
expect(scopes.length).toBeGreaterThan(0)
expect(scopes).toContain('https://www.googleapis.com/auth/gmail.send')
})
it.concurrent('should return empty array for unknown serviceId', () => {
const scopes = getScopesForService('nonexistent-service')
expect(Array.isArray(scopes)).toBe(true)
expect(scopes.length).toBe(0)
})
it.concurrent('should return new array instance (not reference)', () => {
const scopes1 = getScopesForService('gmail')
const scopes2 = getScopesForService('gmail')
expect(scopes1).not.toBe(scopes2)
expect(scopes1).toEqual(scopes2)
})
it.concurrent('should work for Microsoft services', () => {
const scopes = getScopesForService('outlook')
expect(scopes.length).toBeGreaterThan(0)
expect(scopes).toContain('Mail.ReadWrite')
})
it.concurrent('should return empty array for empty string', () => {
const scopes = getScopesForService('')
expect(Array.isArray(scopes)).toBe(true)
expect(scopes.length).toBe(0)
})
})
describe('getMissingRequiredScopes', () => {
it.concurrent('should return empty array when all scopes are granted', () => {
const credential = { scopes: ['read', 'write'] }
const missing = getMissingRequiredScopes(credential, ['read', 'write'])
expect(missing).toEqual([])
})
it.concurrent('should return missing scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'write'])
expect(missing).toEqual(['write'])
})
it.concurrent('should return all required scopes when credential is undefined', () => {
const missing = getMissingRequiredScopes(undefined, ['read', 'write'])
expect(missing).toEqual(['read', 'write'])
})
it.concurrent('should return all required scopes when credential has undefined scopes', () => {
const missing = getMissingRequiredScopes({ scopes: undefined }, ['read', 'write'])
expect(missing).toEqual(['read', 'write'])
})
it.concurrent('should ignore offline_access in required scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'offline_access'])
expect(missing).toEqual([])
})
it.concurrent('should ignore refresh_token in required scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'refresh_token'])
expect(missing).toEqual([])
})
it.concurrent('should ignore offline.access in required scopes', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, ['read', 'offline.access'])
expect(missing).toEqual([])
})
it.concurrent('should filter ignored scopes even when credential is undefined', () => {
const missing = getMissingRequiredScopes(undefined, ['read', 'offline_access', 'refresh_token'])
expect(missing).toEqual(['read'])
})
it.concurrent('should return empty array when requiredScopes is empty', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential, [])
expect(missing).toEqual([])
})
it.concurrent('should return empty array when requiredScopes defaults to empty', () => {
const credential = { scopes: ['read'] }
const missing = getMissingRequiredScopes(credential)
expect(missing).toEqual([])
})
})

View File

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

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