diff --git a/apps/sim/app/api/billing/credits/route.ts b/apps/sim/app/api/billing/credits/route.ts deleted file mode 100644 index 2a35f5cd43e..00000000000 --- a/apps/sim/app/api/billing/credits/route.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { purchaseCreditsContract } from '@/lib/api/contracts/subscription' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { getCreditBalance } from '@/lib/billing/credits/balance' -import { purchaseCredits } from '@/lib/billing/credits/purchase' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CreditsAPI') - -export const GET = withRouteHandler(async () => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const { balance, entityType, entityId } = await getCreditBalance(session.user.id) - return NextResponse.json({ - success: true, - data: { balance, entityType, entityId }, - }) - } catch (error) { - logger.error('Failed to get credit balance', { error, userId: session.user.id }) - return NextResponse.json({ error: 'Failed to get credit balance' }, { status: 500 }) - } -}) - -export const POST = withRouteHandler(async (request: NextRequest) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parsed = await parseRequest( - purchaseCreditsContract, - request, - {}, - { - validationErrorResponse: () => - NextResponse.json( - { error: 'Invalid amount. Must be between $10 and $1000' }, - { status: 400 } - ), - } - ) - if (!parsed.success) return parsed.response - - const result = await purchaseCredits({ - userId: session.user.id, - amountDollars: parsed.data.body.amount, - requestId: parsed.data.body.requestId, - }) - - if (!result.success) { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - - recordAudit({ - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDIT_PURCHASED, - resourceType: AuditResourceType.BILLING, - resourceId: parsed.data.body.requestId, - description: `Purchased $${parsed.data.body.amount} in credits`, - metadata: { - amountDollars: parsed.data.body.amount, - requestId: parsed.data.body.requestId, - }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to purchase credits', { error, userId: session.user.id }) - return NextResponse.json({ error: 'Failed to purchase credits' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/copilot/chat/rename/route.ts b/apps/sim/app/api/copilot/chat/rename/route.ts deleted file mode 100644 index 5022fbfce74..00000000000 --- a/apps/sim/app/api/copilot/chat/rename/route.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { renameCopilotChatContract } from '@/lib/api/contracts/copilot' -import { parseRequest, validationErrorResponse } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' -import { chatPubSub } from '@/lib/copilot/chat-status' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('RenameChatAPI') - -export const PATCH = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - renameCopilotChatContract, - request, - {}, - { - validationErrorResponse: (error) => validationErrorResponse(error, 'Invalid request data'), - } - ) - if (!parsed.success) return parsed.response - const { chatId, title } = parsed.data.body - - const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id) - if (!chat) { - return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) - } - - const now = new Date() - const [updated] = await db - .update(copilotChats) - .set({ title, updatedAt: now, lastSeenAt: now }) - .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) - .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) - - if (!updated) { - return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) - } - - logger.info('Chat renamed', { chatId, title }) - - if (updated.workspaceId) { - chatPubSub?.publishStatusChanged({ - workspaceId: updated.workspaceId, - chatId, - type: 'renamed', - }) - } - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Error renaming chat:', error) - return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/copilot/chat/steer/route.test.ts b/apps/sim/app/api/copilot/chat/steer/route.test.ts deleted file mode 100644 index 417d4d96385..00000000000 --- a/apps/sim/app/api/copilot/chat/steer/route.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * @vitest-environment node - */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockAuthenticate, mockGetLatestRunForStream, mockRequestStreamSteering, mockAppend } = - vi.hoisted(() => ({ - mockAuthenticate: vi.fn(), - mockGetLatestRunForStream: vi.fn(), - mockRequestStreamSteering: vi.fn(), - mockAppend: vi.fn(), - })) - -vi.mock('@/lib/copilot/request/http', () => ({ - authenticateCopilotRequestSessionOnly: mockAuthenticate, -})) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ - getLatestRunForStream: mockGetLatestRunForStream, -})) -vi.mock('@/lib/copilot/request/session/steer', () => ({ - requestStreamSteering: mockRequestStreamSteering, -})) -vi.mock('@/lib/copilot/chat/messages-store', () => ({ - appendCopilotChatMessages: mockAppend, -})) - -import { POST } from '@/app/api/copilot/chat/steer/route' - -function steerRequest(overrides: Record = {}) { - return createMockRequest('POST', { - streamId: 'stream-1', - chatId: 'chat-1', - steeringId: 'steer-1', - content: 'focus on the tests', - ...overrides, - }) -} - -describe('POST /api/copilot/chat/steer', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAuthenticate.mockResolvedValue({ userId: 'user-1', isAuthenticated: true }) - mockGetLatestRunForStream.mockResolvedValue({ chatId: 'chat-1', workspaceId: 'workspace-1' }) - mockRequestStreamSteering.mockResolvedValue({ queued: true, status: 200 }) - mockAppend.mockResolvedValue(undefined) - }) - - it('queues steering with Go and persists the user message', async () => { - const response = await POST(steerRequest()) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ ok: true, queued: true }) - expect(mockRequestStreamSteering).toHaveBeenCalledWith( - expect.objectContaining({ - streamId: 'stream-1', - chatId: 'chat-1', - steeringId: 'steer-1', - content: 'focus on the tests', - userId: 'user-1', - }) - ) - expect(mockAppend).toHaveBeenCalledWith( - 'chat-1', - [ - expect.objectContaining({ - id: 'steer-1', - role: 'user', - content: 'focus on the tests', - }), - ], - { streamId: 'stream-1' } - ) - }) - - it('returns 409 when Go rejects the steer so the client falls back to a normal send', async () => { - mockRequestStreamSteering.mockResolvedValue({ queued: false, status: 429 }) - - const response = await POST(steerRequest()) - - expect(response.status).toBe(409) - expect(await response.json()).toMatchObject({ ok: false, queued: false }) - expect(mockAppend).not.toHaveBeenCalled() - }) - - it('returns 409 when the Go forward throws', async () => { - mockRequestStreamSteering.mockRejectedValue(new Error('network down')) - - const response = await POST(steerRequest()) - - expect(response.status).toBe(409) - expect(mockAppend).not.toHaveBeenCalled() - }) - - it('rejects a chat that does not own the stream', async () => { - mockGetLatestRunForStream.mockResolvedValue({ chatId: 'other-chat' }) - - const response = await POST(steerRequest()) - - expect(response.status).toBe(403) - expect(mockRequestStreamSteering).not.toHaveBeenCalled() - }) - - it('rejects unauthenticated callers', async () => { - mockAuthenticate.mockResolvedValue({ userId: null, isAuthenticated: false }) - - const response = await POST(steerRequest()) - - expect(response.status).toBe(401) - expect(mockRequestStreamSteering).not.toHaveBeenCalled() - }) - - it('rejects an empty content body', async () => { - const response = await POST(steerRequest({ content: '' })) - - expect(response.status).toBe(400) - expect(mockRequestStreamSteering).not.toHaveBeenCalled() - }) - - it('still reports queued when history persistence fails', async () => { - mockAppend.mockRejectedValue(new Error('db down')) - - const response = await POST(steerRequest()) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ ok: true, queued: true }) - }) -}) diff --git a/apps/sim/app/api/copilot/chat/steer/route.ts b/apps/sim/app/api/copilot/chat/steer/route.ts deleted file mode 100644 index 971551054b1..00000000000 --- a/apps/sim/app/api/copilot/chat/steer/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { copilotChatSteerBodySchema } from '@/lib/api/contracts/copilot' -import { validationErrorResponse } from '@/lib/api/server' -import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' -import { CopilotSteerOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' -import { withIncomingGoSpan } from '@/lib/copilot/request/otel' -import { requestStreamSteering } from '@/lib/copilot/request/session/steer' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CopilotChatSteerAPI') - -// POST /api/copilot/chat/steer — queues a mid-turn steering message with the -// Go side for a LIVE stream. Acceptance means "queued", not "applied": Go -// acknowledges application with a `run`/`steering_applied` stream event; a -// client that never sees that ack before the stream ends re-sends the content -// as an ordinary message. A 409 here tells the client to take that ordinary -// path immediately. -export const POST = withRouteHandler((request: NextRequest) => - withIncomingGoSpan( - request.headers, - TraceSpan.CopilotChatSteerStream, - undefined, - async (rootSpan) => { - const { userId: authenticatedUserId, isAuthenticated } = - await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !authenticatedUserId) { - rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.BadRequest) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // boundary-raw-json: tolerant parse; validation happens via the contract schema below - const body = await request.json().catch(() => ({})) - const validation = copilotChatSteerBodySchema.safeParse(body) - if (!validation.success) { - rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.BadRequest) - return validationErrorResponse(validation.error, 'Invalid request body') - } - const { streamId, chatId, steeringId, content } = validation.data - rootSpan.setAttributes({ - [TraceAttr.StreamId]: streamId, - [TraceAttr.ChatId]: chatId, - [TraceAttr.UserId]: authenticatedUserId, - [TraceAttr.CopilotSteeringContentChars]: content.length, - }) - - // Ownership pre-check on the Sim side (Go re-proves it independently): - // the stream must belong to a run of the authenticated user, and the - // claimed chat must match that run. - const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => { - logger.warn('getLatestRunForStream failed while resolving steer context', { - streamId, - error: getErrorMessage(err), - }) - return null - }) - if (run?.chatId && run.chatId !== chatId) { - rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.BadRequest) - return NextResponse.json({ error: 'Stream does not belong to this chat' }, { status: 403 }) - } - - let queued = false - let goStatus = 0 - try { - const result = await requestStreamSteering({ - streamId, - userId: authenticatedUserId, - chatId, - steeringId, - content, - }) - queued = result.queued - goStatus = result.status - } catch (err) { - logger.warn('Steer forward to Go failed', { - streamId, - chatId, - error: getErrorMessage(err), - }) - } - - if (!queued) { - rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.NoActiveTurn) - // 409 = "could not queue; send it as an ordinary message instead". - return NextResponse.json({ ok: false, queued: false, goStatus }, { status: 409 }) - } - - // Persist the steering text as a user message so reloads include it. - // Failure here must not fail the steer — the message is already queued - // with Go and will reach the model; persistence is display-only. - try { - await appendCopilotChatMessages( - chatId, - [ - { - id: steeringId, - role: 'user', - content, - timestamp: new Date().toISOString(), - }, - ], - { streamId } - ) - } catch (err) { - logger.warn('Failed to persist steering message to chat history', { - chatId, - steeringId, - error: getErrorMessage(err), - }) - } - - rootSpan.setAttribute(TraceAttr.CopilotSteerOutcome, CopilotSteerOutcome.Queued) - logger.info('Queued mid-turn steering message', { - streamId, - chatId, - steeringId, - contentChars: content.length, - }) - return NextResponse.json({ ok: true, queued: true }) - } - ) -) diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts deleted file mode 100644 index ab842b87480..00000000000 --- a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts +++ /dev/null @@ -1,539 +0,0 @@ -/** - * Tests for copilot chat update-messages API route - * - * @vitest-environment node - */ -import { - authMockFns, - dbChainMockFns, - queueTableRows, - resetDbChainMock, - schemaMock, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockReplaceCopilotChatMessages } = vi.hoisted(() => ({ - mockReplaceCopilotChatMessages: vi.fn(), -})) - -vi.mock('@/lib/copilot/chat/messages-store', () => ({ - replaceCopilotChatMessages: mockReplaceCopilotChatMessages, -})) - -import { POST } from '@/app/api/copilot/chat/update-messages/route' - -function createMockRequest(method: string, body: Record): NextRequest { - return new NextRequest('http://localhost:3000/api/copilot/chat/update-messages', { - method, - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('Copilot Chat Update Messages API Route', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - - authMockFns.mockGetSession.mockResolvedValue(null) - - dbChainMockFns.returning.mockResolvedValue([{ model: 'gpt-4' }]) - }) - - afterAll(() => { - resetDbChainMock() - }) - - describe('POST', () => { - it('should return 401 when user is not authenticated', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('POST', { - chatId: 'chat-123', - messages: [ - { - id: 'msg-1', - role: 'user', - content: 'Hello', - timestamp: '2024-01-01T00:00:00.000Z', - }, - ], - }) - - const response = await POST(req) - - expect(response.status).toBe(401) - const responseData = await response.json() - expect(responseData).toEqual({ error: 'Unauthorized' }) - }) - - it('should return 400 for invalid request body - missing chatId', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const req = createMockRequest('POST', { - messages: [ - { - id: 'msg-1', - role: 'user', - content: 'Hello', - timestamp: '2024-01-01T00:00:00.000Z', - }, - ], - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.error).toBe('Validation error') - }) - - it('should return 400 for invalid request body - missing messages', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const req = createMockRequest('POST', { - chatId: 'chat-123', - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.error).toBe('Validation error') - }) - - it('should return 400 for invalid message structure - missing required fields', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const req = createMockRequest('POST', { - chatId: 'chat-123', - messages: [ - { - id: 'msg-1', - }, - ], - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.error).toBe('Validation error') - }) - - it('should return 400 for invalid message role', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const req = createMockRequest('POST', { - chatId: 'chat-123', - messages: [ - { - id: 'msg-1', - role: 'invalid-role', - content: 'Hello', - timestamp: '2024-01-01T00:00:00.000Z', - }, - ], - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.error).toBe('Validation error') - }) - - it('should return 404 when chat is not found', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - queueTableRows(schemaMock.copilotChats, []) - - const req = createMockRequest('POST', { - chatId: 'non-existent-chat', - messages: [ - { - id: 'msg-1', - role: 'user', - content: 'Hello', - timestamp: '2024-01-01T00:00:00.000Z', - }, - ], - }) - - const response = await POST(req) - - expect(response.status).toBe(404) - const responseData = await response.json() - expect(responseData.error).toBe('Chat not found or unauthorized') - }) - - it('should return 404 when chat belongs to different user', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - queueTableRows(schemaMock.copilotChats, []) - - const req = createMockRequest('POST', { - chatId: 'other-user-chat', - messages: [ - { - id: 'msg-1', - role: 'user', - content: 'Hello', - timestamp: '2024-01-01T00:00:00.000Z', - }, - ], - }) - - const response = await POST(req) - - expect(response.status).toBe(404) - const responseData = await response.json() - expect(responseData.error).toBe('Chat not found or unauthorized') - }) - - it('should successfully update chat messages', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const existingChat = { - id: 'chat-123', - userId: 'user-123', - messages: [], - } - queueTableRows(schemaMock.copilotChats, [existingChat]) - - const messages = [ - { - id: 'msg-1', - role: 'user', - content: 'Hello, how are you?', - timestamp: '2024-01-01T10:00:00.000Z', - }, - { - id: 'msg-2', - role: 'assistant', - content: 'I am doing well, thank you!', - timestamp: '2024-01-01T10:01:00.000Z', - }, - ] - - const req = createMockRequest('POST', { - chatId: 'chat-123', - messages, - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - messageCount: 2, - }) - - expect(dbChainMockFns.select).toHaveBeenCalled() - expect(dbChainMockFns.update).toHaveBeenCalled() - expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) - expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( - 'chat-123', - messages, - { chatModel: 'gpt-4' }, - expect.anything() - ) - }) - - it('should successfully update chat messages with optional fields', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const existingChat = { - id: 'chat-456', - userId: 'user-123', - messages: [], - } - queueTableRows(schemaMock.copilotChats, [existingChat]) - - const messages = [ - { - id: 'msg-1', - role: 'user', - content: 'Hello', - timestamp: '2024-01-01T10:00:00.000Z', - }, - { - id: 'msg-2', - role: 'assistant', - content: 'Hi there!', - timestamp: '2024-01-01T10:01:00.000Z', - toolCalls: [ - { - id: 'tool-1', - name: 'get_weather', - arguments: { location: 'NYC' }, - }, - ], - contentBlocks: [ - { - type: 'text', - content: 'Here is the weather information', - }, - ], - }, - ] - - const req = createMockRequest('POST', { - chatId: 'chat-456', - messages, - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - messageCount: 2, - }) - - expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) - expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( - 'chat-456', - [ - { - id: 'msg-1', - role: 'user', - content: 'Hello', - timestamp: '2024-01-01T10:00:00.000Z', - }, - { - id: 'msg-2', - role: 'assistant', - content: 'Hi there!', - timestamp: '2024-01-01T10:01:00.000Z', - contentBlocks: [ - { - type: 'text', - content: 'Here is the weather information', - }, - { - type: 'tool', - phase: 'call', - toolCall: { - id: 'tool-1', - name: 'get_weather', - state: 'pending', - }, - }, - ], - }, - ], - { chatModel: 'gpt-4' }, - expect.anything() - ) - }) - - it('should handle empty messages array', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const existingChat = { - id: 'chat-789', - userId: 'user-123', - messages: [], - } - queueTableRows(schemaMock.copilotChats, [existingChat]) - - const req = createMockRequest('POST', { - chatId: 'chat-789', - messages: [], - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - messageCount: 0, - }) - - expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) - expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( - 'chat-789', - [], - { chatModel: 'gpt-4' }, - expect.anything() - ) - }) - - it('should handle database errors during chat lookup', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - dbChainMockFns.limit.mockRejectedValueOnce(new Error('Database connection failed')) - - const req = createMockRequest('POST', { - chatId: 'chat-123', - messages: [ - { - id: 'msg-1', - role: 'user', - content: 'Hello', - timestamp: '2024-01-01T00:00:00.000Z', - }, - ], - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to update chat messages') - }) - - it('should handle database errors during update operation', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const existingChat = { - id: 'chat-123', - userId: 'user-123', - messages: [], - } - queueTableRows(schemaMock.copilotChats, [existingChat]) - - dbChainMockFns.returning.mockRejectedValueOnce(new Error('Update operation failed')) - - const req = createMockRequest('POST', { - chatId: 'chat-123', - messages: [ - { - id: 'msg-1', - role: 'user', - content: 'Hello', - timestamp: '2024-01-01T00:00:00.000Z', - }, - ], - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to update chat messages') - }) - - it('should handle JSON parsing errors in request body', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const req = new NextRequest('http://localhost:3000/api/copilot/chat/update-messages', { - method: 'POST', - body: '{invalid-json', - headers: { - 'Content-Type': 'application/json', - }, - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to update chat messages') - }) - - it('should handle large message arrays', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const existingChat = { - id: 'chat-large', - userId: 'user-123', - messages: [], - } - queueTableRows(schemaMock.copilotChats, [existingChat]) - - const messages = Array.from({ length: 100 }, (_, i) => ({ - id: `msg-${i + 1}`, - role: i % 2 === 0 ? 'user' : 'assistant', - content: `Message ${i + 1}`, - timestamp: new Date(2024, 0, 1, 10, i).toISOString(), - })) - - const req = createMockRequest('POST', { - chatId: 'chat-large', - messages, - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - messageCount: 100, - }) - - expect(dbChainMockFns.set).toHaveBeenCalledWith({ updatedAt: expect.any(Date) }) - expect(mockReplaceCopilotChatMessages).toHaveBeenCalledWith( - 'chat-large', - messages, - { chatModel: 'gpt-4' }, - expect.anything() - ) - }) - - it('should handle messages with both user and assistant roles', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const existingChat = { - id: 'chat-mixed', - userId: 'user-123', - messages: [], - } - queueTableRows(schemaMock.copilotChats, [existingChat]) - - const messages = [ - { - id: 'msg-1', - role: 'user', - content: 'What is the weather like?', - timestamp: '2024-01-01T10:00:00.000Z', - }, - { - id: 'msg-2', - role: 'assistant', - content: 'Let me check the weather for you.', - timestamp: '2024-01-01T10:01:00.000Z', - toolCalls: [ - { - id: 'tool-weather', - name: 'get_weather', - arguments: { location: 'current' }, - }, - ], - }, - { - id: 'msg-3', - role: 'assistant', - content: 'The weather is sunny and 75°F.', - timestamp: '2024-01-01T10:02:00.000Z', - }, - { - id: 'msg-4', - role: 'user', - content: 'Thank you!', - timestamp: '2024-01-01T10:03:00.000Z', - }, - ] - - const req = createMockRequest('POST', { - chatId: 'chat-mixed', - messages, - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - messageCount: 4, - }) - }) - }) -}) diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.ts b/apps/sim/app/api/copilot/chat/update-messages/route.ts deleted file mode 100644 index 8733c2d016e..00000000000 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { updateCopilotMessagesContract } from '@/lib/api/contracts/copilot' -import { parseRequest } from '@/lib/api/server' -import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' -import { replaceCopilotChatMessages } from '@/lib/copilot/chat/messages-store' -import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message' -import { - authenticateCopilotRequestSessionOnly, - createInternalServerErrorResponse, - createNotFoundResponse, - createRequestTracker, - createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CopilotChatUpdateAPI') - -export const POST = withRouteHandler(async (req: NextRequest) => { - const tracker = createRequestTracker() - - try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return createUnauthorizedResponse() - } - - const parsed = await parseRequest( - updateCopilotMessagesContract, - req, - {}, - { - invalidJson: 'throw', - } - ) - if (!parsed.success) return parsed.response - const { chatId, messages, config } = parsed.data.body - - const lastMsg = messages[messages.length - 1] - if (lastMsg?.role === 'assistant') { - logger.info(`[${tracker.requestId}] Received messages to save`, { - messageCount: messages.length, - lastMsgId: lastMsg.id, - lastMsgContentLength: lastMsg.content?.length || 0, - lastMsgContentBlockCount: lastMsg.contentBlocks?.length || 0, - lastMsgContentBlockTypes: lastMsg.contentBlocks?.map((b: any) => b?.type) || [], - }) - } - - const normalizedMessages: PersistedMessage[] = messages.map((message) => - normalizeMessage(message as Record) - ) - - // Debug: Log what we're about to save - const lastMsgParsed = normalizedMessages[normalizedMessages.length - 1] - if (lastMsgParsed?.role === 'assistant') { - logger.info(`[${tracker.requestId}] Parsed messages to save`, { - messageCount: normalizedMessages.length, - lastMsgId: lastMsgParsed.id, - lastMsgContentLength: lastMsgParsed.content?.length || 0, - lastMsgContentBlockCount: lastMsgParsed.contentBlocks?.length || 0, - lastMsgContentBlockTypes: lastMsgParsed.contentBlocks?.map((b: any) => b?.type) || [], - }) - } - - // Verify that the chat belongs to the user - const chat = await getAccessibleCopilotChatAuth(chatId, userId) - - if (!chat) { - return createNotFoundResponse('Chat not found or unauthorized') - } - - const updateData: Record = { - updatedAt: new Date(), - } - - if (config !== undefined) { - updateData.config = config - } - - await db.transaction(async (tx) => { - const [updated] = await tx - .update(copilotChats) - .set(updateData) - .where(eq(copilotChats.id, chatId)) - .returning({ model: copilotChats.model }) - if (!updated) return - await replaceCopilotChatMessages( - chatId, - normalizedMessages, - { chatModel: updated.model ?? null }, - tx - ) - }) - - logger.info(`[${tracker.requestId}] Successfully updated chat`, { - chatId, - newMessageCount: normalizedMessages.length, - hasConfig: !!config, - }) - - return NextResponse.json({ - success: true, - messageCount: normalizedMessages.length, - }) - } catch (error) { - logger.error(`[${tracker.requestId}] Error updating chat messages:`, error) - return createInternalServerErrorResponse('Failed to update chat messages') - } -}) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts deleted file mode 100644 index 41c090b0185..00000000000 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ /dev/null @@ -1,849 +0,0 @@ -/** - * Tests for copilot checkpoints revert API route - * - * @vitest-environment node - */ -import { - authMockFns, - dbChainMockFns, - queueTableRows, - resetDbChainMock, - resetEnvMock, - schemaMock, - setEnv, - workflowAuthzMockFns, - workflowsUtilsMock, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockGetAccessibleCopilotChat, - mockParseWorkflowStateForPersistence, - mockSaveWorkflowNormalizedState, -} = vi.hoisted(() => ({ - mockGetAccessibleCopilotChat: vi.fn(), - mockParseWorkflowStateForPersistence: vi.fn(), - mockSaveWorkflowNormalizedState: vi.fn(), -})) - -vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) - -vi.mock('@/lib/workflows/persistence/save-normalized-state', () => ({ - parseWorkflowStateForPersistence: mockParseWorkflowStateForPersistence, - saveWorkflowNormalizedState: mockSaveWorkflowNormalizedState, -})) - -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ - getAccessibleCopilotChat: mockGetAccessibleCopilotChat, - getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat, -})) - -import { POST } from '@/app/api/copilot/checkpoints/revert/route' - -describe('Copilot Checkpoints Revert API Route', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) - - authMockFns.mockGetSession.mockResolvedValue(null) - - /** Authorization is the route's workflow read, so an allowed result always carries one. */ - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - status: 200, - workflow: { id: 'b2c3d4e5-f6a7-4b89-a0d1-e2f3a4b5c6d7', workspaceId: 'ws-123' }, - }) - - mockGetAccessibleCopilotChat.mockResolvedValue({ id: 'chat-123', userId: 'user-123' }) - - mockParseWorkflowStateForPersistence.mockImplementation((value: unknown) => ({ - success: true, - data: value, - })) - mockSaveWorkflowNormalizedState.mockResolvedValue({ success: true, warnings: [] }) - - global.fetch = vi.fn() - - vi.spyOn(Date, 'now').mockReturnValue(1640995200000) - - const originalDate = Date - const buildDate = (args: any[]): Date => { - if (args.length === 0) { - return new originalDate('2024-01-01T00:00:00.000Z') - } - if (args.length === 1) { - return new originalDate(args[0]) - } - return new originalDate(args[0], args[1], args[2], args[3], args[4], args[5], args[6]) - } - vi.spyOn(global, 'Date').mockImplementation( - class { - constructor(...args: any[]) { - // biome-ignore lint/correctness/noConstructorReturn: vitest 4 constructs mocks via Reflect.construct; returning a real Date overrides the instance so `new Date(...)` yields a genuine Date the route can call .toISOString()/.getTime() on - return buildDate(args) - } - } as any - ) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - afterAll(() => { - resetDbChainMock() - resetEnvMock() - }) - - /** Helper to set authenticated state */ - function setAuthenticated(user = { id: 'user-123', email: 'test@example.com' }) { - authMockFns.mockGetSession.mockResolvedValue({ user }) - } - - /** Helper to set unauthenticated state */ - function setUnauthenticated() { - authMockFns.mockGetSession.mockResolvedValue(null) - } - - describe('POST', () => { - it('should return 401 when user is not authenticated', async () => { - setUnauthenticated() - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-123' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(401) - const responseData = await response.json() - expect(responseData).toEqual({ error: 'Unauthorized' }) - }) - - it('should return 400 for invalid request body - missing checkpointId', async () => { - setAuthenticated() - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(typeof responseData.error).toBe('string') - }) - - it('should return 400 for empty checkpointId', async () => { - setAuthenticated() - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: '' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(typeof responseData.error).toBe('string') - }) - - it('should return 404 when checkpoint is not found', async () => { - setAuthenticated() - - queueTableRows(schemaMock.workflowCheckpoints, []) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'non-existent-checkpoint' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(404) - const responseData = await response.json() - expect(responseData.error).toBe('Checkpoint not found or access denied') - }) - - it('should return 404 when checkpoint belongs to different user', async () => { - setAuthenticated() - - queueTableRows(schemaMock.workflowCheckpoints, []) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'other-user-checkpoint' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(404) - const responseData = await response.json() - expect(responseData.error).toBe('Checkpoint not found or access denied') - }) - - it('should return 404 when workflow is not found', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-123', - workflowId: 'a1b2c3d4-e5f6-4a78-b9c0-d1e2f3a4b5c6', - userId: 'user-123', - workflowState: { blocks: {}, edges: [] }, - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - /** Authorization performs the workflow read, so a missing workflow surfaces through it. */ - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ - allowed: false, - status: 404, - workflow: null, - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-123' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(404) - const responseData = await response.json() - expect(responseData.error).toBe('Workflow not found') - expect(mockSaveWorkflowNormalizedState).not.toHaveBeenCalled() - }) - - it('should return 401 when workflow belongs to different user', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-123', - workflowId: 'b2c3d4e5-f6a7-4b89-a0d1-e2f3a4b5c6d7', - userId: 'user-123', - workflowState: { blocks: {}, edges: [] }, - } - - const mockWorkflow = { - id: 'b2c3d4e5-f6a7-4b89-a0d1-e2f3a4b5c6d7', - userId: 'different-user', - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - queueTableRows(schemaMock.workflow, [mockWorkflow]) - - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({ - allowed: false, - status: 403, - workflow: { id: 'b2c3d4e5-f6a7-4b89-a0d1-e2f3a4b5c6d7', workspaceId: 'ws-123' }, - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-123' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(401) - const responseData = await response.json() - expect(responseData).toEqual({ error: 'Unauthorized' }) - }) - - it('should successfully revert checkpoint with basic workflow state', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-123', - workflowId: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8', - userId: 'user-123', - workflowState: { - blocks: { block1: { type: 'start' } }, - edges: [{ from: 'block1', to: 'block2' }], - loops: {}, - parallels: {}, - isDeployed: true, - }, - } - - const mockWorkflow = { - id: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8', - userId: 'user-123', - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - queueTableRows(schemaMock.workflow, [mockWorkflow]) - - ;(global.fetch as any).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ success: true }), - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Cookie: 'session=test-session', - }, - body: JSON.stringify({ - checkpointId: 'checkpoint-123', - }), - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - workflowId: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8', - checkpointId: 'checkpoint-123', - revertedAt: '2024-01-01T00:00:00.000Z', - checkpoint: { - id: 'checkpoint-123', - workflowState: { - blocks: { block1: { type: 'start' } }, - edges: [{ from: 'block1', to: 'block2' }], - loops: {}, - parallels: {}, - isDeployed: true, - lastSaved: 1640995200000, - }, - }, - }) - - expect(mockSaveWorkflowNormalizedState).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'c3d4e5f6-a7b8-4c09-a1e2-f3a4b5c6d7e8', - userId: 'user-123', - state: { - blocks: { block1: { type: 'start' } }, - edges: [{ from: 'block1', to: 'block2' }], - loops: {}, - parallels: {}, - isDeployed: true, - lastSaved: 1640995200000, - }, - }) - ) - }) - - it('should handle checkpoint state with valid deployedAt date', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-with-date', - workflowId: 'd4e5f6a7-b8c9-4d10-a2e3-a4b5c6d7e8f9', - userId: 'user-123', - workflowState: { - blocks: {}, - edges: [], - deployedAt: '2024-01-01T12:00:00.000Z', - isDeployed: true, - }, - } - - const mockWorkflow = { - id: 'd4e5f6a7-b8c9-4d10-a2e3-a4b5c6d7e8f9', - userId: 'user-123', - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - queueTableRows(schemaMock.workflow, [mockWorkflow]) - - ;(global.fetch as any).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ success: true }), - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-with-date' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData.checkpoint.workflowState.deployedAt).toBeDefined() - expect(responseData.checkpoint.workflowState.deployedAt).toEqual('2024-01-01T12:00:00.000Z') - }) - - it('should handle checkpoint state with invalid deployedAt date', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-invalid-date', - workflowId: 'e5f6a7b8-c9d0-4e11-a3f4-b5c6d7e8f9a0', - userId: 'user-123', - workflowState: { - blocks: {}, - edges: [], - deployedAt: 'invalid-date', - isDeployed: true, - }, - } - - const mockWorkflow = { - id: 'e5f6a7b8-c9d0-4e11-a3f4-b5c6d7e8f9a0', - userId: 'user-123', - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - queueTableRows(schemaMock.workflow, [mockWorkflow]) - - ;(global.fetch as any).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ success: true }), - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-invalid-date' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - // Invalid date should be filtered out - expect(responseData.checkpoint.workflowState.deployedAt).toBeUndefined() - }) - - it('should handle checkpoint state with null/undefined values', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-null-values', - workflowId: 'f6a7b8c9-d0e1-4f23-a4b5-c6d7e8f9a0b1', - userId: 'user-123', - workflowState: { - blocks: null, - edges: undefined, - loops: null, - parallels: undefined, - }, - } - - const mockWorkflow = { - id: 'f6a7b8c9-d0e1-4f23-a4b5-c6d7e8f9a0b1', - userId: 'user-123', - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - queueTableRows(schemaMock.workflow, [mockWorkflow]) - - ;(global.fetch as any).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ success: true }), - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-null-values' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - - // Null/undefined values should be replaced with defaults - expect(responseData.checkpoint.workflowState).toEqual({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - isDeployed: false, - lastSaved: 1640995200000, - }) - }) - - it('should return 500 when the state write fails', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-123', - workflowId: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2', - userId: 'user-123', - workflowState: { blocks: {}, edges: [] }, - } - - const mockWorkflow = { - id: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2', - userId: 'user-123', - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - queueTableRows(schemaMock.workflow, [mockWorkflow]) - - mockSaveWorkflowNormalizedState.mockResolvedValueOnce({ - success: false, - status: 500, - error: 'Failed to save workflow state', - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-123' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to revert workflow to checkpoint') - }) - - /** - * A checkpoint can carry a block whose integration the caller's permission - * group withholds; the write refuses that with a 403 naming the block type. - * Collapsed to a 500 with the generic sentence, the member was told the - * revert had crashed and had nothing to act on. - */ - it.each([ - [403, 'The Slack block is not available under your permission group'], - [409, 'Workflow is locked'], - ])( - 'passes a %i refusal from the state write through with its message', - async (status, error) => { - setAuthenticated() - - queueTableRows(schemaMock.workflowCheckpoints, [ - { - id: 'checkpoint-123', - workflowId: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2', - userId: 'user-123', - workflowState: { blocks: {}, edges: [] }, - }, - ]) - queueTableRows(schemaMock.workflow, [ - { id: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2', userId: 'user-123' }, - ]) - - mockSaveWorkflowNormalizedState.mockResolvedValueOnce({ success: false, status, error }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-123' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(status) - await expect(response.json()).resolves.toEqual({ error }) - } - ) - - it('should return 500 when the checkpoint state fails validation', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-123', - workflowId: 'a7b8c9d0-e1f2-4a34-b5c6-d7e8f9a0b1c2', - userId: 'user-123', - workflowState: { blocks: {}, edges: [] }, - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - queueTableRows(schemaMock.workflow, [{ id: mockCheckpoint.workflowId, userId: 'user-123' }]) - - mockParseWorkflowStateForPersistence.mockReturnValueOnce({ - success: false, - error: { issues: [{ message: 'blocks: invalid' }] }, - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-123' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - expect(mockSaveWorkflowNormalizedState).not.toHaveBeenCalled() - }) - - it('should handle database errors during checkpoint lookup', async () => { - setAuthenticated() - - dbChainMockFns.where.mockReturnValueOnce( - Promise.reject(new Error('Database connection failed')) - ) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-123' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to revert to checkpoint') - }) - - it('should handle database errors during workflow lookup', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-123', - workflowId: 'b8c9d0e1-f2a3-4b45-a6d7-e8f9a0b1c2d3', - userId: 'user-123', - workflowState: { blocks: {}, edges: [] }, - } - - dbChainMockFns.where.mockReturnValueOnce(Promise.resolve([mockCheckpoint])) - /** Authorization performs the workflow read, so a failed lookup surfaces through it. */ - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockRejectedValueOnce( - new Error('Database error during workflow lookup') - ) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-123' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to revert to checkpoint') - }) - - it('should handle unexpected errors from the state write', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-123', - workflowId: 'c9d0e1f2-a3b4-4c56-a7e8-f9a0b1c2d3e4', - userId: 'user-123', - workflowState: { blocks: {}, edges: [] }, - } - - const mockWorkflow = { - id: 'c9d0e1f2-a3b4-4c56-a7e8-f9a0b1c2d3e4', - userId: 'user-123', - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - queueTableRows(schemaMock.workflow, [mockWorkflow]) - - mockSaveWorkflowNormalizedState.mockRejectedValueOnce(new Error('Network error')) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-123' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to revert to checkpoint') - }) - - it('should handle JSON parsing errors in request body', async () => { - setAuthenticated() - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - body: '{invalid-json', - headers: { - 'Content-Type': 'application/json', - }, - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to revert to checkpoint') - }) - - it('should apply the state in-process instead of re-authenticating over HTTP', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-123', - workflowId: 'd0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5', - userId: 'user-123', - workflowState: { blocks: {}, edges: [] }, - } - - const mockWorkflow = { - id: 'd0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5', - userId: 'user-123', - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - queueTableRows(schemaMock.workflow, [mockWorkflow]) - - ;(global.fetch as any).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ success: true }), - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Cookie: 'session=test-session; auth=token123', - }, - body: JSON.stringify({ - checkpointId: 'checkpoint-123', - }), - }) - - await POST(req) - - expect(mockSaveWorkflowNormalizedState).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'd0e1f2a3-b4c5-4d67-a8f9-a0b1c2d3e4f5', - userId: 'user-123', - }) - ) - for (const call of (global.fetch as any).mock.calls) { - expect(String(call[0])).not.toContain('/state') - } - }) - - it('should handle missing cookies gracefully', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-123', - workflowId: 'e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6', - userId: 'user-123', - workflowState: { blocks: {}, edges: [] }, - } - - const mockWorkflow = { - id: 'e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6', - userId: 'user-123', - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - queueTableRows(schemaMock.workflow, [mockWorkflow]) - - ;(global.fetch as any).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ success: true }), - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - // No Cookie header - }, - body: JSON.stringify({ - checkpointId: 'checkpoint-123', - }), - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - expect(mockSaveWorkflowNormalizedState).toHaveBeenCalledWith( - expect.objectContaining({ - workflowId: 'e1f2a3b4-c5d6-4e78-a9a0-b1c2d3e4f5a6', - userId: 'user-123', - }) - ) - }) - - it('should handle complex checkpoint state with all fields', async () => { - setAuthenticated() - - const mockCheckpoint = { - id: 'checkpoint-complex', - workflowId: 'f2a3b4c5-d6e7-4f89-a0b1-c2d3e4f5a6b7', - userId: 'user-123', - workflowState: { - blocks: { - start: { type: 'start', config: {} }, - http: { type: 'http', config: { url: 'https://api.example.com' } }, - end: { type: 'end', config: {} }, - }, - edges: [ - { from: 'start', to: 'http' }, - { from: 'http', to: 'end' }, - ], - loops: { - loop1: { condition: 'true', iterations: 3 }, - }, - parallels: { - parallel1: { branches: ['branch1', 'branch2'] }, - }, - isDeployed: true, - deployedAt: '2024-01-01T10:00:00.000Z', - }, - } - - const mockWorkflow = { - id: 'f2a3b4c5-d6e7-4f89-a0b1-c2d3e4f5a6b7', - userId: 'user-123', - } - - queueTableRows(schemaMock.workflowCheckpoints, [mockCheckpoint]) - queueTableRows(schemaMock.workflow, [mockWorkflow]) - - ;(global.fetch as any).mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ success: true }), - }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints/revert', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ checkpointId: 'checkpoint-complex' }), - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData.checkpoint.workflowState).toEqual({ - blocks: { - start: { type: 'start', config: {} }, - http: { type: 'http', config: { url: 'https://api.example.com' } }, - end: { type: 'end', config: {} }, - }, - edges: [ - { from: 'start', to: 'http' }, - { from: 'http', to: 'end' }, - ], - loops: { - loop1: { condition: 'true', iterations: 3 }, - }, - parallels: { - parallel1: { branches: ['branch1', 'branch2'] }, - }, - isDeployed: true, - deployedAt: '2024-01-01T10:00:00.000Z', - lastSaved: 1640995200000, - }) - }) - }) -}) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts deleted file mode 100644 index 33327757691..00000000000 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { db } from '@sim/db' -import { workflowCheckpoints } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { revertCopilotCheckpointContract } from '@/lib/api/contracts/copilot' -import type { CleanedWorkflowState } from '@/lib/api/contracts/workflows' -import { parseRequest } from '@/lib/api/server' -import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' -import { - authenticateCopilotRequestSessionOnly, - createInternalServerErrorResponse, - createNotFoundResponse, - createRequestTracker, - createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - parseWorkflowStateForPersistence, - saveWorkflowNormalizedState, -} from '@/lib/workflows/persistence/save-normalized-state' -import { isUuidV4 } from '@/executor/constants' - -const logger = createLogger('CheckpointRevertAPI') - -/** - * POST /api/copilot/checkpoints/revert - * Revert workflow to a specific checkpoint state - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const tracker = createRequestTracker() - - try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return createUnauthorizedResponse() - } - - const parsed = await parseRequest( - revertCopilotCheckpointContract, - request, - {}, - { - invalidJson: 'throw', - } - ) - if (!parsed.success) return parsed.response - const { checkpointId } = parsed.data.body - - logger.info(`[${tracker.requestId}] Reverting to checkpoint ${checkpointId}`) - - const checkpoint = await db - .select() - .from(workflowCheckpoints) - .where(and(eq(workflowCheckpoints.id, checkpointId), eq(workflowCheckpoints.userId, userId))) - .then((rows) => rows[0]) - - if (!checkpoint) { - return createNotFoundResponse('Checkpoint not found or access denied') - } - - const chat = await getAccessibleCopilotChatAuth(checkpoint.chatId, userId) - if (!chat) { - return createNotFoundResponse('Checkpoint not found or access denied') - } - - /** Authorization already loads the workflow, so its absence is the not-found signal. */ - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId: checkpoint.workflowId, - userId, - action: 'write', - }) - if (!authorization.workflow) { - return createNotFoundResponse('Workflow not found') - } - if (!authorization.allowed) { - return createUnauthorizedResponse() - } - - const checkpointState: Record = - checkpoint.workflowState && typeof checkpoint.workflowState === 'object' - ? (checkpoint.workflowState as Record) - : {} - - const rawBlocks = checkpointState.blocks - const rawEdges = checkpointState.edges - const rawLoops = checkpointState.loops - const rawParallels = checkpointState.parallels - const rawDeployedAt = checkpointState.deployedAt - - const parsedDeployedAt = - rawDeployedAt === null || rawDeployedAt === undefined - ? null - : new Date(rawDeployedAt as string | number | Date) - - const cleanedState: CleanedWorkflowState = { - blocks: (rawBlocks ?? {}) as Record, - edges: (rawEdges ?? []) as unknown[], - loops: (rawLoops ?? {}) as Record, - parallels: (rawParallels ?? {}) as Record, - isDeployed: Boolean(checkpointState.isDeployed), - lastSaved: Date.now(), - ...(parsedDeployedAt && !Number.isNaN(parsedDeployedAt.getTime()) - ? { deployedAt: parsedDeployedAt } - : {}), - } - - logger.info(`[${tracker.requestId}] Applying cleaned checkpoint state`, { - blocksCount: Object.keys(cleanedState.blocks).length, - edgesCount: cleanedState.edges.length, - hasDeployedAt: !!cleanedState.deployedAt, - isDeployed: cleanedState.isDeployed, - }) - - if (!isUuidV4(checkpoint.workflowId)) { - logger.error(`[${tracker.requestId}] Invalid workflow ID format`) - return NextResponse.json({ error: 'Invalid workflow ID format' }, { status: 400 }) - } - - /** - * The checkpoint blob is persisted JSONB, so it goes through the same - * schema the PUT state contract applies before it is written back — the - * validation the removed HTTP hop used to provide. - */ - const parsedState = parseWorkflowStateForPersistence(cleanedState) - if (!parsedState.success) { - logger.error( - `[${tracker.requestId}] Checkpoint state failed validation`, - parsedState.error.issues - ) - return NextResponse.json( - { error: 'Failed to revert workflow to checkpoint' }, - { status: 500 } - ) - } - - const saveResult = await saveWorkflowNormalizedState({ - requestId: tracker.requestId, - workflowId: checkpoint.workflowId, - userId, - state: parsedState.data, - /** Already resolved above; re-deriving it would repeat 2-3 sequential reads. */ - authorization, - }) - - /** - * The save's own refusals are the caller's answer, not a Sim fault. It - * classifies them itself — a withheld block type in the checkpoint is a - * 403, a locked workflow a 409 — and collapsing every one to a 500 told a - * member that reverting had crashed when their organization had simply - * withheld an integration the checkpoint uses. Only a genuine 5xx keeps the - * generic sentence; the rest carry the refusal's own status and message. - */ - if (!saveResult.success) { - const { status } = saveResult - logger.error(`[${tracker.requestId}] Failed to apply checkpoint state: ${saveResult.error}`) - return NextResponse.json( - { error: status >= 500 ? 'Failed to revert workflow to checkpoint' : saveResult.error }, - { status } - ) - } - - logger.info( - `[${tracker.requestId}] Successfully reverted workflow ${checkpoint.workflowId} to checkpoint ${checkpointId}` - ) - - // Delete the checkpoint after successfully reverting to it - try { - await db.delete(workflowCheckpoints).where(eq(workflowCheckpoints.id, checkpointId)) - logger.info(`[${tracker.requestId}] Deleted checkpoint after reverting`, { checkpointId }) - } catch (deleteError) { - logger.warn(`[${tracker.requestId}] Failed to delete checkpoint after revert`, { - checkpointId, - error: deleteError, - }) - // Don't fail the request if deletion fails - the revert was successful - } - - return NextResponse.json({ - success: true, - workflowId: checkpoint.workflowId, - checkpointId, - revertedAt: new Date().toISOString(), - checkpoint: { - id: checkpoint.id, - workflowState: cleanedState, - }, - }) - } catch (error) { - logger.error(`[${tracker.requestId}] Error reverting to checkpoint:`, error) - return createInternalServerErrorResponse('Failed to revert to checkpoint') - } -}) diff --git a/apps/sim/app/api/copilot/checkpoints/route.test.ts b/apps/sim/app/api/copilot/checkpoints/route.test.ts deleted file mode 100644 index 8ee9df0bc5d..00000000000 --- a/apps/sim/app/api/copilot/checkpoints/route.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Tests for copilot checkpoints API route - * - * @vitest-environment node - */ -import { - authMockFns, - dbChainMockFns, - queueTableRows, - resetDbChainMock, - schemaMock, - workflowAuthzMockFns, - workflowsUtilsMock, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetAccessibleCopilotChat } = vi.hoisted(() => ({ - mockGetAccessibleCopilotChat: vi.fn(), -})) - -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ - getAccessibleCopilotChat: mockGetAccessibleCopilotChat, - getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat, -})) - -vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) - -import { GET, POST } from './route' - -function createMockRequest(method: string, body: Record): NextRequest { - return new NextRequest('http://localhost:3000/api/copilot/checkpoints', { - method, - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - }) -} - -describe('Copilot Checkpoints API Route', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - - authMockFns.mockGetSession.mockResolvedValue(null) - - mockGetAccessibleCopilotChat.mockResolvedValue({ - id: 'chat-123', - userId: 'user-123', - workflowId: 'workflow-123', - }) - workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ - allowed: true, - }) - }) - - afterAll(() => { - resetDbChainMock() - }) - - describe('POST', () => { - it('should return 401 when user is not authenticated', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = createMockRequest('POST', { - workflowId: 'workflow-123', - chatId: 'chat-123', - workflowState: '{"blocks": []}', - }) - - const response = await POST(req) - - expect(response.status).toBe(401) - const responseData = await response.json() - expect(responseData).toEqual({ error: 'Unauthorized' }) - }) - - it('should return 400 for invalid request body', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const req = createMockRequest('POST', { - workflowId: 'workflow-123', - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(typeof responseData.error).toBe('string') - }) - - it('should return 400 when chat not found or unauthorized', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockGetAccessibleCopilotChat.mockResolvedValueOnce(null) - - const req = createMockRequest('POST', { - workflowId: 'workflow-123', - chatId: 'chat-123', - workflowState: '{"blocks": []}', - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.error).toBe('Chat not found or unauthorized') - }) - - it('should return 400 for invalid workflow state JSON', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const req = createMockRequest('POST', { - workflowId: 'workflow-123', - chatId: 'chat-123', - workflowState: 'invalid-json', - }) - - const response = await POST(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.error).toBe('Invalid workflow state JSON') - }) - - it('should successfully create a checkpoint', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const checkpoint = { - id: 'checkpoint-123', - userId: 'user-123', - workflowId: 'workflow-123', - chatId: 'chat-123', - messageId: 'message-123', - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - } - dbChainMockFns.returning.mockResolvedValueOnce([checkpoint]) - - const workflowState = { blocks: [], connections: [] } - const req = createMockRequest('POST', { - workflowId: 'workflow-123', - chatId: 'chat-123', - messageId: 'message-123', - workflowState: JSON.stringify(workflowState), - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - checkpoint: { - id: 'checkpoint-123', - userId: 'user-123', - workflowId: 'workflow-123', - chatId: 'chat-123', - messageId: 'message-123', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - }, - }) - - expect(dbChainMockFns.insert).toHaveBeenCalled() - expect(dbChainMockFns.values).toHaveBeenCalledWith({ - userId: 'user-123', - workflowId: 'workflow-123', - chatId: 'chat-123', - messageId: 'message-123', - workflowState: workflowState, - }) - }) - - it('should create checkpoint without messageId', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const checkpoint = { - id: 'checkpoint-123', - userId: 'user-123', - workflowId: 'workflow-123', - chatId: 'chat-123', - messageId: undefined, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - } - dbChainMockFns.returning.mockResolvedValueOnce([checkpoint]) - - const workflowState = { blocks: [] } - const req = createMockRequest('POST', { - workflowId: 'workflow-123', - chatId: 'chat-123', - workflowState: JSON.stringify(workflowState), - }) - - const response = await POST(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData.success).toBe(true) - expect(responseData.checkpoint.messageId).toBeUndefined() - }) - - it('should handle database errors during checkpoint creation', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - dbChainMockFns.returning.mockRejectedValueOnce(new Error('Database insert failed')) - - const req = createMockRequest('POST', { - workflowId: 'workflow-123', - chatId: 'chat-123', - workflowState: '{"blocks": []}', - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to create checkpoint') - }) - - it('should handle database errors during chat lookup', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - mockGetAccessibleCopilotChat.mockRejectedValueOnce(new Error('Database query failed')) - - const req = createMockRequest('POST', { - workflowId: 'workflow-123', - chatId: 'chat-123', - workflowState: '{"blocks": []}', - }) - - const response = await POST(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to create checkpoint') - }) - }) - - describe('GET', () => { - it('should return 401 when user is not authenticated', async () => { - authMockFns.mockGetSession.mockResolvedValue(null) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') - - const response = await GET(req) - - expect(response.status).toBe(401) - const responseData = await response.json() - expect(responseData).toEqual({ error: 'Unauthorized' }) - }) - - it('should return 400 when chatId is missing', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints') - - const response = await GET(req) - - expect(response.status).toBe(400) - const responseData = await response.json() - expect(responseData.error).toBe('chatId is required') - }) - - it('should return checkpoints for authenticated user and chat', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - const mockCheckpoints = [ - { - id: 'checkpoint-1', - userId: 'user-123', - workflowId: 'workflow-123', - chatId: 'chat-123', - messageId: 'message-1', - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - }, - { - id: 'checkpoint-2', - userId: 'user-123', - workflowId: 'workflow-123', - chatId: 'chat-123', - messageId: 'message-2', - createdAt: new Date('2024-01-02'), - updatedAt: new Date('2024-01-02'), - }, - ] - - queueTableRows(schemaMock.workflowCheckpoints, mockCheckpoints) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') - - const response = await GET(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - checkpoints: [ - { - id: 'checkpoint-1', - userId: 'user-123', - workflowId: 'workflow-123', - chatId: 'chat-123', - messageId: 'message-1', - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-01T00:00:00.000Z', - }, - { - id: 'checkpoint-2', - userId: 'user-123', - workflowId: 'workflow-123', - chatId: 'chat-123', - messageId: 'message-2', - createdAt: '2024-01-02T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }, - ], - }) - - expect(dbChainMockFns.select).toHaveBeenCalled() - expect(dbChainMockFns.where).toHaveBeenCalled() - expect(dbChainMockFns.orderBy).toHaveBeenCalled() - }) - - it('should handle database errors when fetching checkpoints', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - dbChainMockFns.orderBy.mockRejectedValueOnce(new Error('Database query failed')) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') - - const response = await GET(req) - - expect(response.status).toBe(500) - const responseData = await response.json() - expect(responseData.error).toBe('Failed to fetch checkpoints') - }) - - it('should return empty array when no checkpoints found', async () => { - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - - queueTableRows(schemaMock.workflowCheckpoints, []) - - const req = new NextRequest('http://localhost:3000/api/copilot/checkpoints?chatId=chat-123') - - const response = await GET(req) - - expect(response.status).toBe(200) - const responseData = await response.json() - expect(responseData).toEqual({ - success: true, - checkpoints: [], - }) - }) - }) -}) diff --git a/apps/sim/app/api/copilot/checkpoints/route.ts b/apps/sim/app/api/copilot/checkpoints/route.ts deleted file mode 100644 index 4bd861ffb50..00000000000 --- a/apps/sim/app/api/copilot/checkpoints/route.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { db } from '@sim/db' -import { workflowCheckpoints } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import { and, desc, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { - createCopilotCheckpointContract, - listCopilotCheckpointsContract, -} from '@/lib/api/contracts/copilot' -import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server' -import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' -import { - authenticateCopilotRequestSessionOnly, - createBadRequestResponse, - createInternalServerErrorResponse, - createRequestTracker, - createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('WorkflowCheckpointsAPI') - -/** - * POST /api/copilot/checkpoints - * Create a new checkpoint with JSON workflow state - */ -export const POST = withRouteHandler(async (req: NextRequest) => { - const tracker = createRequestTracker() - - try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return createUnauthorizedResponse() - } - - const parsed = await parseRequest( - createCopilotCheckpointContract, - req, - {}, - { - validationErrorResponse: (error) => - validationErrorResponse( - error, - getValidationErrorMessage(error, 'Invalid checkpoint payload') - ), - } - ) - if (!parsed.success) return parsed.response - const { workflowId, chatId, messageId, workflowState } = parsed.data.body - - logger.info(`[${tracker.requestId}] Creating workflow checkpoint`, { - userId, - workflowId, - chatId, - messageId, - parsedData: { workflowId, chatId, messageId }, - messageIdType: typeof messageId, - messageIdExists: !!messageId, - }) - - // Verify that the chat belongs to the user - const chat = await getAccessibleCopilotChatAuth(chatId, userId) - - if (!chat) { - return createBadRequestResponse('Chat not found or unauthorized') - } - - if (chat.workflowId !== workflowId) { - return createBadRequestResponse('Chat does not belong to the requested workflow') - } - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action: 'write', - }) - if (!authorization.allowed) { - return createUnauthorizedResponse() - } - - // Parse the workflow state to validate it's valid JSON - let parsedWorkflowState - try { - parsedWorkflowState = JSON.parse(workflowState) - } catch (error) { - return createBadRequestResponse('Invalid workflow state JSON') - } - - // Create checkpoint with JSON workflow state - const [checkpoint] = await db - .insert(workflowCheckpoints) - .values({ - userId, - workflowId, - chatId, - messageId, - workflowState: parsedWorkflowState, // Store as JSON object - }) - .returning() - - logger.info(`[${tracker.requestId}] Workflow checkpoint created successfully`, { - checkpointId: checkpoint.id, - savedData: { - checkpointId: checkpoint.id, - userId: checkpoint.userId, - workflowId: checkpoint.workflowId, - chatId: checkpoint.chatId, - messageId: checkpoint.messageId, - createdAt: checkpoint.createdAt, - }, - }) - - return NextResponse.json({ - success: true, - checkpoint: { - id: checkpoint.id, - userId: checkpoint.userId, - workflowId: checkpoint.workflowId, - chatId: checkpoint.chatId, - messageId: checkpoint.messageId, - createdAt: checkpoint.createdAt, - updatedAt: checkpoint.updatedAt, - }, - }) - } catch (error) { - logger.error(`[${tracker.requestId}] Failed to create workflow checkpoint:`, error) - return createInternalServerErrorResponse('Failed to create checkpoint') - } -}) - -/** - * GET /api/copilot/checkpoints?chatId=xxx - * Retrieve workflow checkpoints for a chat - */ -export const GET = withRouteHandler(async (req: NextRequest) => { - const tracker = createRequestTracker() - - try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return createUnauthorizedResponse() - } - - const parsed = await parseRequest( - listCopilotCheckpointsContract, - req, - {}, - { - validationErrorResponse: (error) => - validationErrorResponse(error, getValidationErrorMessage(error)), - } - ) - if (!parsed.success) return parsed.response - const { chatId } = parsed.data.query - - logger.info(`[${tracker.requestId}] Fetching workflow checkpoints for chat`, { - userId, - chatId, - }) - - const chat = await getAccessibleCopilotChatAuth(chatId, userId) - if (!chat) { - return createBadRequestResponse('Chat not found or unauthorized') - } - - // Fetch checkpoints for this user and chat - const checkpoints = await db - .select({ - id: workflowCheckpoints.id, - userId: workflowCheckpoints.userId, - workflowId: workflowCheckpoints.workflowId, - chatId: workflowCheckpoints.chatId, - messageId: workflowCheckpoints.messageId, - createdAt: workflowCheckpoints.createdAt, - updatedAt: workflowCheckpoints.updatedAt, - }) - .from(workflowCheckpoints) - .where(and(eq(workflowCheckpoints.chatId, chatId), eq(workflowCheckpoints.userId, userId))) - .orderBy(desc(workflowCheckpoints.createdAt)) - - logger.info(`[${tracker.requestId}] Retrieved ${checkpoints.length} workflow checkpoints`) - - return NextResponse.json({ - success: true, - checkpoints, - }) - } catch (error) { - logger.error(`[${tracker.requestId}] Failed to fetch workflow checkpoints:`, error) - return createInternalServerErrorResponse('Failed to fetch checkpoints') - } -}) diff --git a/apps/sim/app/api/copilot/credentials/route.ts b/apps/sim/app/api/copilot/credentials/route.ts deleted file mode 100644 index 6d598984225..00000000000 --- a/apps/sim/app/api/copilot/credentials/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { copilotCredentialsContract } from '@/lib/api/contracts/copilot' -import { parseRequest } from '@/lib/api/server' -import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' -import { routeExecution } from '@/lib/copilot/tools/server/router' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -/** - * GET /api/copilot/credentials - * Returns connected OAuth credentials for the authenticated user. - * Used by the copilot store for credential masking. - */ -export const GET = withRouteHandler(async (req: NextRequest) => { - const parsed = await parseRequest(copilotCredentialsContract, req, {}) - if (!parsed.success) return parsed.response - - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const result = await routeExecution('get_credentials', {}, { userId }) - return NextResponse.json({ success: true, result }) - } catch (error) { - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to load credentials'), - }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/copilot/models/route.ts b/apps/sim/app/api/copilot/models/route.ts deleted file mode 100644 index 7dadeef7ee2..00000000000 --- a/apps/sim/app/api/copilot/models/route.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { copilotModelsContract } from '@/lib/api/contracts/copilot' -import { parseRequest } from '@/lib/api/server' -import { fetchGo } from '@/lib/copilot/request/go/fetch' -import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' -import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' -import { env } from '@/lib/core/config/env' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -interface AvailableModel { - id: string - friendlyName: string - provider: string -} - -const logger = createLogger('CopilotModelsAPI') - -interface RawAvailableModel { - id: string - friendlyName?: string - displayName?: string - provider?: string -} - -function isRawAvailableModel(item: unknown): item is RawAvailableModel { - return ( - typeof item === 'object' && - item !== null && - 'id' in item && - typeof (item as { id: unknown }).id === 'string' - ) -} - -export const GET = withRouteHandler(async (req: NextRequest) => { - const parsed = await parseRequest(copilotModelsContract, req, {}) - if (!parsed.success) return parsed.response - - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const headers: Record = { - 'Content-Type': 'application/json', - } - if (env.COPILOT_API_KEY) { - headers['x-api-key'] = env.COPILOT_API_KEY - } - - try { - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - const response = await fetchGo(`${mothershipBaseURL}/api/get-available-models`, { - method: 'GET', - headers, - cache: 'no-store', - spanName: 'sim → go /api/get-available-models', - operation: 'get_available_models', - }) - - const payload = await response.json().catch(() => ({})) - if (!response.ok) { - logger.warn('Failed to fetch available models from copilot backend', { - status: response.status, - }) - return NextResponse.json( - { - success: false, - error: payload?.error || 'Failed to fetch available models', - models: [], - }, - { status: response.status } - ) - } - - const rawModels = Array.isArray(payload?.models) ? payload.models : [] - const models: AvailableModel[] = rawModels - .filter((item: unknown): item is RawAvailableModel => isRawAvailableModel(item)) - .map((item: RawAvailableModel) => ({ - id: item.id, - friendlyName: item.friendlyName || item.displayName || item.id, - provider: item.provider || 'unknown', - })) - - return NextResponse.json({ success: true, models }) - } catch (error) { - logger.error('Error fetching available models', { - error: toError(error).message, - }) - return NextResponse.json( - { - success: false, - error: 'Failed to fetch available models', - models: [], - }, - { status: 500 } - ) - } -}) diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts deleted file mode 100644 index d880aaf1dae..00000000000 --- a/apps/sim/app/api/emails/preview/route.ts +++ /dev/null @@ -1,296 +0,0 @@ -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { - renderAbandonedCheckoutEmail, - renderBatchInvitationEmail, - renderCreditPurchaseEmail, - renderCreditsExhaustedEmail, - renderEnterpriseSubscriptionEmail, - renderExistingAccountEmail, - renderFreeTierUpgradeEmail, - renderHelpConfirmationEmail, - renderInvitationEmail, - renderLimitThresholdEmail, - renderOnboardingFollowupEmail, - renderOTPEmail, - renderPasswordResetEmail, - renderPaymentFailedEmail, - renderPlanWelcomeEmail, - renderScheduleDisabledEmail, - renderUsageLimitReachedEmail, - renderUsageThresholdEmail, - renderWelcomeEmail, - renderWorkspaceAddedEmail, - renderWorkspaceInvitationEmail, -} from '@/components/emails' -import { colors, typography } from '@/components/emails/_styles' -import { emailPreviewQuerySchema } from '@/lib/api/contracts/common' -import { validationErrorResponse } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const emailTemplates = { - // Auth emails - otp: () => renderOTPEmail('123456', 'user@example.com', 'email-verification'), - 'otp-sign-in': () => renderOTPEmail('123456', 'user@example.com', 'sign-in'), - 'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'), - 'existing-account': () => renderExistingAccountEmail('John'), - welcome: () => renderWelcomeEmail('John'), - 'onboarding-followup': () => renderOnboardingFollowupEmail('John'), - - // Invitation emails - invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'), - 'workspace-added': () => - renderWorkspaceAddedEmail('Jane Doe', 'Engineering', 'https://sim.ai/workspace/ws_123'), - 'batch-invitation': () => - renderBatchInvitationEmail( - 'Jane Doe', - 'Acme Corp', - 'admin', - [ - { workspaceId: 'ws_123', workspaceName: 'Engineering', permission: 'write' }, - { workspaceId: 'ws_456', workspaceName: 'Design', permission: 'read' }, - ], - 'https://sim.ai/invite/abc123' - ), - 'workspace-invitation': () => - renderWorkspaceInvitationEmail( - 'John Smith', - ['Engineering Team'], - 'https://sim.ai/workspace/invite/abc123' - ), - - // Support emails - 'help-confirmation': () => renderHelpConfirmationEmail('feature_request', 2), - - // Billing emails - 'usage-threshold': () => - renderUsageThresholdEmail({ - userName: 'John', - planName: 'Pro', - percentUsed: 75, - currentUsage: 15, - limit: 20, - ctaLink: 'https://sim.ai/settings/billing', - }), - 'enterprise-subscription': () => renderEnterpriseSubscriptionEmail('John'), - 'free-tier-upgrade': () => - renderFreeTierUpgradeEmail({ - userName: 'John', - percentUsed: 90, - currentUsage: 9, - limit: 10, - upgradeLink: 'https://sim.ai/settings/billing', - }), - 'plan-welcome-pro': () => - renderPlanWelcomeEmail({ - planName: 'Pro', - userName: 'John', - loginLink: 'https://sim.ai/login', - }), - 'plan-welcome-team': () => - renderPlanWelcomeEmail({ - planName: 'Team', - userName: 'John', - loginLink: 'https://sim.ai/login', - }), - 'credit-purchase': () => - renderCreditPurchaseEmail({ - userName: 'John', - amount: 50, - newBalance: 75, - }), - 'credits-exhausted': () => - renderCreditsExhaustedEmail({ - userName: 'John', - limit: 10, - upgradeLink: 'https://sim.ai/settings/billing', - }), - 'abandoned-checkout': () => renderAbandonedCheckoutEmail('John'), - 'limit-threshold-storage-warning': () => - renderLimitThresholdEmail({ - kind: 'warning', - reason: 'storage', - userName: 'John', - usageLabel: '4.2 GB', - limitLabel: '5 GB', - percentUsed: 84, - upgradeLink: 'https://sim.ai/settings/billing', - }), - 'limit-threshold-tables-reached': () => - renderLimitThresholdEmail({ - kind: 'reached', - reason: 'tables', - userName: 'John', - usageLabel: '50,000 rows', - limitLabel: '50,000 rows', - percentUsed: 100, - upgradeLink: 'https://sim.ai/settings/billing', - }), - 'limit-threshold-seats-reached': () => - renderLimitThresholdEmail({ - kind: 'reached', - reason: 'seats', - userName: 'John', - usageLabel: '10 seats', - limitLabel: '10 seats', - percentUsed: 100, - upgradeLink: 'https://sim.ai/settings/billing', - }), - 'payment-failed': () => - renderPaymentFailedEmail({ - userName: 'John', - amountDue: 20, - lastFourDigits: '4242', - billingPortalUrl: 'https://sim.ai/settings/billing', - failureReason: 'Card declined', - }), - 'usage-limit-reached': () => - renderUsageLimitReachedEmail({ - userName: 'John', - planName: 'Pro', - scope: 'user', - currentUsage: 20, - limit: 20, - ctaLink: 'https://sim.ai/settings/billing', - }), - 'usage-limit-reached-org': () => - renderUsageLimitReachedEmail({ - userName: 'John', - planName: 'Team', - scope: 'organization', - currentUsage: 500, - limit: 500, - ctaLink: 'https://sim.ai/workspace/ws_123/settings/billing', - }), - - // Operational notification emails - 'schedule-disabled': () => - renderScheduleDisabledEmail({ - recipientName: 'John', - resourceName: 'Daily digest', - reason: 'consecutive_failures', - failedCount: 100, - manageLink: 'https://sim.ai/workspace/ws_123/w/wf_456', - }), - 'schedule-disabled-auth': () => - renderScheduleDisabledEmail({ - recipientName: 'John', - resourceName: 'Weekly report', - reason: 'authentication_error', - manageLink: 'https://sim.ai/workspace/ws_123/w/wf_456', - }), -} as const - -type EmailTemplate = keyof typeof emailTemplates - -function isEmailTemplate(template: string): template is EmailTemplate { - return template in emailTemplates -} - -const CATEGORIZED = { - Auth: ['otp', 'otp-sign-in', 'reset-password', 'existing-account', 'welcome'], - Invitations: ['invitation', 'batch-invitation', 'workspace-invitation', 'workspace-added'], - Support: ['help-confirmation'], - Billing: [ - 'usage-threshold', - 'usage-limit-reached', - 'usage-limit-reached-org', - 'free-tier-upgrade', - 'credits-exhausted', - 'limit-threshold-storage-warning', - 'limit-threshold-tables-reached', - 'limit-threshold-seats-reached', - 'payment-failed', - 'credit-purchase', - 'plan-welcome-pro', - 'plan-welcome-team', - 'enterprise-subscription', - ], - Notifications: ['schedule-disabled', 'schedule-disabled-auth'], - 'Plain (unbranded)': ['onboarding-followup', 'abandoned-checkout'], -} satisfies Record - -/** - * Category map for the gallery, with any template missing from {@link CATEGORIZED} - * appended rather than dropped — so a newly registered template always shows up - * even if nobody remembers to file it. - */ -const PREVIEW_CATEGORIES: Record = (() => { - const filed = new Set(Object.values(CATEGORIZED).flat()) - const unfiled = (Object.keys(emailTemplates) as EmailTemplate[]).filter((t) => !filed.has(t)) - return unfiled.length > 0 ? { ...CATEGORIZED, Uncategorized: unfiled } : CATEGORIZED -})() - -export const GET = withRouteHandler(async (request: NextRequest) => { - const { searchParams } = new URL(request.url) - const queryValidation = emailPreviewQuerySchema.safeParse( - Object.fromEntries(searchParams.entries()) - ) - if (!queryValidation.success) return validationErrorResponse(queryValidation.error) - const { template } = queryValidation.data - - if (!template) { - const categoryHtml = Object.entries(PREVIEW_CATEGORIES) - .map( - ([category, templates]) => ` -
-

${category}

-
- ${templates - .map( - (t) => ` -
-
${t}open ↗
- -
` - ) - .join('')} -
-
` - ) - .join('') - - return new NextResponse( - ` - - - - - Email Templates - - - -

Email Templates

-

Every email Sim sends — ${Object.keys(emailTemplates).length} previews.

- ${categoryHtml} - -`, - { headers: { 'Content-Type': 'text/html' } } - ) - } - - if (!isEmailTemplate(template)) { - return NextResponse.json({ error: `Unknown template: ${template}` }, { status: 400 }) - } - - const html = await emailTemplates[template]() - - return new NextResponse(html, { - headers: { 'Content-Type': 'text/html' }, - }) -}) diff --git a/apps/sim/app/api/files/download/route.ts b/apps/sim/app/api/files/download/route.ts deleted file mode 100644 index 670f43bb2e4..00000000000 --- a/apps/sim/app/api/files/download/route.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { fileDownloadContract } from '@/lib/api/contracts/storage-transfer' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { hasCloudStorage } from '@/lib/uploads/core/storage-service' -import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' -import { verifyFileAccess } from '@/app/api/files/authorization' -import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' - -const logger = createLogger('FileDownload') - -export const dynamic = 'force-dynamic' - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - - if (!authResult.success || !authResult.userId) { - logger.warn('Unauthorized download URL request', { - error: authResult.error || 'Missing userId', - }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = authResult.userId - - const parsed = await parseRequest( - fileDownloadContract, - request, - {}, - { - validationErrorResponse: (error) => - createErrorResponse( - new Error(getValidationErrorMessage(error, 'Invalid request data')), - 400 - ), - } - ) - if (!parsed.success) return parsed.response - - const { key, name, url } = parsed.data.body - - if (!key) { - return createErrorResponse(new Error('File key is required'), 400) - } - - if (key.startsWith('url/')) { - if (!url) { - return createErrorResponse(new Error('URL is required for URL-type files'), 400) - } - - return NextResponse.json({ - downloadUrl: url, - expiresIn: null, - fileName: name || key.split('/').pop() || 'download', - }) - } - - // Derive context from the trusted key prefix, mirroring the serve route this URL - // delegates to, which re-derives context from the key and ignores any client-supplied value. - const storageContext = inferContextFromKey(key) - - const hasAccess = await verifyFileAccess( - key, - userId, - undefined, // customConfig - storageContext, // context - !hasCloudStorage() // isLocal - ) - - if (!hasAccess) { - logger.warn('Unauthorized download URL request', { userId, key, context: storageContext }) - throw new FileNotFoundError(`File not found: ${key}`) - } - - const { getBaseUrl } = await import('@/lib/core/utils/urls') - const downloadUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(key)}?context=${storageContext}` - - logger.info(`Generated download URL for ${storageContext} file: ${key}`) - - const downloadName = name || key.split('/').pop() || 'download' - recordAudit({ - workspaceId: null, - actorId: userId, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceName: downloadName, - description: `Downloaded file "${downloadName}"`, - metadata: { key, fileName: downloadName, context: storageContext }, - request, - }) - captureServerEvent(userId, 'file_downloaded', { - is_bulk: false, - file_count: 1, - }) - - return NextResponse.json({ - downloadUrl, - expiresIn: null, - fileName: downloadName, - }) - } catch (error) { - logger.error('Error in file download endpoint:', error) - - if (error instanceof FileNotFoundError) { - return createErrorResponse(error) - } - - return createErrorResponse( - error instanceof Error ? error : new Error('Internal server error'), - 500 - ) - } -}) diff --git a/apps/sim/app/api/logs/triggers/route.ts b/apps/sim/app/api/logs/triggers/route.ts deleted file mode 100644 index 2b033384eca..00000000000 --- a/apps/sim/app/api/logs/triggers/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { db } from '@sim/db' -import { workflowExecutionLogs } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, isNotNull, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { triggersQuerySchema } from '@/lib/api/contracts/logs' -import { searchParamsToObject, validationErrorResponse } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('TriggersAPI') - -export const revalidate = 0 - -/** - * GET /api/logs/triggers - * - * Returns unique trigger types from workflow execution logs - * Only includes integration triggers (excludes core types: api, manual, webhook, chat, schedule) - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized triggers access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - - const { searchParams } = new URL(request.url) - const validation = triggersQuerySchema.safeParse(searchParamsToObject(searchParams)) - if (!validation.success) { - logger.error(`[${requestId}] Invalid query parameters`, { error: validation.error }) - return validationErrorResponse(validation.error) - } - - const params = validation.data - - const access = await checkWorkspaceAccess(params.workspaceId, userId) - if (!access.hasAccess) { - return NextResponse.json({ triggers: [], count: 0 }) - } - - const triggers = await db - .selectDistinct({ - trigger: workflowExecutionLogs.trigger, - }) - .from(workflowExecutionLogs) - .where( - and( - eq(workflowExecutionLogs.workspaceId, params.workspaceId), - isNotNull(workflowExecutionLogs.trigger), - sql`${workflowExecutionLogs.trigger} NOT IN ('api', 'manual', 'webhook', 'chat', 'schedule')` - ) - ) - - const triggerValues = triggers - .map((row) => row.trigger) - .filter((t): t is string => Boolean(t)) - .sort() - - return NextResponse.json({ - triggers: triggerValues, - count: triggerValues.length, - }) - } catch (err) { - logger.error(`[${requestId}] Failed to fetch triggers`, { error: err }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/mothership/local-files/stage/route.test.ts b/apps/sim/app/api/mothership/local-files/stage/route.test.ts deleted file mode 100644 index a4126cb7cbe..00000000000 --- a/apps/sim/app/api/mothership/local-files/stage/route.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * @vitest-environment node - */ -import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockSelect, - mockFrom, - mockWhere, - mockLimit, - mockParseRequest, - mockGetAccessibleChat, - mockGetPermission, - mockTrackChatUpload, -} = vi.hoisted(() => ({ - mockSelect: vi.fn(), - mockFrom: vi.fn(), - mockWhere: vi.fn(), - mockLimit: vi.fn(), - mockParseRequest: vi.fn(), - mockGetAccessibleChat: vi.fn(), - mockGetPermission: vi.fn(), - mockTrackChatUpload: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ - db: { select: mockSelect }, -})) - -vi.mock('@sim/db/schema', () => ({ - workspaceFiles: { - key: 'workspaceFiles.key', - userId: 'workspaceFiles.userId', - workspaceId: 'workspaceFiles.workspaceId', - context: 'workspaceFiles.context', - chatId: 'workspaceFiles.chatId', - displayName: 'workspaceFiles.displayName', - originalName: 'workspaceFiles.originalName', - contentType: 'workspaceFiles.contentType', - sizeBytes: 'workspaceFiles.sizeBytes', - deletedAt: 'workspaceFiles.deletedAt', - }, -})) - -vi.mock('drizzle-orm', () => ({ - and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })), - eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })), - isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })), -})) - -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) -vi.mock('@/lib/api/server', () => ({ parseRequest: mockParseRequest })) -vi.mock('@/lib/api/contracts/mothership-chats', () => ({ stageLocalFileUploadContract: {} })) -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ - getAccessibleCopilotChatAuth: mockGetAccessibleChat, -})) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetPermission, -})) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - trackChatUpload: mockTrackChatUpload, -})) - -import { POST } from '@/app/api/mothership/local-files/stage/route' - -function request() { - return new NextRequest('http://localhost:3000/api/mothership/local-files/stage', { - method: 'POST', - body: JSON.stringify({ workspaceId: 'ws-1', chatId: 'chat-1', key: 'storage-key' }), - }) -} - -describe('POST /api/mothership/local-files/stage', () => { - beforeEach(() => { - vi.clearAllMocks() - copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ - userId: 'user-1', - isAuthenticated: true, - }) - mockParseRequest.mockResolvedValue({ - success: true, - data: { body: { workspaceId: 'ws-1', chatId: 'chat-1', key: 'storage-key' } }, - }) - mockGetAccessibleChat.mockResolvedValue({ - id: 'chat-1', - workspaceId: 'ws-1', - type: 'mothership', - }) - mockGetPermission.mockResolvedValue('write') - mockLimit.mockResolvedValue([ - { - chatId: null, - displayName: null, - originalName: 'report.pdf', - contentType: 'application/pdf', - sizeBytes: 42, - }, - ]) - mockWhere.mockReturnValue({ limit: mockLimit }) - mockFrom.mockReturnValue({ where: mockWhere }) - mockSelect.mockReturnValue({ from: mockFrom }) - mockTrackChatUpload.mockResolvedValue({ displayName: 'report.pdf' }) - }) - - it('links only the authenticated user upload to the active chat', async () => { - const response = await POST(request()) - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - success: true, - displayName: 'report.pdf', - fileName: 'report.pdf', - uploadPath: 'uploads/report.pdf', - }) - expect(mockTrackChatUpload).toHaveBeenCalledWith( - 'ws-1', - 'user-1', - 'chat-1', - 'storage-key', - 'report.pdf', - 'application/pdf', - 42 - ) - }) - - it('rejects a chat from another workspace before reading upload metadata', async () => { - mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1', workspaceId: 'ws-other' }) - const response = await POST(request()) - - expect(response.status).toBe(404) - expect(mockSelect).not.toHaveBeenCalled() - }) - - it('is idempotent when the upload is already linked to this chat', async () => { - mockLimit.mockResolvedValue([ - { - chatId: 'chat-1', - displayName: 'report (2).pdf', - originalName: 'report.pdf', - contentType: 'application/pdf', - sizeBytes: 42, - }, - ]) - const response = await POST(request()) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ - fileName: 'report (2).pdf', - uploadPath: 'uploads/report%20(2).pdf', - }) - expect(mockTrackChatUpload).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/mothership/local-files/stage/route.ts b/apps/sim/app/api/mothership/local-files/stage/route.ts deleted file mode 100644 index e52379caec9..00000000000 --- a/apps/sim/app/api/mothership/local-files/stage/route.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { db } from '@sim/db' -import { workspaceFiles } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { stageLocalFileUploadContract } from '@/lib/api/contracts/mothership-chats' -import { parseRequest } from '@/lib/api/server' -import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' -import { - authenticateCopilotRequestSessionOnly, - createInternalServerErrorResponse, - createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - trackChatUpload, - WorkspaceFileKeyOwnershipError, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('StageLocalFileUploadAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return createUnauthorizedResponse() - } - - const parsed = await parseRequest(stageLocalFileUploadContract, request, {}) - if (!parsed.success) return parsed.response - const { workspaceId, chatId, key } = parsed.data.body - - const [chat, permission] = await Promise.all([ - getAccessibleCopilotChatAuth(chatId, userId), - getUserEntityPermissions(userId, 'workspace', workspaceId), - ]) - if (!chat || chat.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) - } - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json( - { error: 'Write or Admin access required for chat uploads' }, - { status: 403 } - ) - } - - const [file] = await db - .select({ - chatId: workspaceFiles.chatId, - displayName: workspaceFiles.displayName, - originalName: workspaceFiles.originalName, - contentType: workspaceFiles.contentType, - sizeBytes: workspaceFiles.sizeBytes, - }) - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.key, key), - eq(workspaceFiles.userId, userId), - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'mothership'), - isNull(workspaceFiles.deletedAt) - ) - ) - .limit(1) - - if (!file) { - return NextResponse.json({ error: 'Uploaded file not found' }, { status: 404 }) - } - if (file.chatId && file.chatId !== chatId) { - return NextResponse.json( - { error: 'Uploaded file is already linked to another chat' }, - { status: 409 } - ) - } - - const displayName = - file.chatId === chatId && file.displayName - ? file.displayName - : ( - await trackChatUpload( - workspaceId, - userId, - chatId, - key, - file.originalName, - file.contentType, - getWorkspaceFileSize(file) - ) - ).displayName - - return NextResponse.json({ - success: true, - displayName, - fileName: displayName, - uploadPath: `uploads/${encodeVfsSegment(displayName)}`, - }) - } catch (error) { - if (error instanceof WorkspaceFileKeyOwnershipError) { - // The caller supplied a key they may not bind — a client error, not ours. - logger.warn('Rejected chat upload staging for an unowned storage key', { - error: error.message, - }) - return NextResponse.json({ error: 'Storage key is not available' }, { status: 403 }) - } - logger.error('Failed to stage local file upload', error) - return createInternalServerErrorResponse('Failed to stage local file upload') - } -}) diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts deleted file mode 100644 index 300d00dddb3..00000000000 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { db } from '@sim/db' -import { invitation, member, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { isOrgAdminRole } from '@sim/platform-authz/workspace' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { organizationParamsSchema } from '@/lib/api/contracts/organization' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('OrganizationInvitations') - -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const paramsResult = organizationParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - - const { id: organizationId } = paramsResult.data - - const [memberEntry] = await db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id))) - .limit(1) - - if (!memberEntry) { - return NextResponse.json( - { error: 'Forbidden - Not a member of this organization' }, - { status: 403 } - ) - } - - const userRole = memberEntry.role - if (!isOrgAdminRole(userRole)) { - return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 }) - } - - const invitations = await db - .select({ - id: invitation.id, - email: invitation.email, - kind: invitation.kind, - role: invitation.role, - status: invitation.status, - expiresAt: invitation.expiresAt, - createdAt: invitation.createdAt, - inviterName: user.name, - inviterEmail: user.email, - }) - .from(invitation) - .leftJoin(user, eq(invitation.inviterId, user.id)) - .where(eq(invitation.organizationId, organizationId)) - .orderBy(invitation.createdAt) - - return NextResponse.json({ - success: true, - data: { invitations, userRole }, - }) - } catch (error) { - logger.error('Failed to get organization invitations', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) diff --git a/apps/sim/app/api/status/route.ts b/apps/sim/app/api/status/route.ts deleted file mode 100644 index 58a47741ed7..00000000000 --- a/apps/sim/app/api/status/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { noInputSchema } from '@/lib/api/contracts/primitives' -import { validationErrorResponse } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { IncidentIOWidgetResponse, StatusResponse, StatusType } from '@/app/api/status/types' - -const logger = createLogger('StatusAPI') - -let cachedResponse: { data: StatusResponse; timestamp: number } | null = null -const CACHE_TTL = 2 * 60 * 1000 - -function determineStatus(data: IncidentIOWidgetResponse): { - status: StatusType - message: string -} { - if (data.ongoing_incidents && data.ongoing_incidents.length > 0) { - const worstImpact = data.ongoing_incidents[0].current_worst_impact - - if (worstImpact === 'full_outage') { - return { status: 'outage', message: 'Service Disruption' } - } - if (worstImpact === 'partial_outage') { - return { status: 'degraded', message: 'Experiencing Issues' } - } - return { status: 'degraded', message: 'Experiencing Issues' } - } - - if (data.in_progress_maintenances && data.in_progress_maintenances.length > 0) { - return { status: 'maintenance', message: 'Under Maintenance' } - } - - return { status: 'operational', message: 'All Systems Operational' } -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const queryValidation = noInputSchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams.entries()) - ) - if (!queryValidation.success) return validationErrorResponse(queryValidation.error) - - const now = Date.now() - - if (cachedResponse && now - cachedResponse.timestamp < CACHE_TTL) { - return NextResponse.json(cachedResponse.data, { - headers: { - 'Cache-Control': 'public, max-age=60, s-maxage=60', - 'X-Cache': 'HIT', - }, - }) - } - - const response = await fetch('https://status.sim.ai/api/v1/summary', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - signal: AbortSignal.timeout(5000), - }) - - if (!response.ok) { - throw new Error(`incident.io API returned ${response.status}`) - } - - const data: IncidentIOWidgetResponse = await response.json() - - const { status, message } = determineStatus(data) - - const statusResponse: StatusResponse = { - status, - message, - url: data.page_url || 'https://status.sim.ai', - lastUpdated: new Date().toISOString(), - } - - cachedResponse = { - data: statusResponse, - timestamp: now, - } - - return NextResponse.json(statusResponse, { - headers: { - 'Cache-Control': 'public, max-age=60, s-maxage=60', - 'X-Cache': 'MISS', - }, - }) - } catch (error) { - logger.error('Error fetching status from incident.io:', error) - - const errorResponse: StatusResponse = { - status: 'error', - message: 'Status Unknown', - url: 'https://status.sim.ai', - lastUpdated: new Date().toISOString(), - } - - return NextResponse.json(errorResponse, { - status: 200, - headers: { - 'Cache-Control': 'public, max-age=30, s-maxage=30', - }, - }) - } -}) diff --git a/apps/sim/app/api/status/types.ts b/apps/sim/app/api/status/types.ts deleted file mode 100644 index 791a399e4a3..00000000000 --- a/apps/sim/app/api/status/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -interface IncidentIOComponent { - id: string - name: string - group_name?: string - current_status: 'operational' | 'degraded_performance' | 'partial_outage' | 'full_outage' -} - -interface IncidentIOIncident { - id: string - name: string - status: 'investigating' | 'identified' | 'monitoring' - url: string - last_update_at: string - last_update_message: string - current_worst_impact: 'degraded_performance' | 'partial_outage' | 'full_outage' - affected_components: IncidentIOComponent[] -} - -interface IncidentIOMaintenance { - id: string - name: string - status: 'maintenance_scheduled' | 'maintenance_in_progress' - url: string - last_update_at: string - last_update_message: string - affected_components: IncidentIOComponent[] - started_at?: string - scheduled_end_at?: string - starts_at?: string - ends_at?: string -} - -export interface IncidentIOWidgetResponse { - page_title: string - page_url: string - ongoing_incidents: IncidentIOIncident[] - in_progress_maintenances: IncidentIOMaintenance[] - scheduled_maintenances: IncidentIOMaintenance[] -} - -export type StatusType = 'operational' | 'degraded' | 'outage' | 'maintenance' | 'loading' | 'error' - -export interface StatusResponse { - status: StatusType - message: string - url: string - lastUpdated: string -} diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts b/apps/sim/app/api/table/[tableId]/export-async/route.test.ts deleted file mode 100644 index b9bc711b714..00000000000 --- a/apps/sim/app/api/table/[tableId]/export-async/route.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * @vitest-environment node - */ -import { createTableDefinition, hybridAuthMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckAccess, - mockMarkTableJobRunning, - mockRunTableExport, - mockGetUserPermissionConfig, -} = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockMarkTableJobRunning: vi.fn(), - mockRunTableExport: vi.fn(), - mockGetUserPermissionConfig: vi.fn(), -})) - -vi.mock('@/lib/permission-groups/resolve.server', () => ({ - getUserPermissionConfig: mockGetUserPermissionConfig, -})) - -vi.mock('@sim/utils/id', () => ({ - generateId: vi.fn().mockReturnValue('job-id-xyz'), - generateShortId: vi.fn().mockReturnValue('short-id'), -})) -vi.mock('@/lib/table/jobs/service', () => ({ markTableJobRunning: mockMarkTableJobRunning })) -vi.mock('@/lib/table/export-runner', () => ({ runTableExport: mockRunTableExport })) -vi.mock('@/lib/core/utils/background', () => ({ - runDetached: (_label: string, work: () => Promise) => { - void work() - }, -})) -vi.mock('@/app/api/table/utils', async () => { - const { NextResponse } = await import('next/server') - return { - checkAccess: mockCheckAccess, - accessError: (result: { status: number }) => - NextResponse.json({ error: 'denied' }, { status: result.status }), - } -}) - -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' -import { POST } from '@/app/api/table/[tableId]/export-async/route' - -function makeRequest(body: unknown, tableId = 'tbl_1') { - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/export-async`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) - return POST(req, { params: Promise.resolve({ tableId }) }) -} - -const validBody = { workspaceId: 'workspace-1', format: 'csv' } - -describe('POST /api/table/[tableId]/export-async', () => { - beforeEach(() => { - vi.clearAllMocks() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'session', - }) - mockCheckAccess.mockResolvedValue({ - ok: true, - table: createTableDefinition({ - columns: [{ name: 'name', type: 'string' }], - rowCount: 50000, - }), - }) - mockGetUserPermissionConfig.mockResolvedValue(null) - mockMarkTableJobRunning.mockResolvedValue(true) - mockRunTableExport.mockResolvedValue(undefined) - }) - - it('claims an export job and kicks off the worker', async () => { - const response = await makeRequest(validBody) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data).toEqual({ tableId: 'tbl_1', jobId: 'job-id-xyz' }) - expect(mockMarkTableJobRunning).toHaveBeenCalledWith('tbl_1', 'job-id-xyz', 'export', { - format: 'csv', - }) - expect(mockRunTableExport).toHaveBeenCalledWith({ - jobId: 'job-id-xyz', - tableId: 'tbl_1', - workspaceId: 'workspace-1', - format: 'csv', - }) - }) - - it('defaults the format to csv', async () => { - const response = await makeRequest({ workspaceId: 'workspace-1' }) - expect(response.status).toBe(200) - expect(mockRunTableExport).toHaveBeenCalledWith(expect.objectContaining({ format: 'csv' })) - }) - - it('returns 409 when the claim fails', async () => { - mockMarkTableJobRunning.mockResolvedValue(false) - const response = await makeRequest(validBody) - expect(response.status).toBe(409) - expect(mockRunTableExport).not.toHaveBeenCalled() - }) - - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) - const response = await makeRequest(validBody) - expect(response.status).toBe(401) - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - }) - - it('returns the access error status when access is denied', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const response = await makeRequest(validBody) - expect(response.status).toBe(403) - expect(mockRunTableExport).not.toHaveBeenCalled() - }) - - it('returns 400 on workspace mismatch', async () => { - const response = await makeRequest({ ...validBody, workspaceId: 'other-ws' }) - expect(response.status).toBe(400) - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - }) - - it('refuses before claiming a job when the group withholds tables.export', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ - ...DEFAULT_PERMISSION_GROUP_CONFIG, - disableTableExport: true, - }) - - const response = await makeRequest(validBody) - - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ - error: "Exporting a table is not available under your organization's permission group", - details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, - }) - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - expect(mockRunTableExport).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/table/[tableId]/export-async/route.ts b/apps/sim/app/api/table/[tableId]/export-async/route.ts deleted file mode 100644 index 29855f25f06..00000000000 --- a/apps/sim/app/api/table/[tableId]/export-async/route.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { exportTableAsyncContract } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' -import { runDetached } from '@/lib/core/utils/background' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' -import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' -import { captureServerEvent } from '@/lib/posthog/server' -import { runTableExport, type TableExportPayload } from '@/lib/table/export-runner' -import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' -import type { TableExportJobPayload } from '@/lib/table/types' -import { accessError, checkAccess } from '@/app/api/table/utils' - -const logger = createLogger('TableExportAsync') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -interface RouteParams { - params: Promise<{ tableId: string }> -} - -/** - * POST /api/table/[tableId]/export-async - * - * Kicks off a background export for large tables (small ones stream synchronously via `/export`). - * Export jobs are read-only, so they bypass the one-running-job-per-table gate (the partial-unique - * index excludes `type = 'export'`) — an export can run alongside an import or delete, and the - * delete-mask keeps a mid-delete export consistent with the delete's outcome. - */ -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest(exportTableAsyncContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId } = parsed.data.params - const { workspaceId, format } = parsed.data.body - - const access = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'read') - if (!access.ok) return accessError(access, requestId, tableId) - if (access.table.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - // permission-group-enforced: tables.export — raw route that queries directly and predates the operation boundary - if (await isWorkspaceCapabilityWithheld(authResult.userId, workspaceId, 'tables.export')) { - return capabilityRefusalResponse('tables.export') - } - - const jobId = generateId() - const jobPayload: TableExportJobPayload = { format } - const claimed = await markTableJobRunning(tableId, jobId, 'export', jobPayload) - if (!claimed) { - // Only possible against another running *export*-typed insert race losing on the pkey, or a - // missing table — the active-job index excludes exports. - return NextResponse.json({ error: 'Failed to start export' }, { status: 409 }) - } - - const payload: TableExportPayload = { - jobId, - tableId, - workspaceId, - format, - } - if (isTriggerDevEnabled) { - try { - const [{ tableExportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ - import('@/background/table-export'), - import('@trigger.dev/sdk'), - import('@/lib/core/async-jobs/region'), - ]) - await tasks.trigger('table-export', payload, { - tags: [`tableId:${tableId}`, `jobId:${jobId}`], - region: await resolveTriggerRegion(), - }) - } catch (error) { - // A failed dispatch must not leave a ghost `running` job holding the - // table's one-write-job slot until the stale-job janitor fires. - await releaseJobClaim(tableId, jobId).catch(() => {}) - throw error - } - } else { - runDetached('table-export', () => runTableExport(payload)) - } - - // Audit at authorization (like the sync route) so a failed/abandoned job still records the export. - recordAudit({ - workspaceId, - actorId: authResult.userId, - action: AuditAction.TABLE_EXPORTED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: access.table.name, - description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, - metadata: { format, rowCount: access.table.rowCount, async: true }, - request, - }) - if (access.table.workspaceId) { - captureServerEvent( - authResult.userId, - 'table_exported', - { table_id: tableId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - } - - logger.info(`[${requestId}] Async export started`, { tableId, jobId, format }) - return NextResponse.json({ success: true, data: { tableId, jobId } }) -}) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts b/apps/sim/app/api/table/[tableId]/export/download/route.test.ts deleted file mode 100644 index 119ac118050..00000000000 --- a/apps/sim/app/api/table/[tableId]/export/download/route.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * @vitest-environment node - */ -import { createTableDefinition, hybridAuthMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockCheckAccess, mockGetTableJob, mockGetUserPermissionConfig, mockPresign } = vi.hoisted( - () => ({ - mockCheckAccess: vi.fn(), - mockGetTableJob: vi.fn(), - mockGetUserPermissionConfig: vi.fn(), - mockPresign: vi.fn(), - }) -) - -vi.mock('@/lib/permission-groups/resolve.server', () => ({ - getUserPermissionConfig: mockGetUserPermissionConfig, -})) -vi.mock('@/lib/table/jobs/service', () => ({ getTableJob: mockGetTableJob })) -vi.mock('@/lib/uploads/core/storage-service', () => ({ - generatePresignedDownloadUrl: mockPresign, -})) -vi.mock('@/app/api/table/utils', async () => { - const { NextResponse } = await import('next/server') - return { - checkAccess: mockCheckAccess, - accessError: (result: { status: number }) => - NextResponse.json({ error: 'denied' }, { status: result.status }), - } -}) - -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' -import { GET } from '@/app/api/table/[tableId]/export/download/route' - -const table = createTableDefinition({ id: 'tbl_1', workspaceId: 'workspace-1' }) - -function makeRequest(tableId = 'tbl_1') { - const req = new NextRequest( - `http://localhost:3000/api/table/${tableId}/export/download?workspaceId=workspace-1&jobId=job-1` - ) - return GET(req, { params: Promise.resolve({ tableId }) }) -} - -describe('GET /api/table/[tableId]/export/download', () => { - beforeEach(() => { - vi.clearAllMocks() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'session', - }) - mockCheckAccess.mockResolvedValue({ ok: true, table }) - mockGetUserPermissionConfig.mockResolvedValue(DEFAULT_PERMISSION_GROUP_CONFIG) - mockGetTableJob.mockResolvedValue({ - type: 'export', - status: 'ready', - payload: { resultKey: 'exports/tbl_1.csv', fileName: 'tbl_1.csv' }, - }) - mockPresign.mockResolvedValue('https://example.com/signed') - }) - - it('presigns a ready export', async () => { - const response = await makeRequest() - expect(response.status).toBe(200) - }) - - it('refuses with the structured capability detail when the group withholds tables.export', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ - ...DEFAULT_PERMISSION_GROUP_CONFIG, - disableTableExport: true, - }) - - const response = await makeRequest() - - expect(response.status).toBe(403) - expect(await response.json()).toEqual({ - error: "Exporting a table is not available under your organization's permission group", - details: { code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }, - }) - expect(mockGetTableJob).not.toHaveBeenCalled() - expect(mockPresign).not.toHaveBeenCalled() - }) - - /** - * An internal executor JWT presents the run's actor, not somebody asking for - * a file: reading it bare would apply that person's group to a delegation the - * executor exemption passes ungated, and refuse the download of an export the - * same run was allowed to start and to list. - */ - it('hands the executor its export without consulting the actor’s group', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'internal_jwt', - }) - mockGetUserPermissionConfig.mockResolvedValue({ - ...DEFAULT_PERMISSION_GROUP_CONFIG, - disableTableExport: true, - }) - - const response = await makeRequest() - - expect(response.status).toBe(200) - expect(mockGetUserPermissionConfig).not.toHaveBeenCalled() - expect(mockPresign).toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/table/[tableId]/export/download/route.ts b/apps/sim/app/api/table/[tableId]/export/download/route.ts deleted file mode 100644 index e14c770162f..00000000000 --- a/apps/sim/app/api/table/[tableId]/export/download/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { exportDownloadContract } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' -import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' -import { getTableJob } from '@/lib/table/jobs/service' -import type { TableExportJobPayload } from '@/lib/table/types' -import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' -import { accessError, checkAccess } from '@/app/api/table/utils' - -const logger = createLogger('TableExportDownload') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -interface RouteParams { - params: Promise<{ tableId: string }> -} - -/** - * GET /api/table/[tableId]/export/download?jobId=… - * - * Resolves a completed export job to a short-lived presigned URL for the generated file. The job - * must belong to the table, be an export, and be `ready` — the worker stamps `resultKey` onto the - * job payload when the upload lands. - */ -export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest(exportDownloadContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId } = parsed.data.params - const { workspaceId, jobId } = parsed.data.query - - const access = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'read') - if (!access.ok) return accessError(access, requestId, tableId) - if (access.table.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - /** - * permission-group-enforced: tables.export — the second door to a finished - * export. Gating only the job that produces one leaves this route handing the - * file to anyone who can name a `jobId`, and the workspace job listing names - * every colleague's. - * - * Keyed to the governed subject, which names nobody for an internal-JWT - * executor call, exactly as the listing and the job that produced this file - * are: `authResult.userId` there is the subject the executor embedded, so - * reading it bare would apply the run's actor's group to a delegation the - * executor exemption deliberately passes ungated — and would refuse the - * download of an export the same run was allowed to start. - */ - const governedUserId = capabilityGovernedAuthUserId(authResult) - if ( - governedUserId && - (await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'tables.export')) - ) { - return capabilityRefusalResponse('tables.export') - } - - const job = await getTableJob(tableId, jobId) - if (!job || job.type !== 'export') { - return NextResponse.json({ error: 'Export job not found' }, { status: 404 }) - } - if (job.status !== 'ready') { - return NextResponse.json({ error: 'Export is not ready' }, { status: 409 }) - } - const payload = job.payload as TableExportJobPayload | null - if (!payload?.resultKey) { - return NextResponse.json({ error: 'Export file is no longer available' }, { status: 410 }) - } - - const url = await generatePresignedDownloadUrl(payload.resultKey, 'workspace') - const fileName = payload.resultKey.split('/').pop() ?? `export.${payload.format}` - logger.info(`[${requestId}] Export download URL issued`, { tableId, jobId }) - return NextResponse.json({ success: true, data: { url, fileName } }) -}) diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.test.ts b/apps/sim/app/api/table/[tableId]/import-async/route.test.ts deleted file mode 100644 index a22bd6b5e7d..00000000000 --- a/apps/sim/app/api/table/[tableId]/import-async/route.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * @vitest-environment node - */ -import { - createTableDefinition, - hybridAuthMockFns, - type TableDefinitionFactoryOptions, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockCheckAccess, mockMarkTableImporting, mockRunTableImport } = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockMarkTableImporting: vi.fn(), - mockRunTableImport: vi.fn(), -})) - -vi.mock('@sim/utils/id', () => ({ - generateId: vi.fn().mockReturnValue('import-id-xyz'), - generateShortId: vi.fn().mockReturnValue('short-id'), -})) -vi.mock('@/lib/table/jobs/service', () => ({ markTableJobRunning: mockMarkTableImporting })) -vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport })) -vi.mock('@/lib/core/utils/background', () => ({ - runDetached: (_label: string, work: () => Promise) => { - void work() - }, -})) -vi.mock('@/app/api/table/utils', async () => { - const { NextResponse } = await import('next/server') - return { - checkAccess: mockCheckAccess, - accessError: (result: { status: number }) => - NextResponse.json({ error: 'denied' }, { status: result.status }), - } -}) - -import { POST } from '@/app/api/table/[tableId]/import-async/route' - -const TABLE_FIXTURE: TableDefinitionFactoryOptions = { - columns: [{ name: 'name', type: 'string' }], -} - -function makeRequest(body: unknown, tableId = 'tbl_1') { - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import-async`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) - return POST(req, { params: Promise.resolve({ tableId }) }) -} - -const validBody = { - workspaceId: 'workspace-1', - fileKey: 'workspace/workspace-1/123-data.csv', - fileName: 'data.csv', - mode: 'append', -} - -describe('POST /api/table/[tableId]/import-async', () => { - beforeEach(() => { - vi.clearAllMocks() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'session', - }) - mockCheckAccess.mockResolvedValue({ ok: true, table: createTableDefinition(TABLE_FIXTURE) }) - mockMarkTableImporting.mockResolvedValue(true) - mockRunTableImport.mockResolvedValue(undefined) - }) - - it('marks the table importing and kicks off the worker with mode + mapping', async () => { - const response = await makeRequest({ - ...validBody, - mode: 'replace', - mapping: { Name: 'name' }, - createColumns: ['Extra'], - }) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data).toEqual({ tableId: 'tbl_1', importId: 'import-id-xyz' }) - expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'import-id-xyz', 'import') - expect(mockRunTableImport).toHaveBeenCalledWith( - expect.objectContaining({ - tableId: 'tbl_1', - mode: 'replace', - delimiter: ',', - mapping: { Name: 'name' }, - createColumns: ['Extra'], - }) - ) - }) - - it('returns 409 when the table is already importing (claim lost)', async () => { - mockMarkTableImporting.mockResolvedValue(false) - const response = await makeRequest(validBody) - expect(response.status).toBe(409) - expect(mockRunTableImport).not.toHaveBeenCalled() - }) - - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) - const response = await makeRequest(validBody) - expect(response.status).toBe(401) - expect(mockMarkTableImporting).not.toHaveBeenCalled() - }) - - it('returns the access error status when access is denied', async () => { - mockCheckAccess.mockResolvedValue({ ok: false, status: 403 }) - const response = await makeRequest(validBody) - expect(response.status).toBe(403) - expect(mockRunTableImport).not.toHaveBeenCalled() - }) - - it('returns 400 when the target table is archived', async () => { - mockCheckAccess.mockResolvedValue({ - ok: true, - table: createTableDefinition({ ...TABLE_FIXTURE, archivedAt: new Date() }), - }) - const response = await makeRequest(validBody) - expect(response.status).toBe(400) - expect(mockRunTableImport).not.toHaveBeenCalled() - }) - - it('returns 400 on workspace mismatch', async () => { - const response = await makeRequest({ ...validBody, workspaceId: 'other-ws' }) - expect(response.status).toBe(400) - }) - - it('returns 400 for an invalid mode', async () => { - const response = await makeRequest({ ...validBody, mode: 'bogus' }) - expect(response.status).toBe(400) - }) -}) diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts deleted file mode 100644 index 6f007f6bc8b..00000000000 --- a/apps/sim/app/api/table/[tableId]/import-async/route.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { importIntoTableAsyncContract } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' -import { runDetached } from '@/lib/core/utils/background' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' -import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' -import { assertRowDelete, assertRowInsert, assertSchemaMutable } from '@/lib/table/mutation-locks' -import { getUserSettings } from '@/lib/users/queries' -import { accessError, checkAccess } from '@/app/api/table/utils' - -const logger = createLogger('TableImportIntoAsync') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -interface RouteParams { - params: Promise<{ tableId: string }> -} - -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const userId = authResult.userId - - const parsed = await parseRequest(importIntoTableAsyncContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId } = parsed.data.params - const { workspaceId, fileKey, fileName, mode, mapping, createColumns, timezone } = - parsed.data.body - - const access = await checkAccess(tableId, { kind: 'user', userId }, 'write') - if (!access.ok) return accessError(access, requestId, tableId) - const { table } = access - - if (table.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - // The fileKey is client-supplied — ensure it points at this workspace's storage prefix so a - // caller can't import another workspace's uploaded object. - if (!fileKey.startsWith(`workspace/${workspaceId}/`)) { - return NextResponse.json({ error: 'Invalid file key for workspace' }, { status: 400 }) - } - if (table.archivedAt) { - return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 }) - } - - // Gate the locks before claiming the single write-job slot, so a locked table - // reports 423 here instead of holding the slot and failing inside the worker. - assertRowInsert(table) - if (mode === 'replace') assertRowDelete(table) - if (createColumns && createColumns.length > 0) assertSchemaMutable(table) - - const ext = fileName.split('.').pop()?.toLowerCase() - if (ext !== 'csv' && ext !== 'tsv') { - return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 }) - } - const delimiter = ext === 'tsv' ? '\t' : ',' - - // Atomically claim the table's job slot — the single concurrency gate. If another job (import - // or delete) already holds it, this returns false (no overlapping workers). - const importId = generateId() - const claimed = await markTableJobRunning(tableId, importId, 'import') - if (!claimed) { - return NextResponse.json( - { error: 'A job is already in progress for this table' }, - { status: 409 } - ) - } - - const importPayload: TableImportPayload = { - importId, - tableId, - workspaceId, - userId, - fileKey, - fileName, - delimiter, - mode, - mapping, - createColumns, - timezone: timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', - } - if (isTriggerDevEnabled) { - // Trigger.dev runs the import outside the web container, so it survives app deploys. - try { - const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ - import('@/background/table-import'), - import('@trigger.dev/sdk'), - import('@/lib/core/async-jobs/region'), - ]) - await tasks.trigger('table-import', importPayload, { - tags: [`tableId:${tableId}`, `jobId:${importId}`], - region: await resolveTriggerRegion(), - }) - } catch (error) { - // A failed dispatch must not leave a ghost `running` job holding the - // table's one-write-job slot until the stale-job janitor fires. - await releaseJobClaim(tableId, importId).catch(() => {}) - throw error - } - } else { - runDetached('table-import', () => runTableImport(importPayload)) - } - - logger.info(`[${requestId}] Async CSV import into existing table started`, { - tableId, - importId, - mode, - fileName, - }) - return NextResponse.json({ success: true, data: { tableId, importId } }) -}) diff --git a/apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts b/apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts deleted file mode 100644 index 0bc244c2182..00000000000 --- a/apps/sim/app/api/table/[tableId]/job/cancel/route.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @vitest-environment node - */ -import { createTableDefinition, hybridAuthMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockCheckAccess, mockMarkJobCanceled, mockGetTableJob, mockAppendTableEvent } = vi.hoisted( - () => ({ - mockCheckAccess: vi.fn(), - mockMarkJobCanceled: vi.fn(), - mockGetTableJob: vi.fn(), - mockAppendTableEvent: vi.fn(), - }) -) - -vi.mock('@/lib/table/jobs/service', () => ({ - markJobCanceled: mockMarkJobCanceled, - getTableJob: mockGetTableJob, -})) -vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) -vi.mock('@/app/api/table/utils', async () => { - const { NextResponse } = await import('next/server') - return { - checkAccess: mockCheckAccess, - accessError: (result: { status: number }) => - NextResponse.json({ error: 'denied' }, { status: result.status }), - } -}) - -import { POST } from '@/app/api/table/[tableId]/job/cancel/route' - -function makeRequest(body: unknown, tableId = 'tbl_1') { - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/job/cancel`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) - return POST(req, { params: Promise.resolve({ tableId }) }) -} - -const validBody = { workspaceId: 'workspace-1', jobId: 'job_1' } - -describe('POST /api/table/[tableId]/job/cancel', () => { - beforeEach(() => { - vi.clearAllMocks() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'session', - }) - mockCheckAccess.mockResolvedValue({ ok: true, table: createTableDefinition() }) - mockMarkJobCanceled.mockResolvedValue(true) - mockGetTableJob.mockResolvedValue({ - id: 'job_1', - type: 'delete', - status: 'running', - payload: null, - }) - }) - - it('cancels the job and emits a typed cancel event', async () => { - const response = await makeRequest(validBody) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data).toEqual({ canceled: true }) - expect(mockMarkJobCanceled).toHaveBeenCalledWith('tbl_1', 'job_1') - expect(mockAppendTableEvent).toHaveBeenCalledWith( - expect.objectContaining({ kind: 'job', type: 'delete', status: 'canceled', jobId: 'job_1' }) - ) - }) - - it('does not emit an event when nothing was running', async () => { - mockMarkJobCanceled.mockResolvedValue(false) - const response = await makeRequest(validBody) - const data = await response.json() - expect(data.data).toEqual({ canceled: false }) - expect(mockAppendTableEvent).not.toHaveBeenCalled() - }) - - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) - const response = await makeRequest(validBody) - expect(response.status).toBe(401) - expect(mockMarkJobCanceled).not.toHaveBeenCalled() - }) - - it('returns 400 on workspace mismatch', async () => { - const response = await makeRequest({ ...validBody, workspaceId: 'other' }) - expect(response.status).toBe(400) - expect(mockMarkJobCanceled).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/table/[tableId]/job/cancel/route.ts b/apps/sim/app/api/table/[tableId]/job/cancel/route.ts deleted file mode 100644 index bee06bba32d..00000000000 --- a/apps/sim/app/api/table/[tableId]/job/cancel/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { cancelTableJobContract } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { appendTableEvent } from '@/lib/table/events' -import { getTableJob, markJobCanceled } from '@/lib/table/jobs/service' -import type { TableJobType } from '@/lib/table/types' -import { accessError, checkAccess } from '@/app/api/table/utils' - -const logger = createLogger('TableJobCancelAPI') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -interface RouteParams { - params: Promise<{ tableId: string }> -} - -/** - * POST /api/table/[tableId]/job/cancel - * - * Cancels an in-flight async table job (import or delete). Flips the table's job status to - * `canceled`, which makes the detached worker's next ownership check fail so it stops. Committed - * work (inserted/deleted rows) is left in place (no rollback). No-op if the job already finished. - */ -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest(cancelTableJobContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId } = parsed.data.params - const { workspaceId, jobId } = parsed.data.body - - const access = await checkAccess(tableId, { kind: 'user', userId: authResult.userId }, 'write') - if (!access.ok) return accessError(access, requestId, tableId) - if (access.table.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - // Resolve the job's actual type (from its own row — the table-level derivation excludes - // exports) so the cancel event carries the right `type`. - const job = await getTableJob(tableId, jobId) - const type = (job?.type ?? 'import') as TableJobType - - const canceled = await markJobCanceled(tableId, jobId) - if (canceled) { - void appendTableEvent({ kind: 'job', type, tableId, jobId, status: 'canceled' }) - } - logger.info(`[${requestId}] Job cancel requested`, { tableId, jobId, type, canceled }) - - return NextResponse.json({ success: true, data: { canceled } }) -}) diff --git a/apps/sim/app/api/table/executor-capability-exemption.test.ts b/apps/sim/app/api/table/executor-capability-exemption.test.ts index fe9f283edaa..9e8d73af8e9 100644 --- a/apps/sim/app/api/table/executor-capability-exemption.test.ts +++ b/apps/sim/app/api/table/executor-capability-exemption.test.ts @@ -28,7 +28,7 @@ const mocks = vi.hoisted(() => ({ findActiveFolder: vi.fn(), getUserSettings: vi.fn(), runDetached: vi.fn(), - runTableImport: vi.fn(), + performCreateTableFromCsv: vi.fn(), })) vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) @@ -45,10 +45,13 @@ vi.mock('@/lib/table', () => ({ getWorkspaceTableLimits: mocks.getWorkspaceTableLimits, listTables: mocks.listTables, releaseJobClaim: vi.fn(), + CSV_SYNC_MAX_FILE_SIZE_BYTES: 5 * 1024 * 1024, sanitizeName: (name: string) => name, TABLE_LIMITS: { MAX_TABLE_NAME_LENGTH: 64 }, })) -vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mocks.runTableImport })) +vi.mock('@/lib/table/orchestration', () => ({ + performCreateTableFromCsv: mocks.performCreateTableFromCsv, +})) vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mocks.findActiveFolder })) vi.mock('@/lib/users/queries', () => ({ getUserSettings: mocks.getUserSettings })) vi.mock('@/lib/core/utils/background', () => ({ runDetached: mocks.runDetached })) @@ -56,7 +59,7 @@ vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' -import { POST as importAsync } from '@/app/api/table/import-async/route' +import { POST as importCsv } from '@/app/api/table/import-csv/route' import { GET as listJobs } from '@/app/api/table/jobs/route' const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' @@ -88,15 +91,13 @@ function getExportJobs() { } function startImport() { - return importAsync( - new NextRequest('http://localhost/api/table/import-async', { + const form = new FormData() + form.append('workspaceId', WORKSPACE_ID) + form.append('file', new Blob(['a,b\n1,2'], { type: 'text/csv' }), 'upload.csv') + return importCsv( + new NextRequest('http://localhost/api/table/import-csv', { method: 'POST', - body: JSON.stringify({ - workspaceId: WORKSPACE_ID, - fileKey: `workspace/${WORKSPACE_ID}/upload.csv`, - fileName: 'upload.csv', - }), - headers: { 'content-type': 'application/json' }, + body: form, }) ) } @@ -112,6 +113,10 @@ describe('the subject the raw table routes gate on', () => { mocks.getWorkspaceTableLimits.mockResolvedValue({ maxTables: 100 }) mocks.getUserSettings.mockResolvedValue({ timezone: 'UTC' }) mocks.createTable.mockResolvedValue({ id: TABLE_ID }) + mocks.performCreateTableFromCsv.mockResolvedValue({ + success: true, + data: { tableId: TABLE_ID }, + }) permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, hideTablesTab: true, @@ -133,7 +138,7 @@ describe('the subject the raw table routes gate on', () => { const response = await startImport() expect(response.status).toBe(200) - expect(mocks.createTable).toHaveBeenCalled() + expect(mocks.performCreateTableFromCsv).toHaveBeenCalled() expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled() }) }) @@ -152,7 +157,7 @@ describe('the subject the raw table routes gate on', () => { const response = await startImport() expect(response.status).toBe(403) - expect(mocks.createTable).not.toHaveBeenCalled() + expect(mocks.performCreateTableFromCsv).not.toHaveBeenCalled() }) }) }) diff --git a/apps/sim/app/api/table/import-async/route.test.ts b/apps/sim/app/api/table/import-async/route.test.ts deleted file mode 100644 index e7b3fabf786..00000000000 --- a/apps/sim/app/api/table/import-async/route.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * @vitest-environment node - */ -import { - hybridAuthMockFns, - permissionGroupScopeMock, - permissionGroupScopeMockFns, - permissionsMock, - permissionsMockFns, - resetPermissionGroupScopeMock, -} from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCreateTable, - mockGetLimits, - mockListTables, - mockRunTableImport, - mockRunDetached, - mockFindActiveFolder, - MockTableConflictError, -} = vi.hoisted(() => ({ - mockCreateTable: vi.fn(), - mockGetLimits: vi.fn(), - mockListTables: vi.fn(), - mockRunTableImport: vi.fn(), - mockRunDetached: vi.fn(), - mockFindActiveFolder: vi.fn(), - MockTableConflictError: class extends Error { - readonly code = 'TABLE_EXISTS' as const - }, -})) - -vi.mock('@sim/utils/id', () => ({ - generateId: vi.fn().mockReturnValue('import-id-123'), - generateShortId: vi.fn().mockReturnValue('short-id'), -})) - -vi.mock('@/lib/table', () => ({ - createTable: mockCreateTable, - getWorkspaceTableLimits: mockGetLimits, - listTables: mockListTables, - sanitizeName: (name: string) => name.replace(/[^a-zA-Z0-9_]/g, '_'), - TABLE_LIMITS: { MAX_TABLE_NAME_LENGTH: 128 }, - TableConflictError: MockTableConflictError, -})) -vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport })) -vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mockFindActiveFolder })) -vi.mock('@/lib/core/utils/background', () => ({ - runDetached: mockRunDetached.mockImplementation( - (_label: string, work: () => Promise) => { - void work() - } - ), -})) -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) - -import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' -import { POST } from '@/app/api/table/import-async/route' - -function makeRequest(body: unknown): NextRequest { - return new NextRequest('http://localhost:3000/api/table/import-async', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) -} - -const validBody = { - workspaceId: 'workspace-1', - fileKey: 'workspace/workspace-1/123-data.csv', - fileName: 'data.csv', -} - -describe('POST /api/table/import-async', () => { - beforeEach(() => { - vi.clearAllMocks() - resetPermissionGroupScopeMock() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'session', - }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockGetLimits.mockResolvedValue({ maxRowsPerTable: 1_000_000, maxTables: 50 }) - mockListTables.mockResolvedValue([]) - mockCreateTable.mockResolvedValue({ id: 'tbl_async', name: 'data' }) - mockRunTableImport.mockResolvedValue(undefined) - mockFindActiveFolder.mockResolvedValue({ id: 'folder-1' }) - }) - - it('imports into the workspace root when no folder is given', async () => { - await POST(makeRequest(validBody)) - - expect(mockFindActiveFolder).not.toHaveBeenCalled() - expect(mockCreateTable).toHaveBeenCalledWith( - expect.objectContaining({ folderId: undefined }), - expect.any(String) - ) - }) - - it('creates the imported table inside the requested folder', async () => { - await POST(makeRequest({ ...validBody, folderId: 'folder-1' })) - - expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'workspace-1', 'table') - expect(mockCreateTable).toHaveBeenCalledWith( - expect.objectContaining({ folderId: 'folder-1' }), - expect.any(String) - ) - }) - - it('rejects a folder from another workspace or resource tree', async () => { - mockFindActiveFolder.mockResolvedValue(null) - - const response = await POST(makeRequest({ ...validBody, folderId: 'kb-folder' })) - - expect(response.status).toBe(404) - expect(mockCreateTable).not.toHaveBeenCalled() - }) - - it('creates an importing table and kicks off the background import', async () => { - const response = await POST(makeRequest(validBody)) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.data).toEqual({ tableId: 'tbl_async', importId: 'import-id-123' }) - expect(mockCreateTable).toHaveBeenCalledWith( - expect.objectContaining({ jobStatus: 'running', jobType: 'import', jobId: 'import-id-123' }), - expect.any(String) - ) - expect(mockRunTableImport).toHaveBeenCalledWith( - expect.objectContaining({ tableId: 'tbl_async', mode: 'create', delimiter: ',' }) - ) - }) - - it('uses a tab delimiter for .tsv files', async () => { - await POST(makeRequest({ ...validBody, fileName: 'data.tsv' })) - expect(mockRunTableImport).toHaveBeenCalledWith(expect.objectContaining({ delimiter: '\t' })) - }) - - it('returns 400 for unsupported extensions', async () => { - const response = await POST(makeRequest({ ...validBody, fileName: 'data.json' })) - expect(response.status).toBe(400) - expect(mockCreateTable).not.toHaveBeenCalled() - }) - - it('returns 401 when unauthenticated', async () => { - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false }) - const response = await POST(makeRequest(validBody)) - expect(response.status).toBe(401) - }) - - it('returns 403 without write permission', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') - const response = await POST(makeRequest(validBody)) - expect(response.status).toBe(403) - expect(mockCreateTable).not.toHaveBeenCalled() - }) - - it('returns 400 when the body is missing required fields', async () => { - const response = await POST(makeRequest({ workspaceId: 'workspace-1' })) - expect(response.status).toBe(400) - }) - - /** - * An import is a table creation, so it is `tables.create` that governs it — - * not `tables.use`. `disableTableCreation` leaves Tables visible and usable, - * which is exactly the configuration a `tables.use` gate would let through. - */ - it('refuses the import when the group disables table creation', async () => { - permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ - ...DEFAULT_PERMISSION_GROUP_CONFIG, - disableTableCreation: true, - }) - - const response = await POST(makeRequest(validBody)) - - expect(response.status).toBe(403) - expect((await response.json()).details).toEqual({ - code: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', - }) - expect(mockCreateTable).not.toHaveBeenCalled() - }) - - it('refuses the import when the group hides Tables entirely', async () => { - permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ - ...DEFAULT_PERMISSION_GROUP_CONFIG, - hideTablesTab: true, - }) - - const response = await POST(makeRequest(validBody)) - - expect(response.status).toBe(403) - expect(mockCreateTable).not.toHaveBeenCalled() - }) - - it('lets the import through when the group withholds something else', async () => { - permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({ - ...DEFAULT_PERMISSION_GROUP_CONFIG, - hideKnowledgeBaseTab: true, - }) - - const response = await POST(makeRequest(validBody)) - - expect(response.status).toBe(200) - expect(mockCreateTable).toHaveBeenCalledTimes(1) - }) -}) diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts deleted file mode 100644 index 17a6b0be553..00000000000 --- a/apps/sim/app/api/table/import-async/route.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { importTableAsyncContract } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { capabilityGovernedAuthUserId, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' -import { runDetached } from '@/lib/core/utils/background' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { findActiveFolder } from '@/lib/folders/queries' -import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' -import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' -import { captureServerEvent } from '@/lib/posthog/server' -import { - createTable, - deleteTable, - getWorkspaceTableLimits, - listTables, - releaseJobClaim, - sanitizeName, - TABLE_LIMITS, -} from '@/lib/table' -import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' -import { getUserSettings } from '@/lib/users/queries' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { orchestrationErrorResponse } from '@/app/api/table/utils' - -const logger = createLogger('TableImportAsync') - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' - -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const userId = authResult.userId - - const parsed = await parseRequest(importTableAsyncContract, request, {}) - if (!parsed.success) return parsed.response - const { workspaceId, fileKey, fileName, folderId, deleteSourceFile, timezone } = parsed.data.body - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - /** - * permission-group-enforced: tables.create — raw route that queries directly - * and predates the operation boundary. An import always ends in a new table, - * so it is creation, not ordinary use. `tables.create` subsumes `tables.use`: - * its rule is denied by `disableTableCreation` OR `hideTablesTab`, so gating - * on it still refuses a group that hides Tables entirely. Keyed to the - * governed subject, which names nobody for an internal-JWT executor call — - * the same rule the synchronous `import-csv` route applies. Not re-read before - * `createTable` below: `resolvePermissionGroupConfig` is memoized per request - * (`withPermissionGroupScope`), so a second call in this handler returns the - * promise this one started and could not observe a revocation. - */ - const governedUserId = capabilityGovernedAuthUserId(authResult) - if ( - governedUserId && - (await isWorkspaceCapabilityWithheld(governedUserId, workspaceId, 'tables.create')) - ) { - return capabilityRefusalResponse('tables.create') - } - // The fileKey is client-supplied — ensure it points at this workspace's storage prefix so a - // caller can't import another workspace's uploaded object. - if (!fileKey.startsWith(`workspace/${workspaceId}/`)) { - return NextResponse.json({ error: 'Invalid file key for workspace' }, { status: 400 }) - } - - // Scoped to `resourceType: 'table'` so a folder from another resource's tree - // can't file the imported table where Tables never lists it. - if (folderId && !(await findActiveFolder(folderId, workspaceId, 'table'))) { - return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 }) - } - - const ext = fileName.split('.').pop()?.toLowerCase() - if (ext !== 'csv' && ext !== 'tsv') { - return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 }) - } - const delimiter = ext === 'tsv' ? '\t' : ',' - - const planLimits = await getWorkspaceTableLimits(workspaceId) - const baseName = sanitizeName(fileName.replace(/\.[^.]+$/, ''), 'imported_table').slice( - 0, - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - ) - // Re-importing the same file shouldn't fail on a name collision — pick the next free - // `name_2`, `name_3`, … (matching how "New table" auto-names), keeping under the cap. - const existingNames = new Set( - (await listTables(workspaceId, { scope: 'all' })).map((t) => t.name.toLowerCase()) - ) - let tableName = baseName - for (let n = 2; existingNames.has(tableName.toLowerCase()); n++) { - const suffix = `_${n}` - tableName = `${baseName.slice(0, TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - suffix.length)}${suffix}` - } - const importId = generateId() - - // Placeholder schema satisfies createTable's validation; the import worker infers the - // real columns from the file and overwrites it before any rows become visible. - let table: Awaited> - try { - table = await createTable( - { - name: tableName, - description: `Imported from ${fileName}`, - schema: { columns: [{ name: 'column_1', type: 'string' }] }, - workspaceId, - folderId, - userId, - maxTables: planLimits.maxTables, - jobStatus: 'running', - jobType: 'import', - jobId: importId, - }, - requestId - ) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } - - const importPayload: TableImportPayload = { - importId, - tableId: table.id, - workspaceId, - userId, - fileKey, - fileName, - delimiter, - mode: 'create', - deleteSourceFile, - timezone: timezone ?? (await getUserSettings(userId)).timezone ?? 'UTC', - } - if (isTriggerDevEnabled) { - // Trigger.dev runs the import outside the web container, so it survives app deploys. - try { - const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ - import('@/background/table-import'), - import('@trigger.dev/sdk'), - import('@/lib/core/async-jobs/region'), - ]) - await tasks.trigger('table-import', importPayload, { - tags: [`tableId:${table.id}`, `jobId:${importId}`], - region: await resolveTriggerRegion(), - }) - } catch (error) { - // A failed dispatch must not leave a ghost `running` job holding the - // table's one-write-job slot — nor, in create mode, the placeholder - // table itself: the user never saw it, so archive it back out of the - // workspace (no hard-delete surface exists; archived is invisible). - await releaseJobClaim(table.id, importId).catch(() => {}) - await deleteTable(table.id, requestId).catch(() => {}) - throw error - } - } else { - runDetached('table-import', () => runTableImport(importPayload)) - } - - captureServerEvent( - userId, - 'table_import_started', - { - table_id: table.id, - workspace_id: workspaceId, - import_id: importId, - file_type: ext, - }, - { groups: { workspace: workspaceId } } - ) - - logger.info(`[${requestId}] Async CSV import started`, { tableId: table.id, importId, fileName }) - return NextResponse.json({ success: true, data: { tableId: table.id, importId } }) -}) diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts deleted file mode 100644 index e116dc1ded1..00000000000 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createLogger } from '@sim/logger' -import { cloudwatchLogGroupsContract } from '@/lib/api/contracts/tools/cloudwatch' -import { parseToolRequest } from '@/lib/api/server' -import { createCloudWatchHttpRoute } from '@/lib/internal/cloudwatch/http-route' -import { executeCloudwatchDescribeLogGroups } from '@/lib/internal/cloudwatch/operations' - -const logger = createLogger('CloudWatchDescribeLogGroups') - -export const POST = createCloudWatchHttpRoute({ - logger, - parse: (request) => - parseToolRequest(cloudwatchLogGroupsContract, request, { - errorFormat: 'firstError', - logger, - }), - execute: executeCloudwatchDescribeLogGroups, - errorMessage: 'Failed to describe CloudWatch log groups', - auth: 'session-or-internal', -}) diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts deleted file mode 100644 index dab604bed6d..00000000000 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createLogger } from '@sim/logger' -import { cloudwatchLogStreamsContract } from '@/lib/api/contracts/tools/cloudwatch' -import { parseToolRequest } from '@/lib/api/server' -import { createCloudWatchHttpRoute } from '@/lib/internal/cloudwatch/http-route' -import { executeCloudwatchDescribeLogStreams } from '@/lib/internal/cloudwatch/operations' - -const logger = createLogger('CloudWatchDescribeLogStreams') - -export const POST = createCloudWatchHttpRoute({ - logger, - parse: (request) => - parseToolRequest(cloudwatchLogStreamsContract, request, { - errorFormat: 'firstError', - logger, - }), - execute: executeCloudwatchDescribeLogStreams, - errorMessage: 'Failed to describe CloudWatch log streams', - auth: 'session-or-internal', -}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts deleted file mode 100644 index 79860777852..00000000000 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { workspaceFileCompiledCheckContract } from '@/lib/api/contracts/workspace-files' -import { - defineInternalJsonRoute, - internalRateLimits, - internalSessionAuth, -} from '@/lib/api/server/routes' -import { internalFileErrorPolicies } from '@/lib/workspace-files/api' -import { compiledCheckWorkspaceFile } from '@/lib/workspace-files/application/compiled-check-workspace-file' - -export const dynamic = 'force-dynamic' -export const runtime = 'nodejs' - -export const GET = defineInternalJsonRoute({ - contract: workspaceFileCompiledCheckContract, - auth: internalSessionAuth, - operation: compiledCheckWorkspaceFile.operation, - rateLimit: internalRateLimits.none({ - reason: 'Preserve existing internal compiled-check behavior', - }), - errorPolicy: internalFileErrorPolicies.compiledCheck, - mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), - useCase: compiledCheckWorkspaceFile, -}) diff --git a/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts b/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts deleted file mode 100644 index 548a6939c43..00000000000 --- a/apps/sim/app/api/workspaces/[id]/metrics/executions/route.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { dbReplica } from '@sim/db' -import { pausedExecutions, workflow, workflowExecutionLogs } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { workspaceMetricsExecutionsQuerySchema } from '@/lib/api/contracts/workspaces' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('MetricsExecutionsAPI') - -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - try { - const { id: workspaceId } = await params - const { searchParams } = new URL(request.url) - const qp = workspaceMetricsExecutionsQuerySchema.parse( - Object.fromEntries(searchParams.entries()) - ) - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const userId = session.user.id - - let end = qp.endTime ? new Date(qp.endTime) : new Date() - let start = qp.startTime - ? new Date(qp.startTime) - : new Date(end.getTime() - 24 * 60 * 60 * 1000) - - const isAllTime = qp.allTime === true - - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { - return NextResponse.json({ error: 'Invalid time range' }, { status: 400 }) - } - - const segments = qp.segments - - const access = await checkWorkspaceAccess(workspaceId, userId) - if (!access.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - const wfWhere = [eq(workflow.workspaceId, workspaceId)] as any[] - if (qp.folderIds) { - const folderList = qp.folderIds.split(',').filter(Boolean) - wfWhere.push(inArray(workflow.folderId, folderList)) - } - if (qp.workflowIds) { - const wfList = qp.workflowIds.split(',').filter(Boolean) - wfWhere.push(inArray(workflow.id, wfList)) - } - - const workflows = await dbReplica - .select({ id: workflow.id, name: workflow.name }) - .from(workflow) - .where(and(...wfWhere)) - - if (workflows.length === 0) { - return NextResponse.json({ - workflows: [], - startTime: start.toISOString(), - endTime: end.toISOString(), - segmentMs: 0, - }) - } - - const workflowIdList = workflows.map((w) => w.id) - - const baseLogWhere = [inArray(workflowExecutionLogs.workflowId, workflowIdList)] as SQL[] - if (qp.triggers) { - const t = qp.triggers.split(',').filter(Boolean) - baseLogWhere.push(inArray(workflowExecutionLogs.trigger, t)) - } - - if (qp.level && qp.level !== 'all') { - const levels = qp.level.split(',').filter(Boolean) - const levelConditions: SQL[] = [] - - for (const level of levels) { - if (level === 'error') { - levelConditions.push(eq(workflowExecutionLogs.level, 'error')) - } else if (level === 'info') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - isNotNull(workflowExecutionLogs.endedAt) - ) - if (condition) levelConditions.push(condition) - } else if (level === 'running') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - isNull(workflowExecutionLogs.endedAt) - ) - if (condition) levelConditions.push(condition) - } else if (level === 'pending') { - const condition = and( - eq(workflowExecutionLogs.level, 'info'), - or( - sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`, - and( - isNotNull(pausedExecutions.status), - sql`${pausedExecutions.status} != 'fully_resumed'` - ) - ) - ) - if (condition) levelConditions.push(condition) - } - } - - if (levelConditions.length > 0) { - const combinedCondition = - levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions) - if (combinedCondition) baseLogWhere.push(combinedCondition) - } - } - - if (isAllTime) { - const boundsQuery = dbReplica - .select({ - minDate: sql`MIN(${workflowExecutionLogs.startedAt})`, - maxDate: sql`MAX(${workflowExecutionLogs.startedAt})`, - }) - .from(workflowExecutionLogs) - .leftJoin( - pausedExecutions, - eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) - ) - .where(and(...baseLogWhere)) - - const [bounds] = await boundsQuery - - if (bounds?.minDate && bounds?.maxDate) { - start = new Date(bounds.minDate) - end = new Date(Math.max(new Date(bounds.maxDate).getTime(), Date.now())) - } else { - return NextResponse.json({ - workflows: workflows.map((wf) => ({ - workflowId: wf.id, - workflowName: wf.name, - segments: [], - })), - startTime: new Date().toISOString(), - endTime: new Date().toISOString(), - segmentMs: 0, - }) - } - } - - if (start >= end) { - return NextResponse.json({ error: 'Invalid time range' }, { status: 400 }) - } - - const totalMs = Math.max(1, end.getTime() - start.getTime()) - const segmentMs = Math.max(1, Math.floor(totalMs / Math.max(1, segments))) - - const logWhere = [ - ...baseLogWhere, - gte(workflowExecutionLogs.startedAt, start), - lte(workflowExecutionLogs.startedAt, end), - ] - - const logs = await dbReplica - .select({ - workflowId: workflowExecutionLogs.workflowId, - level: workflowExecutionLogs.level, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - pausedTotalPauseCount: pausedExecutions.totalPauseCount, - pausedResumedCount: pausedExecutions.resumedCount, - pausedStatus: pausedExecutions.status, - }) - .from(workflowExecutionLogs) - .leftJoin( - pausedExecutions, - eq(pausedExecutions.executionId, workflowExecutionLogs.executionId) - ) - .where(and(...logWhere)) - - type Bucket = { - timestamp: string - totalExecutions: number - successfulExecutions: number - durations: number[] - } - - const wfIdToBuckets = new Map() - for (const wf of workflows) { - const buckets: Bucket[] = Array.from({ length: segments }, (_, i) => ({ - timestamp: new Date(start.getTime() + i * segmentMs).toISOString(), - totalExecutions: 0, - successfulExecutions: 0, - durations: [], - })) - wfIdToBuckets.set(wf.id, buckets) - } - - for (const log of logs) { - if (!log.workflowId) continue // Skip logs for deleted workflows - const idx = Math.min( - segments - 1, - Math.max(0, Math.floor((log.startedAt.getTime() - start.getTime()) / segmentMs)) - ) - const buckets = wfIdToBuckets.get(log.workflowId) - if (!buckets) continue - const b = buckets[idx] - b.totalExecutions += 1 - if ((log.level || '').toLowerCase() !== 'error') b.successfulExecutions += 1 - if (typeof log.totalDurationMs === 'number') b.durations.push(log.totalDurationMs) - } - - function percentile(arr: number[], p: number): number { - if (arr.length === 0) return 0 - const sorted = [...arr].sort((a, b) => a - b) - const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * (sorted.length - 1))) - return sorted[idx] - } - - const result = workflows.map((wf) => { - const buckets = wfIdToBuckets.get(wf.id) as Bucket[] - const segmentsOut = buckets.map((b) => { - const avg = - b.durations.length > 0 - ? Math.round(b.durations.reduce((s, d) => s + d, 0) / b.durations.length) - : 0 - const p50 = percentile(b.durations, 50) - const p90 = percentile(b.durations, 90) - const p99 = percentile(b.durations, 99) - return { - timestamp: b.timestamp, - totalExecutions: b.totalExecutions, - successfulExecutions: b.successfulExecutions, - avgDurationMs: avg, - p50Ms: p50, - p90Ms: p90, - p99Ms: p99, - } - }) - return { workflowId: wf.id, workflowName: wf.name, segments: segmentsOut } - }) - - return NextResponse.json({ - workflows: result, - startTime: start.toISOString(), - endTime: end.toISOString(), - segmentMs, - }) - } catch (error) { - logger.error('MetricsExecutionsAPI error', error) - return NextResponse.json({ error: 'Failed to compute metrics' }, { status: 500 }) - } - } -) diff --git a/apps/sim/lib/api/contracts/common.ts b/apps/sim/lib/api/contracts/common.ts index 44cd3bc354e..ee459d875d4 100644 --- a/apps/sim/lib/api/contracts/common.ts +++ b/apps/sim/lib/api/contracts/common.ts @@ -15,10 +15,6 @@ export const helpFormBodySchema = z.object({ }) export type HelpFormBody = z.input -export const emailPreviewQuerySchema = z.object({ - template: z.string().optional(), -}) - export const integrationRequestBodySchema = z.object({ integrationName: z .string() diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 7a46337b03a..307abaeed5f 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -1,7 +1,6 @@ import { z } from 'zod' import { requiredFieldSchema } from '@/lib/api/contracts/primitives' import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types' -import { cleanedWorkflowStateSchema } from '@/lib/api/contracts/workflows' import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncConfirmationStatus, @@ -44,8 +43,6 @@ export const submitCopilotFeedbackBodySchema = z.object({ export type SubmitCopilotFeedbackBody = z.input -export const copilotCredentialsQuerySchema = z.object({}) - export const copilotConfirmBodySchema = z.object({ toolCallId: z.string().min(1, 'Tool call ID is required'), executionId: z.string().min(1, 'Execution ID is required').max(255).optional(), @@ -91,12 +88,6 @@ export const createWorkflowCopilotChatBodySchema = z.object({ }) export type CreateWorkflowCopilotChatBody = z.input -export const renameCopilotChatBodySchema = z.object({ - chatId: z.string().min(1), - title: z.string().min(1).max(200), -}) -export type RenameCopilotChatBody = z.input - const copilotResourceTypeSchema = z.enum(PERSISTED_RESOURCE_TYPES) export const addCopilotChatResourceBodySchema = z.object({ @@ -129,25 +120,12 @@ export const reorderCopilotChatResourcesBodySchema = z.object({ }) export type ReorderCopilotChatResourcesBody = z.input -export const revertCopilotCheckpointBodySchema = z.object({ - checkpointId: z.string().min(1), -}) -export type RevertCopilotCheckpointBody = z.input - export const copilotChatAbortBodySchema = z.object({ streamId: z.string().optional(), chatId: z.string().optional(), }) export type CopilotChatAbortBody = z.input -export const copilotChatSteerBodySchema = z.object({ - streamId: z.string().min(1, 'streamId is required'), - chatId: z.string().min(1, 'chatId is required'), - steeringId: z.string().min(1, 'steeringId is required'), - content: z.string().min(1, 'content is required').max(32_768, 'content is too long'), -}) -export type CopilotChatSteerBody = z.input - export const copilotToolExecuteInternalBodySchema = z.object({ toolCallId: z.string().min(1, 'toolCallId is required'), toolName: z.string().min(1, 'toolName is required'), @@ -170,21 +148,6 @@ export const copilotChatGetQuerySchema = z }) .passthrough() -export const copilotModelsQuerySchema = z.object({}) - -export const createCopilotCheckpointBodySchema = z.object({ - workflowId: z.string(), - chatId: z.string(), - messageId: z.string().optional(), - workflowState: z.string(), -}) -export type CreateCopilotCheckpointBody = z.input - -export const listCopilotCheckpointsQuerySchema = z.object({ - chatId: z.string({ error: 'chatId is required' }).min(1, 'chatId is required'), -}) -export type ListCopilotCheckpointsQuery = z.input - export const copilotChatStreamQuerySchema = z.object({ streamId: z.string().optional().default(''), after: z.string().optional().default(''), @@ -249,44 +212,6 @@ export const deleteCopilotChatBodySchema = z.object({ }) export type DeleteCopilotChatBody = z.input -const copilotPersistedMessageSchema = z - .object({ - id: z.string(), - role: z.enum(['user', 'assistant', 'system']), - content: z.string(), - timestamp: z.string(), - toolCalls: z.array(z.any()).optional(), - contentBlocks: z.array(z.any()).optional(), - fileAttachments: z - .array( - z.object({ - id: z.string(), - key: z.string(), - filename: z.string(), - media_type: z.string(), - size: z.number(), - }) - ) - .optional(), - contexts: z.array(z.any()).optional(), - citations: z.array(z.any()).optional(), - errorType: z.string().optional(), - }) - .passthrough() - -export const updateCopilotMessagesBodySchema = z.object({ - chatId: z.string(), - messages: z.array(copilotPersistedMessageSchema), - config: z - .object({ - mode: z.string().optional(), - model: z.string().optional(), - }) - .nullable() - .optional(), -}) -export type UpdateCopilotMessagesBody = z.input - export const validateCopilotApiKeyHeadersSchema = z.object({ [COPILOT_BILLING_PROTOCOL_HEADER]: z.enum(COPILOT_BILLING_PROTOCOL_VALUES).optional(), [BILLING_REQUEST_ID_HEADER]: z.string().uuid().optional(), @@ -409,28 +334,12 @@ export type SubmitCopilotFeedbackResult = ContractJsonResponse - export type SegmentStats = z.output export type WorkflowStats = z.output export type DashboardStatsResponse = z.output diff --git a/apps/sim/lib/api/contracts/mothership-chats.ts b/apps/sim/lib/api/contracts/mothership-chats.ts index 6350046512d..15052b1323e 100644 --- a/apps/sim/lib/api/contracts/mothership-chats.ts +++ b/apps/sim/lib/api/contracts/mothership-chats.ts @@ -254,25 +254,6 @@ export const removeMothershipChatResourceContract = defineRouteContract({ }, }) -export const stageLocalFileUploadContract = defineRouteContract({ - method: 'POST', - path: '/api/mothership/local-files/stage', - body: z.object({ - workspaceId: z.string().min(1), - chatId: z.string().min(1), - key: z.string().min(1).max(2048), - }), - response: { - mode: 'json', - schema: z.object({ - success: z.literal(true), - displayName: z.string(), - fileName: z.string(), - uploadPath: z.string(), - }), - }, -}) - export const mothershipChatSchema = z.object({ id: z.string(), title: z.string().nullable(), diff --git a/apps/sim/lib/api/contracts/storage-transfer.ts b/apps/sim/lib/api/contracts/storage-transfer.ts index 9a18a22a9c5..099ced3fdbd 100644 --- a/apps/sim/lib/api/contracts/storage-transfer.ts +++ b/apps/sim/lib/api/contracts/storage-transfer.ts @@ -159,20 +159,6 @@ export const storageContextSchema = z.enum([ 'workspace-logos', ]) -export const fileDownloadBodySchema = z - .object({ - key: z.string().optional(), - name: z.string().optional(), - url: z - .string() - .url() - .refine((value) => ['http:', 'https:'].includes(new URL(value).protocol), { - message: 'URL must use http or https', - }) - .optional(), - }) - .passthrough() - export const fileParseBodySchema = z .object({ filePath: z @@ -311,13 +297,6 @@ export const sshWriteFileContentContract = defineRouteContract({ response: { mode: 'json', schema: jsonResponseSchema }, }) -export const fileDownloadContract = defineRouteContract({ - method: 'POST', - path: '/api/files/download', - body: fileDownloadBodySchema, - response: { mode: 'json', schema: jsonResponseSchema }, -}) - export const fileParseContract = defineRouteContract({ method: 'POST', path: '/api/files/parse', @@ -381,8 +360,6 @@ export type SshMoveRenameBody = ContractBodyInput export type SshReadFileContentBody = ContractBodyInput export type SshUploadFileBody = ContractBodyInput export type SshWriteFileContentBody = ContractBodyInput -export type FileDownloadBody = ContractBodyInput -export type FileDownloadResponse = ContractJsonResponse export type FileParseBody = ContractBodyInput export type FileParseResponse = ContractJsonResponse export type FileDeleteBody = ContractBodyInput diff --git a/apps/sim/lib/api/contracts/subscription.ts b/apps/sim/lib/api/contracts/subscription.ts index 852baa85cd9..735cbba78c1 100644 --- a/apps/sim/lib/api/contracts/subscription.ts +++ b/apps/sim/lib/api/contracts/subscription.ts @@ -236,11 +236,6 @@ export const organizationUsageLimitApiResponseSchema = z }) .passthrough() -export const purchaseCreditsBodySchema = z.object({ - amount: z.number().min(10).max(1000), - requestId: z.string().uuid(), -}) - export const billingPortalBodySchema = z.object({ context: z.enum(['user', 'organization']).optional().default('user'), organizationId: z.string().min(1).optional(), @@ -347,16 +342,6 @@ export const updateUsageLimitContract = defineRouteContract({ }, }) -export const purchaseCreditsContract = defineRouteContract({ - method: 'POST', - path: '/api/billing/credits', - body: purchaseCreditsBodySchema, - response: { - mode: 'json', - schema: successResponseSchema, - }, -}) - export const createBillingPortalContract = defineRouteContract({ method: 'POST', path: '/api/billing/portal', diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 2b8d863ec00..9d3a95e206e 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -943,21 +943,6 @@ export const importTableAsyncBodySchema = z.object({ export type ImportTableAsyncBody = z.input -export const importTableAsyncContract = defineRouteContract({ - method: 'POST', - path: '/api/table/import-async', - body: importTableAsyncBodySchema, - response: { - mode: 'json', - schema: successResponseSchema( - z.object({ - tableId: z.string(), - importId: z.string(), - }) - ), - }, -}) - export const getTableContract = defineRouteContract({ method: 'GET', path: '/api/table/[tableId]', @@ -1308,22 +1293,6 @@ export const importIntoTableAsyncBodySchema = z.object({ export type ImportIntoTableAsyncBody = z.input -export const importIntoTableAsyncContract = defineRouteContract({ - method: 'POST', - path: '/api/table/[tableId]/import-async', - params: tableIdParamsSchema, - body: importIntoTableAsyncBodySchema, - response: { - mode: 'json', - schema: successResponseSchema( - z.object({ - tableId: z.string(), - importId: z.string(), - }) - ), - }, -}) - /** * `createColumns` form field — a JSON-encoded array of CSV header names that * the import should auto-create as new columns on the target table. @@ -1367,22 +1336,6 @@ export const exportTableAsyncBodySchema = z.object({ export type ExportTableAsyncBody = z.input -/** - * Kickoff for a background export (large tables — small ones use the synchronous streaming - * `/export` route). The worker generates the file, uploads it to workspace storage, and the - * client fetches a presigned URL from the download contract once the job is `ready`. - */ -export const exportTableAsyncContract = defineRouteContract({ - method: 'POST', - path: '/api/table/[tableId]/export-async', - params: tableIdParamsSchema, - body: exportTableAsyncBodySchema, - response: { - mode: 'json', - schema: successResponseSchema(z.object({ tableId: z.string(), jobId: z.string() })), - }, -}) - export const tableJobSummarySchema = z.object({ jobId: z.string(), tableId: z.string(), @@ -1421,18 +1374,6 @@ export const exportDownloadQuerySchema = z.object({ jobId: requiredFieldSchema('Job ID is required'), }) -/** Resolves a completed export job to a short-lived presigned download URL. */ -export const exportDownloadContract = defineRouteContract({ - method: 'GET', - path: '/api/table/[tableId]/export/download', - params: tableIdParamsSchema, - query: exportDownloadQuerySchema, - response: { - mode: 'json', - schema: successResponseSchema(z.object({ url: z.string().min(1), fileName: z.string() })), - }, -}) - /** * `mapping` form field — a JSON-encoded `CsvHeaderMapping` (CSV header → * column name, or `null` to skip the header). @@ -1911,22 +1852,6 @@ export const cancelTableJobBodySchema = z.object({ jobId: requiredFieldSchema('Job ID is required'), }) -/** - * Cancel an in-flight async table job (import or delete). The worker stops at its next ownership - * check; committed work (inserted/deleted rows) is left in place. - */ -export const cancelTableJobContract = defineRouteContract({ - method: 'POST', - path: '/api/table/[tableId]/job/cancel', - params: tableIdParamsSchema, - body: cancelTableJobBodySchema, - response: { - mode: 'json', - schema: successResponseSchema(z.object({ canceled: z.boolean() })), - }, -}) -export type CancelTableJobBody = z.input - /** * Run modes for `POST /api/table/[tableId]/columns/run`: * - `all` — every dep-satisfied row not already running/pending diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index bf275a55fb7..b32e97d493c 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -307,13 +307,3 @@ const compiledCheckResponseSchema = z.union([ z.object({ ok: z.literal(true) }), z.object({ ok: z.literal(false), error: z.string(), errorName: z.string() }), ]) - -export const workspaceFileCompiledCheckContract = defineRouteContract({ - method: 'GET', - path: '/api/workspaces/[id]/files/[fileId]/compiled-check', - params: workspaceFileParamsSchema, - response: { - mode: 'json', - schema: compiledCheckResponseSchema, - }, -}) diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 958ff25f862..3b2c99e3a52 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -174,20 +174,6 @@ export const workspaceMemberSchema = z.object({ export type WorkspaceMember = z.output -export const workspaceMetricsExecutionsQuerySchema = z.object({ - startTime: z.string().optional(), - endTime: z.string().optional(), - segments: z.coerce.number().min(1).max(200).default(72), - workflowIds: z.string().optional(), - folderIds: z.string().optional(), - triggers: z.string().optional(), - level: z.string().optional(), - allTime: z - .enum(['true', 'false']) - .optional() - .transform((value) => value === 'true'), -}) - export const listWorkspacesContract = defineRouteContract({ method: 'GET', path: '/api/workspaces', diff --git a/apps/sim/lib/billing/credits/purchase.ts b/apps/sim/lib/billing/credits/purchase.ts index e8dc21cd6f1..90c4a84f0ae 100644 --- a/apps/sim/lib/billing/credits/purchase.ts +++ b/apps/sim/lib/billing/credits/purchase.ts @@ -1,16 +1,8 @@ import { db } from '@sim/db' import { organization, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { getPlanPricing } from '@/lib/billing/core/billing' -import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' -import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { canPurchaseCredits } from '@/lib/billing/credits/balance' -import { isEnterprise } from '@/lib/billing/plan-helpers' -import { requireStripeClient } from '@/lib/billing/stripe-client' -import { getCustomerId, resolveDefaultPaymentMethod } from '@/lib/billing/stripe-payment-method' -import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' import { toDecimal, toNumber } from '@/lib/billing/utils/decimal' const logger = createLogger('CreditPurchase') @@ -90,144 +82,3 @@ export async function setUsageLimitForCredits( logger.error('Failed to set usage limit for credits', { entityType, entityId, error }) } } - -export interface PurchaseCreditsParams { - userId: string - amountDollars: number - requestId: string -} - -export interface PurchaseResult { - success: boolean - error?: string -} - -export async function purchaseCredits(params: PurchaseCreditsParams): Promise { - const { userId, amountDollars, requestId } = params - - if (amountDollars < 10 || amountDollars > 1000) { - return { success: false, error: 'Amount must be between $10 and $1000' } - } - - const canPurchase = await canPurchaseCredits(userId) - if (!canPurchase) { - return { success: false, error: 'Only Pro and Team users can purchase credits' } - } - - const subscription = await getHighestPrioritySubscription(userId) - if (!subscription || !subscription.stripeSubscriptionId) { - return { success: false, error: 'No active subscription found' } - } - - // Enterprise users must contact support - if (isEnterprise(subscription.plan)) { - return { success: false, error: 'Enterprise users must contact support to purchase credits' } - } - - let entityType: 'user' | 'organization' = 'user' - let entityId = userId - - // Org-scoped subs route credit purchases to the organization and must be authorized - // by an org owner/admin. We've already rejected enterprise above. - if (isOrgScopedSubscription(subscription, userId)) { - const isAdmin = await isOrganizationOwnerOrAdmin(userId, subscription.referenceId) - if (!isAdmin) { - return { success: false, error: 'Only organization owners and admins can purchase credits' } - } - entityType = 'organization' - entityId = subscription.referenceId - } - - try { - const stripe = requireStripeClient() - - const stripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId) - const customerId = getCustomerId(stripeSub.customer) - if (!customerId) { - return { success: false, error: 'Subscription missing customer' } - } - - const { paymentMethodId: defaultPaymentMethod } = await resolveDefaultPaymentMethod( - stripe, - subscription.stripeSubscriptionId, - customerId - ) - - if (!defaultPaymentMethod) { - return { - success: false, - error: 'No payment method on file. Please update your billing info.', - } - } - - const amountCents = Math.round(amountDollars * 100) - const idempotencyKey = `credit-purchase:${requestId}` - - const creditMetadata = { - type: 'credit_purchase', - entityType, - entityId, - amountDollars: amountDollars.toString(), - purchasedBy: userId, - } - - // Create invoice - const invoice = await stripe.invoices.create( - { - customer: customerId, - collection_method: 'charge_automatically', - auto_advance: false, - description: `Credit purchase - $${amountDollars}`, - metadata: creditMetadata, - default_payment_method: defaultPaymentMethod, - }, - { idempotencyKey: `${idempotencyKey}-invoice` } - ) - - // Add line item - await stripe.invoiceItems.create( - { - customer: customerId, - invoice: invoice.id, - amount: amountCents, - currency: 'usd', - description: `Prepaid credits ($${amountDollars})`, - metadata: creditMetadata, - }, - { idempotencyKey: `${idempotencyKey}-item` } - ) - - // Finalize and pay - if (!invoice.id) { - return { success: false, error: 'Failed to create invoice' } - } - - const finalized = await stripe.invoices.finalizeInvoice( - invoice.id, - {}, - { idempotencyKey: `${idempotencyKey}-finalize` } - ) - - if (finalized.status === 'open' && finalized.id) { - await stripe.invoices.pay( - finalized.id, - { payment_method: defaultPaymentMethod }, - { idempotencyKey: `${idempotencyKey}-pay` } - ) - } - - logger.info('Credit purchase invoice created and paid', { - invoiceId: invoice.id, - entityType, - entityId, - amountDollars, - purchasedBy: userId, - }) - - return { success: true } - } catch (error) { - logger.error('Failed to purchase credits', { error, userId, amountDollars }) - const message = getErrorMessage(error, 'Failed to process payment') - return { success: false, error: message } - } -} diff --git a/apps/sim/lib/copilot/chat/messages-store.test.ts b/apps/sim/lib/copilot/chat/messages-store.test.ts index a620ce64757..48c082e4c05 100644 --- a/apps/sim/lib/copilot/chat/messages-store.test.ts +++ b/apps/sim/lib/copilot/chat/messages-store.test.ts @@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { appendCopilotChatMessages, persistCopilotChatTurn, - replaceCopilotChatMessages, } from '@/lib/copilot/chat/messages-store' import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' @@ -164,75 +163,6 @@ describe('messages-store', () => { }) }) - describe('replaceCopilotChatMessages', () => { - it('deletes all chat rows when given an empty snapshot', async () => { - await replaceCopilotChatMessages('chat-1', []) - - expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) - expect(dbChainMockFns.delete).toHaveBeenCalledTimes(1) - expect(dbChainMockFns.insert).not.toHaveBeenCalled() - }) - - it('deletes only rows whose message_id is not in the new snapshot, then upserts', async () => { - await replaceCopilotChatMessages('chat-1', [userMsg, assistantMsg]) - - expect(dbChainMockFns.delete).toHaveBeenCalledTimes(1) - expect(dbChainMockFns.insert).toHaveBeenCalledTimes(1) - - const rows = lastValuesRows() - expect(rows).toHaveLength(2) - expect(rows.map((r) => r.messageId)).toEqual(['msg-user-1', 'msg-asst-1']) - - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledTimes(1) - const conflictArg = dbChainMockFns.onConflictDoUpdate.mock.calls[0][0] - expect(conflictArg.set).toHaveProperty('streamId') - expect(conflictArg.set).toHaveProperty('model') - }) - - it('assigns seq as the snapshot array index (0-based)', async () => { - await replaceCopilotChatMessages('chat-1', [userMsg, assistantMsg]) - const rows = lastValuesRows() - expect(rows[0].seq).toBe(0) - expect(rows[1].seq).toBe(1) - }) - - it('OVERWRITES seq on conflict so positions re-densify after a delete', async () => { - await replaceCopilotChatMessages('chat-1', [userMsg]) - const conflictArg = dbChainMockFns.onConflictDoUpdate.mock.calls[0][0] - expect(conflictArg.set.seq.strings.join('')).toBe('excluded.seq') - }) - - it('collapses duplicate message ids to a single row', async () => { - await replaceCopilotChatMessages('chat-1', [userMsg, { ...userMsg, content: 'dupe' }]) - const rows = lastValuesRows() - expect(rows).toHaveLength(1) - expect(rows[0].seq).toBe(0) - }) - - it('passes chatModel to every row in the snapshot', async () => { - await replaceCopilotChatMessages('chat-1', [userMsg], { - chatModel: 'gpt-4o-mini', - }) - - const rows = lastValuesRows() - expect(rows[0].model).toBe('gpt-4o-mini') - }) - - it('propagates DB errors — the snapshot is authoritative', async () => { - dbChainMockFns.transaction.mockRejectedValueOnce(new Error('tx aborted')) - - await expect(replaceCopilotChatMessages('chat-1', [userMsg])).rejects.toThrow('tx aborted') - }) - - it('strips tool-result output before persisting, keeping success/error', async () => { - await replaceCopilotChatMessages('chat-1', [toolMsg]) - - const toolCall = lastRowContent(0).contentBlocks?.[0].toolCall - expect(toolCall?.result).toEqual({ success: false, error: 'too big' }) - expect(JSON.stringify(lastValuesRows())).not.toContain('huge') - }) - }) - describe('persistCopilotChatTurn', () => { it('claims the chat row by id AND liveness, so a soft-deleted chat matches nothing', async () => { dbChainMockFns.returning.mockResolvedValueOnce([{ model: 'claude-sonnet-4-5' }]) diff --git a/apps/sim/lib/copilot/chat/messages-store.ts b/apps/sim/lib/copilot/chat/messages-store.ts index fdf09b7aa3e..eae81ba4c51 100644 --- a/apps/sim/lib/copilot/chat/messages-store.ts +++ b/apps/sim/lib/copilot/chat/messages-store.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { copilotChats, copilotMessages } from '@sim/db/schema' -import { and, eq, isNull, notInArray, sql } from 'drizzle-orm' +import { and, eq, isNull, sql } from 'drizzle-orm' import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' import type { DbOrTx } from '@/lib/db/types' @@ -111,46 +111,3 @@ export async function persistCopilotChatTurn( await appendCopilotChatMessages(chatId, messages, { chatModel: updated.model ?? null }, tx) }) } - -/** - * Replace all messages for a chat from a full snapshot (used by update-messages). - * Throws on failure. Pass `executor` to enlist the delete+insert in an existing - * transaction; otherwise it runs in its own. - */ -export async function replaceCopilotChatMessages( - chatId: string, - messages: PersistedMessage[], - options?: { chatModel?: string | null }, - executor?: DbOrTx -): Promise { - const deduped = dedupeById(messages) - const newMessageIds = deduped.map((m) => m.id) - const run = async (tx: DbOrTx) => { - await tx - .delete(copilotMessages) - .where( - newMessageIds.length > 0 - ? and( - eq(copilotMessages.chatId, chatId), - notInArray(copilotMessages.messageId, newMessageIds) - ) - : eq(copilotMessages.chatId, chatId) - ) - if (deduped.length === 0) return - await tx - .insert(copilotMessages) - .values(deduped.map((m, i) => toRow(chatId, m, i, options))) - .onConflictDoUpdate({ - target: [copilotMessages.chatId, copilotMessages.messageId], - set: { - content: sql`excluded.content`, - role: sql`excluded.role`, - model: sql`COALESCE(excluded.model, ${copilotMessages.model})`, - streamId: sql`COALESCE(excluded.stream_id, ${copilotMessages.streamId})`, - seq: sql`excluded.seq`, - updatedAt: sql`now()`, - }, - }) - } - await (executor ? run(executor) : db.transaction(run)) -} diff --git a/apps/sim/lib/copilot/request/session/steer.ts b/apps/sim/lib/copilot/request/session/steer.ts deleted file mode 100644 index a24a603bbcf..00000000000 --- a/apps/sim/lib/copilot/request/session/steer.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Context } from '@opentelemetry/api' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { fetchGo } from '@/lib/copilot/request/go/fetch' -import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' -import { env } from '@/lib/core/config/env' - -export const DEFAULT_STEER_TIMEOUT_MS = 3000 - -/** - * Queues a mid-turn steering message with the Go side (`/api/streams/steer`). - * - * Acceptance means "queued", not "applied": Go acknowledges application with a - * `run`/`steering_applied` stream event carrying the steeringId. A caller that - * never sees that ack before the stream ends must re-send the content as an - * ordinary message — that contract is what makes delivery loss-free without - * this call having to prove stream liveness. - */ -export async function requestStreamSteering(params: { - streamId: string - userId: string - chatId: string - steeringId: string - content: string - timeoutMs?: number - otelContext?: Context -}): Promise<{ queued: boolean; status: number }> { - const { - streamId, - userId, - chatId, - steeringId, - content, - timeoutMs = DEFAULT_STEER_TIMEOUT_MS, - otelContext, - } = params - - const headers: Record = { - 'Content-Type': 'application/json', - } - if (env.COPILOT_API_KEY) { - headers['x-api-key'] = env.COPILOT_API_KEY - } - Object.assign(headers, getMothershipSourceEnvHeaders()) - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort('steer_fetch_timeout'), timeoutMs) - - try { - const mothershipBaseURL = await getMothershipBaseURL({ userId }) - const response = await fetchGo(`${mothershipBaseURL}/api/streams/steer`, { - method: 'POST', - headers, - signal: controller.signal, - body: JSON.stringify({ - messageId: streamId, - userId, - chatId, - steeringId, - content, - }), - otelContext, - spanName: 'sim → go /api/streams/steer', - operation: 'steer', - attributes: { - [TraceAttr.StreamId]: streamId, - [TraceAttr.ChatId]: chatId, - }, - }) - return { queued: response.ok, status: response.status } - } finally { - clearTimeout(timeout) - } -} diff --git a/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts b/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts index 383aac48911..926f33dff92 100644 --- a/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts +++ b/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts @@ -6,23 +6,13 @@ import { StorageLimitExceededError } from '@/lib/billing/storage' import { OrchestrationError } from '@/lib/core/orchestration/types' import { ArchiveError } from '@/lib/uploads/archive' import { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-policies' -import { - CompiledCheckTooLargeError, - CompiledCheckUnsupportedError, -} from '@/lib/workspace-files/application/compiled-check-workspace-file' import { StyleExtractionUnsupportedError } from '@/lib/workspace-files/application/style-workspace-file' describe('internal file error policies', () => { - it('projects style and compiled-check failures without constructing responses', () => { + it('projects style failures without constructing responses', () => { expect( internalFileErrorPolicies.style.project(new StyleExtractionUnsupportedError('Unsupported')) ).toEqual({ status: 422, body: { error: 'Unsupported' }, headers: undefined }) - expect( - internalFileErrorPolicies.compiledCheck.project(new CompiledCheckUnsupportedError()) - ).toMatchObject({ status: 422 }) - expect( - internalFileErrorPolicies.compiledCheck.project(new CompiledCheckTooLargeError()) - ).toMatchObject({ status: 413 }) }) it('conceals forbidden inline resources with the legacy not-found envelope', () => { diff --git a/apps/sim/lib/workspace-files/api/internal-error-policies.ts b/apps/sim/lib/workspace-files/api/internal-error-policies.ts index 2e899c8e6b2..49a65b0b9b9 100644 --- a/apps/sim/lib/workspace-files/api/internal-error-policies.ts +++ b/apps/sim/lib/workspace-files/api/internal-error-policies.ts @@ -9,10 +9,6 @@ import { import { StorageLimitExceededError } from '@/lib/billing/storage' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { ArchiveError, statusForArchiveError } from '@/lib/uploads/archive' -import { - CompiledCheckTooLargeError, - CompiledCheckUnsupportedError, -} from '@/lib/workspace-files/application/compiled-check-workspace-file' import { StyleExtractionUnsupportedError } from '@/lib/workspace-files/application/style-workspace-file' const logger = createLogger('InternalWorkspaceFileErrors') @@ -22,16 +18,6 @@ const style = extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error return internalErrorResponse(422, { error: error.message }) }) -const compiledCheck = extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) => { - if (error instanceof CompiledCheckUnsupportedError) { - return internalErrorResponse(422, { error: error.message }) - } - if (error instanceof CompiledCheckTooLargeError) { - return internalErrorResponse(413, { error: error.message }) - } - return null -}) - const content = extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) => { if (!(error instanceof StorageLimitExceededError)) return null return internalErrorResponse(402, { error: error.message }) @@ -105,7 +91,6 @@ export const internalFileErrorPolicies = { notFoundMessage: FILE_NOT_FOUND_MESSAGE, }), style, - compiledCheck, downloadUrl, downloadArchive, extractArchive, diff --git a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts deleted file mode 100644 index 27967b13cee..00000000000 --- a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - getE2BDocFormat: vi.fn(), - getFile: vi.fn(), - loadContext: vi.fn(), - resolvePermission: vi.fn(), - fetchBuffer: vi.fn(), - runE2BCompiledCheck: vi.fn(), - runSandboxTask: vi.fn(), - validateMermaidSource: vi.fn(), -})) - -vi.mock('@sim/platform-authz/workspace', () => ({ - permissionSatisfies: () => true, - resolveEffectiveWorkspacePermission: mocks.resolvePermission, -})) - -vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ - getE2BDocFormat: mocks.getE2BDocFormat, -})) - -vi.mock('@/lib/copilot/tools/server/files/doc-recalc', () => ({ - runE2BCompiledCheck: mocks.runE2BCompiledCheck, -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ isDocSandboxEnabled: true })) - -vi.mock('@/lib/execution/constants', () => ({ - BINARY_DOC_TASKS: { pptx: 'document-pptx' }, - MAX_DOCUMENT_PREVIEW_CODE_BYTES: 1_000, -})) - -vi.mock('@/lib/execution/sandbox/run-task', () => ({ - runSandboxTask: mocks.runSandboxTask, - SandboxUserCodeError: class SandboxUserCodeError extends Error { - constructor(message: string, name: string) { - super(message) - this.name = name - } - }, -})) - -vi.mock('@/lib/mermaid/validate', () => ({ - validateMermaidSource: mocks.validateMermaidSource, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - loadActiveWorkspaceFileContext: mocks.loadContext, -})) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - fetchWorkspaceFileBuffer: mocks.fetchBuffer, - getWorkspaceFile: mocks.getFile, -})) - -import { SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' -import { compiledCheckWorkspaceFile } from '@/lib/workspace-files/application/compiled-check-workspace-file' - -const sessionPrincipal = { - kind: 'session' as const, - userId: 'current-user', - sessionId: 'session-1', -} - -function mockFile(name: string) { - mocks.getFile.mockResolvedValue({ - id: 'file-1', - workspaceId: 'workspace-1', - name, - size: 20, - uploadedBy: 'original-uploader', - }) - mocks.fetchBuffer.mockResolvedValue(Buffer.from('source code')) -} - -describe('compiledCheckWorkspaceFile', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.getE2BDocFormat.mockResolvedValue(null) - mocks.resolvePermission.mockResolvedValue('admin') - mocks.loadContext.mockResolvedValue({ - fileId: 'file-1', - workspaceId: 'workspace-1', - workspaceOrganizationId: null, - allowPersonalApiKeys: false, - billedAccountUserId: 'billing-owner', - }) - mocks.runSandboxTask.mockResolvedValue(Buffer.from('compiled')) - }) - - it('rejects API-key principals before canonical loading or business execution', async () => { - const unsupportedPrincipals = [ - { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key' }, - { - kind: 'workspace_api_key' as const, - workspaceId: 'workspace-1', - keyId: 'workspace-key', - }, - ] - - /** - * A workspace key is refused with the code naming *why* — the operation - * denies workspace keys, so the remedy is a personal key — while any other - * disallowed kind gets the generic kind refusal. - */ - const expectedDetailCode = { - personal_api_key: 'PRINCIPAL_KIND_NOT_PERMITTED', - workspace_api_key: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED', - } as const - - for (const principal of unsupportedPrincipals) { - await expect( - compiledCheckWorkspaceFile.execute({ - principal, - input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, - }) - ).rejects.toMatchObject({ - code: 'forbidden', - detailCode: expectedDetailCode[principal.kind], - }) - } - - expect(compiledCheckWorkspaceFile.operation).toMatchObject({ - id: 'files.compiled_check', - minimumRole: 'read', - workspaceApiKey: 'deny', - principalKinds: ['session'], - }) - expect(mocks.loadContext).not.toHaveBeenCalled() - expect(mocks.getFile).not.toHaveBeenCalled() - expect(mocks.fetchBuffer).not.toHaveBeenCalled() - }) - - it('uses the current session user as the legacy sandbox owner, never the uploader', async () => { - mockFile('report.pptx') - - await expect( - compiledCheckWorkspaceFile.execute({ - principal: sessionPrincipal, - input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, - }) - ).resolves.toEqual({ ok: true }) - - expect(mocks.runSandboxTask).toHaveBeenCalledWith( - 'document-pptx', - { code: 'source code', workspaceId: 'workspace-1' }, - { ownerKey: 'user:current-user' } - ) - expect(mocks.runSandboxTask).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), { - ownerKey: 'user:original-uploader', - }) - expect(mocks.loadContext).toHaveBeenCalledTimes(1) - expect(mocks.resolvePermission).toHaveBeenCalledTimes(1) - expect(mocks.getFile).toHaveBeenCalledTimes(1) - expect(mocks.fetchBuffer).toHaveBeenCalledTimes(1) - }) - - it('preserves legacy sandbox user-code failures in the successful response envelope', async () => { - mockFile('report.pptx') - mocks.runSandboxTask.mockRejectedValue( - new SandboxUserCodeError('Presentation source is invalid', 'SyntaxError') - ) - - await expect( - compiledCheckWorkspaceFile.execute({ - principal: sessionPrincipal, - input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, - }) - ).resolves.toEqual({ - ok: false, - error: 'Presentation source is invalid', - errorName: 'SyntaxError', - }) - }) - - it('keeps Mermaid validation on the in-process path', async () => { - mockFile('diagram.mmd') - mocks.validateMermaidSource.mockResolvedValue({ - ok: false, - error: 'Unexpected token', - errorName: 'MermaidError', - }) - - await expect( - compiledCheckWorkspaceFile.execute({ - principal: sessionPrincipal, - input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, - }) - ).resolves.toEqual({ - ok: false, - error: 'Unexpected token', - errorName: 'MermaidError', - }) - - expect(mocks.validateMermaidSource).toHaveBeenCalledWith('source code') - expect(mocks.runE2BCompiledCheck).not.toHaveBeenCalled() - expect(mocks.runSandboxTask).not.toHaveBeenCalled() - }) - - it('keeps E2B user-code failures in the successful response envelope', async () => { - mockFile('report.pptx') - mocks.getE2BDocFormat.mockResolvedValue({ ext: 'pptx' }) - mocks.runE2BCompiledCheck.mockResolvedValue({ ok: false, error: 'Script failed' }) - - await expect( - compiledCheckWorkspaceFile.execute({ - principal: sessionPrincipal, - input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, - }) - ).resolves.toEqual({ - ok: false, - error: 'Script failed', - errorName: 'CompiledCheckError', - }) - - expect(mocks.runE2BCompiledCheck).toHaveBeenCalledWith({ - source: 'source code', - fileName: 'report.pptx', - workspaceId: 'workspace-1', - ext: 'pptx', - principal: sessionPrincipal, - }) - expect(mocks.runSandboxTask).not.toHaveBeenCalled() - }) - - it('propagates E2B infrastructure failures', async () => { - mockFile('report.pptx') - mocks.getE2BDocFormat.mockResolvedValue({ ext: 'pptx' }) - const failure = new Error('E2B unavailable') - mocks.runE2BCompiledCheck.mockRejectedValue(failure) - - await expect( - compiledCheckWorkspaceFile.execute({ - principal: sessionPrincipal, - input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, - }) - ).rejects.toBe(failure) - - expect(mocks.runSandboxTask).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts deleted file mode 100644 index f10d806d9a4..00000000000 --- a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' -import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' -import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' -import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' -import { validateMermaidSource } from '@/lib/mermaid/validate' -import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import type { ActiveWorkspaceFileContext } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' - -export class CompiledCheckUnsupportedError extends Error { - constructor() { - super('Compiled check only supports .docx, .pptx, .pdf, .xlsx, and .mmd files') - this.name = 'CompiledCheckUnsupportedError' - } -} - -export class CompiledCheckTooLargeError extends Error { - constructor() { - super('File source exceeds maximum size') - this.name = 'CompiledCheckTooLargeError' - } -} - -export interface CompiledCheckWorkspaceFileInput { - fileId: string - assertedWorkspaceId?: string -} - -export type CompiledCheckWorkspaceFileResult = - | { ok: true } - | { ok: false; error: string; errorName: string } - -function normalizeCompiledCheckResult(result: { - ok: boolean - error?: string - errorName?: string -}): CompiledCheckWorkspaceFileResult { - if (result.ok) return { ok: true } - return { - ok: false, - error: result.error ?? 'Compiled check failed', - errorName: result.errorName ?? 'CompiledCheckError', - } -} - -async function executeCompiledCheckWorkspaceFile({ - principal, - context, -}: AuthorizedWorkspaceUseCaseContext< - typeof fileOperations.compiledCheck, - CompiledCheckWorkspaceFileInput, - ActiveWorkspaceFileContext ->): Promise { - const file = await getWorkspaceFile(context.workspaceId, context.fileId, { - throwOnError: true, - }) - if (!file) throw new OrchestrationError('not_found', 'File not found') - const ext = file.name.split('.').pop()?.toLowerCase() ?? '' - const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(file.name) : null - const taskId = BINARY_DOC_TASKS[ext] - const isMermaidFile = ext === 'mmd' || ext === 'mermaid' - if (!e2bFmt && !taskId && !isMermaidFile) throw new CompiledCheckUnsupportedError() - - if (file.size > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { - throw new CompiledCheckTooLargeError() - } - - const content = await fetchWorkspaceFileBuffer(file, { - maxBytes: MAX_DOCUMENT_PREVIEW_CODE_BYTES, - }) - - const code = content.toString('utf-8') - if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { - throw new CompiledCheckTooLargeError() - } - if (isMermaidFile) return normalizeCompiledCheckResult(await validateMermaidSource(code)) - if (e2bFmt) { - return normalizeCompiledCheckResult( - await runE2BCompiledCheck({ - source: code, - fileName: file.name, - workspaceId: file.workspaceId, - ext, - principal, - }) - ) - } - - try { - if (!taskId) throw new CompiledCheckUnsupportedError() - await runSandboxTask( - taskId, - { code, workspaceId: file.workspaceId }, - { ownerKey: `user:${principal.userId}` } - ) - return { ok: true } - } catch (error) { - if (error instanceof SandboxUserCodeError) { - return { ok: false, error: error.message, errorName: error.name } - } - throw error - } -} - -export const compiledCheckWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ - operation: fileOperations.compiledCheck, - resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), - execute: executeCompiledCheckWorkspaceFile, -}) diff --git a/bun.lock b/bun.lock index bc7b3e75d1a..f46b855ed91 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index f3c84aaf648..217a74073ad 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -156,8 +156,6 @@ const RAW_JSON_BASELINE_ROUTES = new Set([ 'apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts', 'apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts', 'apps/sim/app/api/organizations/route.ts', - 'apps/sim/app/api/organizations/[id]/invitations/route.ts', - 'apps/sim/app/api/organizations/[id]/members/route.ts', 'apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts', 'apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts', 'apps/sim/app/api/speech/token/route.ts',