Compare commits

...

10 Commits

Author SHA1 Message Date
waleed
276ce665e4 ack comments 2026-01-27 14:50:57 -08:00
waleed
08bad4da9f ack comments 2026-01-27 14:38:38 -08:00
waleed
37dbfe393a fix(workflow): preserve parent and position when duplicating/pasting nested blocks
Three related fixes for blocks inside containers (loop/parallel):

1. regenerateBlockIds now preserves parentId when the parent exists in
   the current workflow, not just when it's in the copy set. This keeps
   duplicated blocks inside their container.

2. calculatePasteOffset now uses simple offset for nested blocks instead
   of viewport-center calculation. Since nested blocks use relative
   positioning, the viewport-center offset would place them incorrectly.

3. Use CONTAINER_DIMENSIONS constants instead of hardcoded magic numbers
   in orphan cleanup position calculation.
2026-01-27 14:17:16 -08:00
waleed
503f676910 fix(store): clear extent property when orphaning blocks
When a block's parent is removed, properly clear both parentId and extent
properties from block.data, matching the pattern used in batchUpdateBlocksWithParent.
2026-01-27 14:10:55 -08:00
waleed
f2ca90ae6f refactor(store): remove unused workflow store functions
Remove redundant functions superseded by collaborative workflow patterns:
- duplicateBlock (superseded by collaborative paste flow)
- toggleBlockAdvancedMode (superseded by setBlockAdvancedMode)
- updateLoopCollection (redundant wrapper)
- setBlockTriggerMode (unused)
- generateLoopBlocks/generateParallelBlocks methods (called directly as utils)

Also removes ~160 lines of related tests and cleans up unused imports.
2026-01-27 14:10:55 -08:00
waleed
fe4fd47b9d improvement(workflow): remove useEffect anti-patterns 2026-01-27 14:10:55 -08:00
Waleed
6b412c578d fix(security): add authentication to remaining tool API routes (#3028)
* fix(security): add authentication to tool API routes

* fix(drive): use checkSessionOrInternalAuth to allow browser access

* fix(selectors): use checkSessionOrInternalAuth for UI-accessible routes
2026-01-27 12:37:03 -08:00
Waleed
dddd0c8277 fix(workflow): use panel-aware viewport center for paste and block placement (#3024) 2026-01-27 12:36:38 -08:00
Waleed
be7f3db059 fix(badge): add type variant for dark mode contrast (#3025)
* fix(badge): add type variant for dark mode contrast

* docs(badge): add type variant to TSDoc
2026-01-27 11:40:14 -08:00
Waleed
416c08267a fix(terminal): persist collapsed state across page refresh (#3023)
* fix(terminal): persist collapsed state across page refresh

* fix(terminal): add activeWorkflowId to auto-open effect deps
2026-01-27 11:38:44 -08:00
94 changed files with 1338 additions and 793 deletions

View File

@@ -10,6 +10,7 @@ describe('OAuth Token API Routes', () => {
const mockGetUserId = vi.fn() const mockGetUserId = vi.fn()
const mockGetCredential = vi.fn() const mockGetCredential = vi.fn()
const mockRefreshTokenIfNeeded = vi.fn() const mockRefreshTokenIfNeeded = vi.fn()
const mockGetOAuthToken = vi.fn()
const mockAuthorizeCredentialUse = vi.fn() const mockAuthorizeCredentialUse = vi.fn()
const mockCheckHybridAuth = vi.fn() const mockCheckHybridAuth = vi.fn()
@@ -29,6 +30,7 @@ describe('OAuth Token API Routes', () => {
getUserId: mockGetUserId, getUserId: mockGetUserId,
getCredential: mockGetCredential, getCredential: mockGetCredential,
refreshTokenIfNeeded: mockRefreshTokenIfNeeded, refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
getOAuthToken: mockGetOAuthToken,
})) }))
vi.doMock('@sim/logger', () => ({ vi.doMock('@sim/logger', () => ({
@@ -230,6 +232,140 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(401) expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'Failed to refresh access token') expect(data).toHaveProperty('error', 'Failed to refresh access token')
}) })
describe('credentialAccountUserId + providerId path', () => {
it('should reject unauthenticated requests', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: false,
error: 'Authentication required',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'target-user-id',
providerId: 'google',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should reject API key authentication', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'api_key',
userId: 'test-user-id',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'google',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should reject internal JWT authentication', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'internal_jwt',
userId: 'test-user-id',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'google',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(401)
expect(data).toHaveProperty('error', 'User not authenticated')
expect(mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should reject requests for other users credentials', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'attacker-user-id',
})
const req = createMockRequest('POST', {
credentialAccountUserId: 'victim-user-id',
providerId: 'google',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Unauthorized')
expect(mockGetOAuthToken).not.toHaveBeenCalled()
})
it('should allow session-authenticated users to access their own credentials', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetOAuthToken.mockResolvedValueOnce('valid-access-token')
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'google',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toHaveProperty('accessToken', 'valid-access-token')
expect(mockGetOAuthToken).toHaveBeenCalledWith('test-user-id', 'google')
})
it('should return 404 when credential not found for user', async () => {
mockCheckHybridAuth.mockResolvedValueOnce({
success: true,
authType: 'session',
userId: 'test-user-id',
})
mockGetOAuthToken.mockResolvedValueOnce(null)
const req = createMockRequest('POST', {
credentialAccountUserId: 'test-user-id',
providerId: 'nonexistent-provider',
})
const { POST } = await import('@/app/api/auth/oauth/token/route')
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toContain('No credential found')
})
})
}) })
/** /**

View File

@@ -71,6 +71,22 @@ export async function POST(request: NextRequest) {
providerId, providerId,
}) })
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized request for credentialAccountUserId path`, {
success: auth.success,
authType: auth.authType,
})
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
if (auth.userId !== credentialAccountUserId) {
logger.warn(
`[${requestId}] User ${auth.userId} attempted to access credentials for ${credentialAccountUserId}`
)
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
try { try {
const accessToken = await getOAuthToken(credentialAccountUserId, providerId) const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
if (!accessToken) { if (!accessToken) {

View File

@@ -26,8 +26,9 @@ vi.mock('@/serializer', () => ({
Serializer: vi.fn(), Serializer: vi.fn(),
})) }))
vi.mock('@/stores/workflows/server-utils', () => ({ vi.mock('@/lib/workflows/subblocks', () => ({
mergeSubblockState: vi.fn().mockReturnValue({}), mergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
mergeSubBlockValues: vi.fn().mockReturnValue({}),
})) }))
const mockDecryptSecret = vi.fn() const mockDecryptSecret = vi.fn()

View File

@@ -1,13 +1,19 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaAddCommentAPI') const logger = createLogger('AsanaAddCommentAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, taskGid, text } = await request.json() const { accessToken, taskGid, text } = await request.json()
if (!accessToken) { if (!accessToken) {

View File

@@ -1,13 +1,19 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaCreateTaskAPI') const logger = createLogger('AsanaCreateTaskAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, workspace, name, notes, assignee, due_on } = await request.json() const { accessToken, workspace, name, notes, assignee, due_on } = await request.json()
if (!accessToken) { if (!accessToken) {

View File

@@ -1,13 +1,19 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaGetProjectsAPI') const logger = createLogger('AsanaGetProjectsAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, workspace } = await request.json() const { accessToken, workspace } = await request.json()
if (!accessToken) { if (!accessToken) {

View File

@@ -1,13 +1,19 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaGetTaskAPI') const logger = createLogger('AsanaGetTaskAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, taskGid, workspace, project, limit } = await request.json() const { accessToken, taskGid, workspace, project, limit } = await request.json()
if (!accessToken) { if (!accessToken) {

View File

@@ -1,13 +1,19 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaSearchTasksAPI') const logger = createLogger('AsanaSearchTasksAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, workspace, text, assignee, projects, completed } = await request.json() const { accessToken, workspace, text, assignee, projects, completed } = await request.json()
if (!accessToken) { if (!accessToken) {

View File

@@ -1,13 +1,19 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { validateAlphanumericId } from '@/lib/core/security/input-validation'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaUpdateTaskAPI') const logger = createLogger('AsanaUpdateTaskAPI')
export async function PUT(request: Request) { export async function PUT(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { accessToken, taskGid, name, notes, assignee, completed, due_on } = await request.json() const { accessToken, taskGid, name, notes, assignee, completed, due_on } = await request.json()
if (!accessToken) { if (!accessToken) {

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceAttachmentAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// Delete an attachment // Delete an attachment
export async function DELETE(request: Request) { export async function DELETE(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { domain, accessToken, cloudId: providedCloudId, attachmentId } = await request.json() const { domain, accessToken, cloudId: providedCloudId, attachmentId } = await request.json()
if (!domain) { if (!domain) {

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceAttachmentsAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// List attachments on a page // List attachments on a page
export async function GET(request: Request) { export async function GET(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain') const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken') const accessToken = searchParams.get('accessToken')

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -46,8 +47,13 @@ const deleteCommentSchema = z
) )
// Update a comment // Update a comment
export async function PUT(request: Request) { export async function PUT(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validation = putCommentSchema.safeParse(body) const validation = putCommentSchema.safeParse(body)
@@ -128,8 +134,13 @@ export async function PUT(request: Request) {
} }
// Delete a comment // Delete a comment
export async function DELETE(request: Request) { export async function DELETE(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validation = deleteCommentSchema.safeParse(body) const validation = deleteCommentSchema.safeParse(body)

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceCommentsAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// Create a comment // Create a comment
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { domain, accessToken, cloudId: providedCloudId, pageId, comment } = await request.json() const { domain, accessToken, cloudId: providedCloudId, pageId, comment } = await request.json()
if (!domain) { if (!domain) {
@@ -86,8 +92,13 @@ export async function POST(request: Request) {
} }
// List comments on a page // List comments on a page
export async function GET(request: Request) { export async function GET(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain') const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken') const accessToken = searchParams.get('accessToken')

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -7,8 +8,13 @@ const logger = createLogger('ConfluenceCreatePageAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { const {
domain, domain,
accessToken, accessToken,

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceLabelsAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// Add a label to a page // Add a label to a page
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { const {
domain, domain,
accessToken, accessToken,
@@ -87,8 +93,13 @@ export async function POST(request: Request) {
} }
// List labels on a page // List labels on a page
export async function GET(request: Request) { export async function GET(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain') const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken') const accessToken = searchParams.get('accessToken')

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -73,8 +74,13 @@ const deletePageSchema = z
} }
) )
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validation = postPageSchema.safeParse(body) const validation = postPageSchema.safeParse(body)
@@ -144,8 +150,13 @@ export async function POST(request: Request) {
} }
} }
export async function PUT(request: Request) { export async function PUT(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validation = putPageSchema.safeParse(body) const validation = putPageSchema.safeParse(body)
@@ -248,8 +259,13 @@ export async function PUT(request: Request) {
} }
} }
export async function DELETE(request: Request) { export async function DELETE(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validation = deletePageSchema.safeParse(body) const validation = deletePageSchema.safeParse(body)

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluencePagesAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// List pages or search pages // List pages or search pages
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { const {
domain, domain,
accessToken, accessToken,

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -7,8 +8,13 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('Confluence Search') const logger = createLogger('Confluence Search')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { const {
domain, domain,
accessToken, accessToken,

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceSpaceAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// Get a specific space // Get a specific space
export async function GET(request: Request) { export async function GET(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain') const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken') const accessToken = searchParams.get('accessToken')

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils' import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceSpacesAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// List all spaces // List all spaces
export async function GET(request: Request) { export async function GET(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const domain = searchParams.get('domain') const domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken') const accessToken = searchParams.get('accessToken')

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
@@ -11,6 +12,11 @@ export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const { domain, accessToken, cloudId: providedCloudId, pageId, file, fileName, comment } = body const { domain, accessToken, cloudId: providedCloudId, pageId, file, fileName, comment } = body

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateNumericId } from '@/lib/core/security/input-validation' import { validateNumericId } from '@/lib/core/security/input-validation'
interface DiscordChannel { interface DiscordChannel {
@@ -13,7 +14,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('DiscordChannelsAPI') const logger = createLogger('DiscordChannelsAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const { botToken, serverId, channelId } = await request.json() const { botToken, serverId, channelId } = await request.json()

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateNumericId } from '@/lib/core/security/input-validation' import { validateNumericId } from '@/lib/core/security/input-validation'
interface DiscordServer { interface DiscordServer {
@@ -12,7 +13,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('DiscordServersAPI') const logger = createLogger('DiscordServersAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const { botToken, serverId } = await request.json() const { botToken, serverId } = await request.json()

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -15,6 +16,11 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId() const requestId = generateRequestId()
logger.info(`[${requestId}] Google Drive file request received`) logger.info(`[${requestId}] Google Drive file request received`)
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId') const credentialId = searchParams.get('credentialId')

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation' import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -73,14 +73,12 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId() const requestId = generateRequestId()
logger.info(`[${requestId}] Google Drive files request received`) logger.info(`[${requestId}] Google Drive files request received`)
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthenticated request rejected`)
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId') const credentialId = searchParams.get('credentialId')
const mimeType = searchParams.get('mimeType') const mimeType = searchParams.get('mimeType')

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, deleteItem } from '@/app/api/tools/dynamodb/utils' import { createDynamoDBClient, deleteItem } from '@/app/api/tools/dynamodb/utils'
const DeleteSchema = z.object({ const DeleteSchema = z.object({
@@ -13,8 +14,13 @@ const DeleteSchema = z.object({
conditionExpression: z.string().optional(), conditionExpression: z.string().optional(),
}) })
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validatedData = DeleteSchema.parse(body) const validatedData = DeleteSchema.parse(body)

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, getItem } from '@/app/api/tools/dynamodb/utils' import { createDynamoDBClient, getItem } from '@/app/api/tools/dynamodb/utils'
const GetSchema = z.object({ const GetSchema = z.object({
@@ -19,8 +20,13 @@ const GetSchema = z.object({
}), }),
}) })
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validatedData = GetSchema.parse(body) const validatedData = GetSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRawDynamoDBClient, describeTable, listTables } from '@/app/api/tools/dynamodb/utils' import { createRawDynamoDBClient, describeTable, listTables } from '@/app/api/tools/dynamodb/utils'
const logger = createLogger('DynamoDBIntrospectAPI') const logger = createLogger('DynamoDBIntrospectAPI')
@@ -17,6 +18,11 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const params = IntrospectSchema.parse(body) const params = IntrospectSchema.parse(body)

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, putItem } from '@/app/api/tools/dynamodb/utils' import { createDynamoDBClient, putItem } from '@/app/api/tools/dynamodb/utils'
const PutSchema = z.object({ const PutSchema = z.object({
@@ -12,8 +13,13 @@ const PutSchema = z.object({
}), }),
}) })
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validatedData = PutSchema.parse(body) const validatedData = PutSchema.parse(body)

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, queryItems } from '@/app/api/tools/dynamodb/utils' import { createDynamoDBClient, queryItems } from '@/app/api/tools/dynamodb/utils'
const QuerySchema = z.object({ const QuerySchema = z.object({
@@ -15,8 +16,13 @@ const QuerySchema = z.object({
limit: z.number().positive().optional(), limit: z.number().positive().optional(),
}) })
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validatedData = QuerySchema.parse(body) const validatedData = QuerySchema.parse(body)

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, scanItems } from '@/app/api/tools/dynamodb/utils' import { createDynamoDBClient, scanItems } from '@/app/api/tools/dynamodb/utils'
const ScanSchema = z.object({ const ScanSchema = z.object({
@@ -14,8 +15,13 @@ const ScanSchema = z.object({
limit: z.number().positive().optional(), limit: z.number().positive().optional(),
}) })
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validatedData = ScanSchema.parse(body) const validatedData = ScanSchema.parse(body)

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, updateItem } from '@/app/api/tools/dynamodb/utils' import { createDynamoDBClient, updateItem } from '@/app/api/tools/dynamodb/utils'
const UpdateSchema = z.object({ const UpdateSchema = z.object({
@@ -16,8 +17,13 @@ const UpdateSchema = z.object({
conditionExpression: z.string().optional(), conditionExpression: z.string().optional(),
}) })
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validatedData = UpdateSchema.parse(body) const validatedData = UpdateSchema.parse(body)

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access' import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request' import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -29,6 +30,11 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId() const requestId = generateRequestId()
logger.info(`[${requestId}] Google Sheets sheets request received`) logger.info(`[${requestId}] Google Sheets sheets request received`)
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId') const credentialId = searchParams.get('credentialId')

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils' import { getJiraCloudId } from '@/tools/jira/utils'
@@ -7,8 +8,13 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JiraIssueAPI') const logger = createLogger('JiraIssueAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { domain, accessToken, issueId, cloudId: providedCloudId } = await request.json() const { domain, accessToken, issueId, cloudId: providedCloudId } = await request.json()
if (!domain) { if (!domain) {
logger.error('Missing domain in request') logger.error('Missing domain in request')

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils' import { getJiraCloudId } from '@/tools/jira/utils'
@@ -26,8 +27,13 @@ const validateRequiredParams = (domain: string | null, accessToken: string | nul
return null return null
} }
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { domain, accessToken, issueKeys = [], cloudId: providedCloudId } = await request.json() const { domain, accessToken, issueKeys = [], cloudId: providedCloudId } = await request.json()
const validationError = validateRequiredParams(domain || null, accessToken || null) const validationError = validateRequiredParams(domain || null, accessToken || null)
@@ -101,8 +107,13 @@ export async function POST(request: Request) {
} }
} }
export async function GET(request: Request) { export async function GET(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const url = new URL(request.url) const url = new URL(request.url)
const domain = url.searchParams.get('domain')?.trim() const domain = url.searchParams.get('domain')?.trim()
const accessToken = url.searchParams.get('accessToken') const accessToken = url.searchParams.get('accessToken')

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils' import { getJiraCloudId } from '@/tools/jira/utils'
@@ -7,8 +8,13 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JiraProjectsAPI') const logger = createLogger('JiraProjectsAPI')
export async function GET(request: Request) { export async function GET(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const url = new URL(request.url) const url = new URL(request.url)
const domain = url.searchParams.get('domain')?.trim() const domain = url.searchParams.get('domain')?.trim()
const accessToken = url.searchParams.get('accessToken') const accessToken = url.searchParams.get('accessToken')
@@ -98,8 +104,13 @@ export async function GET(request: Request) {
} }
} }
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { domain, accessToken, projectId, cloudId: providedCloudId } = await request.json() const { domain, accessToken, projectId, cloudId: providedCloudId } = await request.json()
if (!domain) { if (!domain) {

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils' import { getJiraCloudId } from '@/tools/jira/utils'
@@ -21,8 +22,13 @@ const jiraUpdateSchema = z.object({
cloudId: z.string().optional(), cloudId: z.string().optional(),
}) })
export async function PUT(request: Request) { export async function PUT(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const body = await request.json() const body = await request.json()
const validation = jiraUpdateSchema.safeParse(body) const validation = jiraUpdateSchema.safeParse(body)

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils' import { getJiraCloudId } from '@/tools/jira/utils'
@@ -7,8 +8,13 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JiraWriteAPI') const logger = createLogger('JiraWriteAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
try { try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const { const {
domain, domain,
accessToken, accessToken,

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import {
validateAlphanumericId, validateAlphanumericId,
validateEnum, validateEnum,
@@ -15,7 +16,12 @@ const logger = createLogger('JsmApprovalsAPI')
const VALID_ACTIONS = ['get', 'answer'] as const const VALID_ACTIONS = ['get', 'answer'] as const
const VALID_DECISIONS = ['approve', 'decline'] as const const VALID_DECISIONS = ['approve', 'decline'] as const
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { const {

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmCommentAPI') const logger = createLogger('JsmCommentAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const { const {
domain, domain,

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmCommentsAPI') const logger = createLogger('JsmCommentsAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { const {

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmCustomersAPI') const logger = createLogger('JsmCustomersAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { const {

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import {
validateAlphanumericId, validateAlphanumericId,
validateEnum, validateEnum,
@@ -13,7 +14,12 @@ const logger = createLogger('JsmOrganizationAPI')
const VALID_ACTIONS = ['create', 'add_to_service_desk'] as const const VALID_ACTIONS = ['create', 'add_to_service_desk'] as const
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { const {

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmOrganizationsAPI') const logger = createLogger('JsmOrganizationsAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, serviceDeskId, start, limit } = body const { domain, accessToken, cloudId: cloudIdParam, serviceDeskId, start, limit } = body

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import {
validateEnum, validateEnum,
validateJiraCloudId, validateJiraCloudId,
@@ -13,7 +14,12 @@ const logger = createLogger('JsmParticipantsAPI')
const VALID_ACTIONS = ['get', 'add'] as const const VALID_ACTIONS = ['get', 'add'] as const
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { const {

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmQueuesAPI') const logger = createLogger('JsmQueuesAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { const {

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import {
validateAlphanumericId, validateAlphanumericId,
validateJiraCloudId, validateJiraCloudId,
@@ -11,7 +12,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmRequestAPI') const logger = createLogger('JsmRequestAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { const {

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmRequestsAPI') const logger = createLogger('JsmRequestsAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { const {

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmRequestTypesAPI') const logger = createLogger('JsmRequestTypesAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, serviceDeskId, start, limit } = body const { domain, accessToken, cloudId: cloudIdParam, serviceDeskId, start, limit } = body

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmServiceDesksAPI') const logger = createLogger('JsmServiceDesksAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, start, limit } = body const { domain, accessToken, cloudId: cloudIdParam, start, limit } = body

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmSlaAPI') const logger = createLogger('JsmSlaAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, start, limit } = body const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, start, limit } = body

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import {
validateAlphanumericId, validateAlphanumericId,
validateJiraCloudId, validateJiraCloudId,
@@ -11,7 +12,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmTransitionAPI') const logger = createLogger('JsmTransitionAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const { const {
domain, domain,

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation' import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils' import { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JsmTransitionsAPI') const logger = createLogger('JsmTransitionsAPI')
export async function POST(request: Request) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey } = body const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey } = body

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils' import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBDeleteAPI') const logger = createLogger('MongoDBDeleteAPI')
@@ -40,6 +41,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
let client = null let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB delete attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = DeleteSchema.parse(body) const params = DeleteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validatePipeline } from '../utils' import { createMongoDBConnection, sanitizeCollectionName, validatePipeline } from '../utils'
const logger = createLogger('MongoDBExecuteAPI') const logger = createLogger('MongoDBExecuteAPI')
@@ -32,6 +33,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
let client = null let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB execute attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = ExecuteSchema.parse(body) const params = ExecuteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName } from '../utils' import { createMongoDBConnection, sanitizeCollectionName } from '../utils'
const logger = createLogger('MongoDBInsertAPI') const logger = createLogger('MongoDBInsertAPI')
@@ -37,6 +38,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
let client = null let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB insert attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = InsertSchema.parse(body) const params = InsertSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, executeIntrospect } from '../utils' import { createMongoDBConnection, executeIntrospect } from '../utils'
const logger = createLogger('MongoDBIntrospectAPI') const logger = createLogger('MongoDBIntrospectAPI')
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
let client = null let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB introspect attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = IntrospectSchema.parse(body) const params = IntrospectSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils' import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBQueryAPI') const logger = createLogger('MongoDBQueryAPI')
@@ -49,6 +50,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
let client = null let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB query attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = QuerySchema.parse(body) const params = QuerySchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils' import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBUpdateAPI') const logger = createLogger('MongoDBUpdateAPI')
@@ -59,6 +60,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
let client = null let client = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized MongoDB update attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = UpdateSchema.parse(body) const params = UpdateSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import {
convertNeo4jTypesToJSON, convertNeo4jTypesToJSON,
createNeo4jDriver, createNeo4jDriver,
@@ -26,6 +27,12 @@ export async function POST(request: NextRequest) {
let driver = null let driver = null
let session = null let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j create attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = CreateSchema.parse(body) const params = CreateSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createNeo4jDriver, validateCypherQuery } from '@/app/api/tools/neo4j/utils' import { createNeo4jDriver, validateCypherQuery } from '@/app/api/tools/neo4j/utils'
const logger = createLogger('Neo4jDeleteAPI') const logger = createLogger('Neo4jDeleteAPI')
@@ -23,6 +24,12 @@ export async function POST(request: NextRequest) {
let driver = null let driver = null
let session = null let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j delete attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = DeleteSchema.parse(body) const params = DeleteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import {
convertNeo4jTypesToJSON, convertNeo4jTypesToJSON,
createNeo4jDriver, createNeo4jDriver,
@@ -26,6 +27,12 @@ export async function POST(request: NextRequest) {
let driver = null let driver = null
let session = null let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j execute attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = ExecuteSchema.parse(body) const params = ExecuteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createNeo4jDriver } from '@/app/api/tools/neo4j/utils' import { createNeo4jDriver } from '@/app/api/tools/neo4j/utils'
import type { Neo4jNodeSchema, Neo4jRelationshipSchema } from '@/tools/neo4j/types' import type { Neo4jNodeSchema, Neo4jRelationshipSchema } from '@/tools/neo4j/types'
@@ -21,6 +22,12 @@ export async function POST(request: NextRequest) {
let driver = null let driver = null
let session = null let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j introspect attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = IntrospectSchema.parse(body) const params = IntrospectSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import {
convertNeo4jTypesToJSON, convertNeo4jTypesToJSON,
createNeo4jDriver, createNeo4jDriver,
@@ -26,6 +27,12 @@ export async function POST(request: NextRequest) {
let driver = null let driver = null
let session = null let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j merge attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = MergeSchema.parse(body) const params = MergeSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import {
convertNeo4jTypesToJSON, convertNeo4jTypesToJSON,
createNeo4jDriver, createNeo4jDriver,
@@ -26,6 +27,12 @@ export async function POST(request: NextRequest) {
let driver = null let driver = null
let session = null let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j query attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = QuerySchema.parse(body) const params = QuerySchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { import {
convertNeo4jTypesToJSON, convertNeo4jTypesToJSON,
createNeo4jDriver, createNeo4jDriver,
@@ -26,6 +27,12 @@ export async function POST(request: NextRequest) {
let driver = null let driver = null
let session = null let session = null
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
logger.warn(`[${requestId}] Unauthorized Neo4j update attempt`)
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = UpdateSchema.parse(body) const params = UpdateSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeDelete } from '@/app/api/tools/rds/utils' import { createRdsClient, executeDelete } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSDeleteAPI') const logger = createLogger('RDSDeleteAPI')
@@ -22,6 +23,11 @@ const DeleteSchema = z.object({
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = DeleteSchema.parse(body) const params = DeleteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeStatement } from '@/app/api/tools/rds/utils' import { createRdsClient, executeStatement } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSExecuteAPI') const logger = createLogger('RDSExecuteAPI')
@@ -19,6 +20,11 @@ const ExecuteSchema = z.object({
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = ExecuteSchema.parse(body) const params = ExecuteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeInsert } from '@/app/api/tools/rds/utils' import { createRdsClient, executeInsert } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSInsertAPI') const logger = createLogger('RDSInsertAPI')
@@ -22,6 +23,11 @@ const InsertSchema = z.object({
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = InsertSchema.parse(body) const params = InsertSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeIntrospect, type RdsEngine } from '@/app/api/tools/rds/utils' import { createRdsClient, executeIntrospect, type RdsEngine } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSIntrospectAPI') const logger = createLogger('RDSIntrospectAPI')
@@ -20,6 +21,11 @@ const IntrospectSchema = z.object({
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = IntrospectSchema.parse(body) const params = IntrospectSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeStatement, validateQuery } from '@/app/api/tools/rds/utils' import { createRdsClient, executeStatement, validateQuery } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSQueryAPI') const logger = createLogger('RDSQueryAPI')
@@ -19,6 +20,11 @@ const QuerySchema = z.object({
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = QuerySchema.parse(body) const params = QuerySchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeUpdate } from '@/app/api/tools/rds/utils' import { createRdsClient, executeUpdate } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSUpdateAPI') const logger = createLogger('RDSUpdateAPI')
@@ -25,6 +26,11 @@ const UpdateSchema = z.object({
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = UpdateSchema.parse(body) const params = UpdateSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createSqsClient, sendMessage } from '../utils' import { createSqsClient, sendMessage } from '../utils'
const logger = createLogger('SQSSendMessageAPI') const logger = createLogger('SQSSendMessageAPI')
@@ -21,6 +22,11 @@ const SendMessageSchema = z.object({
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8) const requestId = randomUUID().slice(0, 8)
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
try { try {
const body = await request.json() const body = await request.json()
const params = SendMessageSchema.parse(body) const params = SendMessageSchema.parse(body)

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env' import { env } from '@/lib/core/config/env'
import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction' import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction'
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
@@ -91,6 +92,11 @@ function substituteVariables(text: string, variables: Record<string, string> | u
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
let stagehand: StagehandType | null = null let stagehand: StagehandType | null = null
try { try {

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger' import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env' import { env } from '@/lib/core/config/env'
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils' import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
@@ -22,6 +23,11 @@ const requestSchema = z.object({
}) })
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
let stagehand: StagehandType | null = null let stagehand: StagehandType | null = null
try { try {

View File

@@ -203,7 +203,9 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'> <span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{field.name} {field.name}
</span> </span>
<Badge size='sm'>{field.type || 'string'}</Badge> <Badge variant='type' size='sm'>
{field.type || 'string'}
</Badge>
</div> </div>
</div> </div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'> <div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>

View File

@@ -511,7 +511,9 @@ export function McpDeploy({
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'> <span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{field.name} {field.name}
</span> </span>
<Badge size='sm'>{field.type}</Badge> <Badge variant='type' size='sm'>
{field.type}
</Badge>
</div> </div>
</div> </div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'> <div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>

View File

@@ -245,7 +245,9 @@ export function DocumentTagEntry({
{tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`} {tag.collapsed ? tag.tagName || `Tag ${index + 1}` : `Tag ${index + 1}`}
</span> </span>
{tag.collapsed && tag.tagName && ( {tag.collapsed && tag.tagName && (
<Badge size='sm'>{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}</Badge> <Badge variant='type' size='sm'>
{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}
</Badge>
)} )}
</div> </div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}> <div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>

View File

@@ -223,7 +223,11 @@ function InputMappingField({
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'> <span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{fieldName} {fieldName}
</span> </span>
{fieldType && <Badge size='sm'>{fieldType}</Badge>} {fieldType && (
<Badge variant='type' size='sm'>
{fieldType}
</Badge>
)}
</div> </div>
</div> </div>

View File

@@ -238,7 +238,9 @@ export function KnowledgeTagFilters({
{filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`} {filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`}
</span> </span>
{filter.collapsed && filter.tagName && ( {filter.collapsed && filter.tagName && (
<Badge size='sm'>{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}</Badge> <Badge variant='type' size='sm'>
{FIELD_TYPE_LABELS[filter.fieldType] || 'Text'}
</Badge>
)} )}
</div> </div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}> <div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>

View File

@@ -310,7 +310,11 @@ export function FieldFormat({
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'> <span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{field.name || `${title} ${index + 1}`} {field.name || `${title} ${index + 1}`}
</span> </span>
{field.name && showType && <Badge size='sm'>{field.type}</Badge>} {field.name && showType && (
<Badge variant='type' size='sm'>
{field.type}
</Badge>
)}
</div> </div>
<div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}> <div className='flex items-center gap-[8px] pl-[8px]' onClick={(e) => e.stopPropagation()}>
<Button variant='ghost' onClick={addField} disabled={isReadOnly} className='h-auto p-0'> <Button variant='ghost' onClick={addField} disabled={isReadOnly} className='h-auto p-0'>

View File

@@ -345,7 +345,11 @@ export function VariablesInput({
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'> <span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{assignment.variableName || `Variable ${index + 1}`} {assignment.variableName || `Variable ${index + 1}`}
</span> </span>
{assignment.variableName && <Badge size='sm'>{assignment.type}</Badge>} {assignment.variableName && (
<Badge variant='type' size='sm'>
{assignment.type}
</Badge>
)}
</div> </div>
<div <div
className='flex items-center gap-[8px] pl-[8px]' className='flex items-center gap-[8px] pl-[8px]'

View File

@@ -796,6 +796,7 @@ export const Terminal = memo(function Terminal() {
const terminalRef = useRef<HTMLElement>(null) const terminalRef = useRef<HTMLElement>(null)
const prevEntriesLengthRef = useRef(0) const prevEntriesLengthRef = useRef(0)
const prevWorkflowEntriesLengthRef = useRef(0) const prevWorkflowEntriesLengthRef = useRef(0)
const hasInitializedEntriesRef = useRef(false)
const isTerminalFocusedRef = useRef(false) const isTerminalFocusedRef = useRef(false)
const lastExpandedHeightRef = useRef<number>(DEFAULT_EXPANDED_HEIGHT) const lastExpandedHeightRef = useRef<number>(DEFAULT_EXPANDED_HEIGHT)
const setTerminalHeight = useTerminalStore((state) => state.setTerminalHeight) const setTerminalHeight = useTerminalStore((state) => state.setTerminalHeight)
@@ -1007,12 +1008,33 @@ export const Terminal = memo(function Terminal() {
return JSON.stringify(outputData, null, 2) return JSON.stringify(outputData, null, 2)
}, [outputData]) }, [outputData])
/**
* Reset entry tracking when switching workflows to ensure auto-open
* works correctly for each workflow independently.
*/
useEffect(() => {
hasInitializedEntriesRef.current = false
}, [activeWorkflowId])
/** /**
* Auto-open the terminal on new entries when "Open on run" is enabled. * Auto-open the terminal on new entries when "Open on run" is enabled.
* This mirrors the header toggle behavior by using expandToLastHeight, * This mirrors the header toggle behavior by using expandToLastHeight,
* ensuring we always get the same smooth height transition. * ensuring we always get the same smooth height transition.
*
* Skips the initial sync after console hydration to avoid auto-opening
* when persisted entries are restored on page refresh.
*/ */
useEffect(() => { useEffect(() => {
if (!hasConsoleHydrated) {
return
}
if (!hasInitializedEntriesRef.current) {
hasInitializedEntriesRef.current = true
prevWorkflowEntriesLengthRef.current = allWorkflowEntries.length
return
}
if (!openOnRun) { if (!openOnRun) {
prevWorkflowEntriesLengthRef.current = allWorkflowEntries.length prevWorkflowEntriesLengthRef.current = allWorkflowEntries.length
return return
@@ -1026,7 +1048,14 @@ export const Terminal = memo(function Terminal() {
} }
prevWorkflowEntriesLengthRef.current = currentLength prevWorkflowEntriesLengthRef.current = currentLength
}, [allWorkflowEntries.length, expandToLastHeight, openOnRun, isExpanded]) }, [
allWorkflowEntries.length,
expandToLastHeight,
openOnRun,
isExpanded,
hasConsoleHydrated,
activeWorkflowId,
])
/** /**
* Handle row click - toggle if clicking same entry * Handle row click - toggle if clicking same entry

View File

@@ -66,7 +66,6 @@ import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings' import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
import { useCanvasViewport } from '@/hooks/use-canvas-viewport' import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup' import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useCanvasModeStore } from '@/stores/canvas-mode' import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useChatStore } from '@/stores/chat/store' import { useChatStore } from '@/stores/chat/store'
@@ -100,39 +99,33 @@ const logger = createLogger('Workflow')
const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 } const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 }
/** /**
* Gets the center of the current viewport in flow coordinates * Calculates the offset to paste blocks at viewport center, or simple offset for nested blocks
*/
function getViewportCenter(
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
): { x: number; y: number } {
const flowContainer = document.querySelector('.react-flow')
if (!flowContainer) {
return screenToFlowPosition({
x: window.innerWidth / 2,
y: window.innerHeight / 2,
})
}
const rect = flowContainer.getBoundingClientRect()
return screenToFlowPosition({
x: rect.width / 2,
y: rect.height / 2,
})
}
/**
* Calculates the offset to paste blocks at viewport center
*/ */
function calculatePasteOffset( function calculatePasteOffset(
clipboard: { clipboard: {
blocks: Record<string, { position: { x: number; y: number }; type: string; height?: number }> blocks: Record<
string,
{
position: { x: number; y: number }
type: string
height?: number
data?: { parentId?: string }
}
>
} | null, } | null,
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number } viewportCenter: { x: number; y: number },
existingBlocks: Record<string, { id: string }> = {}
): { x: number; y: number } { ): { x: number; y: number } {
if (!clipboard) return DEFAULT_PASTE_OFFSET if (!clipboard) return DEFAULT_PASTE_OFFSET
const clipboardBlocks = Object.values(clipboard.blocks) const clipboardBlocks = Object.values(clipboard.blocks)
if (clipboardBlocks.length === 0) return DEFAULT_PASTE_OFFSET if (clipboardBlocks.length === 0) return DEFAULT_PASTE_OFFSET
const allBlocksNested = clipboardBlocks.every(
(b) => b.data?.parentId && existingBlocks[b.data.parentId]
)
if (allBlocksNested) return DEFAULT_PASTE_OFFSET
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x)) const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
const maxX = Math.max( const maxX = Math.max(
...clipboardBlocks.map((b) => { ...clipboardBlocks.map((b) => {
@@ -155,8 +148,6 @@ function calculatePasteOffset(
) )
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 } const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
const viewportCenter = getViewportCenter(screenToFlowPosition)
return { return {
x: viewportCenter.x - clipboardCenter.x, x: viewportCenter.x - clipboardCenter.x,
y: viewportCenter.y - clipboardCenter.y, y: viewportCenter.y - clipboardCenter.y,
@@ -266,7 +257,7 @@ const WorkflowContent = React.memo(() => {
const router = useRouter() const router = useRouter()
const reactFlowInstance = useReactFlow() const reactFlowInstance = useReactFlow()
const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance) const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance)
const { emitCursorUpdate } = useSocket() const { emitCursorUpdate } = useSocket()
const workspaceId = params.workspaceId as string const workspaceId = params.workspaceId as string
@@ -330,16 +321,12 @@ const WorkflowContent = React.memo(() => {
const isAutoConnectEnabled = useAutoConnect() const isAutoConnectEnabled = useAutoConnect()
const autoConnectRef = useRef(isAutoConnectEnabled) const autoConnectRef = useRef(isAutoConnectEnabled)
useEffect(() => { autoConnectRef.current = isAutoConnectEnabled
autoConnectRef.current = isAutoConnectEnabled
}, [isAutoConnectEnabled])
// Panel open states for context menu // Panel open states for context menu
const isVariablesOpen = useVariablesStore((state) => state.isOpen) const isVariablesOpen = useVariablesStore((state) => state.isOpen)
const isChatOpen = useChatStore((state) => state.isChatOpen) const isChatOpen = useChatStore((state) => state.isChatOpen)
// Permission config for invitation control
const { isInvitationsDisabled } = usePermissionConfig()
const snapGrid: [number, number] = useMemo( const snapGrid: [number, number] = useMemo(
() => [snapToGridSize, snapToGridSize], () => [snapToGridSize, snapToGridSize],
[snapToGridSize] [snapToGridSize]
@@ -473,11 +460,16 @@ const WorkflowContent = React.memo(() => {
) )
/** Re-applies diff markers when blocks change after socket rehydration. */ /** Re-applies diff markers when blocks change after socket rehydration. */
const blocksRef = useRef(blocks) const diffBlocksRef = useRef(blocks)
useEffect(() => { useEffect(() => {
if (!isWorkflowReady) return if (!isWorkflowReady) return
if (hasActiveDiff && isDiffReady && blocks !== blocksRef.current) {
blocksRef.current = blocks const blocksChanged = blocks !== diffBlocksRef.current
if (!blocksChanged) return
diffBlocksRef.current = blocks
if (hasActiveDiff && isDiffReady) {
setTimeout(() => reapplyDiffMarkers(), 0) setTimeout(() => reapplyDiffMarkers(), 0)
} }
}, [blocks, hasActiveDiff, isDiffReady, reapplyDiffMarkers, isWorkflowReady]) }, [blocks, hasActiveDiff, isDiffReady, reapplyDiffMarkers, isWorkflowReady])
@@ -540,8 +532,7 @@ const WorkflowContent = React.memo(() => {
}) })
}, [edges, isShowingDiff, isDiffReady, diffAnalysis, blocks]) }, [edges, isShowingDiff, isDiffReady, diffAnalysis, blocks])
const { userPermissions, workspacePermissions, permissionsError } = const { userPermissions } = useWorkspacePermissionsContext()
useWorkspacePermissionsContext()
/** Returns read-only permissions when viewing snapshot, otherwise user permissions. */ /** Returns read-only permissions when viewing snapshot, otherwise user permissions. */
const effectivePermissions = useMemo(() => { const effectivePermissions = useMemo(() => {
@@ -777,25 +768,6 @@ const WorkflowContent = React.memo(() => {
[isErrorConnectionDrag] [isErrorConnectionDrag]
) )
/** Logs permission loading results for debugging. */
useEffect(() => {
if (permissionsError) {
logger.error('Failed to load workspace permissions', {
workspaceId,
error: permissionsError,
})
} else if (workspacePermissions) {
logger.info('Workspace permissions loaded in workflow', {
workspaceId,
userCount: workspacePermissions.total,
permissions: workspacePermissions.users.map((u) => ({
email: u.email,
permissions: u.permissionType,
})),
})
}
}, [workspacePermissions, permissionsError, workspaceId])
const updateNodeParent = useCallback( const updateNodeParent = useCallback(
(nodeId: string, newParentId: string | null, affectedEdges: any[] = []) => { (nodeId: string, newParentId: string | null, affectedEdges: any[] = []) => {
const node = getNodes().find((n: any) => n.id === nodeId) const node = getNodes().find((n: any) => n.id === nodeId)
@@ -901,11 +873,125 @@ const WorkflowContent = React.memo(() => {
* Consolidates shared logic for context paste, duplicate, and keyboard paste. * Consolidates shared logic for context paste, duplicate, and keyboard paste.
*/ */
const executePasteOperation = useCallback( const executePasteOperation = useCallback(
(operation: 'paste' | 'duplicate', pasteOffset: { x: number; y: number }) => { (
const pasteData = preparePasteData(pasteOffset) operation: 'paste' | 'duplicate',
pasteOffset: { x: number; y: number },
targetContainer?: {
loopId: string
loopPosition: { x: number; y: number }
dimensions: { width: number; height: number }
} | null,
pasteTargetPosition?: { x: number; y: number }
) => {
// For context menu paste into a subflow, calculate offset to center blocks at click position
// Skip click-position centering if blocks came from inside a subflow (relative coordinates)
let effectiveOffset = pasteOffset
if (targetContainer && pasteTargetPosition && clipboard) {
const clipboardBlocks = Object.values(clipboard.blocks)
// Only use click-position centering for top-level blocks (absolute coordinates)
// Blocks with parentId have relative positions that can't be mixed with absolute click position
const hasNestedBlocks = clipboardBlocks.some((b) => b.data?.parentId)
if (clipboardBlocks.length > 0 && !hasNestedBlocks) {
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
const maxX = Math.max(
...clipboardBlocks.map((b) => b.position.x + BLOCK_DIMENSIONS.FIXED_WIDTH)
)
const minY = Math.min(...clipboardBlocks.map((b) => b.position.y))
const maxY = Math.max(
...clipboardBlocks.map((b) => b.position.y + BLOCK_DIMENSIONS.MIN_HEIGHT)
)
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
effectiveOffset = {
x: pasteTargetPosition.x - clipboardCenter.x,
y: pasteTargetPosition.y - clipboardCenter.y,
}
}
}
const pasteData = preparePasteData(effectiveOffset)
if (!pasteData) return if (!pasteData) return
const pastedBlocksArray = Object.values(pasteData.blocks) let pastedBlocksArray = Object.values(pasteData.blocks)
// If pasting into a subflow, adjust blocks to be children of that subflow
if (targetContainer) {
// Check if any pasted block is a trigger - triggers cannot be in subflows
const hasTrigger = pastedBlocksArray.some((b) => TriggerUtils.isTriggerBlock(b))
if (hasTrigger) {
addNotification({
level: 'error',
message: 'Triggers cannot be placed inside loop or parallel subflows.',
workflowId: activeWorkflowId || undefined,
})
return
}
// Check if any pasted block is a subflow - subflows cannot be nested
const hasSubflow = pastedBlocksArray.some((b) => b.type === 'loop' || b.type === 'parallel')
if (hasSubflow) {
addNotification({
level: 'error',
message: 'Subflows cannot be nested inside other subflows.',
workflowId: activeWorkflowId || undefined,
})
return
}
// Adjust each block's position to be relative to the container and set parentId
pastedBlocksArray = pastedBlocksArray.map((block) => {
// For blocks already nested (have parentId), positions are already relative - use as-is
// For top-level blocks, convert absolute position to relative by subtracting container position
const wasNested = Boolean(block.data?.parentId)
const relativePosition = wasNested
? { x: block.position.x, y: block.position.y }
: {
x: block.position.x - targetContainer.loopPosition.x,
y: block.position.y - targetContainer.loopPosition.y,
}
// Clamp position to keep block inside container (below header)
const clampedPosition = {
x: Math.max(
CONTAINER_DIMENSIONS.LEFT_PADDING,
Math.min(
relativePosition.x,
targetContainer.dimensions.width -
BLOCK_DIMENSIONS.FIXED_WIDTH -
CONTAINER_DIMENSIONS.RIGHT_PADDING
)
),
y: Math.max(
CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING,
Math.min(
relativePosition.y,
targetContainer.dimensions.height -
BLOCK_DIMENSIONS.MIN_HEIGHT -
CONTAINER_DIMENSIONS.BOTTOM_PADDING
)
),
}
return {
...block,
position: clampedPosition,
data: {
...block.data,
parentId: targetContainer.loopId,
extent: 'parent',
},
}
})
// Update pasteData.blocks with the modified blocks
pasteData.blocks = pastedBlocksArray.reduce(
(acc, block) => {
acc[block.id] = block
return acc
},
{} as Record<string, (typeof pastedBlocksArray)[0]>
)
}
const validation = validateTriggerPaste(pastedBlocksArray, blocks, operation) const validation = validateTriggerPaste(pastedBlocksArray, blocks, operation)
if (!validation.isValid) { if (!validation.isValid) {
addNotification({ addNotification({
@@ -926,21 +1012,47 @@ const WorkflowContent = React.memo(() => {
pasteData.parallels, pasteData.parallels,
pasteData.subBlockValues pasteData.subBlockValues
) )
// Resize container if we pasted into a subflow
if (targetContainer) {
resizeLoopNodesWrapper()
}
}, },
[ [
preparePasteData, preparePasteData,
blocks, blocks,
clipboard,
addNotification, addNotification,
activeWorkflowId, activeWorkflowId,
collaborativeBatchAddBlocks, collaborativeBatchAddBlocks,
setPendingSelection, setPendingSelection,
resizeLoopNodesWrapper,
] ]
) )
const handleContextPaste = useCallback(() => { const handleContextPaste = useCallback(() => {
if (!hasClipboard()) return if (!hasClipboard()) return
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
}, [hasClipboard, executePasteOperation, clipboard, screenToFlowPosition]) // Convert context menu position to flow coordinates and check if inside a subflow
const flowPosition = screenToFlowPosition(contextMenuPosition)
const targetContainer = isPointInLoopNode(flowPosition)
executePasteOperation(
'paste',
calculatePasteOffset(clipboard, getViewportCenter(), blocks),
targetContainer,
flowPosition // Pass the click position so blocks are centered at where user right-clicked
)
}, [
hasClipboard,
executePasteOperation,
clipboard,
getViewportCenter,
screenToFlowPosition,
contextMenuPosition,
isPointInLoopNode,
blocks,
])
const handleContextDuplicate = useCallback(() => { const handleContextDuplicate = useCallback(() => {
copyBlocks(contextMenuBlocks.map((b) => b.id)) copyBlocks(contextMenuBlocks.map((b) => b.id))
@@ -1006,10 +1118,6 @@ const WorkflowContent = React.memo(() => {
setIsChatOpen(!isChatOpen) setIsChatOpen(!isChatOpen)
}, []) }, [])
const handleContextInvite = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-invite-modal'))
}, [])
useEffect(() => { useEffect(() => {
let cleanup: (() => void) | null = null let cleanup: (() => void) | null = null
@@ -1054,7 +1162,10 @@ const WorkflowContent = React.memo(() => {
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') { } else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
if (effectivePermissions.canEdit && hasClipboard()) { if (effectivePermissions.canEdit && hasClipboard()) {
event.preventDefault() event.preventDefault()
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition)) executePasteOperation(
'paste',
calculatePasteOffset(clipboard, getViewportCenter(), blocks)
)
} }
} }
} }
@@ -1074,8 +1185,9 @@ const WorkflowContent = React.memo(() => {
hasClipboard, hasClipboard,
effectivePermissions.canEdit, effectivePermissions.canEdit,
clipboard, clipboard,
screenToFlowPosition, getViewportCenter,
executePasteOperation, executePasteOperation,
blocks,
]) ])
/** /**
@@ -1507,7 +1619,7 @@ const WorkflowContent = React.memo(() => {
if (!type) return if (!type) return
if (type === 'connectionBlock') return if (type === 'connectionBlock') return
const basePosition = getViewportCenter(screenToFlowPosition) const basePosition = getViewportCenter()
if (type === 'loop' || type === 'parallel') { if (type === 'loop' || type === 'parallel') {
const id = crypto.randomUUID() const id = crypto.randomUUID()
@@ -1576,7 +1688,7 @@ const WorkflowContent = React.memo(() => {
) )
} }
}, [ }, [
screenToFlowPosition, getViewportCenter,
blocks, blocks,
addBlock, addBlock,
effectivePermissions.canEdit, effectivePermissions.canEdit,
@@ -2050,6 +2162,8 @@ const WorkflowContent = React.memo(() => {
// Local state for nodes - allows smooth drag without store updates on every frame // Local state for nodes - allows smooth drag without store updates on every frame
const [displayNodes, setDisplayNodes] = useState<Node[]>([]) const [displayNodes, setDisplayNodes] = useState<Node[]>([])
// Sync derivedNodes to displayNodes while preserving selection state
// This effect handles both normal sync and pending selection from paste/duplicate
useEffect(() => { useEffect(() => {
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection // Check for pending selection (from paste/duplicate), otherwise preserve existing selection
if (pendingSelection && pendingSelection.length > 0) { if (pendingSelection && pendingSelection.length > 0) {
@@ -2079,7 +2193,6 @@ const WorkflowContent = React.memo(() => {
}, [derivedNodes, blocks, pendingSelection, clearPendingSelection]) }, [derivedNodes, blocks, pendingSelection, clearPendingSelection])
// Phase 2: When displayNodes updates, check if pending zoom blocks are ready // Phase 2: When displayNodes updates, check if pending zoom blocks are ready
// (Phase 1 is located earlier in the file where pendingZoomBlockIdsRef is defined)
useEffect(() => { useEffect(() => {
const pendingBlockIds = pendingZoomBlockIdsRef.current const pendingBlockIds = pendingZoomBlockIdsRef.current
if (!pendingBlockIds || pendingBlockIds.size === 0) { if (!pendingBlockIds || pendingBlockIds.size === 0) {
@@ -2270,40 +2383,6 @@ const WorkflowContent = React.memo(() => {
resizeLoopNodesWrapper() resizeLoopNodesWrapper()
}, [derivedNodes, resizeLoopNodesWrapper, isWorkflowReady]) }, [derivedNodes, resizeLoopNodesWrapper, isWorkflowReady])
/** Cleans up orphaned nodes with invalid parent references after deletion. */
useEffect(() => {
if (!isWorkflowReady) return
// Create a mapping of node IDs to check for missing parent references
const nodeIds = new Set(Object.keys(blocks))
// Check for nodes with invalid parent references and collect updates
const orphanedUpdates: Array<{
id: string
position: { x: number; y: number }
parentId: string
}> = []
Object.entries(blocks).forEach(([id, block]) => {
const parentId = block.data?.parentId
// If block has a parent reference but parent no longer exists
if (parentId && !nodeIds.has(parentId)) {
logger.warn('Found orphaned node with invalid parent reference', {
nodeId: id,
missingParentId: parentId,
})
const absolutePosition = getNodeAbsolutePosition(id)
orphanedUpdates.push({ id, position: absolutePosition, parentId: '' })
}
})
// Batch update all orphaned nodes at once
if (orphanedUpdates.length > 0) {
batchUpdateBlocksWithParent(orphanedUpdates)
}
}, [blocks, batchUpdateBlocksWithParent, getNodeAbsolutePosition, isWorkflowReady])
/** Handles edge removal changes. */ /** Handles edge removal changes. */
const onEdgesChange = useCallback( const onEdgesChange = useCallback(
(changes: any) => { (changes: any) => {

View File

@@ -611,7 +611,9 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'> <span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{name} {name}
</span> </span>
<Badge size='sm'>{prop.type || 'any'}</Badge> <Badge variant='type' size='sm'>
{prop.type || 'any'}
</Badge>
</div> </div>
</div> </div>
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'> <div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>

View File

@@ -16,6 +16,7 @@ const badgeVariants = cva(
'gap-[4px] rounded-[40px] border border-[var(--border)] text-[var(--text-secondary)] bg-[var(--surface-4)] hover:text-[var(--text-primary)] hover:border-[var(--border-1)] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]', 'gap-[4px] rounded-[40px] border border-[var(--border)] text-[var(--text-secondary)] bg-[var(--surface-4)] hover:text-[var(--text-primary)] hover:border-[var(--border-1)] hover:bg-[var(--surface-6)] dark:hover:bg-[var(--surface-5)]',
outline: outline:
'gap-[4px] rounded-[40px] border border-[var(--border-1)] bg-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-5)] dark:hover:bg-transparent dark:hover:border-[var(--surface-6)]', 'gap-[4px] rounded-[40px] border border-[var(--border-1)] bg-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-5)] dark:hover:bg-transparent dark:hover:border-[var(--surface-6)]',
type: 'gap-[4px] rounded-[40px] border border-[var(--border)] text-[var(--text-secondary)] bg-[var(--surface-4)] dark:bg-[var(--surface-6)]',
green: `${STATUS_BASE} bg-[#bbf7d0] text-[#15803d] dark:bg-[rgba(34,197,94,0.2)] dark:text-[#86efac]`, green: `${STATUS_BASE} bg-[#bbf7d0] text-[#15803d] dark:bg-[rgba(34,197,94,0.2)] dark:text-[#86efac]`,
red: `${STATUS_BASE} bg-[#fecaca] text-[var(--text-error)] dark:bg-[#551a1a] dark:text-[var(--text-error)]`, red: `${STATUS_BASE} bg-[#fecaca] text-[var(--text-error)] dark:bg-[#551a1a] dark:text-[var(--text-error)]`,
gray: `${STATUS_BASE} bg-[#e7e5e4] text-[#57534e] dark:bg-[var(--terminal-status-info-bg)] dark:text-[var(--terminal-status-info-color)]`, gray: `${STATUS_BASE} bg-[#e7e5e4] text-[#57534e] dark:bg-[var(--terminal-status-info-bg)] dark:text-[var(--terminal-status-info-color)]`,
@@ -84,7 +85,7 @@ export interface BadgeProps
* *
* @remarks * @remarks
* Supports two categories of variants: * Supports two categories of variants:
* - **Bordered**: `default`, `outline` - traditional badges with borders * - **Bordered**: `default`, `outline`, `type` - traditional badges with borders
* - **Status colors**: `green`, `red`, `gray`, `blue`, `blue-secondary`, `purple`, * - **Status colors**: `green`, `red`, `gray`, `blue`, `blue-secondary`, `purple`,
* `orange`, `amber`, `teal`, `cyan`, `gray-secondary` - borderless colored badges * `orange`, `amber`, `teal`, `cyan`, `gray-secondary` - borderless colored badges
* *

View File

@@ -57,31 +57,16 @@ function getVisibleCanvasBounds(): VisibleBounds {
* Gets the center of the visible canvas in screen coordinates. * Gets the center of the visible canvas in screen coordinates.
*/ */
function getVisibleCanvasCenter(): { x: number; y: number } { function getVisibleCanvasCenter(): { x: number; y: number } {
const style = getComputedStyle(document.documentElement) const bounds = getVisibleCanvasBounds()
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
const terminalHeight = Number.parseInt(style.getPropertyValue('--terminal-height') || '0', 10)
const flowContainer = document.querySelector('.react-flow') const flowContainer = document.querySelector('.react-flow')
if (!flowContainer) { const rect = flowContainer?.getBoundingClientRect()
const visibleWidth = window.innerWidth - sidebarWidth - panelWidth const containerLeft = rect?.left ?? 0
const visibleHeight = window.innerHeight - terminalHeight const containerTop = rect?.top ?? 0
return {
x: sidebarWidth + visibleWidth / 2,
y: visibleHeight / 2,
}
}
const rect = flowContainer.getBoundingClientRect()
// Calculate actual visible area in screen coordinates
const visibleLeft = Math.max(rect.left, sidebarWidth)
const visibleRight = Math.min(rect.right, window.innerWidth - panelWidth)
const visibleBottom = Math.min(rect.bottom, window.innerHeight - terminalHeight)
return { return {
x: (visibleLeft + visibleRight) / 2, x: containerLeft + bounds.offsetLeft + bounds.width / 2,
y: (rect.top + visibleBottom) / 2, y: containerTop + bounds.height / 2,
} }
} }

View File

@@ -14,6 +14,7 @@ import {
loadDeployedWorkflowState, loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables, loadWorkflowFromNormalizedTables,
} from '@/lib/workflows/persistence/utils' } from '@/lib/workflows/persistence/utils'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { updateWorkflowRunCounts } from '@/lib/workflows/utils' import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
import { Executor } from '@/executor' import { Executor } from '@/executor'
@@ -26,7 +27,6 @@ import type {
import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types' import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors' import { hasExecutionResult } from '@/executor/utils/errors'
import { Serializer } from '@/serializer' import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
const logger = createLogger('ExecutionCore') const logger = createLogger('ExecutionCore')
@@ -172,8 +172,7 @@ export async function executeWorkflowCore(
logger.info(`[${requestId}] Using deployed workflow state (deployed execution)`) logger.info(`[${requestId}] Using deployed workflow state (deployed execution)`)
} }
// Merge block states const mergedStates = mergeSubblockStateWithValues(blocks)
const mergedStates = mergeSubblockState(blocks)
const personalEnvUserId = const personalEnvUserId =
metadata.isClientSession && metadata.sessionUserId metadata.isClientSession && metadata.sessionUserId

View File

@@ -1,52 +0,0 @@
/**
* Server-Safe Workflow Utilities
*
* This file contains workflow utility functions that can be safely imported
* by server-side API routes without causing client/server boundary violations.
*
* Unlike the main utils.ts file, this does NOT import any client-side stores
* or React hooks, making it safe for use in Next.js API routes.
*/
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Server-safe version of mergeSubblockState for API routes
*
* Merges workflow block states with provided subblock values while maintaining block structure.
* This version takes explicit subblock values instead of reading from client stores.
*
* @param blocks - Block configurations from workflow state
* @param subBlockValues - Object containing subblock values keyed by blockId -> subBlockId -> value
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Merged block states with updated values
*/
export function mergeSubblockState(
blocks: Record<string, BlockState>,
subBlockValues: Record<string, Record<string, any>> = {},
blockId?: string
): Record<string, BlockState> {
return mergeSubblockStateWithValues(blocks, subBlockValues, blockId)
}
/**
* Server-safe async version of mergeSubblockState for API routes
*
* Asynchronously merges workflow block states with provided subblock values.
* This version takes explicit subblock values instead of reading from client stores.
*
* @param blocks - Block configurations from workflow state
* @param subBlockValues - Object containing subblock values keyed by blockId -> subBlockId -> value
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Promise resolving to merged block states with updated values
*/
export async function mergeSubblockStateAsync(
blocks: Record<string, BlockState>,
subBlockValues: Record<string, Record<string, any>> = {},
blockId?: string
): Promise<Record<string, BlockState>> {
// Since we're not reading from client stores, we can just return the sync version
// The async nature was only needed for the client-side store operations
return mergeSubblockState(blocks, subBlockValues, blockId)
}

View File

@@ -7,7 +7,7 @@ import {
} from '@sim/testing' } from '@sim/testing'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { normalizeName } from '@/executor/constants' import { normalizeName } from '@/executor/constants'
import { getUniqueBlockName } from './utils' import { getUniqueBlockName, regenerateBlockIds } from './utils'
describe('normalizeName', () => { describe('normalizeName', () => {
it.concurrent('should convert to lowercase', () => { it.concurrent('should convert to lowercase', () => {
@@ -223,3 +223,213 @@ describe('getUniqueBlockName', () => {
expect(getUniqueBlockName('myblock', existingBlocks)).toBe('myblock 2') expect(getUniqueBlockName('myblock', existingBlocks)).toBe('myblock 2')
}) })
}) })
describe('regenerateBlockIds', () => {
const positionOffset = { x: 50, y: 50 }
it('should preserve parentId and use same offset when duplicating a block inside an existing subflow', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const existingBlocks = {
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
}
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset, // { x: 50, y: 50 } - small offset, used as-is
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const duplicatedBlock = newBlocks[0]
expect(duplicatedBlock.data?.parentId).toBe(loopId)
expect(duplicatedBlock.data?.extent).toBe('parent')
expect(duplicatedBlock.position).toEqual({ x: 150, y: 100 })
})
it('should clear parentId when parent does not exist in paste set or existing blocks', () => {
const nonExistentParentId = 'non-existent-loop'
const childId = 'child-1'
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: nonExistentParentId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const duplicatedBlock = newBlocks[0]
expect(duplicatedBlock.data?.parentId).toBeUndefined()
expect(duplicatedBlock.data?.extent).toBeUndefined()
})
it('should remap parentId when copying both parent and child together', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const blocksToCopy = {
[loopId]: createLoopBlock({
id: loopId,
name: 'Loop 1',
position: { x: 200, y: 200 },
}),
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(2)
const newLoop = newBlocks.find((b) => b.type === 'loop')
const newChild = newBlocks.find((b) => b.type === 'agent')
expect(newLoop).toBeDefined()
expect(newChild).toBeDefined()
expect(newChild!.data?.parentId).toBe(newLoop!.id)
expect(newChild!.data?.extent).toBe('parent')
expect(newLoop!.position).toEqual({ x: 250, y: 250 })
expect(newChild!.position).toEqual({ x: 100, y: 50 })
})
it('should apply offset to top-level blocks', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].position).toEqual({ x: 150, y: 150 })
})
it('should generate unique names for duplicated blocks', () => {
const blockId = 'block-1'
const existingBlocks = {
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
}
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].name).toBe('Agent 2')
})
it('should ignore large viewport offset for blocks inside existing subflows', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const existingBlocks = {
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
}
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const largeViewportOffset = { x: 2000, y: 1500 }
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
largeViewportOffset,
existingBlocks,
getUniqueBlockName
)
const duplicatedBlock = Object.values(result.blocks)[0]
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
expect(duplicatedBlock.data?.parentId).toBe(loopId)
})
})

View File

@@ -1,7 +1,8 @@
import type { Edge } from 'reactflow' import type { Edge } from 'reactflow'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks' import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock, normalizeName } from '@/executor/constants' import { isAnnotationOnlyBlock, normalizeName } from '@/executor/constants'
@@ -16,7 +17,8 @@ import type {
} from '@/stores/workflows/workflow/types' } from '@/stores/workflows/workflow/types'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath'] /** Threshold to detect viewport-based offsets vs small duplicate offsets */
const LARGE_OFFSET_THRESHOLD = 300
/** /**
* Checks if an edge is valid (source and target exist, not annotation-only, target is not a trigger) * Checks if an edge is valid (source and target exist, not annotation-only, target is not a trigger)
@@ -204,64 +206,6 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
} }
} }
export interface PrepareDuplicateBlockStateOptions {
sourceBlock: BlockState
newId: string
newName: string
positionOffset: { x: number; y: number }
subBlockValues: Record<string, unknown>
}
/**
* Prepares a BlockState for duplicating an existing block.
* Copies block structure and subblock values, excluding webhook fields.
*/
export function prepareDuplicateBlockState(options: PrepareDuplicateBlockStateOptions): {
block: BlockState
subBlockValues: Record<string, unknown>
} {
const { sourceBlock, newId, newName, positionOffset, subBlockValues } = options
const filteredSubBlockValues = Object.fromEntries(
Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key))
)
const baseSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
: {}
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
if (field in baseSubBlocks) {
delete baseSubBlocks[field]
}
})
const mergedSubBlocks = mergeSubBlockValues(baseSubBlocks, filteredSubBlockValues) as Record<
string,
SubBlockState
>
const block: BlockState = {
id: newId,
type: sourceBlock.type,
name: newName,
position: {
x: sourceBlock.position.x + positionOffset.x,
y: sourceBlock.position.y + positionOffset.y,
},
data: sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {},
subBlocks: mergedSubBlocks,
outputs: sourceBlock.outputs ? JSON.parse(JSON.stringify(sourceBlock.outputs)) : {},
enabled: sourceBlock.enabled ?? true,
horizontalHandles: sourceBlock.horizontalHandles ?? true,
advancedMode: sourceBlock.advancedMode ?? false,
triggerMode: sourceBlock.triggerMode ?? false,
height: sourceBlock.height || 0,
}
return { block, subBlockValues: filteredSubBlockValues }
}
/** /**
* Merges workflow block states with subblock values while maintaining block structure * Merges workflow block states with subblock values while maintaining block structure
* @param blocks - Block configurations from workflow store * @param blocks - Block configurations from workflow store
@@ -348,78 +292,6 @@ export function mergeSubblockState(
) )
} }
/**
* Asynchronously merges workflow block states with subblock values
* Ensures all values are properly resolved before returning
*
* @param blocks - Block configurations from workflow store
* @param workflowId - ID of the workflow to merge values for
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Promise resolving to merged block states with updated values
*/
export async function mergeSubblockStateAsync(
blocks: Record<string, BlockState>,
workflowId?: string,
blockId?: string
): Promise<Record<string, BlockState>> {
const subBlockStore = useSubBlockStore.getState()
if (workflowId) {
const workflowValues = subBlockStore.workflowValues[workflowId] || {}
return mergeSubblockStateWithValues(blocks, workflowValues, blockId)
}
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
// Process blocks in parallel for better performance
const processedBlockEntries = await Promise.all(
Object.entries(blocksToProcess).map(async ([id, block]) => {
// Skip if block is undefined or doesn't have subBlocks
if (!block || !block.subBlocks) {
return [id, block] as const
}
// Process all subblocks in parallel
const subBlockEntries = await Promise.all(
Object.entries(block.subBlocks).map(async ([subBlockId, subBlock]) => {
// Skip if subBlock is undefined
if (!subBlock) {
return null
}
const storedValue = subBlockStore.getValue(id, subBlockId)
return [
subBlockId,
{
...subBlock,
value: (storedValue !== undefined && storedValue !== null
? storedValue
: subBlock.value) as SubBlockState['value'],
},
] as const
})
)
// Convert entries back to an object
const mergedSubBlocks = Object.fromEntries(
subBlockEntries.filter((entry): entry is readonly [string, SubBlockState] => entry !== null)
) as Record<string, SubBlockState>
// Return the full block state with updated subBlocks (including orphaned values)
return [
id,
{
...block,
subBlocks: mergedSubBlocks,
},
] as const
})
)
return Object.fromEntries(processedBlockEntries) as Record<string, BlockState>
}
function updateValueReferences(value: unknown, nameMap: Map<string, string>): unknown { function updateValueReferences(value: unknown, nameMap: Map<string, string>): unknown {
if (typeof value === 'string') { if (typeof value === 'string') {
let updatedValue = value let updatedValue = value
@@ -444,14 +316,10 @@ function updateValueReferences(value: unknown, nameMap: Map<string, string>): un
function updateBlockReferences( function updateBlockReferences(
blocks: Record<string, BlockState>, blocks: Record<string, BlockState>,
idMap: Map<string, string>,
nameMap: Map<string, string>, nameMap: Map<string, string>,
clearTriggerRuntimeValues = false clearTriggerRuntimeValues = false
): void { ): void {
Object.entries(blocks).forEach(([_, block]) => { Object.entries(blocks).forEach(([_, block]) => {
// NOTE: parentId remapping is handled in regenerateBlockIds' second pass.
// Do NOT remap parentId here as it would incorrectly clear already-mapped IDs.
if (block.subBlocks) { if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => { Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (clearTriggerRuntimeValues && TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) { if (clearTriggerRuntimeValues && TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) {
@@ -533,7 +401,7 @@ export function regenerateWorkflowIds(
}) })
} }
updateBlockReferences(newBlocks, blockIdMap, nameMap, clearTriggerRuntimeValues) updateBlockReferences(newBlocks, nameMap, clearTriggerRuntimeValues)
return { return {
blocks: newBlocks, blocks: newBlocks,
@@ -574,13 +442,36 @@ export function regenerateBlockIds(
const newNormalizedName = normalizeName(newName) const newNormalizedName = normalizeName(newName)
nameMap.set(oldNormalizedName, newNormalizedName) nameMap.set(oldNormalizedName, newNormalizedName)
// Check if this block has a parent that's also being copied // Determine position offset based on parent relationship:
// If so, it's a nested block and should keep its relative position (no offset) // 1. Parent also being copied: keep exact relative position (parent itself will be offset)
// Only top-level blocks (no parent in the paste set) get the position offset // 2. Parent exists in existing workflow: use provided offset, but cap large viewport-based
// offsets since they don't make sense for relative positions
// 3. Top-level block (no parent): apply full paste offset
const hasParentInPasteSet = block.data?.parentId && blocks[block.data.parentId] const hasParentInPasteSet = block.data?.parentId && blocks[block.data.parentId]
const newPosition = hasParentInPasteSet const hasParentInExistingWorkflow =
? { x: block.position.x, y: block.position.y } // Keep relative position block.data?.parentId && existingBlockNames[block.data.parentId]
: { x: block.position.x + positionOffset.x, y: block.position.y + positionOffset.y }
let newPosition: Position
if (hasParentInPasteSet) {
// Parent also being copied - keep exact relative position
newPosition = { x: block.position.x, y: block.position.y }
} else if (hasParentInExistingWorkflow) {
// Block stays in existing subflow - use provided offset unless it's viewport-based (large)
const isLargeOffset =
Math.abs(positionOffset.x) > LARGE_OFFSET_THRESHOLD ||
Math.abs(positionOffset.y) > LARGE_OFFSET_THRESHOLD
const effectiveOffset = isLargeOffset ? DEFAULT_DUPLICATE_OFFSET : positionOffset
newPosition = {
x: block.position.x + effectiveOffset.x,
y: block.position.y + effectiveOffset.y,
}
} else {
// Top-level block - apply full paste offset
newPosition = {
x: block.position.x + positionOffset.x,
y: block.position.y + positionOffset.y,
}
}
// Placeholder block - we'll update parentId in second pass // Placeholder block - we'll update parentId in second pass
const newBlock: BlockState = { const newBlock: BlockState = {
@@ -602,19 +493,30 @@ export function regenerateBlockIds(
}) })
// Second pass: update parentId references for nested blocks // Second pass: update parentId references for nested blocks
// If a block's parent is also being pasted, map to new parentId; otherwise clear it // If a block's parent is also being pasted, map to new parentId
// If parent exists in existing workflow, keep the original parentId (block stays in same subflow)
// Otherwise clear the parentId
Object.entries(newBlocks).forEach(([, block]) => { Object.entries(newBlocks).forEach(([, block]) => {
if (block.data?.parentId) { if (block.data?.parentId) {
const oldParentId = block.data.parentId const oldParentId = block.data.parentId
const newParentId = blockIdMap.get(oldParentId) const newParentId = blockIdMap.get(oldParentId)
if (newParentId) { if (newParentId) {
// Parent is being pasted - map to new parent ID
block.data = { block.data = {
...block.data, ...block.data,
parentId: newParentId, parentId: newParentId,
extent: 'parent', extent: 'parent',
} }
} else if (existingBlockNames[oldParentId]) {
// Parent exists in existing workflow - keep original parentId (block stays in same subflow)
block.data = {
...block.data,
parentId: oldParentId,
extent: 'parent',
}
} else { } else {
// Parent doesn't exist anywhere - clear the relationship
block.data = { ...block.data, parentId: undefined, extent: undefined } block.data = { ...block.data, parentId: undefined, extent: undefined }
} }
} }
@@ -647,7 +549,7 @@ export function regenerateBlockIds(
} }
}) })
updateBlockReferences(newBlocks, blockIdMap, nameMap, false) updateBlockReferences(newBlocks, nameMap, false)
Object.entries(newSubBlockValues).forEach(([_, blockValues]) => { Object.entries(newSubBlockValues).forEach(([_, blockValues]) => {
Object.keys(blockValues).forEach((subBlockId) => { Object.keys(blockValues).forEach((subBlockId) => {

View File

@@ -22,8 +22,6 @@ import {
WorkflowBuilder, WorkflowBuilder,
} from '@sim/testing' } from '@sim/testing'
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
describe('workflow store', () => { describe('workflow store', () => {
@@ -365,30 +363,6 @@ describe('workflow store', () => {
}) })
}) })
describe('duplicateBlock', () => {
it('should duplicate a block', () => {
const { addBlock, duplicateBlock } = useWorkflowStore.getState()
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
duplicateBlock('original')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
expect(blockIds.length).toBe(2)
const duplicatedId = blockIds.find((id) => id !== 'original')
expect(duplicatedId).toBeDefined()
if (duplicatedId) {
expect(blocks[duplicatedId].type).toBe('agent')
expect(blocks[duplicatedId].name).toContain('Original Agent')
expect(blocks[duplicatedId].position.x).not.toBe(0)
}
})
})
describe('batchUpdatePositions', () => { describe('batchUpdatePositions', () => {
it('should update block position', () => { it('should update block position', () => {
const { addBlock, batchUpdatePositions } = useWorkflowStore.getState() const { addBlock, batchUpdatePositions } = useWorkflowStore.getState()
@@ -452,29 +426,6 @@ describe('workflow store', () => {
expect(state.loops.loop1.forEachItems).toBe('["a", "b", "c"]') expect(state.loops.loop1.forEachItems).toBe('["a", "b", "c"]')
}) })
it('should regenerate loops when updateLoopCollection is called', () => {
const { addBlock, updateLoopCollection } = useWorkflowStore.getState()
addBlock(
'loop1',
'loop',
'Test Loop',
{ x: 0, y: 0 },
{
loopType: 'forEach',
collection: '["item1", "item2"]',
}
)
updateLoopCollection('loop1', '["item1", "item2", "item3"]')
const state = useWorkflowStore.getState()
expect(state.blocks.loop1?.data?.collection).toBe('["item1", "item2", "item3"]')
expect(state.loops.loop1).toBeDefined()
expect(state.loops.loop1.forEachItems).toBe('["item1", "item2", "item3"]')
})
it('should clamp loop count between 1 and 1000', () => { it('should clamp loop count between 1 and 1000', () => {
const { addBlock, updateLoopCount } = useWorkflowStore.getState() const { addBlock, updateLoopCount } = useWorkflowStore.getState()
@@ -599,118 +550,6 @@ describe('workflow store', () => {
}) })
}) })
describe('mode switching', () => {
it('should toggle advanced mode on a block', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
let state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(false)
toggleBlockAdvancedMode('agent1')
state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(true)
toggleBlockAdvancedMode('agent1')
state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(false)
})
it('should preserve systemPrompt and userPrompt when switching modes', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
const { setState: setSubBlockState } = useSubBlockStore
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
setSubBlockState({
workflowValues: {
'test-workflow': {
agent1: {
systemPrompt: 'You are a helpful assistant',
userPrompt: 'Hello, how are you?',
},
},
},
})
toggleBlockAdvancedMode('agent1')
let subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.userPrompt).toBe(
'Hello, how are you?'
)
toggleBlockAdvancedMode('agent1')
subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.userPrompt).toBe(
'Hello, how are you?'
)
})
it('should preserve memories when switching from advanced to basic mode', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
const { setState: setSubBlockState } = useSubBlockStore
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
toggleBlockAdvancedMode('agent1')
setSubBlockState({
workflowValues: {
'test-workflow': {
agent1: {
systemPrompt: 'You are a helpful assistant',
userPrompt: 'What did we discuss?',
memories: [
{ role: 'user', content: 'My name is John' },
{ role: 'assistant', content: 'Nice to meet you, John!' },
],
},
},
},
})
toggleBlockAdvancedMode('agent1')
const subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.userPrompt).toBe(
'What did we discuss?'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.memories).toEqual([
{ role: 'user', content: 'My name is John' },
{ role: 'assistant', content: 'Nice to meet you, John!' },
])
})
it('should handle mode switching when no subblock values exist', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
expect(useWorkflowStore.getState().blocks.agent1?.advancedMode).toBe(false)
expect(() => toggleBlockAdvancedMode('agent1')).not.toThrow()
const state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(true)
})
it('should not throw when toggling non-existent block', () => {
const { toggleBlockAdvancedMode } = useWorkflowStore.getState()
expect(() => toggleBlockAdvancedMode('non-existent')).not.toThrow()
})
})
describe('workflow state management', () => { describe('workflow state management', () => {
it('should work with WorkflowBuilder for complex setups', () => { it('should work with WorkflowBuilder for complex setups', () => {
const workflowState = WorkflowBuilder.linear(3).build() const workflowState = WorkflowBuilder.linear(3).build()

View File

@@ -2,20 +2,16 @@ import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow' import type { Edge } from 'reactflow'
import { create } from 'zustand' import { create } from 'zustand'
import { devtools } from 'zustand/middleware' import { devtools } from 'zustand/middleware'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { import { filterNewEdges, filterValidEdges } from '@/stores/workflows/utils'
filterNewEdges,
filterValidEdges,
getUniqueBlockName,
mergeSubblockState,
} from '@/stores/workflows/utils'
import type { import type {
BlockState,
Position, Position,
SubBlockState, SubBlockState,
WorkflowState, WorkflowState,
@@ -139,30 +135,30 @@ export const useWorkflowStore = create<WorkflowStore>()(
...(parentId && { parentId, extent: extent || 'parent' }), ...(parentId && { parentId, extent: extent || 'parent' }),
} }
const newState = { const newBlocks = {
blocks: { ...get().blocks,
...get().blocks, [id]: {
[id]: { id,
id, type,
type, name,
name, position,
position, subBlocks: {},
subBlocks: {}, outputs: {},
outputs: {}, enabled: blockProperties?.enabled ?? true,
enabled: blockProperties?.enabled ?? true, horizontalHandles: blockProperties?.horizontalHandles ?? true,
horizontalHandles: blockProperties?.horizontalHandles ?? true, advancedMode: blockProperties?.advancedMode ?? false,
advancedMode: blockProperties?.advancedMode ?? false, triggerMode: blockProperties?.triggerMode ?? false,
triggerMode: blockProperties?.triggerMode ?? false, height: blockProperties?.height ?? 0,
height: blockProperties?.height ?? 0, data: nodeData,
data: nodeData,
},
}, },
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
} }
set(newState) set({
blocks: newBlocks,
edges: [...get().edges],
loops: generateLoopBlocks(newBlocks),
parallels: generateParallelBlocks(newBlocks),
})
get().updateLastSaved() get().updateLastSaved()
return return
} }
@@ -215,31 +211,31 @@ export const useWorkflowStore = create<WorkflowStore>()(
const triggerMode = blockProperties?.triggerMode ?? false const triggerMode = blockProperties?.triggerMode ?? false
const outputs = getBlockOutputs(type, subBlocks, triggerMode) const outputs = getBlockOutputs(type, subBlocks, triggerMode)
const newState = { const newBlocks = {
blocks: { ...get().blocks,
...get().blocks, [id]: {
[id]: { id,
id, type,
type, name,
name, position,
position, subBlocks,
subBlocks, outputs,
outputs, enabled: blockProperties?.enabled ?? true,
enabled: blockProperties?.enabled ?? true, horizontalHandles: blockProperties?.horizontalHandles ?? true,
horizontalHandles: blockProperties?.horizontalHandles ?? true, advancedMode: blockProperties?.advancedMode ?? false,
advancedMode: blockProperties?.advancedMode ?? false, triggerMode: triggerMode,
triggerMode: triggerMode, height: blockProperties?.height ?? 0,
height: blockProperties?.height ?? 0, layout: {},
layout: {}, data: nodeData,
data: nodeData,
},
}, },
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
} }
set(newState) set({
blocks: newBlocks,
edges: [...get().edges],
loops: generateLoopBlocks(newBlocks),
parallels: generateParallelBlocks(newBlocks),
})
get().updateLastSaved() get().updateLastSaved()
}, },
@@ -448,6 +444,41 @@ export const useWorkflowStore = create<WorkflowStore>()(
delete newBlocks[blockId] delete newBlocks[blockId]
}) })
// Clean up orphaned nodes - blocks whose parent was removed but weren't descendants
// This can happen in edge cases (e.g., data inconsistency, external modifications)
const remainingBlockIds = new Set(Object.keys(newBlocks))
const CONTAINER_OFFSET = {
x: CONTAINER_DIMENSIONS.LEFT_PADDING,
y: CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING,
}
Object.entries(newBlocks).forEach(([blockId, block]) => {
const parentId = block.data?.parentId
if (parentId && !remainingBlockIds.has(parentId)) {
// Parent was removed - convert to absolute position and clear parentId
// Child positions are relative to container content area (after header + padding)
let absoluteX = block.position.x
let absoluteY = block.position.y
// Traverse up the parent chain, adding position + container offset for each level
let currentParentId: string | undefined = parentId
while (currentParentId) {
const parent: BlockState | undefined = currentBlocks[currentParentId]
if (!parent) break
absoluteX += parent.position.x + CONTAINER_OFFSET.x
absoluteY += parent.position.y + CONTAINER_OFFSET.y
currentParentId = parent.data?.parentId
}
const { parentId: _removed, extent: _removedExtent, ...restData } = block.data || {}
newBlocks[blockId] = {
...block,
position: { x: absoluteX, y: absoluteY },
data: Object.keys(restData).length > 0 ? restData : undefined,
}
}
})
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) { if (activeWorkflowId) {
const subBlockStore = useSubBlockStore.getState() const subBlockStore = useSubBlockStore.getState()
@@ -584,7 +615,20 @@ export const useWorkflowStore = create<WorkflowStore>()(
options?: { updateLastSaved?: boolean } options?: { updateLastSaved?: boolean }
) => { ) => {
set((state) => { set((state) => {
const nextBlocks = workflowState.blocks || {} const incomingBlocks = workflowState.blocks || {}
const nextBlocks: typeof incomingBlocks = {}
for (const [id, block] of Object.entries(incomingBlocks)) {
if (block.data?.parentId && !incomingBlocks[block.data.parentId]) {
nextBlocks[id] = {
...block,
data: { ...block.data, parentId: undefined, extent: undefined },
}
} else {
nextBlocks[id] = block
}
}
const nextEdges = filterValidEdges(workflowState.edges || [], nextBlocks) const nextEdges = filterValidEdges(workflowState.edges || [], nextBlocks)
const nextLoops = const nextLoops =
Object.keys(workflowState.loops || {}).length > 0 Object.keys(workflowState.loops || {}).length > 0
@@ -635,66 +679,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
get().updateLastSaved() get().updateLastSaved()
}, },
duplicateBlock: (id: string) => {
const block = get().blocks[id]
if (!block) return
const newId = crypto.randomUUID()
const offsetPosition = {
x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x,
y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y,
}
const newName = getUniqueBlockName(block.name, get().blocks)
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const mergedBlock = mergeSubblockState(get().blocks, activeWorkflowId || undefined, id)[id]
const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce(
(acc, [subId, subBlock]) => ({
...acc,
[subId]: {
...subBlock,
value: JSON.parse(JSON.stringify(subBlock.value)),
},
}),
{}
)
const newState = {
blocks: {
...get().blocks,
[newId]: {
...block,
id: newId,
name: newName,
position: offsetPosition,
subBlocks: newSubBlocks,
},
},
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
}
if (activeWorkflowId) {
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
[newId]: JSON.parse(JSON.stringify(subBlockValues)),
},
},
}))
}
set(newState)
get().updateLastSaved()
},
setBlockHandles: (id: string, horizontalHandles: boolean) => { setBlockHandles: (id: string, horizontalHandles: boolean) => {
const block = get().blocks[id] const block = get().blocks[id]
if (!block || block.horizontalHandles === horizontalHandles) return if (!block || block.horizontalHandles === horizontalHandles) return
@@ -890,27 +874,10 @@ export const useWorkflowStore = create<WorkflowStore>()(
get().updateLastSaved() get().updateLastSaved()
}, },
setBlockTriggerMode: (id: string, triggerMode: boolean) => {
set((state) => ({
blocks: {
...state.blocks,
[id]: {
...state.blocks[id],
triggerMode,
},
},
edges: [...state.edges],
loops: { ...state.loops },
}))
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => { updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => {
set((state) => { set((state) => {
const block = state.blocks[id] const block = state.blocks[id]
if (!block) { if (!block) {
logger.warn(`Cannot update layout metrics: Block ${id} not found in workflow store`)
return state return state
} }
@@ -932,7 +899,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
} }
}) })
get().updateLastSaved() get().updateLastSaved()
// No sync needed for layout changes, just visual
}, },
updateLoopCount: (loopId: string, count: number) => updateLoopCount: (loopId: string, count: number) =>
@@ -1050,30 +1016,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
} }
}), }),
updateLoopCollection: (loopId: string, collection: string) => {
const store = get()
const block = store.blocks[loopId]
if (!block || block.type !== 'loop') return
const loopType = block.data?.loopType || 'for'
if (loopType === 'while') {
store.setLoopWhileCondition(loopId, collection)
} else if (loopType === 'doWhile') {
store.setLoopDoWhileCondition(loopId, collection)
} else if (loopType === 'forEach') {
store.setLoopForEachItems(loopId, collection)
} else {
// Default to forEach-style storage for backward compatibility
store.setLoopForEachItems(loopId, collection)
}
},
// Function to convert UI loop blocks to execution format
generateLoopBlocks: () => {
return generateLoopBlocks(get().blocks)
},
triggerUpdate: () => { triggerUpdate: () => {
set((state) => ({ set((state) => ({
...state, ...state,
@@ -1161,28 +1103,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
} }
}, },
toggleBlockAdvancedMode: (id: string) => {
const block = get().blocks[id]
if (!block) return
const newState = {
blocks: {
...get().blocks,
[id]: {
...block,
advancedMode: !block.advancedMode,
},
},
edges: [...get().edges],
loops: { ...get().loops },
}
set(newState)
get().triggerUpdate()
// Note: Socket.IO handles real-time sync automatically
},
// Parallel block methods implementation // Parallel block methods implementation
updateParallelCount: (parallelId: string, count: number) => { updateParallelCount: (parallelId: string, count: number) => {
const block = get().blocks[parallelId] const block = get().blocks[parallelId]
@@ -1208,7 +1128,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
set(newState) set(newState)
get().updateLastSaved() get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
}, },
updateParallelCollection: (parallelId: string, collection: string) => { updateParallelCollection: (parallelId: string, collection: string) => {
@@ -1235,7 +1154,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
set(newState) set(newState)
get().updateLastSaved() get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
}, },
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => { updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => {
@@ -1262,12 +1180,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
set(newState) set(newState)
get().updateLastSaved() get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
// Function to convert UI parallel blocks to execution format
generateParallelBlocks: () => {
return generateParallelBlocks(get().blocks)
}, },
setDragStartPosition: (position) => { setDragStartPosition: (position) => {

View File

@@ -214,7 +214,6 @@ export interface WorkflowActions {
clear: () => Partial<WorkflowState> clear: () => Partial<WorkflowState>
updateLastSaved: () => void updateLastSaved: () => void
setBlockEnabled: (id: string, enabled: boolean) => void setBlockEnabled: (id: string, enabled: boolean) => void
duplicateBlock: (id: string) => void
setBlockHandles: (id: string, horizontalHandles: boolean) => void setBlockHandles: (id: string, horizontalHandles: boolean) => void
updateBlockName: ( updateBlockName: (
id: string, id: string,
@@ -225,23 +224,18 @@ export interface WorkflowActions {
} }
setBlockAdvancedMode: (id: string, advancedMode: boolean) => void setBlockAdvancedMode: (id: string, advancedMode: boolean) => void
setBlockCanonicalMode: (id: string, canonicalId: string, mode: 'basic' | 'advanced') => void setBlockCanonicalMode: (id: string, canonicalId: string, mode: 'basic' | 'advanced') => void
setBlockTriggerMode: (id: string, triggerMode: boolean) => void
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => void updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => void
triggerUpdate: () => void triggerUpdate: () => void
updateLoopCount: (loopId: string, count: number) => void updateLoopCount: (loopId: string, count: number) => void
updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => void updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => void
updateLoopCollection: (loopId: string, collection: string) => void
setLoopForEachItems: (loopId: string, items: any) => void setLoopForEachItems: (loopId: string, items: any) => void
setLoopWhileCondition: (loopId: string, condition: string) => void setLoopWhileCondition: (loopId: string, condition: string) => void
setLoopDoWhileCondition: (loopId: string, condition: string) => void setLoopDoWhileCondition: (loopId: string, condition: string) => void
updateParallelCount: (parallelId: string, count: number) => void updateParallelCount: (parallelId: string, count: number) => void
updateParallelCollection: (parallelId: string, collection: string) => void updateParallelCollection: (parallelId: string, collection: string) => void
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void
generateLoopBlocks: () => Record<string, Loop>
generateParallelBlocks: () => Record<string, Parallel>
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void
revertToDeployedState: (deployedState: WorkflowState) => void revertToDeployedState: (deployedState: WorkflowState) => void
toggleBlockAdvancedMode: (id: string) => void
setDragStartPosition: (position: DragStartPosition | null) => void setDragStartPosition: (position: DragStartPosition | null) => void
getDragStartPosition: () => DragStartPosition | null getDragStartPosition: () => DragStartPosition | null
getWorkflowState: () => WorkflowState getWorkflowState: () => WorkflowState