mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-10 22:55:16 -05:00
Compare commits
8 Commits
feat/copil
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5dc180d9f | ||
|
|
78fef22d0e | ||
|
|
6d16f216c8 | ||
|
|
f8e9614c9c | ||
|
|
c5dd90e79d | ||
|
|
20b230d1aa | ||
|
|
be3cdcf981 | ||
|
|
73540e3936 |
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1.3.3-alpine
|
||||
FROM oven/bun:1.3.9-alpine
|
||||
|
||||
# Install necessary packages for development
|
||||
RUN apk add --no-cache \
|
||||
|
||||
2
.github/workflows/docs-embeddings.yml
vendored
2
.github/workflows/docs-embeddings.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.3
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
4
.github/workflows/i18n.yml
vendored
4
.github/workflows/i18n.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.3
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.3
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
|
||||
2
.github/workflows/migrations.yml
vendored
2
.github/workflows/migrations.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.3
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
|
||||
2
.github/workflows/publish-cli.yml
vendored
2
.github/workflows/publish-cli.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.3
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/publish-ts-sdk.yml
vendored
2
.github/workflows/publish-ts-sdk.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.3
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Setup Node.js for npm publishing
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/test-build.yml
vendored
2
.github/workflows/test-build.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.3
|
||||
bun-version: 1.3.9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -44,6 +44,7 @@ Retrieve detailed information about a specific Jira issue
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `issueKey` | string | Yes | Jira issue key to retrieve \(e.g., PROJ-123\) |
|
||||
| `includeAttachments` | boolean | No | Download attachment file contents and include them as files in the output |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
@@ -65,6 +66,7 @@ Retrieve detailed information about a specific Jira issue
|
||||
| ↳ `key` | string | Status category key \(e.g., new, indeterminate, done\) |
|
||||
| ↳ `name` | string | Status category name \(e.g., To Do, In Progress, Done\) |
|
||||
| ↳ `colorName` | string | Status category color \(e.g., blue-gray, yellow, green\) |
|
||||
| `statusName` | string | Issue status name \(e.g., Open, In Progress, Done\) |
|
||||
| `issuetype` | object | Issue type |
|
||||
| ↳ `id` | string | Issue type ID |
|
||||
| ↳ `name` | string | Issue type name \(e.g., Task, Bug, Story, Epic\) |
|
||||
@@ -88,6 +90,7 @@ Retrieve detailed information about a specific Jira issue
|
||||
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
|
||||
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
|
||||
| ↳ `timeZone` | string | User timezone |
|
||||
| `assigneeName` | string | Assignee display name or account ID |
|
||||
| `reporter` | object | Reporter user |
|
||||
| ↳ `accountId` | string | Atlassian account ID of the user |
|
||||
| ↳ `displayName` | string | Display name of the user |
|
||||
@@ -173,6 +176,7 @@ Retrieve detailed information about a specific Jira issue
|
||||
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
|
||||
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
|
||||
| ↳ `timeZone` | string | User timezone |
|
||||
| ↳ `authorName` | string | Comment author display name |
|
||||
| ↳ `updateAuthor` | object | User who last updated the comment |
|
||||
| ↳ `accountId` | string | Atlassian account ID of the user |
|
||||
| ↳ `displayName` | string | Display name of the user |
|
||||
@@ -196,6 +200,7 @@ Retrieve detailed information about a specific Jira issue
|
||||
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
|
||||
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
|
||||
| ↳ `timeZone` | string | User timezone |
|
||||
| ↳ `authorName` | string | Worklog author display name |
|
||||
| ↳ `updateAuthor` | object | User who last updated the worklog |
|
||||
| ↳ `accountId` | string | Atlassian account ID of the user |
|
||||
| ↳ `displayName` | string | Display name of the user |
|
||||
@@ -225,9 +230,11 @@ Retrieve detailed information about a specific Jira issue
|
||||
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
|
||||
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
|
||||
| ↳ `timeZone` | string | User timezone |
|
||||
| ↳ `authorName` | string | Attachment author display name |
|
||||
| ↳ `created` | string | ISO 8601 timestamp when the attachment was created |
|
||||
| `issueKey` | string | Issue key \(e.g., PROJ-123\) |
|
||||
| `issue` | json | Complete raw Jira issue object from the API |
|
||||
| `files` | file[] | Downloaded attachment files \(only when includeAttachments is true\) |
|
||||
|
||||
### `jira_update`
|
||||
|
||||
@@ -258,6 +265,7 @@ Update a Jira issue
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Updated issue key \(e.g., PROJ-123\) |
|
||||
| `summary` | string | Issue summary after update |
|
||||
|
||||
@@ -296,6 +304,7 @@ Create a new Jira issue
|
||||
| `issueKey` | string | Created issue key \(e.g., PROJ-123\) |
|
||||
| `self` | string | REST API URL for the created issue |
|
||||
| `summary` | string | Issue summary |
|
||||
| `success` | boolean | Whether the issue was created successfully |
|
||||
| `url` | string | URL to the created issue in Jira |
|
||||
| `assigneeId` | string | Account ID of the assigned user \(null if no assignee was set\) |
|
||||
|
||||
@@ -358,6 +367,7 @@ Delete a Jira issue
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Deleted issue key |
|
||||
|
||||
### `jira_assign_issue`
|
||||
@@ -378,6 +388,7 @@ Assign a Jira issue to a user
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Issue key that was assigned |
|
||||
| `assigneeId` | string | Account ID of the assignee \(use "-1" for auto-assign, null to unassign\) |
|
||||
|
||||
@@ -401,6 +412,7 @@ Move a Jira issue between workflow statuses (e.g., To Do -> In Progress)
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Issue key that was transitioned |
|
||||
| `transitionId` | string | Applied transition ID |
|
||||
| `transitionName` | string | Applied transition name |
|
||||
@@ -443,6 +455,7 @@ Search for Jira issues using JQL (Jira Query Language)
|
||||
| ↳ `key` | string | Status category key \(e.g., new, indeterminate, done\) |
|
||||
| ↳ `name` | string | Status category name \(e.g., To Do, In Progress, Done\) |
|
||||
| ↳ `colorName` | string | Status category color \(e.g., blue-gray, yellow, green\) |
|
||||
| ↳ `statusName` | string | Issue status name \(e.g., Open, In Progress, Done\) |
|
||||
| ↳ `issuetype` | object | Issue type |
|
||||
| ↳ `id` | string | Issue type ID |
|
||||
| ↳ `name` | string | Issue type name \(e.g., Task, Bug, Story, Epic\) |
|
||||
@@ -466,6 +479,7 @@ Search for Jira issues using JQL (Jira Query Language)
|
||||
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
|
||||
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
|
||||
| ↳ `timeZone` | string | User timezone |
|
||||
| ↳ `assigneeName` | string | Assignee display name or account ID |
|
||||
| ↳ `reporter` | object | Reporter user |
|
||||
| ↳ `accountId` | string | Atlassian account ID of the user |
|
||||
| ↳ `displayName` | string | Display name of the user |
|
||||
@@ -509,6 +523,7 @@ Add a comment to a Jira issue
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Issue key the comment was added to |
|
||||
| `commentId` | string | Created comment ID |
|
||||
| `body` | string | Comment text content |
|
||||
@@ -558,6 +573,7 @@ Get all comments from a Jira issue
|
||||
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
|
||||
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
|
||||
| ↳ `timeZone` | string | User timezone |
|
||||
| ↳ `authorName` | string | Comment author display name |
|
||||
| ↳ `updateAuthor` | object | User who last updated the comment |
|
||||
| ↳ `accountId` | string | Atlassian account ID of the user |
|
||||
| ↳ `displayName` | string | Display name of the user |
|
||||
@@ -592,6 +608,7 @@ Update an existing comment on a Jira issue
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Issue key |
|
||||
| `commentId` | string | Updated comment ID |
|
||||
| `body` | string | Updated comment text |
|
||||
@@ -624,6 +641,7 @@ Delete a comment from a Jira issue
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Issue key |
|
||||
| `commentId` | string | Deleted comment ID |
|
||||
|
||||
@@ -637,6 +655,7 @@ Get all attachments from a Jira issue
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `domain` | string | Yes | Your Jira domain \(e.g., yourcompany.atlassian.net\) |
|
||||
| `issueKey` | string | Yes | Jira issue key to get attachments from \(e.g., PROJ-123\) |
|
||||
| `includeAttachments` | boolean | No | Download attachment file contents and include them as files in the output |
|
||||
| `cloudId` | string | No | Jira Cloud ID for the instance. If not provided, it will be fetched using the domain. |
|
||||
|
||||
#### Output
|
||||
@@ -660,7 +679,9 @@ Get all attachments from a Jira issue
|
||||
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
|
||||
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
|
||||
| ↳ `timeZone` | string | User timezone |
|
||||
| ↳ `authorName` | string | Attachment author display name |
|
||||
| ↳ `created` | string | ISO 8601 timestamp when the attachment was created |
|
||||
| `files` | file[] | Downloaded attachment files \(only when includeAttachments is true\) |
|
||||
|
||||
### `jira_add_attachment`
|
||||
|
||||
@@ -688,10 +709,7 @@ Add attachments to a Jira issue
|
||||
| ↳ `size` | number | File size in bytes |
|
||||
| ↳ `content` | string | URL to download the attachment |
|
||||
| `attachmentIds` | array | Array of attachment IDs |
|
||||
| `files` | array | Uploaded file metadata |
|
||||
| ↳ `name` | string | File name |
|
||||
| ↳ `mimeType` | string | MIME type |
|
||||
| ↳ `size` | number | File size in bytes |
|
||||
| `files` | file[] | Uploaded attachment files |
|
||||
|
||||
### `jira_delete_attachment`
|
||||
|
||||
@@ -710,6 +728,7 @@ Delete an attachment from a Jira issue
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `attachmentId` | string | Deleted attachment ID |
|
||||
|
||||
### `jira_add_worklog`
|
||||
@@ -733,6 +752,7 @@ Add a time tracking worklog entry to a Jira issue
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Issue key the worklog was added to |
|
||||
| `worklogId` | string | Created worklog ID |
|
||||
| `timeSpent` | string | Time spent in human-readable format \(e.g., 3h 20m\) |
|
||||
@@ -781,6 +801,7 @@ Get all worklog entries from a Jira issue
|
||||
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
|
||||
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
|
||||
| ↳ `timeZone` | string | User timezone |
|
||||
| ↳ `authorName` | string | Worklog author display name |
|
||||
| ↳ `updateAuthor` | object | User who last updated the worklog |
|
||||
| ↳ `accountId` | string | Atlassian account ID of the user |
|
||||
| ↳ `displayName` | string | Display name of the user |
|
||||
@@ -818,6 +839,7 @@ Update an existing worklog entry on a Jira issue
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Issue key |
|
||||
| `worklogId` | string | Updated worklog ID |
|
||||
| `timeSpent` | string | Human-readable time spent \(e.g., "3h 20m"\) |
|
||||
@@ -861,6 +883,7 @@ Delete a worklog entry from a Jira issue
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Issue key |
|
||||
| `worklogId` | string | Deleted worklog ID |
|
||||
|
||||
@@ -884,6 +907,7 @@ Create a link relationship between two Jira issues
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `inwardIssue` | string | Inward issue key |
|
||||
| `outwardIssue` | string | Outward issue key |
|
||||
| `linkType` | string | Type of issue link |
|
||||
@@ -906,6 +930,7 @@ Delete a link between two Jira issues
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `linkId` | string | Deleted link ID |
|
||||
|
||||
### `jira_add_watcher`
|
||||
@@ -926,6 +951,7 @@ Add a watcher to a Jira issue to receive notifications about updates
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Issue key |
|
||||
| `watcherAccountId` | string | Added watcher account ID |
|
||||
|
||||
@@ -947,6 +973,7 @@ Remove a watcher from a Jira issue
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `ts` | string | ISO 8601 timestamp of the operation |
|
||||
| `success` | boolean | Operation success status |
|
||||
| `issueKey` | string | Issue key |
|
||||
| `watcherAccountId` | string | Removed watcher account ID |
|
||||
|
||||
@@ -977,6 +1004,8 @@ Get Jira users. If an account ID is provided, returns a single user. Otherwise,
|
||||
| ↳ `accountType` | string | Type of account \(e.g., atlassian, app, customer\) |
|
||||
| ↳ `avatarUrl` | string | URL to the user avatar \(48x48\) |
|
||||
| ↳ `timeZone` | string | User timezone |
|
||||
| ↳ `avatarUrls` | json | User avatar URLs in multiple sizes \(16x16, 24x24, 32x32, 48x48\) |
|
||||
| ↳ `self` | string | REST API URL for this user |
|
||||
| `total` | number | Total number of users returned |
|
||||
| `startAt` | number | Pagination start index |
|
||||
| `maxResults` | number | Maximum results per page |
|
||||
|
||||
@@ -41,7 +41,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<Ro
|
||||
|
||||
if (!agent.agent.isPublished) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(agent.agent.workspaceId, auth.userId)
|
||||
if (!workspaceAccess.exists || !workspaceAccess.hasAccess) {
|
||||
return NextResponse.json({ error: 'Agent not published' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { sanitizeAgentName } from '@/lib/a2a/utils'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { hasValidStartBlockInState } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('A2AAgentsAPI')
|
||||
|
||||
@@ -39,10 +39,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ws = await getWorkspaceById(workspaceId)
|
||||
if (!ws) {
|
||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId)
|
||||
if (!workspaceAccess.exists) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
if (!workspaceAccess.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const agents = await db
|
||||
.select({
|
||||
@@ -103,6 +106,14 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(workspaceId, auth.userId)
|
||||
if (!workspaceAccess.exists) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
if (!workspaceAccess.canWrite) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const [wf] = await db
|
||||
.select({
|
||||
id: workflow.id,
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
isTerminalState,
|
||||
parseWorkflowSSEChunk,
|
||||
} from '@/lib/a2a/utils'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
|
||||
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
|
||||
import { SSE_HEADERS } from '@/lib/core/utils/sse'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import {
|
||||
A2A_ERROR_CODES,
|
||||
A2A_METHODS,
|
||||
@@ -191,6 +193,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
|
||||
const authSchemes = (agent.authentication as { schemes?: string[] })?.schemes || []
|
||||
const requiresAuth = !authSchemes.includes('none')
|
||||
let authenticatedUserId: string | null = null
|
||||
let authenticatedAuthType: AuthResult['authType']
|
||||
let authenticatedApiKeyType: AuthResult['apiKeyType']
|
||||
|
||||
if (requiresAuth) {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
@@ -200,6 +205,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
authenticatedUserId = auth.userId
|
||||
authenticatedAuthType = auth.authType
|
||||
authenticatedApiKeyType = auth.apiKeyType
|
||||
|
||||
const workspaceAccess = await checkWorkspaceAccess(agent.workspaceId, authenticatedUserId)
|
||||
if (!workspaceAccess.exists || !workspaceAccess.hasAccess) {
|
||||
return NextResponse.json(
|
||||
createError(null, A2A_ERROR_CODES.AUTHENTICATION_REQUIRED, 'Access denied'),
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [wf] = await db
|
||||
@@ -225,34 +241,61 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
}
|
||||
|
||||
const { id, method, params: rpcParams } = body
|
||||
const apiKey = request.headers.get('X-API-Key')
|
||||
const requestApiKey = request.headers.get('X-API-Key')
|
||||
const apiKey = authenticatedAuthType === 'api_key' ? requestApiKey : null
|
||||
const isPersonalApiKeyCaller =
|
||||
authenticatedAuthType === 'api_key' && authenticatedApiKeyType === 'personal'
|
||||
const billedUserId = await getWorkspaceBilledAccountUserId(agent.workspaceId)
|
||||
if (!billedUserId) {
|
||||
logger.error('Unable to resolve workspace billed account for A2A execution', {
|
||||
agentId: agent.id,
|
||||
workspaceId: agent.workspaceId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
createError(
|
||||
id,
|
||||
A2A_ERROR_CODES.INTERNAL_ERROR,
|
||||
'Unable to resolve billing account for this workspace'
|
||||
),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
const executionUserId =
|
||||
isPersonalApiKeyCaller && authenticatedUserId ? authenticatedUserId : billedUserId
|
||||
|
||||
logger.info(`A2A request: ${method} for agent ${agentId}`)
|
||||
|
||||
switch (method) {
|
||||
case A2A_METHODS.MESSAGE_SEND:
|
||||
return handleMessageSend(id, agent, rpcParams as MessageSendParams, apiKey)
|
||||
return handleMessageSend(id, agent, rpcParams as MessageSendParams, apiKey, executionUserId)
|
||||
|
||||
case A2A_METHODS.MESSAGE_STREAM:
|
||||
return handleMessageStream(request, id, agent, rpcParams as MessageSendParams, apiKey)
|
||||
return handleMessageStream(
|
||||
request,
|
||||
id,
|
||||
agent,
|
||||
rpcParams as MessageSendParams,
|
||||
apiKey,
|
||||
executionUserId
|
||||
)
|
||||
|
||||
case A2A_METHODS.TASKS_GET:
|
||||
return handleTaskGet(id, rpcParams as TaskIdParams)
|
||||
return handleTaskGet(id, agent.id, rpcParams as TaskIdParams)
|
||||
|
||||
case A2A_METHODS.TASKS_CANCEL:
|
||||
return handleTaskCancel(id, rpcParams as TaskIdParams)
|
||||
return handleTaskCancel(id, agent.id, rpcParams as TaskIdParams)
|
||||
|
||||
case A2A_METHODS.TASKS_RESUBSCRIBE:
|
||||
return handleTaskResubscribe(request, id, rpcParams as TaskIdParams)
|
||||
return handleTaskResubscribe(request, id, agent.id, rpcParams as TaskIdParams)
|
||||
|
||||
case A2A_METHODS.PUSH_NOTIFICATION_SET:
|
||||
return handlePushNotificationSet(id, rpcParams as PushNotificationSetParams)
|
||||
return handlePushNotificationSet(id, agent.id, rpcParams as PushNotificationSetParams)
|
||||
|
||||
case A2A_METHODS.PUSH_NOTIFICATION_GET:
|
||||
return handlePushNotificationGet(id, rpcParams as TaskIdParams)
|
||||
return handlePushNotificationGet(id, agent.id, rpcParams as TaskIdParams)
|
||||
|
||||
case A2A_METHODS.PUSH_NOTIFICATION_DELETE:
|
||||
return handlePushNotificationDelete(id, rpcParams as TaskIdParams)
|
||||
return handlePushNotificationDelete(id, agent.id, rpcParams as TaskIdParams)
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
@@ -268,6 +311,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
}
|
||||
}
|
||||
|
||||
async function getTaskForAgent(taskId: string, agentId: string) {
|
||||
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, taskId)).limit(1)
|
||||
if (!task || task.agentId !== agentId) {
|
||||
return null
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle message/send - Send a message (v0.3)
|
||||
*/
|
||||
@@ -280,7 +331,8 @@ async function handleMessageSend(
|
||||
workspaceId: string
|
||||
},
|
||||
params: MessageSendParams,
|
||||
apiKey?: string | null
|
||||
apiKey?: string | null,
|
||||
executionUserId?: string
|
||||
): Promise<NextResponse> {
|
||||
if (!params?.message) {
|
||||
return NextResponse.json(
|
||||
@@ -318,6 +370,13 @@ async function handleMessageSend(
|
||||
)
|
||||
}
|
||||
|
||||
if (existingTask.agentId !== agent.id) {
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'),
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (isTerminalState(existingTask.status as TaskState)) {
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.TASK_ALREADY_COMPLETE, 'Task already in terminal state'),
|
||||
@@ -363,6 +422,7 @@ async function handleMessageSend(
|
||||
} = await buildExecuteRequest({
|
||||
workflowId: agent.workflowId,
|
||||
apiKey,
|
||||
userId: executionUserId,
|
||||
})
|
||||
|
||||
logger.info(`Executing workflow ${agent.workflowId} for A2A task ${taskId}`)
|
||||
@@ -475,7 +535,8 @@ async function handleMessageStream(
|
||||
workspaceId: string
|
||||
},
|
||||
params: MessageSendParams,
|
||||
apiKey?: string | null
|
||||
apiKey?: string | null,
|
||||
executionUserId?: string
|
||||
): Promise<NextResponse> {
|
||||
if (!params?.message) {
|
||||
return NextResponse.json(
|
||||
@@ -522,6 +583,13 @@ async function handleMessageStream(
|
||||
})
|
||||
}
|
||||
|
||||
if (existingTask.agentId !== agent.id) {
|
||||
await releaseLock(lockKey, lockValue)
|
||||
return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
|
||||
status: 404,
|
||||
})
|
||||
}
|
||||
|
||||
if (isTerminalState(existingTask.status as TaskState)) {
|
||||
await releaseLock(lockKey, lockValue)
|
||||
return NextResponse.json(
|
||||
@@ -595,6 +663,7 @@ async function handleMessageStream(
|
||||
} = await buildExecuteRequest({
|
||||
workflowId: agent.workflowId,
|
||||
apiKey,
|
||||
userId: executionUserId,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
@@ -788,7 +857,11 @@ async function handleMessageStream(
|
||||
/**
|
||||
* Handle tasks/get - Query task status
|
||||
*/
|
||||
async function handleTaskGet(id: string | number, params: TaskIdParams): Promise<NextResponse> {
|
||||
async function handleTaskGet(
|
||||
id: string | number,
|
||||
agentId: string,
|
||||
params: TaskIdParams
|
||||
): Promise<NextResponse> {
|
||||
if (!params?.id) {
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'),
|
||||
@@ -801,7 +874,7 @@ async function handleTaskGet(id: string | number, params: TaskIdParams): Promise
|
||||
? params.historyLength
|
||||
: undefined
|
||||
|
||||
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
|
||||
const task = await getTaskForAgent(params.id, agentId)
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
|
||||
@@ -825,7 +898,11 @@ async function handleTaskGet(id: string | number, params: TaskIdParams): Promise
|
||||
/**
|
||||
* Handle tasks/cancel - Cancel a running task
|
||||
*/
|
||||
async function handleTaskCancel(id: string | number, params: TaskIdParams): Promise<NextResponse> {
|
||||
async function handleTaskCancel(
|
||||
id: string | number,
|
||||
agentId: string,
|
||||
params: TaskIdParams
|
||||
): Promise<NextResponse> {
|
||||
if (!params?.id) {
|
||||
return NextResponse.json(
|
||||
createError(id, A2A_ERROR_CODES.INVALID_PARAMS, 'Task ID is required'),
|
||||
@@ -833,7 +910,7 @@ async function handleTaskCancel(id: string | number, params: TaskIdParams): Prom
|
||||
)
|
||||
}
|
||||
|
||||
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
|
||||
const task = await getTaskForAgent(params.id, agentId)
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
|
||||
@@ -897,6 +974,7 @@ async function handleTaskCancel(id: string | number, params: TaskIdParams): Prom
|
||||
async function handleTaskResubscribe(
|
||||
request: NextRequest,
|
||||
id: string | number,
|
||||
agentId: string,
|
||||
params: TaskIdParams
|
||||
): Promise<NextResponse> {
|
||||
if (!params?.id) {
|
||||
@@ -906,7 +984,7 @@ async function handleTaskResubscribe(
|
||||
)
|
||||
}
|
||||
|
||||
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
|
||||
const task = await getTaskForAgent(params.id, agentId)
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
|
||||
@@ -1103,6 +1181,7 @@ async function handleTaskResubscribe(
|
||||
*/
|
||||
async function handlePushNotificationSet(
|
||||
id: string | number,
|
||||
agentId: string,
|
||||
params: PushNotificationSetParams
|
||||
): Promise<NextResponse> {
|
||||
if (!params?.id) {
|
||||
@@ -1130,7 +1209,7 @@ async function handlePushNotificationSet(
|
||||
)
|
||||
}
|
||||
|
||||
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
|
||||
const task = await getTaskForAgent(params.id, agentId)
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
|
||||
@@ -1181,6 +1260,7 @@ async function handlePushNotificationSet(
|
||||
*/
|
||||
async function handlePushNotificationGet(
|
||||
id: string | number,
|
||||
agentId: string,
|
||||
params: TaskIdParams
|
||||
): Promise<NextResponse> {
|
||||
if (!params?.id) {
|
||||
@@ -1190,7 +1270,7 @@ async function handlePushNotificationGet(
|
||||
)
|
||||
}
|
||||
|
||||
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
|
||||
const task = await getTaskForAgent(params.id, agentId)
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
|
||||
@@ -1224,6 +1304,7 @@ async function handlePushNotificationGet(
|
||||
*/
|
||||
async function handlePushNotificationDelete(
|
||||
id: string | number,
|
||||
agentId: string,
|
||||
params: TaskIdParams
|
||||
): Promise<NextResponse> {
|
||||
if (!params?.id) {
|
||||
@@ -1233,7 +1314,7 @@ async function handlePushNotificationDelete(
|
||||
)
|
||||
}
|
||||
|
||||
const [task] = await db.select().from(a2aTask).where(eq(a2aTask.id, params.id)).limit(1)
|
||||
const task = await getTaskForAgent(params.id, agentId)
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json(createError(id, A2A_ERROR_CODES.TASK_NOT_FOUND, 'Task not found'), {
|
||||
|
||||
@@ -105,6 +105,7 @@ export function formatTaskResponse(task: Task, historyLength?: number): Task {
|
||||
export interface ExecuteRequestConfig {
|
||||
workflowId: string
|
||||
apiKey?: string | null
|
||||
userId?: string
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
@@ -124,7 +125,7 @@ export async function buildExecuteRequest(
|
||||
if (config.apiKey) {
|
||||
headers['X-API-Key'] = config.apiKey
|
||||
} else {
|
||||
const internalToken = await generateInternalToken()
|
||||
const internalToken = await generateInternalToken(config.userId)
|
||||
headers.Authorization = `Bearer ${internalToken}`
|
||||
useInternalAuth = true
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, user, workflow } from '@sim/db/schema'
|
||||
import { account, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
@@ -8,7 +8,7 @@ import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { evaluateScopeCoverage, type OAuthProvider, parseProvider } from '@/lib/oauth'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const { provider: providerParam, workflowId, credentialId } = parseResult.data
|
||||
|
||||
// Authenticate requester (supports session, API key, internal JWT)
|
||||
// Authenticate requester (supports session and internal JWT)
|
||||
const authResult = await checkSessionOrInternalAuth(request)
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthenticated credentials request rejected`)
|
||||
@@ -88,47 +88,24 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
const requesterUserId = authResult.userId
|
||||
|
||||
// Resolve effective user id: workflow owner if workflowId provided (with access check); else requester
|
||||
let effectiveUserId: string
|
||||
const effectiveUserId = requesterUserId
|
||||
if (workflowId) {
|
||||
// Load workflow owner and workspace for access control
|
||||
const rows = await db
|
||||
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!rows.length) {
|
||||
logger.warn(`[${requestId}] Workflow not found for credentials request`, { workflowId })
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: requesterUserId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!workflowAuthorization.allowed) {
|
||||
logger.warn(`[${requestId}] Forbidden credentials request for workflow`, {
|
||||
requesterUserId,
|
||||
workflowId,
|
||||
status: workflowAuthorization.status,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: workflowAuthorization.message || 'Forbidden' },
|
||||
{ status: workflowAuthorization.status }
|
||||
)
|
||||
}
|
||||
|
||||
const wf = rows[0]
|
||||
|
||||
if (requesterUserId !== wf.userId) {
|
||||
if (!wf.workspaceId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Forbidden - workflow has no workspace and requester is not owner`,
|
||||
{
|
||||
requesterUserId,
|
||||
}
|
||||
)
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const perm = await getUserEntityPermissions(requesterUserId, 'workspace', wf.workspaceId)
|
||||
if (perm === null) {
|
||||
logger.warn(`[${requestId}] Forbidden credentials request - no workspace access`, {
|
||||
requesterUserId,
|
||||
workspaceId: wf.workspaceId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
effectiveUserId = wf.userId
|
||||
} else {
|
||||
effectiveUserId = requesterUserId
|
||||
}
|
||||
|
||||
// Parse the provider to get base provider and feature type (if provider is present)
|
||||
@@ -136,18 +113,16 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
let accountsData
|
||||
|
||||
if (credentialId) {
|
||||
// Foreign-aware lookup for a specific credential by id
|
||||
// If workflowId is provided and requester has access (checked above), allow fetching by id only
|
||||
if (workflowId) {
|
||||
accountsData = await db.select().from(account).where(eq(account.id, credentialId))
|
||||
} else {
|
||||
// Fallback: constrain to requester's own credentials when not in a workflow context
|
||||
accountsData = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId)))
|
||||
}
|
||||
if (credentialId && workflowId) {
|
||||
// When both workflowId and credentialId are provided, fetch by ID only.
|
||||
// Workspace authorization above already proves access; the credential
|
||||
// may belong to another workspace member (e.g. for display name resolution).
|
||||
accountsData = await db.select().from(account).where(eq(account.id, credentialId))
|
||||
} else if (credentialId) {
|
||||
accountsData = await db
|
||||
.select()
|
||||
.from(account)
|
||||
.where(and(eq(account.userId, effectiveUserId), eq(account.id, credentialId)))
|
||||
} else {
|
||||
// Fetch all credentials for provider and effective user
|
||||
accountsData = await db
|
||||
|
||||
@@ -4,16 +4,9 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { createSession, loggerMock } from '@sim/testing'
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockSession = createSession({ userId: 'test-user-id' })
|
||||
const mockGetSession = vi.fn()
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: () => mockGetSession(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
@@ -37,7 +30,6 @@ import { db } from '@sim/db'
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
import {
|
||||
getCredential,
|
||||
getUserId,
|
||||
refreshAccessTokenIfNeeded,
|
||||
refreshTokenIfNeeded,
|
||||
} from '@/app/api/auth/oauth/utils'
|
||||
@@ -48,7 +40,6 @@ const mockRefreshOAuthToken = refreshOAuthToken as any
|
||||
describe('OAuth Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue(mockSession)
|
||||
mockDbTyped.limit.mockReturnValue([])
|
||||
})
|
||||
|
||||
@@ -56,42 +47,6 @@ describe('OAuth Utils', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('getUserId', () => {
|
||||
it('should get user ID from session when no workflowId is provided', async () => {
|
||||
const userId = await getUserId('request-id')
|
||||
|
||||
expect(userId).toBe('test-user-id')
|
||||
})
|
||||
|
||||
it('should get user ID from workflow when workflowId is provided', async () => {
|
||||
mockDbTyped.limit.mockReturnValueOnce([{ userId: 'workflow-owner-id' }])
|
||||
|
||||
const userId = await getUserId('request-id', 'workflow-id')
|
||||
|
||||
expect(mockDbTyped.select).toHaveBeenCalled()
|
||||
expect(mockDbTyped.from).toHaveBeenCalled()
|
||||
expect(mockDbTyped.where).toHaveBeenCalled()
|
||||
expect(mockDbTyped.limit).toHaveBeenCalledWith(1)
|
||||
expect(userId).toBe('workflow-owner-id')
|
||||
})
|
||||
|
||||
it('should return undefined if no session is found', async () => {
|
||||
mockGetSession.mockResolvedValueOnce(null)
|
||||
|
||||
const userId = await getUserId('request-id')
|
||||
|
||||
expect(userId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined if workflow is not found', async () => {
|
||||
mockDbTyped.limit.mockReturnValueOnce([])
|
||||
|
||||
const userId = await getUserId('request-id', 'nonexistent-workflow-id')
|
||||
|
||||
expect(userId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCredential', () => {
|
||||
it('should return credential when found', async () => {
|
||||
const mockCredential = { id: 'credential-id', userId: 'test-user-id' }
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, credentialSetMember, workflow } from '@sim/db/schema'
|
||||
import { account, credentialSetMember } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { refreshOAuthToken } from '@/lib/oauth'
|
||||
import {
|
||||
getMicrosoftRefreshTokenExpiry,
|
||||
@@ -49,41 +48,6 @@ export async function safeAccountInsert(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user ID based on either a session or a workflow ID
|
||||
*/
|
||||
export async function getUserId(
|
||||
requestId: string,
|
||||
workflowId?: string
|
||||
): Promise<string | undefined> {
|
||||
// If workflowId is provided, this is a server-side request
|
||||
if (workflowId) {
|
||||
// Get the workflow to verify the user ID
|
||||
const workflows = await db
|
||||
.select({ userId: workflow.userId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflows.length) {
|
||||
logger.warn(`[${requestId}] Workflow not found`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return workflows[0].userId
|
||||
}
|
||||
// This is a client-side request, use the session
|
||||
const session = await getSession()
|
||||
|
||||
// Check if the user is authenticated
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthenticated request rejected`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return session.user.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a credential by ID and verify it belongs to the user
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
@@ -32,6 +33,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'organizationId is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId)
|
||||
if (!hasPermission) {
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({ customer: subscriptionTable.stripeCustomerId })
|
||||
.from(subscriptionTable)
|
||||
|
||||
@@ -47,6 +47,10 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isProd: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Chat API Utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('process', {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
validateAuthToken,
|
||||
} from '@/lib/core/security/deployment'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { hasAdminPermission } from '@/lib/workspaces/permissions/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatAuthUtils')
|
||||
|
||||
@@ -24,29 +24,23 @@ export function setChatAuthCookie(
|
||||
|
||||
/**
|
||||
* Check if user has permission to create a chat for a specific workflow
|
||||
* Either the user owns the workflow directly OR has admin permission for the workflow's workspace
|
||||
*/
|
||||
export async function checkWorkflowAccessForChatCreation(
|
||||
workflowId: string,
|
||||
userId: string
|
||||
): Promise<{ hasAccess: boolean; workflow?: any }> {
|
||||
const workflowData = await db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1)
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'admin',
|
||||
})
|
||||
|
||||
if (workflowData.length === 0) {
|
||||
if (!authorization.workflow) {
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
const workflowRecord = workflowData[0]
|
||||
|
||||
if (workflowRecord.userId === userId) {
|
||||
return { hasAccess: true, workflow: workflowRecord }
|
||||
}
|
||||
|
||||
if (workflowRecord.workspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(userId, workflowRecord.workspaceId)
|
||||
if (hasAdmin) {
|
||||
return { hasAccess: true, workflow: workflowRecord }
|
||||
}
|
||||
if (authorization.allowed) {
|
||||
return { hasAccess: true, workflow: authorization.workflow }
|
||||
}
|
||||
|
||||
return { hasAccess: false }
|
||||
@@ -54,7 +48,6 @@ export async function checkWorkflowAccessForChatCreation(
|
||||
|
||||
/**
|
||||
* Check if user has access to view/edit/delete a specific chat
|
||||
* Either the user owns the chat directly OR has admin permission for the workflow's workspace
|
||||
*/
|
||||
export async function checkChatAccess(
|
||||
chatId: string,
|
||||
@@ -75,19 +68,17 @@ export async function checkChatAccess(
|
||||
}
|
||||
|
||||
const { chat: chatRecord, workflowWorkspaceId } = chatData[0]
|
||||
|
||||
if (chatRecord.userId === userId) {
|
||||
return { hasAccess: true, chat: chatRecord }
|
||||
if (!workflowWorkspaceId) {
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
if (workflowWorkspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(userId, workflowWorkspaceId)
|
||||
if (hasAdmin) {
|
||||
return { hasAccess: true, chat: chatRecord }
|
||||
}
|
||||
}
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: chatRecord.workflowId,
|
||||
userId,
|
||||
action: 'admin',
|
||||
})
|
||||
|
||||
return { hasAccess: false }
|
||||
return authorization.allowed ? { hasAccess: true, chat: chatRecord } : { hasAccess: false }
|
||||
}
|
||||
|
||||
export async function validateChatAuth(
|
||||
|
||||
@@ -25,6 +25,13 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
getEmailDomain: vi.fn(() => 'localhost:3000'),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
}),
|
||||
}))
|
||||
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockWhere.mockReturnValue({ then: mockThen })
|
||||
@@ -212,6 +219,12 @@ describe('Copilot Checkpoints Revert API Route', () => {
|
||||
.mockResolvedValueOnce(mockCheckpoint) // Checkpoint found
|
||||
.mockResolvedValueOnce(mockWorkflow) // Workflow found but different user
|
||||
|
||||
const { authorizeWorkflowByWorkspacePermission } = await import('@/lib/workflows/utils')
|
||||
vi.mocked(authorizeWorkflowByWorkspacePermission).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
checkpointId: 'checkpoint-123',
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request-helpers'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { isUuidV4 } from '@/executor/constants'
|
||||
|
||||
const logger = createLogger('CheckpointRevertAPI')
|
||||
@@ -58,7 +59,12 @@ export async function POST(request: NextRequest) {
|
||||
return createNotFoundResponse('Workflow not found')
|
||||
}
|
||||
|
||||
if (workflowData.userId !== userId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: checkpoint.workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { webhook as webhookTable, workflow as workflowTable } from '@sim/db/schema'
|
||||
import { account, webhook as webhookTable } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -8,6 +8,16 @@ import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
const logger = createLogger('TeamsSubscriptionRenewal')
|
||||
|
||||
async function getCredentialOwnerUserId(credentialId: string): Promise<string | null> {
|
||||
const [credentialRecord] = await db
|
||||
.select({ userId: account.userId })
|
||||
.from(account)
|
||||
.where(eq(account.id, credentialId))
|
||||
.limit(1)
|
||||
|
||||
return credentialRecord?.userId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron endpoint to renew Microsoft Teams chat subscriptions before they expire
|
||||
*
|
||||
@@ -27,14 +37,12 @@ export async function GET(request: NextRequest) {
|
||||
let totalFailed = 0
|
||||
let totalChecked = 0
|
||||
|
||||
// Get all active Microsoft Teams webhooks with their workflows
|
||||
// Get all active Microsoft Teams webhooks
|
||||
const webhooksWithWorkflows = await db
|
||||
.select({
|
||||
webhook: webhookTable,
|
||||
workflow: workflowTable,
|
||||
})
|
||||
.from(webhookTable)
|
||||
.innerJoin(workflowTable, eq(webhookTable.workflowId, workflowTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(webhookTable.isActive, true),
|
||||
@@ -52,7 +60,7 @@ export async function GET(request: NextRequest) {
|
||||
// Renewal threshold: 48 hours before expiration
|
||||
const renewalThreshold = new Date(Date.now() + 48 * 60 * 60 * 1000)
|
||||
|
||||
for (const { webhook, workflow } of webhooksWithWorkflows) {
|
||||
for (const { webhook } of webhooksWithWorkflows) {
|
||||
const config = (webhook.providerConfig as Record<string, any>) || {}
|
||||
|
||||
// Check if this is a Teams chat subscription that needs renewal
|
||||
@@ -80,10 +88,17 @@ export async function GET(request: NextRequest) {
|
||||
continue
|
||||
}
|
||||
|
||||
const credentialOwnerUserId = await getCredentialOwnerUserId(credentialId)
|
||||
if (!credentialOwnerUserId) {
|
||||
logger.error(`Credential owner not found for credential ${credentialId}`)
|
||||
totalFailed++
|
||||
continue
|
||||
}
|
||||
|
||||
// Get fresh access token
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
workflow.userId,
|
||||
credentialOwnerUserId,
|
||||
`renewal-${webhook.id}`
|
||||
)
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ function setupFileApiMocks(
|
||||
verifyCopilotFileAccess: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/uploads/contexts/workspace', () => ({
|
||||
uploadWorkspaceFile: vi.fn().mockResolvedValue({
|
||||
id: 'test-file-id',
|
||||
|
||||
@@ -206,6 +206,13 @@ export async function POST(request: NextRequest) {
|
||||
if (!workspaceId) {
|
||||
throw new InvalidRequestError('Workspace context requires workspaceId parameter')
|
||||
}
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
if (permission !== 'admin' && permission !== 'write') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required for workspace uploads' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { uploadWorkspaceFile } = await import('@/lib/uploads/contexts/workspace')
|
||||
|
||||
@@ -22,8 +22,8 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isProd: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
hasAdminPermission: vi.fn(),
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Form API Utils', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
validateAuthToken,
|
||||
} from '@/lib/core/security/deployment'
|
||||
import { decryptSecret } from '@/lib/core/security/encryption'
|
||||
import { hasAdminPermission } from '@/lib/workspaces/permissions/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('FormAuthUtils')
|
||||
|
||||
@@ -24,29 +24,23 @@ export function setFormAuthCookie(
|
||||
|
||||
/**
|
||||
* Check if user has permission to create a form for a specific workflow
|
||||
* Either the user owns the workflow directly OR has admin permission for the workflow's workspace
|
||||
*/
|
||||
export async function checkWorkflowAccessForFormCreation(
|
||||
workflowId: string,
|
||||
userId: string
|
||||
): Promise<{ hasAccess: boolean; workflow?: any }> {
|
||||
const workflowData = await db.select().from(workflow).where(eq(workflow.id, workflowId)).limit(1)
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'admin',
|
||||
})
|
||||
|
||||
if (workflowData.length === 0) {
|
||||
if (!authorization.workflow) {
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
const workflowRecord = workflowData[0]
|
||||
|
||||
if (workflowRecord.userId === userId) {
|
||||
return { hasAccess: true, workflow: workflowRecord }
|
||||
}
|
||||
|
||||
if (workflowRecord.workspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(userId, workflowRecord.workspaceId)
|
||||
if (hasAdmin) {
|
||||
return { hasAccess: true, workflow: workflowRecord }
|
||||
}
|
||||
if (authorization.allowed) {
|
||||
return { hasAccess: true, workflow: authorization.workflow }
|
||||
}
|
||||
|
||||
return { hasAccess: false }
|
||||
@@ -54,17 +48,13 @@ export async function checkWorkflowAccessForFormCreation(
|
||||
|
||||
/**
|
||||
* Check if user has access to view/edit/delete a specific form
|
||||
* Either the user owns the form directly OR has admin permission for the workflow's workspace
|
||||
*/
|
||||
export async function checkFormAccess(
|
||||
formId: string,
|
||||
userId: string
|
||||
): Promise<{ hasAccess: boolean; form?: any }> {
|
||||
const formData = await db
|
||||
.select({
|
||||
form: form,
|
||||
workflowWorkspaceId: workflow.workspaceId,
|
||||
})
|
||||
.select({ form: form, workflowWorkspaceId: workflow.workspaceId })
|
||||
.from(form)
|
||||
.innerJoin(workflow, eq(form.workflowId, workflow.id))
|
||||
.where(eq(form.id, formId))
|
||||
@@ -75,19 +65,17 @@ export async function checkFormAccess(
|
||||
}
|
||||
|
||||
const { form: formRecord, workflowWorkspaceId } = formData[0]
|
||||
|
||||
if (formRecord.userId === userId) {
|
||||
return { hasAccess: true, form: formRecord }
|
||||
if (!workflowWorkspaceId) {
|
||||
return { hasAccess: false }
|
||||
}
|
||||
|
||||
if (workflowWorkspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(userId, workflowWorkspaceId)
|
||||
if (hasAdmin) {
|
||||
return { hasAccess: true, form: formRecord }
|
||||
}
|
||||
}
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: formRecord.workflowId,
|
||||
userId,
|
||||
action: 'admin',
|
||||
})
|
||||
|
||||
return { hasAccess: false }
|
||||
return authorization.allowed ? { hasAccess: true, form: formRecord } : { hasAccess: false }
|
||||
}
|
||||
|
||||
export async function validateFormAuth(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { validateHallucination } from '@/lib/guardrails/validate_hallucination'
|
||||
import { validateJson } from '@/lib/guardrails/validate_json'
|
||||
@@ -13,6 +14,11 @@ export async function POST(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Guardrails validation request received`)
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const {
|
||||
validationType,
|
||||
@@ -109,6 +115,10 @@ export async function POST(request: NextRequest) {
|
||||
validationType,
|
||||
inputType: typeof input,
|
||||
})
|
||||
const authHeaders = {
|
||||
cookie: request.headers.get('cookie') || undefined,
|
||||
authorization: request.headers.get('authorization') || undefined,
|
||||
}
|
||||
|
||||
const validationResult = await executeValidation(
|
||||
validationType,
|
||||
@@ -134,6 +144,7 @@ export async function POST(request: NextRequest) {
|
||||
piiEntityTypes,
|
||||
piiMode,
|
||||
piiLanguage,
|
||||
authHeaders,
|
||||
requestId
|
||||
)
|
||||
|
||||
@@ -213,6 +224,7 @@ async function executeValidation(
|
||||
piiEntityTypes: string[] | undefined,
|
||||
piiMode: string | undefined,
|
||||
piiLanguage: string | undefined,
|
||||
authHeaders: { cookie?: string; authorization?: string } | undefined,
|
||||
requestId: string
|
||||
): Promise<{
|
||||
passed: boolean
|
||||
@@ -253,6 +265,7 @@ async function executeValidation(
|
||||
providerCredentials,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
authHeaders,
|
||||
requestId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
if (job.status === JOB_STATUS.PROCESSING || job.status === JOB_STATUS.PENDING) {
|
||||
response.estimatedDuration = 180000
|
||||
response.estimatedDuration = 300000
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { batchChunkOperation, createChunk, queryChunks } from '@/lib/knowledge/chunks/service'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils'
|
||||
import { calculateCost } from '@/providers/utils'
|
||||
|
||||
@@ -38,13 +38,14 @@ export async function GET(
|
||||
const { id: knowledgeBaseId, documentId } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized chunks access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, session.user.id)
|
||||
const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId)
|
||||
|
||||
if (!accessCheck.hasAccess) {
|
||||
if (accessCheck.notFound) {
|
||||
@@ -54,7 +55,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
|
||||
}
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted unauthorized chunks access: ${accessCheck.reason}`
|
||||
`[${requestId}] User ${userId} attempted unauthorized chunks access: ${accessCheck.reason}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
@@ -113,13 +114,25 @@ export async function POST(
|
||||
const body = await req.json()
|
||||
const { workflowId, ...searchParams } = body
|
||||
|
||||
const userId = await getUserId(requestId, workflowId)
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
if (!userId) {
|
||||
const errorMessage = workflowId ? 'Workflow not found' : 'Unauthorized'
|
||||
const statusCode = workflowId ? 404 : 401
|
||||
logger.warn(`[${requestId}] Authentication failed: ${errorMessage}`)
|
||||
return NextResponse.json({ error: errorMessage }, { status: statusCode })
|
||||
if (workflowId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId)
|
||||
@@ -248,13 +261,14 @@ export async function PATCH(
|
||||
const { id: knowledgeBaseId, documentId } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized batch chunk operation attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, session.user.id)
|
||||
const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId)
|
||||
|
||||
if (!accessCheck.hasAccess) {
|
||||
if (accessCheck.notFound) {
|
||||
@@ -264,7 +278,7 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
|
||||
}
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted unauthorized batch chunk operation: ${accessCheck.reason}`
|
||||
`[${requestId}] User ${userId} attempted unauthorized batch chunk operation: ${accessCheck.reason}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
deleteDocument,
|
||||
@@ -54,13 +54,14 @@ export async function GET(
|
||||
const { id: knowledgeBaseId, documentId } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized document access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, session.user.id)
|
||||
const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, userId)
|
||||
|
||||
if (!accessCheck.hasAccess) {
|
||||
if (accessCheck.notFound) {
|
||||
@@ -70,7 +71,7 @@ export async function GET(
|
||||
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
|
||||
}
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted unauthorized document access: ${accessCheck.reason}`
|
||||
`[${requestId}] User ${userId} attempted unauthorized document access: ${accessCheck.reason}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
@@ -97,13 +98,14 @@ export async function PUT(
|
||||
const { id: knowledgeBaseId, documentId } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized document update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, session.user.id)
|
||||
const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId)
|
||||
|
||||
if (!accessCheck.hasAccess) {
|
||||
if (accessCheck.notFound) {
|
||||
@@ -113,7 +115,7 @@ export async function PUT(
|
||||
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
|
||||
}
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted unauthorized document update: ${accessCheck.reason}`
|
||||
`[${requestId}] User ${userId} attempted unauthorized document update: ${accessCheck.reason}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
@@ -227,13 +229,14 @@ export async function DELETE(
|
||||
const { id: knowledgeBaseId, documentId } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized document delete attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, session.user.id)
|
||||
const accessCheck = await checkDocumentWriteAccess(knowledgeBaseId, documentId, userId)
|
||||
|
||||
if (!accessCheck.hasAccess) {
|
||||
if (accessCheck.notFound) {
|
||||
@@ -243,7 +246,7 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: accessCheck.reason }, { status: 404 })
|
||||
}
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted unauthorized document deletion: ${accessCheck.reason}`
|
||||
`[${requestId}] User ${userId} attempted unauthorized document deletion: ${accessCheck.reason}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import {
|
||||
bulkDocumentOperation,
|
||||
bulkDocumentOperationByFilter,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
processDocumentsWithQueue,
|
||||
} from '@/lib/knowledge/documents/service'
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
|
||||
const logger = createLogger('DocumentsAPI')
|
||||
@@ -170,16 +171,28 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
bodyKeys: Object.keys(body),
|
||||
})
|
||||
|
||||
const userId = await getUserId(requestId, workflowId)
|
||||
|
||||
if (!userId) {
|
||||
const errorMessage = workflowId ? 'Workflow not found' : 'Unauthorized'
|
||||
const statusCode = workflowId ? 404 : 401
|
||||
logger.warn(`[${requestId}] Authentication failed: ${errorMessage}`, {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Authentication failed: ${auth.error || 'Unauthorized'}`, {
|
||||
workflowId,
|
||||
hasWorkflowId: !!workflowId,
|
||||
})
|
||||
return NextResponse.json({ error: errorMessage }, { status: statusCode })
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
if (workflowId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, userId)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import {
|
||||
@@ -54,13 +54,14 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized knowledge base access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseAccess(id, session.user.id)
|
||||
const accessCheck = await checkKnowledgeBaseAccess(id, userId)
|
||||
|
||||
if (!accessCheck.hasAccess) {
|
||||
if ('notFound' in accessCheck && accessCheck.notFound) {
|
||||
@@ -68,7 +69,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
|
||||
}
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted to access unauthorized knowledge base ${id}`
|
||||
`[${requestId}] User ${userId} attempted to access unauthorized knowledge base ${id}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
@@ -79,7 +80,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Retrieved knowledge base: ${id} for user ${session.user.id}`)
|
||||
logger.info(`[${requestId}] Retrieved knowledge base: ${id} for user ${userId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -96,13 +97,14 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized knowledge base update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseWriteAccess(id, session.user.id)
|
||||
const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId)
|
||||
|
||||
if (!accessCheck.hasAccess) {
|
||||
if ('notFound' in accessCheck && accessCheck.notFound) {
|
||||
@@ -110,7 +112,7 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
|
||||
}
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted to update unauthorized knowledge base ${id}`
|
||||
`[${requestId}] User ${userId} attempted to update unauthorized knowledge base ${id}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
@@ -131,7 +133,7 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
requestId
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${session.user.id}`)
|
||||
logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -163,13 +165,14 @@ export async function DELETE(
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(_request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized knowledge base delete attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const accessCheck = await checkKnowledgeBaseWriteAccess(id, session.user.id)
|
||||
const accessCheck = await checkKnowledgeBaseWriteAccess(id, userId)
|
||||
|
||||
if (!accessCheck.hasAccess) {
|
||||
if ('notFound' in accessCheck && accessCheck.notFound) {
|
||||
@@ -177,7 +180,7 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
|
||||
}
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted to delete unauthorized knowledge base ${id}`
|
||||
`[${requestId}] User ${userId} attempted to delete unauthorized knowledge base ${id}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
@@ -192,7 +195,7 @@ export async function DELETE(
|
||||
// Telemetry should not fail the operation
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${session.user.id}`)
|
||||
logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -17,7 +17,7 @@ mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue({ role: 'owner' }),
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
|
||||
}))
|
||||
|
||||
describe('Knowledge Base API Route', () => {
|
||||
|
||||
@@ -104,6 +104,8 @@ describe('Knowledge Search API Route', () => {
|
||||
|
||||
const mockGetUserId = vi.fn()
|
||||
const mockFetch = vi.fn()
|
||||
const mockCheckSessionOrInternalAuth = vi.fn()
|
||||
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
|
||||
|
||||
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||
const mockSearchResults = [
|
||||
@@ -132,8 +134,12 @@ describe('Knowledge Search API Route', () => {
|
||||
db: mockDbChain,
|
||||
}))
|
||||
|
||||
vi.doMock('@/app/api/auth/oauth/utils', () => ({
|
||||
getUserId: mockGetUserId,
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
||||
}))
|
||||
|
||||
Object.values(mockDbChain).forEach((fn) => {
|
||||
@@ -157,6 +163,15 @@ describe('Knowledge Search API Route', () => {
|
||||
doc2: 'Document 2',
|
||||
})
|
||||
mockGetDocumentTagDefinitions.mockClear()
|
||||
mockCheckSessionOrInternalAuth.mockClear().mockResolvedValue({
|
||||
success: true,
|
||||
userId: 'user-123',
|
||||
authType: 'session',
|
||||
})
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockClear().mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
})
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
|
||||
@@ -311,11 +326,18 @@ describe('Knowledge Search API Route', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(mockGetUserId).toHaveBeenCalledWith(expect.any(String), 'workflow-123')
|
||||
expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
|
||||
workflowId: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
action: 'read',
|
||||
})
|
||||
})
|
||||
|
||||
it.concurrent('should return unauthorized for unauthenticated request', async () => {
|
||||
mockGetUserId.mockResolvedValue(null)
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', validSearchData)
|
||||
const { POST } = await import('@/app/api/knowledge/search/route')
|
||||
@@ -332,7 +354,11 @@ describe('Knowledge Search API Route', () => {
|
||||
workflowId: 'nonexistent-workflow',
|
||||
}
|
||||
|
||||
mockGetUserId.mockResolvedValue(null)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 404,
|
||||
message: 'Workflow not found',
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', workflowData)
|
||||
const { POST } = await import('@/app/api/knowledge/search/route')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
@@ -8,7 +9,7 @@ import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils'
|
||||
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||
import { estimateTokenCount } from '@/lib/tokenization/estimators'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import {
|
||||
generateSearchEmbedding,
|
||||
getDocumentNamesByIds,
|
||||
@@ -76,12 +77,24 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const { workflowId, ...searchParams } = body
|
||||
|
||||
const userId = await getUserId(requestId, workflowId)
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
if (!userId) {
|
||||
const errorMessage = workflowId ? 'Workflow not found' : 'Unauthorized'
|
||||
const statusCode = workflowId ? 404 : 401
|
||||
return NextResponse.json({ error: errorMessage }, { status: statusCode })
|
||||
if (workflowId) {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/definitions'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { resolveWorkflowIdForUser } from '@/lib/workflows/utils'
|
||||
import {
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
resolveWorkflowIdForUser,
|
||||
} from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('CopilotMcpAPI')
|
||||
const mcpRateLimiter = new RateLimiter()
|
||||
@@ -626,7 +629,16 @@ async function handleBuildToolCall(
|
||||
const requestText = (args.request as string) || JSON.stringify(args)
|
||||
const workflowId = args.workflowId as string | undefined
|
||||
|
||||
const resolved = workflowId ? { workflowId } : await resolveWorkflowIdForUser(userId)
|
||||
const resolved = workflowId
|
||||
? await (async () => {
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
return authorization.allowed ? { workflowId } : null
|
||||
})()
|
||||
: await resolveWorkflowIdForUser(userId)
|
||||
|
||||
if (!resolved?.workflowId) {
|
||||
return {
|
||||
|
||||
227
apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
Normal file
227
apps/sim/app/api/mcp/serve/[serverId]/route.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Tests for MCP serve route auth propagation.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockCheckHybridAuth = vi.fn()
|
||||
const mockGetUserEntityPermissions = vi.fn()
|
||||
const mockGenerateInternalToken = vi.fn()
|
||||
const mockDbSelect = vi.fn()
|
||||
const mockDbFrom = vi.fn()
|
||||
const mockDbWhere = vi.fn()
|
||||
const mockDbLimit = vi.fn()
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
describe('MCP Serve Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockDbSelect.mockReturnValue({ from: mockDbFrom })
|
||||
mockDbFrom.mockReturnValue({ where: mockDbWhere })
|
||||
mockDbWhere.mockReturnValue({ limit: mockDbLimit })
|
||||
|
||||
vi.doMock('@sim/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
and: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
}))
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockDbSelect,
|
||||
},
|
||||
}))
|
||||
vi.doMock('@sim/db/schema', () => ({
|
||||
workflowMcpServer: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
workspaceId: 'workspaceId',
|
||||
isPublic: 'isPublic',
|
||||
createdBy: 'createdBy',
|
||||
},
|
||||
workflowMcpTool: {
|
||||
serverId: 'serverId',
|
||||
toolName: 'toolName',
|
||||
toolDescription: 'toolDescription',
|
||||
parameterSchema: 'parameterSchema',
|
||||
workflowId: 'workflowId',
|
||||
},
|
||||
workflow: {
|
||||
id: 'id',
|
||||
isDeployed: 'isDeployed',
|
||||
},
|
||||
}))
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: mockCheckHybridAuth,
|
||||
}))
|
||||
vi.doMock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: mockGetUserEntityPermissions,
|
||||
}))
|
||||
vi.doMock('@/lib/auth/internal', () => ({
|
||||
generateInternalToken: mockGenerateInternalToken,
|
||||
}))
|
||||
vi.doMock('@/lib/core/utils/urls', () => ({
|
||||
getBaseUrl: () => 'http://localhost:3000',
|
||||
}))
|
||||
vi.doMock('@/lib/core/execution-limits', () => ({
|
||||
getMaxExecutionTimeout: () => 10_000,
|
||||
}))
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 401 for private server when auth fails', async () => {
|
||||
mockDbLimit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'server-1',
|
||||
name: 'Private Server',
|
||||
workspaceId: 'ws-1',
|
||||
isPublic: false,
|
||||
createdBy: 'owner-1',
|
||||
},
|
||||
])
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({ success: false, error: 'Unauthorized' })
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }),
|
||||
})
|
||||
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 401 on GET for private server when auth fails', async () => {
|
||||
mockDbLimit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'server-1',
|
||||
name: 'Private Server',
|
||||
workspaceId: 'ws-1',
|
||||
isPublic: false,
|
||||
createdBy: 'owner-1',
|
||||
},
|
||||
])
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({ success: false, error: 'Unauthorized' })
|
||||
|
||||
const { GET } = await import('./route')
|
||||
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1')
|
||||
const response = await GET(req, { params: Promise.resolve({ serverId: 'server-1' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('forwards X-API-Key for private server api_key auth', async () => {
|
||||
mockDbLimit
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'server-1',
|
||||
name: 'Private Server',
|
||||
workspaceId: 'ws-1',
|
||||
isPublic: false,
|
||||
createdBy: 'owner-1',
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
|
||||
.mockResolvedValueOnce([{ isDeployed: true }])
|
||||
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'api_key',
|
||||
apiKeyType: 'personal',
|
||||
})
|
||||
mockGetUserEntityPermissions.mockResolvedValueOnce('write')
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ output: { ok: true } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
|
||||
method: 'POST',
|
||||
headers: { 'X-API-Key': 'pk_test_123' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'tool_a', arguments: { q: 'test' } },
|
||||
}),
|
||||
})
|
||||
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit
|
||||
const headers = fetchOptions.headers as Record<string, string>
|
||||
expect(headers['X-API-Key']).toBe('pk_test_123')
|
||||
expect(headers.Authorization).toBeUndefined()
|
||||
expect(mockGenerateInternalToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards internal token for private server session auth', async () => {
|
||||
mockDbLimit
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'server-1',
|
||||
name: 'Private Server',
|
||||
workspaceId: 'ws-1',
|
||||
isPublic: false,
|
||||
createdBy: 'owner-1',
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }])
|
||||
.mockResolvedValueOnce([{ isDeployed: true }])
|
||||
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'session',
|
||||
})
|
||||
mockGetUserEntityPermissions.mockResolvedValueOnce('read')
|
||||
mockGenerateInternalToken.mockResolvedValueOnce('internal-token-user-1')
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ output: { ok: true } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
)
|
||||
|
||||
const { POST } = await import('./route')
|
||||
const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'tool_a' },
|
||||
}),
|
||||
})
|
||||
const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const fetchOptions = fetchMock.mock.calls[0][1] as RequestInit
|
||||
const headers = fetchOptions.headers as Record<string, string>
|
||||
expect(headers.Authorization).toBe('Bearer internal-token-user-1')
|
||||
expect(headers['X-API-Key']).toBeUndefined()
|
||||
expect(mockGenerateInternalToken).toHaveBeenCalledWith('user-1')
|
||||
})
|
||||
})
|
||||
@@ -19,10 +19,11 @@ import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { generateInternalToken } from '@/lib/auth/internal'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMcpServeAPI')
|
||||
|
||||
@@ -32,6 +33,12 @@ interface RouteParams {
|
||||
serverId: string
|
||||
}
|
||||
|
||||
interface ExecuteAuthContext {
|
||||
authType?: AuthResult['authType']
|
||||
userId: string
|
||||
apiKey?: string | null
|
||||
}
|
||||
|
||||
function createResponse(id: RequestId, result: unknown): JSONRPCResponse {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
@@ -73,6 +80,22 @@ export async function GET(request: NextRequest, { params }: { params: Promise<Ro
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!server.isPublic) {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
auth.userId,
|
||||
'workspace',
|
||||
server.workspaceId
|
||||
)
|
||||
if (workspacePermission === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
name: server.name,
|
||||
version: '1.0.0',
|
||||
@@ -94,11 +117,27 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
return NextResponse.json({ error: 'Server not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let executeAuthContext: ExecuteAuthContext | null = null
|
||||
if (!server.isPublic) {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
auth.userId,
|
||||
'workspace',
|
||||
server.workspaceId
|
||||
)
|
||||
if (workspacePermission === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
executeAuthContext = {
|
||||
authType: auth.authType,
|
||||
userId: auth.userId,
|
||||
apiKey: auth.authType === 'api_key' ? request.headers.get('X-API-Key') : null,
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
@@ -119,9 +158,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
}
|
||||
|
||||
const { id, method, params: rpcParams } = message
|
||||
const apiKey =
|
||||
request.headers.get('X-API-Key') ||
|
||||
request.headers.get('Authorization')?.replace('Bearer ', '')
|
||||
|
||||
switch (method) {
|
||||
case 'initialize': {
|
||||
@@ -144,7 +180,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<R
|
||||
id,
|
||||
serverId,
|
||||
rpcParams as { name: string; arguments?: Record<string, unknown> },
|
||||
apiKey,
|
||||
executeAuthContext,
|
||||
server.isPublic ? server.createdBy : undefined
|
||||
)
|
||||
|
||||
@@ -207,7 +243,7 @@ async function handleToolsCall(
|
||||
id: RequestId,
|
||||
serverId: string,
|
||||
params: { name: string; arguments?: Record<string, unknown> } | undefined,
|
||||
apiKey?: string | null,
|
||||
executeAuthContext?: ExecuteAuthContext | null,
|
||||
publicServerOwnerId?: string
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
@@ -255,8 +291,13 @@ async function handleToolsCall(
|
||||
if (publicServerOwnerId) {
|
||||
const internalToken = await generateInternalToken(publicServerOwnerId)
|
||||
headers.Authorization = `Bearer ${internalToken}`
|
||||
} else if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey
|
||||
} else if (executeAuthContext) {
|
||||
if (executeAuthContext.authType === 'api_key' && executeAuthContext.apiKey) {
|
||||
headers['X-API-Key'] = executeAuthContext.apiKey
|
||||
} else {
|
||||
const internalToken = await generateInternalToken(executeAuthContext.userId)
|
||||
headers.Authorization = `Bearer ${internalToken}`
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Executing workflow ${tool.workflowId} via MCP tool ${params.name}`)
|
||||
@@ -311,6 +352,17 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!server.isPublic) {
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
auth.userId,
|
||||
'workspace',
|
||||
server.workspaceId
|
||||
)
|
||||
if (workspacePermission === null) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`MCP session terminated for server ${serverId}`)
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NextRequest } from 'next/server'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
|
||||
|
||||
@@ -170,6 +171,11 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
||||
workflowRecord.description ||
|
||||
`Execute ${workflowRecord.name} workflow`
|
||||
|
||||
const parameterSchema =
|
||||
body.parameterSchema && Object.keys(body.parameterSchema).length > 0
|
||||
? body.parameterSchema
|
||||
: await generateParameterSchemaForWorkflow(body.workflowId)
|
||||
|
||||
const toolId = crypto.randomUUID()
|
||||
const [tool] = await db
|
||||
.insert(workflowMcpTool)
|
||||
@@ -179,7 +185,7 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
||||
workflowId: body.workflowId,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema: body.parameterSchema || {},
|
||||
parameterSchema,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NextRequest } from 'next/server'
|
||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||
import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||
import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema'
|
||||
import { hasValidStartBlock } from '@/lib/workflows/triggers/trigger-utils.server'
|
||||
|
||||
@@ -156,6 +157,8 @@ export const POST = withMcpAuth('write')(
|
||||
const toolDescription =
|
||||
workflowRecord.description || `Execute ${workflowRecord.name} workflow`
|
||||
|
||||
const parameterSchema = await generateParameterSchemaForWorkflow(workflowRecord.id)
|
||||
|
||||
const toolId = crypto.randomUUID()
|
||||
await db.insert(workflowMcpTool).values({
|
||||
id: toolId,
|
||||
@@ -163,7 +166,7 @@ export const POST = withMcpAuth('write')(
|
||||
workflowId: workflowRecord.id,
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterSchema: {},
|
||||
parameterSchema,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
||||
@@ -446,15 +446,46 @@ export async function PUT(
|
||||
})
|
||||
.where(eq(workspaceInvitation.id, wsInvitation.id))
|
||||
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permissionType: wsInvitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
const existingPermission = await tx
|
||||
.select({ id: permissions.id, permissionType: permissions.permissionType })
|
||||
.from(permissions)
|
||||
.where(
|
||||
and(
|
||||
eq(permissions.entityId, wsInvitation.workspaceId),
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (existingPermission) {
|
||||
const PERMISSION_RANK = { read: 0, write: 1, admin: 2 } as const
|
||||
type PermissionLevel = keyof typeof PERMISSION_RANK
|
||||
const existingRank =
|
||||
PERMISSION_RANK[existingPermission.permissionType as PermissionLevel] ?? 0
|
||||
const newPermission = (wsInvitation.permissions || 'read') as PermissionLevel
|
||||
const newRank = PERMISSION_RANK[newPermission] ?? 0
|
||||
|
||||
if (newRank > existingRank) {
|
||||
await tx
|
||||
.update(permissions)
|
||||
.set({
|
||||
permissionType: newPermission,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(permissions.id, existingPermission.id))
|
||||
}
|
||||
} else {
|
||||
await tx.insert(permissions).values({
|
||||
id: randomUUID(),
|
||||
entityType: 'workspace',
|
||||
entityId: wsInvitation.workspaceId,
|
||||
userId: session.user.id,
|
||||
permissionType: wsInvitation.permissions || 'read',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (status === 'cancelled') {
|
||||
await tx
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { preprocessExecution } from '@/lib/execution/preprocessing'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
|
||||
const logger = createLogger('WorkflowResumeAPI')
|
||||
@@ -37,7 +38,26 @@ export async function POST(
|
||||
}
|
||||
|
||||
const resumeInput = payload?.input ?? payload ?? {}
|
||||
const userId = workflow.userId ?? ''
|
||||
const isPersonalApiKeyCaller =
|
||||
access.auth?.authType === 'api_key' && access.auth?.apiKeyType === 'personal'
|
||||
|
||||
let userId: string
|
||||
if (isPersonalApiKeyCaller && access.auth?.userId) {
|
||||
userId = access.auth.userId
|
||||
} else {
|
||||
const billedAccountUserId = await getWorkspaceBilledAccountUserId(workflow.workspaceId)
|
||||
if (!billedAccountUserId) {
|
||||
logger.error('Unable to resolve workspace billed account for resume execution', {
|
||||
workflowId,
|
||||
workspaceId: workflow.workspaceId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Unable to resolve billing account for this workspace' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
userId = billedAccountUserId
|
||||
}
|
||||
|
||||
const resumeExecutionId = randomUUID()
|
||||
const requestId = generateRequestId()
|
||||
@@ -58,8 +78,8 @@ export async function POST(
|
||||
checkRateLimit: false, // Manual triggers bypass rate limits
|
||||
checkDeployment: false, // Resuming existing execution, deployment already checked
|
||||
skipUsageLimits: true, // Resume is continuation of authorized execution - don't recheck limits
|
||||
useAuthenticatedUserAsActor: isPersonalApiKeyCaller,
|
||||
workspaceId: workflow.workspaceId || undefined,
|
||||
isResumeContext: true, // Enable billing fallback for paused workflow resumes
|
||||
})
|
||||
|
||||
if (!preprocessResult.success) {
|
||||
|
||||
@@ -7,21 +7,20 @@ import { loggerMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetSession, mockGetUserEntityPermissions, mockDbSelect, mockDbUpdate } = vi.hoisted(
|
||||
() => ({
|
||||
const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission, mockDbSelect, mockDbUpdate } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockGetUserEntityPermissions: vi.fn(),
|
||||
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
|
||||
mockDbSelect: vi.fn(),
|
||||
mockDbUpdate: vi.fn(),
|
||||
})
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: mockGetUserEntityPermissions,
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
@@ -81,7 +80,12 @@ describe('Schedule PUT API (Reactivate)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -140,6 +144,13 @@ describe('Schedule PUT API (Reactivate)', () => {
|
||||
})
|
||||
|
||||
it('returns 404 when workflow does not exist for schedule', async () => {
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 404,
|
||||
workflow: null,
|
||||
workspacePermission: null,
|
||||
message: 'Workflow not found',
|
||||
})
|
||||
mockDbChain([[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }], []])
|
||||
|
||||
const res = await PUT(createRequest({ action: 'reactivate' }), createParams('sched-1'))
|
||||
@@ -152,6 +163,14 @@ describe('Schedule PUT API (Reactivate)', () => {
|
||||
|
||||
describe('Authorization', () => {
|
||||
it('returns 403 when user is not workflow owner', async () => {
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
workflow: { id: 'wf-1', workspaceId: null },
|
||||
workspacePermission: null,
|
||||
message:
|
||||
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.',
|
||||
})
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }],
|
||||
[{ userId: 'other-user', workspaceId: null }],
|
||||
@@ -161,11 +180,17 @@ describe('Schedule PUT API (Reactivate)', () => {
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
const data = await res.json()
|
||||
expect(data.error).toBe('Not authorized to modify this schedule')
|
||||
expect(data.error).toContain('Personal workflows are deprecated')
|
||||
})
|
||||
|
||||
it('returns 403 for workspace member with only read permission', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: 'read',
|
||||
message: 'Unauthorized: Access denied to write this workflow',
|
||||
})
|
||||
mockDbChain([
|
||||
[{ id: 'sched-1', workflowId: 'wf-1', status: 'disabled' }],
|
||||
[{ userId: 'other-user', workspaceId: 'ws-1' }],
|
||||
@@ -198,7 +223,6 @@ describe('Schedule PUT API (Reactivate)', () => {
|
||||
})
|
||||
|
||||
it('allows workspace member with write permission to reactivate', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('write')
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
@@ -218,7 +242,6 @@ describe('Schedule PUT API (Reactivate)', () => {
|
||||
})
|
||||
|
||||
it('allows workspace admin to reactivate', async () => {
|
||||
mockGetUserEntityPermissions.mockResolvedValue('admin')
|
||||
mockDbChain([
|
||||
[
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowSchedule } from '@sim/db/schema'
|
||||
import { workflowSchedule } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -7,7 +7,7 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('ScheduleAPI')
|
||||
|
||||
@@ -57,31 +57,23 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const [workflowRecord] = await db
|
||||
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, schedule.workflowId))
|
||||
.limit(1)
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: schedule.workflowId,
|
||||
userId: session.user.id,
|
||||
action: 'write',
|
||||
})
|
||||
|
||||
if (!workflowRecord) {
|
||||
if (!authorization.workflow) {
|
||||
logger.warn(`[${requestId}] Workflow not found for schedule: ${scheduleId}`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let isAuthorized = workflowRecord.userId === session.user.id
|
||||
|
||||
if (!isAuthorized && workflowRecord.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workflowRecord.workspaceId
|
||||
)
|
||||
isAuthorized = userPermission === 'write' || userPermission === 'admin'
|
||||
}
|
||||
|
||||
if (!isAuthorized) {
|
||||
if (!authorization.allowed) {
|
||||
logger.warn(`[${requestId}] User not authorized to modify this schedule: ${scheduleId}`)
|
||||
return NextResponse.json({ error: 'Not authorized to modify this schedule' }, { status: 403 })
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Not authorized to modify this schedule' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
}
|
||||
|
||||
if (schedule.status === 'active') {
|
||||
|
||||
@@ -7,18 +7,20 @@ import { loggerMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetSession, mockGetUserEntityPermissions, mockDbSelect } = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockGetUserEntityPermissions: vi.fn(),
|
||||
mockDbSelect: vi.fn(),
|
||||
}))
|
||||
const { mockGetSession, mockAuthorizeWorkflowByWorkspacePermission, mockDbSelect } = vi.hoisted(
|
||||
() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
|
||||
mockDbSelect: vi.fn(),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: mockGetUserEntityPermissions,
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
@@ -80,7 +82,12 @@ describe('Schedule GET API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
|
||||
mockGetUserEntityPermissions.mockResolvedValue('read')
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: 'read',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -89,7 +96,6 @@ describe('Schedule GET API', () => {
|
||||
|
||||
it('returns schedule data for authorized user', async () => {
|
||||
mockDbChain([
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
[
|
||||
{
|
||||
schedule: {
|
||||
@@ -111,7 +117,7 @@ describe('Schedule GET API', () => {
|
||||
})
|
||||
|
||||
it('returns null when no schedule exists', async () => {
|
||||
mockDbChain([[{ userId: 'user-1', workspaceId: null }], []])
|
||||
mockDbChain([[]])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
const data = await res.json()
|
||||
@@ -135,6 +141,13 @@ describe('Schedule GET API', () => {
|
||||
})
|
||||
|
||||
it('returns 404 for non-existent workflow', async () => {
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 404,
|
||||
message: 'Workflow not found',
|
||||
workflow: null,
|
||||
workspacePermission: null,
|
||||
})
|
||||
mockDbChain([[]])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
@@ -143,6 +156,13 @@ describe('Schedule GET API', () => {
|
||||
})
|
||||
|
||||
it('denies access for unauthorized user', async () => {
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to read this workflow',
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: null,
|
||||
})
|
||||
mockDbChain([[{ userId: 'other-user', workspaceId: null }]])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
@@ -151,10 +171,7 @@ describe('Schedule GET API', () => {
|
||||
})
|
||||
|
||||
it('allows workspace members to view', async () => {
|
||||
mockDbChain([
|
||||
[{ userId: 'other-user', workspaceId: 'ws-1' }],
|
||||
[{ schedule: { id: 'sched-1', status: 'active', failedCount: 0 } }],
|
||||
])
|
||||
mockDbChain([[{ schedule: { id: 'sched-1', status: 'active', failedCount: 0 } }]])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
|
||||
@@ -162,10 +179,7 @@ describe('Schedule GET API', () => {
|
||||
})
|
||||
|
||||
it('indicates disabled schedule with failures', async () => {
|
||||
mockDbChain([
|
||||
[{ userId: 'user-1', workspaceId: null }],
|
||||
[{ schedule: { id: 'sched-1', status: 'disabled', failedCount: 100 } }],
|
||||
])
|
||||
mockDbChain([[{ schedule: { id: 'sched-1', status: 'disabled', failedCount: 100 } }]])
|
||||
|
||||
const res = await GET(createRequest('http://test/api/schedules?workflowId=wf-1'))
|
||||
const data = await res.json()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema'
|
||||
import { workflowDeploymentVersion, workflowSchedule } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('ScheduledAPI')
|
||||
|
||||
@@ -29,29 +29,21 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing workflowId parameter' }, { status: 400 })
|
||||
}
|
||||
|
||||
const [workflowRecord] = await db
|
||||
.select({ userId: workflow.userId, workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: session.user.id,
|
||||
action: 'read',
|
||||
})
|
||||
|
||||
if (!workflowRecord) {
|
||||
if (!authorization.workflow) {
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let isAuthorized = workflowRecord.userId === session.user.id
|
||||
|
||||
if (!isAuthorized && workflowRecord.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workflowRecord.workspaceId
|
||||
if (!authorization.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Not authorized to view this workflow' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
isAuthorized = userPermission !== null
|
||||
}
|
||||
|
||||
if (!isAuthorized) {
|
||||
return NextResponse.json({ error: 'Not authorized to view this workflow' }, { status: 403 })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Getting schedule for workflow ${workflowId}`)
|
||||
|
||||
@@ -214,6 +214,14 @@ describe('Custom Tools API Routes', () => {
|
||||
vi.doMock('@/lib/workflows/custom-tools/operations', () => ({
|
||||
upsertCustomTools: vi.fn().mockResolvedValue(sampleTools),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { workspaceId: 'workspace-123' },
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -272,20 +280,6 @@ describe('Custom Tools API Routes', () => {
|
||||
it('should handle workflowId parameter', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/api/tools/custom?workflowId=workflow-123')
|
||||
|
||||
mockLimit.mockResolvedValueOnce([{ workspaceId: 'workspace-123' }])
|
||||
|
||||
mockWhere.mockImplementationOnce((condition) => {
|
||||
const queryBuilder = {
|
||||
limit: mockLimit,
|
||||
then: (resolve: (value: typeof sampleTools) => void) => {
|
||||
resolve(sampleTools)
|
||||
return queryBuilder
|
||||
},
|
||||
catch: (reject: (error: Error) => void) => queryBuilder,
|
||||
}
|
||||
return queryBuilder
|
||||
})
|
||||
|
||||
const { GET } = await import('@/app/api/tools/custom/route')
|
||||
|
||||
const response = await GET(req)
|
||||
@@ -375,7 +369,8 @@ describe('Custom Tools API Routes', () => {
|
||||
})
|
||||
|
||||
it('should handle tool not found', async () => {
|
||||
mockLimit.mockResolvedValueOnce([])
|
||||
const mockLimitNotFound = vi.fn().mockResolvedValue([])
|
||||
mockWhere.mockReturnValueOnce({ limit: mockLimitNotFound })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/tools/custom?id=non-existent')
|
||||
|
||||
@@ -398,7 +393,8 @@ describe('Custom Tools API Routes', () => {
|
||||
}))
|
||||
|
||||
const userScopedTool = { ...sampleTools[0], workspaceId: null, userId: 'user-123' }
|
||||
mockLimit.mockResolvedValueOnce([userScopedTool])
|
||||
const mockLimitUserScoped = vi.fn().mockResolvedValue([userScopedTool])
|
||||
mockWhere.mockReturnValueOnce({ limit: mockLimitUserScoped })
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/tools/custom?id=tool-1')
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { customTools, workflow } from '@sim/db/schema'
|
||||
import { customTools } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('CustomToolsAPI')
|
||||
@@ -52,27 +53,32 @@ export async function GET(request: NextRequest) {
|
||||
const userId = authResult.userId
|
||||
|
||||
let resolvedWorkspaceId: string | null = workspaceId
|
||||
let resolvedFromWorkflowAuthorization = false
|
||||
|
||||
if (!resolvedWorkspaceId && workflowId) {
|
||||
const [workflowData] = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!workflowAuthorization.allowed) {
|
||||
logger.warn(`[${requestId}] Workflow authorization failed for custom tools`, {
|
||||
workflowId,
|
||||
userId,
|
||||
status: workflowAuthorization.status,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: workflowAuthorization.message || 'Access denied' },
|
||||
{ status: workflowAuthorization.status }
|
||||
)
|
||||
}
|
||||
|
||||
resolvedWorkspaceId = workflowData.workspaceId
|
||||
resolvedWorkspaceId = workflowAuthorization.workflow?.workspaceId ?? null
|
||||
resolvedFromWorkflowAuthorization = true
|
||||
}
|
||||
|
||||
// Check workspace permissions
|
||||
// For internal JWT with workflowId: checkSessionOrInternalAuth already resolved userId from workflow owner
|
||||
// For session: verify user has access to the workspace
|
||||
// For legacy (no workspaceId): skip workspace check, rely on userId match
|
||||
if (resolvedWorkspaceId && !(authResult.authType === 'internal_jwt' && workflowId)) {
|
||||
// Check workspace permissions for all auth types
|
||||
if (resolvedWorkspaceId && !resolvedFromWorkflowAuthorization) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
userId,
|
||||
'workspace',
|
||||
|
||||
@@ -47,16 +47,9 @@ export async function POST(request: NextRequest) {
|
||||
(await getJiraCloudId(validatedData.domain, validatedData.accessToken))
|
||||
|
||||
const formData = new FormData()
|
||||
const filesOutput: Array<{ name: string; mimeType: string; data: string; size: number }> = []
|
||||
|
||||
for (const file of userFiles) {
|
||||
const buffer = await downloadFileFromStorage(file, requestId, logger)
|
||||
filesOutput.push({
|
||||
name: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
data: buffer.toString('base64'),
|
||||
size: buffer.length,
|
||||
})
|
||||
const blob = new Blob([new Uint8Array(buffer)], {
|
||||
type: file.type || 'application/octet-stream',
|
||||
})
|
||||
@@ -109,7 +102,7 @@ export async function POST(request: NextRequest) {
|
||||
issueKey: validatedData.issueKey,
|
||||
attachments,
|
||||
attachmentIds,
|
||||
files: filesOutput,
|
||||
files: userFiles,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getOrganizationBillingData,
|
||||
isOrganizationOwnerOrAdmin,
|
||||
} from '@/lib/billing/core/organization'
|
||||
import { isUserMemberOfOrganization } from '@/lib/billing/organizations/membership'
|
||||
|
||||
const logger = createLogger('UnifiedUsageAPI')
|
||||
|
||||
@@ -61,6 +62,12 @@ export async function GET(request: NextRequest) {
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const membership = await isUserMemberOfOrganization(session.user.id, organizationId)
|
||||
if (!membership.isMember) {
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
const org = await getOrganizationBillingData(organizationId)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -160,7 +160,7 @@ export async function POST(req: NextRequest) {
|
||||
let workspaceId: string | null = null
|
||||
if (workflowId) {
|
||||
const [workflowRecord] = await db
|
||||
.select({ workspaceId: workflow.workspaceId, userId: workflow.userId })
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
@@ -183,9 +183,18 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
} else if (workflowRecord.userId !== session.user.id) {
|
||||
logger.warn(`[${requestId}] User ${session.user.id} does not own workflow ${workflowId}`)
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 403 })
|
||||
} else {
|
||||
logger.warn(
|
||||
`[${requestId}] Workflow ${workflowId} has no workspaceId; wand request blocked`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.',
|
||||
},
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import { webhook, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { validateInteger } from '@/lib/core/security/input-validation'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('WebhookAPI')
|
||||
|
||||
@@ -22,11 +22,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const { id } = await params
|
||||
logger.debug(`[${requestId}] Fetching webhook with ID: ${id}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized webhook access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const webhooks = await db
|
||||
.select({
|
||||
@@ -50,28 +51,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const webhookData = webhooks[0]
|
||||
|
||||
// Check if user has permission to access this webhook
|
||||
let hasAccess = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (webhookData.workflow.userId === session.user.id) {
|
||||
hasAccess = true
|
||||
}
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has any permission
|
||||
if (!hasAccess && webhookData.workflow.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
webhookData.workflow.workspaceId
|
||||
)
|
||||
if (userPermission !== null) {
|
||||
hasAccess = true
|
||||
}
|
||||
}
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: webhookData.workflow.id,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
const hasAccess = authorization.allowed
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn(`[${requestId}] User ${session.user.id} denied access to webhook: ${id}`)
|
||||
logger.warn(`[${requestId}] User ${userId} denied access to webhook: ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -90,11 +78,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
const { id } = await params
|
||||
logger.debug(`[${requestId}] Updating webhook with ID: ${id}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized webhook update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
const body = await request.json()
|
||||
const { isActive, failedCount } = body
|
||||
@@ -127,27 +116,15 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
}
|
||||
|
||||
const webhookData = webhooks[0]
|
||||
let canModify = false
|
||||
|
||||
if (webhookData.workflow.userId === session.user.id) {
|
||||
canModify = true
|
||||
}
|
||||
|
||||
if (!canModify && webhookData.workflow.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
webhookData.workflow.workspaceId
|
||||
)
|
||||
if (userPermission === 'write' || userPermission === 'admin') {
|
||||
canModify = true
|
||||
}
|
||||
}
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: webhookData.workflow.id,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const canModify = authorization.allowed
|
||||
|
||||
if (!canModify) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} denied permission to modify webhook: ${id}`
|
||||
)
|
||||
logger.warn(`[${requestId}] User ${userId} denied permission to modify webhook: ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -185,11 +162,12 @@ export async function DELETE(
|
||||
const { id } = await params
|
||||
logger.debug(`[${requestId}] Deleting webhook with ID: ${id}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized webhook deletion attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
// Find the webhook and check permissions
|
||||
const webhooks = await db
|
||||
@@ -213,30 +191,15 @@ export async function DELETE(
|
||||
|
||||
const webhookData = webhooks[0]
|
||||
|
||||
// Check if user has permission to delete this webhook
|
||||
let canDelete = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (webhookData.workflow.userId === session.user.id) {
|
||||
canDelete = true
|
||||
}
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has write or admin permission
|
||||
if (!canDelete && webhookData.workflow.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
webhookData.workflow.workspaceId
|
||||
)
|
||||
if (userPermission === 'write' || userPermission === 'admin') {
|
||||
canDelete = true
|
||||
}
|
||||
}
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: webhookData.workflow.id,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const canDelete = authorization.allowed
|
||||
|
||||
if (!canDelete) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} denied permission to delete webhook: ${id}`
|
||||
)
|
||||
logger.warn(`[${requestId}] User ${userId} denied permission to delete webhook: ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from '@sim/db'
|
||||
import { webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import { permissions, webhook, workflow, workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, desc, eq, isNull, or } from 'drizzle-orm'
|
||||
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
configureRssPolling,
|
||||
syncWebhooksForCredentialSet,
|
||||
} from '@/lib/webhooks/utils.server'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants'
|
||||
|
||||
const logger = createLogger('WebhooksAPI')
|
||||
@@ -57,15 +57,12 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
const wfRecord = wf[0]
|
||||
let canRead = wfRecord.userId === session.user.id
|
||||
if (!canRead && wfRecord.workspaceId) {
|
||||
const permission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
wfRecord.workspaceId
|
||||
)
|
||||
canRead = permission === 'read' || permission === 'write' || permission === 'admin'
|
||||
}
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: wfRecord.id,
|
||||
userId: session.user.id,
|
||||
action: 'read',
|
||||
})
|
||||
const canRead = authorization.allowed
|
||||
|
||||
if (!canRead) {
|
||||
logger.warn(
|
||||
@@ -114,8 +111,17 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ webhooks: [] }, { status: 200 })
|
||||
}
|
||||
|
||||
// Default: list webhooks owned by the session user
|
||||
logger.debug(`[${requestId}] Fetching user-owned webhooks for ${session.user.id}`)
|
||||
logger.debug(`[${requestId}] Fetching workspace-accessible webhooks for ${session.user.id}`)
|
||||
const workspacePermissionRows = await db
|
||||
.select({ workspaceId: permissions.entityId })
|
||||
.from(permissions)
|
||||
.where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
|
||||
|
||||
const workspaceIds = workspacePermissionRows.map((row) => row.workspaceId)
|
||||
if (workspaceIds.length === 0) {
|
||||
return NextResponse.json({ webhooks: [] }, { status: 200 })
|
||||
}
|
||||
|
||||
const webhooks = await db
|
||||
.select({
|
||||
webhook: webhook,
|
||||
@@ -126,9 +132,9 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
.from(webhook)
|
||||
.innerJoin(workflow, eq(webhook.workflowId, workflow.id))
|
||||
.where(eq(workflow.userId, session.user.id))
|
||||
.where(inArray(workflow.workspaceId, workspaceIds))
|
||||
|
||||
logger.info(`[${requestId}] Retrieved ${webhooks.length} user-owned webhooks`)
|
||||
logger.info(`[${requestId}] Retrieved ${webhooks.length} workspace-accessible webhooks`)
|
||||
return NextResponse.json({ webhooks }, { status: 200 })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error fetching webhooks`, error)
|
||||
@@ -237,25 +243,12 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const workflowRecord = workflowData[0]
|
||||
|
||||
// Check if user has permission to modify this workflow
|
||||
let canModify = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (workflowRecord.userId === userId) {
|
||||
canModify = true
|
||||
}
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has write or admin permission
|
||||
if (!canModify && workflowRecord.workspaceId) {
|
||||
const userPermission = await getUserEntityPermissions(
|
||||
userId,
|
||||
'workspace',
|
||||
workflowRecord.workspaceId
|
||||
)
|
||||
if (userPermission === 'write' || userPermission === 'admin') {
|
||||
canModify = true
|
||||
}
|
||||
}
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const canModify = authorization.allowed
|
||||
|
||||
if (!canModify) {
|
||||
logger.warn(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { applyAutoLayout } from '@/lib/workflows/autolayout'
|
||||
import {
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
type NormalizedWorkflowData,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -52,13 +52,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const userId = auth.userId
|
||||
|
||||
const body = await request.json()
|
||||
const layoutOptions = AutoLayoutRequestSchema.parse(body)
|
||||
@@ -67,26 +67,28 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
userId,
|
||||
})
|
||||
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, userId)
|
||||
const workflowData = accessContext?.workflow
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const workflowData = authorization.workflow
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for autolayout`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const canUpdate =
|
||||
accessContext?.isOwner ||
|
||||
(workflowData.workspaceId
|
||||
? accessContext?.workspacePermission === 'write' ||
|
||||
accessContext?.workspacePermission === 'admin'
|
||||
: false)
|
||||
const canUpdate = authorization.allowed
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied permission to autolayout workflow ${workflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
}
|
||||
|
||||
let currentWorkflowData: NormalizedWorkflowData | null
|
||||
|
||||
131
apps/sim/app/api/workflows/[id]/chat/status/route.test.ts
Normal file
131
apps/sim/app/api/workflows/[id]/chat/status/route.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Tests for workflow chat status route auth and access.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockCheckSessionOrInternalAuth = vi.fn()
|
||||
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
|
||||
const mockDbSelect = vi.fn()
|
||||
const mockDbFrom = vi.fn()
|
||||
const mockDbWhere = vi.fn()
|
||||
const mockDbLimit = vi.fn()
|
||||
|
||||
describe('Workflow Chat Status Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockDbSelect.mockReturnValue({ from: mockDbFrom })
|
||||
mockDbFrom.mockReturnValue({ where: mockDbWhere })
|
||||
mockDbWhere.mockReturnValue({ limit: mockDbLimit })
|
||||
mockDbLimit.mockResolvedValue([])
|
||||
|
||||
vi.doMock('@sim/logger', () => loggerMock)
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
eq: vi.fn(),
|
||||
}))
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockDbSelect,
|
||||
},
|
||||
}))
|
||||
vi.doMock('@sim/db/schema', () => ({
|
||||
chat: {
|
||||
id: 'id',
|
||||
identifier: 'identifier',
|
||||
title: 'title',
|
||||
description: 'description',
|
||||
customizations: 'customizations',
|
||||
authType: 'authType',
|
||||
allowedEmails: 'allowedEmails',
|
||||
outputConfigs: 'outputConfigs',
|
||||
password: 'password',
|
||||
isActive: 'isActive',
|
||||
workflowId: 'workflowId',
|
||||
},
|
||||
}))
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
vi.doMock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false })
|
||||
|
||||
const { GET } = await import('./route')
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when user lacks workspace access', async () => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'session',
|
||||
})
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Access denied',
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: null,
|
||||
})
|
||||
|
||||
const { GET } = await import('./route')
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns deployment details when authorized', async () => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'session',
|
||||
})
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: 'read',
|
||||
})
|
||||
mockDbLimit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'chat-1',
|
||||
identifier: 'assistant',
|
||||
title: 'Support Bot',
|
||||
description: 'desc',
|
||||
customizations: { theme: 'dark' },
|
||||
authType: 'public',
|
||||
allowedEmails: [],
|
||||
outputConfigs: {},
|
||||
password: 'secret',
|
||||
isActive: true,
|
||||
},
|
||||
])
|
||||
|
||||
const { GET } = await import('./route')
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/chat/status')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.isDeployed).toBe(true)
|
||||
expect(data.deployment.id).toBe('chat-1')
|
||||
expect(data.deployment.hasPassword).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,10 @@ import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatStatusAPI')
|
||||
@@ -10,11 +13,28 @@ const logger = createLogger('ChatStatusAPI')
|
||||
/**
|
||||
* GET endpoint to check if a workflow has an active chat deployment
|
||||
*/
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: id,
|
||||
userId: auth.userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return createErrorResponse(
|
||||
authorization.message || 'Access denied',
|
||||
authorization.status || 403
|
||||
)
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Checking chat deployment status for workflow: ${id}`)
|
||||
|
||||
// Find any active chat deployments for this workflow
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
|
||||
@@ -22,23 +22,22 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow duplication attempt for ${sourceWorkflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { name, description, color, workspaceId, folderId } = DuplicateRequestSchema.parse(body)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${session.user.id}`
|
||||
)
|
||||
logger.info(`[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${userId}`)
|
||||
|
||||
const result = await duplicateWorkflow({
|
||||
sourceWorkflowId,
|
||||
userId: session.user.id,
|
||||
userId,
|
||||
name,
|
||||
description,
|
||||
color,
|
||||
@@ -72,7 +71,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
if (error.message === 'Source workflow not found or access denied') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} denied access to source workflow ${sourceWorkflowId}`
|
||||
`[${requestId}] User ${userId} denied access to source workflow ${sourceWorkflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -29,7 +29,11 @@ import {
|
||||
loadWorkflowFromNormalizedTables,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
|
||||
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
|
||||
import {
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
createHttpResponseFromBlock,
|
||||
workflowHasResponseBlock,
|
||||
} from '@/lib/workflows/utils'
|
||||
import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
|
||||
@@ -340,6 +344,17 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
: validatedInput
|
||||
|
||||
const shouldUseDraftState = useDraftState ?? auth.authType === 'session'
|
||||
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: shouldUseDraftState ? 'write' : 'read',
|
||||
})
|
||||
if (!workflowAuthorization.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: workflowAuthorization.message || 'Access denied' },
|
||||
{ status: workflowAuthorization.status }
|
||||
)
|
||||
}
|
||||
|
||||
const streamHeader = req.headers.get('X-Stream-Response') === 'true'
|
||||
const enableSSE = streamHeader || streamParam === true
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { markExecutionCancelled } from '@/lib/execution/cancellation'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('CancelExecutionAPI')
|
||||
|
||||
@@ -20,6 +21,18 @@ export async function POST(
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: auth.userId,
|
||||
action: 'write',
|
||||
})
|
||||
if (!workflowAuthorization.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: workflowAuthorization.message || 'Access denied' },
|
||||
{ status: workflowAuthorization.status }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Cancel execution requested', { workflowId, executionId, userId: auth.userId })
|
||||
|
||||
const marked = await markExecutionCancelled(executionId)
|
||||
|
||||
119
apps/sim/app/api/workflows/[id]/form/status/route.test.ts
Normal file
119
apps/sim/app/api/workflows/[id]/form/status/route.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Tests for workflow form status route auth and access.
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockCheckSessionOrInternalAuth = vi.fn()
|
||||
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
|
||||
const mockDbSelect = vi.fn()
|
||||
const mockDbFrom = vi.fn()
|
||||
const mockDbWhere = vi.fn()
|
||||
const mockDbLimit = vi.fn()
|
||||
|
||||
describe('Workflow Form Status Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockDbSelect.mockReturnValue({ from: mockDbFrom })
|
||||
mockDbFrom.mockReturnValue({ where: mockDbWhere })
|
||||
mockDbWhere.mockReturnValue({ limit: mockDbLimit })
|
||||
mockDbLimit.mockResolvedValue([])
|
||||
|
||||
vi.doMock('@sim/logger', () => loggerMock)
|
||||
vi.doMock('drizzle-orm', () => ({
|
||||
and: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
}))
|
||||
vi.doMock('@sim/db', () => ({
|
||||
db: {
|
||||
select: mockDbSelect,
|
||||
},
|
||||
}))
|
||||
vi.doMock('@sim/db/schema', () => ({
|
||||
form: {
|
||||
id: 'id',
|
||||
identifier: 'identifier',
|
||||
title: 'title',
|
||||
workflowId: 'workflowId',
|
||||
isActive: 'isActive',
|
||||
},
|
||||
}))
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
|
||||
}))
|
||||
vi.doMock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false })
|
||||
|
||||
const { GET } = await import('./route')
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/form/status')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 when user lacks workspace access', async () => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'session',
|
||||
})
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Access denied',
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: null,
|
||||
})
|
||||
|
||||
const { GET } = await import('./route')
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/form/status')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns deployed form when authorized', async () => {
|
||||
mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
userId: 'user-1',
|
||||
authType: 'session',
|
||||
})
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
|
||||
workspacePermission: 'read',
|
||||
})
|
||||
mockDbLimit.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'form-1',
|
||||
identifier: 'feedback-form',
|
||||
title: 'Feedback',
|
||||
isActive: true,
|
||||
},
|
||||
])
|
||||
|
||||
const { GET } = await import('./route')
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/form/status')
|
||||
const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
expect(data.isDeployed).toBe(true)
|
||||
expect(data.form.id).toBe('form-1')
|
||||
})
|
||||
})
|
||||
@@ -3,20 +3,31 @@ import { form } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('FormStatusAPI')
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const { id: workflowId } = await params
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: auth.userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return createErrorResponse(
|
||||
authorization.message || 'Access denied',
|
||||
authorization.status || 403
|
||||
)
|
||||
}
|
||||
|
||||
const formResult = await db
|
||||
.select({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { LoggingSession } from '@/lib/logs/execution/logging-session'
|
||||
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
import type { ExecutionResult } from '@/executor/types'
|
||||
@@ -69,15 +70,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
const triggerType = isChatExecution ? 'chat' : 'manual'
|
||||
const loggingSession = new LoggingSession(id, executionId, triggerType, requestId)
|
||||
|
||||
const userId = accessValidation.workflow.userId
|
||||
const workspaceId = accessValidation.workflow.workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.error(`[${requestId}] Workflow ${id} has no workspaceId`)
|
||||
return createErrorResponse('Workflow has no associated workspace', 500)
|
||||
}
|
||||
const billedAccountUserId = await getWorkspaceBilledAccountUserId(workspaceId)
|
||||
if (!billedAccountUserId) {
|
||||
logger.error(`[${requestId}] Unable to resolve billed account for workspace ${workspaceId}`)
|
||||
return createErrorResponse('Unable to resolve billing account for this workspace', 500)
|
||||
}
|
||||
|
||||
await loggingSession.safeStart({
|
||||
userId,
|
||||
userId: billedAccountUserId,
|
||||
workspaceId,
|
||||
variables: {},
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const mockGetSession = vi.fn()
|
||||
const mockLoadWorkflowFromNormalizedTables = vi.fn()
|
||||
const mockGetWorkflowById = vi.fn()
|
||||
const mockGetWorkflowAccessContext = vi.fn()
|
||||
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
|
||||
const mockDbDelete = vi.fn()
|
||||
const mockDbUpdate = vi.fn()
|
||||
const mockDbSelect = vi.fn()
|
||||
@@ -35,8 +35,11 @@ vi.mock('@/lib/workflows/utils', async () => {
|
||||
return {
|
||||
...actual,
|
||||
getWorkflowById: (workflowId: string) => mockGetWorkflowById(workflowId),
|
||||
getWorkflowAccessContext: (workflowId: string, userId?: string) =>
|
||||
mockGetWorkflowAccessContext(workflowId, userId),
|
||||
authorizeWorkflowByWorkspacePermission: (params: {
|
||||
workflowId: string
|
||||
userId: string
|
||||
action?: 'read' | 'write' | 'admin'
|
||||
}) => mockAuthorizeWorkflowByWorkspacePermission(params),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -86,13 +89,6 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(null)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
workflow: null,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent')
|
||||
const params = Promise.resolve({ id: 'nonexistent' })
|
||||
@@ -104,12 +100,12 @@ describe('Workflow By ID API Route', () => {
|
||||
expect(data.error).toBe('Workflow not found')
|
||||
})
|
||||
|
||||
it.concurrent('should allow access when user owns the workflow', async () => {
|
||||
it.concurrent('should allow access when user has admin workspace permission', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: null,
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const mockNormalizedData = {
|
||||
@@ -125,12 +121,11 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
|
||||
@@ -166,12 +161,11 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'read',
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
|
||||
@@ -199,12 +193,12 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to read this workflow',
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: null,
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123')
|
||||
@@ -214,7 +208,7 @@ describe('Workflow By ID API Route', () => {
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Access denied')
|
||||
expect(data.error).toBe('Unauthorized: Access denied to read this workflow')
|
||||
})
|
||||
|
||||
it.concurrent('should use normalized tables when available', async () => {
|
||||
@@ -222,7 +216,7 @@ describe('Workflow By ID API Route', () => {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: null,
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const mockNormalizedData = {
|
||||
@@ -238,12 +232,11 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
mockLoadWorkflowFromNormalizedTables.mockResolvedValue(mockNormalizedData)
|
||||
@@ -261,12 +254,12 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
describe('DELETE /api/workflows/[id]', () => {
|
||||
it('should allow owner to delete workflow', async () => {
|
||||
it('should allow admin to delete workflow', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: null,
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
@@ -274,12 +267,17 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
mockDbSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
|
||||
}),
|
||||
})
|
||||
|
||||
mockDbDelete.mockReturnValue({
|
||||
@@ -315,12 +313,11 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'admin',
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Mock db.select() to return multiple workflows so deletion is allowed
|
||||
@@ -363,12 +360,11 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'admin',
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Mock db.select() to return only 1 workflow (the one being deleted)
|
||||
@@ -403,12 +399,12 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to admin this workflow',
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: null,
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
@@ -420,17 +416,17 @@ describe('Workflow By ID API Route', () => {
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Access denied')
|
||||
expect(data.error).toBe('Unauthorized: Access denied to admin this workflow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/workflows/[id]', () => {
|
||||
it('should allow owner to update workflow', async () => {
|
||||
it('should allow user with write permission to update workflow', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: null,
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
const updateData = { name: 'Updated Workflow' }
|
||||
@@ -441,12 +437,11 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
mockDbUpdate.mockReturnValue({
|
||||
@@ -486,12 +481,11 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'write',
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
mockDbUpdate.mockReturnValue({
|
||||
@@ -530,12 +524,12 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to write this workflow',
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'read',
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
@@ -548,7 +542,7 @@ describe('Workflow By ID API Route', () => {
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Access denied')
|
||||
expect(data.error).toBe('Unauthorized: Access denied to write this workflow')
|
||||
})
|
||||
|
||||
it.concurrent('should validate request data', async () => {
|
||||
@@ -556,7 +550,7 @@ describe('Workflow By ID API Route', () => {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: null,
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
@@ -564,12 +558,11 @@ describe('Workflow By ID API Route', () => {
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
const invalidData = { name: '' }
|
||||
|
||||
@@ -4,14 +4,12 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { verifyInternalToken } from '@/lib/auth/internal'
|
||||
import { checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { getWorkflowAccessContext, getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowByIdAPI')
|
||||
|
||||
@@ -34,50 +32,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
let isInternalCall = false
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.split(' ')[1]
|
||||
const verification = await verifyInternalToken(token)
|
||||
isInternalCall = verification.valid
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success) {
|
||||
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let userId: string | null = null
|
||||
const userId = auth.userId || null
|
||||
|
||||
if (isInternalCall) {
|
||||
logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`)
|
||||
} else {
|
||||
const session = await getSession()
|
||||
let authenticatedUserId: string | null = session?.user?.id || null
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
const apiKeyHeader = request.headers.get('x-api-key')
|
||||
if (apiKeyHeader) {
|
||||
const authResult = await authenticateApiKeyFromHeader(apiKeyHeader)
|
||||
if (authResult.success && authResult.userId) {
|
||||
authenticatedUserId = authResult.userId
|
||||
if (authResult.keyId) {
|
||||
await updateApiKeyLastUsed(authResult.keyId).catch((error) => {
|
||||
logger.warn(`[${requestId}] Failed to update API key last used timestamp:`, {
|
||||
keyId: authResult.keyId,
|
||||
error,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!authenticatedUserId) {
|
||||
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
userId = authenticatedUserId
|
||||
}
|
||||
|
||||
let accessContext = null
|
||||
let workflowData = await getWorkflowById(workflowId)
|
||||
|
||||
if (!workflowData) {
|
||||
@@ -86,36 +48,28 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
// Check if user has access to this workflow
|
||||
let hasAccess = false
|
||||
if (!userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (isInternalCall) {
|
||||
// Internal calls have full access
|
||||
hasAccess = true
|
||||
} else {
|
||||
// Case 1: User owns the workflow
|
||||
if (workflowData) {
|
||||
accessContext = await getWorkflowAccessContext(workflowId, userId ?? undefined)
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.workflow) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!accessContext) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
workflowData = accessContext.workflow
|
||||
|
||||
if (accessContext.isOwner) {
|
||||
hasAccess = true
|
||||
}
|
||||
|
||||
if (!hasAccess && workflowData.workspaceId && accessContext.workspacePermission) {
|
||||
hasAccess = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
workflowData = authorization.workflow
|
||||
if (!authorization.allowed) {
|
||||
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status }
|
||||
)
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from normalized tables`)
|
||||
@@ -196,43 +150,36 @@ export async function DELETE(
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized deletion attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const userId = auth.userId
|
||||
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, userId)
|
||||
const workflowData = accessContext?.workflow || (await getWorkflowById(workflowId))
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'admin',
|
||||
})
|
||||
const workflowData = authorization.workflow || (await getWorkflowById(workflowId))
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for deletion`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user has permission to delete this workflow
|
||||
let canDelete = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (workflowData.userId === userId) {
|
||||
canDelete = true
|
||||
}
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has admin permission
|
||||
if (!canDelete && workflowData.workspaceId) {
|
||||
const context = accessContext || (await getWorkflowAccessContext(workflowId, userId))
|
||||
if (context?.workspacePermission === 'admin') {
|
||||
canDelete = true
|
||||
}
|
||||
}
|
||||
const canDelete = authorization.allowed
|
||||
|
||||
if (!canDelete) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied permission to delete workflow ${workflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this is the last workflow in the workspace
|
||||
@@ -403,48 +350,40 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized update attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const userId = auth.userId
|
||||
|
||||
const body = await request.json()
|
||||
const updates = UpdateWorkflowSchema.parse(body)
|
||||
|
||||
// Fetch the workflow to check ownership/access
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, userId)
|
||||
const workflowData = accessContext?.workflow || (await getWorkflowById(workflowId))
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const workflowData = authorization.workflow || (await getWorkflowById(workflowId))
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for update`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user has permission to update this workflow
|
||||
let canUpdate = false
|
||||
|
||||
// Case 1: User owns the workflow
|
||||
if (workflowData.userId === userId) {
|
||||
canUpdate = true
|
||||
}
|
||||
|
||||
// Case 2: Workflow belongs to a workspace and user has write or admin permission
|
||||
if (!canUpdate && workflowData.workspaceId) {
|
||||
const context = accessContext || (await getWorkflowAccessContext(workflowId, userId))
|
||||
if (context?.workspacePermission === 'write' || context?.workspacePermission === 'admin') {
|
||||
canUpdate = true
|
||||
}
|
||||
}
|
||||
const canUpdate = authorization.allowed
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied permission to update workflow ${workflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { updatedAt: new Date() }
|
||||
|
||||
@@ -4,13 +4,13 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence'
|
||||
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
|
||||
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
|
||||
@@ -118,40 +118,38 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
const { id: workflowId } = await params
|
||||
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized state update attempt for workflow ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const userId = auth.userId
|
||||
|
||||
const body = await request.json()
|
||||
const state = WorkflowStateSchema.parse(body)
|
||||
|
||||
// Fetch the workflow to check ownership/access
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, userId)
|
||||
const workflowData = accessContext?.workflow
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const workflowData = authorization.workflow
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow ${workflowId} not found for state update`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if user has permission to update this workflow
|
||||
const canUpdate =
|
||||
accessContext?.isOwner ||
|
||||
(workflowData.workspaceId
|
||||
? accessContext?.workspacePermission === 'write' ||
|
||||
accessContext?.workspacePermission === 'admin'
|
||||
: false)
|
||||
const canUpdate = authorization.allowed
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} denied permission to update workflow state ${workflowId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Sanitize custom tools in agent blocks before saving
|
||||
|
||||
@@ -16,19 +16,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Workflow Variables API Route', () => {
|
||||
let authMocks: ReturnType<typeof mockAuth>
|
||||
const mockGetWorkflowAccessContext = vi.fn()
|
||||
const mockAuthorizeWorkflowByWorkspacePermission = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
setupCommonApiMocks()
|
||||
mockCryptoUuid('mock-request-id-12345678')
|
||||
authMocks = mockAuth(defaultMockUser)
|
||||
mockGetWorkflowAccessContext.mockReset()
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockReset()
|
||||
|
||||
vi.doMock('@sim/db', () => databaseMock)
|
||||
|
||||
vi.doMock('@/lib/workflows/utils', () => ({
|
||||
getWorkflowAccessContext: mockGetWorkflowAccessContext,
|
||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('Workflow Variables API Route', () => {
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const { GET } = await import('@/app/api/workflows/[id]/variables/route')
|
||||
const { GET } = await import('./route')
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
@@ -53,12 +53,18 @@ describe('Workflow Variables API Route', () => {
|
||||
|
||||
it('should return 404 when workflow does not exist', async () => {
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce(null)
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 404,
|
||||
message: 'Workflow not found',
|
||||
workflow: null,
|
||||
workspacePermission: null,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/nonexistent/variables')
|
||||
const params = Promise.resolve({ id: 'nonexistent' })
|
||||
|
||||
const { GET } = await import('@/app/api/workflows/[id]/variables/route')
|
||||
const { GET } = await import('./route')
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
@@ -66,29 +72,28 @@ describe('Workflow Variables API Route', () => {
|
||||
expect(data.error).toBe('Workflow not found')
|
||||
})
|
||||
|
||||
it('should allow access when user owns the workflow', async () => {
|
||||
it('should allow access when user has workspace permission', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
workspaceId: null,
|
||||
workspaceId: 'workspace-456',
|
||||
variables: {
|
||||
'var-1': { id: 'var-1', name: 'test', type: 'string', value: 'hello' },
|
||||
},
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const { GET } = await import('@/app/api/workflows/[id]/variables/route')
|
||||
const { GET } = await import('./route')
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -107,18 +112,17 @@ describe('Workflow Variables API Route', () => {
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-owner',
|
||||
workspacePermission: 'read',
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const { GET } = await import('@/app/api/workflows/[id]/variables/route')
|
||||
const { GET } = await import('./route')
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -135,48 +139,47 @@ describe('Workflow Variables API Route', () => {
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to read this workflow',
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-owner',
|
||||
workspacePermission: null,
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const { GET } = await import('@/app/api/workflows/[id]/variables/route')
|
||||
const { GET } = await import('./route')
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
expect(data.error).toBe('Unauthorized: Access denied to read this workflow')
|
||||
})
|
||||
|
||||
it.concurrent('should include proper cache headers', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
workspaceId: null,
|
||||
workspaceId: 'workspace-456',
|
||||
variables: {
|
||||
'var-1': { id: 'var-1', name: 'test', type: 'string', value: 'hello' },
|
||||
},
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
workspacePermission: 'admin',
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const { GET } = await import('@/app/api/workflows/[id]/variables/route')
|
||||
const { GET } = await import('./route')
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -186,21 +189,20 @@ describe('Workflow Variables API Route', () => {
|
||||
})
|
||||
|
||||
describe('POST /api/workflows/[id]/variables', () => {
|
||||
it('should allow owner to update variables', async () => {
|
||||
it('should allow user with write permission to update variables', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
workspaceId: null,
|
||||
workspaceId: 'workspace-456',
|
||||
variables: {},
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
const variables = {
|
||||
@@ -219,7 +221,7 @@ describe('Workflow Variables API Route', () => {
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const { POST } = await import('@/app/api/workflows/[id]/variables/route')
|
||||
const { POST } = await import('./route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
@@ -236,12 +238,12 @@ describe('Workflow Variables API Route', () => {
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
status: 403,
|
||||
message: 'Unauthorized: Access denied to write this workflow',
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-owner',
|
||||
workspacePermission: null,
|
||||
isOwner: false,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
const variables = {
|
||||
@@ -260,29 +262,28 @@ describe('Workflow Variables API Route', () => {
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const { POST } = await import('@/app/api/workflows/[id]/variables/route')
|
||||
const { POST } = await import('./route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.status).toBe(403)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Unauthorized')
|
||||
expect(data.error).toBe('Unauthorized: Access denied to write this workflow')
|
||||
})
|
||||
|
||||
it.concurrent('should validate request data schema', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
workspaceId: null,
|
||||
workspaceId: 'workspace-456',
|
||||
variables: {},
|
||||
}
|
||||
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
mockGetWorkflowAccessContext.mockResolvedValueOnce({
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: null,
|
||||
workspacePermission: null,
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
|
||||
const invalidData = { variables: [{ name: 'test' }] }
|
||||
@@ -293,7 +294,7 @@ describe('Workflow Variables API Route', () => {
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const { POST } = await import('@/app/api/workflows/[id]/variables/route')
|
||||
const { POST } = await import('./route')
|
||||
const response = await POST(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
@@ -305,12 +306,14 @@ describe('Workflow Variables API Route', () => {
|
||||
describe('Error handling', () => {
|
||||
it.concurrent('should handle database errors gracefully', async () => {
|
||||
authMocks.setAuthenticated({ id: 'user-123', email: 'test@example.com' })
|
||||
mockGetWorkflowAccessContext.mockRejectedValueOnce(new Error('Database connection failed'))
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockRejectedValueOnce(
|
||||
new Error('Database connection failed')
|
||||
)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123/variables')
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const { GET } = await import('@/app/api/workflows/[id]/variables/route')
|
||||
const { GET } = await import('./route')
|
||||
const response = await GET(req, { params })
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
|
||||
@@ -4,9 +4,9 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
|
||||
const logger = createLogger('WorkflowVariablesAPI')
|
||||
@@ -34,31 +34,34 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const workflowId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow variables update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
// Get the workflow record
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, session.user.id)
|
||||
const workflowData = accessContext?.workflow
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'write',
|
||||
})
|
||||
const workflowData = authorization.workflow
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
const workspaceId = workflowData.workspaceId
|
||||
|
||||
// Check authorization - either the user owns the workflow or has workspace permissions
|
||||
const isAuthorized =
|
||||
accessContext?.isOwner || (workspaceId ? accessContext?.workspacePermission !== null : false)
|
||||
const isAuthorized = authorization.allowed
|
||||
|
||||
if (!isAuthorized) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted to update variables for workflow ${workflowId} without permission`
|
||||
`[${requestId}] User ${userId} attempted to update variables for workflow ${workflowId} without permission`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
@@ -100,32 +103,34 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const workflowId = (await params).id
|
||||
|
||||
try {
|
||||
// Get the session directly in the API route
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow variables access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
// Get the workflow record
|
||||
const accessContext = await getWorkflowAccessContext(workflowId, session.user.id)
|
||||
const workflowData = accessContext?.workflow
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
const workflowData = authorization.workflow
|
||||
|
||||
if (!workflowData) {
|
||||
logger.warn(`[${requestId}] Workflow not found: ${workflowId}`)
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
const workspaceId = workflowData.workspaceId
|
||||
|
||||
// Check authorization - either the user owns the workflow or has workspace permissions
|
||||
const isAuthorized =
|
||||
accessContext?.isOwner || (workspaceId ? accessContext?.workspacePermission !== null : false)
|
||||
const isAuthorized = authorization.allowed
|
||||
|
||||
if (!isAuthorized) {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted to access variables for workflow ${workflowId} without permission`
|
||||
`[${requestId}] User ${userId} attempted to access variables for workflow ${workflowId} without permission`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: authorization.message || 'Access denied' },
|
||||
{ status: authorization.status || 403 }
|
||||
)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Return variables if they exist
|
||||
|
||||
@@ -5,14 +5,16 @@ import {
|
||||
authenticateApiKeyFromHeader,
|
||||
updateApiKeyLastUsed,
|
||||
} from '@/lib/api-key/service'
|
||||
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { getWorkflowById } from '@/lib/workflows/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission, getWorkflowById } from '@/lib/workflows/utils'
|
||||
|
||||
const logger = createLogger('WorkflowMiddleware')
|
||||
|
||||
export interface ValidationResult {
|
||||
error?: { message: string; status: number }
|
||||
workflow?: any
|
||||
auth?: AuthResult
|
||||
}
|
||||
|
||||
export async function validateWorkflowAccess(
|
||||
@@ -31,6 +33,44 @@ export async function validateWorkflowAccess(
|
||||
}
|
||||
}
|
||||
|
||||
if (!workflow.workspaceId) {
|
||||
return {
|
||||
error: {
|
||||
message:
|
||||
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.',
|
||||
status: 403,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (!requireDeployment) {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return {
|
||||
error: {
|
||||
message: auth.error || 'Unauthorized',
|
||||
status: 401,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const authorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId: auth.userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed) {
|
||||
return {
|
||||
error: {
|
||||
message: authorization.message || 'Access denied',
|
||||
status: authorization.status,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return { workflow, auth }
|
||||
}
|
||||
|
||||
if (requireDeployment) {
|
||||
if (!workflow.isDeployed) {
|
||||
return {
|
||||
@@ -65,24 +105,13 @@ export async function validateWorkflowAccess(
|
||||
|
||||
let validResult: ApiKeyAuthResult | null = null
|
||||
|
||||
if (workflow.workspaceId) {
|
||||
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
|
||||
workspaceId: workflow.workspaceId as string,
|
||||
keyTypes: ['workspace', 'personal'],
|
||||
})
|
||||
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
|
||||
workspaceId: workflow.workspaceId as string,
|
||||
keyTypes: ['workspace', 'personal'],
|
||||
})
|
||||
|
||||
if (workspaceResult.success) {
|
||||
validResult = workspaceResult
|
||||
}
|
||||
} else {
|
||||
const personalResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
|
||||
userId: workflow.userId as string,
|
||||
keyTypes: ['personal'],
|
||||
})
|
||||
|
||||
if (personalResult.success) {
|
||||
validResult = personalResult
|
||||
}
|
||||
if (workspaceResult.success) {
|
||||
validResult = workspaceResult
|
||||
}
|
||||
|
||||
if (!validResult) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
@@ -23,21 +23,21 @@ const ReorderSchema = z.object({
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized reorder attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { workspaceId, updates } = ReorderSchema.parse(body)
|
||||
|
||||
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission || permission === 'read') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}`
|
||||
`[${requestId}] User ${userId} lacks write permission for workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ error: 'Write access required' }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { db } from '@sim/db'
|
||||
import { workflow } from '@sim/db/schema'
|
||||
import { permissions, workflow } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, asc, eq, isNull, min } from 'drizzle-orm'
|
||||
import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
@@ -21,20 +21,19 @@ const CreateWorkflowSchema = z.object({
|
||||
})
|
||||
|
||||
// GET /api/workflows - Get workflows for user (optionally filtered by workspaceId)
|
||||
export async function GET(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const startTime = Date.now()
|
||||
const url = new URL(request.url)
|
||||
const workspaceId = url.searchParams.get('workspaceId')
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const userId = auth.userId
|
||||
|
||||
if (workspaceId) {
|
||||
const wsExists = await workspaceExists(workspaceId)
|
||||
@@ -73,10 +72,18 @@ export async function GET(request: Request) {
|
||||
.where(eq(workflow.workspaceId, workspaceId))
|
||||
.orderBy(...orderByClause)
|
||||
} else {
|
||||
const workspacePermissionRows = await db
|
||||
.select({ workspaceId: permissions.entityId })
|
||||
.from(permissions)
|
||||
.where(and(eq(permissions.userId, userId), eq(permissions.entityType, 'workspace')))
|
||||
const workspaceIds = workspacePermissionRows.map((row) => row.workspaceId)
|
||||
if (workspaceIds.length === 0) {
|
||||
return NextResponse.json({ data: [] }, { status: 200 })
|
||||
}
|
||||
workflows = await db
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.userId, userId))
|
||||
.where(inArray(workflow.workspaceId, workspaceIds))
|
||||
.orderBy(...orderByClause)
|
||||
}
|
||||
|
||||
@@ -91,12 +98,12 @@ export async function GET(request: Request) {
|
||||
// POST /api/workflows - Create a new workflow
|
||||
export async function POST(req: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
const auth = await checkSessionOrInternalAuth(req, { requireWorkflowId: false })
|
||||
if (!auth.success || !auth.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized workflow creation attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
try {
|
||||
const body = await req.json()
|
||||
@@ -109,28 +116,33 @@ export async function POST(req: NextRequest) {
|
||||
sortOrder: providedSortOrder,
|
||||
} = CreateWorkflowSchema.parse(body)
|
||||
|
||||
if (workspaceId) {
|
||||
const workspacePermission = await getUserEntityPermissions(
|
||||
session.user.id,
|
||||
'workspace',
|
||||
workspaceId
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Workflow creation blocked: missing workspaceId`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'workspaceId is required. Personal workflows are deprecated and cannot be created.',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!workspacePermission || workspacePermission === 'read') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${session.user.id} attempted to create workflow in workspace ${workspaceId} without write permissions`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required to create workflows in this workspace' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
const workspacePermission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
|
||||
if (!workspacePermission || workspacePermission === 'read') {
|
||||
logger.warn(
|
||||
`[${requestId}] User ${userId} attempted to create workflow in workspace ${workspaceId} without write permissions`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Write or Admin access required to create workflows in this workspace' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const workflowId = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`)
|
||||
logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${userId}`)
|
||||
|
||||
import('@/lib/core/telemetry')
|
||||
.then(({ PlatformEvents }) => {
|
||||
@@ -153,18 +165,14 @@ export async function POST(req: NextRequest) {
|
||||
const [minResult] = await db
|
||||
.select({ minOrder: min(workflow.sortOrder) })
|
||||
.from(workflow)
|
||||
.where(
|
||||
workspaceId
|
||||
? and(eq(workflow.workspaceId, workspaceId), folderCondition)
|
||||
: and(eq(workflow.userId, session.user.id), folderCondition)
|
||||
)
|
||||
.where(and(eq(workflow.workspaceId, workspaceId), folderCondition))
|
||||
sortOrder = (minResult?.minOrder ?? 1) - 1
|
||||
}
|
||||
|
||||
await db.insert(workflow).values({
|
||||
id: workflowId,
|
||||
userId: session.user.id,
|
||||
workspaceId: workspaceId || null,
|
||||
userId,
|
||||
workspaceId,
|
||||
folderId: folderId || null,
|
||||
sortOrder,
|
||||
name,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
PopoverItem,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { WorkflowIcon } from '@/components/icons'
|
||||
import { AgentSkillsIcon, WorkflowIcon } from '@/components/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { formatDuration } from '@/lib/core/utils/formatting'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
@@ -118,6 +118,10 @@ function getBlockIconAndColor(
|
||||
|
||||
// Check for tool by name first (most specific)
|
||||
if (lowerType === 'tool' && toolName) {
|
||||
// Handle load_skill tool with the AgentSkillsIcon
|
||||
if (toolName === 'load_skill') {
|
||||
return { icon: AgentSkillsIcon, bgColor: '#8B5CF6' }
|
||||
}
|
||||
const toolBlock = getBlockByToolName(toolName)
|
||||
if (toolBlock) {
|
||||
return { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { validateTriggerPaste } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -114,7 +114,8 @@ export const ActionBar = memo(
|
||||
)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { isExecuting, getLastExecutionSnapshot } = useExecutionStore()
|
||||
const { isExecuting } = useCurrentWorkflowExecution()
|
||||
const getLastExecutionSnapshot = useExecutionStore((s) => s.getLastExecutionSnapshot)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const edges = useWorkflowStore((state) => state.edges)
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI
|
||||
import type { BlockLog, ExecutionResult } from '@/executor/types'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import { getChatPosition } from '@/stores/chat/utils'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useCurrentWorkflowExecution } from '@/stores/execution'
|
||||
import { useOperationQueue } from '@/stores/operation-queue/store'
|
||||
import { useTerminalConsoleStore } from '@/stores/terminal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -256,7 +256,7 @@ export function Chat() {
|
||||
const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated)
|
||||
const entriesFromStore = useTerminalConsoleStore((state) => state.entries)
|
||||
const entries = hasConsoleHydrated ? entriesFromStore : []
|
||||
const { isExecuting } = useExecutionStore()
|
||||
const { isExecuting } = useCurrentWorkflowExecution()
|
||||
const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
|
||||
const { data: session } = useSession()
|
||||
const { addToQueue } = useOperationQueue()
|
||||
|
||||
@@ -4,11 +4,8 @@ import type React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { RepeatIcon, SplitIcon } from 'lucide-react'
|
||||
import { Combobox, type ComboboxOptionGroup } from '@/components/emcn'
|
||||
import {
|
||||
extractFieldsFromSchema,
|
||||
parseResponseFormatSafely,
|
||||
} from '@/lib/core/utils/response-format'
|
||||
import { getToolOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -124,42 +121,27 @@ export function OutputSelect({
|
||||
: `block-${block.id}`
|
||||
|
||||
const blockConfig = getBlock(block.type)
|
||||
const responseFormatValue =
|
||||
shouldUseBaseline && baselineWorkflow
|
||||
? baselineWorkflow.blocks?.[block.id]?.subBlocks?.responseFormat?.value
|
||||
: subBlockValues?.[block.id]?.responseFormat
|
||||
const responseFormat = parseResponseFormatSafely(responseFormatValue, block.id)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const effectiveTriggerMode = Boolean(block.triggerMode && isTriggerCapable)
|
||||
|
||||
let outputsToProcess: Record<string, unknown> = {}
|
||||
|
||||
if (responseFormat) {
|
||||
const schemaFields = extractFieldsFromSchema(responseFormat)
|
||||
if (schemaFields.length > 0) {
|
||||
schemaFields.forEach((field) => {
|
||||
outputsToProcess[field.name] = { type: field.type }
|
||||
})
|
||||
} else {
|
||||
outputsToProcess = blockConfig?.outputs || {}
|
||||
const rawSubBlockValues =
|
||||
shouldUseBaseline && baselineWorkflow
|
||||
? baselineWorkflow.blocks?.[block.id]?.subBlocks
|
||||
: subBlockValues?.[block.id]
|
||||
const subBlocks: Record<string, { value: unknown }> = {}
|
||||
if (rawSubBlockValues && typeof rawSubBlockValues === 'object') {
|
||||
for (const [key, val] of Object.entries(rawSubBlockValues)) {
|
||||
// Handle both { value: ... } and raw value formats
|
||||
subBlocks[key] = val && typeof val === 'object' && 'value' in val ? val : { value: val }
|
||||
}
|
||||
} else {
|
||||
// Build subBlocks object for tool selector
|
||||
const rawSubBlockValues =
|
||||
shouldUseBaseline && baselineWorkflow
|
||||
? baselineWorkflow.blocks?.[block.id]?.subBlocks
|
||||
: subBlockValues?.[block.id]
|
||||
const subBlocks: Record<string, { value: unknown }> = {}
|
||||
if (rawSubBlockValues && typeof rawSubBlockValues === 'object') {
|
||||
for (const [key, val] of Object.entries(rawSubBlockValues)) {
|
||||
// Handle both { value: ... } and raw value formats
|
||||
subBlocks[key] = val && typeof val === 'object' && 'value' in val ? val : { value: val }
|
||||
}
|
||||
}
|
||||
|
||||
const toolOutputs = blockConfig ? getToolOutputs(blockConfig, subBlocks) : {}
|
||||
outputsToProcess =
|
||||
Object.keys(toolOutputs).length > 0 ? toolOutputs : blockConfig?.outputs || {}
|
||||
}
|
||||
|
||||
outputsToProcess = getEffectiveBlockOutputs(block.type, subBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
}) as Record<string, unknown>
|
||||
|
||||
if (Object.keys(outputsToProcess).length === 0) return
|
||||
|
||||
const addOutput = (path: string, outputObj: unknown, prefix = '') => {
|
||||
|
||||
@@ -45,6 +45,12 @@ interface McpDeployProps {
|
||||
onCanSaveChange?: (canSave: boolean) => void
|
||||
}
|
||||
|
||||
function haveSameServerSelection(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
const bSet = new Set(b)
|
||||
return a.every((id) => bSet.has(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON Schema from input format with optional descriptions
|
||||
*/
|
||||
@@ -143,6 +149,7 @@ export function McpDeploy({
|
||||
})
|
||||
const [parameterDescriptions, setParameterDescriptions] = useState<Record<string, string>>({})
|
||||
const [pendingServerChanges, setPendingServerChanges] = useState<Set<string>>(new Set())
|
||||
const [saveErrors, setSaveErrors] = useState<string[]>([])
|
||||
|
||||
const parameterSchema = useMemo(
|
||||
() => generateParameterSchema(inputFormat, parameterDescriptions),
|
||||
@@ -179,6 +186,7 @@ export function McpDeploy({
|
||||
}
|
||||
return ids
|
||||
}, [servers, serverToolsMap])
|
||||
const [draftSelectedServerIds, setDraftSelectedServerIds] = useState<string[] | null>(null)
|
||||
|
||||
const hasLoadedInitialData = useRef(false)
|
||||
|
||||
@@ -238,9 +246,10 @@ export function McpDeploy({
|
||||
}
|
||||
}, [toolName, toolDescription, parameterDescriptions, savedValues])
|
||||
|
||||
const hasDeployedTools = selectedServerIds.length > 0
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!savedValues || !hasDeployedTools) return false
|
||||
const selectedServerIdsForForm = draftSelectedServerIds ?? selectedServerIds
|
||||
|
||||
const hasToolConfigurationChanges = useMemo(() => {
|
||||
if (!savedValues) return false
|
||||
if (toolName !== savedValues.toolName) return true
|
||||
if (toolDescription !== savedValues.toolDescription) return true
|
||||
if (
|
||||
@@ -249,11 +258,18 @@ export function McpDeploy({
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [toolName, toolDescription, parameterDescriptions, hasDeployedTools, savedValues])
|
||||
}, [toolName, toolDescription, parameterDescriptions, savedValues])
|
||||
const hasServerSelectionChanges = useMemo(
|
||||
() => !haveSameServerSelection(selectedServerIdsForForm, selectedServerIds),
|
||||
[selectedServerIdsForForm, selectedServerIds]
|
||||
)
|
||||
const hasChanges =
|
||||
hasServerSelectionChanges ||
|
||||
(hasToolConfigurationChanges && selectedServerIdsForForm.length > 0)
|
||||
|
||||
useEffect(() => {
|
||||
onCanSaveChange?.(hasChanges && hasDeployedTools && !!toolName.trim())
|
||||
}, [hasChanges, hasDeployedTools, toolName, onCanSaveChange])
|
||||
onCanSaveChange?.(hasChanges && !!toolName.trim())
|
||||
}, [hasChanges, toolName, onCanSaveChange])
|
||||
|
||||
/**
|
||||
* Save tool configuration to all deployed servers
|
||||
@@ -261,74 +277,25 @@ export function McpDeploy({
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!toolName.trim()) return
|
||||
|
||||
const toolsToUpdate: Array<{ serverId: string; toolId: string }> = []
|
||||
for (const server of servers) {
|
||||
const toolInfo = serverToolsMap[server.id]
|
||||
if (toolInfo?.tool) {
|
||||
toolsToUpdate.push({ serverId: server.id, toolId: toolInfo.tool.id })
|
||||
}
|
||||
}
|
||||
const currentIds = new Set(selectedServerIds)
|
||||
const nextIds = new Set(selectedServerIdsForForm)
|
||||
const toAdd = new Set(selectedServerIdsForForm.filter((id) => !currentIds.has(id)))
|
||||
const toRemove = selectedServerIds.filter((id) => !nextIds.has(id))
|
||||
const shouldUpdateExisting = hasToolConfigurationChanges
|
||||
|
||||
if (toolsToUpdate.length === 0) return
|
||||
if (toAdd.size === 0 && toRemove.length === 0 && !shouldUpdateExisting) return
|
||||
|
||||
onSubmittingChange?.(true)
|
||||
setSaveErrors([])
|
||||
try {
|
||||
for (const { serverId, toolId } of toolsToUpdate) {
|
||||
await updateToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId,
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
}
|
||||
// Update saved values after successful save (triggers re-render → hasChanges becomes false)
|
||||
setSavedValues({
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptions: { ...parameterDescriptions },
|
||||
})
|
||||
onCanSaveChange?.(false)
|
||||
onSubmittingChange?.(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to save tool configuration:', error)
|
||||
onSubmittingChange?.(false)
|
||||
}
|
||||
}, [
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptions,
|
||||
parameterSchema,
|
||||
servers,
|
||||
serverToolsMap,
|
||||
workspaceId,
|
||||
updateToolMutation,
|
||||
onSubmittingChange,
|
||||
onCanSaveChange,
|
||||
])
|
||||
|
||||
const serverOptions: ComboboxOption[] = useMemo(() => {
|
||||
return servers.map((server) => ({
|
||||
label: server.name,
|
||||
value: server.id,
|
||||
}))
|
||||
}, [servers])
|
||||
|
||||
const handleServerSelectionChange = useCallback(
|
||||
async (newSelectedIds: string[]) => {
|
||||
if (!toolName.trim()) return
|
||||
|
||||
const currentIds = new Set(selectedServerIds)
|
||||
const newIds = new Set(newSelectedIds)
|
||||
|
||||
const toAdd = newSelectedIds.filter((id) => !currentIds.has(id))
|
||||
const toRemove = selectedServerIds.filter((id) => !newIds.has(id))
|
||||
const errors: string[] = []
|
||||
const addedEntries: Record<string, { tool: WorkflowMcpTool; isLoading: boolean }> = {}
|
||||
const removedIds: string[] = []
|
||||
|
||||
for (const serverId of toAdd) {
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
try {
|
||||
await addToolMutation.mutateAsync({
|
||||
const addedTool = await addToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
workflowId,
|
||||
@@ -336,10 +303,13 @@ export function McpDeploy({
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
addedEntries[serverId] = { tool: addedTool, isLoading: false }
|
||||
onAddedToServer?.()
|
||||
logger.info(`Added workflow ${workflowId} as tool to server ${serverId}`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to add tool:', error)
|
||||
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
|
||||
errors.push(`Failed to add to ${serverName}`)
|
||||
logger.error(`Failed to add tool to server ${serverId}:`, error)
|
||||
} finally {
|
||||
setPendingServerChanges((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -351,54 +321,115 @@ export function McpDeploy({
|
||||
|
||||
for (const serverId of toRemove) {
|
||||
const toolInfo = serverToolsMap[serverId]
|
||||
if (toolInfo?.tool) {
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
if (!toolInfo?.tool) continue
|
||||
|
||||
setPendingServerChanges((prev) => new Set(prev).add(serverId))
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolInfo.tool.id,
|
||||
})
|
||||
removedIds.push(serverId)
|
||||
} catch (error) {
|
||||
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
|
||||
errors.push(`Failed to remove from ${serverName}`)
|
||||
logger.error(`Failed to remove tool from server ${serverId}:`, error)
|
||||
} finally {
|
||||
setPendingServerChanges((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUpdateExisting) {
|
||||
for (const serverId of selectedServerIdsForForm) {
|
||||
if (toAdd.has(serverId)) continue
|
||||
const toolInfo = serverToolsMap[serverId]
|
||||
if (!toolInfo?.tool) continue
|
||||
|
||||
try {
|
||||
await deleteToolMutation.mutateAsync({
|
||||
await updateToolMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId,
|
||||
toolId: toolInfo.tool.id,
|
||||
})
|
||||
setServerToolsMap((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[serverId]
|
||||
return next
|
||||
toolName: toolName.trim(),
|
||||
toolDescription: toolDescription.trim() || undefined,
|
||||
parameterSchema,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove tool:', error)
|
||||
} finally {
|
||||
setPendingServerChanges((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(serverId)
|
||||
return next
|
||||
})
|
||||
const serverName = servers.find((s) => s.id === serverId)?.name || serverId
|
||||
errors.push(`Failed to update on ${serverName}`)
|
||||
logger.error(`Failed to update tool on server ${serverId}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedServerIds,
|
||||
serverToolsMap,
|
||||
toolName,
|
||||
toolDescription,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
parameterSchema,
|
||||
addToolMutation,
|
||||
deleteToolMutation,
|
||||
onAddedToServer,
|
||||
]
|
||||
)
|
||||
|
||||
setServerToolsMap((prev) => {
|
||||
const next = { ...prev, ...addedEntries }
|
||||
for (const id of removedIds) {
|
||||
delete next[id]
|
||||
}
|
||||
return next
|
||||
})
|
||||
if (errors.length > 0) {
|
||||
setSaveErrors(errors)
|
||||
} else {
|
||||
setDraftSelectedServerIds(null)
|
||||
setSavedValues({
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptions: { ...parameterDescriptions },
|
||||
})
|
||||
onCanSaveChange?.(false)
|
||||
}
|
||||
onSubmittingChange?.(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to save tool configuration:', error)
|
||||
onSubmittingChange?.(false)
|
||||
}
|
||||
}, [
|
||||
toolName,
|
||||
toolDescription,
|
||||
parameterDescriptions,
|
||||
parameterSchema,
|
||||
selectedServerIds,
|
||||
selectedServerIdsForForm,
|
||||
hasToolConfigurationChanges,
|
||||
serverToolsMap,
|
||||
workspaceId,
|
||||
workflowId,
|
||||
servers,
|
||||
addToolMutation,
|
||||
deleteToolMutation,
|
||||
updateToolMutation,
|
||||
onAddedToServer,
|
||||
onSubmittingChange,
|
||||
onCanSaveChange,
|
||||
])
|
||||
|
||||
const serverOptions: ComboboxOption[] = useMemo(() => {
|
||||
return servers.map((server) => ({
|
||||
label: server.name,
|
||||
value: server.id,
|
||||
}))
|
||||
}, [servers])
|
||||
|
||||
const handleServerSelectionChange = useCallback((newSelectedIds: string[]) => {
|
||||
setDraftSelectedServerIds(newSelectedIds)
|
||||
}, [])
|
||||
|
||||
const selectedServersLabel = useMemo(() => {
|
||||
const count = selectedServerIds.length
|
||||
const count = selectedServerIdsForForm.length
|
||||
if (count === 0) return 'Select servers...'
|
||||
if (count === 1) {
|
||||
const server = servers.find((s) => s.id === selectedServerIds[0])
|
||||
const server = servers.find((s) => s.id === selectedServerIdsForForm[0])
|
||||
return server?.name || '1 server'
|
||||
}
|
||||
return `${count} servers selected`
|
||||
}, [selectedServerIds, servers])
|
||||
}, [selectedServerIdsForForm, servers])
|
||||
|
||||
const isPending = pendingServerChanges.size > 0
|
||||
|
||||
@@ -544,7 +575,7 @@ export function McpDeploy({
|
||||
<Combobox
|
||||
options={serverOptions}
|
||||
multiSelect
|
||||
multiSelectValues={selectedServerIds}
|
||||
multiSelectValues={selectedServerIdsForForm}
|
||||
onMultiSelectChange={handleServerSelectionChange}
|
||||
placeholder='Select servers...'
|
||||
searchable
|
||||
@@ -561,10 +592,14 @@ export function McpDeploy({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addToolMutation.isError && (
|
||||
<p className='mt-[6.5px] text-[12px] text-[var(--text-error)]'>
|
||||
{addToolMutation.error?.message || 'Failed to add tool'}
|
||||
</p>
|
||||
{saveErrors.length > 0 && (
|
||||
<div className='mt-[6.5px] flex flex-col gap-[2px]'>
|
||||
{saveErrors.map((error) => (
|
||||
<p key={error} className='text-[12px] text-[var(--text-error)]'>
|
||||
{error}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
|
||||
@@ -61,8 +61,6 @@ function ConnectionItem({
|
||||
blockId: connection.id,
|
||||
blockType: connection.type,
|
||||
mergedSubBlocks,
|
||||
responseFormat: connection.responseFormat,
|
||||
operation: connection.operation,
|
||||
triggerMode: sourceBlock?.triggerMode,
|
||||
})
|
||||
const hasFields = fields.length > 0
|
||||
|
||||
@@ -14,16 +14,11 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
extractFieldsFromSchema,
|
||||
parseResponseFormatSafely,
|
||||
} from '@/lib/core/utils/response-format'
|
||||
import {
|
||||
getBlockOutputPaths,
|
||||
getBlockOutputType,
|
||||
getEffectiveBlockOutputPaths,
|
||||
getEffectiveBlockOutputType,
|
||||
getOutputPathsFromSchema,
|
||||
getToolOutputPaths,
|
||||
getToolOutputType,
|
||||
} from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
|
||||
import { KeyboardNavigationHandler } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler'
|
||||
import type {
|
||||
@@ -214,43 +209,19 @@ const getOutputTypeForPath = (
|
||||
outputPath: string,
|
||||
mergedSubBlocksOverride?: Record<string, any>
|
||||
): string => {
|
||||
if (block?.triggerMode && blockConfig?.triggers?.enabled) {
|
||||
return getBlockOutputType(block.type, outputPath, mergedSubBlocksOverride, true)
|
||||
}
|
||||
if (block?.type === 'starter') {
|
||||
const startWorkflowValue =
|
||||
mergedSubBlocksOverride?.startWorkflow?.value ?? getSubBlockValue(blockId, 'startWorkflow')
|
||||
|
||||
if (startWorkflowValue === 'chat') {
|
||||
const chatModeTypes: Record<string, string> = {
|
||||
input: 'string',
|
||||
conversationId: 'string',
|
||||
files: 'file[]',
|
||||
}
|
||||
return chatModeTypes[outputPath] || 'any'
|
||||
}
|
||||
const inputFormatValue =
|
||||
mergedSubBlocksOverride?.inputFormat?.value ?? getSubBlockValue(blockId, 'inputFormat')
|
||||
if (inputFormatValue && Array.isArray(inputFormatValue)) {
|
||||
const field = inputFormatValue.find(
|
||||
(f: { name?: string; type?: string }) => f.name === outputPath
|
||||
)
|
||||
if (field?.type) return field.type
|
||||
}
|
||||
} else if (blockConfig?.category === 'triggers') {
|
||||
const blockState = useWorkflowStore.getState().blocks[blockId]
|
||||
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
|
||||
return getBlockOutputType(block.type, outputPath, subBlocks)
|
||||
} else if (blockConfig?.tools?.config?.tool) {
|
||||
const blockState = useWorkflowStore.getState().blocks[blockId]
|
||||
const subBlocks = mergedSubBlocksOverride ?? (blockState?.subBlocks || {})
|
||||
return getToolOutputType(blockConfig, subBlocks, outputPath)
|
||||
if (block?.type === 'variables') {
|
||||
return 'any'
|
||||
}
|
||||
|
||||
const subBlocks =
|
||||
mergedSubBlocksOverride ?? useWorkflowStore.getState().blocks[blockId]?.subBlocks
|
||||
const triggerMode = block?.triggerMode && blockConfig?.triggers?.enabled
|
||||
return getBlockOutputType(block?.type ?? '', outputPath, subBlocks, triggerMode)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const triggerMode = Boolean(block?.triggerMode && isTriggerCapable)
|
||||
|
||||
return getEffectiveBlockOutputType(block?.type ?? '', outputPath, subBlocks, {
|
||||
triggerMode,
|
||||
preferToolOutputs: !triggerMode,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1088,24 +1059,9 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
|
||||
const mergedSubBlocks = getMergedSubBlocks(activeSourceBlockId)
|
||||
const responseFormatValue = mergedSubBlocks?.responseFormat?.value
|
||||
const responseFormat = parseResponseFormatSafely(responseFormatValue, activeSourceBlockId)
|
||||
|
||||
let blockTags: string[]
|
||||
|
||||
if (sourceBlock.type === 'evaluator') {
|
||||
const metricsValue = getSubBlockValue(activeSourceBlockId, 'metrics')
|
||||
|
||||
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
|
||||
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
|
||||
blockTags = validMetrics.map(
|
||||
(metric: { name: string }) => `${normalizedBlockName}.${metric.name.toLowerCase()}`
|
||||
)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (sourceBlock.type === 'variables') {
|
||||
if (sourceBlock.type === 'variables') {
|
||||
const variablesValue = getSubBlockValue(activeSourceBlockId, 'variables')
|
||||
|
||||
if (variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0) {
|
||||
@@ -1119,106 +1075,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
} else {
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else if (responseFormat) {
|
||||
const schemaFields = extractFieldsFromSchema(responseFormat)
|
||||
if (schemaFields.length > 0) {
|
||||
blockTags = schemaFields.map((field) => `${normalizedBlockName}.${field.name}`)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(
|
||||
sourceBlock.type,
|
||||
mergedSubBlocks,
|
||||
sourceBlock.triggerMode
|
||||
)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (!blockConfig.outputs || Object.keys(blockConfig.outputs).length === 0) {
|
||||
if (sourceBlock.type === 'starter') {
|
||||
const startWorkflowValue = mergedSubBlocks?.startWorkflow?.value
|
||||
|
||||
if (startWorkflowValue === 'chat') {
|
||||
blockTags = [
|
||||
`${normalizedBlockName}.input`,
|
||||
`${normalizedBlockName}.conversationId`,
|
||||
`${normalizedBlockName}.files`,
|
||||
]
|
||||
} else {
|
||||
const inputFormatValue = mergedSubBlocks?.inputFormat?.value
|
||||
|
||||
if (
|
||||
inputFormatValue &&
|
||||
Array.isArray(inputFormatValue) &&
|
||||
inputFormatValue.length > 0
|
||||
) {
|
||||
blockTags = inputFormatValue
|
||||
.filter((field: { name?: string }) => field.name && field.name.trim() !== '')
|
||||
.map((field: { name: string }) => `${normalizedBlockName}.${field.name}`)
|
||||
} else {
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
}
|
||||
} else if (sourceBlock.type === 'api_trigger' || sourceBlock.type === 'input_trigger') {
|
||||
const inputFormatValue = mergedSubBlocks?.inputFormat?.value
|
||||
|
||||
if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
blockTags = inputFormatValue
|
||||
.filter((field: { name?: string }) => field.name && field.name.trim() !== '')
|
||||
.map((field: { name: string }) => `${normalizedBlockName}.${field.name}`)
|
||||
} else {
|
||||
blockTags = []
|
||||
}
|
||||
} else {
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else {
|
||||
if (blockConfig.category === 'triggers' || sourceBlock.type === 'starter') {
|
||||
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
if (dynamicOutputs.length > 0) {
|
||||
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else if (sourceBlock.type === 'starter') {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else if (sourceBlock.type === TRIGGER_TYPES.GENERIC_WEBHOOK) {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else {
|
||||
blockTags = []
|
||||
}
|
||||
} else if (sourceBlock?.triggerMode && blockConfig.triggers?.enabled) {
|
||||
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks, true)
|
||||
if (dynamicOutputs.length > 0) {
|
||||
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks, true)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (sourceBlock.type === 'human_in_the_loop') {
|
||||
const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
const sourceBlockConfig = getBlock(sourceBlock.type)
|
||||
const isTriggerCapable = sourceBlockConfig ? hasTriggerCapability(sourceBlockConfig) : false
|
||||
const effectiveTriggerMode = Boolean(sourceBlock.triggerMode && isTriggerCapable)
|
||||
const outputPaths = getEffectiveBlockOutputPaths(sourceBlock.type, mergedSubBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
})
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
|
||||
const isSelfReference = activeSourceBlockId === blockId
|
||||
|
||||
if (dynamicOutputs.length > 0) {
|
||||
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks)
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
}
|
||||
if (sourceBlock.type === 'human_in_the_loop' && activeSourceBlockId === blockId) {
|
||||
blockTags = allTags.filter(
|
||||
(tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint')
|
||||
)
|
||||
} else if (allTags.length === 0) {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else {
|
||||
const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks)
|
||||
|
||||
if (toolOutputPaths.length > 0) {
|
||||
blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(
|
||||
sourceBlock.type,
|
||||
mergedSubBlocks,
|
||||
sourceBlock.triggerMode
|
||||
)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
blockTags = allTags
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1432,45 +1306,10 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
const normalizedBlockName = normalizeName(blockName)
|
||||
|
||||
const mergedSubBlocks = getMergedSubBlocks(accessibleBlockId)
|
||||
const responseFormatValue = mergedSubBlocks?.responseFormat?.value
|
||||
const responseFormat = parseResponseFormatSafely(responseFormatValue, accessibleBlockId)
|
||||
|
||||
let blockTags: string[]
|
||||
|
||||
if (blockConfig.category === 'triggers' || accessibleBlock.type === 'starter') {
|
||||
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
|
||||
|
||||
if (dynamicOutputs.length > 0) {
|
||||
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else if (accessibleBlock.type === 'starter') {
|
||||
const startWorkflowValue = mergedSubBlocks?.startWorkflow?.value
|
||||
if (startWorkflowValue === 'chat') {
|
||||
blockTags = [
|
||||
`${normalizedBlockName}.input`,
|
||||
`${normalizedBlockName}.conversationId`,
|
||||
`${normalizedBlockName}.files`,
|
||||
]
|
||||
} else {
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else if (accessibleBlock.type === TRIGGER_TYPES.GENERIC_WEBHOOK) {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else {
|
||||
blockTags = []
|
||||
}
|
||||
} else if (accessibleBlock.type === 'evaluator') {
|
||||
const metricsValue = getSubBlockValue(accessibleBlockId, 'metrics')
|
||||
|
||||
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
|
||||
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
|
||||
blockTags = validMetrics.map(
|
||||
(metric: { name: string }) => `${normalizedBlockName}.${metric.name.toLowerCase()}`
|
||||
)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (accessibleBlock.type === 'variables') {
|
||||
if (accessibleBlock.type === 'variables') {
|
||||
const variablesValue = getSubBlockValue(accessibleBlockId, 'variables')
|
||||
|
||||
if (variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0) {
|
||||
@@ -1484,57 +1323,26 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
} else {
|
||||
blockTags = [normalizedBlockName]
|
||||
}
|
||||
} else if (responseFormat) {
|
||||
const schemaFields = extractFieldsFromSchema(responseFormat)
|
||||
if (schemaFields.length > 0) {
|
||||
blockTags = schemaFields.map((field) => `${normalizedBlockName}.${field.name}`)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(
|
||||
accessibleBlock.type,
|
||||
mergedSubBlocks,
|
||||
accessibleBlock.triggerMode
|
||||
)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (!blockConfig.outputs || Object.keys(blockConfig.outputs).length === 0) {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else {
|
||||
const blockState = blocks[accessibleBlockId]
|
||||
if (blockState?.triggerMode && blockConfig.triggers?.enabled) {
|
||||
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, true)
|
||||
if (dynamicOutputs.length > 0) {
|
||||
blockTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, true)
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
} else if (accessibleBlock.type === 'human_in_the_loop') {
|
||||
const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks)
|
||||
const accessibleBlockConfig = getBlock(accessibleBlock.type)
|
||||
const isTriggerCapable = accessibleBlockConfig
|
||||
? hasTriggerCapability(accessibleBlockConfig)
|
||||
: false
|
||||
const effectiveTriggerMode = Boolean(accessibleBlock.triggerMode && isTriggerCapable)
|
||||
const outputPaths = getEffectiveBlockOutputPaths(accessibleBlock.type, mergedSubBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
})
|
||||
const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
|
||||
const isSelfReference = accessibleBlockId === blockId
|
||||
|
||||
if (dynamicOutputs.length > 0) {
|
||||
const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`)
|
||||
blockTags = isSelfReference
|
||||
? allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint'))
|
||||
: allTags
|
||||
} else {
|
||||
blockTags = [`${normalizedBlockName}.url`, `${normalizedBlockName}.resumeEndpoint`]
|
||||
}
|
||||
if (accessibleBlock.type === 'human_in_the_loop' && accessibleBlockId === blockId) {
|
||||
blockTags = allTags.filter(
|
||||
(tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint')
|
||||
)
|
||||
} else if (allTags.length === 0) {
|
||||
blockTags = [normalizedBlockName]
|
||||
} else {
|
||||
const toolOutputPaths = getToolOutputPaths(blockConfig, mergedSubBlocks)
|
||||
|
||||
if (toolOutputPaths.length > 0) {
|
||||
blockTags = toolOutputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
} else {
|
||||
const outputPaths = getBlockOutputPaths(
|
||||
accessibleBlock.type,
|
||||
mergedSubBlocks,
|
||||
accessibleBlock.triggerMode
|
||||
)
|
||||
|
||||
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
|
||||
}
|
||||
blockTags = allTags
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ToolCredentialSelector({
|
||||
disabled = false,
|
||||
}: ToolCredentialSelectorProps) {
|
||||
const [showOAuthModal, setShowOAuthModal] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [editingInputValue, setEditingInputValue] = useState('')
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
@@ -100,11 +100,7 @@ export function ToolCredentialSelector({
|
||||
return ''
|
||||
}, [selectedCredential, isForeign])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setInputValue(resolvedLabel)
|
||||
}
|
||||
}, [resolvedLabel, isEditing])
|
||||
const inputValue = isEditing ? editingInputValue : resolvedLabel
|
||||
|
||||
const invalidSelection =
|
||||
Boolean(selectedId) &&
|
||||
@@ -189,13 +185,12 @@ export function ToolCredentialSelector({
|
||||
|
||||
const matchedCred = credentials.find((c) => c.id === newValue)
|
||||
if (matchedCred) {
|
||||
setInputValue(matchedCred.name)
|
||||
handleSelect(newValue)
|
||||
return
|
||||
}
|
||||
|
||||
setIsEditing(true)
|
||||
setInputValue(newValue)
|
||||
setEditingInputValue(newValue)
|
||||
},
|
||||
[credentials, handleAddCredential, handleSelect]
|
||||
)
|
||||
|
||||
@@ -2642,7 +2642,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
</div>
|
||||
|
||||
{!isCustomTool && isExpandedForDisplay && (
|
||||
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t px-[8px] py-[8px]'>
|
||||
<div className='flex flex-col gap-[10px] overflow-visible rounded-b-[4px] border-[var(--border-1)] border-t bg-[var(--surface-2)] px-[8px] py-[8px]'>
|
||||
{/* Operation dropdown for tools with multiple operations */}
|
||||
{(() => {
|
||||
const hasOperations = hasMultipleOperations(tool.type)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
extractFieldsFromSchema,
|
||||
parseResponseFormatSafely,
|
||||
} from '@/lib/core/utils/response-format'
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -20,18 +18,7 @@ export interface ConnectedBlock {
|
||||
type: string
|
||||
outputType: string | string[]
|
||||
name: string
|
||||
responseFormat?: {
|
||||
// Support both formats
|
||||
fields?: Field[]
|
||||
name?: string
|
||||
schema?: {
|
||||
type: string
|
||||
properties: Record<string, any>
|
||||
required?: string[]
|
||||
}
|
||||
}
|
||||
outputs?: Record<string, any>
|
||||
operation?: string
|
||||
}
|
||||
|
||||
export function useBlockConnections(blockId: string) {
|
||||
@@ -102,47 +89,32 @@ export function useBlockConnections(blockId: string) {
|
||||
|
||||
// Get merged subblocks for this source block
|
||||
const mergedSubBlocks = getMergedSubBlocks(sourceId)
|
||||
const blockConfig = getBlock(sourceBlock.type)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const effectiveTriggerMode = Boolean(sourceBlock.triggerMode && isTriggerCapable)
|
||||
|
||||
// Get the response format from the subblock store
|
||||
const responseFormatValue = useSubBlockStore.getState().getValue(sourceId, 'responseFormat')
|
||||
const blockOutputs = getEffectiveBlockOutputs(sourceBlock.type, mergedSubBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
})
|
||||
|
||||
// Safely parse response format with proper error handling
|
||||
const responseFormat = parseResponseFormatSafely(responseFormatValue, sourceId)
|
||||
|
||||
// Get operation value for tool-based blocks
|
||||
const operationValue = useSubBlockStore.getState().getValue(sourceId, 'operation')
|
||||
|
||||
// Use getBlockOutputs to properly handle dynamic outputs from inputFormat
|
||||
const blockOutputs = getBlockOutputs(
|
||||
sourceBlock.type,
|
||||
mergedSubBlocks,
|
||||
sourceBlock.triggerMode
|
||||
)
|
||||
|
||||
// Extract fields from the response format if available, otherwise use block outputs
|
||||
let outputFields: Field[]
|
||||
if (responseFormat) {
|
||||
outputFields = extractFieldsFromSchema(responseFormat)
|
||||
} else {
|
||||
// Convert block outputs to field format
|
||||
outputFields = Object.entries(blockOutputs).map(([key, value]: [string, any]) => ({
|
||||
const outputFields: Field[] = Object.entries(blockOutputs).map(
|
||||
([key, value]: [string, any]) => ({
|
||||
name: key,
|
||||
type: value && typeof value === 'object' && 'type' in value ? value.type : 'string',
|
||||
description:
|
||||
value && typeof value === 'object' && 'description' in value
|
||||
? value.description
|
||||
: undefined,
|
||||
}))
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
id: sourceBlock.id,
|
||||
type: sourceBlock.type,
|
||||
outputType: outputFields.map((field: Field) => field.name),
|
||||
name: sourceBlock.name,
|
||||
responseFormat,
|
||||
outputs: blockOutputs,
|
||||
operation: operationValue,
|
||||
distance: nodeDistances.get(sourceId) || Number.POSITIVE_INFINITY,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { DiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { hasDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useIsBlockActive } from '@/stores/execution'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
import type { CurrentWorkflow } from '../../../hooks/use-current-workflow'
|
||||
import type { WorkflowBlockProps } from '../types'
|
||||
@@ -67,7 +67,7 @@ export function useBlockState(
|
||||
const isDeletedBlock = !isShowingDiff && diffAnalysis?.deleted_blocks?.includes(blockId)
|
||||
|
||||
// Execution state
|
||||
const isActiveBlock = useExecutionStore((state) => state.activeBlockIds.has(blockId))
|
||||
const isActiveBlock = useIsBlockActive(blockId)
|
||||
const isActive = data.isActive || isActiveBlock
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { X } from 'lucide-react'
|
||||
import { BaseEdge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath } from 'reactflow'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { EdgeDiffStatus } from '@/lib/workflows/diff/types'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useLastRunEdges } from '@/stores/execution'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
|
||||
|
||||
/** Extended edge props with optional handle identifiers */
|
||||
@@ -49,7 +49,7 @@ const WorkflowEdgeComponent = ({
|
||||
isDiffReady: state.isDiffReady,
|
||||
}))
|
||||
)
|
||||
const lastRunEdges = useExecutionStore((state) => state.lastRunEdges)
|
||||
const lastRunEdges = useLastRunEdges()
|
||||
|
||||
const dataSourceHandle = (data as { sourceHandle?: string } | undefined)?.sourceHandle
|
||||
const isErrorEdge = (sourceHandle ?? dataSourceHandle) === 'error'
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { extractFieldsFromSchema } from '@/lib/core/utils/response-format'
|
||||
import {
|
||||
getBlockOutputPaths,
|
||||
getBlockOutputs,
|
||||
getToolOutputs,
|
||||
} from '@/lib/workflows/blocks/block-outputs'
|
||||
import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import type { SchemaField } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/connection-blocks/components/field-item/field-item'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
@@ -76,11 +71,7 @@ const extractNestedFields = (properties: Record<string, any>): SchemaField[] =>
|
||||
/**
|
||||
* Creates a schema field from an output definition
|
||||
*/
|
||||
const createFieldFromOutput = (
|
||||
name: string,
|
||||
output: any,
|
||||
responseFormatFields?: SchemaField[]
|
||||
): SchemaField => {
|
||||
const createFieldFromOutput = (name: string, output: any): SchemaField => {
|
||||
const hasExplicitType = isObject(output) && typeof output.type === 'string'
|
||||
const type = hasExplicitType ? output.type : isObject(output) ? 'object' : 'string'
|
||||
|
||||
@@ -90,11 +81,7 @@ const createFieldFromOutput = (
|
||||
description: isObject(output) && 'description' in output ? output.description : undefined,
|
||||
}
|
||||
|
||||
if (name === 'data' && responseFormatFields && responseFormatFields.length > 0) {
|
||||
field.children = responseFormatFields
|
||||
} else {
|
||||
field.children = extractChildFields(output)
|
||||
}
|
||||
field.children = extractChildFields(output)
|
||||
|
||||
return field
|
||||
}
|
||||
@@ -103,8 +90,6 @@ interface UseBlockOutputFieldsParams {
|
||||
blockId: string
|
||||
blockType: string
|
||||
mergedSubBlocks?: Record<string, any>
|
||||
responseFormat?: any
|
||||
operation?: string
|
||||
triggerMode?: boolean
|
||||
}
|
||||
|
||||
@@ -116,8 +101,6 @@ export function useBlockOutputFields({
|
||||
blockId,
|
||||
blockType,
|
||||
mergedSubBlocks,
|
||||
responseFormat,
|
||||
operation,
|
||||
triggerMode,
|
||||
}: UseBlockOutputFieldsParams): SchemaField[] {
|
||||
return useMemo(() => {
|
||||
@@ -138,21 +121,6 @@ export function useBlockOutputFields({
|
||||
return []
|
||||
}
|
||||
|
||||
// Handle evaluator blocks - use metrics if available
|
||||
if (blockType === 'evaluator') {
|
||||
const metricsValue = mergedSubBlocks?.metrics?.value ?? getSubBlockValue(blockId, 'metrics')
|
||||
|
||||
if (metricsValue && Array.isArray(metricsValue) && metricsValue.length > 0) {
|
||||
const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name)
|
||||
return validMetrics.map((metric: { name: string }) => ({
|
||||
name: metric.name.toLowerCase(),
|
||||
type: 'number',
|
||||
description: `Metric: ${metric.name}`,
|
||||
}))
|
||||
}
|
||||
// Fall through to use blockConfig.outputs
|
||||
}
|
||||
|
||||
// Handle variables blocks - use variable assignments if available
|
||||
if (blockType === 'variables') {
|
||||
const variablesValue =
|
||||
@@ -172,123 +140,16 @@ export function useBlockOutputFields({
|
||||
return []
|
||||
}
|
||||
|
||||
// Get base outputs using getBlockOutputs (handles triggers, starter, approval, etc.)
|
||||
let baseOutputs: Record<string, any> = {}
|
||||
|
||||
if (blockConfig.category === 'triggers' || blockType === 'starter') {
|
||||
// Use getBlockOutputPaths to get dynamic outputs, then reconstruct the structure
|
||||
const outputPaths = getBlockOutputPaths(blockType, mergedSubBlocks, triggerMode)
|
||||
if (outputPaths.length > 0) {
|
||||
// Reconstruct outputs structure from paths
|
||||
// This is a simplified approach - we'll use the paths to build the structure
|
||||
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks, triggerMode)
|
||||
} else if (blockType === 'starter') {
|
||||
const startWorkflowValue = mergedSubBlocks?.startWorkflow?.value
|
||||
if (startWorkflowValue === 'chat') {
|
||||
baseOutputs = {
|
||||
input: { type: 'string', description: 'User message' },
|
||||
conversationId: { type: 'string', description: 'Conversation ID' },
|
||||
files: { type: 'file[]', description: 'Uploaded files' },
|
||||
}
|
||||
} else {
|
||||
const inputFormatValue = mergedSubBlocks?.inputFormat?.value
|
||||
if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
|
||||
baseOutputs = {}
|
||||
inputFormatValue.forEach((field: { name?: string; type?: string }) => {
|
||||
if (field.name && field.name.trim() !== '') {
|
||||
baseOutputs[field.name] = {
|
||||
type: field.type || 'string',
|
||||
description: `Field from input format`,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (blockType === TRIGGER_TYPES.GENERIC_WEBHOOK) {
|
||||
// Generic webhook returns the whole payload
|
||||
baseOutputs = {}
|
||||
} else {
|
||||
baseOutputs = {}
|
||||
}
|
||||
} else if (triggerMode && blockConfig.triggers?.enabled) {
|
||||
// Trigger mode enabled
|
||||
const dynamicOutputs = getBlockOutputPaths(blockType, mergedSubBlocks, true)
|
||||
if (dynamicOutputs.length > 0) {
|
||||
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks, true)
|
||||
} else {
|
||||
baseOutputs = blockConfig.outputs || {}
|
||||
}
|
||||
} else if (blockType === 'approval') {
|
||||
// Approval block uses dynamic outputs from inputFormat
|
||||
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks)
|
||||
} else {
|
||||
// For tool-based blocks, try to get tool outputs first
|
||||
const toolOutputs = blockConfig ? getToolOutputs(blockConfig, mergedSubBlocks) : {}
|
||||
|
||||
if (Object.keys(toolOutputs).length > 0) {
|
||||
baseOutputs = toolOutputs
|
||||
} else {
|
||||
baseOutputs = getBlockOutputs(blockType, mergedSubBlocks, triggerMode)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle responseFormat
|
||||
const responseFormatFields = responseFormat ? extractFieldsFromSchema(responseFormat) : []
|
||||
|
||||
// If responseFormat exists and has fields, merge with base outputs
|
||||
if (responseFormatFields.length > 0) {
|
||||
// If base outputs is empty, use responseFormat fields directly
|
||||
if (Object.keys(baseOutputs).length === 0) {
|
||||
return responseFormatFields.map((field) => ({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
description: field.description,
|
||||
children: undefined, // ResponseFormat fields are flat
|
||||
}))
|
||||
}
|
||||
|
||||
// Otherwise, merge: responseFormat takes precedence for 'data' field
|
||||
const fields: SchemaField[] = []
|
||||
const responseFormatFieldNames = new Set(responseFormatFields.map((f) => f.name))
|
||||
|
||||
// Add base outputs, replacing 'data' with responseFormat fields if present
|
||||
for (const [name, output] of Object.entries(baseOutputs)) {
|
||||
if (name === 'data' && responseFormatFields.length > 0) {
|
||||
fields.push(
|
||||
createFieldFromOutput(
|
||||
name,
|
||||
output,
|
||||
responseFormatFields.map((f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
description: f.description,
|
||||
}))
|
||||
)
|
||||
)
|
||||
} else if (!responseFormatFieldNames.has(name)) {
|
||||
fields.push(createFieldFromOutput(name, output))
|
||||
}
|
||||
}
|
||||
|
||||
// Add responseFormat fields that aren't in base outputs
|
||||
for (const field of responseFormatFields) {
|
||||
if (!baseOutputs[field.name]) {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
description: field.description,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// No responseFormat, just use base outputs
|
||||
const isTriggerCapable = hasTriggerCapability(blockConfig)
|
||||
const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable)
|
||||
const baseOutputs = getEffectiveBlockOutputs(blockType, mergedSubBlocks, {
|
||||
triggerMode: effectiveTriggerMode,
|
||||
preferToolOutputs: !effectiveTriggerMode,
|
||||
}) as Record<string, any>
|
||||
if (Object.keys(baseOutputs).length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Object.entries(baseOutputs).map(([name, output]) => createFieldFromOutput(name, output))
|
||||
}, [blockId, blockType, mergedSubBlocks, responseFormat, operation, triggerMode])
|
||||
}, [blockId, blockType, mergedSubBlocks, triggerMode])
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useBlockState } from '@/app/workspace/[workspaceId]/w/[workflowId]/comp
|
||||
import type { WorkflowBlockProps } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import { getBlockRingStyles } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-ring-utils'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useLastRunPath } from '@/stores/execution'
|
||||
import { usePanelEditorStore, usePanelStore } from '@/stores/panel'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
@@ -64,7 +64,7 @@ export function useBlockVisual({
|
||||
)
|
||||
const isEditorOpen = !isPreview && isThisBlockInEditor && activeTabIsEditor
|
||||
|
||||
const lastRunPath = useExecutionStore((state) => state.lastRunPath)
|
||||
const lastRunPath = useLastRunPath()
|
||||
const runPathStatus = isPreview ? undefined : lastRunPath.get(blockId)
|
||||
|
||||
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
|
||||
|
||||
@@ -34,7 +34,7 @@ import { coerceValue } from '@/executor/utils/start-block'
|
||||
import { subscriptionKeys } from '@/hooks/queries/subscription'
|
||||
import { useExecutionStream } from '@/hooks/use-execution-stream'
|
||||
import { WorkflowValidationError } from '@/serializer'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/execution'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useVariablesStore } from '@/stores/panel'
|
||||
import { useEnvironmentStore } from '@/stores/settings/environment'
|
||||
@@ -112,24 +112,19 @@ export function useWorkflowExecution() {
|
||||
useTerminalConsoleStore()
|
||||
const { getAllVariables } = useEnvironmentStore()
|
||||
const { getVariablesByWorkflowId, variables } = useVariablesStore()
|
||||
const {
|
||||
isExecuting,
|
||||
isDebugging,
|
||||
pendingBlocks,
|
||||
executor,
|
||||
debugContext,
|
||||
setIsExecuting,
|
||||
setIsDebugging,
|
||||
setPendingBlocks,
|
||||
setExecutor,
|
||||
setDebugContext,
|
||||
setActiveBlocks,
|
||||
setBlockRunStatus,
|
||||
setEdgeRunStatus,
|
||||
setLastExecutionSnapshot,
|
||||
getLastExecutionSnapshot,
|
||||
clearLastExecutionSnapshot,
|
||||
} = useExecutionStore()
|
||||
const { isExecuting, isDebugging, pendingBlocks, executor, debugContext } =
|
||||
useCurrentWorkflowExecution()
|
||||
const setIsExecuting = useExecutionStore((s) => s.setIsExecuting)
|
||||
const setIsDebugging = useExecutionStore((s) => s.setIsDebugging)
|
||||
const setPendingBlocks = useExecutionStore((s) => s.setPendingBlocks)
|
||||
const setExecutor = useExecutionStore((s) => s.setExecutor)
|
||||
const setDebugContext = useExecutionStore((s) => s.setDebugContext)
|
||||
const setActiveBlocks = useExecutionStore((s) => s.setActiveBlocks)
|
||||
const setBlockRunStatus = useExecutionStore((s) => s.setBlockRunStatus)
|
||||
const setEdgeRunStatus = useExecutionStore((s) => s.setEdgeRunStatus)
|
||||
const setLastExecutionSnapshot = useExecutionStore((s) => s.setLastExecutionSnapshot)
|
||||
const getLastExecutionSnapshot = useExecutionStore((s) => s.getLastExecutionSnapshot)
|
||||
const clearLastExecutionSnapshot = useExecutionStore((s) => s.clearLastExecutionSnapshot)
|
||||
const [executionResult, setExecutionResult] = useState<ExecutionResult | null>(null)
|
||||
const executionStream = useExecutionStream()
|
||||
const currentChatExecutionIdRef = useRef<string | null>(null)
|
||||
@@ -158,13 +153,15 @@ export function useWorkflowExecution() {
|
||||
* Resets all debug-related state
|
||||
*/
|
||||
const resetDebugState = useCallback(() => {
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setDebugContext(null)
|
||||
setExecutor(null)
|
||||
setPendingBlocks([])
|
||||
setActiveBlocks(new Set())
|
||||
if (!activeWorkflowId) return
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setIsDebugging(activeWorkflowId, false)
|
||||
setDebugContext(activeWorkflowId, null)
|
||||
setExecutor(activeWorkflowId, null)
|
||||
setPendingBlocks(activeWorkflowId, [])
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
}, [
|
||||
activeWorkflowId,
|
||||
setIsExecuting,
|
||||
setIsDebugging,
|
||||
setDebugContext,
|
||||
@@ -312,18 +309,20 @@ export function useWorkflowExecution() {
|
||||
} = config
|
||||
|
||||
const updateActiveBlocks = (blockId: string, isActive: boolean) => {
|
||||
if (!workflowId) return
|
||||
if (isActive) {
|
||||
activeBlocksSet.add(blockId)
|
||||
} else {
|
||||
activeBlocksSet.delete(blockId)
|
||||
}
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
setActiveBlocks(workflowId, new Set(activeBlocksSet))
|
||||
}
|
||||
|
||||
const markIncomingEdges = (blockId: string) => {
|
||||
if (!workflowId) return
|
||||
const incomingEdges = workflowEdges.filter((edge) => edge.target === blockId)
|
||||
incomingEdges.forEach((edge) => {
|
||||
setEdgeRunStatus(edge.id, 'success')
|
||||
setEdgeRunStatus(workflowId, edge.id, 'success')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -459,7 +458,7 @@ export function useWorkflowExecution() {
|
||||
|
||||
const onBlockCompleted = (data: BlockCompletedData) => {
|
||||
updateActiveBlocks(data.blockId, false)
|
||||
setBlockRunStatus(data.blockId, 'success')
|
||||
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'success')
|
||||
|
||||
executedBlockIds.add(data.blockId)
|
||||
accumulatedBlockStates.set(data.blockId, {
|
||||
@@ -489,7 +488,7 @@ export function useWorkflowExecution() {
|
||||
|
||||
const onBlockError = (data: BlockErrorData) => {
|
||||
updateActiveBlocks(data.blockId, false)
|
||||
setBlockRunStatus(data.blockId, 'error')
|
||||
if (workflowId) setBlockRunStatus(workflowId, data.blockId, 'error')
|
||||
|
||||
executedBlockIds.add(data.blockId)
|
||||
accumulatedBlockStates.set(data.blockId, {
|
||||
@@ -547,19 +546,20 @@ export function useWorkflowExecution() {
|
||||
*/
|
||||
const handleDebugSessionContinuation = useCallback(
|
||||
(result: ExecutionResult) => {
|
||||
if (!activeWorkflowId) return
|
||||
logger.info('Debug step completed, next blocks pending', {
|
||||
nextPendingBlocks: result.metadata?.pendingBlocks?.length || 0,
|
||||
})
|
||||
|
||||
// Update debug context and pending blocks
|
||||
if (result.metadata?.context) {
|
||||
setDebugContext(result.metadata.context)
|
||||
setDebugContext(activeWorkflowId, result.metadata.context)
|
||||
}
|
||||
if (result.metadata?.pendingBlocks) {
|
||||
setPendingBlocks(result.metadata.pendingBlocks)
|
||||
setPendingBlocks(activeWorkflowId, result.metadata.pendingBlocks)
|
||||
}
|
||||
},
|
||||
[setDebugContext, setPendingBlocks]
|
||||
[activeWorkflowId, setDebugContext, setPendingBlocks]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -663,11 +663,11 @@ export function useWorkflowExecution() {
|
||||
|
||||
// Reset execution result and set execution state
|
||||
setExecutionResult(null)
|
||||
setIsExecuting(true)
|
||||
setIsExecuting(activeWorkflowId, true)
|
||||
|
||||
// Set debug mode only if explicitly requested
|
||||
if (enableDebug) {
|
||||
setIsDebugging(true)
|
||||
setIsDebugging(activeWorkflowId, true)
|
||||
}
|
||||
|
||||
// Determine if this is a chat execution
|
||||
@@ -965,9 +965,9 @@ export function useWorkflowExecution() {
|
||||
controller.close()
|
||||
}
|
||||
if (currentChatExecutionIdRef.current === executionId) {
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setActiveBlocks(new Set())
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setIsDebugging(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -989,16 +989,16 @@ export function useWorkflowExecution() {
|
||||
'manual'
|
||||
)
|
||||
if (result && 'metadata' in result && result.metadata?.isDebugSession) {
|
||||
setDebugContext(result.metadata.context || null)
|
||||
setDebugContext(activeWorkflowId, result.metadata.context || null)
|
||||
if (result.metadata.pendingBlocks) {
|
||||
setPendingBlocks(result.metadata.pendingBlocks)
|
||||
setPendingBlocks(activeWorkflowId, result.metadata.pendingBlocks)
|
||||
}
|
||||
} else if (result && 'success' in result) {
|
||||
setExecutionResult(result)
|
||||
// Reset execution state after successful non-debug execution
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setActiveBlocks(new Set())
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setIsDebugging(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
|
||||
if (isChatExecution) {
|
||||
if (!result.metadata) {
|
||||
@@ -1179,7 +1179,7 @@ export function useWorkflowExecution() {
|
||||
logger.error('No trigger blocks found for manual run', {
|
||||
allBlockTypes: Object.values(filteredStates).map((b) => b.type),
|
||||
})
|
||||
setIsExecuting(false)
|
||||
if (activeWorkflowId) setIsExecuting(activeWorkflowId, false)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -1195,7 +1195,7 @@ export function useWorkflowExecution() {
|
||||
'Workflow Validation'
|
||||
)
|
||||
logger.error('Multiple API triggers found')
|
||||
setIsExecuting(false)
|
||||
if (activeWorkflowId) setIsExecuting(activeWorkflowId, false)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -1220,7 +1220,7 @@ export function useWorkflowExecution() {
|
||||
'Workflow Validation'
|
||||
)
|
||||
logger.error('Trigger has no outgoing connections', { triggerName, startBlockId })
|
||||
setIsExecuting(false)
|
||||
if (activeWorkflowId) setIsExecuting(activeWorkflowId, false)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1251,7 +1251,7 @@ export function useWorkflowExecution() {
|
||||
'Workflow Validation'
|
||||
)
|
||||
logger.error('No startBlockId found after trigger search')
|
||||
setIsExecuting(false)
|
||||
if (activeWorkflowId) setIsExecuting(activeWorkflowId, false)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -1457,8 +1457,10 @@ export function useWorkflowExecution() {
|
||||
logger.info('Execution aborted by user')
|
||||
|
||||
// Reset execution state
|
||||
setIsExecuting(false)
|
||||
setActiveBlocks(new Set())
|
||||
if (activeWorkflowId) {
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
}
|
||||
|
||||
// Return gracefully without error
|
||||
return {
|
||||
@@ -1533,9 +1535,11 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
|
||||
setExecutionResult(errorResult)
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setActiveBlocks(new Set())
|
||||
if (activeWorkflowId) {
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setIsDebugging(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
}
|
||||
|
||||
let notificationMessage = WORKFLOW_EXECUTION_FAILURE_MESSAGE
|
||||
if (isRecord(error) && isRecord(error.request) && sanitizeMessage(error.request.url)) {
|
||||
@@ -1706,8 +1710,8 @@ export function useWorkflowExecution() {
|
||||
const handleCancelExecution = useCallback(() => {
|
||||
logger.info('Workflow execution cancellation requested')
|
||||
|
||||
// Cancel the execution stream (server-side)
|
||||
executionStream.cancel()
|
||||
// Cancel the execution stream for this workflow (server-side)
|
||||
executionStream.cancel(activeWorkflowId ?? undefined)
|
||||
|
||||
// Mark current chat execution as superseded so its cleanup won't affect new executions
|
||||
currentChatExecutionIdRef.current = null
|
||||
@@ -1715,12 +1719,12 @@ export function useWorkflowExecution() {
|
||||
// Mark all running entries as canceled in the terminal
|
||||
if (activeWorkflowId) {
|
||||
cancelRunningEntries(activeWorkflowId)
|
||||
}
|
||||
|
||||
// Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setActiveBlocks(new Set())
|
||||
// Reset execution state - this triggers chat stream cleanup via useEffect in chat.tsx
|
||||
setIsExecuting(activeWorkflowId, false)
|
||||
setIsDebugging(activeWorkflowId, false)
|
||||
setActiveBlocks(activeWorkflowId, new Set())
|
||||
}
|
||||
|
||||
// If in debug mode, also reset debug state
|
||||
if (isDebugging) {
|
||||
@@ -1833,7 +1837,7 @@ export function useWorkflowExecution() {
|
||||
}
|
||||
}
|
||||
|
||||
setIsExecuting(true)
|
||||
setIsExecuting(workflowId, true)
|
||||
const executionId = uuidv4()
|
||||
const accumulatedBlockLogs: BlockLog[] = []
|
||||
const accumulatedBlockStates = new Map<string, BlockState>()
|
||||
@@ -1929,8 +1933,8 @@ export function useWorkflowExecution() {
|
||||
logger.error('Run-from-block failed:', error)
|
||||
}
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
setActiveBlocks(new Set())
|
||||
setIsExecuting(workflowId, false)
|
||||
setActiveBlocks(workflowId, new Set())
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -1962,7 +1966,7 @@ export function useWorkflowExecution() {
|
||||
logger.info('Starting run-until-block execution', { workflowId, stopAfterBlockId: blockId })
|
||||
|
||||
setExecutionResult(null)
|
||||
setIsExecuting(true)
|
||||
setIsExecuting(workflowId, true)
|
||||
|
||||
const executionId = uuidv4()
|
||||
try {
|
||||
@@ -1981,9 +1985,9 @@ export function useWorkflowExecution() {
|
||||
const errorResult = handleExecutionError(error, { executionId })
|
||||
return errorResult
|
||||
} finally {
|
||||
setIsExecuting(false)
|
||||
setIsDebugging(false)
|
||||
setActiveBlocks(new Set())
|
||||
setIsExecuting(workflowId, false)
|
||||
setIsDebugging(workflowId, false)
|
||||
setActiveBlocks(workflowId, new Set())
|
||||
}
|
||||
},
|
||||
[activeWorkflowId, setExecutionResult, setIsExecuting, setIsDebugging, setActiveBlocks]
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function executeWorkflowWithFullLogging(
|
||||
const executionId = options.executionId || uuidv4()
|
||||
const { addConsole } = useTerminalConsoleStore.getState()
|
||||
const { setActiveBlocks, setBlockRunStatus, setEdgeRunStatus } = useExecutionStore.getState()
|
||||
const wfId = activeWorkflowId
|
||||
const workflowEdges = useWorkflowStore.getState().edges
|
||||
|
||||
const activeBlocksSet = new Set<string>()
|
||||
@@ -103,22 +104,22 @@ export async function executeWorkflowWithFullLogging(
|
||||
switch (event.type) {
|
||||
case 'block:started': {
|
||||
activeBlocksSet.add(event.data.blockId)
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
|
||||
const incomingEdges = workflowEdges.filter(
|
||||
(edge) => edge.target === event.data.blockId
|
||||
)
|
||||
incomingEdges.forEach((edge) => {
|
||||
setEdgeRunStatus(edge.id, 'success')
|
||||
setEdgeRunStatus(wfId, edge.id, 'success')
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'block:completed':
|
||||
activeBlocksSet.delete(event.data.blockId)
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
|
||||
setBlockRunStatus(event.data.blockId, 'success')
|
||||
setBlockRunStatus(wfId, event.data.blockId, 'success')
|
||||
|
||||
addConsole({
|
||||
input: event.data.input || {},
|
||||
@@ -145,9 +146,9 @@ export async function executeWorkflowWithFullLogging(
|
||||
|
||||
case 'block:error':
|
||||
activeBlocksSet.delete(event.data.blockId)
|
||||
setActiveBlocks(new Set(activeBlocksSet))
|
||||
setActiveBlocks(wfId, new Set(activeBlocksSet))
|
||||
|
||||
setBlockRunStatus(event.data.blockId, 'error')
|
||||
setBlockRunStatus(wfId, event.data.blockId, 'error')
|
||||
|
||||
addConsole({
|
||||
input: event.data.input || {},
|
||||
@@ -192,7 +193,7 @@ export async function executeWorkflowWithFullLogging(
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
setActiveBlocks(new Set())
|
||||
setActiveBlocks(wfId, new Set())
|
||||
}
|
||||
|
||||
return executionResult
|
||||
|
||||
@@ -74,7 +74,7 @@ import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
|
||||
import { useCanvasModeStore } from '@/stores/canvas-mode'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import { useCopilotTrainingStore } from '@/stores/copilot-training/store'
|
||||
import { useExecutionStore } from '@/stores/execution'
|
||||
import { defaultWorkflowExecutionState, useExecutionStore } from '@/stores/execution'
|
||||
import { useSearchModalStore } from '@/stores/modals/search/store'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useCopilotStore, usePanelEditorStore } from '@/stores/panel'
|
||||
@@ -740,16 +740,18 @@ const WorkflowContent = React.memo(() => {
|
||||
[collaborativeBatchAddBlocks, setSelectedEdges, setPendingSelection]
|
||||
)
|
||||
|
||||
const { activeBlockIds, pendingBlocks, isDebugging, isExecuting, getLastExecutionSnapshot } =
|
||||
useExecutionStore(
|
||||
useShallow((state) => ({
|
||||
activeBlockIds: state.activeBlockIds,
|
||||
pendingBlocks: state.pendingBlocks,
|
||||
isDebugging: state.isDebugging,
|
||||
isExecuting: state.isExecuting,
|
||||
getLastExecutionSnapshot: state.getLastExecutionSnapshot,
|
||||
}))
|
||||
)
|
||||
const { activeBlockIds, pendingBlocks, isDebugging, isExecuting } = useExecutionStore(
|
||||
useShallow((state) => {
|
||||
const wf = activeWorkflowId ? state.workflowExecutions.get(activeWorkflowId) : undefined
|
||||
return {
|
||||
activeBlockIds: wf?.activeBlockIds ?? defaultWorkflowExecutionState.activeBlockIds,
|
||||
pendingBlocks: wf?.pendingBlocks ?? defaultWorkflowExecutionState.pendingBlocks,
|
||||
isDebugging: wf?.isDebugging ?? false,
|
||||
isExecuting: wf?.isExecuting ?? false,
|
||||
}
|
||||
})
|
||||
)
|
||||
const getLastExecutionSnapshot = useExecutionStore((s) => s.getLastExecutionSnapshot)
|
||||
|
||||
const [dragStartParentId, setDragStartParentId] = useState<string | null>(null)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
|
||||
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
|
||||
|
||||
@@ -103,12 +102,7 @@ export function CustomTools() {
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]',
|
||||
isLoading && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
@@ -118,7 +112,7 @@ export function CustomTools() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
useMcpToolsQuery,
|
||||
useRefreshMcpServer,
|
||||
useStoredMcpTools,
|
||||
useUpdateMcpServer,
|
||||
} from '@/hooks/queries/mcp'
|
||||
import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -96,6 +97,8 @@ interface McpServer {
|
||||
name?: string
|
||||
transport?: string
|
||||
url?: string
|
||||
headers?: Record<string, string>
|
||||
enabled?: boolean
|
||||
connectionStatus?: 'connected' | 'disconnected' | 'error'
|
||||
lastError?: string | null
|
||||
lastConnected?: string
|
||||
@@ -378,6 +381,13 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const deleteServerMutation = useDeleteMcpServer()
|
||||
const refreshServerMutation = useRefreshMcpServer()
|
||||
const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest()
|
||||
const updateServerMutation = useUpdateMcpServer()
|
||||
const {
|
||||
testResult: editTestResult,
|
||||
isTestingConnection: isEditTestingConnection,
|
||||
testConnection: editTestConnection,
|
||||
clearTestResult: clearEditTestResult,
|
||||
} = useMcpServerTest()
|
||||
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
|
||||
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -407,6 +417,19 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const [urlScrollLeft, setUrlScrollLeft] = useState(0)
|
||||
const [headerScrollLeft, setHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [editFormData, setEditFormData] = useState<McpServerFormData>(DEFAULT_FORM_DATA)
|
||||
const [editOriginalData, setEditOriginalData] = useState<McpServerFormData>(DEFAULT_FORM_DATA)
|
||||
const [isUpdatingServer, setIsUpdatingServer] = useState(false)
|
||||
const [editSaveError, setEditSaveError] = useState<string | null>(null)
|
||||
const [editShowEnvVars, setEditShowEnvVars] = useState(false)
|
||||
const [editEnvSearchTerm, setEditEnvSearchTerm] = useState('')
|
||||
const [editCursorPosition, setEditCursorPosition] = useState(0)
|
||||
const [editActiveInputField, setEditActiveInputField] = useState<InputFieldType | null>(null)
|
||||
const [editActiveHeaderIndex, setEditActiveHeaderIndex] = useState<number | null>(null)
|
||||
const [editUrlScrollLeft, setEditUrlScrollLeft] = useState(0)
|
||||
const [editHeaderScrollLeft, setEditHeaderScrollLeft] = useState<Record<string, number>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (initialServerId && servers.some((s) => s.id === initialServerId)) {
|
||||
setSelectedServerId(initialServerId)
|
||||
@@ -757,6 +780,215 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
[refreshServerMutation, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Resets edit modal environment variable dropdown state.
|
||||
*/
|
||||
const resetEditEnvVarState = useCallback(() => {
|
||||
setEditShowEnvVars(false)
|
||||
setEditActiveInputField(null)
|
||||
setEditActiveHeaderIndex(null)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Opens the edit modal and populates form with current server data.
|
||||
*/
|
||||
const handleOpenEditModal = useCallback(
|
||||
(server: McpServer) => {
|
||||
const headers: HeaderEntry[] = server.headers
|
||||
? Object.entries(server.headers).map(([key, value]) => ({ key, value }))
|
||||
: [{ key: '', value: '' }]
|
||||
if (headers.length === 0) headers.push({ key: '', value: '' })
|
||||
|
||||
const data: McpServerFormData = {
|
||||
name: server.name || '',
|
||||
transport: (server.transport as McpTransport) || 'streamable-http',
|
||||
url: server.url || '',
|
||||
timeout: 30000,
|
||||
headers,
|
||||
}
|
||||
setEditFormData(data)
|
||||
setEditOriginalData(JSON.parse(JSON.stringify(data)))
|
||||
setShowEditModal(true)
|
||||
setEditSaveError(null)
|
||||
clearEditTestResult()
|
||||
resetEditEnvVarState()
|
||||
setEditUrlScrollLeft(0)
|
||||
setEditHeaderScrollLeft({})
|
||||
},
|
||||
[clearEditTestResult, resetEditEnvVarState]
|
||||
)
|
||||
|
||||
/**
|
||||
* Closes the edit modal and resets state.
|
||||
*/
|
||||
const handleCloseEditModal = useCallback(() => {
|
||||
setShowEditModal(false)
|
||||
setEditFormData(DEFAULT_FORM_DATA)
|
||||
setEditOriginalData(DEFAULT_FORM_DATA)
|
||||
setEditSaveError(null)
|
||||
clearEditTestResult()
|
||||
resetEditEnvVarState()
|
||||
}, [clearEditTestResult, resetEditEnvVarState])
|
||||
|
||||
/**
|
||||
* Handles environment variable selection in the edit modal.
|
||||
*/
|
||||
const handleEditEnvVarSelect = useCallback(
|
||||
(newValue: string) => {
|
||||
if (editActiveInputField === 'url') {
|
||||
setEditFormData((prev) => ({ ...prev, url: newValue }))
|
||||
} else if (editActiveHeaderIndex !== null) {
|
||||
const field = editActiveInputField === 'header-key' ? 'key' : 'value'
|
||||
const processedValue = field === 'key' ? newValue.replace(/[{}]/g, '') : newValue
|
||||
setEditFormData((prev) => {
|
||||
const newHeaders = [...(prev.headers || [])]
|
||||
if (newHeaders[editActiveHeaderIndex]) {
|
||||
newHeaders[editActiveHeaderIndex] = {
|
||||
...newHeaders[editActiveHeaderIndex],
|
||||
[field]: processedValue,
|
||||
}
|
||||
}
|
||||
return { ...prev, headers: newHeaders }
|
||||
})
|
||||
}
|
||||
resetEditEnvVarState()
|
||||
},
|
||||
[editActiveInputField, editActiveHeaderIndex, resetEditEnvVarState]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles input changes in the edit modal and manages env var dropdown.
|
||||
*/
|
||||
const handleEditInputChange = useCallback(
|
||||
(field: InputFieldType, value: string, headerIndex?: number) => {
|
||||
const input = document.activeElement as HTMLInputElement
|
||||
const pos = input?.selectionStart || 0
|
||||
setEditCursorPosition(pos)
|
||||
|
||||
if (editTestResult) {
|
||||
clearEditTestResult()
|
||||
}
|
||||
|
||||
const envVarTrigger = checkEnvVarTrigger(value, pos)
|
||||
setEditShowEnvVars(envVarTrigger.show)
|
||||
setEditEnvSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
|
||||
|
||||
if (envVarTrigger.show) {
|
||||
setEditActiveInputField(field)
|
||||
setEditActiveHeaderIndex(headerIndex ?? null)
|
||||
} else {
|
||||
resetEditEnvVarState()
|
||||
}
|
||||
|
||||
if (field === 'url') {
|
||||
setEditFormData((prev) => ({ ...prev, url: value }))
|
||||
} else if (headerIndex !== undefined) {
|
||||
const headerField = field === 'header-key' ? 'key' : 'value'
|
||||
setEditFormData((prev) => {
|
||||
const newHeaders = [...(prev.headers || [])]
|
||||
if (newHeaders[headerIndex]) {
|
||||
newHeaders[headerIndex] = { ...newHeaders[headerIndex], [headerField]: value }
|
||||
}
|
||||
return { ...prev, headers: newHeaders }
|
||||
})
|
||||
}
|
||||
},
|
||||
[editTestResult, clearEditTestResult, resetEditEnvVarState]
|
||||
)
|
||||
|
||||
const handleEditHeaderScroll = useCallback((key: string, scrollLeft: number) => {
|
||||
setEditHeaderScrollLeft((prev) => ({ ...prev, [key]: scrollLeft }))
|
||||
}, [])
|
||||
|
||||
const handleEditAddHeader = useCallback(() => {
|
||||
setEditFormData((prev) => ({
|
||||
...prev,
|
||||
headers: [...(prev.headers || []), { key: '', value: '' }],
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const handleEditRemoveHeader = useCallback((index: number) => {
|
||||
setEditFormData((prev) => ({
|
||||
...prev,
|
||||
headers: (prev.headers || []).filter((_, i) => i !== index),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Tests the connection with the edit modal's form data.
|
||||
*/
|
||||
const handleEditTestConnection = useCallback(async () => {
|
||||
if (!editFormData.name.trim() || !editFormData.url?.trim()) return
|
||||
|
||||
await editTestConnection({
|
||||
name: editFormData.name,
|
||||
transport: editFormData.transport,
|
||||
url: editFormData.url,
|
||||
headers: headersToRecord(editFormData.headers),
|
||||
timeout: editFormData.timeout,
|
||||
workspaceId,
|
||||
})
|
||||
}, [editFormData, editTestConnection, workspaceId, headersToRecord])
|
||||
|
||||
/**
|
||||
* Saves the edited MCP server after validating and testing the connection.
|
||||
*/
|
||||
const handleSaveEdit = useCallback(async () => {
|
||||
if (!selectedServerId || !editFormData.name.trim()) return
|
||||
|
||||
setEditSaveError(null)
|
||||
try {
|
||||
const headersRecord = headersToRecord(editFormData.headers)
|
||||
const serverConfig = {
|
||||
name: editFormData.name,
|
||||
transport: editFormData.transport,
|
||||
url: editFormData.url,
|
||||
headers: headersRecord,
|
||||
timeout: editFormData.timeout,
|
||||
workspaceId,
|
||||
}
|
||||
|
||||
const connectionResult = await editTestConnection(serverConfig)
|
||||
|
||||
if (!connectionResult.success) {
|
||||
setEditSaveError(connectionResult.error || 'Connection test failed')
|
||||
return
|
||||
}
|
||||
|
||||
setIsUpdatingServer(true)
|
||||
const currentServer = servers.find((s) => s.id === selectedServerId)
|
||||
await updateServerMutation.mutateAsync({
|
||||
workspaceId,
|
||||
serverId: selectedServerId,
|
||||
updates: {
|
||||
name: editFormData.name.trim(),
|
||||
transport: editFormData.transport,
|
||||
url: editFormData.url,
|
||||
headers: headersRecord,
|
||||
timeout: editFormData.timeout || 30000,
|
||||
enabled: currentServer?.enabled ?? true,
|
||||
},
|
||||
})
|
||||
|
||||
setShowEditModal(false)
|
||||
logger.info(`Updated MCP server: ${editFormData.name}`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update server'
|
||||
setEditSaveError(message)
|
||||
logger.error('Failed to update MCP server:', error)
|
||||
} finally {
|
||||
setIsUpdatingServer(false)
|
||||
}
|
||||
}, [
|
||||
selectedServerId,
|
||||
editFormData,
|
||||
editTestConnection,
|
||||
updateServerMutation,
|
||||
workspaceId,
|
||||
headersToRecord,
|
||||
servers,
|
||||
])
|
||||
|
||||
/**
|
||||
* Gets the selected server and its tools for the detail view.
|
||||
*/
|
||||
@@ -777,6 +1009,26 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
|
||||
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
|
||||
|
||||
const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim()
|
||||
const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection)
|
||||
const hasEditChanges = useMemo(() => {
|
||||
if (editFormData.name !== editOriginalData.name) return true
|
||||
if (editFormData.url !== editOriginalData.url) return true
|
||||
if (editFormData.transport !== editOriginalData.transport) return true
|
||||
|
||||
const currentHeaders = editFormData.headers || []
|
||||
const originalHeaders = editOriginalData.headers || []
|
||||
if (currentHeaders.length !== originalHeaders.length) return true
|
||||
for (let i = 0; i < currentHeaders.length; i++) {
|
||||
if (
|
||||
currentHeaders[i].key !== originalHeaders[i].key ||
|
||||
currentHeaders[i].value !== originalHeaders[i].value
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [editFormData, editOriginalData])
|
||||
|
||||
/**
|
||||
* Gets issues for stored tools that reference a specific server tool.
|
||||
* Returns issues from all workflows that have stored this tool.
|
||||
@@ -905,7 +1157,6 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
<Badge
|
||||
variant={getIssueBadgeVariant(issues[0].issue)}
|
||||
size='sm'
|
||||
className='cursor-help'
|
||||
>
|
||||
{getIssueBadgeLabel(issues[0].issue)}
|
||||
</Badge>
|
||||
@@ -991,23 +1242,135 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
</div>
|
||||
|
||||
<div className='mt-auto flex items-center justify-between'>
|
||||
<Button
|
||||
onClick={() => handleRefreshServer(server.id)}
|
||||
variant='default'
|
||||
disabled={!!refreshingServers[server.id]}
|
||||
>
|
||||
{refreshingServers[server.id]?.status === 'refreshing'
|
||||
? 'Refreshing...'
|
||||
: refreshingServers[server.id]?.status === 'refreshed'
|
||||
? refreshingServers[server.id].workflowsUpdated
|
||||
? `Synced (${refreshingServers[server.id].workflowsUpdated} workflow${refreshingServers[server.id].workflowsUpdated === 1 ? '' : 's'})`
|
||||
: 'Refreshed'
|
||||
: 'Refresh Tools'}
|
||||
</Button>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button
|
||||
onClick={() => handleRefreshServer(server.id)}
|
||||
variant='default'
|
||||
disabled={!!refreshingServers[server.id]}
|
||||
>
|
||||
{refreshingServers[server.id]?.status === 'refreshing'
|
||||
? 'Refreshing...'
|
||||
: refreshingServers[server.id]?.status === 'refreshed'
|
||||
? refreshingServers[server.id].workflowsUpdated
|
||||
? `Synced (${refreshingServers[server.id].workflowsUpdated} workflow${refreshingServers[server.id].workflowsUpdated === 1 ? '' : 's'})`
|
||||
: 'Refreshed'
|
||||
: 'Refresh Tools'}
|
||||
</Button>
|
||||
<Button onClick={() => handleOpenEditModal(server)} variant='default'>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleBackToList} variant='tertiary'>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Edit MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<FormField label='Server Name'>
|
||||
<EmcnInput
|
||||
placeholder='e.g., My MCP Server'
|
||||
value={editFormData.name}
|
||||
onChange={(e) => {
|
||||
if (editTestResult) clearEditTestResult()
|
||||
setEditFormData((prev) => ({ ...prev, name: e.target.value }))
|
||||
}}
|
||||
className='h-9'
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label='Server URL'>
|
||||
<FormattedInput
|
||||
placeholder='https://mcp.server.dev/{{YOUR_API_KEY}}/sse'
|
||||
value={editFormData.url || ''}
|
||||
scrollLeft={editUrlScrollLeft}
|
||||
showEnvVars={editShowEnvVars && editActiveInputField === 'url'}
|
||||
envVarProps={{
|
||||
searchTerm: editEnvSearchTerm,
|
||||
cursorPosition: editCursorPosition,
|
||||
workspaceId,
|
||||
onSelect: handleEditEnvVarSelect,
|
||||
onClose: resetEditEnvVarState,
|
||||
}}
|
||||
availableEnvVars={availableEnvVars}
|
||||
onChange={(e) => handleEditInputChange('url', e.target.value)}
|
||||
onScroll={setEditUrlScrollLeft}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Headers
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={handleEditAddHeader}
|
||||
className='h-6 w-6 p-0'
|
||||
>
|
||||
<Plus className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='flex max-h-[140px] flex-col gap-[8px] overflow-y-auto'>
|
||||
{(editFormData.headers || []).map((header, index) => (
|
||||
<HeaderRow
|
||||
key={index}
|
||||
header={header}
|
||||
index={index}
|
||||
headerScrollLeft={editHeaderScrollLeft}
|
||||
showEnvVars={editShowEnvVars}
|
||||
activeInputField={editActiveInputField}
|
||||
activeHeaderIndex={editActiveHeaderIndex}
|
||||
envSearchTerm={editEnvSearchTerm}
|
||||
cursorPosition={editCursorPosition}
|
||||
workspaceId={workspaceId}
|
||||
availableEnvVars={availableEnvVars}
|
||||
onInputChange={handleEditInputChange}
|
||||
onHeaderScroll={handleEditHeaderScroll}
|
||||
onEnvVarSelect={handleEditEnvVarSelect}
|
||||
onEnvVarClose={resetEditEnvVarState}
|
||||
onRemove={() => handleEditRemoveHeader(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{editSaveError && (
|
||||
<p className='mb-[8px] w-full text-[12px] text-[var(--text-error)]'>
|
||||
{editSaveError}
|
||||
</p>
|
||||
)}
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleEditTestConnection}
|
||||
disabled={isEditTestingConnection || !isEditFormValid}
|
||||
>
|
||||
{editTestButtonLabel}
|
||||
</Button>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Button variant='ghost' onClick={handleCloseEditModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={!hasEditChanges || isUpdatingServer || !isEditFormValid}
|
||||
variant='tertiary'
|
||||
>
|
||||
{isUpdatingServer ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
@@ -52,21 +52,17 @@ export function SkillModal({
|
||||
const [content, setContent] = useState('')
|
||||
const [errors, setErrors] = useState<FieldErrors>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [prevOpen, setPrevOpen] = useState(false)
|
||||
const [prevInitialValues, setPrevInitialValues] = useState(initialValues)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (initialValues) {
|
||||
setName(initialValues.name)
|
||||
setDescription(initialValues.description)
|
||||
setContent(initialValues.content)
|
||||
} else {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setContent('')
|
||||
}
|
||||
setErrors({})
|
||||
}
|
||||
}, [open, initialValues])
|
||||
if ((open && !prevOpen) || (open && initialValues !== prevInitialValues)) {
|
||||
setName(initialValues?.name ?? '')
|
||||
setDescription(initialValues?.description ?? '')
|
||||
setContent(initialValues?.content ?? '')
|
||||
setErrors({})
|
||||
}
|
||||
if (open !== prevOpen) setPrevOpen(open)
|
||||
if (initialValues !== prevInitialValues) setPrevInitialValues(initialValues)
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!initialValues) return true
|
||||
|
||||
@@ -4,17 +4,8 @@ import { useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import { Input, Skeleton } from '@/components/ui'
|
||||
import { SkillModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/skills/components/skill-modal'
|
||||
import type { SkillDefinition } from '@/hooks/queries/skills'
|
||||
import { useDeleteSkill, useSkills } from '@/hooks/queries/skills'
|
||||
@@ -105,12 +96,7 @@ export function Skills() {
|
||||
<>
|
||||
<div className='flex h-full flex-col gap-[16px]'>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]',
|
||||
isLoading && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-1 items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
|
||||
<Search
|
||||
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
|
||||
strokeWidth={2}
|
||||
@@ -120,7 +106,7 @@ export function Skills() {
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-100'
|
||||
className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={isLoading} variant='tertiary'>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Checkbox,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
TagInput,
|
||||
type TagItem,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
@@ -64,8 +65,8 @@ const PermissionSelector = React.memo<PermissionSelectorProps>(
|
||||
PermissionSelector.displayName = 'PermissionSelector'
|
||||
|
||||
interface MemberInvitationCardProps {
|
||||
inviteEmail: string
|
||||
setInviteEmail: (email: string) => void
|
||||
inviteEmails: TagItem[]
|
||||
setInviteEmails: (emails: TagItem[]) => void
|
||||
isInviting: boolean
|
||||
showWorkspaceInvite: boolean
|
||||
setShowWorkspaceInvite: (show: boolean) => void
|
||||
@@ -82,8 +83,8 @@ interface MemberInvitationCardProps {
|
||||
}
|
||||
|
||||
export function MemberInvitationCard({
|
||||
inviteEmail,
|
||||
setInviteEmail,
|
||||
inviteEmails,
|
||||
setInviteEmails,
|
||||
isInviting,
|
||||
showWorkspaceInvite,
|
||||
setShowWorkspaceInvite,
|
||||
@@ -100,45 +101,26 @@ export function MemberInvitationCard({
|
||||
}: MemberInvitationCardProps) {
|
||||
const selectedCount = selectedWorkspaces.length
|
||||
const hasAvailableSeats = availableSeats > 0
|
||||
const [emailError, setEmailError] = useState<string>('')
|
||||
const hasValidEmails = inviteEmails.some((e) => e.isValid)
|
||||
|
||||
const validateEmailInput = (email: string) => {
|
||||
if (!email.trim()) {
|
||||
setEmailError('')
|
||||
return
|
||||
}
|
||||
const handleAddEmail = (value: string) => {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
|
||||
const validation = quickValidateEmail(email.trim())
|
||||
if (!validation.isValid) {
|
||||
setEmailError(validation.reason || 'Please enter a valid email address')
|
||||
} else {
|
||||
setEmailError('')
|
||||
}
|
||||
const isDuplicate = inviteEmails.some((e) => e.value === normalized)
|
||||
if (isDuplicate) return false
|
||||
|
||||
const validation = quickValidateEmail(normalized)
|
||||
setInviteEmails([...inviteEmails, { value: normalized, isValid: validation.isValid }])
|
||||
return validation.isValid
|
||||
}
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setInviteEmail(value)
|
||||
if (emailError) {
|
||||
setEmailError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleInviteClick = () => {
|
||||
if (inviteEmail.trim()) {
|
||||
validateEmailInput(inviteEmail)
|
||||
const validation = quickValidateEmail(inviteEmail.trim())
|
||||
if (!validation.isValid) {
|
||||
return // Don't proceed if validation fails
|
||||
}
|
||||
}
|
||||
|
||||
onInviteMember()
|
||||
const handleRemoveEmail = (_value: string, index: number) => {
|
||||
setInviteEmails(inviteEmails.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-5)]'>
|
||||
{/* Header */}
|
||||
<div className='px-[14px] py-[10px]'>
|
||||
<h4 className='font-medium text-[14px] text-[var(--text-primary)]'>Invite Team Members</h4>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
@@ -147,46 +129,18 @@ export function MemberInvitationCard({
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[12px] border-[var(--border-1)] border-t bg-[var(--surface-4)] px-[14px] py-[12px]'>
|
||||
{/* Main invitation input */}
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<div className='flex-1'>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type='email'
|
||||
name='fakeemailremembered'
|
||||
autoComplete='email'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
placeholder='Enter email address'
|
||||
value={inviteEmail}
|
||||
onChange={handleEmailChange}
|
||||
<TagInput
|
||||
items={inviteEmails}
|
||||
onAdd={handleAddEmail}
|
||||
onRemove={handleRemoveEmail}
|
||||
placeholder='Enter email addresses'
|
||||
placeholderWithTags='Add another email'
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
|
||||
name='member_invite_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck={false}
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
aria-autocomplete='none'
|
||||
triggerKeys={['Enter', ',', ' ']}
|
||||
maxHeight='max-h-24'
|
||||
/>
|
||||
{emailError && (
|
||||
<p className='mt-1 text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Popover
|
||||
open={showWorkspaceInvite}
|
||||
@@ -220,8 +174,9 @@ export function MemberInvitationCard({
|
||||
align='end'
|
||||
maxHeight={320}
|
||||
sideOffset={4}
|
||||
className='w-[240px] border border-[var(--border-muted)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
|
||||
style={{ minWidth: '240px', maxWidth: '240px' }}
|
||||
minWidth={240}
|
||||
maxWidth={240}
|
||||
border
|
||||
>
|
||||
{isLoadingWorkspaces ? (
|
||||
<div className='px-[6px] py-[16px] text-center'>
|
||||
@@ -286,14 +241,13 @@ export function MemberInvitationCard({
|
||||
</Popover>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleInviteClick}
|
||||
disabled={!inviteEmail || isInviting || !hasAvailableSeats}
|
||||
onClick={() => onInviteMember()}
|
||||
disabled={!hasValidEmails || isInviting || !hasAvailableSeats}
|
||||
>
|
||||
{isInviting ? 'Inviting...' : hasAvailableSeats ? 'Invite' : 'No Seats'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Invitation error - inline */}
|
||||
{invitationError && (
|
||||
<p className='text-[12px] text-[var(--text-error)] leading-tight'>
|
||||
{invitationError instanceof Error && invitationError.message
|
||||
@@ -302,7 +256,6 @@ export function MemberInvitationCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Success message */}
|
||||
{inviteSuccess && (
|
||||
<p className='text-[11px] text-[var(--text-success)] leading-tight'>
|
||||
Invitation sent successfully
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { TagItem } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
@@ -69,7 +70,7 @@ export function TeamManagement() {
|
||||
|
||||
const [inviteSuccess, setInviteSuccess] = useState(false)
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [inviteEmails, setInviteEmails] = useState<TagItem[]>([])
|
||||
const [showWorkspaceInvite, setShowWorkspaceInvite] = useState(false)
|
||||
const [selectedWorkspaces, setSelectedWorkspaces] = useState<
|
||||
Array<{ workspaceId: string; permission: string }>
|
||||
@@ -129,7 +130,8 @@ export function TeamManagement() {
|
||||
}, [orgName, orgSlug, createOrgMutation])
|
||||
|
||||
const handleInviteMember = useCallback(async () => {
|
||||
if (!session?.user || !activeOrganization?.id || !inviteEmail.trim()) return
|
||||
const validEmails = inviteEmails.filter((e) => e.isValid).map((e) => e.value)
|
||||
if (!session?.user || !activeOrganization?.id || validEmails.length === 0) return
|
||||
|
||||
try {
|
||||
const workspaceInvitations =
|
||||
@@ -141,23 +143,21 @@ export function TeamManagement() {
|
||||
: undefined
|
||||
|
||||
await inviteMutation.mutateAsync({
|
||||
email: inviteEmail.trim(),
|
||||
emails: validEmails,
|
||||
orgId: activeOrganization.id,
|
||||
workspaceInvitations,
|
||||
})
|
||||
|
||||
// Show success state
|
||||
setInviteSuccess(true)
|
||||
setTimeout(() => setInviteSuccess(false), 3000)
|
||||
|
||||
// Reset form
|
||||
setInviteEmail('')
|
||||
setInviteEmails([])
|
||||
setSelectedWorkspaces([])
|
||||
setShowWorkspaceInvite(false)
|
||||
} catch (error) {
|
||||
logger.error('Failed to invite member', error)
|
||||
}
|
||||
}, [session?.user?.id, activeOrganization?.id, inviteEmail, selectedWorkspaces, inviteMutation])
|
||||
}, [session?.user?.id, activeOrganization?.id, inviteEmails, selectedWorkspaces, inviteMutation])
|
||||
|
||||
const handleWorkspaceToggle = useCallback((workspaceId: string, permission: string) => {
|
||||
setSelectedWorkspaces((prev) => {
|
||||
@@ -391,15 +391,15 @@ export function TeamManagement() {
|
||||
{adminOrOwner && !isInvitationsDisabled && (
|
||||
<div>
|
||||
<MemberInvitationCard
|
||||
inviteEmail={inviteEmail}
|
||||
setInviteEmail={setInviteEmail}
|
||||
inviteEmails={inviteEmails}
|
||||
setInviteEmails={setInviteEmails}
|
||||
isInviting={inviteMutation.isPending}
|
||||
showWorkspaceInvite={showWorkspaceInvite}
|
||||
setShowWorkspaceInvite={setShowWorkspaceInvite}
|
||||
selectedWorkspaces={selectedWorkspaces}
|
||||
userWorkspaces={adminWorkspaces}
|
||||
onInviteMember={handleInviteMember}
|
||||
onLoadUserWorkspaces={async () => {}} // No-op: data is auto-loaded by React Query
|
||||
onLoadUserWorkspaces={async () => {}}
|
||||
onWorkspaceToggle={handleWorkspaceToggle}
|
||||
inviteSuccess={inviteSuccess}
|
||||
availableSeats={Math.max(0, totalSeats - usedSeats.used)}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import type { TraceSpan, WorkflowExecutionLog } from '@/lib/logs/types'
|
||||
import { sendEmail } from '@/lib/messaging/email/mailer'
|
||||
import type { AlertConfig } from '@/lib/notifications/alert-rules'
|
||||
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceNotificationDelivery')
|
||||
|
||||
@@ -72,14 +73,20 @@ async function buildPayload(
|
||||
if (!log.workflowId) return null
|
||||
|
||||
const workflowData = await db
|
||||
.select({ name: workflowTable.name, userId: workflowTable.userId })
|
||||
.select({
|
||||
name: workflowTable.name,
|
||||
workspaceId: workflowTable.workspaceId,
|
||||
})
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, log.workflowId))
|
||||
.limit(1)
|
||||
|
||||
const timestamp = Date.now()
|
||||
const executionData = (log.executionData || {}) as Record<string, unknown>
|
||||
const userId = workflowData[0]?.userId
|
||||
const workflowRecord = workflowData[0]
|
||||
const userId = workflowRecord?.workspaceId
|
||||
? await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
|
||||
: null
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
id: `evt_${uuidv4()}`,
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ConfluenceBlock: BlockConfig<ConfluenceResponse> = {
|
||||
id: 'domain',
|
||||
title: 'Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter Confluence domain (e.g., simstudio.atlassian.net)',
|
||||
placeholder: 'Enter Confluence domain (e.g., company.atlassian.net)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
@@ -462,7 +462,7 @@ export const ConfluenceV2Block: BlockConfig<ConfluenceResponse> = {
|
||||
id: 'domain',
|
||||
title: 'Domain',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter Confluence domain (e.g., simstudio.atlassian.net)',
|
||||
placeholder: 'Enter Confluence domain (e.g., company.atlassian.net)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ export const JiraBlock: BlockConfig<JiraResponse> = {
|
||||
title: 'Domain',
|
||||
type: 'short-input',
|
||||
required: true,
|
||||
placeholder: 'Enter Jira domain (e.g., simstudio.atlassian.net)',
|
||||
placeholder: 'Enter Jira domain (e.g., company.atlassian.net)',
|
||||
},
|
||||
{
|
||||
id: 'credential',
|
||||
|
||||
@@ -49,7 +49,7 @@ export const JiraServiceManagementBlock: BlockConfig<JsmResponse> = {
|
||||
title: 'Domain',
|
||||
type: 'short-input',
|
||||
required: true,
|
||||
placeholder: 'Enter Jira domain (e.g., simstudio.atlassian.net)',
|
||||
placeholder: 'Enter Jira domain (e.g., company.atlassian.net)',
|
||||
},
|
||||
{
|
||||
id: 'credential',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
containsUserFileWithMetadata,
|
||||
@@ -376,6 +377,7 @@ export class BlockExecutor {
|
||||
* - Filters out system fields (UI-only, readonly, internal flags)
|
||||
* - Removes UI state from inputFormat items (e.g., collapsed)
|
||||
* - Parses JSON strings to objects for readability
|
||||
* - Redacts sensitive fields (privateKey, password, tokens, etc.)
|
||||
* Returns a new object - does not mutate the original inputs.
|
||||
*/
|
||||
private sanitizeInputsForLog(inputs: Record<string, any>): Record<string, any> {
|
||||
@@ -410,7 +412,7 @@ export class BlockExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
return redactApiKeys(result)
|
||||
}
|
||||
|
||||
private callOnBlockStart(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {
|
||||
extractFieldsFromSchema,
|
||||
parseResponseFormatSafely,
|
||||
} from '@/lib/core/utils/response-format'
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { isTriggerBehavior, normalizeName } from '@/executor/constants'
|
||||
import type { ExecutionContext } from '@/executor/types'
|
||||
import type { OutputSchema } from '@/executor/utils/block-reference'
|
||||
@@ -12,8 +10,6 @@ import {
|
||||
isBranchNodeId,
|
||||
} from '@/executor/utils/subflow-utils'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
export interface BlockDataCollection {
|
||||
blockData: Record<string, unknown>
|
||||
@@ -21,118 +17,44 @@ export interface BlockDataCollection {
|
||||
blockOutputSchemas: Record<string, OutputSchema>
|
||||
}
|
||||
|
||||
/**
|
||||
* Block types where inputFormat fields should be merged into outputs schema.
|
||||
* These are blocks where users define custom fields via inputFormat that become
|
||||
* valid output paths (e.g., <start.myField>, <webhook1.customField>, <hitl1.resumeField>).
|
||||
*
|
||||
* Note: This includes non-trigger blocks like 'starter' and 'human_in_the_loop' which
|
||||
* have category 'blocks' but still need their inputFormat exposed as outputs.
|
||||
*/
|
||||
const BLOCKS_WITH_INPUT_FORMAT_OUTPUTS = [
|
||||
'start_trigger',
|
||||
'starter',
|
||||
'api_trigger',
|
||||
'input_trigger',
|
||||
'generic_webhook',
|
||||
'human_in_the_loop',
|
||||
] as const
|
||||
|
||||
function getInputFormatFields(block: SerializedBlock): OutputSchema {
|
||||
const inputFormat = normalizeInputFormatValue(block.config?.params?.inputFormat)
|
||||
if (inputFormat.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const schema: OutputSchema = {}
|
||||
for (const field of inputFormat) {
|
||||
if (!field.name) continue
|
||||
schema[field.name] = { type: field.type || 'any' }
|
||||
}
|
||||
|
||||
return schema
|
||||
interface SubBlockWithValue {
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
function getEvaluatorMetricsSchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
if (block.metadata?.id !== 'evaluator') return undefined
|
||||
function paramsToSubBlocks(
|
||||
params: Record<string, unknown> | undefined
|
||||
): Record<string, SubBlockWithValue> {
|
||||
if (!params) return {}
|
||||
|
||||
const metrics = block.config?.params?.metrics
|
||||
if (!Array.isArray(metrics) || metrics.length === 0) return undefined
|
||||
|
||||
const validMetrics = metrics.filter(
|
||||
(m: { name?: string }) => m?.name && typeof m.name === 'string'
|
||||
)
|
||||
if (validMetrics.length === 0) return undefined
|
||||
|
||||
const schema: OutputSchema = { ...(block.outputs as OutputSchema) }
|
||||
for (const metric of validMetrics) {
|
||||
schema[metric.name.toLowerCase()] = { type: 'number' }
|
||||
const subBlocks: Record<string, SubBlockWithValue> = {}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
subBlocks[key] = { value }
|
||||
}
|
||||
return schema
|
||||
return subBlocks
|
||||
}
|
||||
|
||||
function getResponseFormatSchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
const responseFormatValue = block.config?.params?.responseFormat
|
||||
if (!responseFormatValue) return undefined
|
||||
|
||||
const parsed = parseResponseFormatSafely(responseFormatValue, block.id)
|
||||
if (!parsed) return undefined
|
||||
|
||||
const fields = extractFieldsFromSchema(parsed)
|
||||
if (fields.length === 0) return undefined
|
||||
|
||||
const schema: OutputSchema = {}
|
||||
for (const field of fields) {
|
||||
schema[field.name] = { type: field.type || 'any' }
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
export function getBlockSchema(
|
||||
block: SerializedBlock,
|
||||
toolConfig?: ToolConfig
|
||||
): OutputSchema | undefined {
|
||||
function getRegistrySchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
const blockType = block.metadata?.id
|
||||
if (!blockType) return undefined
|
||||
|
||||
if (
|
||||
blockType &&
|
||||
BLOCKS_WITH_INPUT_FORMAT_OUTPUTS.includes(
|
||||
blockType as (typeof BLOCKS_WITH_INPUT_FORMAT_OUTPUTS)[number]
|
||||
)
|
||||
) {
|
||||
const baseOutputs = (block.outputs as OutputSchema) || {}
|
||||
const inputFormatFields = getInputFormatFields(block)
|
||||
const merged = { ...baseOutputs, ...inputFormatFields }
|
||||
if (Object.keys(merged).length > 0) {
|
||||
return merged
|
||||
}
|
||||
const subBlocks = paramsToSubBlocks(block.config?.params)
|
||||
const blockConfig = getBlock(blockType)
|
||||
const isTriggerCapable = blockConfig ? hasTriggerCapability(blockConfig) : false
|
||||
const triggerMode = Boolean(isTriggerBehavior(block) && isTriggerCapable)
|
||||
const outputs = getEffectiveBlockOutputs(blockType, subBlocks, {
|
||||
triggerMode,
|
||||
preferToolOutputs: !triggerMode,
|
||||
includeHidden: true,
|
||||
}) as OutputSchema
|
||||
|
||||
if (!outputs || Object.keys(outputs).length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
const evaluatorSchema = getEvaluatorMetricsSchema(block)
|
||||
if (evaluatorSchema) {
|
||||
return evaluatorSchema
|
||||
}
|
||||
|
||||
const responseFormatSchema = getResponseFormatSchema(block)
|
||||
if (responseFormatSchema) {
|
||||
return responseFormatSchema
|
||||
}
|
||||
|
||||
const isTrigger = isTriggerBehavior(block)
|
||||
|
||||
if (isTrigger && block.outputs && Object.keys(block.outputs).length > 0) {
|
||||
return block.outputs as OutputSchema
|
||||
}
|
||||
|
||||
if (toolConfig?.outputs && Object.keys(toolConfig.outputs).length > 0) {
|
||||
return toolConfig.outputs as OutputSchema
|
||||
}
|
||||
|
||||
if (block.outputs && Object.keys(block.outputs).length > 0) {
|
||||
return block.outputs as OutputSchema
|
||||
}
|
||||
|
||||
return undefined
|
||||
export function getBlockSchema(block: SerializedBlock): OutputSchema | undefined {
|
||||
return getRegistrySchema(block)
|
||||
}
|
||||
|
||||
export function collectBlockData(
|
||||
@@ -170,9 +92,7 @@ export function collectBlockData(
|
||||
blockNameMapping[normalizeName(block.metadata.name)] = id
|
||||
}
|
||||
|
||||
const toolId = block.config?.tool
|
||||
const toolConfig = toolId ? getTool(toolId) : undefined
|
||||
const schema = getBlockSchema(block, toolConfig)
|
||||
const schema = getBlockSchema(block)
|
||||
if (schema && Object.keys(schema).length > 0) {
|
||||
blockOutputSchemas[id] = schema
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { BlockResolver } from './block'
|
||||
import type { ResolutionContext } from './reference'
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@/lib/workflows/blocks/block-outputs', () => ({
|
||||
getBlockOutputs: vi.fn(() => ({})),
|
||||
}))
|
||||
vi.mock('@/blocks/registry', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/blocks/registry')>('@/blocks/registry')
|
||||
return actual
|
||||
})
|
||||
|
||||
function createTestWorkflow(
|
||||
blocks: Array<{
|
||||
@@ -135,7 +135,7 @@ describe('BlockResolver', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for non-existent path when no schema defined', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { existing: 'value' },
|
||||
@@ -144,55 +144,93 @@ describe('BlockResolver', () => {
|
||||
expect(resolver.resolve('<source.nonexistent>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should throw error for path not in output schema', async () => {
|
||||
const { getBlockOutputs } = await import('@/lib/workflows/blocks/block-outputs')
|
||||
const mockGetBlockOutputs = vi.mocked(getBlockOutputs)
|
||||
const customOutputs = {
|
||||
validField: { type: 'string', description: 'A valid field' },
|
||||
nested: {
|
||||
child: { type: 'number', description: 'Nested child' },
|
||||
},
|
||||
}
|
||||
mockGetBlockOutputs.mockReturnValue(customOutputs as any)
|
||||
|
||||
it.concurrent('should throw error for path not in output schema', () => {
|
||||
const workflow = createTestWorkflow([
|
||||
{
|
||||
id: 'source',
|
||||
outputs: customOutputs,
|
||||
type: 'start_trigger',
|
||||
},
|
||||
])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { validField: 'value', nested: { child: 42 } },
|
||||
source: { input: 'value' },
|
||||
})
|
||||
|
||||
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(
|
||||
/"invalidField" doesn't exist on block "source"/
|
||||
)
|
||||
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(/Available fields:/)
|
||||
|
||||
mockGetBlockOutputs.mockReturnValue({})
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for path in schema but missing in data', () => {
|
||||
const workflow = createTestWorkflow([
|
||||
{
|
||||
id: 'source',
|
||||
outputs: {
|
||||
requiredField: { type: 'string', description: 'Always present' },
|
||||
optionalField: { type: 'string', description: 'Sometimes missing' },
|
||||
},
|
||||
type: 'function',
|
||||
},
|
||||
])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { requiredField: 'value' },
|
||||
source: { stdout: 'log output' },
|
||||
})
|
||||
|
||||
expect(resolver.resolve('<source.requiredField>', ctx)).toBe('value')
|
||||
expect(resolver.resolve('<source.optionalField>', ctx)).toBeUndefined()
|
||||
expect(resolver.resolve('<source.stdout>', ctx)).toBe('log output')
|
||||
expect(resolver.resolve('<source.result>', ctx)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should allow hiddenFromDisplay fields for pre-execution schema validation',
|
||||
() => {
|
||||
const workflow = createTestWorkflow([
|
||||
{
|
||||
id: 'workflow-block',
|
||||
name: 'Workflow',
|
||||
type: 'workflow',
|
||||
},
|
||||
])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {})
|
||||
|
||||
expect(resolver.resolve('<workflow.childTraceSpans>', ctx)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent(
|
||||
'should allow hiddenFromDisplay fields for workflow_input pre-execution schema validation',
|
||||
() => {
|
||||
const workflow = createTestWorkflow([
|
||||
{
|
||||
id: 'workflow-input-block',
|
||||
name: 'Workflow Input',
|
||||
type: 'workflow_input',
|
||||
},
|
||||
])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {})
|
||||
|
||||
expect(resolver.resolve('<workflowinput.childTraceSpans>', ctx)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent(
|
||||
'should allow hiddenFromDisplay fields for HITL pre-execution schema validation',
|
||||
() => {
|
||||
const workflow = createTestWorkflow([
|
||||
{
|
||||
id: 'hitl-block',
|
||||
name: 'HITL',
|
||||
type: 'human_in_the_loop',
|
||||
},
|
||||
])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {})
|
||||
|
||||
expect(resolver.resolve('<hitl.response>', ctx)).toBeUndefined()
|
||||
expect(resolver.resolve('<hitl.submission>', ctx)).toBeUndefined()
|
||||
expect(resolver.resolve('<hitl.resumeInput>', ctx)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should return undefined for non-existent block', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'existing' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
@@ -975,7 +1013,7 @@ describe('BlockResolver', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should handle output with undefined values', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { value: undefined, other: 'exists' },
|
||||
@@ -985,7 +1023,7 @@ describe('BlockResolver', () => {
|
||||
})
|
||||
|
||||
it.concurrent('should return undefined for deeply nested non-existent path', () => {
|
||||
const workflow = createTestWorkflow([{ id: 'source' }])
|
||||
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
|
||||
const resolver = new BlockResolver(workflow)
|
||||
const ctx = createTestContext('current', {
|
||||
source: { level1: { level2: {} } },
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
type Resolver,
|
||||
} from '@/executor/variables/resolvers/reference'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import { getTool } from '@/tools/utils'
|
||||
|
||||
export class BlockResolver implements Resolver {
|
||||
private nameToBlockId: Map<string, string>
|
||||
@@ -68,9 +67,7 @@ export class BlockResolver implements Resolver {
|
||||
blockData[blockId] = output
|
||||
}
|
||||
|
||||
const toolId = block.config?.tool
|
||||
const toolConfig = toolId ? getTool(toolId) : undefined
|
||||
const outputSchema = getBlockSchema(block, toolConfig)
|
||||
const outputSchema = getBlockSchema(block)
|
||||
|
||||
if (outputSchema && Object.keys(outputSchema).length > 0) {
|
||||
blockOutputSchemas[blockId] = outputSchema
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user