Compare commits

...

10 Commits

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:53:00 -08:00
Waleed
244cf4ff7e feat(selectors): add dropdown selectors for 14 integrations (#3433)
* feat(selectors): add dropdown selectors for 14 integrations

* fix(selectors): secure OAuth tokens in JSM and Confluence selector routes

Convert JSM selector-servicedesks, selector-requesttypes, and Confluence
selector-spaces routes from GET (with access token in URL query params) to
POST with authorizeCredentialUse + refreshAccessTokenIfNeeded pattern. Also
adds missing ensureCredential guard to microsoft.planner.plans registry entry.

* fix(selectors): use sanitized serviceDeskId and encode SharePoint siteId

Use serviceDeskIdValidation.sanitized instead of raw serviceDeskId in JSM
request types URL. Add encodeURIComponent to SharePoint siteId to prevent
URL path injection.

* lint

* fix(selectors): revert encodeURIComponent on SharePoint siteId

SharePoint site IDs use the format "hostname,guid,guid" with commas that
must remain unencoded for the Microsoft Graph API. The encodeURIComponent
call would convert commas to %2C and break the API call.

* fix(selectors): use sanitized cloudId in Confluence and JSM route URLs

Use cloudIdValidation.sanitized instead of raw cloudId in URL construction
for consistency with the validation pattern, even though the current
validator returns the input unchanged.

* fix(selectors): add missing context fields to resolution, ensureCredential to sharepoint.lists, and siteId validation

- Add baseId, datasetId, serviceDeskId to SelectorResolutionArgs,
  ExtendedSelectorContext, extractExtendedContext, useSelectorDisplayName,
  and resolveSelectorForSubBlock so cascading selectors resolve correctly
  through the resolution path.
- Add ensureCredential guard to sharepoint.lists registry entry.
- Add regex validation for SharePoint siteId format (hostname,GUID,GUID).

* fix(selectors): rename advanced subBlock IDs to avoid canonicalParamId clashes

Rename all advanced-mode subBlock IDs that matched their canonicalParamId
to use a `manual*` prefix, following the established convention
(e.g., manualSiteId, manualCredential). This prevents ambiguity between
subBlock IDs and canonical parameter names in the serialization layer.

25 renames across 14 blocks: baseId→manualBaseId, tableId→manualTableId,
workspace→manualWorkspace, objectType→manualObjectType, etc.

* Revert "fix(selectors): rename advanced subBlock IDs to avoid canonicalParamId clashes"

This reverts commit 4e30161c68.

* fix(selectors): rename canonicalParamIds to avoid subBlock ID clashes

Prefix all clashing canonicalParamId values with `selected_` so they
don't match any subBlock ID. Update each block's `inputs` section and
`tools.config.params` function to destructure the new canonical names
and remap them to the original tool param names. SubBlock IDs and tool
definitions remain unchanged for backwards compatibility.

Affected: 25 canonical params across 14 blocks (airtable, asana, attio,
calcom, confluence, google_bigquery, google_tasks, jsm, microsoft_planner,
notion, pipedrive, sharepoint, trello, zoom).

* fix(selectors): rename pre-existing driveId and files canonicalParamIds in SharePoint

Apply the same selected_ prefix convention to the pre-existing SharePoint
driveId and files canonical params that clashed with their subBlock IDs.

* style: format long lines in calcom, pipedrive, and sharepoint blocks

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

* fix(selectors): resolve cascading context for selected_ canonical params and normalize Asana response

Strip `selected_` prefix from canonical param IDs when mapping to
SelectorContext fields so cascading selectors (Airtable base→table,
BigQuery dataset→table, JSM serviceDesk→requestType) correctly
propagate parent values.

Normalize Asana workspaces route to return `{ id, name }` instead of
`{ gid, name }` for consistency with all other selector routes.

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

* fix(selectors): replace hacky prefix stripping with explicit CANONICAL_TO_CONTEXT mapping

Replace CONTEXT_FIELD_SET (Record<string, true>) with CANONICAL_TO_CONTEXT
(Record<string, keyof SelectorContext>) that explicitly maps canonical
param IDs to their SelectorContext field names.

This properly handles the selected_ prefix aliases (e.g. selected_baseId
→ baseId) without string manipulation, and removes the unsafe
Record<string, unknown> cast.

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

* refactor(selectors): remove unnecessary selected_ prefix from canonicalParamIds

The selected_ prefix was added to avoid a perceived clash between
canonicalParamId and subBlock id values, but this clash does not
actually cause any issues — pre-existing blocks on main (Google Sheets,
Webflow, SharePoint) already use matching values successfully.

Remove the prefix from all 14 blocks, revert use-selector-setup.ts to
the simple CONTEXT_FIELD_SET pattern, and simplify tools.config.params
functions that were only remapping the prefix back.

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

* fix(selectors): add spaceId selector pair to Confluence V2 block

The V2 block was missing the spaceSelector basic-mode selector that the
V1 (Legacy) block already had.

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

* refactor(selectors): revert V1 block changes, add selectors to Notion V1 for V2 inheritance

Confluence V1: reverted to main state (V2 has its own subBlocks).
Notion V1: added selector pairs per-operation since V2 inherits
subBlocks, inputs, and params from V1.

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

* fix(selectors): audit fixes for auth patterns, registry gaps, and display name resolution

- Convert Microsoft Planner plans/tasks routes from GET+getSession to POST+authorizeCredentialUse
- Add fetchById to microsoft.planner (tasks) and sharepoint.sites registry entries
- Add ensureCredential to sharepoint.sites and microsoft.planner registry fetchList
- Update microsoft.planner.plans registry to use POST method
- Add siteId, collectionId, spreadsheetId, fileId to SelectorDisplayNameArgs and caller
- Add fileId to SelectorResolutionArgs and resolution context
- Fix Zoom topicUpdate visibility in basic mode (remove mode:'advanced')
- Change Zoom meetings selector to fetch upcoming_meetings instead of only scheduled

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

* style: lint formatting fixes

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

* fix(selectors): consolidate Notion canonical param pairs into array conditions

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

* fix(selectors): add missing selectorKey to Confluence V1 page selector

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

* fix(selectors): use sanitized IDs in URLs, convert SharePoint routes to POST+authorizeCredentialUse

- Use planIdValidation.sanitized in MS Planner tasks fetch URL
- Convert sharepoint/lists and sharepoint/sites from GET+getSession to POST+authorizeCredentialUse
- Update registry entries to match POST pattern

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

* fix(selectors): revert Zoom meetings type to scheduled for broader compatibility

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

* fix(selectors): add SharePoint site ID validator, fix cascading selector display name fallbacks

- Add validateSharePointSiteId to input-validation.ts
- Use validation util in SharePoint lists route instead of inline regex
- Add || fallback to selector IDs in workflow-block.tsx so cascading
  display names resolve in basic mode (baseSelector, planSelector, etc.)

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

* fix(selectors): hoist requestId before try block in all selector routes

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

* fix(selectors): hoist requestId before try block in Trello boards route

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

* fix(selectors): guard selector queries against unresolved variable references

Skip fetchById and context population when values are design-time
placeholders (<Block.output> or {{ENV_VAR}}) rather than real IDs.

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

* refactor(selectors): replace hardcoded display name fallbacks with canonical-aware resolution

Use resolveDependencyValue to resolve context values for
useSelectorDisplayName, eliminating manual || getStringValue('*Selector')
fallbacks that required updating for each new selector pair.

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

* fix(selectors): tighten SharePoint site ID validation to exclude underscores

SharePoint composite site IDs use hostname,guid,guid format where only
alphanumerics, periods, hyphens, and commas are valid characters.

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

* fix(selectors): ensure string IDs in Pipedrive/Cal.com routes, fix Trello closed board filter

Pipedrive pipelines and Cal.com event-types/schedules routes now
consistently return string IDs via String() conversion.

Trello boards route no longer filters out closed boards, preserving
them for fetchById lookups. The closed filter is applied only in the
registry's fetchList so archived boards don't appear in dropdowns
but can still be resolved by ID for display names.

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

* fix(selectors): convert Zoom meeting IDs to strings for consistency

Zoom API returns numeric meeting IDs. Convert with String() to match
the string ID convention used by all other selector routes.

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

* fix(selectors): align registry types with route string ID returns

Routes already convert numeric IDs to strings via String(), so update
the registry types (CalcomEventType, CalcomSchedule, PipedrivePipeline,
ZoomMeeting) from id: number to id: string and remove the now-redundant
String() coercions in fetchList/fetchById.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:34:28 -08:00
Waleed
ae887185a1 fix(memory): upgrade bun from 1.3.9 to 1.3.10 (#3441) 2026-03-06 11:35:46 -08:00
Waleed
06c88441f8 fix(tool-input): restore workflow input mapper visibility (#3438) 2026-03-06 05:51:27 -08:00
110 changed files with 4764 additions and 2166 deletions

View File

@@ -20,6 +20,7 @@ When the user asks you to create a block:
import { {ServiceName}Icon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { getScopesForService } from '@/lib/oauth/utils'
export const {ServiceName}Block: BlockConfig = {
type: '{service}', // snake_case identifier
@@ -115,12 +116,17 @@ export const {ServiceName}Block: BlockConfig = {
id: 'credential',
title: 'Account',
type: 'oauth-input',
serviceId: '{service}', // Must match OAuth provider
serviceId: '{service}', // Must match OAuth provider service key
requiredScopes: getScopesForService('{service}'), // Import from @/lib/oauth/utils
placeholder: 'Select account',
required: true,
}
```
**Scopes:** Always use `getScopesForService(serviceId)` from `@/lib/oauth/utils` for `requiredScopes`. Never hardcode scope arrays — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.
**Scope descriptions:** When adding a new OAuth provider, also add human-readable descriptions for all scopes in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`.
### Selectors (with dynamic options)
```typescript
// Channel selector (Slack, Discord, etc.)
@@ -624,6 +630,7 @@ export const registry: Record<string, BlockConfig> = {
import { ServiceIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { getScopesForService } from '@/lib/oauth/utils'
export const ServiceBlock: BlockConfig = {
type: 'service',
@@ -654,6 +661,7 @@ export const ServiceBlock: BlockConfig = {
title: 'Service Account',
type: 'oauth-input',
serviceId: 'service',
requiredScopes: getScopesForService('service'),
placeholder: 'Select account',
required: true,
},
@@ -792,7 +800,8 @@ All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MU
- [ ] Conditions use correct syntax (field, value, not, and)
- [ ] DependsOn set for fields that need other values
- [ ] Required fields marked correctly (boolean or condition)
- [ ] OAuth inputs have correct `serviceId`
- [ ] OAuth inputs have correct `serviceId` and `requiredScopes: getScopesForService(serviceId)`
- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes
- [ ] Tools.access lists all tool IDs (snake_case)
- [ ] Tools.config.tool returns correct tool ID (snake_case)
- [ ] Outputs match tool outputs

View File

@@ -114,6 +114,7 @@ export const {service}{Action}Tool: ToolConfig<Params, Response> = {
import { {Service}Icon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { getScopesForService } from '@/lib/oauth/utils'
export const {Service}Block: BlockConfig = {
type: '{service}',
@@ -144,6 +145,7 @@ export const {Service}Block: BlockConfig = {
title: '{Service} Account',
type: 'oauth-input',
serviceId: '{service}',
requiredScopes: getScopesForService('{service}'),
required: true,
},
// Conditional fields per operation
@@ -409,7 +411,7 @@ If creating V2 versions (API-aligned outputs):
### Block
- [ ] Created `blocks/blocks/{service}.ts`
- [ ] Defined operation dropdown with all operations
- [ ] Added credential field (oauth-input or short-input)
- [ ] Added credential field with `requiredScopes: getScopesForService('{service}')`
- [ ] Added conditional fields per operation
- [ ] Set up dependsOn for cascading selectors
- [ ] Configured tools.access with all tool IDs
@@ -419,6 +421,12 @@ If creating V2 versions (API-aligned outputs):
- [ ] If triggers: set `triggers.enabled` and `triggers.available`
- [ ] If triggers: spread trigger subBlocks with `getTrigger()`
### OAuth Scopes (if OAuth service)
- [ ] Defined scopes in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS`
- [ ] Added scope descriptions in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode)
- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode)
### Icon
- [ ] Asked user to provide SVG
- [ ] Added icon to `components/icons.tsx`
@@ -717,6 +725,25 @@ Use `wandConfig` for fields that are hard to fill out manually:
}
```
### OAuth Scopes (Centralized System)
Scopes are maintained in a single source of truth and reused everywhere:
1. **Define scopes** in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
2. **Add descriptions** in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for the OAuth modal UI
3. **Reference in auth.ts** using `getCanonicalScopesForProvider(providerId)` from `@/lib/oauth/utils`
4. **Reference in blocks** using `getScopesForService(serviceId)` from `@/lib/oauth/utils`
**Never hardcode scope arrays** in `auth.ts` or block `requiredScopes`. Always import from the centralized source.
```typescript
// In auth.ts (Better Auth config)
scopes: getCanonicalScopesForProvider('{service}'),
// In block credential sub-block
requiredScopes: getScopesForService('{service}'),
```
### Common Gotchas
1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration
@@ -729,3 +756,5 @@ Use `wandConfig` for fields that are hard to fill out manually:
8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility
9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields
10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled
11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts
12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`

View File

@@ -26,8 +26,9 @@ apps/sim/blocks/blocks/{service}.ts # Block definition
apps/sim/tools/registry.ts # Tool registry entries for this service
apps/sim/blocks/registry.ts # Block registry entry for this service
apps/sim/components/icons.tsx # Icon definition
apps/sim/lib/auth/auth.ts # OAuth scopes (if OAuth service)
apps/sim/lib/oauth/oauth.ts # OAuth provider config (if OAuth service)
apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider()
apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes
apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI
```
## Step 2: Pull API Documentation
@@ -199,11 +200,14 @@ For **each tool** in `tools.access`:
## Step 5: Validate OAuth Scopes (if OAuth service)
- [ ] `auth.ts` scopes include ALL scopes needed by ALL tools in the integration
- [ ] `oauth.ts` provider config scopes match `auth.ts` scopes
- [ ] Block `requiredScopes` (if defined) matches `auth.ts` scopes
Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `lib/oauth/oauth.ts`.
- [ ] Scopes defined in `lib/oauth/oauth.ts` under `OAUTH_PROVIDERS[provider].services[service].scopes`
- [ ] `auth.ts` uses `getCanonicalScopesForProvider(providerId)` — NOT a hardcoded array
- [ ] Block `requiredScopes` uses `getScopesForService(serviceId)` — NOT a hardcoded array
- [ ] No hardcoded scope arrays in `auth.ts` or block files (should all use utility functions)
- [ ] Each scope has a human-readable description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
- [ ] No excess scopes that aren't needed by any tool
- [ ] Each scope has a human-readable description in `oauth-required-modal.tsx`'s `SCOPE_DESCRIPTIONS`
## Step 6: Validate Pagination Consistency
@@ -244,7 +248,8 @@ Group findings by severity:
- Missing `.trim()` on ID fields in request URLs
- Missing `?? null` on nullable response fields
- Block condition array missing an operation that uses that field
- Missing scope description in `oauth-required-modal.tsx`
- Hardcoded scope arrays instead of using `getScopesForService()` / `getCanonicalScopesForProvider()`
- Missing scope description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
**Suggestion** (minor improvements):
- Better description text
@@ -273,7 +278,8 @@ After fixing, confirm:
- [ ] Validated wandConfig on timestamps and complex inputs
- [ ] Validated tools.config mapping, tool selector, and type coercions
- [ ] Validated block outputs match what tools return, with typed JSON where possible
- [ ] Validated OAuth scopes alignment across auth.ts, oauth.ts, block, and modal (if OAuth)
- [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays
- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
- [ ] Validated pagination consistency across tools and block
- [ ] Validated error handling (error checks, meaningful messages)
- [ ] Validated registry entries (tools and block, alphabetical, correct imports)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,40 +6,33 @@
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetSession,
mockDb,
mockLogger,
mockParseProvider,
mockEvaluateScopeCoverage,
mockJwtDecode,
mockEq,
} = vi.hoisted(() => {
const db = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn(),
const { mockGetSession, mockDb, mockLogger, mockParseProvider, mockJwtDecode, mockEq } = vi.hoisted(
() => {
const db = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn(),
}
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
}
return {
mockGetSession: vi.fn(),
mockDb: db,
mockLogger: logger,
mockParseProvider: vi.fn(),
mockJwtDecode: vi.fn(),
mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
}
}
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
}
return {
mockGetSession: vi.fn(),
mockDb: db,
mockLogger: logger,
mockParseProvider: vi.fn(),
mockEvaluateScopeCoverage: vi.fn(),
mockJwtDecode: vi.fn(),
mockEq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
}
})
)
vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
@@ -66,7 +59,6 @@ vi.mock('@sim/logger', () => ({
vi.mock('@/lib/oauth/utils', () => ({
parseProvider: mockParseProvider,
evaluateScopeCoverage: mockEvaluateScopeCoverage,
}))
import { GET } from '@/app/api/auth/oauth/connections/route'
@@ -83,16 +75,6 @@ describe('OAuth Connections API Route', () => {
baseProvider: providerId.split('-')[0] || providerId,
featureType: providerId.split('-')[1] || 'default',
}))
mockEvaluateScopeCoverage.mockImplementation(
(_providerId: string, _grantedScopes: string[]) => ({
canonicalScopes: ['email', 'profile'],
grantedScopes: ['email', 'profile'],
missingScopes: [],
extraScopes: [],
requiresReauthorization: false,
})
)
})
it('should return connections successfully', async () => {

View File

@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import type { OAuthProvider } from '@/lib/oauth'
import { evaluateScopeCoverage, parseProvider } from '@/lib/oauth'
import { parseProvider } from '@/lib/oauth'
const logger = createLogger('OAuthConnectionsAPI')
@@ -49,8 +49,7 @@ export async function GET(request: NextRequest) {
for (const acc of accounts) {
const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider)
const grantedScopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
const scopeEvaluation = evaluateScopeCoverage(acc.providerId, grantedScopes)
const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : []
if (baseProvider) {
// Try multiple methods to get a user-friendly display name
@@ -96,10 +95,6 @@ export async function GET(request: NextRequest) {
const accountSummary = {
id: acc.id,
name: displayName,
scopes: scopeEvaluation.grantedScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
}
if (existingConnection) {
@@ -108,20 +103,8 @@ export async function GET(request: NextRequest) {
existingConnection.accounts.push(accountSummary)
existingConnection.scopes = Array.from(
new Set([...(existingConnection.scopes || []), ...scopeEvaluation.grantedScopes])
new Set([...(existingConnection.scopes || []), ...scopes])
)
existingConnection.missingScopes = Array.from(
new Set([...(existingConnection.missingScopes || []), ...scopeEvaluation.missingScopes])
)
existingConnection.extraScopes = Array.from(
new Set([...(existingConnection.extraScopes || []), ...scopeEvaluation.extraScopes])
)
existingConnection.canonicalScopes =
existingConnection.canonicalScopes && existingConnection.canonicalScopes.length > 0
? existingConnection.canonicalScopes
: scopeEvaluation.canonicalScopes
existingConnection.requiresReauthorization =
existingConnection.requiresReauthorization || scopeEvaluation.requiresReauthorization
const existingTimestamp = existingConnection.lastConnected
? new Date(existingConnection.lastConnected).getTime()
@@ -138,11 +121,7 @@ export async function GET(request: NextRequest) {
baseProvider,
featureType,
isConnected: true,
scopes: scopeEvaluation.grantedScopes,
canonicalScopes: scopeEvaluation.canonicalScopes,
missingScopes: scopeEvaluation.missingScopes,
extraScopes: scopeEvaluation.extraScopes,
requiresReauthorization: scopeEvaluation.requiresReauthorization,
scopes,
lastConnected: acc.updatedAt.toISOString(),
accounts: [accountSummary],
})

View File

@@ -7,7 +7,7 @@
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger } = vi.hoisted(() => {
const { mockCheckSessionOrInternalAuth, mockLogger } = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
@@ -19,7 +19,6 @@ const { mockCheckSessionOrInternalAuth, mockEvaluateScopeCoverage, mockLogger }
}
return {
mockCheckSessionOrInternalAuth: vi.fn(),
mockEvaluateScopeCoverage: vi.fn(),
mockLogger: logger,
}
})
@@ -28,10 +27,6 @@ vi.mock('@/lib/auth/hybrid', () => ({
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))
vi.mock('@/lib/oauth', () => ({
evaluateScopeCoverage: mockEvaluateScopeCoverage,
}))
vi.mock('@/lib/core/utils/request', () => ({
generateRequestId: vi.fn().mockReturnValue('mock-request-id'),
}))
@@ -87,16 +82,6 @@ describe('OAuth Credentials API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEvaluateScopeCoverage.mockImplementation(
(_providerId: string, grantedScopes: string[]) => ({
canonicalScopes: grantedScopes,
grantedScopes,
missingScopes: [],
extraScopes: [],
requiresReauthorization: false,
})
)
})
it('should handle unauthenticated user', async () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { AirtableIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { AirtableResponse } from '@/tools/airtable/types'
@@ -38,13 +39,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'airtable',
requiredScopes: [
'data.records:read',
'data.records:write',
'schema.bases:read',
'user.email:read',
'webhook:manage',
],
requiredScopes: getScopesForService('airtable'),
placeholder: 'Select Airtable account',
required: true,
},
@@ -57,21 +52,51 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'baseSelector',
title: 'Base',
type: 'project-selector',
canonicalParamId: 'baseId',
serviceId: 'airtable',
selectorKey: 'airtable.bases',
selectorAllowSearch: false,
placeholder: 'Select Airtable base',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'listBases', not: true },
required: { field: 'operation', value: 'listBases', not: true },
},
{
id: 'baseId',
title: 'Base ID',
type: 'short-input',
canonicalParamId: 'baseId',
placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)',
dependsOn: ['credential'],
mode: 'advanced',
condition: { field: 'operation', value: 'listBases', not: true },
required: { field: 'operation', value: 'listBases', not: true },
},
{
id: 'tableSelector',
title: 'Table',
type: 'file-selector',
canonicalParamId: 'tableId',
serviceId: 'airtable',
selectorKey: 'airtable.tables',
selectorAllowSearch: false,
placeholder: 'Select Airtable table',
dependsOn: ['credential', 'baseSelector'],
mode: 'basic',
condition: { field: 'operation', value: ['listBases', 'listTables'], not: true },
required: { field: 'operation', value: ['listBases', 'listTables'], not: true },
},
{
id: 'tableId',
title: 'Table ID',
type: 'short-input',
canonicalParamId: 'tableId',
placeholder: 'Enter table ID (e.g., tblXXXXXXXXXXXXXX)',
dependsOn: ['credential', 'baseId'],
mode: 'advanced',
condition: { field: 'operation', value: ['listBases', 'listTables'], not: true },
required: { field: 'operation', value: ['listBases', 'listTables'], not: true },
},

View File

@@ -1,4 +1,5 @@
import { AsanaIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { AsanaResponse } from '@/tools/asana/types'
@@ -36,7 +37,7 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
mode: 'basic',
required: true,
serviceId: 'asana',
requiredScopes: ['default'],
requiredScopes: getScopesForService('asana'),
placeholder: 'Select Asana account',
},
{
@@ -48,12 +49,31 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
placeholder: 'Enter credential ID',
required: true,
},
{
id: 'workspaceSelector',
title: 'Workspace',
type: 'project-selector',
canonicalParamId: 'workspace',
serviceId: 'asana',
selectorKey: 'asana.workspaces',
selectorAllowSearch: false,
placeholder: 'Select Asana workspace',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['create_task', 'get_projects', 'search_tasks'],
},
required: true,
},
{
id: 'workspace',
title: 'Workspace GID',
type: 'short-input',
canonicalParamId: 'workspace',
required: true,
placeholder: 'Enter Asana workspace GID',
mode: 'advanced',
condition: {
field: 'operation',
value: ['create_task', 'get_projects', 'search_tasks'],
@@ -81,11 +101,29 @@ export const AsanaBlock: BlockConfig<AsanaResponse> = {
value: ['update_task', 'add_comment'],
},
},
{
id: 'getTasksWorkspaceSelector',
title: 'Workspace',
type: 'project-selector',
canonicalParamId: 'getTasks_workspace',
serviceId: 'asana',
selectorKey: 'asana.workspaces',
selectorAllowSearch: false,
placeholder: 'Select Asana workspace',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['get_task'],
},
},
{
id: 'getTasks_workspace',
title: 'Workspace GID',
type: 'short-input',
canonicalParamId: 'getTasks_workspace',
placeholder: 'Enter workspace GID',
mode: 'advanced',
condition: {
field: 'operation',
value: ['get_task'],

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { ConfluenceIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -55,37 +56,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence',
requiredScopes: [
'read:confluence-content.all',
'read:confluence-space.summary',
'read:space:confluence',
'read:space-details:confluence',
'write:confluence-content',
'write:confluence-space',
'write:confluence-file',
'read:content:confluence',
'read:page:confluence',
'write:page:confluence',
'read:comment:confluence',
'write:comment:confluence',
'delete:comment:confluence',
'read:attachment:confluence',
'write:attachment:confluence',
'delete:attachment:confluence',
'delete:page:confluence',
'read:label:confluence',
'write:label:confluence',
'search:confluence',
'read:me',
'offline_access',
'read:blogpost:confluence',
'write:blogpost:confluence',
'read:content.property:confluence',
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
'read:user:confluence',
],
requiredScopes: getScopesForService('confluence'),
placeholder: 'Select Confluence account',
required: true,
},
@@ -464,45 +435,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'confluence',
requiredScopes: [
'read:confluence-content.all',
'read:confluence-space.summary',
'read:space:confluence',
'read:space-details:confluence',
'write:confluence-content',
'write:confluence-space',
'write:confluence-file',
'read:content:confluence',
'read:page:confluence',
'write:page:confluence',
'read:comment:confluence',
'write:comment:confluence',
'delete:comment:confluence',
'read:attachment:confluence',
'write:attachment:confluence',
'delete:attachment:confluence',
'delete:page:confluence',
'read:label:confluence',
'write:label:confluence',
'search:confluence',
'read:me',
'offline_access',
'read:blogpost:confluence',
'write:blogpost:confluence',
'read:content.property:confluence',
'write:content.property:confluence',
'read:hierarchical-content:confluence',
'read:content.metadata:confluence',
'read:user:confluence',
'read:task:confluence',
'write:task:confluence',
'delete:blogpost:confluence',
'write:space:confluence',
'delete:space:confluence',
'read:space.property:confluence',
'write:space.property:confluence',
'read:space.permission:confluence',
],
requiredScopes: getScopesForService('confluence'),
placeholder: 'Select Confluence account',
required: true,
},
@@ -645,11 +578,44 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
],
},
},
{
id: 'spaceSelector',
title: 'Space',
type: 'project-selector',
canonicalParamId: 'spaceId',
serviceId: 'confluence',
selectorKey: 'confluence.spaces',
selectorAllowSearch: false,
placeholder: 'Select Confluence space',
dependsOn: ['credential', 'domain'],
mode: 'basic',
required: true,
condition: {
field: 'operation',
value: [
'create',
'get_space',
'update_space',
'delete_space',
'list_pages_in_space',
'search_in_space',
'create_blogpost',
'list_blogposts_in_space',
'list_space_labels',
'list_space_permissions',
'list_space_properties',
'create_space_property',
'delete_space_property',
],
},
},
{
id: 'spaceId',
title: 'Space ID',
type: 'short-input',
canonicalParamId: 'spaceId',
placeholder: 'Enter Confluence space ID',
mode: 'advanced',
required: true,
condition: {
field: 'operation',
@@ -1250,7 +1216,6 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
...rest
} = params
// Use canonical param (serializer already handles basic/advanced mode)
const effectivePageId = pageId ? String(pageId).trim() : ''
if (operation === 'add_label') {
@@ -1511,7 +1476,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
operation: { type: 'string', description: 'Operation to perform' },
domain: { type: 'string', description: 'Confluence domain' },
oauthCredential: { type: 'string', description: 'Confluence access token' },
pageId: { type: 'string', description: 'Page identifier (canonical param)' },
pageId: { type: 'string', description: 'Page identifier' },
spaceId: { type: 'string', description: 'Space identifier' },
blogPostId: { type: 'string', description: 'Blog post identifier' },
versionNumber: { type: 'number', description: 'Page version number' },

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { GoogleBigQueryIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
@@ -36,7 +37,7 @@ export const GoogleBigQueryBlock: BlockConfig = {
mode: 'basic',
required: true,
serviceId: 'google-bigquery',
requiredScopes: ['https://www.googleapis.com/auth/bigquery'],
requiredScopes: getScopesForService('google-bigquery'),
placeholder: 'Select Google account',
},
{
@@ -109,20 +110,52 @@ Return ONLY the SQL query - no explanations, no quotes, no extra text.`,
condition: { field: 'operation', value: 'query' },
},
{
id: 'datasetSelector',
title: 'Dataset',
type: 'project-selector',
canonicalParamId: 'datasetId',
serviceId: 'google-bigquery',
selectorKey: 'bigquery.datasets',
selectorAllowSearch: false,
placeholder: 'Select BigQuery dataset',
dependsOn: ['credential', 'projectId'],
mode: 'basic',
condition: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] },
required: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] },
},
{
id: 'datasetId',
title: 'Dataset ID',
type: 'short-input',
canonicalParamId: 'datasetId',
placeholder: 'Enter BigQuery dataset ID',
mode: 'advanced',
condition: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] },
required: { field: 'operation', value: ['list_tables', 'get_table', 'insert_rows'] },
},
{
id: 'tableSelector',
title: 'Table',
type: 'file-selector',
canonicalParamId: 'tableId',
serviceId: 'google-bigquery',
selectorKey: 'bigquery.tables',
selectorAllowSearch: false,
placeholder: 'Select BigQuery table',
dependsOn: ['credential', 'projectId', 'datasetSelector'],
mode: 'basic',
condition: { field: 'operation', value: ['get_table', 'insert_rows'] },
required: { field: 'operation', value: ['get_table', 'insert_rows'] },
},
{
id: 'tableId',
title: 'Table ID',
type: 'short-input',
canonicalParamId: 'tableId',
placeholder: 'Enter BigQuery table ID',
mode: 'advanced',
condition: { field: 'operation', value: ['get_table', 'insert_rows'] },
required: { field: 'operation', value: ['get_table', 'insert_rows'] },
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { GoogleTasksIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { GoogleTasksResponse } from '@/tools/google_tasks/types'
@@ -38,7 +39,7 @@ export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
mode: 'basic',
required: true,
serviceId: 'google-tasks',
requiredScopes: ['https://www.googleapis.com/auth/tasks'],
requiredScopes: getScopesForService('google-tasks'),
placeholder: 'Select Google Tasks account',
},
{
@@ -51,12 +52,27 @@ export const GoogleTasksBlock: BlockConfig<GoogleTasksResponse> = {
required: true,
},
// Task List ID - shown for all task operations (not list_task_lists)
// Task List - shown for all task operations (not list_task_lists)
{
id: 'taskListSelector',
title: 'Task List',
type: 'project-selector',
canonicalParamId: 'taskListId',
serviceId: 'google-tasks',
selectorKey: 'google.tasks.lists',
selectorAllowSearch: false,
placeholder: 'Select task list',
dependsOn: ['credential'],
mode: 'basic',
condition: { field: 'operation', value: 'list_task_lists', not: true },
},
{
id: 'taskListId',
title: 'Task List ID',
type: 'short-input',
canonicalParamId: 'taskListId',
placeholder: 'Task list ID (leave empty for default list)',
mode: 'advanced',
condition: { field: 'operation', value: 'list_task_lists', not: true },
},
@@ -210,7 +226,9 @@ Return ONLY the timestamp - no explanations, no extra text.`,
params: (params) => {
const { oauthCredential, operation, showCompleted, maxResults, ...rest } = params
const processedParams: Record<string, unknown> = { ...rest }
const processedParams: Record<string, unknown> = {
...rest,
}
if (maxResults && typeof maxResults === 'string') {
processedParams.maxResults = Number.parseInt(maxResults, 10)

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { JiraServiceManagementIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { JsmResponse } from '@/tools/jsm/types'
@@ -59,42 +60,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
mode: 'basic',
required: true,
serviceId: 'jira',
requiredScopes: [
'read:jira-user',
'read:jira-work',
'write:jira-work',
'read:project:jira',
'read:me',
'offline_access',
'read:issue:jira',
'read:status:jira',
'read:user:jira',
'read:issue-details:jira',
'write:comment:jira',
'read:comment:jira',
'read:servicedesk:jira-service-management',
'read:requesttype:jira-service-management',
'read:request:jira-service-management',
'write:request:jira-service-management',
'read:request.comment:jira-service-management',
'write:request.comment:jira-service-management',
'read:customer:jira-service-management',
'write:customer:jira-service-management',
'read:servicedesk.customer:jira-service-management',
'write:servicedesk.customer:jira-service-management',
'read:organization:jira-service-management',
'write:organization:jira-service-management',
'read:servicedesk.organization:jira-service-management',
'write:servicedesk.organization:jira-service-management',
'read:queue:jira-service-management',
'read:request.sla:jira-service-management',
'read:request.status:jira-service-management',
'write:request.status:jira-service-management',
'read:request.participant:jira-service-management',
'write:request.participant:jira-service-management',
'read:request.approval:jira-service-management',
'write:request.approval:jira-service-management',
],
requiredScopes: getScopesForService('jira'),
placeholder: 'Select Jira account',
},
{
@@ -107,10 +73,16 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
required: true,
},
{
id: 'serviceDeskId',
title: 'Service Desk ID',
type: 'short-input',
placeholder: 'Enter service desk ID',
id: 'serviceDeskSelector',
title: 'Service Desk',
type: 'project-selector',
canonicalParamId: 'serviceDeskId',
serviceId: 'jira',
selectorKey: 'jsm.serviceDesks',
selectorAllowSearch: false,
placeholder: 'Select service desk',
dependsOn: ['credential', 'domain'],
mode: 'basic',
required: {
field: 'operation',
value: [
@@ -139,12 +111,63 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
],
},
},
{
id: 'serviceDeskId',
title: 'Service Desk ID',
type: 'short-input',
canonicalParamId: 'serviceDeskId',
placeholder: 'Enter service desk ID',
mode: 'advanced',
required: {
field: 'operation',
value: [
'get_request_types',
'create_request',
'get_customers',
'add_customer',
'get_organizations',
'add_organization',
'get_queues',
'get_request_type_fields',
],
},
condition: {
field: 'operation',
value: [
'get_request_types',
'create_request',
'get_customers',
'add_customer',
'get_organizations',
'add_organization',
'get_queues',
'get_requests',
'get_request_type_fields',
],
},
},
{
id: 'requestTypeSelector',
title: 'Request Type',
type: 'file-selector',
canonicalParamId: 'requestTypeId',
serviceId: 'jira',
selectorKey: 'jsm.requestTypes',
selectorAllowSearch: false,
placeholder: 'Select request type',
dependsOn: ['credential', 'domain', 'serviceDeskSelector'],
mode: 'basic',
required: true,
condition: { field: 'operation', value: ['create_request', 'get_request_type_fields'] },
},
{
id: 'requestTypeId',
title: 'Request Type ID',
type: 'short-input',
canonicalParamId: 'requestTypeId',
required: true,
placeholder: 'Enter request type ID',
mode: 'advanced',
condition: { field: 'operation', value: ['create_request', 'get_request_type_fields'] },
},
{

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { MicrosoftPlannerIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { MicrosoftPlannerResponse } from '@/tools/microsoft_planner/types'
@@ -64,15 +65,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'microsoft-planner',
requiredScopes: [
'openid',
'profile',
'email',
'Group.ReadWrite.All',
'Group.Read.All',
'Tasks.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('microsoft-planner'),
placeholder: 'Select Microsoft account',
},
{
@@ -84,12 +77,36 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
placeholder: 'Enter credential ID',
},
// Plan ID - for various operations
// Plan selector - basic mode
{
id: 'planSelector',
title: 'Plan',
type: 'project-selector',
canonicalParamId: 'planId',
serviceId: 'microsoft-planner',
selectorKey: 'microsoft.planner.plans',
selectorAllowSearch: false,
placeholder: 'Select a plan',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['create_task', 'read_task', 'read_plan', 'list_buckets', 'create_bucket'],
},
required: {
field: 'operation',
value: ['read_plan', 'list_buckets', 'create_bucket', 'create_task'],
},
},
// Plan ID - advanced mode
{
id: 'planId',
title: 'Plan ID',
type: 'short-input',
canonicalParamId: 'planId',
placeholder: 'Enter the plan ID',
mode: 'advanced',
condition: {
field: 'operation',
value: ['create_task', 'read_task', 'read_plan', 'list_buckets', 'create_bucket'],
@@ -110,7 +127,7 @@ export const MicrosoftPlannerBlock: BlockConfig<MicrosoftPlannerResponse> = {
serviceId: 'microsoft-planner',
selectorKey: 'microsoft.planner',
condition: { field: 'operation', value: ['read_task'] },
dependsOn: ['credential', 'planId'],
dependsOn: ['credential', 'planSelector'],
mode: 'basic',
canonicalParamId: 'readTaskId',
},

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { PipedriveIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { PipedriveResponse } from '@/tools/pipedrive/types'
@@ -48,15 +49,7 @@ export const PipedriveBlock: BlockConfig<PipedriveResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'pipedrive',
requiredScopes: [
'base',
'deals:full',
'contacts:full',
'leads:full',
'activities:full',
'mail:full',
'projects:full',
],
requiredScopes: getScopesForService('pipedrive'),
placeholder: 'Select Pipedrive account',
required: true,
},
@@ -96,12 +89,35 @@ export const PipedriveBlock: BlockConfig<PipedriveResponse> = {
placeholder: 'Filter by organization ID',
condition: { field: 'operation', value: ['get_all_deals'] },
},
{
id: 'pipelineSelector',
title: 'Pipeline',
type: 'project-selector',
canonicalParamId: 'pipeline_id',
serviceId: 'pipedrive',
selectorKey: 'pipedrive.pipelines',
selectorAllowSearch: false,
placeholder: 'Select pipeline',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: ['get_all_deals', 'create_deal', 'get_pipeline_deals'],
},
required: { field: 'operation', value: 'get_pipeline_deals' },
},
{
id: 'pipeline_id',
title: 'Pipeline ID',
type: 'short-input',
placeholder: 'Filter by pipeline ID ',
condition: { field: 'operation', value: ['get_all_deals'] },
canonicalParamId: 'pipeline_id',
placeholder: 'Enter pipeline ID',
mode: 'advanced',
condition: {
field: 'operation',
value: ['get_all_deals', 'create_deal', 'get_pipeline_deals'],
},
required: { field: 'operation', value: 'get_pipeline_deals' },
},
{
id: 'updated_since',
@@ -174,13 +190,6 @@ Return ONLY the timestamp string - no explanations, no quotes, no extra text.`,
placeholder: 'Associated organization ID ',
condition: { field: 'operation', value: ['create_deal'] },
},
{
id: 'pipeline_id',
title: 'Pipeline ID',
type: 'short-input',
placeholder: 'Pipeline ID ',
condition: { field: 'operation', value: ['create_deal'] },
},
{
id: 'stage_id',
title: 'Stage ID',
@@ -329,14 +338,6 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n
],
},
},
{
id: 'pipeline_id',
title: 'Pipeline ID',
type: 'short-input',
placeholder: 'Enter pipeline ID',
required: true,
condition: { field: 'operation', value: ['get_pipeline_deals'] },
},
{
id: 'stage_id',
title: 'Stage ID',

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { MicrosoftSharepointIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import { normalizeFileInput } from '@/blocks/utils'
@@ -41,15 +42,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
canonicalParamId: 'oauthCredential',
mode: 'basic',
serviceId: 'sharepoint',
requiredScopes: [
'openid',
'profile',
'email',
'Sites.Read.All',
'Sites.ReadWrite.All',
'Sites.Manage.All',
'offline_access',
],
requiredScopes: getScopesForService('sharepoint'),
placeholder: 'Select Microsoft account',
},
{
@@ -68,14 +61,7 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
canonicalParamId: 'siteId',
serviceId: 'sharepoint',
selectorKey: 'sharepoint.sites',
requiredScopes: [
'openid',
'profile',
'email',
'Files.Read',
'Files.ReadWrite',
'offline_access',
],
requiredScopes: getScopesForService('sharepoint'),
mimeType: 'application/vnd.microsoft.graph.folder',
placeholder: 'Select a site',
dependsOn: ['credential'],
@@ -112,12 +98,26 @@ export const SharepointBlock: BlockConfig<SharepointResponse> = {
mode: 'advanced',
},
{
id: 'listSelector',
title: 'List',
type: 'file-selector',
canonicalParamId: 'listId',
serviceId: 'sharepoint',
selectorKey: 'sharepoint.lists',
selectorAllowSearch: false,
placeholder: 'Select a list',
dependsOn: ['credential', 'siteSelector'],
mode: 'basic',
condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] },
},
{
id: 'listId',
title: 'List ID',
type: 'short-input',
placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.',
canonicalParamId: 'listId',
placeholder: 'Enter list ID (GUID). Required for Update; optional for Read.',
mode: 'advanced',
condition: { field: 'operation', value: ['read_list', 'update_list', 'add_list_items'] },
},
@@ -425,7 +425,9 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
includeColumns,
includeItems,
files, // canonical param from uploadFiles (basic) or files (advanced)
driveId, // canonical param from driveId
columnDefinitions,
listId,
...others
} = rest as any
@@ -457,7 +459,7 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
try {
logger.info('SharepointBlock list item param check', {
siteId: effectiveSiteId || undefined,
listId: (others as any)?.listId,
listId: listId,
listTitle: (others as any)?.listTitle,
itemId: sanitizedItemId,
hasItemFields: !!parsedItemFields && typeof parsedItemFields === 'object',
@@ -477,6 +479,8 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
pageSize: others.pageSize ? Number.parseInt(others.pageSize as string, 10) : undefined,
mimeType: mimeType,
...others,
...(listId ? { listId } : {}),
...(driveId ? { driveId } : {}),
itemId: sanitizedItemId,
listItemFields: parsedItemFields,
includeColumns: coerceBoolean(includeColumns),
@@ -517,10 +521,13 @@ Return ONLY the JSON object - no explanations, no markdown, no extra text.`,
includeItems: { type: 'boolean', description: 'Include items in response' },
itemId: { type: 'string', description: 'List item ID (canonical param)' },
listItemFields: { type: 'string', description: 'List item fields (canonical param)' },
driveId: { type: 'string', description: 'Document library (drive) ID (canonical param)' },
driveId: {
type: 'string',
description: 'Document library (drive) ID',
},
folderPath: { type: 'string', description: 'Folder path for file upload' },
fileName: { type: 'string', description: 'File name override' },
files: { type: 'array', description: 'Files to upload (canonical param)' },
files: { type: 'array', description: 'Files to upload' },
},
outputs: {
sites: {

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { TrelloIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { ToolResponse } from '@/tools/types'
@@ -44,7 +45,7 @@ export const TrelloBlock: BlockConfig<ToolResponse> = {
serviceId: 'trello',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: ['read', 'write'],
requiredScopes: getScopesForService('trello'),
placeholder: 'Select Trello account',
required: true,
},
@@ -59,26 +60,50 @@ export const TrelloBlock: BlockConfig<ToolResponse> = {
},
{
id: 'boardId',
id: 'boardSelector',
title: 'Board',
type: 'short-input',
placeholder: 'Enter board ID',
type: 'project-selector',
canonicalParamId: 'boardId',
serviceId: 'trello',
selectorKey: 'trello.boards',
selectorAllowSearch: false,
placeholder: 'Select Trello board',
dependsOn: ['credential'],
mode: 'basic',
condition: {
field: 'operation',
value: 'trello_list_lists',
value: [
'trello_list_lists',
'trello_list_cards',
'trello_create_card',
'trello_get_actions',
],
},
required: {
field: 'operation',
value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'],
},
required: true,
},
{
id: 'boardId',
title: 'Board',
title: 'Board ID',
type: 'short-input',
placeholder: 'Enter board ID or search for a board',
canonicalParamId: 'boardId',
placeholder: 'Enter board ID',
mode: 'advanced',
condition: {
field: 'operation',
value: 'trello_list_cards',
value: [
'trello_list_lists',
'trello_list_cards',
'trello_create_card',
'trello_get_actions',
],
},
required: {
field: 'operation',
value: ['trello_list_lists', 'trello_list_cards', 'trello_create_card'],
},
required: true,
},
{
id: 'listId',
@@ -90,17 +115,6 @@ export const TrelloBlock: BlockConfig<ToolResponse> = {
value: 'trello_list_cards',
},
},
{
id: 'boardId',
title: 'Board',
type: 'short-input',
placeholder: 'Enter board ID or search for a board',
condition: {
field: 'operation',
value: 'trello_create_card',
},
required: true,
},
{
id: 'listId',
title: 'List',
@@ -278,16 +292,6 @@ Return ONLY the date/timestamp string - no explanations, no quotes, no extra tex
},
},
{
id: 'boardId',
title: 'Board ID',
type: 'short-input',
placeholder: 'Enter board ID to get board actions',
condition: {
field: 'operation',
value: 'trello_get_actions',
},
},
{
id: 'cardId',
title: 'Card ID',

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { ZoomIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
import type { ZoomResponse } from '@/tools/zoom/types'
@@ -40,19 +41,7 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
serviceId: 'zoom',
canonicalParamId: 'oauthCredential',
mode: 'basic',
requiredScopes: [
'user:read:user',
'meeting:write:meeting',
'meeting:read:meeting',
'meeting:read:list_meetings',
'meeting:update:meeting',
'meeting:delete:meeting',
'meeting:read:invitation',
'meeting:read:list_past_participants',
'cloud_recording:read:list_user_recordings',
'cloud_recording:read:list_recording_files',
'cloud_recording:delete:recording_file',
],
requiredScopes: getScopesForService('zoom'),
placeholder: 'Select Zoom account',
required: true,
},
@@ -77,12 +66,39 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
value: ['zoom_create_meeting', 'zoom_list_meetings', 'zoom_list_recordings'],
},
},
// Meeting ID for get/update/delete/invitation/recordings/participants operations
// Meeting selector for get/update/delete/invitation/recordings/participants operations
{
id: 'meetingSelector',
title: 'Meeting',
type: 'project-selector',
canonicalParamId: 'meetingId',
serviceId: 'zoom',
selectorKey: 'zoom.meetings',
selectorAllowSearch: true,
placeholder: 'Select Zoom meeting',
dependsOn: ['credential'],
mode: 'basic',
required: true,
condition: {
field: 'operation',
value: [
'zoom_get_meeting',
'zoom_update_meeting',
'zoom_delete_meeting',
'zoom_get_meeting_invitation',
'zoom_get_meeting_recordings',
'zoom_delete_recording',
'zoom_list_past_participants',
],
},
},
{
id: 'meetingId',
title: 'Meeting ID',
type: 'short-input',
canonicalParamId: 'meetingId',
placeholder: 'Enter meeting ID',
mode: 'advanced',
required: true,
condition: {
field: 'operation',
@@ -114,7 +130,6 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
title: 'Topic',
type: 'short-input',
placeholder: 'Meeting topic (optional)',
mode: 'advanced',
condition: {
field: 'operation',
value: ['zoom_update_meeting'],

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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