Compare commits

..

4 Commits

Author SHA1 Message Date
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
91 changed files with 1210 additions and 379 deletions

View File

@@ -10,6 +10,7 @@ describe('OAuth Token API Routes', () => {
const mockGetUserId = vi.fn()
const mockGetCredential = vi.fn()
const mockRefreshTokenIfNeeded = vi.fn()
const mockGetOAuthToken = vi.fn()
const mockAuthorizeCredentialUse = vi.fn()
const mockCheckHybridAuth = vi.fn()
@@ -29,6 +30,7 @@ describe('OAuth Token API Routes', () => {
getUserId: mockGetUserId,
getCredential: mockGetCredential,
refreshTokenIfNeeded: mockRefreshTokenIfNeeded,
getOAuthToken: mockGetOAuthToken,
}))
vi.doMock('@sim/logger', () => ({
@@ -230,6 +232,140 @@ describe('OAuth Token API Routes', () => {
expect(response.status).toBe(401)
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,
})
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 {
const accessToken = await getOAuthToken(credentialAccountUserId, providerId)
if (!accessToken) {

View File

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

View File

@@ -1,13 +1,19 @@
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'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaAddCommentAPI')
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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()
if (!accessToken) {

View File

@@ -1,13 +1,19 @@
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'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaCreateTaskAPI')
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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()
if (!accessToken) {

View File

@@ -1,13 +1,19 @@
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'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaGetProjectsAPI')
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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()
if (!accessToken) {

View File

@@ -1,13 +1,19 @@
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'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaGetTaskAPI')
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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()
if (!accessToken) {

View File

@@ -1,13 +1,19 @@
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'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaSearchTasksAPI')
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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()
if (!accessToken) {

View File

@@ -1,13 +1,19 @@
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'
export const dynamic = 'force-dynamic'
const logger = createLogger('AsanaUpdateTaskAPI')
export async function PUT(request: Request) {
export async function PUT(request: NextRequest) {
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()
if (!accessToken) {

View File

@@ -1,5 +1,6 @@
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 { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceAttachmentAPI')
export const dynamic = 'force-dynamic'
// Delete an attachment
export async function DELETE(request: Request) {
export async function DELETE(request: NextRequest) {
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()
if (!domain) {

View File

@@ -1,5 +1,6 @@
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 { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceAttachmentsAPI')
export const dynamic = 'force-dynamic'
// List attachments on a page
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
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 domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -46,8 +47,13 @@ const deleteCommentSchema = z
)
// Update a comment
export async function PUT(request: Request) {
export async function PUT(request: NextRequest) {
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 validation = putCommentSchema.safeParse(body)
@@ -128,8 +134,13 @@ export async function PUT(request: Request) {
}
// Delete a comment
export async function DELETE(request: Request) {
export async function DELETE(request: NextRequest) {
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 validation = deleteCommentSchema.safeParse(body)

View File

@@ -1,5 +1,6 @@
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 { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceCommentsAPI')
export const dynamic = 'force-dynamic'
// Create a comment
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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()
if (!domain) {
@@ -86,8 +92,13 @@ export async function POST(request: Request) {
}
// List comments on a page
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
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 domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')

View File

@@ -1,5 +1,6 @@
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 { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -7,8 +8,13 @@ const logger = createLogger('ConfluenceCreatePageAPI')
export const dynamic = 'force-dynamic'
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const {
domain,
accessToken,

View File

@@ -1,5 +1,6 @@
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 { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceLabelsAPI')
export const dynamic = 'force-dynamic'
// Add a label to a page
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const {
domain,
accessToken,
@@ -87,8 +93,13 @@ export async function POST(request: Request) {
}
// List labels on a page
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
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 domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
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 {
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 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 {
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 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 {
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 validation = deletePageSchema.safeParse(body)

View File

@@ -1,5 +1,6 @@
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 { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluencePagesAPI')
export const dynamic = 'force-dynamic'
// List pages or search pages
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const {
domain,
accessToken,

View File

@@ -1,5 +1,6 @@
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 { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -7,8 +8,13 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('Confluence Search')
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const {
domain,
accessToken,

View File

@@ -1,5 +1,6 @@
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 { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceSpaceAPI')
export const dynamic = 'force-dynamic'
// Get a specific space
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
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 domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')

View File

@@ -1,5 +1,6 @@
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 { getConfluenceCloudId } from '@/tools/confluence/utils'
@@ -8,8 +9,13 @@ const logger = createLogger('ConfluenceSpacesAPI')
export const dynamic = 'force-dynamic'
// List all spaces
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
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 domain = searchParams.get('domain')
const accessToken = searchParams.get('accessToken')

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId, validateJiraCloudId } from '@/lib/core/security/input-validation'
import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
@@ -11,6 +12,11 @@ export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) {
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 { domain, accessToken, cloudId: providedCloudId, pageId, file, fileName, comment } = body

View File

@@ -1,5 +1,6 @@
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'
interface DiscordChannel {
@@ -13,7 +14,12 @@ export const dynamic = 'force-dynamic'
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 {
const { botToken, serverId, channelId } = await request.json()

View File

@@ -1,5 +1,6 @@
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'
interface DiscordServer {
@@ -12,7 +13,12 @@ export const dynamic = 'force-dynamic'
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 {
const { botToken, serverId } = await request.json()

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -15,6 +16,11 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId()
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 {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -73,14 +73,12 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId()
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 {
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 credentialId = searchParams.get('credentialId')
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 { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, deleteItem } from '@/app/api/tools/dynamodb/utils'
const DeleteSchema = z.object({
@@ -13,8 +14,13 @@ const DeleteSchema = z.object({
conditionExpression: z.string().optional(),
})
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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 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 { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, getItem } from '@/app/api/tools/dynamodb/utils'
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 {
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 validatedData = GetSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRawDynamoDBClient, describeTable, listTables } from '@/app/api/tools/dynamodb/utils'
const logger = createLogger('DynamoDBIntrospectAPI')
@@ -17,6 +18,11 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
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 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 { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, putItem } from '@/app/api/tools/dynamodb/utils'
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 {
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 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 { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, queryItems } from '@/app/api/tools/dynamodb/utils'
const QuerySchema = z.object({
@@ -15,8 +16,13 @@ const QuerySchema = z.object({
limit: z.number().positive().optional(),
})
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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 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 { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, scanItems } from '@/app/api/tools/dynamodb/utils'
const ScanSchema = z.object({
@@ -14,8 +15,13 @@ const ScanSchema = z.object({
limit: z.number().positive().optional(),
})
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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 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 { checkInternalAuth } from '@/lib/auth/hybrid'
import { createDynamoDBClient, updateItem } from '@/app/api/tools/dynamodb/utils'
const UpdateSchema = z.object({
@@ -16,8 +17,13 @@ const UpdateSchema = z.object({
conditionExpression: z.string().optional(),
})
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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 validatedData = UpdateSchema.parse(body)

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
@@ -29,6 +30,11 @@ export async function GET(request: NextRequest) {
const requestId = generateRequestId()
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 {
const { searchParams } = new URL(request.url)
const credentialId = searchParams.get('credentialId')

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId } from '@/tools/jira/utils'
@@ -7,8 +8,13 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JiraIssueAPI')
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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()
if (!domain) {
logger.error('Missing domain in request')

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId } from '@/tools/jira/utils'
@@ -26,8 +27,13 @@ const validateRequiredParams = (domain: string | null, accessToken: string | nul
return null
}
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
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 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 {
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 domain = url.searchParams.get('domain')?.trim()
const accessToken = url.searchParams.get('accessToken')

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId } from '@/tools/jira/utils'
@@ -7,8 +8,13 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JiraProjectsAPI')
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
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 domain = url.searchParams.get('domain')?.trim()
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 {
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()
if (!domain) {

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateJiraCloudId, validateJiraIssueKey } from '@/lib/core/security/input-validation'
import { getJiraCloudId } from '@/tools/jira/utils'
@@ -21,8 +22,13 @@ const jiraUpdateSchema = z.object({
cloudId: z.string().optional(),
})
export async function PUT(request: Request) {
export async function PUT(request: NextRequest) {
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 validation = jiraUpdateSchema.safeParse(body)

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId } from '@/tools/jira/utils'
@@ -7,8 +8,13 @@ export const dynamic = 'force-dynamic'
const logger = createLogger('JiraWriteAPI')
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
const {
domain,
accessToken,

View File

@@ -1,5 +1,6 @@
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,
validateEnum,
@@ -15,7 +16,12 @@ const logger = createLogger('JsmApprovalsAPI')
const VALID_ACTIONS = ['get', 'answer'] 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 {
const body = await request.json()
const {

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
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 {
const {
domain,

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
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 {
const body = await request.json()
const {

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
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 {
const body = await request.json()
const {

View File

@@ -1,5 +1,6 @@
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,
validateEnum,
@@ -13,7 +14,12 @@ const logger = createLogger('JsmOrganizationAPI')
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 {
const body = await request.json()
const {

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
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 {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, serviceDeskId, start, limit } = body

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
validateEnum,
validateJiraCloudId,
@@ -13,7 +14,12 @@ const logger = createLogger('JsmParticipantsAPI')
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 {
const body = await request.json()
const {

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
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 {
const body = await request.json()
const {

View File

@@ -1,5 +1,6 @@
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,
@@ -11,7 +12,12 @@ export const dynamic = 'force-dynamic'
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 {
const body = await request.json()
const {

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
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 {
const body = await request.json()
const {

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
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 {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, serviceDeskId, start, limit } = body

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
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 {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, start, limit } = body

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
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 {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey, start, limit } = body

View File

@@ -1,5 +1,6 @@
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,
@@ -11,7 +12,12 @@ export const dynamic = 'force-dynamic'
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 {
const {
domain,

View File

@@ -1,5 +1,6 @@
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 { getJiraCloudId, getJsmApiBaseUrl, getJsmHeaders } from '@/tools/jsm/utils'
@@ -7,7 +8,12 @@ export const dynamic = 'force-dynamic'
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 {
const body = await request.json()
const { domain, accessToken, cloudId: cloudIdParam, issueIdOrKey } = body

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBDeleteAPI')
@@ -40,6 +41,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
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 {
const body = await request.json()
const params = DeleteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validatePipeline } from '../utils'
const logger = createLogger('MongoDBExecuteAPI')
@@ -32,6 +33,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
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 {
const body = await request.json()
const params = ExecuteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName } from '../utils'
const logger = createLogger('MongoDBInsertAPI')
@@ -37,6 +38,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
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 {
const body = await request.json()
const params = InsertSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, executeIntrospect } from '../utils'
const logger = createLogger('MongoDBIntrospectAPI')
@@ -20,6 +21,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
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 {
const body = await request.json()
const params = IntrospectSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBQueryAPI')
@@ -49,6 +50,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
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 {
const body = await request.json()
const params = QuerySchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createMongoDBConnection, sanitizeCollectionName, validateFilter } from '../utils'
const logger = createLogger('MongoDBUpdateAPI')
@@ -59,6 +60,12 @@ export async function POST(request: NextRequest) {
const requestId = randomUUID().slice(0, 8)
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 {
const body = await request.json()
const params = UpdateSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
@@ -26,6 +27,12 @@ export async function POST(request: NextRequest) {
let driver = 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 {
const body = await request.json()
const params = CreateSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createNeo4jDriver, validateCypherQuery } from '@/app/api/tools/neo4j/utils'
const logger = createLogger('Neo4jDeleteAPI')
@@ -23,6 +24,12 @@ export async function POST(request: NextRequest) {
let driver = 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 {
const body = await request.json()
const params = DeleteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
@@ -26,6 +27,12 @@ export async function POST(request: NextRequest) {
let driver = 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 {
const body = await request.json()
const params = ExecuteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createNeo4jDriver } from '@/app/api/tools/neo4j/utils'
import type { Neo4jNodeSchema, Neo4jRelationshipSchema } from '@/tools/neo4j/types'
@@ -21,6 +22,12 @@ export async function POST(request: NextRequest) {
let driver = 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 {
const body = await request.json()
const params = IntrospectSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
@@ -26,6 +27,12 @@ export async function POST(request: NextRequest) {
let driver = 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 {
const body = await request.json()
const params = MergeSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
@@ -26,6 +27,12 @@ export async function POST(request: NextRequest) {
let driver = 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 {
const body = await request.json()
const params = QuerySchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import {
convertNeo4jTypesToJSON,
createNeo4jDriver,
@@ -26,6 +27,12 @@ export async function POST(request: NextRequest) {
let driver = 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 {
const body = await request.json()
const params = UpdateSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeDelete } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSDeleteAPI')
@@ -22,6 +23,11 @@ const DeleteSchema = z.object({
export async function POST(request: NextRequest) {
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 {
const body = await request.json()
const params = DeleteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeStatement } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSExecuteAPI')
@@ -19,6 +20,11 @@ const ExecuteSchema = z.object({
export async function POST(request: NextRequest) {
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 {
const body = await request.json()
const params = ExecuteSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeInsert } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSInsertAPI')
@@ -22,6 +23,11 @@ const InsertSchema = z.object({
export async function POST(request: NextRequest) {
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 {
const body = await request.json()
const params = InsertSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeIntrospect, type RdsEngine } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSIntrospectAPI')
@@ -20,6 +21,11 @@ const IntrospectSchema = z.object({
export async function POST(request: NextRequest) {
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 {
const body = await request.json()
const params = IntrospectSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeStatement, validateQuery } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSQueryAPI')
@@ -19,6 +20,11 @@ const QuerySchema = z.object({
export async function POST(request: NextRequest) {
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 {
const body = await request.json()
const params = QuerySchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createRdsClient, executeUpdate } from '@/app/api/tools/rds/utils'
const logger = createLogger('RDSUpdateAPI')
@@ -25,6 +26,11 @@ const UpdateSchema = z.object({
export async function POST(request: NextRequest) {
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 {
const body = await request.json()
const params = UpdateSchema.parse(body)

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { createSqsClient, sendMessage } from '../utils'
const logger = createLogger('SQSSendMessageAPI')
@@ -21,6 +22,11 @@ const SendMessageSchema = z.object({
export async function POST(request: NextRequest) {
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 {
const body = await request.json()
const params = SendMessageSchema.parse(body)

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction'
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) {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}
let stagehand: StagehandType | null = null
try {

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
@@ -22,6 +23,11 @@ const requestSchema = z.object({
})
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
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)]'>
{field.name}
</span>
<Badge size='sm'>{field.type || 'string'}</Badge>
<Badge variant='type' size='sm'>
{field.type || 'string'}
</Badge>
</div>
</div>
<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)]'>
{field.name}
</span>
<Badge size='sm'>{field.type}</Badge>
<Badge variant='type' size='sm'>
{field.type}
</Badge>
</div>
</div>
<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}`}
</span>
{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 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)]'>
{fieldName}
</span>
{fieldType && <Badge size='sm'>{fieldType}</Badge>}
{fieldType && (
<Badge variant='type' size='sm'>
{fieldType}
</Badge>
)}
</div>
</div>

View File

@@ -238,7 +238,9 @@ export function KnowledgeTagFilters({
{filter.collapsed ? filter.tagName || `Filter ${index + 1}` : `Filter ${index + 1}`}
</span>
{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 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)]'>
{field.name || `${title} ${index + 1}`}
</span>
{field.name && showType && <Badge size='sm'>{field.type}</Badge>}
{field.name && showType && (
<Badge variant='type' size='sm'>
{field.type}
</Badge>
)}
</div>
<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'>

View File

@@ -345,7 +345,11 @@ export function VariablesInput({
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{assignment.variableName || `Variable ${index + 1}`}
</span>
{assignment.variableName && <Badge size='sm'>{assignment.type}</Badge>}
{assignment.variableName && (
<Badge variant='type' size='sm'>
{assignment.type}
</Badge>
)}
</div>
<div
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 prevEntriesLengthRef = useRef(0)
const prevWorkflowEntriesLengthRef = useRef(0)
const hasInitializedEntriesRef = useRef(false)
const isTerminalFocusedRef = useRef(false)
const lastExpandedHeightRef = useRef<number>(DEFAULT_EXPANDED_HEIGHT)
const setTerminalHeight = useTerminalStore((state) => state.setTerminalHeight)
@@ -1007,12 +1008,33 @@ export const Terminal = memo(function Terminal() {
return JSON.stringify(outputData, null, 2)
}, [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.
* This mirrors the header toggle behavior by using expandToLastHeight,
* 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(() => {
if (!hasConsoleHydrated) {
return
}
if (!hasInitializedEntriesRef.current) {
hasInitializedEntriesRef.current = true
prevWorkflowEntriesLengthRef.current = allWorkflowEntries.length
return
}
if (!openOnRun) {
prevWorkflowEntriesLengthRef.current = allWorkflowEntries.length
return
@@ -1026,7 +1048,14 @@ export const Terminal = memo(function Terminal() {
}
prevWorkflowEntriesLengthRef.current = currentLength
}, [allWorkflowEntries.length, expandToLastHeight, openOnRun, isExpanded])
}, [
allWorkflowEntries.length,
expandToLastHeight,
openOnRun,
isExpanded,
hasConsoleHydrated,
activeWorkflowId,
])
/**
* 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 { useCanvasViewport } from '@/hooks/use-canvas-viewport'
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
import { useCanvasModeStore } from '@/stores/canvas-mode'
import { useChatStore } from '@/stores/chat/store'
@@ -99,26 +98,6 @@ const logger = createLogger('Workflow')
const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 }
/**
* Gets the center of the current viewport in flow coordinates
*/
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
*/
@@ -126,7 +105,7 @@ function calculatePasteOffset(
clipboard: {
blocks: Record<string, { position: { x: number; y: number }; type: string; height?: number }>
} | null,
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
viewportCenter: { x: number; y: number }
): { x: number; y: number } {
if (!clipboard) return DEFAULT_PASTE_OFFSET
@@ -155,8 +134,6 @@ function calculatePasteOffset(
)
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
const viewportCenter = getViewportCenter(screenToFlowPosition)
return {
x: viewportCenter.x - clipboardCenter.x,
y: viewportCenter.y - clipboardCenter.y,
@@ -266,7 +243,7 @@ const WorkflowContent = React.memo(() => {
const router = useRouter()
const reactFlowInstance = useReactFlow()
const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance)
const { emitCursorUpdate } = useSocket()
const workspaceId = params.workspaceId as string
@@ -338,8 +315,6 @@ const WorkflowContent = React.memo(() => {
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
const isChatOpen = useChatStore((state) => state.isChatOpen)
// Permission config for invitation control
const { isInvitationsDisabled } = usePermissionConfig()
const snapGrid: [number, number] = useMemo(
() => [snapToGridSize, snapToGridSize],
[snapToGridSize]
@@ -901,11 +876,125 @@ const WorkflowContent = React.memo(() => {
* Consolidates shared logic for context paste, duplicate, and keyboard paste.
*/
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
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)
if (!validation.isValid) {
addNotification({
@@ -926,21 +1015,46 @@ const WorkflowContent = React.memo(() => {
pasteData.parallels,
pasteData.subBlockValues
)
// Resize container if we pasted into a subflow
if (targetContainer) {
resizeLoopNodesWrapper()
}
},
[
preparePasteData,
blocks,
clipboard,
addNotification,
activeWorkflowId,
collaborativeBatchAddBlocks,
setPendingSelection,
resizeLoopNodesWrapper,
]
)
const handleContextPaste = useCallback(() => {
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()),
targetContainer,
flowPosition // Pass the click position so blocks are centered at where user right-clicked
)
}, [
hasClipboard,
executePasteOperation,
clipboard,
getViewportCenter,
screenToFlowPosition,
contextMenuPosition,
isPointInLoopNode,
])
const handleContextDuplicate = useCallback(() => {
copyBlocks(contextMenuBlocks.map((b) => b.id))
@@ -1006,10 +1120,6 @@ const WorkflowContent = React.memo(() => {
setIsChatOpen(!isChatOpen)
}, [])
const handleContextInvite = useCallback(() => {
window.dispatchEvent(new CustomEvent('open-invite-modal'))
}, [])
useEffect(() => {
let cleanup: (() => void) | null = null
@@ -1054,7 +1164,7 @@ const WorkflowContent = React.memo(() => {
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
if (effectivePermissions.canEdit && hasClipboard()) {
event.preventDefault()
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
executePasteOperation('paste', calculatePasteOffset(clipboard, getViewportCenter()))
}
}
}
@@ -1074,7 +1184,7 @@ const WorkflowContent = React.memo(() => {
hasClipboard,
effectivePermissions.canEdit,
clipboard,
screenToFlowPosition,
getViewportCenter,
executePasteOperation,
])
@@ -1507,7 +1617,7 @@ const WorkflowContent = React.memo(() => {
if (!type) return
if (type === 'connectionBlock') return
const basePosition = getViewportCenter(screenToFlowPosition)
const basePosition = getViewportCenter()
if (type === 'loop' || type === 'parallel') {
const id = crypto.randomUUID()
@@ -1576,7 +1686,7 @@ const WorkflowContent = React.memo(() => {
)
}
}, [
screenToFlowPosition,
getViewportCenter,
blocks,
addBlock,
effectivePermissions.canEdit,

View File

@@ -611,7 +611,9 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
{name}
</span>
<Badge size='sm'>{prop.type || 'any'}</Badge>
<Badge variant='type' size='sm'>
{prop.type || 'any'}
</Badge>
</div>
</div>
<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)]',
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)]',
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]`,
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)]`,
@@ -84,7 +85,7 @@ export interface BadgeProps
*
* @remarks
* 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`,
* `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.
*/
function getVisibleCanvasCenter(): { x: number; y: number } {
const style = getComputedStyle(document.documentElement)
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 bounds = getVisibleCanvasBounds()
const flowContainer = document.querySelector('.react-flow')
if (!flowContainer) {
const visibleWidth = window.innerWidth - sidebarWidth - panelWidth
const visibleHeight = window.innerHeight - terminalHeight
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)
const rect = flowContainer?.getBoundingClientRect()
const containerLeft = rect?.left ?? 0
const containerTop = rect?.top ?? 0
return {
x: (visibleLeft + visibleRight) / 2,
y: (rect.top + visibleBottom) / 2,
x: containerLeft + bounds.offsetLeft + bounds.width / 2,
y: containerTop + bounds.height / 2,
}
}

View File

@@ -14,6 +14,7 @@ import {
loadDeployedWorkflowState,
loadWorkflowFromNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
import { Executor } from '@/executor'
@@ -26,7 +27,6 @@ import type {
import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types'
import { hasExecutionResult } from '@/executor/utils/errors'
import { Serializer } from '@/serializer'
import { mergeSubblockState } from '@/stores/workflows/server-utils'
const logger = createLogger('ExecutionCore')
@@ -172,8 +172,7 @@ export async function executeWorkflowCore(
logger.info(`[${requestId}] Using deployed workflow state (deployed execution)`)
}
// Merge block states
const mergedStates = mergeSubblockState(blocks)
const mergedStates = mergeSubblockStateWithValues(blocks)
const personalEnvUserId =
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'
import { describe, expect, it } from 'vitest'
import { normalizeName } from '@/executor/constants'
import { getUniqueBlockName } from './utils'
import { getUniqueBlockName, regenerateBlockIds } from './utils'
describe('normalizeName', () => {
it.concurrent('should convert to lowercase', () => {
@@ -223,3 +223,213 @@ describe('getUniqueBlockName', () => {
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 { v4 as uuidv4 } from 'uuid'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
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 { getBlock } from '@/blocks'
import { isAnnotationOnlyBlock, normalizeName } from '@/executor/constants'
@@ -16,7 +17,8 @@ import type {
} from '@/stores/workflows/workflow/types'
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)
@@ -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
* @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 {
if (typeof value === 'string') {
let updatedValue = value
@@ -444,14 +316,10 @@ function updateValueReferences(value: unknown, nameMap: Map<string, string>): un
function updateBlockReferences(
blocks: Record<string, BlockState>,
idMap: Map<string, string>,
nameMap: Map<string, string>,
clearTriggerRuntimeValues = false
): void {
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) {
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
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 {
blocks: newBlocks,
@@ -574,13 +442,36 @@ export function regenerateBlockIds(
const newNormalizedName = normalizeName(newName)
nameMap.set(oldNormalizedName, newNormalizedName)
// Check if this block has a parent that's also being copied
// If so, it's a nested block and should keep its relative position (no offset)
// Only top-level blocks (no parent in the paste set) get the position offset
// Determine position offset based on parent relationship:
// 1. Parent also being copied: keep exact relative position (parent itself will be 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 newPosition = hasParentInPasteSet
? { x: block.position.x, y: block.position.y } // Keep relative position
: { x: block.position.x + positionOffset.x, y: block.position.y + positionOffset.y }
const hasParentInExistingWorkflow =
block.data?.parentId && existingBlockNames[block.data.parentId]
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
const newBlock: BlockState = {
@@ -602,19 +493,30 @@ export function regenerateBlockIds(
})
// 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]) => {
if (block.data?.parentId) {
const oldParentId = block.data.parentId
const newParentId = blockIdMap.get(oldParentId)
if (newParentId) {
// Parent is being pasted - map to new parent ID
block.data = {
...block.data,
parentId: newParentId,
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 {
// Parent doesn't exist anywhere - clear the relationship
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.keys(blockValues).forEach((subBlockId) => {