diff --git a/apps/sim/app/api/table/[tableId]/exports/route.ts b/apps/sim/app/api/table/[tableId]/exports/route.ts index 525f455b81b..228fc3be0db 100644 --- a/apps/sim/app/api/table/[tableId]/exports/route.ts +++ b/apps/sim/app/api/table/[tableId]/exports/route.ts @@ -1,39 +1,27 @@ -import { type NextRequest, NextResponse } from 'next/server' import { createTableExportResourceContract } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - createTableExportResource, - toV2TableExport, -} from '@/lib/table/orchestration/export-resource' -import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { createTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2TableExport } from '@/lib/table/orchestration/export-resource' -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(createTableExportResourceContract, request, context) - if (!parsed.success) return parsed.response - const access = await checkAccess(parsed.data.params.tableId, auth.userId, 'read') - if (!access.ok) return accessError(access, 'table-export') - if (access.table.workspaceId !== parsed.data.body.workspaceId) { - return NextResponse.json({ error: 'Table not found' }, { status: 404 }) - } - try { - const record = await createTableExportResource({ - table: access.table, - format: parsed.data.body.format, - }) - return NextResponse.json({ data: toV2TableExport(record, true) }, { status: 201 }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const POST = defineInternalJsonRoute({ + contract: createTableExportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.createExport, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated table export creation has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + tableId: params.tableId, + workspaceId: body.workspaceId, + format: body.format, + }), + useCase: createTableExportUseCase, + present: ({ export: record }) => ({ data: toV2TableExport(record, true) }), }) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.test.ts b/apps/sim/app/api/table/[tableId]/groups/route.test.ts index 674298e8c66..24b6c533a57 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.test.ts @@ -1,209 +1,73 @@ /** * @vitest-environment node */ -import { hybridAuthMockFns, workflowAuthzMockFns } from '@sim/testing' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { TableDefinition } from '@/lib/table' +import { describe, expect, it, vi } from 'vitest' -const { mockCheckAccess, mockAddWorkflowGroup, mockUpdateWorkflowGroup } = vi.hoisted(() => ({ - mockCheckAccess: vi.fn(), - mockAddWorkflowGroup: vi.fn(), - mockUpdateWorkflowGroup: vi.fn(), -})) +interface CapturedDefinition { + contract: { method: string; path: string } + auth: unknown + operation: { id: string } + useCase: unknown +} -vi.mock('@/app/api/table/utils', async () => { - const { NextResponse } = await import('next/server') - return { - accessError: (result: { status: number }) => - NextResponse.json({ error: 'denied' }, { status: result.status }), - checkAccess: mockCheckAccess, - normalizeColumn: (column: unknown) => column, - } -}) +const mocks = vi.hoisted(() => ({ + auth: { kind: 'session-or-executor' }, + definitions: [] as CapturedDefinition[], + useCases: { + create: { operation: { id: 'tables.groups.create' } }, + remove: { operation: { id: 'tables.groups.delete' } }, + update: { operation: { id: 'tables.groups.update' } }, + }, +})) -vi.mock('@/lib/table/workflow-groups/service', () => ({ - addWorkflowGroup: mockAddWorkflowGroup, - updateWorkflowGroup: mockUpdateWorkflowGroup, - deleteWorkflowGroup: vi.fn(), +vi.mock('@/lib/api/server/routes', () => ({ + defineInternalJsonRoute: (definition: CapturedDefinition) => { + mocks.definitions.push(definition) + return vi.fn() + }, + extendInternalErrorPolicy: vi.fn(() => ({ kind: 'table' })), + internalErrorResponse: vi.fn(), + internalPlainOrchestrationErrorPolicy: { kind: 'plain' }, + internalRateLimits: { + none: ({ reason }: { reason: string }) => ({ kind: 'none', reason }), + }, })) -import { PATCH, POST } from '@/app/api/table/[tableId]/groups/route' +vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth })) -function buildTable(overrides: Partial = {}): TableDefinition { - return { - id: 'tbl_1', - name: 'People', - description: null, - schema: { columns: [] }, - metadata: null, - rowCount: 0, - maxRows: 100, - workspaceId: 'workspace-1', - createdBy: 'user-1', - archivedAt: null, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - ...overrides, - } -} +vi.mock('@/lib/table/application/groups', () => ({ + createTableGroupUseCase: mocks.useCases.create, + deleteTableGroupUseCase: mocks.useCases.remove, + updateTableGroupUseCase: mocks.useCases.update, +})) -function callPost(body: Record, tableId = 'tbl_1') { - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/groups`, { - method: 'POST', - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - }) - return POST(req, { params: Promise.resolve({ tableId }) }) -} +vi.mock('@/app/api/table/utils', () => ({ + normalizeColumn: vi.fn(), +})) -function callPatch(body: Record, tableId = 'tbl_1') { - const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/groups`, { - method: 'PATCH', - body: JSON.stringify(body), - headers: { 'Content-Type': 'application/json' }, - }) - return PATCH(req, { params: Promise.resolve({ tableId }) }) -} +import '@/app/api/table/[tableId]/groups/route' -const baseGroup = { - id: 'grp_1', - workflowId: 'wf_1', - outputs: [{ blockId: 'block_1', path: 'result', columnName: 'result' }], +function definition(method: string): CapturedDefinition { + const match = mocks.definitions.find((candidate) => candidate.contract.method === method) + if (!match) throw new Error(`Missing ${method} group route definition`) + return match } -const baseOutputColumns = [{ name: 'result', type: 'string', workflowGroupId: 'grp_1' }] - -describe('POST /api/table/[tableId]/groups', () => { - beforeEach(() => { - vi.clearAllMocks() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'session', - }) - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: { id: 'wf_1' }, - workspaceId: 'workspace-1', - workspaceOrganizationId: null, - }) - mockAddWorkflowGroup.mockResolvedValue({ - schema: { columns: baseOutputColumns, workflowGroups: [baseGroup] }, - }) - }) - - it('rejects a workflowId belonging to a different workspace', async () => { - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: { id: 'wf_1' }, - workspaceId: 'other-workspace', - workspaceOrganizationId: null, - }) - const res = await callPost({ - workspaceId: 'workspace-1', - group: baseGroup, - outputColumns: baseOutputColumns, - }) - expect(res.status).toBe(400) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('rejects a nonexistent workflowId', async () => { - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue(null) - const res = await callPost({ - workspaceId: 'workspace-1', - group: baseGroup, - outputColumns: baseOutputColumns, - }) - expect(res.status).toBe(400) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('succeeds when the workflow belongs to the same workspace', async () => { - const res = await callPost({ - workspaceId: 'workspace-1', - group: baseGroup, - outputColumns: baseOutputColumns, - }) - expect(res.status).toBe(200) - expect(mockAddWorkflowGroup).toHaveBeenCalled() - }) - - it('skips the workflow check for enrichment groups without a workflowId', async () => { - const res = await callPost({ - workspaceId: 'workspace-1', - group: { ...baseGroup, workflowId: '', enrichmentId: 'enrich_1' }, - outputColumns: baseOutputColumns, - }) - expect(res.status).toBe(200) - expect(workflowAuthzMockFns.mockGetActiveWorkflowContext).not.toHaveBeenCalled() - expect(mockAddWorkflowGroup).toHaveBeenCalled() - }) -}) - -describe('PATCH /api/table/[tableId]/groups', () => { - beforeEach(() => { - vi.clearAllMocks() - hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ - success: true, - userId: 'user-1', - authType: 'session', - }) - mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() }) - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: { id: 'wf_1' }, - workspaceId: 'workspace-1', - workspaceOrganizationId: null, - }) - mockUpdateWorkflowGroup.mockResolvedValue({ - schema: { columns: baseOutputColumns, workflowGroups: [baseGroup] }, - }) - }) - - it('rejects changing workflowId to one in a different workspace', async () => { - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue({ - workflow: { id: 'wf_2' }, - workspaceId: 'other-workspace', - workspaceOrganizationId: null, - }) - const res = await callPatch({ - workspaceId: 'workspace-1', - groupId: 'grp_1', - workflowId: 'wf_2', - }) - expect(res.status).toBe(400) - expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() - }) - - it('rejects a nonexistent workflowId', async () => { - workflowAuthzMockFns.mockGetActiveWorkflowContext.mockResolvedValue(null) - const res = await callPatch({ - workspaceId: 'workspace-1', - groupId: 'grp_1', - workflowId: 'wf_missing', - }) - expect(res.status).toBe(400) - expect(mockUpdateWorkflowGroup).not.toHaveBeenCalled() - }) - - it('succeeds when changing workflowId to one in the same workspace', async () => { - const res = await callPatch({ - workspaceId: 'workspace-1', - groupId: 'grp_1', - workflowId: 'wf_1', - }) - expect(res.status).toBe(200) - expect(mockUpdateWorkflowGroup).toHaveBeenCalled() - }) - - it('skips the workflow check when workflowId is not being changed', async () => { - const res = await callPatch({ - workspaceId: 'workspace-1', - groupId: 'grp_1', - name: 'Renamed group', - }) - expect(res.status).toBe(200) - expect(workflowAuthzMockFns.mockGetActiveWorkflowContext).not.toHaveBeenCalled() - expect(mockUpdateWorkflowGroup).toHaveBeenCalled() +describe('/api/table/[tableId]/groups', () => { + it('routes every mutation through its session-or-executor application use case', () => { + const expected = [ + ['POST', mocks.useCases.create], + ['PATCH', mocks.useCases.update], + ['DELETE', mocks.useCases.remove], + ] as const + + expect(mocks.definitions).toHaveLength(expected.length) + for (const [method, useCase] of expected) { + const route = definition(method) + expect(route.contract.path).toBe('/api/table/[tableId]/groups') + expect(route.auth).toBe(mocks.auth) + expect(route.useCase).toBe(useCase) + expect(route.operation.id).toBe(useCase.operation.id) + } }) }) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index c6f2e7c46b4..70ab6758ade 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -1,218 +1,75 @@ -import { createLogger } from '@sim/logger' -import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' -import { type NextRequest, NextResponse } from 'next/server' import { addWorkflowGroupContract, deleteWorkflowGroupContract, updateWorkflowGroupContract, } 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 { signalTableSchemaChanged } from '@/lib/table/events' import { - addWorkflowGroup, - deleteWorkflowGroup, - updateWorkflowGroup, -} from '@/lib/table/workflow-groups/service' + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { - accessError, - checkAccess, - normalizeColumn, - tableLockErrorResponse, -} from '@/app/api/table/utils' + createTableGroupUseCase, + deleteTableGroupUseCase, + updateTableGroupUseCase, +} from '@/lib/table/application/groups' +import { tableOperations } from '@/lib/table/application/operations' +import { TableLockedError } from '@/lib/table/mutation-locks' +import type { TableDefinition } from '@/lib/table/types' +import { normalizeColumn } from '@/app/api/table/utils' -const logger = createLogger('TableWorkflowGroupsAPI') +const errorPolicy = extendInternalErrorPolicy(internalPlainOrchestrationErrorPolicy, (error) => + error instanceof TableLockedError + ? internalErrorResponse(423, { error: error.message, lock: error.lock }) + : null +) -interface RouteParams { - params: Promise<{ tableId: string }> -} - -/** - * Confirms `workflowId` resolves to an active workflow in `workspaceId` before it is - * persisted onto a table's workflow group. Returns a 400 response when the workflow - * doesn't exist or belongs to a different workspace, otherwise `null`. - */ -async function validateWorkflowInWorkspace( - workflowId: string, - workspaceId: string -): Promise { - const context = await getActiveWorkflowContext(workflowId) - if (!context || context.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workflow ID' }, { status: 400 }) - } - return null -} +const rateLimit = internalRateLimits.none({ + reason: 'Existing authenticated table group mutations have no request-rate policy', +}) -/** - * Maps known service-layer error messages onto HTTP responses; falls through - * to a 500 with a generic message for anything unrecognized. The three - * group-route handlers all surface the same error shapes from - * `addWorkflowGroup` / `updateWorkflowGroup` / `deleteWorkflowGroup`, so they - * share this mapper instead of repeating the if-chain three times. - */ -function mapWorkflowGroupError(error: unknown, fallbackMessage: string): NextResponse { - const lockError = tableLockErrorResponse(error) - if (lockError) return lockError - if (error instanceof Error) { - const msg = error.message - if (msg === 'Table not found' || msg.includes('not found')) { - return NextResponse.json({ error: msg }, { status: 404 }) - } - if ( - msg.includes('Schema validation') || - msg.includes('Missing column definition') || - msg.includes('already exists') || - msg.includes('exceed') - ) { - return NextResponse.json({ error: msg }, { status: 400 }) - } +function presentTable(table: TableDefinition) { + return { + success: true as const, + data: { + columns: table.schema.columns.map(normalizeColumn), + workflowGroups: table.schema.workflowGroups ?? [], + }, } - logger.error(fallbackMessage, error) - return NextResponse.json({ error: fallbackMessage }, { status: 500 }) } -/** POST /api/table/[tableId]/groups — create a workflow group + its output columns. */ -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(addWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId } = parsed.data.params - const validated = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - if (result.table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - if (validated.group.workflowId) { - const workflowError = await validateWorkflowInWorkspace( - validated.group.workflowId, - result.table.workspaceId - ) - if (workflowError) return workflowError - } - const updatedTable = await addWorkflowGroup( - { - tableId, - group: validated.group, - outputColumns: validated.outputColumns, - autoRun: validated.autoRun, - actorUserId: authResult.userId, - }, - requestId - ) - signalTableSchemaChanged(tableId) - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - workflowGroups: updatedTable.schema.workflowGroups ?? [], - }, - }) - } catch (error) { - return mapWorkflowGroupError(error, 'Failed to add workflow group') - } +export const POST = defineInternalJsonRoute({ + contract: addWorkflowGroupContract, + operation: tableOperations.createGroup, + useCase: createTableGroupUseCase, + auth: internalTableSessionOrExecutorAuth, + rateLimit, + errorPolicy, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table }) => presentTable(table), }) -/** PATCH /api/table/[tableId]/groups — update a workflow group (deps / outputs). */ -export const PATCH = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(updateWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId } = parsed.data.params - const validated = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - if (result.table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - if (validated.workflowId !== undefined) { - const workflowError = await validateWorkflowInWorkspace( - validated.workflowId, - result.table.workspaceId - ) - if (workflowError) return workflowError - } - const updatedTable = await updateWorkflowGroup( - { - tableId, - groupId: validated.groupId, - actorUserId: authResult.userId, - ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), - ...(validated.name !== undefined ? { name: validated.name } : {}), - ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), - ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), - ...(validated.newOutputColumns !== undefined - ? { newOutputColumns: validated.newOutputColumns } - : {}), - ...(validated.mappingUpdates !== undefined - ? { mappingUpdates: validated.mappingUpdates } - : {}), - ...(validated.inputMappings !== undefined - ? { inputMappings: validated.inputMappings } - : {}), - ...(validated.deploymentMode !== undefined - ? { deploymentMode: validated.deploymentMode } - : {}), - ...(validated.type !== undefined ? { type: validated.type } : {}), - ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), - }, - requestId - ) - signalTableSchemaChanged(tableId) - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - workflowGroups: updatedTable.schema.workflowGroups ?? [], - }, - }) - } catch (error) { - return mapWorkflowGroupError(error, 'Failed to update workflow group') - } +export const PATCH = defineInternalJsonRoute({ + contract: updateWorkflowGroupContract, + operation: tableOperations.updateGroup, + useCase: updateTableGroupUseCase, + auth: internalTableSessionOrExecutorAuth, + rateLimit, + errorPolicy, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table }) => presentTable(table), }) -/** DELETE /api/table/[tableId]/groups — remove a workflow group + its columns. */ -export const DELETE = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(deleteWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId } = parsed.data.params - const validated = parsed.data.body - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - if (result.table.workspaceId !== validated.workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - const updatedTable = await deleteWorkflowGroup( - { tableId, groupId: validated.groupId }, - requestId - ) - signalTableSchemaChanged(tableId) - return NextResponse.json({ - success: true, - data: { - columns: updatedTable.schema.columns.map(normalizeColumn), - workflowGroups: updatedTable.schema.workflowGroups ?? [], - }, - }) - } catch (error) { - return mapWorkflowGroupError(error, 'Failed to delete workflow group') - } +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkflowGroupContract, + operation: tableOperations.deleteGroup, + useCase: deleteTableGroupUseCase, + auth: internalTableSessionOrExecutorAuth, + rateLimit, + errorPolicy, + mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), + present: ({ table }) => presentTable(table), }) diff --git a/apps/sim/app/api/table/exports/[exportId]/download/route.ts b/apps/sim/app/api/table/exports/[exportId]/download/route.ts index 93ba5175585..71c9ca300c3 100644 --- a/apps/sim/app/api/table/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/table/exports/[exportId]/download/route.ts @@ -1,47 +1,25 @@ -import { type NextRequest, NextResponse } from 'next/server' import { downloadTableExportResourceContract } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' -import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' -import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' +import { + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { downloadTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' -const DOWNLOAD_TTL_SECONDS = 60 * 60 - -interface ExportRouteParams { - params: Promise<{ exportId: string }> -} - -export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(downloadTableExportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const record = await requireTableExport( - parsed.data.params.exportId, - parsed.data.query.workspaceId - ) - const access = await checkAccess(record.tableId, auth.userId, 'read') - if (!access.ok) return accessError(access, 'table-export') - const result = tableExportResult(record) - return NextResponse.json({ - data: { - url: await generatePresignedDownloadUrl( - result.resultKey, - 'workspace', - DOWNLOAD_TTL_SECONDS - ), - fileName: result.resultKey.split('/').pop() ?? `export.${result.format}`, - expiresAt: new Date(Date.now() + DOWNLOAD_TTL_SECONDS * 1000).toISOString(), - }, - }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const GET = defineInternalJsonRoute({ + contract: downloadTableExportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.downloadExport, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated table export download signing has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: downloadTableExportUseCase, + present: (result) => ({ data: result }), }) diff --git a/apps/sim/app/api/table/exports/[exportId]/route.ts b/apps/sim/app/api/table/exports/[exportId]/route.ts index c7e9f56b405..5e4d3a8c14c 100644 --- a/apps/sim/app/api/table/exports/[exportId]/route.ts +++ b/apps/sim/app/api/table/exports/[exportId]/route.ts @@ -1,68 +1,45 @@ -import { type NextRequest, NextResponse } from 'next/server' import { cancelTableExportResourceContract, getTableExportResourceContract, } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - cancelTableExportResource, - requireTableExport, - toV2TableExport, -} from '@/lib/table/orchestration/export-resource' -import { accessError, checkAccess, orchestrationErrorResponse } from '@/app/api/table/utils' + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { cancelTableExportUseCase, readTableExportUseCase } from '@/lib/table/application/exports' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2TableExport } from '@/lib/table/orchestration/export-resource' -interface ExportRouteParams { - params: Promise<{ exportId: string }> -} - -async function authorizedExport(exportId: string, workspaceId: string, userId: string) { - const record = await requireTableExport(exportId, workspaceId) - const access = await checkAccess(record.tableId, userId, 'read') - return { record, access } -} +const rateLimit = internalRateLimits.none({ + reason: 'Existing authenticated table export resource access has no request-rate policy', +}) -export const GET = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(getTableExportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const { record, access } = await authorizedExport( - parsed.data.params.exportId, - parsed.data.query.workspaceId, - auth.userId - ) - if (!access.ok) return accessError(access, 'table-export') - return NextResponse.json({ data: toV2TableExport(record) }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const GET = defineInternalJsonRoute({ + contract: getTableExportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.readExport, + rateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: readTableExportUseCase, + present: ({ export: record }) => ({ data: toV2TableExport(record) }), }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: ExportRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(cancelTableExportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const { record, access } = await authorizedExport( - parsed.data.params.exportId, - parsed.data.query.workspaceId, - auth.userId - ) - if (!access.ok) return accessError(access, 'table-export') - return NextResponse.json({ data: toV2TableExport(await cancelTableExportResource(record)) }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const DELETE = defineInternalJsonRoute({ + contract: cancelTableExportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.cancelExport, + rateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + exportId: params.exportId, + workspaceId: query.workspaceId, + }), + useCase: cancelTableExportUseCase, + present: ({ export: record }) => ({ data: toV2TableExport(record) }), }) diff --git a/apps/sim/app/api/table/imports/[importId]/complete/route.ts b/apps/sim/app/api/table/imports/[importId]/complete/route.ts index 0de609c4c0d..6952511835d 100644 --- a/apps/sim/app/api/table/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -1,51 +1,27 @@ -import { type NextRequest, NextResponse } from 'next/server' import { completeTableImportResourceContract } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - findOwnedTableImport, - getOwnedTableImportUpload, - startUploadedTableImport, - toV2TableImport, -} from '@/lib/table/orchestration/import-resource' -import { completeUploadSession } from '@/lib/uploads/upload-session/service' -import { orchestrationErrorResponse } from '@/app/api/table/utils' + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { completeTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2TableImport } from '@/lib/table/orchestration/import-resource' -interface ImportRouteParams { - params: Promise<{ importId: string }> -} - -export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(completeTableImportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const upload = await getOwnedTableImportUpload({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, - userId: auth.userId, - uploadToken: parsed.data.headers['upload-token'], - }) - const existing = await findOwnedTableImport({ - importId: upload.id, - workspaceId: parsed.data.query.workspaceId, - userId: upload.userId, - }) - if (existing) return NextResponse.json({ data: toV2TableImport(existing) }) - const completed = await completeUploadSession({ - session: upload, - finalize: async () => ({ value: null }), - }) - return NextResponse.json({ - data: toV2TableImport(await startUploadedTableImport(completed.session)), - }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const POST = defineInternalJsonRoute({ + contract: completeTableImportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.completeImport, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated table import completion has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query, headers }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: completeTableImportUseCase, + present: ({ import: record }) => ({ data: toV2TableImport(record) }), }) diff --git a/apps/sim/app/api/table/imports/[importId]/parts/route.ts b/apps/sim/app/api/table/imports/[importId]/parts/route.ts index f86289bf5a2..4a3b4261c60 100644 --- a/apps/sim/app/api/table/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts @@ -1,39 +1,27 @@ -import { type NextRequest, NextResponse } from 'next/server' import { createTableImportPartUrlsContract } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' -import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' -import { orchestrationErrorResponse } from '@/app/api/table/utils' +import { + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { createTableImportPartsUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' -interface ImportRouteParams { - params: Promise<{ importId: string }> -} - -export const POST = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(createTableImportPartUrlsContract, request, context) - if (!parsed.success) return parsed.response - try { - const upload = await getOwnedTableImportUpload({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, - userId: auth.userId, - uploadToken: parsed.data.headers['upload-token'], - }) - const parts = await createUploadPartUrls({ - session: upload, - partNumbers: parsed.data.body.partNumbers, - localOrigin: request.nextUrl.origin, - }) - return NextResponse.json({ data: { parts } }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const POST = defineInternalJsonRoute({ + contract: createTableImportPartUrlsContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.createImportParts, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated table import part signing has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query, headers, body }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: createTableImportPartsUseCase, + present: ({ parts }) => ({ data: { parts } }), }) diff --git a/apps/sim/app/api/table/imports/[importId]/route.ts b/apps/sim/app/api/table/imports/[importId]/route.ts index 15fb5cd2914..60dd58dc125 100644 --- a/apps/sim/app/api/table/imports/[importId]/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/route.ts @@ -1,76 +1,46 @@ -import { type NextRequest, NextResponse } from 'next/server' import { cancelTableImportResourceContract, getTableImportResourceContract, } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - abortTableImportUpload, - cancelTableImportResource, - getOwnedTableImport, - toV2TableImport, -} from '@/lib/table/orchestration/import-resource' -import { orchestrationErrorResponse } from '@/app/api/table/utils' + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { cancelTableImportUseCase, readTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2TableImport } from '@/lib/table/orchestration/import-resource' -interface ImportRouteParams { - params: Promise<{ importId: string }> -} - -async function userId(request: NextRequest): Promise { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - return auth.success && auth.userId - ? auth.userId - : NextResponse.json({ error: 'Authentication required' }, { status: 401 }) -} +const rateLimit = internalRateLimits.none({ + reason: 'Existing authenticated table import resource access has no request-rate policy', +}) -export const GET = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { - const user = await userId(request) - if (user instanceof NextResponse) return user - const parsed = await parseRequest(getTableImportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const record = await getOwnedTableImport({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, - userId: user, - }) - return NextResponse.json({ data: await toV2TableImport(record) }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const GET = defineInternalJsonRoute({ + contract: getTableImportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.readImport, + rateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + }), + useCase: readTableImportUseCase, + present: ({ import: record }) => ({ data: toV2TableImport(record) }), }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: ImportRouteParams) => { - const user = await userId(request) - if (user instanceof NextResponse) return user - const parsed = await parseRequest(cancelTableImportResourceContract, request, context) - if (!parsed.success) return parsed.response - try { - const uploadToken = parsed.data.headers['upload-token'] - const record = uploadToken - ? await abortTableImportUpload({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, - userId: user, - uploadToken, - }) - : await cancelTableImportResource( - await getOwnedTableImport({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, - userId: user, - }) - ) - return NextResponse.json({ - data: toV2TableImport(record), - }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const DELETE = defineInternalJsonRoute({ + contract: cancelTableImportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.cancelImport, + rateLimit, + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ params, query, headers }) => ({ + importId: params.importId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: cancelTableImportUseCase, + present: ({ import: record }) => ({ data: toV2TableImport(record) }), }) diff --git a/apps/sim/app/api/table/imports/route.ts b/apps/sim/app/api/table/imports/route.ts index d7e42288f09..143207c2e5e 100644 --- a/apps/sim/app/api/table/imports/route.ts +++ b/apps/sim/app/api/table/imports/route.ts @@ -1,31 +1,23 @@ -import { type NextRequest, NextResponse } from 'next/server' import { createTableImportResourceContract } from '@/lib/api/contracts/table-transfers' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - createTableImportResource, - toV2CreateTableImport, -} from '@/lib/table/orchestration/import-resource' -import { orchestrationErrorResponse } from '@/app/api/table/utils' + defineInternalJsonRoute, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { createTableImportUseCase } from '@/lib/table/application/imports' +import { tableOperations } from '@/lib/table/application/operations' +import { toV2CreateTableImport } from '@/lib/table/orchestration/import-resource' -export const POST = withRouteHandler(async (request: NextRequest) => { - const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!auth.success || !auth.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const parsed = await parseRequest(createTableImportResourceContract, request, {}) - if (!parsed.success) return parsed.response - try { - const created = await createTableImportResource( - parsed.data.body, - auth.userId, - request.nextUrl.origin - ) - return NextResponse.json({ data: toV2CreateTableImport(created) }, { status: 201 }) - } catch (error) { - const classified = orchestrationErrorResponse(error) - if (classified) return classified - throw error - } +export const POST = defineInternalJsonRoute({ + contract: createTableImportResourceContract, + auth: internalTableSessionOrExecutorAuth, + operation: tableOperations.createImport, + rateLimit: internalRateLimits.none({ + reason: 'Existing authenticated table import creation has no request-rate policy', + }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: ({ body }) => ({ body }), + useCase: createTableImportUseCase, + present: ({ import: created }) => ({ data: toV2CreateTableImport(created) }), }) diff --git a/apps/sim/app/api/table/table-transfer-routes.test.ts b/apps/sim/app/api/table/table-transfer-routes.test.ts new file mode 100644 index 00000000000..7511900d81f --- /dev/null +++ b/apps/sim/app/api/table/table-transfer-routes.test.ts @@ -0,0 +1,113 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +interface CapturedDefinition { + contract: { + method: string + path: string + response: { status?: number } + } + auth: unknown + operation: { id: string } + useCase: unknown +} + +const mocks = vi.hoisted(() => ({ + auth: { kind: 'session-or-executor' }, + definitions: [] as CapturedDefinition[], + useCases: { + cancelExport: { operation: { id: 'tables.exports.cancel' } }, + cancelImport: { operation: { id: 'tables.imports.cancel' } }, + completeImport: { operation: { id: 'tables.imports.complete' } }, + createExport: { operation: { id: 'tables.exports.create' } }, + createImport: { operation: { id: 'tables.imports.create' } }, + createImportParts: { operation: { id: 'tables.imports.create_parts' } }, + downloadExport: { operation: { id: 'tables.exports.download' } }, + readExport: { operation: { id: 'tables.exports.read' } }, + readImport: { operation: { id: 'tables.imports.read' } }, + }, +})) + +vi.mock('@/lib/api/server/routes', () => ({ + defineInternalJsonRoute: (definition: CapturedDefinition) => { + mocks.definitions.push(definition) + return vi.fn() + }, + internalPlainOrchestrationErrorPolicy: { kind: 'plain' }, + internalRateLimits: { + none: ({ reason }: { reason: string }) => ({ kind: 'none', reason }), + }, +})) + +vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth })) + +vi.mock('@/lib/table/application/imports', () => ({ + cancelTableImportUseCase: mocks.useCases.cancelImport, + completeTableImportUseCase: mocks.useCases.completeImport, + createTableImportPartsUseCase: mocks.useCases.createImportParts, + createTableImportUseCase: mocks.useCases.createImport, + readTableImportUseCase: mocks.useCases.readImport, +})) + +vi.mock('@/lib/table/application/exports', () => ({ + cancelTableExportUseCase: mocks.useCases.cancelExport, + createTableExportUseCase: mocks.useCases.createExport, + downloadTableExportUseCase: mocks.useCases.downloadExport, + readTableExportUseCase: mocks.useCases.readExport, +})) + +vi.mock('@/lib/table/orchestration/import-resource', () => ({ + toV2CreateTableImport: vi.fn(), + toV2TableImport: vi.fn(), +})) + +vi.mock('@/lib/table/orchestration/export-resource', () => ({ + toV2TableExport: vi.fn(), +})) + +import '@/app/api/table/[tableId]/exports/route' +import '@/app/api/table/exports/[exportId]/download/route' +import '@/app/api/table/exports/[exportId]/route' +import '@/app/api/table/imports/[importId]/complete/route' +import '@/app/api/table/imports/[importId]/parts/route' +import '@/app/api/table/imports/[importId]/route' +import '@/app/api/table/imports/route' + +function definition(method: string, path: string): CapturedDefinition { + const match = mocks.definitions.find( + (candidate) => candidate.contract.method === method && candidate.contract.path === path + ) + if (!match) throw new Error(`Missing ${method} ${path} route definition`) + return match +} + +describe('internal table transfer routes', () => { + it('routes every ordinary transfer control leg through session-or-executor use cases', () => { + const expected = [ + ['POST', '/api/table/imports', mocks.useCases.createImport], + ['GET', '/api/table/imports/[importId]', mocks.useCases.readImport], + ['DELETE', '/api/table/imports/[importId]', mocks.useCases.cancelImport], + ['POST', '/api/table/imports/[importId]/parts', mocks.useCases.createImportParts], + ['POST', '/api/table/imports/[importId]/complete', mocks.useCases.completeImport], + ['POST', '/api/table/[tableId]/exports', mocks.useCases.createExport], + ['GET', '/api/table/exports/[exportId]', mocks.useCases.readExport], + ['DELETE', '/api/table/exports/[exportId]', mocks.useCases.cancelExport], + ['GET', '/api/table/exports/[exportId]/download', mocks.useCases.downloadExport], + ] as const + + expect(mocks.definitions).toHaveLength(expected.length) + for (const [method, path, useCase] of expected) { + const route = definition(method, path) + expect(route.auth).toBe(mocks.auth) + expect(route.useCase).toBe(useCase) + expect(route.operation.id).toBe(useCase.operation.id) + } + }) + + it('preserves the create response statuses', () => { + expect(definition('POST', '/api/table/imports').contract.response.status).toBe(201) + expect(definition('POST', '/api/table/[tableId]/exports').contract.response.status).toBe(201) + }) +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts index d20a9074734..ad7a2d1bc2f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.test.ts @@ -27,6 +27,9 @@ vi.mock('@/lib/core/rate-limiter', () => ({ getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/app/api/v2/tables/presenters', () => ({ + presentV2TableExport: (tableExport: unknown) => ({ data: tableExport }), +})) vi.mock('@/lib/table/application/exports', () => ({ createTableExportUseCase: { operation: { id: 'tables.exports.create' }, execute: mocks.create }, readTableExportUseCase: { operation: { id: 'tables.exports.read' }, execute: mocks.read }, diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts index 9b8115899a5..9de6249cbe0 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -3,6 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { createTableExportUseCase } from '@/lib/table/application/exports' import { tableOperations } from '@/lib/table/application/operations' +import { presentV2TableExport } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -19,5 +20,5 @@ export const POST = defineV2JsonRoute({ format: body.format, }), useCase: createTableExportUseCase, - present: ({ export: tableExport }) => ({ data: tableExport }), + present: ({ export: tableExport }) => presentV2TableExport(tableExport, true), }) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts index 61c18650cdf..d962454a3ad 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -6,6 +6,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { cancelTableExportUseCase, readTableExportUseCase } from '@/lib/table/application/exports' import { tableOperations } from '@/lib/table/application/operations' +import { presentV2TableExport } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -21,7 +22,7 @@ export const GET = defineV2JsonRoute({ workspaceId: query.workspaceId, }), useCase: readTableExportUseCase, - present: ({ export: tableExport }) => ({ data: tableExport }), + present: ({ export: tableExport }) => presentV2TableExport(tableExport), }) export const DELETE = defineV2JsonRoute({ @@ -35,5 +36,5 @@ export const DELETE = defineV2JsonRoute({ workspaceId: query.workspaceId, }), useCase: cancelTableExportUseCase, - present: ({ export: tableExport }) => ({ data: tableExport }), + present: ({ export: tableExport }) => presentV2TableExport(tableExport), }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts index de1c14d4349..6964d818d6b 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.test.ts @@ -25,6 +25,9 @@ vi.mock('@/lib/core/rate-limiter', () => ({ getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/app/api/v2/tables/presenters', () => ({ + presentV2TableImport: (tableImport: unknown) => ({ data: tableImport }), +})) vi.mock('@/lib/table/application/imports', () => ({ completeTableImportUseCase: { operation: { id: 'tables.imports.complete' }, diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts index 00ac2393205..368970ec9b9 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -3,6 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { completeTableImportUseCase } from '@/lib/table/application/imports' import { tableOperations } from '@/lib/table/application/operations' +import { presentV2TableImport } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -19,5 +20,5 @@ export const POST = defineV2JsonRoute({ uploadToken: headers['upload-token'], }), useCase: completeTableImportUseCase, - present: ({ import: tableImport }) => ({ data: tableImport }), + present: ({ import: tableImport }) => presentV2TableImport(tableImport), }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts index aba56dd3df1..7092782c602 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -6,6 +6,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { cancelTableImportUseCase, readTableImportUseCase } from '@/lib/table/application/imports' import { tableOperations } from '@/lib/table/application/operations' +import { presentV2TableImport } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -21,7 +22,7 @@ export const GET = defineV2JsonRoute({ workspaceId: query.workspaceId, }), useCase: readTableImportUseCase, - present: ({ import: tableImport }) => ({ data: tableImport }), + present: ({ import: tableImport }) => presentV2TableImport(tableImport), }) export const DELETE = defineV2JsonRoute({ @@ -36,5 +37,5 @@ export const DELETE = defineV2JsonRoute({ uploadToken: headers['upload-token'], }), useCase: cancelTableImportUseCase, - present: ({ import: tableImport }) => ({ data: tableImport }), + present: ({ import: tableImport }) => presentV2TableImport(tableImport), }) diff --git a/apps/sim/app/api/v2/tables/imports/route.test.ts b/apps/sim/app/api/v2/tables/imports/route.test.ts index a0d53ee1880..90bd9e23e22 100644 --- a/apps/sim/app/api/v2/tables/imports/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -25,6 +25,9 @@ vi.mock('@/lib/core/rate-limiter', () => ({ getRateLimit: () => ({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/app/api/v2/tables/presenters', () => ({ + presentV2CreateTableImport: (tableImport: unknown) => ({ data: tableImport }), +})) vi.mock('@/lib/table/application/imports', () => ({ createTableImportUseCase: { operation: { id: 'tables.imports.create' }, execute: mocks.create }, })) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index 2fba1da2417..ea630f4f9c7 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -3,6 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2TableErrorPolicies } from '@/lib/table/api' import { createTableImportUseCase } from '@/lib/table/application/imports' import { tableOperations } from '@/lib/table/application/operations' +import { presentV2CreateTableImport } from '@/app/api/v2/tables/presenters' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -15,5 +16,5 @@ export const POST = defineV2JsonRoute({ errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ body }) => ({ body }), useCase: createTableImportUseCase, - present: ({ import: tableImport }) => ({ data: tableImport }), + present: ({ import: tableImport }) => presentV2CreateTableImport(tableImport), }) diff --git a/apps/sim/app/api/v2/tables/presenters.test.ts b/apps/sim/app/api/v2/tables/presenters.test.ts new file mode 100644 index 00000000000..da5f27bca2c --- /dev/null +++ b/apps/sim/app/api/v2/tables/presenters.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { + presentV2CreateTableImport, + presentV2TableExport, + presentV2TableImport, +} from '@/app/api/v2/tables/presenters' + +const createdAt = new Date('2026-08-01T00:00:00.000Z') +const importRecord = { + id: 'import-1', + workspaceId: 'workspace-1', + userId: 'user-1', + source: { type: 'workspace_file' as const, fileId: 'file-1' }, + target: { type: 'new' as const, name: 'People' }, + options: {}, + tableId: 'table-1', + status: 'running' as const, + rowsProcessed: 2, + error: null, + createdAt, + updatedAt: createdAt, + completedAt: null, +} +const exportRecord = { + id: 'export-1', + tableId: 'table-1', + workspaceId: 'workspace-1', + type: 'export', + status: 'running', + payload: { format: 'csv' as const }, + rowsProcessed: 0, + error: null, + startedAt: createdAt, + updatedAt: createdAt, + completedAt: null, +} + +describe('v2 table presenters', () => { + it('converts domain import records at the v2 boundary', () => { + expect(presentV2CreateTableImport({ record: importRecord, upload: null })).toEqual({ + data: { + session: { + id: 'import-1', + workspaceId: 'workspace-1', + status: 'processing', + source: importRecord.source, + target: importRecord.target, + tableId: 'table-1', + rowsProcessed: 2, + error: null, + createdAt: createdAt.toISOString(), + updatedAt: createdAt.toISOString(), + completedAt: null, + }, + uploadToken: null, + transfer: null, + }, + }) + expect(presentV2TableImport(importRecord).data.createdAt).toBe(createdAt.toISOString()) + }) + + it('converts domain export records and preserves queued create presentation', () => { + expect(presentV2TableExport(exportRecord, true)).toEqual({ + data: { + id: 'export-1', + tableId: 'table-1', + workspaceId: 'workspace-1', + format: 'csv', + status: 'queued', + rowsProcessed: 0, + error: null, + createdAt: createdAt.toISOString(), + updatedAt: createdAt.toISOString(), + completedAt: null, + }, + }) + }) +}) diff --git a/apps/sim/app/api/v2/tables/presenters.ts b/apps/sim/app/api/v2/tables/presenters.ts new file mode 100644 index 00000000000..d194cb5c3fa --- /dev/null +++ b/apps/sim/app/api/v2/tables/presenters.ts @@ -0,0 +1,19 @@ +import { type TableExportRecord, toV2TableExport } from '@/lib/table/orchestration/export-resource' +import { + type CreateTableImportResult, + type TableImportResource, + toV2CreateTableImport, + toV2TableImport, +} from '@/lib/table/orchestration/import-resource' + +export function presentV2CreateTableImport(result: CreateTableImportResult) { + return { data: toV2CreateTableImport(result) } +} + +export function presentV2TableImport(record: TableImportResource) { + return { data: toV2TableImport(record) } +} + +export function presentV2TableExport(record: TableExportRecord, queued = false) { + return { data: toV2TableExport(record, queued) } +} diff --git a/apps/sim/lib/api/contracts/table-transfers.ts b/apps/sim/lib/api/contracts/table-transfers.ts index a1849b8736c..abd6194254f 100644 --- a/apps/sim/lib/api/contracts/table-transfers.ts +++ b/apps/sim/lib/api/contracts/table-transfers.ts @@ -22,7 +22,7 @@ export const createTableImportResourceContract = defineRouteContract({ method: 'POST', path: '/api/table/imports', body: v2CreateTableImportBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2CreateTableImportDataSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2CreateTableImportDataSchema), status: 201 }, }) export const getTableImportResourceContract = defineRouteContract({ @@ -66,7 +66,7 @@ export const createTableExportResourceContract = defineRouteContract({ path: '/api/table/[tableId]/exports', params: tableIdParamsSchema, body: exportTableAsyncBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2TableExportSchema), status: 201 }, }) export const getTableExportResourceContract = defineRouteContract({ diff --git a/apps/sim/lib/copilot/application/execute-table-use-case.test.ts b/apps/sim/lib/copilot/application/execute-table-use-case.test.ts new file mode 100644 index 00000000000..a7fbcd2d531 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-table-use-case.test.ts @@ -0,0 +1,96 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it, vi } from 'vitest' +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +import { tableOperations } from '@/lib/table/application/operations' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as const + +describe('executeCopilotTableUseCase', () => { + it('binds the in-process Copilot identity and exact table scope', async () => { + const execute = vi.fn().mockResolvedValue({ id: 'table-1' }) + const useCase = { operation: tableOperations.read, execute } + + await expect( + executeCopilotTableUseCase( + trustedContext, + useCase, + { tableId: 'model-table' }, + { + tableId: 'table-1', + } + ) + ).resolves.toEqual({ id: 'table-1' }) + + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + audience: 'sim:tables', + delegationId: 'copilot-tool:tool-call-1', + resourceScope: { + tableId: 'table-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }), + input: { tableId: 'model-table' }, + }) + }) + + it('creates an unscoped Table principal for workspace-level commands', async () => { + const execute = vi.fn().mockResolvedValue({ ok: true }) + + await executeCopilotTableUseCase( + trustedContext, + { operation: tableOperations.delete, execute }, + { workspaceId: 'workspace-1' } + ) + + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + serviceId: 'copilot', + resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + }), + input: { workspaceId: 'workspace-1' }, + }) + }) + + it('rejects untrusted contexts and unregistered operation objects before execution', () => { + const execute = vi.fn() + const useCase = { operation: tableOperations.read, execute } + + expect(() => + executeCopilotTableUseCase( + { ...trustedContext, copilotToolExecution: false }, + useCase, + { tableId: 'table-1' }, + { tableId: 'table-1' } + ) + ).toThrow('trusted Copilot execution context') + + expect(() => + executeCopilotTableUseCase( + trustedContext, + { + operation: { ...tableOperations.read, minimumRole: 'write' }, + execute, + }, + { tableId: 'table-1' }, + { tableId: 'table-1' } + ) + ).toThrow('Unregistered Copilot table operation') + expect(execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/application/execute-table-use-case.ts b/apps/sim/lib/copilot/application/execute-table-use-case.ts index b973f5fd1bf..d634d93141c 100644 --- a/apps/sim/lib/copilot/application/execute-table-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-table-use-case.ts @@ -3,22 +3,12 @@ import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/applic import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' import type { OperationUseCase } from '@/lib/core/application' import { tableDelegationPolicy } from '@/lib/table/application/authorization' -import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' -import { - resolveActiveTableContext, - resolveTableWorkspaceContext, -} from '@/lib/table/application/context' import { type TableOperation, tableOperations } from '@/lib/table/application/operations' interface ExecuteCopilotTableUseCaseOptions { tableId?: string } -export interface AdmitCopilotTableOperationInput { - workspaceId: string - tableId?: string -} - const executeTableUseCase = createCopilotApplicationAdapter< TableOperation, ExecuteCopilotTableUseCaseOptions @@ -33,7 +23,7 @@ const executeTableUseCase = createCopilotApplicationAdapter< projectResourceScope: ({ tableId }) => (tableId ? { tableId } : {}), }) -/** Enters a registered table application use case under trusted Copilot delegation. */ +/** Normalizes trusted Copilot authentication before entering a Table application use case. */ export function executeCopilotTableUseCase( context: CopilotTableDelegationContext | undefined, useCase: OperationUseCase, @@ -42,26 +32,3 @@ export function executeCopilotTableUseCase( ): Promise { return executeTableUseCase(context, useCase, input, options) } - -/** - * Authorizes a Copilot operation that still retains a compatibility-specific - * presenter or execution strategy before that trusted adapter invokes it. - */ -export function admitCopilotTableOperation( - context: CopilotTableDelegationContext | undefined, - operation: O, - input: AdmitCopilotTableOperationInput -): Promise { - const useCase = defineAuthorizedTableUseCase({ - operation, - resolveContext: ({ input: admitted }: { input: AdmitCopilotTableOperationInput }) => - admitted.tableId - ? resolveActiveTableContext({ - tableId: admitted.tableId, - assertedWorkspaceId: admitted.workspaceId, - }) - : resolveTableWorkspaceContext(admitted.workspaceId), - async execute() {}, - }) - return executeCopilotTableUseCase(context, useCase, input, { tableId: input.tableId }) -} diff --git a/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts b/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts new file mode 100644 index 00000000000..f453b071688 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ + resolveWorkflowOutputs: { execute: mocks.execute }, +})) + +import { executeCopilotResolveWorkflowOutputs } from '@/lib/copilot/application/execute-workflow-use-case' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as const + +describe('executeCopilotResolveWorkflowOutputs', () => { + afterEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('enters the fixed Workflow resolver with trusted Copilot identity', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + mocks.execute.mockResolvedValueOnce({ + workflowId: 'workflow-1', + outputs: null, + executionOrderByBlockId: {}, + }) + + await expect( + executeCopilotResolveWorkflowOutputs(trustedContext, { + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + ).resolves.toMatchObject({ workflowId: 'workflow-1' }) + + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + }, + input: { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-1' }, + }) + }) + + it('rejects untrusted context before Workflow application execution', () => { + expect(() => + executeCopilotResolveWorkflowOutputs( + { ...trustedContext, copilotToolExecution: false }, + { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-1' } + ) + ).toThrow('trusted Copilot execution context') + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/application/execute-workflow-use-case.ts b/apps/sim/lib/copilot/application/execute-workflow-use-case.ts new file mode 100644 index 00000000000..a91acba9560 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-workflow-use-case.ts @@ -0,0 +1,35 @@ +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, + createCopilotApplicationPrincipal, + requireTrustedCopilotExecutionContext, +} from '@/lib/copilot/auth/application-delegation' +import { workflowDelegationPolicy } from '@/lib/workflows/application/authorization' +import { + type ResolveWorkflowOutputsInput, + type ResolveWorkflowOutputsResult, + resolveWorkflowOutputs, +} from '@/lib/workflows/application/resolve-workflow-outputs' + +export type CopilotWorkflowDelegationContext = CopilotExecutionContext + +const workflowDelegation = { + audience: workflowDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context: Parameters[0]) => + `copilot-tool:${context.toolCallId}`, +} as const + +/** Resolves workflow output metadata through one fixed authorized Workflow command. */ +export function executeCopilotResolveWorkflowOutputs( + context: CopilotWorkflowDelegationContext | undefined, + input: ResolveWorkflowOutputsInput +): Promise { + return resolveWorkflowOutputs.execute({ + principal: createCopilotApplicationPrincipal( + requireTrustedCopilotExecutionContext(context), + workflowDelegation + ), + input, + }) +} diff --git a/apps/sim/lib/copilot/application/table-commands.test.ts b/apps/sim/lib/copilot/application/table-commands.test.ts new file mode 100644 index 00000000000..900f8b76dfe --- /dev/null +++ b/apps/sim/lib/copilot/application/table-commands.test.ts @@ -0,0 +1,183 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + executeTableUseCase: vi.fn(), + useCases: { + addOutput: { operation: { id: 'tables.groups.update' } }, + createEnrichment: { operation: { id: 'tables.groups.create' } }, + createFromFile: { operation: { id: 'tables.imports.create_from_workspace_file' } }, + createWorkflowGroup: { operation: { id: 'tables.groups.create' } }, + deleteTables: { operation: { id: 'tables.delete' } }, + importFile: { operation: { id: 'tables.imports.workspace_file' } }, + replaceProjectedRows: { operation: { id: 'tables.rows.replace' } }, + updateWorkflowGroup: { operation: { id: 'tables.groups.update' } }, + }, +})) + +vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ + executeCopilotTableUseCase: mocks.executeTableUseCase, +})) +vi.mock('@/lib/table/application/groups', () => ({ + addWorkflowTableGroupOutput: mocks.useCases.addOutput, + createTableEnrichmentGroup: mocks.useCases.createEnrichment, + createWorkflowTableGroup: mocks.useCases.createWorkflowGroup, + updateWorkflowTableGroup: mocks.useCases.updateWorkflowGroup, +})) +vi.mock('@/lib/table/application/copilot-table-lifecycle', () => ({ + deleteCopilotTables: mocks.useCases.deleteTables, +})) +vi.mock('@/lib/table/application/rows', () => ({ + replaceProjectedWireRows: mocks.useCases.replaceProjectedRows, +})) +vi.mock('@/lib/table/application/workspace-file-imports', () => ({ + createTableFromWorkspaceFile: mocks.useCases.createFromFile, + importWorkspaceFileIntoTable: mocks.useCases.importFile, +})) + +import { + copilotAddWorkflowTableGroupOutputPolicy, + copilotCreateTableEnrichmentGroupPolicy, + copilotCreateTableFromWorkspaceFilePolicy, + copilotCreateWorkflowTableGroupPolicy, + copilotDeleteTablesPolicy, + copilotImportWorkspaceFileIntoTablePolicy, + copilotReplaceProjectedWireRowsPolicy, + copilotUpdateWorkflowTableGroupPolicy, + executeCopilotAddWorkflowTableGroupOutput, + executeCopilotCreateTableEnrichmentGroup, + executeCopilotCreateTableFromWorkspaceFile, + executeCopilotCreateWorkflowTableGroup, + executeCopilotDeleteTables, + executeCopilotImportWorkspaceFileIntoTable, + executeCopilotReplaceProjectedWireRows, + executeCopilotUpdateWorkflowTableGroup, +} from '@/lib/copilot/application/table-commands' + +const context = { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} +describe('fixed Copilot Table application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it.each([ + [ + 'replace projected rows', + executeCopilotReplaceProjectedWireRows, + mocks.useCases.replaceProjectedRows, + ], + [ + 'create workflow group', + executeCopilotCreateWorkflowTableGroup, + mocks.useCases.createWorkflowGroup, + ], + [ + 'update workflow group', + executeCopilotUpdateWorkflowTableGroup, + mocks.useCases.updateWorkflowGroup, + ], + ['add workflow output', executeCopilotAddWorkflowTableGroupOutput, mocks.useCases.addOutput], + [ + 'create enrichment group', + executeCopilotCreateTableEnrichmentGroup, + mocks.useCases.createEnrichment, + ], + [ + 'import a workspace file', + executeCopilotImportWorkspaceFileIntoTable, + mocks.useCases.importFile, + ], + ])( + 'dispatches %s to exactly one code-defined Table command', + async (_label, execute, useCase) => { + mocks.executeTableUseCase.mockResolvedValue({ ok: true }) + const input = { tableId: 'table-1', workspaceId: 'workspace-1' } + + await expect(execute(context, input as never)).resolves.toEqual({ ok: true }) + + expect(mocks.executeTableUseCase).toHaveBeenCalledWith(context, useCase, input, { + tableId: 'table-1', + }) + expect(mocks.executeTableUseCase).toHaveBeenCalledTimes(1) + } + ) + + it('uses a workspace-scoped Table principal for create-from-file', async () => { + mocks.executeTableUseCase.mockResolvedValue({ kind: 'empty' }) + const input = { workspaceId: 'workspace-1', fileReference: 'files/people.csv' } + + await executeCopilotCreateTableFromWorkspaceFile(context, input) + + expect(mocks.executeTableUseCase).toHaveBeenCalledWith( + context, + mocks.useCases.createFromFile, + input + ) + }) + + it('uses one workspace-scoped Table command for best-effort multi-table deletion', async () => { + const result = { + deleted: [{ id: 'table-1', name: 'People' }], + failed: ['table-2'], + } + mocks.executeTableUseCase.mockResolvedValue(result) + const input = { + workspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + assertNotAborted: vi.fn(), + } + + await expect(executeCopilotDeleteTables(context, input)).resolves.toBe(result) + + expect(mocks.executeTableUseCase).toHaveBeenCalledWith( + context, + mocks.useCases.deleteTables, + input + ) + expect(mocks.executeTableUseCase).toHaveBeenCalledTimes(1) + }) + + it('declares inherited request-rate admission and no direct provider cost for every command', () => { + const policies = [ + copilotReplaceProjectedWireRowsPolicy, + copilotCreateWorkflowTableGroupPolicy, + copilotDeleteTablesPolicy, + copilotUpdateWorkflowTableGroupPolicy, + copilotAddWorkflowTableGroupOutputPolicy, + copilotCreateTableEnrichmentGroupPolicy, + copilotCreateTableFromWorkspaceFilePolicy, + copilotImportWorkspaceFileIntoTablePolicy, + ] + + for (const policy of policies) { + expect(policy.rate.kind).toBe('inherited_copilot_request') + expect(policy.rate.reason).toBeTruthy() + expect(policy.cost.kind).toBe('none') + expect(policy.cost.reason).toBeTruthy() + } + }) + + it('rejects an untrusted context before application execution', async () => { + const error = new Error('trusted Copilot execution context required') + mocks.executeTableUseCase.mockImplementationOnce(() => { + throw error + }) + + expect(() => + executeCopilotReplaceProjectedWireRows(undefined, { + tableId: 'table-1', + sourceRows: [], + projectedRows: [], + }) + ).toThrow(error) + expect(mocks.executeTableUseCase).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/copilot/application/table-commands.ts b/apps/sim/lib/copilot/application/table-commands.ts new file mode 100644 index 00000000000..66f8ead69f4 --- /dev/null +++ b/apps/sim/lib/copilot/application/table-commands.ts @@ -0,0 +1,144 @@ +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' +import { + type DeleteCopilotTablesInput, + deleteCopilotTables, +} from '@/lib/table/application/copilot-table-lifecycle' +import { + type AddTableGroupOutputInput, + addWorkflowTableGroupOutput, + type CreateTableEnrichmentGroupInput, + type CreateWorkflowTableGroupInput, + createTableEnrichmentGroup, + createWorkflowTableGroup, + type UpdateWorkflowTableGroupInput, + updateWorkflowTableGroup, +} from '@/lib/table/application/groups' +import { + type ReplaceProjectedWireRowsInput, + replaceProjectedWireRows, +} from '@/lib/table/application/rows' +import { + type CreateTableFromWorkspaceFileInput, + createTableFromWorkspaceFile, + type ImportWorkspaceFileInput, + importWorkspaceFileIntoTable, +} from '@/lib/table/application/workspace-file-imports' + +const INHERITED_COPILOT_RATE_POLICY = { + kind: 'inherited_copilot_request', + reason: 'The authenticated Copilot request owns request-rate admission.', +} as const + +const NO_DIRECT_PROVIDER_COST_POLICY = { + kind: 'none', + reason: 'This command does not invoke a paid provider; table quota and storage limits apply.', +} as const + +export const copilotDeleteTablesPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotDeleteTables( + context: CopilotTableDelegationContext | undefined, + input: DeleteCopilotTablesInput +) { + return executeCopilotTableUseCase(context, deleteCopilotTables, input) +} + +export const copilotReplaceProjectedWireRowsPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotReplaceProjectedWireRows( + context: CopilotTableDelegationContext | undefined, + input: ReplaceProjectedWireRowsInput +) { + return executeCopilotTableUseCase(context, replaceProjectedWireRows, input, { + tableId: input.tableId, + }) +} + +export const copilotCreateWorkflowTableGroupPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotCreateWorkflowTableGroup( + context: CopilotTableDelegationContext | undefined, + input: CreateWorkflowTableGroupInput +) { + return executeCopilotTableUseCase(context, createWorkflowTableGroup, input, { + tableId: input.tableId, + }) +} + +export const copilotUpdateWorkflowTableGroupPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotUpdateWorkflowTableGroup( + context: CopilotTableDelegationContext | undefined, + input: UpdateWorkflowTableGroupInput +) { + return executeCopilotTableUseCase(context, updateWorkflowTableGroup, input, { + tableId: input.tableId, + }) +} + +export const copilotAddWorkflowTableGroupOutputPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotAddWorkflowTableGroupOutput( + context: CopilotTableDelegationContext | undefined, + input: AddTableGroupOutputInput +) { + return executeCopilotTableUseCase(context, addWorkflowTableGroupOutput, input, { + tableId: input.tableId, + }) +} + +export const copilotCreateTableEnrichmentGroupPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotCreateTableEnrichmentGroup( + context: CopilotTableDelegationContext | undefined, + input: CreateTableEnrichmentGroupInput +) { + return executeCopilotTableUseCase(context, createTableEnrichmentGroup, input, { + tableId: input.tableId, + }) +} + +export const copilotCreateTableFromWorkspaceFilePolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotCreateTableFromWorkspaceFile( + context: CopilotTableDelegationContext | undefined, + input: CreateTableFromWorkspaceFileInput +) { + return executeCopilotTableUseCase(context, createTableFromWorkspaceFile, input) +} + +export const copilotImportWorkspaceFileIntoTablePolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotImportWorkspaceFileIntoTable( + context: CopilotTableDelegationContext | undefined, + input: ImportWorkspaceFileInput +) { + return executeCopilotTableUseCase(context, importWorkspaceFileIntoTable, input, { + tableId: input.tableId, + }) +} diff --git a/apps/sim/lib/copilot/auth/table-delegation.test.ts b/apps/sim/lib/copilot/auth/table-delegation.test.ts deleted file mode 100644 index 33dc57ea01f..00000000000 --- a/apps/sim/lib/copilot/auth/table-delegation.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { resolveCopilotTablePrincipal } from '@/lib/copilot/auth/table-delegation' - -describe('Copilot table delegation', () => { - const context = { - userId: 'user-1', - workspaceId: 'workspace-1', - toolCallId: 'tool-call-1', - chatId: 'chat-1', - executionId: 'execution-1', - copilotToolExecution: true, - } as const - - it('binds the trusted workspace, subject, tool call, and table scope', () => { - expect(resolveCopilotTablePrincipal(context, 'table-1')).toMatchObject({ - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'copilot-tool:tool-call-1', - audience: 'sim:tables', - resourceScope: { - tableId: 'table-1', - chatId: 'chat-1', - executionId: 'execution-1', - }, - }) - }) - - it('rejects untrusted or incomplete contexts', () => { - expect(() => - resolveCopilotTablePrincipal({ ...context, copilotToolExecution: false }, 'table-1') - ).toThrow('trusted Copilot execution context') - expect(() => - resolveCopilotTablePrincipal({ ...context, workspaceId: undefined }, 'table-1') - ).toThrow('workspace ID') - expect(() => - resolveCopilotTablePrincipal({ ...context, toolCallId: undefined }, 'table-1') - ).toThrow('tool call ID') - }) -}) diff --git a/apps/sim/lib/copilot/auth/table-delegation.ts b/apps/sim/lib/copilot/auth/table-delegation.ts index 83a055b99c1..7e516616fcb 100644 --- a/apps/sim/lib/copilot/auth/table-delegation.ts +++ b/apps/sim/lib/copilot/auth/table-delegation.ts @@ -1,28 +1,8 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' -import { - COPILOT_APPLICATION_DELEGATION_TTL_MS, - type CopilotExecutionContext, - createCopilotApplicationPrincipal, - requireTrustedCopilotExecutionContext, -} from '@/lib/copilot/auth/application-delegation' -import { tableDelegationPolicy } from '@/lib/table/application/authorization' +import type { CopilotExecutionContext } from '@/lib/copilot/auth/application-delegation' export type CopilotTableDelegationContext = CopilotExecutionContext -/** Normalizes trusted Copilot execution context into the shared table principal. */ -export function resolveCopilotTablePrincipal( - context: CopilotTableDelegationContext | undefined, - tableId?: string -): DelegatedPrincipal { - return createCopilotApplicationPrincipal(requireTrustedCopilotExecutionContext(context), { - audience: tableDelegationPolicy.audience, - ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, - createDelegationId: (trustedContext) => `copilot-tool:${trustedContext.toolCallId}`, - resourceScope: tableId ? { tableId } : undefined, - }) -} - export function messageForCopilotTableError( error: unknown, fallback = 'Table operation failed' diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 9d0eb39c9a3..42d15ce282c 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -6,26 +6,25 @@ import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockReadTable, mockReplaceTableRows, mockSpanAddEvent } = vi.hoisted(() => ({ - mockReadTable: vi.fn(), - mockReplaceTableRows: vi.fn(), - mockSpanAddEvent: vi.fn(), +const mocks = vi.hoisted(() => ({ + executeReplace: vi.fn(), + spanAddEvent: vi.fn(), })) -vi.mock('@/lib/table/application/tables', () => ({ - readTableUseCase: { execute: mockReadTable }, +vi.mock('@/lib/copilot/application/table-commands', () => ({ + executeCopilotReplaceProjectedWireRows: mocks.executeReplace, })) - -vi.mock('@/lib/table/application/rows', () => ({ - replaceTableRows: { execute: mockReplaceTableRows }, -})) - vi.mock('@/lib/copilot/request/otel', () => ({ withCopilotSpan: ( _name: string, _attrs: Record | undefined, - fn: (span: unknown) => Promise - ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: mockSpanAddEvent }), + run: (span: unknown) => Promise + ) => + run({ + setAttribute: vi.fn(), + setAttributes: vi.fn(), + addEvent: mocks.spanAddEvent, + }), })) import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' @@ -35,45 +34,34 @@ import { maybeWriteReadCsvToTable, } from '@/lib/copilot/request/tools/tables' import type { ExecutionContext } from '@/lib/copilot/request/types' +import { ProjectedWireRowsValidationError } from '@/lib/table/application/rows' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} + const tableLogger = vi.mocked(loggerMock.createLogger).mock.results[ vi .mocked(loggerMock.createLogger) .mock.calls.findIndex(([name]) => name === 'CopilotToolResultTables') ]?.value -function buildTable(overrides: Partial = {}): TableDefinition { - return { - id: 'tbl_1', - name: 'People', - description: null, - schema: { - columns: [ - { id: 'col_name', name: 'name', type: 'string' }, - { id: 'col_age', name: 'age', type: 'number' }, - { id: 'col_status', name: 'status', type: 'string' }, - { id: 'col_active', name: 'active', type: 'boolean' }, - { id: 'col_metadata', name: 'metadata', type: 'json' }, - ], - }, - metadata: null, - rowCount: 0, - maxRows: 100, - workspaceId: 'workspace-1', - createdBy: 'user-1', - locks: { schemaLocked: false, insertLocked: false, updateLocked: false, deleteLocked: false }, - archivedAt: null, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - ...overrides, - } as TableDefinition -} - function buildContext(overrides: Partial = {}): ExecutionContext { return { userId: 'user-1', - workflowId: 'wf-1', + workflowId: 'workflow-1', workspaceId: 'workspace-1', userPermission: 'write', copilotToolExecution: true, @@ -83,424 +71,244 @@ function buildContext(overrides: Partial = {}): ExecutionConte } } -describe('maybeWriteOutputToTable', () => { +describe('automatic Copilot tool-output table persistence', () => { beforeEach(() => { vi.clearAllMocks() - mockReadTable.mockResolvedValue({ table: buildTable(), folderPath: '/' }) - mockReplaceTableRows.mockImplementation(async ({ input }: { input: { rows: unknown[] } }) => ({ - deletedCount: 0, - insertedCount: input.rows.length, - })) + mocks.executeReplace.mockImplementation( + async (_context: ExecutionContext, input: { sourceRows: unknown[] }) => ({ + table, + deletedCount: 0, + insertedCount: input.sourceRows.length, + }) + ) }) - it('rejects a table from another workspace without touching it', async () => { - mockReadTable.mockRejectedValue(new Error('Table not found')) + it('maps tool rows into one authorized schema-locked replacement command', async () => { + const context = buildContext() + const rows = [ + { name: 'Ada', age: 30 }, + { name: 'Grace', age: 40 }, + ] const result = await maybeWriteOutputToTable( FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'Alice' }] } }, - buildContext() + { outputTable: 'table-1' }, + { success: true, output: { result: rows } }, + context ) expect(result).toEqual({ - success: false, - error: 'Failed to write to table: Table operation failed', - }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() - }) - - it('denies a read-only principal without touching the table', async () => { - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'Alice' }] } }, - buildContext({ userPermission: 'read' }) - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('requires write access') - expect(mockReadTable).not.toHaveBeenCalled() - expect(mockReplaceTableRows).not.toHaveBeenCalled() - }) - - it('replaces rows through the service with name keys remapped to column ids', async () => { - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { - success: true, - output: { - result: [ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 40 }, - ], - }, + success: true, + output: { + message: 'Wrote 2 rows to table table-1', + tableId: 'table-1', + rowCount: 2, }, - buildContext() - ) - - expect(result.success).toBe(true) - expect(mockReplaceTableRows).toHaveBeenCalledTimes(1) - const [{ input }] = mockReplaceTableRows.mock.calls[0] - expect(input).toMatchObject({ - tableId: 'tbl_1', + }) + expect(mocks.executeReplace).toHaveBeenCalledTimes(1) + expect(mocks.executeReplace).toHaveBeenCalledWith(context, { + tableId: 'table-1', assertedWorkspaceId: 'workspace-1', - rows: [ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 40 }, - ], + sourceRows: rows, + projectedRows: rows, }) }) - it('projects activated secrets before persistence without rewriting sibling literals', async () => { - const parentRegistry = new ResolvedSecretTraceRegistry([ - { - name: 'OUTPUT_SECRET', - plaintext: 'secret-value', - encryptedValue: 'encrypted-output-secret', - }, - { - name: 'UNRELATED', - plaintext: 'true', - encryptedValue: 'encrypted-unrelated', - }, + it('projects active secrets before handing rows to the application command', async () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'OUTPUT_SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }, ]) - parentRegistry.recordResolved('UNRELATED', 'true') - const toolRegistry = parentRegistry.forkForToolInput({ code: 'return {{OUTPUT_SECRET}}' }) - toolRegistry.recordResolved('OUTPUT_SECRET', 'secret-value') - const runtimeRows = [{ name: 'secret-value', age: '123', status: 'true' }] + registry.recordResolved('OUTPUT_SECRET', 'secret-value') + const runtimeRows = [{ name: 'secret-value', status: 'literal' }] const result = await maybeWriteOutputToTable( FunctionExecute.id, - { outputTable: 'tbl_1' }, + { outputTable: 'table-1' }, { success: true, output: { result: runtimeRows } }, - buildContext({ resolvedSecretTraceRegistry: toolRegistry }) + buildContext({ resolvedSecretTraceRegistry: registry }) ) expect(result.success).toBe(true) - const persistedRows = mockReplaceTableRows.mock.calls[0][0].input.rows - expect(persistedRows).toEqual([{ name: '{{OUTPUT_SECRET}}', age: '123', status: 'true' }]) - expect(runtimeRows).toEqual([{ name: 'secret-value', age: '123', status: 'true' }]) - - const modelFacing = projectToolResultForCopilot( - { success: true, output: { data: { rows: persistedRows } } }, - toolRegistry + expect(mocks.executeReplace).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + sourceRows: runtimeRows, + projectedRows: [{ name: '{{OUTPUT_SECRET}}', status: 'literal' }], + }) ) - expect(modelFacing.output).toEqual({ - data: { - rows: [{ name: '{{OUTPUT_SECRET}}', age: '123', status: 'true' }], - }, - }) + expect(runtimeRows).toEqual([{ name: 'secret-value', status: 'literal' }]) + const projectedRows = mocks.executeReplace.mock.calls[0][1].projectedRows const laterRead = projectToolResultForCopilot( - { success: true, output: { data: { rows: persistedRows } } }, + { success: true, output: { data: { rows: projectedRows } } }, new ResolvedSecretTraceRegistry() ) - expect(laterRead.output).toEqual({ data: { rows: persistedRows } }) + expect(laterRead.output).toEqual({ data: { rows: projectedRows } }) }) - it('does not write when table persistence provenance is incomplete', async () => { + it('rejects unavailable secret provenance before the application command', async () => { const registry = new ResolvedSecretTraceRegistry() registry.markIncomplete() - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'unknown' }] } }, - buildContext({ resolvedSecretTraceRegistry: registry }) - ) - - expect(result).toEqual({ + await expect( + maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'table-1' }, + { success: true, output: { result: [{ name: 'unknown' }] } }, + buildContext({ resolvedSecretTraceRegistry: registry }) + ) + ).resolves.toEqual({ success: false, error: 'Tool output could not be persisted safely because secret provenance was unavailable.', }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() - }) - - it('preserves legacy table writes when execution provenance is unavailable', async () => { - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'unknown' }] } }, - buildContext({ resolvedSecretTraceRegistry: undefined }) - ) - - expect(result.success).toBe(true) - expect(mockReplaceTableRows).toHaveBeenCalledWith( - expect.objectContaining({ input: expect.objectContaining({ rows: [{ name: 'unknown' }] }) }) - ) - }) - - it('fails fast when no row keys match the table columns', async () => { - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ wrong: 1 }, { keys: 2 }] } }, - buildContext() - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Row 1 has no keys matching columns') - expect(mockReplaceTableRows).not.toHaveBeenCalled() - }) - - it('fails fast when only some rows match instead of writing empty rows', async () => { - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'Alice' }, { wrong: 'x' }] } }, - buildContext() - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Row 2 has no keys matching columns') - expect(mockReplaceTableRows).not.toHaveBeenCalled() + expect(mocks.executeReplace).not.toHaveBeenCalled() }) - it('surfaces service validation failures as tool errors', async () => { - mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required')) - - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ age: 30 }] } }, - buildContext() + it('preserves typed application validation for a correctable tool error', async () => { + mocks.executeReplace.mockRejectedValueOnce( + new ProjectedWireRowsValidationError('Row 1 has no keys matching table columns') ) - expect(result.success).toBe(false) - expect(result.error).toContain('Table operation failed') - }) - - it('fails fast when authoritative inserted count differs from the requested rows', async () => { - mockReplaceTableRows.mockResolvedValue({ deletedCount: 1, insertedCount: 1 }) - const result = await maybeWriteOutputToTable( FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'Alice' }, { name: 'Bob' }] } }, + { outputTable: 'table-1' }, + { success: true, output: { result: [{ wrong: true }] } }, buildContext() ) - expect(result.success).toBe(false) - expect(result.error).toContain('Table operation failed') + expect(result).toEqual({ + success: false, + error: 'Row 1 has no keys matching table columns', + }) }) - it('keeps raw errors for terminal projection but projects application logs and OTel events', async () => { + it('conceals unknown application failures in results, logs, and trace events', async () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, + { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret' }, ]) registry.recordResolved('SECRET', 'secret-value') - mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) + mocks.executeReplace.mockRejectedValueOnce(new Error('database duplicate: secret-value')) const result = await maybeWriteOutputToTable( FunctionExecute.id, - { outputTable: 'tbl_1' }, + { outputTable: 'table-1' }, { success: true, output: { result: [{ name: 'secret-value' }] } }, buildContext({ resolvedSecretTraceRegistry: registry }) ) - expect(result.error).not.toContain('secret-value') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('Table operation failed') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('Table operation failed') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') - }) -}) - -describe('maybeWriteReadCsvToTable', () => { - beforeEach(() => { - vi.clearAllMocks() - mockReadTable.mockResolvedValue({ table: buildTable(), folderPath: '/' }) - mockReplaceTableRows.mockImplementation(async ({ input }: { input: { rows: unknown[] } }) => ({ - deletedCount: 0, - insertedCount: input.rows.length, - })) - }) - - it('rejects a table from another workspace without touching it', async () => { - mockReadTable.mockRejectedValue(new Error('Table not found')) - - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,age\nAlice,30' } }, - buildContext() - ) - expect(result).toEqual({ success: false, - error: 'Failed to import into table: Table operation failed', + error: 'Failed to write to table: Table operation failed', }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() + expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('Table operation failed') + expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') + expect(JSON.stringify(mocks.spanAddEvent.mock.calls)).toContain('Table operation failed') + expect(JSON.stringify(mocks.spanAddEvent.mock.calls)).not.toContain('secret-value') }) - it('denies a read-only principal without touching the table', async () => { - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,age\nAlice,30' } }, + it('rejects read-only Copilot execution before any application command', async () => { + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'table-1' }, + { success: true, output: { result: [{ name: 'Ada' }] } }, buildContext({ userPermission: 'read' }) ) expect(result.success).toBe(false) expect(result.error).toContain('requires write access') - expect(mockReadTable).not.toHaveBeenCalled() - expect(mockReplaceTableRows).not.toHaveBeenCalled() + expect(mocks.executeReplace).not.toHaveBeenCalled() }) - it('imports CSV content through the service with id-keyed rows', async () => { - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,age\nAlice,30\nBob,40' } }, + it('fails closed when the authoritative inserted count is inconsistent', async () => { + mocks.executeReplace.mockResolvedValueOnce({ table, deletedCount: 1, insertedCount: 1 }) + + const result = await maybeWriteOutputToTable( + FunctionExecute.id, + { outputTable: 'table-1' }, + { success: true, output: { result: [{ name: 'Ada' }, { name: 'Grace' }] } }, buildContext() ) - expect(result.success).toBe(true) - const [{ input }] = mockReplaceTableRows.mock.calls[0] - expect(input.rows).toEqual([ - { name: 'Alice', age: '30' }, - { name: 'Bob', age: '40' }, - ]) + expect(result).toEqual({ + success: false, + error: 'Failed to write to table: Table operation failed', + }) }) +}) - it('projects active secret literals into string-compatible CSV columns', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' }, - { name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' }, - ]) - registry.recordResolved('NUMBER', '123') - registry.recordResolved('BOOLEAN', 'true') - - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,status\n123,true' } }, - buildContext({ resolvedSecretTraceRegistry: registry }) - ) - - expect(result.success).toBe(true) - expect(mockReplaceTableRows).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - rows: [ - { - name: '{{NUMBER}}', - status: '{{BOOLEAN}}', - }, - ], - }), +describe('automatic Copilot file-read table persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.executeReplace.mockImplementation( + async (_context: ExecutionContext, input: { sourceRows: unknown[] }) => ({ + table, + deletedCount: 1, + insertedCount: input.sourceRows.length, }) ) }) - it('rejects active secret literals in number and boolean columns before mutation', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'NUMBER', plaintext: '123', encryptedValue: 'encrypted-number' }, - { name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'encrypted-boolean' }, - ]) - registry.recordResolved('NUMBER', '123') - registry.recordResolved('BOOLEAN', 'true') - + it('keeps CSV parsing and presentation in the adapter and performs one application command', async () => { + const context = buildContext() const result = await maybeWriteReadCsvToTable( ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,age,active\nAlice,123,true' } }, - buildContext({ resolvedSecretTraceRegistry: registry }) + { outputTable: 'table-1', path: 'files/people.csv' }, + { success: true, output: { content: 'name,age\nAda,30\nGrace,40' } }, + context ) expect(result).toEqual({ - success: false, - error: - 'Tool output could not be persisted safely because a resolved secret is incompatible with the target column type.', + success: true, + output: { + message: 'Imported 2 rows from "files/people.csv" into table "People"', + tableId: 'table-1', + tableName: 'People', + rowCount: 2, + }, + }) + expect(mocks.executeReplace).toHaveBeenCalledTimes(1) + expect(mocks.executeReplace).toHaveBeenCalledWith(context, { + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + sourceRows: [ + { name: 'Ada', age: '30' }, + { name: 'Grace', age: '40' }, + ], + projectedRows: [ + { name: 'Ada', age: '30' }, + { name: 'Grace', age: '40' }, + ], }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() - expect(JSON.stringify(result)).not.toContain('123') - expect(JSON.stringify(result)).not.toContain('true') }) - it('does not import CSV rows when persistence provenance is incomplete', async () => { - const registry = new ResolvedSecretTraceRegistry() - registry.markIncomplete() - + it('keeps JSON shape validation in the adapter', async () => { const result = await maybeWriteReadCsvToTable( ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name\nAlice' } }, - buildContext({ resolvedSecretTraceRegistry: registry }) + { outputTable: 'table-1', path: 'files/people.json' }, + { success: true, output: { content: '{"name":"Ada"}' } }, + buildContext() ) expect(result).toEqual({ success: false, - error: 'Tool output could not be persisted safely because secret provenance was unavailable.', + error: 'JSON file must contain an array of objects for table import', }) - expect(mockReplaceTableRows).not.toHaveBeenCalled() - }) - - it('preserves legacy CSV imports when execution provenance is unavailable', async () => { - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name,age,active\nlegacy-value,123,true' } }, - buildContext({ resolvedSecretTraceRegistry: undefined }) - ) - - expect(result.success).toBe(true) - expect(mockReplaceTableRows).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - rows: [{ name: 'legacy-value', age: '123', active: 'true' }], - }), - }) - ) - }) - - it('fails fast when the file headers match no table columns', async () => { - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'wrong,headers\n1,2' } }, - buildContext() - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Row 1 has no keys matching columns') - expect(mockReplaceTableRows).not.toHaveBeenCalled() + expect(mocks.executeReplace).not.toHaveBeenCalled() }) - it('surfaces service validation failures as tool errors', async () => { - mockReplaceTableRows.mockRejectedValue(new Error('Row 1: name is required')) + it('preserves safe unknown-error projection for CSV persistence', async () => { + mocks.executeReplace.mockRejectedValueOnce(new Error('database unavailable')) const result = await maybeWriteReadCsvToTable( ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'age\n30' } }, + { outputTable: 'table-1', path: 'files/people.csv' }, + { success: true, output: { content: 'name\nAda' } }, buildContext() ) - expect(result.success).toBe(false) - expect(result.error).toContain('Table operation failed') - }) - - it('projects active secret literals in CSV-import log and OTel errors', async () => { - const registry = new ResolvedSecretTraceRegistry([ - { name: 'SECRET', plaintext: 'secret-value', encryptedValue: 'encrypted-secret-value' }, - ]) - registry.recordResolved('SECRET', 'secret-value') - mockReplaceTableRows.mockRejectedValue(new Error('Duplicate value "secret-value"')) - - const result = await maybeWriteReadCsvToTable( - ReadTool.id, - { outputTable: 'tbl_1', path: 'files/people.csv' }, - { success: true, output: { content: 'name\nsecret-value' } }, - buildContext({ resolvedSecretTraceRegistry: registry }) - ) - - expect(result.error).not.toContain('secret-value') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).toContain('Table operation failed') - expect(JSON.stringify(tableLogger?.warn.mock.calls)).not.toContain('secret-value') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).toContain('Table operation failed') - expect(JSON.stringify(mockSpanAddEvent.mock.calls)).not.toContain('secret-value') + expect(result).toEqual({ + success: false, + error: 'Failed to import into table: Table operation failed', + }) }) }) diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index edf6dcb33af..5e38eaba239 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -1,11 +1,7 @@ -import { isDeepStrictEqual } from 'node:util' import { createLogger } from '@sim/logger' -import { isPlainRecord } from '@sim/utils/object' import { parse as csvParse } from 'csv-parse/sync' -import { - messageForCopilotTableError, - resolveCopilotTablePrincipal, -} from '@/lib/copilot/auth/table-delegation' +import { executeCopilotReplaceProjectedWireRows } from '@/lib/copilot/application/table-commands' +import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -18,40 +14,17 @@ import { projectToolOutputForPersistence, } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import type { RowData, TableDefinition } from '@/lib/table' -import { replaceTableRows } from '@/lib/table/application/rows' -import { readTableUseCase } from '@/lib/table/application/tables' -import { columnTypeOf } from '@/lib/table/column-types' -import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' +import type { TableDefinition } from '@/lib/table' +import { ProjectedWireRowsValidationError } from '@/lib/table/application/rows' const logger = createLogger('CopilotToolResultTables') const MAX_OUTPUT_TABLE_ROWS = 10_000 -const TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR = - 'Tool output could not be persisted safely because a resolved secret is incompatible with the target column type.' - -function hasUnsupportedProjectedCell( - table: TableDefinition, - sourceRows: Array>, - projectedRows: Array> -): boolean { - const columnsByName = new Map(table.schema.columns.map((column) => [column.name, column])) - for (let rowIndex = 0; rowIndex < projectedRows.length; rowIndex += 1) { - for (const [name, projectedValue] of Object.entries(projectedRows[rowIndex])) { - const column = columnsByName.get(name) - if (!column || isDeepStrictEqual(sourceRows[rowIndex]?.[name], projectedValue)) continue - const type = columnTypeOf(column).id - if (type !== 'string' && type !== 'json') return true - } - } - return false -} - /** * Replaces a table's rows with wire rows keyed by column name. Translates the - * keys to stable column ids (unknown keys are dropped, matching every other - * name-translating boundary) and delegates to `replaceTableRows`, which owns - * locking, validation, plan row limits, batching, and rowCount maintenance. + * projected values through one authorized application command. That command + * validates and translates against the table schema it holds under the schema + * lock before performing the atomic replacement. */ async function replaceTableRowsFromWire( tableId: string, @@ -61,53 +34,34 @@ async function replaceTableRowsFromWire( | { success: false; error: string } | { success: true; table: TableDefinition; insertedCount: number; deletedCount: number } > { - const principal = resolveCopilotTablePrincipal(context, tableId) - const { table } = await readTableUseCase.execute({ - principal, - input: { tableId, workspaceId: principal.workspaceId }, - }) + const workspaceId = context.workspaceId + if (!workspaceId) throw new Error('Table persistence requires a workspace ID') const persistenceProjection = context.resolvedSecretTraceRegistry ? projectToolOutputForPersistence(rows, context.resolvedSecretTraceRegistry) : { safe: true as const, value: rows } if (!persistenceProjection.safe) { return { success: false, error: persistenceProjection.error } } - if ( - !Array.isArray(persistenceProjection.value) || - !persistenceProjection.value.every(isPlainRecord) - ) { - return { success: false, error: 'Table rows could not be persisted safely' } - } - if (hasUnsupportedProjectedCell(table, rows, persistenceProjection.value)) { - return { success: false, error: TABLE_SECRET_PROJECTION_UNSUPPORTED_ERROR } - } - - const projectedRows = persistenceProjection.value.map((row) => row as RowData) - const columnNames = new Set(table.schema.columns.map((column) => column.name)) - const emptyIndex = projectedRows.findIndex( - (row) => !Object.keys(row).some((name) => columnNames.has(name)) - ) - if (emptyIndex !== -1) { - return { - success: false, - error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`, + let replacement: Awaited> + try { + replacement = await executeCopilotReplaceProjectedWireRows(context, { + tableId, + assertedWorkspaceId: workspaceId, + sourceRows: rows, + projectedRows: persistenceProjection.value, + }) + } catch (error) { + if (error instanceof ProjectedWireRowsValidationError) { + return { success: false, error: error.message } } + throw error } - const replacement = await replaceTableRows.execute({ - principal, - input: { - tableId: table.id, - assertedWorkspaceId: principal.workspaceId, - rows: projectedRows, - secretProvenance: projectedRows.map(createExactEmptyTableRowSecretProvenance), - }, - }) - if (replacement.insertedCount !== projectedRows.length) { + if (replacement.insertedCount !== rows.length) { throw new Error('Table row replacement inserted an unexpected row count') } return { success: true, - table, + table: replacement.table, insertedCount: replacement.insertedCount, deletedCount: replacement.deletedCount, } diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 5c1fbcc5a22..83c263815a2 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ -import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' import type { TableDefinition } from '@/lib/table' const { @@ -23,11 +23,16 @@ const { mockReleaseJobClaim, mockQueryRows, mockDeleteRowsByFilter, + mockDeleteColumns, mockUpdateRowsByFilter, mockRunTableImport, mockRunTableDelete, mockRunTableUpdate, - mockExecuteCopilotTableUseCase, + mockExecuteCopilotFileUseCase, + mockExecuteCopilotWorkflowUseCase, + mockLoadWorkspaceFileContext, + mockLoadTableRowSecretProvenance, + mockResolveWorkflowContext, fakeEnrichment, } = vi.hoisted(() => ({ mockUpdateColumnType: vi.fn(), @@ -46,11 +51,16 @@ const { mockReleaseJobClaim: vi.fn(), mockQueryRows: vi.fn(), mockDeleteRowsByFilter: vi.fn(), + mockDeleteColumns: vi.fn(), mockUpdateRowsByFilter: vi.fn(), mockRunTableImport: vi.fn(), mockRunTableDelete: vi.fn(), mockRunTableUpdate: vi.fn(), - mockExecuteCopilotTableUseCase: vi.fn(), + mockExecuteCopilotFileUseCase: vi.fn(), + mockExecuteCopilotWorkflowUseCase: vi.fn(), + mockLoadWorkspaceFileContext: vi.fn(), + mockLoadTableRowSecretProvenance: vi.fn(), + mockResolveWorkflowContext: vi.fn(), fakeEnrichment: { id: 'work-email', name: 'Work Email', @@ -71,11 +81,12 @@ vi.mock('@sim/utils/id', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, + resolveWorkspaceFileReference: async (workspaceId: string, reference: string) => { + const file = await mockResolveWorkspaceFileReference(workspaceId, reference) + return file ? { ...file, workspaceId: file.workspaceId ?? workspaceId } : null + }, fetchWorkspaceFileBuffer: mockDownloadWorkspaceFile, -})) -vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ - resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, + loadActiveWorkspaceFileContext: mockLoadWorkspaceFileContext, })) vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ readWorkspaceFileContent: { @@ -96,23 +107,64 @@ vi.mock('@/lib/copilot/auth/file-delegation', () => ({ })) vi.mock('@/lib/copilot/auth/table-delegation', () => ({ - messageForCopilotTableError: (error: unknown) => getErrorMessage(error, 'Table operation failed'), - resolveCopilotTablePrincipal: (_context: unknown, tableId?: string) => ({ - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'test-tool', - audience: 'sim:tables', - issuedAt: new Date(0), - expiresAt: new Date(Date.now() + 60_000), - resourceScope: tableId ? { tableId } : undefined, + messageForCopilotTableError: (error: unknown) => { + const classified = error as { code?: string; message?: string } + return classified.code && classified.code !== 'internal' + ? (classified.message ?? 'Table operation failed') + : 'Table operation failed' + }, +})) + +vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ + executeCopilotFileUseCase: mockExecuteCopilotFileUseCase, +})) + +vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ + executeCopilotResolveWorkflowOutputs: mockExecuteCopilotWorkflowUseCase, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: vi.fn().mockResolvedValue('write'), +})) + +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: async (input: { tableId: string; assertedWorkspaceId?: string }) => { + const table = await mockGetTableById(input.tableId) + if (!table || (input.assertedWorkspaceId && table.workspaceId !== input.assertedWorkspaceId)) { + throw Object.assign(new Error('Table not found'), { code: 'not_found' }) + } + if (table.archivedAt) { + throw Object.assign(new Error('Table is archived'), { code: 'conflict' }) + } + return { + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + } + }, + resolveTableWorkspaceContext: async (workspaceId: string) => ({ + workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', }), })) -vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ - admitCopilotTableOperation: vi.fn(), - executeCopilotTableUseCase: mockExecuteCopilotTableUseCase, +vi.mock('@/lib/table/application/folder-paths', () => ({ + resolveTableFolderPath: async () => ({ + folderId: null, + index: { idByPath: new Map(), pathById: new Map() }, + }), + tableFolderPathForId: () => '/', })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ @@ -142,7 +194,7 @@ vi.mock('@/lib/table/workflow-groups/service', () => ({ vi.mock('@/lib/table/columns/service', () => ({ addTableColumn: vi.fn(), deleteColumn: vi.fn(), - deleteColumns: vi.fn(), + deleteColumns: mockDeleteColumns, renameColumn: vi.fn(), updateColumnConstraints: vi.fn(), updateColumnType: mockUpdateColumnType, @@ -163,6 +215,16 @@ vi.mock('@/lib/table/rows/service', () => ({ updateRowsByFilter: mockUpdateRowsByFilter, })) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: (data: Record) => ({ + complete: true, + columns: Object.fromEntries( + Object.keys(data).map((columnId) => [columnId, { version: 1, complete: true, entries: [] }]) + ), + }), + loadTableRowSecretProvenance: mockLoadTableRowSecretProvenance, +})) + vi.mock('@/lib/table/jobs/service', () => ({ markTableJobRunningInWorkspace: mockMarkTableJobRunning, releaseJobClaimInWorkspace: mockReleaseJobClaim, @@ -186,67 +248,74 @@ vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetWorkspaceTableLimits, })) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mockResolveWorkflowContext, +})) + +vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ + loadResolvedWorkflowOutputs: async () => mockExecuteCopilotWorkflowUseCase(), + resolveWorkflowOutputs: { operation: { id: 'workflows.read' } }, +})) + import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' -import { decodeCursor, encodeCursor } from '@/lib/table/rows/cursor' +import { encodeCursor } from '@/lib/table/rows/cursor' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' beforeEach(() => { - mockExecuteCopilotTableUseCase.mockImplementation( - async ( - _context: unknown, - useCase: { operation: { id: string } }, - input: Record - ) => { - const table = await mockGetTableById(input.tableId) - switch (useCase.operation.id) { - case 'tables.create': { - const limits = await mockGetWorkspaceTableLimits(input.workspaceId) - const created = await mockCreateTable({ ...input, ...limits }) - return { table: created } - } - case 'tables.rows.query': { - if (!table) throw new Error('Table not found') - if (input.cursor && Array.isArray(input.sort) && input.sort.length > 0) { - throw new Error('Cursor is not valid for a sorted query') - } - const cursor = input.cursor ? decodeCursor(String(input.cursor)) : undefined - const result = await mockQueryRows(table, { - predicate: input.predicate, - sort: input.sort, - limit: input.limit, - after: cursor?.after, - offset: cursor?.offset, - includeTotal: input.includeTotal, - withExecutions: false, - }) - return { table, ...result } - } - case 'tables.columns.update': { - if (!table) throw new Error('Table not found') - const updates = input.updates as Record - const column = table.schema.columns.find( - (candidate) => candidate.name === input.columnName - ) - const next = - updates.type !== undefined && updates.type !== column?.type - ? await mockUpdateColumnType({ - tableId: input.tableId, - columnName: input.columnName, - newType: updates.type, - }) - : await mockUpdateColumnOptions({ - tableId: input.tableId, - columnName: input.columnName, - options: Array.isArray(updates.options) - ? updates.options.map((option) => - typeof option === 'string' ? { name: option } : option - ) - : column?.options, - multiple: updates.multiple, - }) - return { table: next, changed: true } - } - default: - throw new Error(`Unexpected application operation ${useCase.operation.id}`) + mockLoadWorkspaceFileContext.mockResolvedValue({ workspaceId: 'workspace-1' }) + mockLoadTableRowSecretProvenance.mockResolvedValue({ + version: 1, + complete: true, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }) + mockResolveWorkflowContext.mockImplementation( + async ({ + workflowId, + assertedWorkspaceId, + }: { + workflowId: string + assertedWorkspaceId: string + }) => ({ + workflowId, + workspaceId: assertedWorkspaceId, + }) + ) + mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ + workflowId: 'workflow-1', + outputs: [ + { + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + path: 'content', + leafType: 'string', + }, + ], + executionOrderByBlockId: { 'block-1': 1 }, + }) + mockExecuteCopilotFileUseCase.mockImplementation( + async (_context: unknown, _useCase: unknown, input: Record) => { + const workspaceId = String(input.workspaceId) + const reference = String(input.reference) + const file = await mockResolveWorkspaceFileReference(workspaceId, reference) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const provenance = await mockGetBoundWorkspaceFileSecretProvenance(workspaceId, { + fileId: file.id, + key: file.key, + context: 'workspace', + }) + if (provenance.status !== 'exact' || provenance.entries.length > 0) { + throw new OrchestrationError( + 'validation', + `Cannot import "${reference}": the file cannot be verified as free of resolved secrets.` + ) + } + return { + file: { ...file, workspaceId }, + ...(input.maxBytes === undefined + ? {} + : { content: await mockDownloadWorkspaceFile(file, { maxBytes: input.maxBytes }) }), } } ) @@ -275,6 +344,15 @@ function buildTable(overrides: Partial = {}): TableDefinition { } } +function buildToolContext() { + return { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + } as const +} + /** Lets a runDetached microtask chain run before asserting on the work it dispatched. */ async function flushDetached(): Promise { await Promise.resolve() @@ -308,7 +386,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -330,7 +408,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'replace' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -373,7 +451,7 @@ describe('userTableServerTool.import_file', () => { mapping: { 'Full Name': 'name', Years: 'age' }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -390,7 +468,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'merge' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(result.message).toMatch(/Invalid mode/) @@ -404,7 +482,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(result.message).toMatch(/archived/i) @@ -417,7 +495,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(result.message).toMatch(/not found/i) @@ -431,7 +509,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(result.message).toMatch(/missing required columns/i) @@ -441,7 +519,7 @@ describe('userTableServerTool.import_file', () => { it('claims and releases the table job slot around an inline import', async () => { const result = await userTableServerTool.execute( { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -462,7 +540,7 @@ describe('userTableServerTool.import_file', () => { mockMarkTableJobRunning.mockResolvedValueOnce(false) const result = await userTableServerTool.execute( { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -482,7 +560,7 @@ describe('userTableServerTool.import_file', () => { const result = await userTableServerTool.execute( { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'replace' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -516,7 +594,7 @@ describe('userTableServerTool.import_file', () => { const result = await userTableServerTool.execute( { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -534,7 +612,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'uploads/people.csv' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -550,7 +628,7 @@ describe('userTableServerTool.import_file', () => { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'files/typo.csv' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -559,6 +637,7 @@ describe('userTableServerTool.import_file', () => { it('rejects a background import while another job holds the table slot', async () => { mockResolveWorkspaceFileReference.mockResolvedValueOnce({ + id: 'file-1', name: 'big.csv', type: 'text/csv', key: 'workspace/workspace-1/big.csv', @@ -568,7 +647,7 @@ describe('userTableServerTool.import_file', () => { const result = await userTableServerTool.execute( { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -601,7 +680,7 @@ describe('userTableServerTool.create_from_file', () => { it('stamps the workspace plan limits on the created table', async () => { const result = await userTableServerTool.execute( { operation: 'create_from_file', args: { fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -617,7 +696,7 @@ describe('userTableServerTool.create_from_file', () => { const result = await userTableServerTool.execute( { operation: 'create_from_file', args: { fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -629,18 +708,18 @@ describe('userTableServerTool.create_from_file', () => { expect(mockDeleteTable).not.toHaveBeenCalled() }) - it('rolls back the created table and reports the reason when row insertion fails', async () => { + it('rolls back the created table and safely conceals unknown insertion failures', async () => { mockBatchInsertRows.mockRejectedValueOnce(new Error('Row 2: Column "email" must be unique')) const result = await userTableServerTool.execute( { operation: 'create_from_file', args: { fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(mockDeleteTable).toHaveBeenCalledWith('tbl_new', expect.any(String)) - expect(result.message).toMatch(/rolled back/i) - expect(result.message).toMatch(/must be unique/i) + expect(result.message).toBe('Operation failed: Table operation failed') + expect(result.message).not.toMatch(/must be unique/i) }) it('creates a placeholder table and dispatches a background import for large CSV files', async () => { @@ -654,7 +733,7 @@ describe('userTableServerTool.create_from_file', () => { const result = await userTableServerTool.execute( { operation: 'create_from_file', args: { fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -683,7 +762,7 @@ describe('userTableServerTool.create_from_file', () => { const result = await userTableServerTool.execute( { operation: 'create_from_file', args: { fileId: 'file-1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -709,7 +788,7 @@ describe('userTableServerTool.create', () => { schema: { columns: [{ name: 'name', type: 'string', required: true }] }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -719,6 +798,96 @@ describe('userTableServerTool.create', () => { }) }) +describe('userTableServerTool.delete_column', () => { + it('presents the authoritative canonical deletion for aliases and duplicates', async () => { + const current = buildTable({ + schema: { + columns: [ + { id: 'column-name', name: 'name', type: 'string' }, + { id: 'column-age', name: 'age', type: 'number' }, + ], + }, + }) + mockGetTableById.mockResolvedValue(current) + mockDeleteColumns.mockResolvedValue({ + ...current, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, + }) + + const result = await userTableServerTool.execute( + { + operation: 'delete_column', + args: { + tableId: 'tbl_1', + columnNames: ['age', 'column-age', 'AGE'], + }, + }, + buildToolContext() + ) + + expect(result.success).toBe(true) + expect(result.message).toBe('Deleted 1 column: age') + expect(mockDeleteColumns).toHaveBeenCalledWith( + { tableId: 'tbl_1', columnNames: ['age', 'column-age', 'AGE'] }, + expect.any(String), + { expectedWorkspaceId: 'workspace-1' } + ) + }) +}) + +describe('userTableServerTool workflow scope', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue(buildTable()) + mockAddWorkflowGroup.mockImplementation( + async ({ group, outputColumns }: { group: unknown; outputColumns: unknown[] }) => + buildTable({ + schema: { + columns: outputColumns, + workflowGroups: [group], + } as never, + }) + ) + }) + + it('conceals a cross-workspace workflow id before persisting a group', async () => { + mockResolveWorkflowContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Workflow not found') + ) + + const result = await userTableServerTool.execute( + { + operation: 'add_workflow_group', + args: { + tableId: 'tbl_1', + workflowId: 'workflow-cross-workspace', + outputs: [{ blockId: 'block-1', path: 'content' }], + }, + }, + buildToolContext() + ) + + expect(result).toEqual({ success: false, message: 'Operation failed: Workflow not found' }) + expect(mockResolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: 'workflow-cross-workspace', + assertedWorkspaceId: 'workspace-1', + }) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('conceals unknown application failures from tool output', async () => { + mockQueryRows.mockRejectedValueOnce(new Error('database host unavailable')) + + const result = await userTableServerTool.execute( + { operation: 'query_rows', args: { tableId: 'tbl_1' } }, + buildToolContext() + ) + + expect(result).toEqual({ success: false, message: 'Operation failed: Table operation failed' }) + expect(result.message).not.toContain('database host unavailable') + }) +}) + describe('userTableServerTool.list_enrichments', () => { beforeEach(() => { vi.clearAllMocks() @@ -727,7 +896,7 @@ describe('userTableServerTool.list_enrichments', () => { it('returns the enrichment catalog metadata', async () => { const result = await userTableServerTool.execute( { operation: 'list_enrichments', args: {} }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -759,7 +928,15 @@ describe('userTableServerTool.add_enrichment', () => { }, }) ) - mockAddWorkflowGroup.mockResolvedValue(buildTable()) + mockAddWorkflowGroup.mockImplementation( + async ({ group, outputColumns }: { group: unknown; outputColumns: unknown[] }) => + buildTable({ + schema: { + columns: outputColumns, + workflowGroups: [group], + } as never, + }) + ) }) it('creates an enrichment group with mapped inputs and derived output columns', async () => { @@ -775,7 +952,7 @@ describe('userTableServerTool.add_enrichment', () => { ], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -820,7 +997,7 @@ describe('userTableServerTool.add_enrichment', () => { autoRun: true, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -836,7 +1013,7 @@ describe('userTableServerTool.add_enrichment', () => { operation: 'add_enrichment', args: { tableId: 'tbl_1', enrichmentId: 'nope', inputMappings: [] }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -854,7 +1031,7 @@ describe('userTableServerTool.add_enrichment', () => { inputMappings: [{ inputName: 'fullName', columnName: 'name' }], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -875,7 +1052,7 @@ describe('userTableServerTool.add_enrichment', () => { ], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -907,26 +1084,52 @@ describe('userTableServerTool.query_rows', () => { }) }) - it('passes an explicit limit through unchanged (no row cap)', async () => { + it('rejects an explicit limit above the Copilot page cap before any DB call', async () => { const result = await userTableServerTool.execute( { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 100000 } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) - expect(result.success).toBe(true) - const options = mockQueryRows.mock.calls[0][1] as Record - expect(options.limit).toBe(100000) + expect(result.success).toBe(false) + expect(result.message).toBe('Limit cannot exceed 1000') + expect(mockGetTableById).not.toHaveBeenCalled() + expect(mockQueryRows).not.toHaveBeenCalled() }) - it('omits the limit so queryRows returns every matching row', async () => { + it('defaults an omitted Copilot page limit to the surface maximum', async () => { const result = await userTableServerTool.execute( { operation: 'query_rows', args: { tableId: 'tbl_1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) const options = mockQueryRows.mock.calls[0][1] as Record - expect(options.limit).toBeUndefined() + expect(options.limit).toBe(1000) + }) + + it('imports application-owned persisted provenance into the tool trace registry', async () => { + const registry = new ResolvedSecretTraceRegistry() + const importProvenance = vi.spyOn(registry, 'importCrossingProvenance') + + const result = await userTableServerTool.execute( + { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 2 } }, + { ...buildToolContext(), resolvedSecretTraceRegistry: registry } + ) + + expect(result.success).toBe(true) + expect(mockLoadTableRowSecretProvenance).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'row_1' })]), + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + expect(importProvenance).toHaveBeenCalledWith( + expect.objectContaining({ + version: 1, + complete: true, + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }), + expect.arrayContaining([{ name: 'r1' }]), + { trusted: true } + ) }) it('normalizes a root condition before querying', async () => { @@ -935,7 +1138,7 @@ describe('userTableServerTool.query_rows', () => { operation: 'query_rows', args: { tableId: 'tbl_1', filter: { field: 'name', op: 'eq', value: 'r1' } }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -952,7 +1155,7 @@ describe('userTableServerTool.query_rows', () => { }) const result = await userTableServerTool.execute( { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 2, cursor } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -974,7 +1177,7 @@ describe('userTableServerTool.query_rows', () => { }) const result = await userTableServerTool.execute( { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 2 } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.message).toContain('more available') @@ -993,7 +1196,7 @@ describe('userTableServerTool.query_rows', () => { operation: 'query_rows', args: { tableId: 'tbl_1', cursor, order: [{ field: 'name', direction: 'desc' }] }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -1035,7 +1238,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { limit: 5000, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -1058,7 +1261,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { operation: 'delete_rows_by_filter', args: { tableId: 'tbl_1', filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] } }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -1080,7 +1283,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { operation: 'delete_rows_by_filter', args: { tableId: 'tbl_1', filter: { field: 'name', op: 'eq', value: 'x' } }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -1099,7 +1302,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { limit: 100, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -1121,7 +1324,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { operation: 'delete_rows_by_filter', args: { tableId: 'tbl_1', filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] } }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -1160,7 +1363,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { operation: 'delete_rows_by_filter', args: { tableId: 'tbl_1', filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] } }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -1179,7 +1382,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { limit: 100, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -1215,7 +1418,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { limit: 5000, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -1239,7 +1442,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { age: 1 }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) expect(result.data?.affectedCount).toBe(5) @@ -1257,7 +1460,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { age: 1 }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -1281,7 +1484,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { age: 1 }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -1322,7 +1525,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { email: 'y' }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) expect(mockQueryRows).not.toHaveBeenCalled() @@ -1348,7 +1551,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { age: 1 }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) expect(result.message).toMatch(/job is already in progress/i) @@ -1367,7 +1570,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { limit: 100, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) expect(mockQueryRows).not.toHaveBeenCalled() @@ -1409,7 +1612,7 @@ describe('userTableServerTool.update_column — select routing', () => { options: ['Open', 'Closed'], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } as never + buildToolContext() ) expect(mockUpdateColumnType).not.toHaveBeenCalled() @@ -1425,7 +1628,7 @@ describe('userTableServerTool.update_column — select routing', () => { operation: 'update_column', args: { tableId: 'tbl_1', columnName: 'status', newType: 'string' }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } as never + buildToolContext() ) expect(mockUpdateColumnType).toHaveBeenCalledTimes(1) @@ -1438,7 +1641,7 @@ describe('userTableServerTool.update_column — select routing', () => { operation: 'update_column', args: { tableId: 'tbl_1', columnName: 'status', multiple: true }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } as never + buildToolContext() ) expect(mockUpdateColumnOptions).toHaveBeenCalledTimes(1) @@ -1456,13 +1659,13 @@ describe('userTableServerTool.delete bounds', () => { operation: 'delete', args: { tableIds: Array.from({ length: 101 }, (_, index) => `table-${index}`) }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result).toEqual({ success: false, message: 'Cannot delete more than 100 tables at once', }) - expect(mockExecuteCopilotTableUseCase).not.toHaveBeenCalled() + expect(mockGetTableById).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index fca3d5c1e97..2abb2117184 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -1,50 +1,39 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' +import { toError } from '@sim/utils/errors' +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +import { executeCopilotResolveWorkflowOutputs } from '@/lib/copilot/application/execute-workflow-use-case' import { - admitCopilotTableOperation, - executeCopilotTableUseCase, -} from '@/lib/copilot/application/execute-table-use-case' -import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' -import { - messageForCopilotTableError, - resolveCopilotTablePrincipal, -} from '@/lib/copilot/auth/table-delegation' + executeCopilotAddWorkflowTableGroupOutput, + executeCopilotCreateTableEnrichmentGroup, + executeCopilotCreateTableFromWorkspaceFile, + executeCopilotCreateWorkflowTableGroup, + executeCopilotDeleteTables, + executeCopilotImportWorkspaceFileIntoTable, + executeCopilotUpdateWorkflowTableGroup, +} from '@/lib/copilot/application/table-commands' +import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' -import { runDetached } from '@/lib/core/utils/background' -import { - buildAutoMapping, - COLUMN_TYPES, - CSV_ASYNC_IMPORT_THRESHOLD_BYTES, - CSV_MAX_BATCH_SIZE, - type CsvHeaderMapping, - CsvImportValidationError, - coerceRowsForTable, - getWorkspaceTableLimits, - inferSchemaFromCsv, - parseFileRows, - sanitizeName, - TABLE_LIMITS, - validateMapping, -} from '@/lib/table' +import { COLUMN_TYPES, CSV_MAX_BATCH_SIZE, type CsvHeaderMapping, TABLE_LIMITS } from '@/lib/table' import { addTableColumnUseCase, + deleteTableColumnsUseCase, deleteTableColumnUseCase, updateTableColumnUseCase, } from '@/lib/table/application/columns' import { - createTableGroupUseCase, + copilotBatchUpdateRows, + copilotDeleteRowsByFilter, + copilotUpdateRowsByFilter, +} from '@/lib/table/application/copilot-bulk-rows' +import { + deleteTableGroupOutputUseCase, deleteTableGroupUseCase, - updateTableGroupUseCase, } from '@/lib/table/application/groups' -import { type TableOperation, tableOperations } from '@/lib/table/application/operations' import { createTableRows, deleteTableRow, @@ -56,72 +45,23 @@ import { import { cancelTableRuns, startTableRun } from '@/lib/table/application/runs' import { createTableUseCase, - deleteTableUseCase, readTableUseCase, updateTableUseCase, } from '@/lib/table/application/tables' import { namedRowMapper } from '@/lib/table/cell-format' -import { buildIdByName, rowDataNameToId } from '@/lib/table/column-keys' -import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { deleteColumns } from '@/lib/table/columns/service' import { isSupportedCurrencyCode } from '@/lib/table/currency' -import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' -import { signalTableRowsChanged, signalTableSchemaChanged } from '@/lib/table/events' -import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' -import { - markTableJobRunningInWorkspace, - releaseJobClaimInWorkspace, -} from '@/lib/table/jobs/service' -import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' -import { predicateToFilter } from '@/lib/table/query-builder/converters' import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate' -import { validatePredicate } from '@/lib/table/query-builder/validate' -import { - createExactEmptyTableRowSecretProvenance, - loadTableRowSecretProvenance, -} from '@/lib/table/rows/secret-provenance' -import { - batchInsertRows, - batchUpdateRows, - deleteRowsByFilter, - queryRows, - replaceTableRows, - updateRowsByFilter, -} from '@/lib/table/rows/service' +import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' import { normalizeSelectOptionsInput } from '@/lib/table/select-options' -import { predicateToStorage } from '@/lib/table/select-values' -import { createTable, deleteTable, getTableById } from '@/lib/table/service' import type { - ColumnDefinition, - Filter, RowData, SortSpec, - TableDefinition, - TableDeleteJobPayload, TablePredicateInput, TableSchema, - TableUpdateJobPayload, - WorkflowGroup, WorkflowGroupDependencies, WorkflowGroupDeploymentMode, - WorkflowGroupInputMapping, - WorkflowGroupOutput, } from '@/lib/table/types' -import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner' -import { - addWorkflowGroup, - addWorkflowGroupOutput, - deleteWorkflowGroupOutput, -} from '@/lib/table/workflow-groups/service' -import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { - type FlattenedBlockOutput, - flattenWorkflowOutputs, -} from '@/lib/workflows/blocks/flatten-outputs' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' -import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' +import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('UserTableServerTool') @@ -137,348 +77,16 @@ type UserTableResult = { } const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE -const MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 - -const USER_TABLE_OPERATIONS: Readonly> = { - create: tableOperations.create, - create_from_file: tableOperations.create, - import_file: tableOperations.createImport, - get: tableOperations.read, - get_schema: tableOperations.read, - delete: tableOperations.delete, - insert_row: tableOperations.createRows, - batch_insert_rows: tableOperations.createRows, - get_row: tableOperations.readRow, - query_rows: tableOperations.queryRows, - update_row: tableOperations.updateRow, - delete_row: tableOperations.deleteRow, - update_rows_by_filter: tableOperations.updateRows, - delete_rows_by_filter: tableOperations.deleteRows, - batch_update_rows: tableOperations.updateRows, - batch_delete_rows: tableOperations.deleteRows, - add_column: tableOperations.addColumn, - rename_column: tableOperations.updateColumn, - delete_column: tableOperations.deleteColumn, - update_column: tableOperations.updateColumn, - rename: tableOperations.update, - add_workflow_group: tableOperations.createGroup, - update_workflow_group: tableOperations.updateGroup, - delete_workflow_group: tableOperations.deleteGroup, - add_workflow_group_output: tableOperations.updateGroup, - delete_workflow_group_output: tableOperations.updateGroup, - run_column: tableOperations.startRun, - cancel_table_runs: tableOperations.cancelRuns, - add_enrichment: tableOperations.createGroup, -} -const DIRECT_APPLICATION_OPERATIONS = new Set([ - 'create', - 'get', - 'get_schema', - 'delete', - 'rename', - 'insert_row', - 'batch_insert_rows', - 'get_row', - 'query_rows', - 'update_row', - 'delete_row', - 'batch_delete_rows', - 'add_column', - 'rename_column', - 'update_column', - 'add_workflow_group', - 'update_workflow_group', - 'delete_workflow_group', - 'run_column', - 'cancel_table_runs', -]) - -async function resolveWorkspaceFileRecordOrThrow( - fileReference: string, +function resolveAuthorizedWorkflowOutputs( + workflowId: string, workspaceId: string, - principal: ReturnType + context: ServerToolContext ) { - let record - try { - record = await resolveWorkspaceFileReference({ - principal, - operation: fileOperations.readContent, - workspaceId, - reference: fileReference, - }) - } catch { - // Only workspace files resolve here. A chat upload is a real, correctly-copied - // path, so pointing it at glob("files/**") would send the agent looking for a - // file that is not in that tree until materialize_file moves it there. - if (fileReference.replace(/^\/+/, '').startsWith('uploads/')) { - throw new Error( - `Cannot import "${fileReference}": chat uploads are not workspace files. Use materialize_file to save it to a files/... path first, then pass that canonical path.` - ) - } - throw new Error( - `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` - ) - } - if (!record) { - if (fileReference.replace(/^\/+/, '').startsWith('uploads/')) { - throw new Error( - `Cannot import "${fileReference}": chat uploads are not workspace files. Use materialize_file to save it to a files/... path first, then pass that canonical path.` - ) - } - throw new Error( - `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` - ) - } - - const provenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, { - fileId: record.id, - key: record.key, - context: 'workspace', + return executeCopilotResolveWorkflowOutputs(context, { + workflowId, + assertedWorkspaceId: workspaceId, }) - if (provenance.status !== 'exact' || provenance.entries.length > 0) { - throw new Error( - `Cannot import "${fileReference}": the file cannot be verified as free of resolved secrets.` - ) - } - - return record -} - -/** - * Whether a workspace file should import as a background job instead of inline: - * CSV/TSV at or above the same byte threshold the UI uses. Other formats - * (xlsx/json) aren't supported by the streaming import worker and stay inline. - */ -function shouldImportInBackground(record: { name: string; size: number }): boolean { - const ext = record.name.split('.').pop()?.toLowerCase() - return (ext === 'csv' || ext === 'tsv') && record.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES -} - -/** - * Dispatches a background import for an already-claimed job slot, mirroring the - * import-async routes: trigger.dev when enabled (survives deploys, retries), - * detached in-process worker otherwise. A failed dispatch releases the claim so - * a ghost `running` job can't hold the table's one-write-job slot. - */ -async function dispatchImportJob(payload: TableImportPayload): Promise { - if (isTriggerDevEnabled) { - 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', payload, { - tags: [`tableId:${payload.tableId}`, `jobId:${payload.importId}`], - region: await resolveTriggerRegion(), - }) - } catch (error) { - try { - const released = await releaseJobClaimInWorkspace( - payload.tableId, - payload.workspaceId, - payload.importId - ) - if (!released) throw new Error('Table import claim was no longer active') - } catch (cleanupError) { - logger.error('Failed to release table import claim after dispatch failure', { - tableId: payload.tableId, - jobId: payload.importId, - error: getErrorMessage(cleanupError), - }) - } - throw error - } - } else { - runDetached('table-import', () => runTableImport(payload)) - } -} - -/** - * Dispatches a background filter-delete for an already-claimed job slot, - * mirroring the delete-async route. Same release-on-failed-dispatch guard as - * {@link dispatchImportJob}. - */ -async function dispatchDeleteJob(params: { - jobId: string - tableId: string - workspaceId: string - filter: Filter - cutoff: Date - maxRows?: number -}): Promise { - const { jobId, tableId, workspaceId, filter, cutoff, maxRows } = params - if (isTriggerDevEnabled) { - try { - const [{ tableDeleteTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ - import('@/background/table-delete'), - import('@trigger.dev/sdk'), - import('@/lib/core/async-jobs/region'), - ]) - await tasks.trigger( - 'table-delete', - { jobId, tableId, workspaceId, filter, cutoff: cutoff.toISOString(), maxRows }, - { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() } - ) - } catch (error) { - try { - const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) - if (!released) throw new Error('Table delete claim was no longer active') - } catch (cleanupError) { - logger.error('Failed to release table delete claim after dispatch failure', { - tableId, - jobId, - error: getErrorMessage(cleanupError), - }) - } - throw error - } - } else { - runDetached('table-delete', () => - runTableDelete({ jobId, tableId, workspaceId, filter, cutoff, maxRows }).catch( - async (error) => { - await markTableDeleteFailed(tableId, jobId, error) - throw error - } - ) - ) - } -} - -/** - * Dispatches a background bulk update for an already-claimed job slot, mirroring - * {@link dispatchDeleteJob}: trigger.dev when enabled, detached worker otherwise, releasing the - * slot on a failed dispatch. - */ -async function dispatchUpdateJob(params: { - jobId: string - tableId: string - workspaceId: string - filter: Filter - data: RowData - cutoff: Date - maxRows?: number -}): Promise { - const { jobId, tableId, workspaceId, filter, data, cutoff, maxRows } = params - if (isTriggerDevEnabled) { - try { - const [{ tableUpdateTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ - import('@/background/table-update'), - import('@trigger.dev/sdk'), - import('@/lib/core/async-jobs/region'), - ]) - await tasks.trigger( - 'table-update', - { jobId, tableId, workspaceId, filter, data, cutoff: cutoff.toISOString(), maxRows }, - { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() } - ) - } catch (error) { - try { - const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) - if (!released) throw new Error('Table update claim was no longer active') - } catch (cleanupError) { - logger.error('Failed to release table update claim after dispatch failure', { - tableId, - jobId, - error: getErrorMessage(cleanupError), - }) - } - throw error - } - } else { - runDetached('table-update', () => - runTableUpdate({ jobId, tableId, workspaceId, filter, data, cutoff, maxRows }).catch( - async (error) => { - await markTableUpdateFailed(tableId, jobId, error) - throw error - } - ) - ) - } -} - -async function withReleasedTableJobClaim( - tableId: string, - workspaceId: string, - jobId: string, - run: () => Promise -): Promise { - let result: T - try { - result = await run() - } catch (error) { - try { - const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) - if (!released) { - logger.error('Table job claim was no longer active after operation failure', { - tableId, - workspaceId, - jobId, - }) - } - } catch (cleanupError) { - logger.error('Failed to release table job claim after operation failure', { - tableId, - workspaceId, - jobId, - error: getErrorMessage(cleanupError), - }) - } - throw error - } - const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) - if (!released) { - logger.error('Table job claim was no longer active after successful operation', { - tableId, - workspaceId, - jobId, - }) - throw new Error('Table job claim was no longer active') - } - return result -} - -/** - * Loads the live workflow state and flattens it into pickable outputs. Used - * to validate `(blockId, path)` pairs the AI passes to add/update_workflow_group - * before they get stored as stale references — and to power `list_workflow_outputs` - * so the AI can discover valid picks instead of guessing. - */ -async function loadFlattenedWorkflowOutputs( - workflowId: string -): Promise { - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) return null - const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ - id: b.id, - type: b.type, - name: b.name, - triggerMode: (b as { triggerMode?: boolean }).triggerMode, - subBlocks: b.subBlocks as Record | undefined, - })) - return flattenWorkflowOutputs(blocks, normalized.edges ?? []) -} - -/** - * Validates a list of `(blockId, path)` outputs against the live workflow. - * Returns `null` on success; on failure returns an error message that lists - * the valid options so the AI can retry without guessing again. - */ -function validateOutputsAgainstWorkflow( - outputs: Array<{ blockId: string; path: string }>, - flattened: FlattenedBlockOutput[], - workflowId: string -): string | null { - const valid = new Set(flattened.map((f) => `${f.blockId}::${f.path}`)) - const invalid = outputs.filter((o) => !valid.has(`${o.blockId}::${o.path}`)) - if (invalid.length === 0) return null - const sample = flattened - .slice(0, 12) - .map((f) => ` - ${f.blockId} (${f.blockName}) → ${f.path}`) - .join('\n') - const invalidList = invalid.map((o) => ` - ${o.blockId} → ${o.path}`).join('\n') - return `Invalid output(s) for workflow ${workflowId}:\n${invalidList}\n\nValid options${flattened.length > 12 ? ' (first 12)' : ''}:\n${sample}\n\nCall list_workflow_outputs with workflowId="${workflowId}" to see all valid (blockId, path) picks.` } /** @@ -491,17 +99,15 @@ function parseDeploymentMode(value: unknown): WorkflowGroupDeploymentMode | unde return value === 'live' || value === 'deployed' ? value : undefined } -/** - * Validates an optional row limit. There's no upper bound the caller must respect — the model may - * ask for any number. `MAX_QUERY_LIMIT` / `MAX_BULK_OPERATION_SIZE` are applied internally instead - * (query_rows clamps the page; bulk ops above the bound run as a background job). Returns an error - * message, or `null` when the limit is acceptable. - */ -function limitError(limit: unknown): string | null { +/** Validates an optional row limit against the policy for the requested surface operation. */ +function limitError(limit: unknown, max?: number): string | null { if (limit === undefined) return null if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) { return 'Limit must be an integer of at least 1' } + if (max !== undefined && limit > max) { + return `Limit cannot exceed ${max}` + } return null } @@ -525,57 +131,18 @@ function normalizeSchemaSelectColumns(schema: TableSchema): TableSchema { } } -async function batchInsertAll( - tableId: string, - rows: RowData[], - table: TableDefinition, - workspaceId: string, - context?: ServerToolContext -): Promise { - let inserted = 0 - const userId = context?.userId - for (let i = 0; i < rows.length; i += MAX_BATCH_SIZE) { - assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.') - const batch = rows.slice(i, i + MAX_BATCH_SIZE) - const requestId = generateId().slice(0, 8) - const result = await batchInsertRows( - { - tableId, - rows: batch, - workspaceId, - userId, - secretProvenance: batch.map(createExactEmptyTableRowSecretProvenance), - }, - // Pass the running total so each batch's capacity check sees cumulative rows, - // not the same pre-loop snapshot (which would let a multi-batch insert overshoot). - { ...table, rowCount: table.rowCount + inserted }, - requestId - ) - inserted += result.length - } - return inserted -} - -async function importRowsForModel( - rows: Array<{ id: string; data: RowData; updatedAt: Date | string }>, +async function importRowsProvenanceForModel( + provenance: ResolvedSecretTraceProvenanceV1 | undefined, + values: unknown[], context: ServerToolContext ): Promise { const registry = context.resolvedSecretTraceRegistry if (!registry) return - if (!context.workspaceId) { + if (!provenance) { registry.markIncomplete() return } - - const provenance = await loadTableRowSecretProvenance(rows, { - userId: context.userId, - workspaceId: context.workspaceId, - }) - await registry.importCrossingProvenance( - provenance, - rows.map((row) => row.data), - { trusted: true } - ) + await registry.importCrossingProvenance(provenance, values, { trusted: true }) } export const userTableServerTool: BaseServerTool = { @@ -590,21 +157,10 @@ export const userTableServerTool: BaseServerTool } const { operation, args = {} } = params - const tableId = typeof args.tableId === 'string' ? args.tableId : undefined - const tablePrincipal = resolveCopilotTablePrincipal(context, tableId) - const workspaceId = tablePrincipal.workspaceId + const workspaceId = context.workspaceId const assertNotAborted = () => assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.') - try { - const semanticOperation = USER_TABLE_OPERATIONS[operation] - if (semanticOperation && !DIRECT_APPLICATION_OPERATIONS.has(operation)) { - await admitCopilotTableOperation( - context, - semanticOperation, - tableId ? { workspaceId, tableId } : { workspaceId } - ) - } switch (operation) { case 'create': { if (!args.name) { @@ -695,28 +251,13 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const deleted: string[] = [] - const failed: string[] = [] - - for (const tableId of tableIds) { - try { - assertNotAborted() - await executeCopilotTableUseCase( - context, - deleteTableUseCase, - { tableId, workspaceId }, - { tableId } - ) - deleted.push(tableId) - } catch (error) { - const classified = messageForCopilotTableError(error, '') - if (classified === 'Table not found') { - failed.push(tableId) - continue - } - throw error - } - } + assertNotAborted() + const { deleted: archived, failed } = await executeCopilotDeleteTables(context, { + tableIds, + workspaceId, + assertNotAborted, + }) + const deleted = archived.map((table) => table.id) return { success: deleted.length > 0, @@ -819,17 +360,22 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const { table: rowTable, row } = await executeCopilotTableUseCase( + const { + table: rowTable, + row, + secretProvenance, + } = await executeCopilotTableUseCase( context, readTableRow, { tableId: args.tableId, assertedWorkspaceId: workspaceId, rowId: args.rowId, + includePersistedSecretProvenance: Boolean(context.resolvedSecretTraceRegistry), }, { tableId: args.tableId } ) - await importRowsForModel([row], context) + await importRowsProvenanceForModel(secretProvenance, [row.data], context) const toNamedRow = namedRowMapper(rowTable.schema.columns) return { @@ -852,7 +398,7 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const queryLimitError = limitError(args.limit) + const queryLimitError = limitError(args.limit, TABLE_LIMITS.MAX_QUERY_LIMIT) if (queryLimitError) { return { success: false, message: queryLimitError } } @@ -867,15 +413,20 @@ export const userTableServerTool: BaseServerTool ? normalizeTablePredicate(args.filter as TablePredicateInput) : undefined, sort: args.order as SortSpec | undefined, - limit: args.limit, + limit: args.limit ?? TABLE_LIMITS.MAX_QUERY_LIMIT, cursor: args.cursor, includeTotal: !args.cursor, + includePersistedSecretProvenance: Boolean(context.resolvedSecretTraceRegistry), }, { tableId: args.tableId } ) - const { table } = result + const { table, secretProvenance, ...queryResult } = result const toNamedRow = namedRowMapper(table.schema.columns) - await importRowsForModel(result.rows, context) + await importRowsProvenanceForModel( + secretProvenance, + result.rows.map((row) => row.data), + context + ) // nextCursor covers both cut kinds (explicit limit or the 5MB byte // budget) — either way the truthful signal is "more rows exist". The @@ -888,7 +439,7 @@ export const userTableServerTool: BaseServerTool success: true, message, data: { - ...result, + ...queryResult, rows: result.rows.map((r) => ({ ...r, data: toNamedRow(r.data), @@ -912,7 +463,11 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const { table, row: updatedRow } = await executeCopilotTableUseCase( + const { + table, + row: updatedRow, + secretProvenance, + } = await executeCopilotTableUseCase( context, updateTableRow, { @@ -921,11 +476,12 @@ export const userTableServerTool: BaseServerTool rowId: args.rowId, data: args.data, secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), + includePersistedSecretProvenance: Boolean(context.resolvedSecretTraceRegistry), }, { tableId: args.tableId } ) const toNamedRow = namedRowMapper(table.schema.columns) - await importRowsForModel([updatedRow], context) + await importRowsProvenanceForModel(secretProvenance, [updatedRow.data], context) return { success: true, @@ -986,95 +542,26 @@ export const userTableServerTool: BaseServerTool return { success: false, message: updateLimitError } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) - const idByName = buildIdByName(table.schema) - // Agent authors a predicate object; validate → translate → Filter for - // the bulk engine (same fieldPredicate leaf → identical SQL). Select - // operands arrive as option NAMES and must resolve to stored ids. - const filter = args.filter as TablePredicateInput - validatePredicate(filter, table.schema.columns) - const normalizedFilter = normalizeTablePredicate(filter) - const idFilter = predicateToFilter(predicateToStorage(normalizedFilter, table.schema)) - const idData = rowDataNameToId(args.data, idByName) - - // Inline handles up to MAX_BULK_OPERATION_SIZE rows in one request; a larger operation - // (an explicit limit above the cap, or unbounded "update everything matching") runs in the - // background worker so a broad update on a huge table doesn't load every matching row into - // this request. A small explicit limit is the fast path — no count needed. A patch - // touching a unique column always stays inline (the service rejects bulk-setting a unique - // value across multiple rows). - const patchTouchesUnique = table.schema.columns.some( - (c) => c.unique === true && (c.id ?? c.name) in idData - ) - const updateInlineEligible = - args.limit !== undefined && args.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE - if (!updateInlineEligible && !patchTouchesUnique) { - const { totalCount } = await queryRows( - table, - { filter: idFilter, limit: 1, withExecutions: false }, - requestId - ) - const matchCount = totalCount ?? 0 - const target = args.limit !== undefined ? Math.min(args.limit, matchCount) : matchCount - if (target > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { - const cutoff = new Date() - const jobId = generateId() - const payload: TableUpdateJobPayload = { - filter: idFilter, - data: idData, - cutoff: cutoff.toISOString(), - affectedCount: target, - maxRows: args.limit, - } - // Gate the update lock at enqueue — the background worker is a - // trusted continuation and does not re-check. - assertRowUpdate(table, patchColumnIds(idData)) - assertNotAborted() - const claimed = await markTableJobRunningInWorkspace( - table.id, - workspaceId, - jobId, - 'update', - payload - ) - if (!claimed) { - return { success: false, message: 'A job is already in progress for this table' } - } - await dispatchUpdateJob({ - jobId, - tableId: table.id, - workspaceId, - filter: idFilter, - data: idData, - cutoff, - maxRows: args.limit, - }) - return { - success: true, - message: `Started background update of ${target} matching rows (job ${jobId}). Rows update in the background — query_rows to check progress. Note: background updates don't auto-recompute workflow/enrichment columns; use run_column afterward if needed.`, - data: { jobId, affectedCount: target }, - } - } - } - assertNotAborted() - const result = await updateRowsByFilter( - table, + const result = await executeCopilotTableUseCase( + context, + copilotUpdateRowsByFilter, { - filter: idFilter, - data: idData, + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + filter: normalizeTablePredicate(args.filter as TablePredicateInput), + data: args.data as RowData, limit: args.limit, - actorUserId: context.userId, - secretProvenance: createExactEmptyTableRowSecretProvenance(idData), }, - requestId + { tableId: args.tableId } ) - if (result.affectedCount > 0) signalTableRowsChanged(args.tableId) + if (result.kind === 'background') { + return { + success: true, + message: `Started background update of ${result.affectedCount} matching rows (job ${result.jobId}). Rows update in the background — query_rows to check progress. Note: background updates don't auto-recompute workflow/enrichment columns; use run_column afterward if needed.`, + data: { jobId: result.jobId, affectedCount: result.affectedCount }, + } + } return { success: true, @@ -1098,115 +585,26 @@ export const userTableServerTool: BaseServerTool return { success: false, message: deleteLimitError } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) - const idByName = buildIdByName(table.schema) - // Agent authors a predicate object; validate → translate → Filter for - // the bulk engine (same fieldPredicate leaf → identical SQL). Select - // operands arrive as option NAMES and must resolve to stored ids. - const filter = args.filter as TablePredicateInput - validatePredicate(filter, table.schema.columns) - const normalizedFilter = normalizeTablePredicate(filter) - const idFilter = predicateToFilter(predicateToStorage(normalizedFilter, table.schema)) - - // Inline handles up to MAX_BULK_OPERATION_SIZE rows; a larger delete (an explicit limit - // above the cap, or unbounded "delete everything matching") hands off to the background - // delete worker so a broad delete on a huge table doesn't load every matching id into this - // request. A small explicit limit is the fast path. - const deleteInlineEligible = - args.limit !== undefined && args.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE - if (!deleteInlineEligible) { - const { totalCount } = await queryRows( - table, - { filter: idFilter, limit: 1, withExecutions: false }, - requestId - ) - const matchCount = totalCount ?? 0 - const target = args.limit !== undefined ? Math.min(args.limit, matchCount) : matchCount - if (target > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { - const doomedCount = Math.min(target, table.rowCount) - const cutoff = new Date() - const jobId = generateId() - // Unbounded: mask the whole matching set (instant post-delete view), so `doomedCount` - // drives the count adjustment. Bounded (maxRows): no mask — `doomedCount` is omitted so - // the count isn't double-subtracted; rows disappear progressively as they're deleted. - const bounded = args.limit !== undefined - const payload: TableDeleteJobPayload = bounded - ? { filter: idFilter, cutoff: cutoff.toISOString(), maxRows: args.limit } - : { filter: idFilter, cutoff: cutoff.toISOString(), doomedCount } - // Gate the delete lock at enqueue — the worker is a trusted continuation. - assertRowDelete(table) - assertNotAborted() - const claimed = await markTableJobRunningInWorkspace( - table.id, - workspaceId, - jobId, - 'delete', - payload - ) - if (!claimed) { - return { success: false, message: 'A job is already in progress for this table' } - } - await dispatchDeleteJob({ - jobId, - tableId: table.id, - workspaceId, - filter: idFilter, - cutoff, - maxRows: args.limit, - }) - return { - success: true, - message: bounded - ? `Started background delete of up to ${doomedCount} matching rows (job ${jobId}). Rows delete in the background — query_rows to check progress.` - : `Started background delete of ${doomedCount} matching rows (job ${jobId}). The rows are hidden from reads immediately — query_rows already reflects the post-delete view.`, - data: { jobId, doomedCount }, - } - } - } - - // Claim the table's one-write-job slot for the inline delete too, so it - // can't interleave with a running background import/delete. Mask-safe: a - // payload-less delete job is ignored by pendingDeleteMask, and the delete - // completes synchronously within this request before the slot is released. assertNotAborted() - const inlineDeleteId = generateId() - const deleteClaimed = await markTableJobRunningInWorkspace( - table.id, - workspaceId, - inlineDeleteId, - 'delete' - ) - if (!deleteClaimed) { - return { success: false, message: 'A job is already in progress for this table' } - } - const result = await withReleasedTableJobClaim( - table.id, - workspaceId, - inlineDeleteId, - () => deleteRowsByFilter(table, { filter: idFilter, limit: args.limit }, requestId) + const result = await executeCopilotTableUseCase( + context, + copilotDeleteRowsByFilter, + { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + filter: normalizeTablePredicate(args.filter as TablePredicateInput), + limit: args.limit, + }, + { tableId: args.tableId } ) - if (result.affectedCount > 0) signalTableRowsChanged(args.tableId) - - if (result.affectedCount > 0) { - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: table.id, - resourceName: table.name, - description: `Deleted ${result.affectedCount} row(s) from table "${table.name}"`, - metadata: { - op: 'bulk_delete', - rowsDeleted: result.affectedCount, - source: 'tool_input', - }, - }) + if (result.kind === 'background') { + return { + success: true, + message: result.bounded + ? `Started background delete of up to ${result.doomedCount} matching rows (job ${result.jobId}). Rows delete in the background — query_rows to check progress.` + : `Started background delete of ${result.doomedCount} matching rows (job ${result.jobId}). The rows are hidden from reads immediately — query_rows already reflects the post-delete view.`, + data: { jobId: result.jobId, doomedCount: result.doomedCount }, + } } return { @@ -1255,35 +653,17 @@ export const userTableServerTool: BaseServerTool } } - const table = await getTableById(args.tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - const requestId = generateId().slice(0, 8) assertNotAborted() - const idByName = buildIdByName(table.schema) - const idUpdates = (updates as Array<{ rowId: string; data: RowData }>).map((update) => ({ - rowId: update.rowId, - data: rowDataNameToId(update.data, idByName), - })) - const result = await batchUpdateRows( + const result = await executeCopilotTableUseCase( + context, + copilotBatchUpdateRows, { tableId: args.tableId, - updates: idUpdates, - workspaceId, - actorUserId: context.userId, - secretProvenanceByRowId: Object.fromEntries( - idUpdates.map((update) => [ - update.rowId, - createExactEmptyTableRowSecretProvenance(update.data), - ]) - ), + assertedWorkspaceId: workspaceId, + updates: updates as Array<{ rowId: string; data: RowData }>, }, - table, - requestId + { tableId: args.tableId } ) - if (result.affectedCount > 0) signalTableRowsChanged(args.tableId) return { success: true, @@ -1351,172 +731,49 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const filePrincipal = resolveCopilotFilePrincipal(context) - const record = await resolveWorkspaceFileRecordOrThrow( - fileReference, + assertNotAborted() + const result = await executeCopilotCreateTableFromWorkspaceFile(context, { workspaceId, - filePrincipal - ) - - // Large CSV/TSV: create a placeholder table whose creation claims the - // job slot, then let the streaming import worker infer the schema and - // populate rows in the background (mirrors POST /api/table/import-async). - if (shouldImportInBackground(record)) { - const planLimits = await getWorkspaceTableLimits(workspaceId) - const tableName = - args.name || - sanitizeName(record.name.replace(/\.[^.]+$/, ''), 'imported_table').slice( - 0, - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - ) - const requestId = generateId().slice(0, 8) - const importId = generateId() - assertNotAborted() - const table = await createTable( - { - name: tableName, - description: args.description || `Imported from ${record.name}`, - schema: { columns: [{ name: 'column_1', type: 'string' }] }, - workspaceId, - userId: context.userId, - maxRows: planLimits.maxRowsPerTable, - maxTables: planLimits.maxTables, - jobStatus: 'running', - jobType: 'import', - jobId: importId, - }, - requestId - ) - try { - await dispatchImportJob({ - importId, - tableId: table.id, - workspaceId, - userId: context.userId, - fileKey: record.key, - fileName: record.name, - delimiter: record.name.toLowerCase().endsWith('.tsv') ? '\t' : ',', - mode: 'create', - deleteSourceFile: false, - }) - } catch (dispatchError) { - try { - await deleteTable(table.id, generateId().slice(0, 8)) - } catch (cleanupError) { - logger.error('Failed to remove placeholder table after import dispatch failure', { - tableId: table.id, - error: getErrorMessage(cleanupError), - }) - } - throw dispatchError - } + fileReference, + name: args.name, + description: args.description, + assertNotAborted, + }) + if (result.kind === 'empty') { + return { success: false, message: 'File contains no data rows' } + } + const record = result.sourceFile + if (result.kind === 'background') { return { success: true, - message: `Created table "${table.name}" (${table.id}); importing rows from "${record.name}" in the background (job ${importId}). Columns and rows appear as the import progresses — query_rows to check what has landed.`, + message: `Created table "${result.table.name}" (${result.table.id}); importing rows from "${record.name}" in the background (job ${result.jobId}). Columns and rows appear as the import progresses — query_rows to check what has landed.`, data: { - tableId: table.id, - tableName: table.name, - jobId: importId, + tableId: result.table.id, + tableName: result.table.name, + jobId: result.jobId, sourceFile: record.name, }, } } - const file = { - buffer: ( - await readWorkspaceFileContent.execute({ - principal: filePrincipal, - input: { - fileId: record.id, - assertedWorkspaceId: workspaceId, - maxBytes: MAX_INLINE_FILE_BYTES, - }, - }) - ).content, - name: record.name, - type: record.type, - } - const { headers, rows } = await parseFileRows(file.buffer, file.name, file.type) - if (rows.length === 0) { - return { success: false, message: 'File contains no data rows' } - } - - const { columns, headerToColumn } = inferSchemaFromCsv(headers, rows) - const tableName = args.name || file.name.replace(/\.[^.]+$/, '') - const requestId = generateId().slice(0, 8) - assertNotAborted() - const planLimits = await getWorkspaceTableLimits(workspaceId) - - const droppedRows = Math.max(0, rows.length - planLimits.maxRowsPerTable) - const rowsToImport = droppedRows > 0 ? rows.slice(0, planLimits.maxRowsPerTable) : rows - - const table = await createTable( - { - name: tableName, - description: args.description || `Imported from ${file.name}`, - schema: { columns }, - workspaceId, - userId: context.userId, - maxTables: planLimits.maxTables, - }, - requestId - ) - - // Coerce against the created table's schema so rows key by the ids - // `createTable` assigned (not the inferred, id-less columns). - const coerced = coerceRowsForTable(rowsToImport, table.schema, headerToColumn) - let inserted: number - try { - inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) - } catch (insertError) { - const cleanupRequestId = generateId().slice(0, 8) - await deleteTable(table.id, cleanupRequestId).catch((cleanupError) => { - logger.error('Failed to roll back table after import failure', { - tableId: table.id, - error: toError(cleanupError).message, - }) - }) - const reason = toError(insertError).message - const cause = - insertError instanceof Error && insertError.cause - ? toError(insertError.cause).message - : undefined - logger.error('Failed to import rows into new table', { - tableId: table.id, - fileName: file.name, - error: reason, - cause, - }) - return { - success: false, - message: `Failed to import rows from "${file.name}" — the table was rolled back. ${cause ? `${reason} (${cause})` : reason}`, - } - } - - logger.info('Table created from file', { - tableId: table.id, - fileName: file.name, - columns: columns.length, - rows: inserted, - droppedRows, - userId: context.userId, - }) - - const createdMessage = `Created table "${table.name}" with ${columns.length} columns and ${inserted.toLocaleString()} rows from "${file.name}"` + const createdMessage = `Created table "${result.table.name}" with ${result.columns.length} columns and ${result.insertedCount.toLocaleString()} rows from "${record.name}"` const message = - droppedRows > 0 - ? `${createdMessage}. Dropped ${droppedRows.toLocaleString()} row(s) that exceed this plan's limit of ${planLimits.maxRowsPerTable.toLocaleString()} rows per table.` + result.droppedRows > 0 + ? `${createdMessage}. Dropped ${result.droppedRows.toLocaleString()} row(s) that exceed this plan's limit of ${result.maxRowsPerTable.toLocaleString()} rows per table.` : createdMessage return { success: true, message, data: { - tableId: table.id, - tableName: table.name, - columns: columns.map((c) => ({ name: c.name, type: c.type })), - rowCount: inserted, - sourceFile: file.name, + tableId: result.table.id, + tableName: result.table.name, + columns: result.columns.map((column) => ({ + name: column.name, + type: column.type, + })), + rowCount: result.insertedCount, + sourceFile: record.name, }, } } @@ -1551,182 +808,56 @@ export const userTableServerTool: BaseServerTool } const mode: 'append' | 'replace' = rawMode === 'replace' ? 'replace' : 'append' - const table = await getTableById(tableId) - if (!table || table.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${tableId}` } - } - if (table.archivedAt) { - return { success: false, message: `Table is archived: ${tableId}` } - } - - const filePrincipal = resolveCopilotFilePrincipal(context) - const record = await resolveWorkspaceFileRecordOrThrow( + assertNotAborted() + const result = await executeCopilotImportWorkspaceFileIntoTable(context, { + tableId, + assertedWorkspaceId: workspaceId, fileReference, - workspaceId, - filePrincipal - ) - - // Large CSV/TSV: claim the table's one-write-job slot and hand the - // file to the streaming import worker (mirrors - // POST /api/table/[tableId]/import-async). - if (shouldImportInBackground(record)) { - const importId = generateId() - assertNotAborted() - const claimed = await markTableJobRunningInWorkspace( - table.id, - workspaceId, - importId, - 'import' - ) - if (!claimed) { - return { success: false, message: 'A job is already in progress for this table' } - } - await dispatchImportJob({ - importId, - tableId: table.id, - workspaceId, - userId: context.userId, - fileKey: record.key, - fileName: record.name, - delimiter: record.name.toLowerCase().endsWith('.tsv') ? '\t' : ',', - mode, - mapping: rawMapping, - deleteSourceFile: false, - }) + mode, + mapping: rawMapping, + assertNotAborted, + }) + if (result.kind === 'background') { return { success: true, - message: `Started background ${mode} import of "${record.name}" into "${table.name}" (job ${importId}). Rows appear as the import progresses — query_rows to check what has landed.`, - data: { tableId: table.id, jobId: importId, mode }, + message: `Started background ${mode} import of "${result.sourceFileName}" into "${result.table.name}" (job ${result.jobId}). Rows appear as the import progresses — query_rows to check what has landed.`, + data: { tableId: result.table.id, jobId: result.jobId, mode }, } } - - // Claim the table's one-write-job slot up front — before the download - // and parse — so the inline import is mutually exclusive with any - // background import/delete for its whole duration, not just the write, - // and contention is detected before the parse work is spent. - const inlineImportId = generateId() - assertNotAborted() - const inlineClaimed = await markTableJobRunningInWorkspace( - table.id, - workspaceId, - inlineImportId, - 'import' - ) - if (!inlineClaimed) { - return { success: false, message: 'A job is already in progress for this table' } - } - return withReleasedTableJobClaim(table.id, workspaceId, inlineImportId, async () => { - const file = { - buffer: ( - await readWorkspaceFileContent.execute({ - principal: filePrincipal, - input: { - fileId: record.id, - assertedWorkspaceId: workspaceId, - maxBytes: MAX_INLINE_FILE_BYTES, - }, - }) - ).content, - name: record.name, - type: record.type, - } - const { headers, rows } = await parseFileRows(file.buffer, file.name, file.type) - if (rows.length === 0) { - return { success: false, message: 'File contains no data rows' } - } - - const mapping: CsvHeaderMapping = rawMapping ?? buildAutoMapping(headers, table.schema) - - let validation: ReturnType - try { - validation = validateMapping({ - csvHeaders: headers, - mapping, - tableSchema: table.schema, - }) - } catch (err) { - if (err instanceof CsvImportValidationError) { - return { success: false, message: err.message } - } - throw err - } - - if (validation.mappedHeaders.length === 0) { - return { - success: false, - message: `No matching columns between file (${headers.join(', ')}) and table (${table.schema.columns.map((c) => c.name).join(', ')})`, - } - } - - const coerced = coerceRowsForTable(rows, table.schema, validation.effectiveMap) - - if (mode === 'replace') { - const requestId = generateId().slice(0, 8) - const result = await replaceTableRows( - { - tableId: table.id, - rows: coerced, - workspaceId, - userId: context.userId, - secretProvenance: coerced.map(createExactEmptyTableRowSecretProvenance), - }, - table, - requestId - ) - signalTableRowsChanged(table.id) - - logger.info('Rows replaced from file', { - tableId: table.id, - fileName: file.name, - mode, - matchedColumns: validation.mappedHeaders.length, - deleted: result.deletedCount, - inserted: result.insertedCount, - userId: context.userId, - }) - - return { - success: true, - message: `Replaced rows in "${table.name}" from "${file.name}": deleted ${result.deletedCount}, inserted ${result.insertedCount}`, - data: { - tableId: table.id, - tableName: table.name, - mode, - matchedColumns: validation.mappedHeaders, - skippedColumns: validation.skippedHeaders, - deletedCount: result.deletedCount, - insertedCount: result.insertedCount, - sourceFile: file.name, - }, - } - } - - const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) - if (inserted > 0) signalTableRowsChanged(table.id) - - logger.info('Rows imported from file', { - tableId: table.id, - fileName: file.name, - mode, - matchedColumns: validation.mappedHeaders.length, - rows: inserted, - userId: context.userId, - }) - + if (result.kind === 'empty') { + return { success: false, message: 'File contains no data rows' } + } + if (result.kind !== 'inline') + throw new Error('Inline table import returned a background job') + if (result.mode === 'replace') { return { success: true, - message: `Imported ${inserted} rows into "${table.name}" from "${file.name}" (${validation.mappedHeaders.length} columns matched)`, + message: `Replaced rows in "${result.table.name}" from "${result.sourceFileName}": deleted ${result.deletedCount}, inserted ${result.insertedCount}`, data: { - tableId: table.id, - tableName: table.name, + tableId: result.table.id, + tableName: result.table.name, mode, - matchedColumns: validation.mappedHeaders, - skippedColumns: validation.skippedHeaders, - rowCount: inserted, - sourceFile: file.name, + matchedColumns: result.matchedColumns, + skippedColumns: result.skippedColumns, + deletedCount: result.deletedCount, + insertedCount: result.insertedCount, + sourceFile: result.sourceFileName, }, } - }) + } + return { + success: true, + message: `Imported ${result.insertedCount} rows into "${result.table.name}" from "${result.sourceFileName}" (${result.matchedColumns.length} columns matched)`, + data: { + tableId: result.table.id, + tableName: result.table.name, + mode, + matchedColumns: result.matchedColumns, + skippedColumns: result.skippedColumns, + rowCount: result.insertedCount, + sourceFile: result.sourceFileName, + }, + } } case 'add_column': { @@ -1836,22 +967,18 @@ export const userTableServerTool: BaseServerTool data: { schema: updated.schema }, } } - await executeCopilotTableUseCase( + assertNotAborted() + const { table: updated, deletedColumns } = await executeCopilotTableUseCase( context, - readTableUseCase, - { tableId: args.tableId, workspaceId }, + deleteTableColumnsUseCase, + { tableId: args.tableId, workspaceId, columnNames: names }, { tableId: args.tableId } ) - assertNotAborted() - const updated = await deleteColumns( - { tableId: args.tableId, columnNames: names }, - generateId().slice(0, 8), - { expectedWorkspaceId: workspaceId } - ) - signalTableSchemaChanged(args.tableId) return { success: true, - message: `Deleted ${names.length} columns: ${names.join(', ')}`, + message: `Deleted ${deletedColumns.length} ${deletedColumns.length === 1 ? 'column' : 'columns'}: ${deletedColumns + .map((column) => column.name) + .join(', ')}`, data: { schema: updated.schema }, } } @@ -1962,7 +1089,12 @@ export const userTableServerTool: BaseServerTool message: 'workflowId is required for list_workflow_outputs', } } - const flattened = await loadFlattenedWorkflowOutputs(workflowId) + const resolvedWorkflow = await resolveAuthorizedWorkflowOutputs( + workflowId, + workspaceId, + context + ) + const flattened = resolvedWorkflow.outputs if (!flattened) { return { success: false, @@ -1997,13 +1129,6 @@ export const userTableServerTool: BaseServerTool message: 'outputs array (with blockId + path entries) is required', } } - const { table: tableForGroup } = await executeCopilotTableUseCase( - context, - readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } - ) - for (const o of rawOutputs) { if (!o.blockId || !o.path) { return { @@ -2013,72 +1138,26 @@ export const userTableServerTool: BaseServerTool } } - const flattened = await loadFlattenedWorkflowOutputs(workflowId) - if (!flattened) { - return { - success: false, - message: `Workflow not found or has no blocks: ${workflowId}`, - } - } - const validationError = validateOutputsAgainstWorkflow( - rawOutputs.map((o) => ({ blockId: o.blockId, path: o.path })), - flattened, - workflowId - ) - if (validationError) { - return { success: false, message: validationError } - } - const leafTypeByKey = new Map( - flattened.map((f) => [`${f.blockId}::${f.path}`, f.leafType]) - ) - - const taken = new Set(tableForGroup.schema.columns.map((c) => c.name)) - const groupId = generateId() - const outputs: WorkflowGroupOutput[] = [] - const outputColumns: ColumnDefinition[] = [] - for (const o of rawOutputs) { - const colName = o.columnName ?? deriveOutputColumnName(o.path, taken) - taken.add(colName) - outputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) - const leafType = o.columnType ?? leafTypeByKey.get(`${o.blockId}::${o.path}`) - outputColumns.push({ - name: colName, - type: columnTypeForLeaf(leafType), - required: false, - unique: false, - workflowGroupId: groupId, - }) - } const dependencies = args.dependencies as WorkflowGroupDependencies | undefined const name = args.name as string | undefined const deploymentMode = parseDeploymentMode(args.deploymentMode) - const group: WorkflowGroup = { - id: groupId, - workflowId, - ...(name ? { name } : {}), - ...(dependencies ? { dependencies } : {}), - ...(deploymentMode ? { deploymentMode } : {}), - outputs, - } assertNotAborted() const autoRun = args.autoRun === true - const { table: updated } = await executeCopilotTableUseCase( - context, - createTableGroupUseCase, - { - tableId: args.tableId, - workspaceId, - group, - outputColumns, - autoRun, - }, - { tableId: args.tableId } - ) + const { table: updated, group } = await executeCopilotCreateWorkflowTableGroup(context, { + tableId: args.tableId, + workspaceId, + workflowId, + outputs: rawOutputs, + name, + dependencies, + deploymentMode, + autoRun, + }) return { success: true, - message: `Added workflow group "${name ?? groupId}" with ${outputs.length} output column(s)`, + message: `Added workflow group "${name ?? group.id}" with ${group.outputs.length} output column(s)`, data: { - groupId, + groupId: group.id, schema: updated.schema, }, } @@ -2091,64 +1170,31 @@ export const userTableServerTool: BaseServerTool if (!groupId) { return { success: false, message: 'groupId is required for update_workflow_group' } } - const { table: tableForUpdate } = await executeCopilotTableUseCase( - context, - readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } - ) - const updateOutputs = args.outputs as WorkflowGroupOutput[] | undefined - if (updateOutputs && updateOutputs.length > 0) { - // Resolve which workflow these outputs apply to: explicit override - // wins, else the existing group's workflowId. - const existingGroup = tableForUpdate.schema.workflowGroups?.find( - (g) => g.id === groupId - ) - const targetWorkflowId = - (args.workflowId as string | undefined) ?? existingGroup?.workflowId - if (!targetWorkflowId) { - return { - success: false, - message: `Cannot validate outputs — workflow group ${groupId} not found and no workflowId provided`, - } - } - const flattened = await loadFlattenedWorkflowOutputs(targetWorkflowId) - if (!flattened) { - return { - success: false, - message: `Workflow not found or has no blocks: ${targetWorkflowId}`, - } - } - const validationError = validateOutputsAgainstWorkflow( - updateOutputs.map((o) => ({ blockId: o.blockId, path: o.path })), - flattened, - targetWorkflowId - ) - if (validationError) { - return { success: false, message: validationError } - } - } + const updateOutputs = args.outputs as + | Array<{ + blockId: string + path: string + columnName?: string + columnType?: string + }> + | undefined + const mappingUpdates = args.mappingUpdates as + | Array<{ columnName: string; blockId: string; path: string }> + | undefined + const explicitWorkflowId = args.workflowId as string | undefined assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - updateTableGroupUseCase, - { - tableId: args.tableId, - workspaceId, - groupId, - workflowId: args.workflowId as string | undefined, - name: args.name as string | undefined, - dependencies: args.dependencies as WorkflowGroupDependencies | undefined, - outputs: updateOutputs, - newOutputColumns: args.newOutputColumns as ColumnDefinition[] | undefined, - mappingUpdates: args.mappingUpdates as - | Array<{ columnName: string; blockId: string; path: string }> - | undefined, - deploymentMode: parseDeploymentMode(args.deploymentMode), - autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, - }, - { tableId: args.tableId } - ) + const { table: updated } = await executeCopilotUpdateWorkflowTableGroup(context, { + tableId: args.tableId, + workspaceId, + groupId, + workflowId: explicitWorkflowId, + name: args.name as string | undefined, + dependencies: args.dependencies as WorkflowGroupDependencies | undefined, + outputs: updateOutputs, + mappingUpdates, + deploymentMode: parseDeploymentMode(args.deploymentMode), + autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, + }) return { success: true, message: `Updated workflow group ${groupId}`, @@ -2190,25 +1236,15 @@ export const userTableServerTool: BaseServerTool message: 'groupId, blockId, and path are required for add_workflow_group_output', } } - const tableForAdd = await getTableById(args.tableId) - if (!tableForAdd || tableForAdd.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await addWorkflowGroupOutput( - { - tableId: args.tableId, - groupId, - blockId, - path, - columnName, - actorUserId: context.userId, - workspaceId, - }, - requestId - ) - signalTableSchemaChanged(args.tableId) + const { table: updated } = await executeCopilotAddWorkflowTableGroupOutput(context, { + tableId: args.tableId, + workspaceId, + groupId, + blockId, + path, + columnName, + }) return { success: true, message: `Added output to workflow group ${groupId}`, @@ -2227,17 +1263,13 @@ export const userTableServerTool: BaseServerTool message: 'groupId and columnName are required for delete_workflow_group_output', } } - const tableForRemove = await getTableById(args.tableId) - if (!tableForRemove || tableForRemove.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await deleteWorkflowGroupOutput( + const { table: updated } = await executeCopilotTableUseCase( + context, + deleteTableGroupOutputUseCase, { tableId: args.tableId, groupId, columnName, workspaceId }, - requestId + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Removed output "${columnName}" from workflow group ${groupId}`, @@ -2371,105 +1403,30 @@ export const userTableServerTool: BaseServerTool if (!enrichmentId) { return { success: false, message: 'enrichmentId is required for add_enrichment' } } - const { getEnrichment } = await import('@/enrichments/registry') - const enrichment = getEnrichment(enrichmentId) - if (!enrichment) { - return { - success: false, - message: `Unknown enrichment "${enrichmentId}". Call list_enrichments to see available ids.`, - } - } - const tableForEnrichment = await getTableById(args.tableId) - if (!tableForEnrichment || tableForEnrichment.workspaceId !== workspaceId) { - return { success: false, message: `Table not found: ${args.tableId}` } - } - - // Validate the input mapping: every required input must be mapped, and - // each mapped column must already exist on the table. const rawMappings = args.inputMappings as | Array<{ inputName: string; columnName: string }> | undefined - const mappingByInput = new Map( - (Array.isArray(rawMappings) ? rawMappings : []).map((m) => [m.inputName, m.columnName]) - ) - const existingColumns = new Set(tableForEnrichment.schema.columns.map((c) => c.name)) - for (const input of enrichment.inputs) { - const mapped = mappingByInput.get(input.id) - if (input.required && !mapped) { - return { - success: false, - message: `Enrichment "${enrichment.name}" requires input "${input.id}" to be mapped to a column`, - } - } - if (mapped && !existingColumns.has(mapped)) { - return { - success: false, - message: `Mapped column "${mapped}" for input "${input.id}" does not exist on table ${args.tableId}`, - } - } - } - const inputMappings: WorkflowGroupInputMapping[] = enrichment.inputs - .filter((input) => mappingByInput.has(input.id)) - .map((input) => ({ - inputName: input.id, - columnName: mappingByInput.get(input.id) as string, - })) - - // Each enrichment output becomes a new column. Names can be overridden - // per output id; otherwise the enrichment's default name is used. - const outputNameOverrides = (args.outputColumnNames ?? {}) as Record - const taken = new Set(tableForEnrichment.schema.columns.map((c) => c.name)) - const groupId = generateId() - const outputs: WorkflowGroupOutput[] = [] - const outputColumns: ColumnDefinition[] = [] - for (const out of enrichment.outputs) { - const desired = (outputNameOverrides[out.id] ?? '').trim() || out.name - const colName = deriveOutputColumnName(desired, taken) - taken.add(colName) - outputs.push({ blockId: '', path: '', outputId: out.id, columnName: colName }) - outputColumns.push({ - name: colName, - type: out.type, - required: false, - unique: false, - workflowGroupId: groupId, - }) - } - - // Default the run dependencies to the mapped input columns so a row - // fires once its inputs are filled. Mothership stages groups silently - // by default (autoRun false) — call run_column to fire rows. - const dependencies = - (args.dependencies as WorkflowGroupDependencies | undefined) ?? - ({ - columns: inputMappings.map((m) => m.columnName), - } satisfies WorkflowGroupDependencies) - const name = (args.name as string | undefined) ?? enrichment.name const autoRun = args.autoRun === true - const group: WorkflowGroup = { - id: groupId, - workflowId: '', - enrichmentId, - name, - type: 'enrichment', - dependencies, - outputs, - inputMappings, - autoRun, - } - const requestId = generateId().slice(0, 8) assertNotAborted() - const updated = await addWorkflowGroup( - { tableId: args.tableId, group, outputColumns, autoRun, actorUserId: context.userId }, - requestId + const { table: updated, group } = await executeCopilotCreateTableEnrichmentGroup( + context, + { + tableId: args.tableId, + workspaceId, + enrichmentId, + inputMappings: Array.isArray(rawMappings) ? rawMappings : undefined, + outputColumnNames: (args.outputColumnNames ?? {}) as Record, + dependencies: args.dependencies as WorkflowGroupDependencies | undefined, + name: args.name as string | undefined, + autoRun, + } ) - signalTableSchemaChanged(args.tableId) return { success: true, - message: `Added enrichment "${name}" with ${outputs.length} output column(s)${ + message: `Added enrichment "${group.name}" with ${group.outputs.length} output column(s)${ autoRun ? ' (auto-run enabled)' : ' (staged — use run_column to fire rows)' }`, - data: { groupId, schema: updated.schema }, + data: { groupId: group.id, schema: updated.schema }, } } diff --git a/apps/sim/lib/table/api/index.ts b/apps/sim/lib/table/api/index.ts index 962b274a4f3..892fe18d6f9 100644 --- a/apps/sim/lib/table/api/index.ts +++ b/apps/sim/lib/table/api/index.ts @@ -1 +1,4 @@ -export { v2TableErrorPolicies } from '@/lib/table/api/route-policies' +export { + internalTableSessionOrExecutorAuth, + v2TableErrorPolicies, +} from '@/lib/table/api/route-policies' diff --git a/apps/sim/lib/table/api/route-policies.test.ts b/apps/sim/lib/table/api/route-policies.test.ts new file mode 100644 index 00000000000..2f5a0ba19e2 --- /dev/null +++ b/apps/sim/lib/table/api/route-policies.test.ts @@ -0,0 +1,190 @@ +/** + * @vitest-environment node + */ + +import { resetEnvMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { MockInvalidBindingError, mockBindDelegation, mockGetSession } = vi.hoisted(() => { + class MockInvalidBindingError extends Error {} + return { + MockInvalidBindingError, + mockBindDelegation: vi.fn(), + mockGetSession: vi.fn(), + } +}) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/auth/internal-delegation', () => ({ + bindInternalExecutorDelegation: mockBindDelegation, + InvalidInternalDelegationBindingError: MockInvalidBindingError, +})) +vi.unmock('@/lib/auth/internal') + +import { + InternalUnauthenticatedError, + internalPlainOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { generateInternalDelegationToken, generateInternalToken } from '@/lib/auth/internal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' +import { v2TableErrorPolicies } from '@/lib/table/api/route-policies' + +afterAll(resetEnvMock) + +describe('internal Table route authentication', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue(null) + mockBindDelegation.mockImplementation(async (delegation, options) => ({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: delegation.subjectUserId, + workspaceId: 'canonical-workspace', + delegationId: delegation.delegationId, + audience: options.audience, + issuedAt: delegation.issuedAt, + expiresAt: delegation.expiresAt, + resourceScope: options.resourceScope, + delegationContext: { + kind: 'workflow_execution', + workflowId: delegation.workflowId, + executionId: delegation.executionId, + }, + })) + }) + + it('binds table scope to the current workflow without trusting route workspace input', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + + const principal = await internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/table-1/groups?workspaceId=forged-workspace', { + headers: { authorization: `Bearer ${token}` }, + }), + { tableId: 'table-1', workspaceId: 'forged-workspace' } + ) + + expect(principal).toMatchObject({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'canonical-workspace', + audience: 'sim:tables', + resourceScope: { tableId: 'table-1' }, + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + expect(mockBindDelegation).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + executionId: 'execution-1', + }), + { audience: 'sim:tables', resourceScope: { tableId: 'table-1' } } + ) + }) + + it('binds transfer resource routes as unscoped Table-domain principals', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + + await internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/imports/import-1', { + headers: { authorization: `Bearer ${token}` }, + }), + { importId: 'import-1' } + ) + + expect(mockBindDelegation).toHaveBeenCalledWith(expect.any(Object), { + audience: 'sim:tables', + resourceScope: undefined, + }) + }) + + it('rejects legacy actorless internal tokens before canonical binding', async () => { + const token = await generateInternalToken() + + await expect( + internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/table-1/groups', { + headers: { authorization: `Bearer ${token}` }, + }), + { tableId: 'table-1' } + ) + ).rejects.toBeInstanceOf(InternalUnauthenticatedError) + expect(mockBindDelegation).not.toHaveBeenCalled() + }) + + it('rejects a token whose current workflow binding no longer exists', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + mockBindDelegation.mockRejectedValue(new MockInvalidBindingError()) + + await expect( + internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/table-1/groups', { + headers: { authorization: `Bearer ${token}` }, + }), + { tableId: 'table-1' } + ) + ).rejects.toBeInstanceOf(InternalUnauthenticatedError) + }) + + it('propagates canonical-binding infrastructure failures', async () => { + const token = await generateInternalDelegationToken({ + subjectUserId: 'user-1', + workflowId: 'workflow-1', + }) + const infrastructureError = new Error('database unavailable') + mockBindDelegation.mockRejectedValue(infrastructureError) + + await expect( + internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/table-1/groups', { + headers: { authorization: `Bearer ${token}` }, + }), + { tableId: 'table-1' } + ) + ).rejects.toBe(infrastructureError) + }) + + it('preserves browser session principals when no executor token is supplied', async () => { + mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + + await expect( + internalTableSessionOrExecutorAuth.authenticate( + new NextRequest('http://localhost/api/table/table-1/groups'), + { tableId: 'table-1' } + ) + ).resolves.toEqual({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + }) + + it('renders an invalid related workflow as 400 on internal and v2 surfaces', async () => { + const error = new OrchestrationError('validation', 'Invalid workflow ID') + + expect(internalPlainOrchestrationErrorPolicy.project(error)).toEqual({ + status: 400, + body: { error: 'Invalid workflow ID' }, + headers: undefined, + }) + const response = v2TableErrorPolicies.default.render(error) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: { code: 'BAD_REQUEST', message: 'Invalid workflow ID' }, + }) + }) +}) diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index 4f0b039202c..41d1eda13ff 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -1,4 +1,9 @@ -import { createV2ResourceConcealmentPolicy, type V2ErrorPolicy } from '@/lib/api/server/routes' +import { + createInternalSessionOrExecutorAuth, + createV2ResourceConcealmentPolicy, + type V2ErrorPolicy, +} from '@/lib/api/server/routes' +import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization' import { TableOperationError } from '@/lib/table/application/errors' import { TableLockedError } from '@/lib/table/mutation-locks' import { @@ -7,6 +12,14 @@ import { v2ErrorForOrchestration, } from '@/app/api/v2/lib/response' +export const internalTableSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({ + audience: TABLE_DELEGATION_AUDIENCE, + resourceScope: (params) => { + const tableId = typeof params.tableId === 'string' ? params.tableId : undefined + return tableId ? { tableId } : undefined + }, +}) + function renderTableError(error: unknown) { if (error instanceof TableOperationError) { return v2ErrorForOrchestration( diff --git a/apps/sim/lib/table/application/authorization.test.ts b/apps/sim/lib/table/application/authorization.test.ts index f5a2ae99f7b..297359a3b42 100644 --- a/apps/sim/lib/table/application/authorization.test.ts +++ b/apps/sim/lib/table/application/authorization.test.ts @@ -122,7 +122,34 @@ describe('table operation authorization', () => { ) }) - it('rejects wrong-audience, expired, cross-workspace, and wrong-table delegations before lookup', async () => { + it('requires delegated scope to match the context in both directions', async () => { + const unscopedPrincipal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'execution-1', + audience: 'sim:tables', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + } + const workspaceContext = { ...authorizationContext, tableId: undefined } + + await authorizeTableOperation(unscopedPrincipal, tableOperations.readImport, workspaceContext) + + await expect( + authorizeTableOperation( + { ...unscopedPrincipal, resourceScope: { tableId: 'table-1' } }, + tableOperations.readImport, + workspaceContext + ) + ).rejects.toMatchObject>({ code: 'forbidden' }) + await expect( + authorizeTableOperation(unscopedPrincipal, tableOperations.read, authorizationContext) + ).rejects.toMatchObject>({ code: 'forbidden' }) + }) + + it('rejects wrong-audience, expired, cross-workspace, unscoped, and wrong-table delegations before lookup', async () => { const base = { kind: 'delegated' as const, serviceId: 'copilot' as const, @@ -150,6 +177,10 @@ describe('table operation authorization', () => { expiresAt: new Date(Date.now() + 60_000), resourceScope: { tableId: 'table-1' }, }) + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() + 60_000), + }) await expectForbidden({ ...base, expiresAt: new Date(Date.now() + 60_000), diff --git a/apps/sim/lib/table/application/authorization.ts b/apps/sim/lib/table/application/authorization.ts index 03612e1d534..85330ac85c9 100644 --- a/apps/sim/lib/table/application/authorization.ts +++ b/apps/sim/lib/table/application/authorization.ts @@ -24,10 +24,9 @@ export const tableDelegationPolicy: WorkspaceDelegationPolicy, context: TableAuthorizationContext ) { - return ( - principal.resourceScope?.tableId === undefined || - principal.resourceScope.tableId === context.tableId - ) + return context.tableId === undefined + ? principal.resourceScope?.tableId === undefined + : principal.resourceScope?.tableId === context.tableId }, } diff --git a/apps/sim/lib/table/application/columns.test.ts b/apps/sim/lib/table/application/columns.test.ts new file mode 100644 index 00000000000..dfa9f41ccc5 --- /dev/null +++ b/apps/sim/lib/table/application/columns.test.ts @@ -0,0 +1,189 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + deleteColumns: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + signal: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/table', () => ({ + TABLE_LIMITS: { MAX_COLUMNS_PER_TABLE: 3 }, + addTableColumn: vi.fn(), + deleteColumn: vi.fn(), + deleteColumns: mocks.deleteColumns, + getColumnId: (column: { id?: string; name: string }) => column.id ?? column.name, +})) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveContext, +})) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) +vi.mock('@/lib/table/orchestration', () => ({ performUpdateTableColumn: vi.fn() })) + +import { deleteTableColumnsUseCase } from '@/lib/table/application/columns' + +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { + columns: [ + { id: 'column-name', name: 'name', type: 'string' }, + { id: 'column-first', name: 'first', type: 'string' }, + { id: 'column-last', name: 'last', type: 'string' }, + ], + }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'owner-1', + archivedAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + resourceScope: { tableId: 'table-1' }, +} + +const tableAfterDelete: TableDefinition = { + ...table, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, + updatedAt: new Date('2026-08-02T00:00:00.000Z'), +} + +describe('multi-column delete application use case', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.deleteColumns.mockResolvedValue(tableAfterDelete) + }) + + it('owns canonical mutation, audit, and schema effects', async () => { + const result = await deleteTableColumnsUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + columnNames: ['first', 'last'], + }, + }) + + expect(mocks.deleteColumns).toHaveBeenCalledWith( + { tableId: 'table-1', columnNames: ['first', 'last'] }, + 'request-1', + { expectedWorkspaceId: 'workspace-1' } + ) + expect(result.deletedColumns).toEqual([ + { id: 'column-first', name: 'first' }, + { id: 'column-last', name: 'last' }, + ]) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Deleted 2 columns from table "People"', + metadata: expect.objectContaining({ columnNames: ['first', 'last'] }), + }) + ) + expect(mocks.signal).toHaveBeenCalledWith('table-1') + }) + + it('derives aliases and duplicate references from the authoritative schema delta', async () => { + mocks.deleteColumns.mockResolvedValue({ + ...table, + schema: { + columns: [ + { id: 'column-name', name: 'name', type: 'string' }, + { id: 'column-last', name: 'last', type: 'string' }, + ], + }, + }) + + const result = await deleteTableColumnsUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + columnNames: ['first', 'column-first', 'FIRST'], + }, + }) + + expect(result.deletedColumns).toEqual([{ id: 'column-first', name: 'first' }]) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Deleted 1 column from table "People"', + metadata: expect.objectContaining({ columnNames: ['first'] }), + }) + ) + }) + + it('rejects an oversized request before mutation', async () => { + await expect( + deleteTableColumnsUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + columnNames: ['first', 'last', 'name', 'extra'], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.deleteColumns).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('rejects admission before mutation when delegated scope is stale', async () => { + mocks.resolvePermission.mockResolvedValueOnce('read') + + await expect( + deleteTableColumnsUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + columnNames: ['first', 'last'], + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.deleteColumns).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/columns.ts b/apps/sim/lib/table/application/columns.ts index eae6c59b4f7..4a56b2c1b4a 100644 --- a/apps/sim/lib/table/application/columns.ts +++ b/apps/sim/lib/table/application/columns.ts @@ -1,12 +1,16 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { addTableColumn, type ColumnDefinition, type ColumnType, deleteColumn, + deleteColumns, + getColumnId, type SelectOption, + TABLE_LIMITS, type TableDefinition, } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' @@ -156,5 +160,61 @@ export const deleteTableColumnUseCase = defineAuthorizedTableUseCase({ }, }) +export interface DeleteTableColumnsInput extends TableColumnInput { + columnNames: string[] +} + +interface DeletedTableColumn { + id: string + name: string +} + +export const deleteTableColumnsUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteColumn, + resolveContext: ({ input }: { input: DeleteTableColumnsInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }): Promise<{ + table: TableDefinition + deletedColumns: DeletedTableColumn[] + }> { + if (input.columnNames.length < 1) { + throw new OrchestrationError('validation', 'At least one column name is required') + } + if (input.columnNames.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Cannot delete more than ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} columns` + ) + } + const table = await deleteColumns( + { tableId: context.table.id, columnNames: input.columnNames }, + generateRequestId(), + { expectedWorkspaceId: context.workspaceId } + ) + const remainingColumnIds = new Set(table.schema.columns.map(getColumnId)) + const deletedColumns = context.table.schema.columns + .filter((column) => !remainingColumnIds.has(getColumnId(column))) + .map((column) => ({ id: getColumnId(column), name: column.name })) + return { table, deletedColumns } + }, + projectAudit({ context, result }) { + if (result.deletedColumns.length === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted ${result.deletedColumns.length} ${result.deletedColumns.length === 1 ? 'column' : 'columns'} from table "${context.table.name}"`, + metadata: { columnNames: result.deletedColumns.map((column) => column.name) }, + } + }, + afterSuccess({ context, result }) { + if (result.deletedColumns.length > 0) signalTableSchemaChanged(context.table.id) + }, +}) + export type TableColumnApplicationResult = { table: TableDefinition } export type TableColumnDefinition = ColumnDefinition diff --git a/apps/sim/lib/table/application/copilot-bulk-rows.test.ts b/apps/sim/lib/table/application/copilot-bulk-rows.test.ts new file mode 100644 index 00000000000..031893dcff6 --- /dev/null +++ b/apps/sim/lib/table/application/copilot-bulk-rows.test.ts @@ -0,0 +1,242 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + batchUpdate: vi.fn(), + deleteByFilter: vi.fn(), + markJob: vi.fn(), + releaseJob: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + signal: vi.fn(), + translateFilter: vi.fn(), + updateByFilter: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mocks.audit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/utils/id', () => ({ + generateId: vi.fn(() => 'job-12345678'), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: vi.fn() })) + +vi.mock('@/lib/table', () => ({ + batchUpdateRows: mocks.batchUpdate, + CSV_MAX_BATCH_SIZE: 1000, + deleteRowsByFilter: mocks.deleteByFilter, + queryRows: vi.fn(), + rowDataNameToId: (data: Record) => data, + TABLE_LIMITS: { MAX_BULK_OPERATION_SIZE: 1000 }, + updateRowsByFilter: mocks.updateByFilter, +})) + +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveContext, +})) + +vi.mock('@/lib/table/application/rows', () => ({ + tablePredicateNamesToFilter: mocks.translateFilter, +})) + +vi.mock('@/lib/table/column-keys', () => ({ buildIdByName: () => new Map() })) +vi.mock('@/lib/table/delete-runner', () => ({ + markTableDeleteFailed: vi.fn(), + runTableDelete: vi.fn(), +})) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mocks.signal })) +vi.mock('@/lib/table/jobs/service', () => ({ + markTableJobRunningInWorkspace: mocks.markJob, + releaseJobClaimInWorkspace: mocks.releaseJob, +})) +vi.mock('@/lib/table/mutation-locks', () => ({ + assertRowDelete: vi.fn(), + assertRowUpdate: vi.fn(), + patchColumnIds: () => [], +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: () => ({ complete: true, columns: {} }), +})) +vi.mock('@/lib/table/update-runner', () => ({ + markTableUpdateFailed: vi.fn(), + runTableUpdate: vi.fn(), +})) + +import { + copilotBatchUpdateRows, + copilotDeleteRowsByFilter, + copilotUpdateRowsByFilter, +} from '@/lib/table/application/copilot-bulk-rows' + +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { columns: [{ id: 'column-1', name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 2, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'owner-1', + archivedAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + resourceScope: { tableId: 'table-1' }, +} + +const input = { + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + filter: { all: [] as [] }, + data: { name: 'Ada' }, + limit: 1, +} + +describe('Copilot bulk row application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.translateFilter.mockReturnValue({}) + mocks.updateByFilter.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) + mocks.deleteByFilter.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) + mocks.batchUpdate.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) + mocks.markJob.mockResolvedValue(true) + mocks.releaseJob.mockResolvedValue(true) + }) + + it('rejects delegated resource-scope mismatches before mutation', async () => { + await expect( + copilotUpdateRowsByFilter.execute({ + principal: { ...principal, resourceScope: { tableId: 'table-other' } }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.updateByFilter).not.toHaveBeenCalled() + }) + + it('rejects current permission loss before mutation', async () => { + mocks.resolvePermission.mockResolvedValueOnce('read') + + await expect(copilotUpdateRowsByFilter.execute({ principal, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(mocks.updateByFilter).not.toHaveBeenCalled() + }) + + it('projects audit and shared effects only from an authoritative mutation result', async () => { + await copilotUpdateRowsByFilter.execute({ principal, input }) + + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'table.updated', + resourceId: 'table-1', + metadata: expect.objectContaining({ operation: 'tables.rows.update_many', rowsUpdated: 1 }), + }) + ) + expect(mocks.signal).toHaveBeenCalledWith('table-1') + + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.translateFilter.mockReturnValue({}) + mocks.updateByFilter.mockResolvedValue({ affectedCount: 0, affectedRowIds: [] }) + + await copilotUpdateRowsByFilter.execute({ principal, input }) + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('releases inline delete claims and audits the committed count', async () => { + await copilotDeleteRowsByFilter.execute({ + principal, + input: { + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + filter: { all: [] }, + limit: 1, + }, + }) + + expect(mocks.deleteByFilter).toHaveBeenCalledTimes(1) + expect(mocks.releaseJob).toHaveBeenCalledWith('table-1', 'workspace-1', 'job-12345678') + expect(mocks.audit).toHaveBeenCalledTimes(1) + }) + + it('runs batch mutation behavior behind the same delegated boundary', async () => { + await copilotBatchUpdateRows.execute({ + principal, + input: { + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + updates: [{ rowId: 'row-1', data: { name: 'Grace' } }], + }, + }) + + expect(mocks.batchUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'workspace-1', + actorUserId: 'user-1', + }), + table, + 'job-1234' + ) + }) + + it('propagates unknown infrastructure failures without audit or effects', async () => { + const failure = new Error('database host unavailable') + mocks.updateByFilter.mockRejectedValueOnce(failure) + + await expect(copilotUpdateRowsByFilter.execute({ principal, input })).rejects.toBe(failure) + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/copilot-bulk-rows.ts b/apps/sim/lib/table/application/copilot-bulk-rows.ts new file mode 100644 index 00000000000..cd4393fe5ef --- /dev/null +++ b/apps/sim/lib/table/application/copilot-bulk-rows.ts @@ -0,0 +1,424 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { + batchUpdateRows, + CSV_MAX_BATCH_SIZE, + deleteRowsByFilter, + type Filter, + queryRows, + type RowData, + rowDataNameToId, + TABLE_LIMITS, + type TableDeleteJobPayload, + type TablePredicate, + type TableUpdateJobPayload, + updateRowsByFilter, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { resolveActiveTableContext } from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { tablePredicateNamesToFilter } from '@/lib/table/application/rows' +import { buildIdByName } from '@/lib/table/column-keys' +import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner' +import { signalTableRowsChanged } from '@/lib/table/events' +import { + markTableJobRunningInWorkspace, + releaseJobClaimInWorkspace, +} from '@/lib/table/jobs/service' +import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' +import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' +import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner' + +const logger = createLogger('CopilotBulkRowsApplication') + +interface CopilotBulkRowsInput { + tableId: string + assertedWorkspaceId: string +} + +export interface CopilotUpdateRowsByFilterInput extends CopilotBulkRowsInput { + filter: TablePredicate + data: RowData + limit?: number +} + +export type CopilotUpdateRowsByFilterResult = + | { kind: 'inline'; affectedCount: number; affectedRowIds: string[] } + | { kind: 'background'; affectedCount: number; jobId: string } + +export interface CopilotDeleteRowsByFilterInput extends CopilotBulkRowsInput { + filter: TablePredicate + limit?: number +} + +export type CopilotDeleteRowsByFilterResult = + | { kind: 'inline'; affectedCount: number; affectedRowIds: string[] } + | { kind: 'background'; doomedCount: number; jobId: string; bounded: boolean } + +export interface CopilotBatchUpdateRowsInput extends CopilotBulkRowsInput { + updates: Array<{ rowId: string; data: RowData }> +} + +export interface CopilotBatchUpdateRowsResult { + affectedCount: number + affectedRowIds: string[] +} + +function requestId(): string { + return generateId().slice(0, 8) +} + +function validateLimit(limit: number | undefined): void { + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) { + throw new OrchestrationError('validation', 'Limit must be an integer of at least 1') + } +} + +async function releaseClaim(tableId: string, workspaceId: string, jobId: string): Promise { + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) throw new Error('Table job claim was no longer active') +} + +async function withReleasedClaim( + tableId: string, + workspaceId: string, + jobId: string, + run: () => Promise +): Promise { + let result: T + try { + result = await run() + } catch (error) { + try { + await releaseClaim(tableId, workspaceId, jobId) + } catch (cleanupError) { + logger.error('Failed to release table job claim after operation failure', { + tableId, + workspaceId, + jobId, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + await releaseClaim(tableId, workspaceId, jobId) + return result +} + +async function releaseClaimAfterDispatchFailure(params: { + tableId: string + workspaceId: string + jobId: string +}): Promise { + try { + await releaseClaim(params.tableId, params.workspaceId, params.jobId) + } catch (cleanupError) { + logger.error('Failed to release table job claim after dispatch failure', { + ...params, + error: getErrorMessage(cleanupError), + }) + } +} + +async function dispatchUpdateJob(params: { + jobId: string + tableId: string + workspaceId: string + filter: Filter + data: RowData + cutoff: Date + maxRows?: number +}): Promise { + if (isTriggerDevEnabled) { + try { + const [{ tableUpdateTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-update'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger( + 'table-update', + { ...params, cutoff: params.cutoff.toISOString() }, + { + tags: [`tableId:${params.tableId}`, `jobId:${params.jobId}`], + region: await resolveTriggerRegion(), + } + ) + } catch (error) { + await releaseClaimAfterDispatchFailure(params) + throw error + } + return + } + runDetached('table-update', () => + runTableUpdate(params).catch(async (error) => { + await markTableUpdateFailed(params.tableId, params.jobId, error) + throw error + }) + ) +} + +async function dispatchDeleteJob(params: { + jobId: string + tableId: string + workspaceId: string + filter: Filter + cutoff: Date + maxRows?: number +}): Promise { + if (isTriggerDevEnabled) { + try { + const [{ tableDeleteTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([ + import('@/background/table-delete'), + import('@trigger.dev/sdk'), + import('@/lib/core/async-jobs/region'), + ]) + await tasks.trigger( + 'table-delete', + { ...params, cutoff: params.cutoff.toISOString() }, + { + tags: [`tableId:${params.tableId}`, `jobId:${params.jobId}`], + region: await resolveTriggerRegion(), + } + ) + } catch (error) { + await releaseClaimAfterDispatchFailure(params) + throw error + } + return + } + runDetached('table-delete', () => + runTableDelete(params).catch(async (error) => { + await markTableDeleteFailed(params.tableId, params.jobId, error) + throw error + }) + ) +} + +export const copilotUpdateRowsByFilter = defineAuthorizedTableUseCase({ + operation: tableOperations.updateRows, + resolveContext: ({ input }: { input: CopilotUpdateRowsByFilterInput }) => + resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise { + validateLimit(input.limit) + const idData = rowDataNameToId(input.data, buildIdByName(context.table.schema)) + const filter = tablePredicateNamesToFilter(input.filter, context.table) + const patchTouchesUnique = context.table.schema.columns.some( + (column) => column.unique === true && (column.id ?? column.name) in idData + ) + const inlineEligible = + input.limit !== undefined && input.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + + if (!inlineEligible && !patchTouchesUnique) { + const { totalCount } = await queryRows( + context.table, + { filter, limit: 1, withExecutions: false }, + requestId() + ) + const matchCount = totalCount ?? 0 + const target = input.limit === undefined ? matchCount : Math.min(input.limit, matchCount) + if (target > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { + const cutoff = new Date() + const jobId = generateId() + const payload: TableUpdateJobPayload = { + filter, + data: idData, + cutoff: cutoff.toISOString(), + affectedCount: target, + maxRows: input.limit, + } + assertRowUpdate(context.table, patchColumnIds(idData)) + const claimed = await markTableJobRunningInWorkspace( + context.tableId, + context.workspaceId, + jobId, + 'update', + payload + ) + if (!claimed) { + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + } + await dispatchUpdateJob({ + jobId, + tableId: context.tableId, + workspaceId: context.workspaceId, + filter, + data: idData, + cutoff, + maxRows: input.limit, + }) + return { kind: 'background', affectedCount: target, jobId } + } + } + + const result = await updateRowsByFilter( + context.table, + { + filter, + data: idData, + limit: input.limit, + actorUserId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + secretProvenance: createExactEmptyTableRowSecretProvenance(idData), + }, + requestId() + ) + return { kind: 'inline', ...result } + }, + projectAudit({ context, result }) { + if (result.kind !== 'inline' || result.affectedCount === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.tableId, + resourceName: context.table.name, + description: `Updated ${result.affectedCount} row(s) in table "${context.table.name}"`, + metadata: { op: 'bulk_update', rowsUpdated: result.affectedCount }, + } + }, + afterSuccess({ context, result }) { + if (result.kind === 'inline' && result.affectedCount > 0) { + signalTableRowsChanged(context.tableId) + } + }, +}) + +export const copilotDeleteRowsByFilter = defineAuthorizedTableUseCase({ + operation: tableOperations.deleteRows, + resolveContext: ({ input }: { input: CopilotDeleteRowsByFilterInput }) => + resolveActiveTableContext(input), + async execute({ input, context }): Promise { + validateLimit(input.limit) + const filter = tablePredicateNamesToFilter(input.filter, context.table) + const inlineEligible = + input.limit !== undefined && input.limit <= TABLE_LIMITS.MAX_BULK_OPERATION_SIZE + + if (!inlineEligible) { + const { totalCount } = await queryRows( + context.table, + { filter, limit: 1, withExecutions: false }, + requestId() + ) + const matchCount = totalCount ?? 0 + const target = input.limit === undefined ? matchCount : Math.min(input.limit, matchCount) + if (target > TABLE_LIMITS.MAX_BULK_OPERATION_SIZE) { + const doomedCount = Math.min(target, context.table.rowCount) + const cutoff = new Date() + const jobId = generateId() + const bounded = input.limit !== undefined + const payload: TableDeleteJobPayload = bounded + ? { filter, cutoff: cutoff.toISOString(), maxRows: input.limit } + : { filter, cutoff: cutoff.toISOString(), doomedCount } + assertRowDelete(context.table) + const claimed = await markTableJobRunningInWorkspace( + context.tableId, + context.workspaceId, + jobId, + 'delete', + payload + ) + if (!claimed) { + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + } + await dispatchDeleteJob({ + jobId, + tableId: context.tableId, + workspaceId: context.workspaceId, + filter, + cutoff, + maxRows: input.limit, + }) + return { kind: 'background', doomedCount, jobId, bounded } + } + } + + const jobId = generateId() + const claimed = await markTableJobRunningInWorkspace( + context.tableId, + context.workspaceId, + jobId, + 'delete' + ) + if (!claimed) { + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + } + const result = await withReleasedClaim(context.tableId, context.workspaceId, jobId, () => + deleteRowsByFilter(context.table, { filter, limit: input.limit }, requestId()) + ) + return { kind: 'inline', ...result } + }, + projectAudit({ context, result }) { + if (result.kind !== 'inline' || result.affectedCount === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.tableId, + resourceName: context.table.name, + description: `Deleted ${result.affectedCount} row(s) from table "${context.table.name}"`, + metadata: { op: 'bulk_delete', rowsDeleted: result.affectedCount }, + } + }, + afterSuccess({ context, result }) { + if (result.kind === 'inline' && result.affectedCount > 0) { + signalTableRowsChanged(context.tableId) + } + }, +}) + +export const copilotBatchUpdateRows = defineAuthorizedTableUseCase({ + operation: tableOperations.updateRows, + resolveContext: ({ input }: { input: CopilotBatchUpdateRowsInput }) => + resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise { + if (input.updates.length < 1 || input.updates.length > CSV_MAX_BATCH_SIZE) { + throw new OrchestrationError( + 'validation', + `Batch update count must be between 1 and ${CSV_MAX_BATCH_SIZE}` + ) + } + const idByName = buildIdByName(context.table.schema) + const updates = input.updates.map((update) => ({ + rowId: update.rowId, + data: rowDataNameToId(update.data, idByName), + })) + return batchUpdateRows( + { + tableId: context.tableId, + updates, + workspaceId: context.workspaceId, + actorUserId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + secretProvenanceByRowId: Object.fromEntries( + updates.map((update) => [ + update.rowId, + createExactEmptyTableRowSecretProvenance(update.data), + ]) + ), + }, + context.table, + requestId() + ) + }, + projectAudit({ context, result }) { + if (result.affectedCount === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: context.tableId, + resourceName: context.table.name, + description: `Updated ${result.affectedCount} row(s) in table "${context.table.name}"`, + metadata: { op: 'batch_update', rowsUpdated: result.affectedCount }, + } + }, + afterSuccess({ context, result }) { + if (result.affectedCount > 0) signalTableRowsChanged(context.tableId) + }, +}) diff --git a/apps/sim/lib/table/application/copilot-table-lifecycle.test.ts b/apps/sim/lib/table/application/copilot-table-lifecycle.test.ts new file mode 100644 index 00000000000..51e04aa5bad --- /dev/null +++ b/apps/sim/lib/table/application/copilot-table-lifecycle.test.ts @@ -0,0 +1,196 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + deleteTable: vi.fn(), + resolveActiveTableContext: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkspaceContext: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_DELETED: 'table.deleted' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/table', () => ({ + deleteTable: mocks.deleteTable, + TABLE_LIMITS: { MAX_TABLES_PER_WORKSPACE: 100 }, +})) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveActiveTableContext, + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, +})) + +import { deleteCopilotTables } from '@/lib/table/application/copilot-table-lifecycle' + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + resourceScope: { chatId: 'chat-1' }, +} + +describe('deleteCopilotTables', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveWorkspaceContext.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolveActiveTableContext.mockImplementation( + async ({ tableId }: { tableId: string }) => ({ + tableId, + workspaceId: 'workspace-1', + }) + ) + mocks.deleteTable.mockImplementation(async (tableId: string) => ({ + archived: { name: `Table ${tableId}`, workspaceId: 'workspace-1' }, + })) + }) + + it('canonically resolves each table and audits each authoritative archive', async () => { + const assertNotAborted = vi.fn() + const result = await deleteCopilotTables.execute({ + principal, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + assertNotAborted, + }, + }) + + expect(result).toEqual({ + deleted: [ + { id: 'table-1', name: 'Table table-1' }, + { id: 'table-2', name: 'Table table-2' }, + ], + failed: [], + }) + expect(mocks.resolveActiveTableContext).toHaveBeenNthCalledWith(1, { + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.resolveActiveTableContext).toHaveBeenNthCalledWith(2, { + tableId: 'table-2', + assertedWorkspaceId: 'workspace-1', + }) + expect(assertNotAborted).toHaveBeenCalledTimes(2) + expect(mocks.audit).toHaveBeenCalledTimes(2) + expect(mocks.audit).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ resourceId: 'table-1', resourceName: 'Table table-1' }) + ) + }) + + it('conceals a cross-workspace table as a best-effort miss', async () => { + mocks.resolveActiveTableContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Table not found') + ) + + await expect( + deleteCopilotTables.execute({ + principal, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-other'], + assertNotAborted: vi.fn(), + }, + }) + ).resolves.toEqual({ deleted: [], failed: ['table-other'] }) + + expect(mocks.deleteTable).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rejects admission before canonical loads or mutation', async () => { + mocks.resolvePermission.mockResolvedValueOnce(null) + + await expect( + deleteCopilotTables.execute({ + principal, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-1'], + assertNotAborted: vi.fn(), + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveActiveTableContext).not.toHaveBeenCalled() + expect(mocks.deleteTable).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('does not project partial audit when the compound command fails', async () => { + const failure = new Error('delete storage unavailable') + mocks.deleteTable + .mockResolvedValueOnce({ + archived: { name: 'Table table-1', workspaceId: 'workspace-1' }, + }) + .mockRejectedValueOnce(failure) + + await expect( + deleteCopilotTables.execute({ + principal, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + assertNotAborted: vi.fn(), + }, + }) + ).rejects.toBe(failure) + + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('checks cancellation immediately before each archive and stops partial progress', async () => { + const canceled = new Error('Request aborted before tool mutation could be applied') + const assertNotAborted = vi + .fn() + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw canceled + }) + + await expect( + deleteCopilotTables.execute({ + principal, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + assertNotAborted, + }, + }) + ).rejects.toBe(canceled) + + expect(mocks.resolveActiveTableContext).toHaveBeenCalledTimes(2) + expect(mocks.deleteTable).toHaveBeenCalledTimes(1) + expect(mocks.deleteTable).toHaveBeenCalledWith('table-1', 'request-1', { + expectedWorkspaceId: 'workspace-1', + }) + expect(mocks.audit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/copilot-table-lifecycle.ts b/apps/sim/lib/table/application/copilot-table-lifecycle.ts new file mode 100644 index 00000000000..57369da7510 --- /dev/null +++ b/apps/sim/lib/table/application/copilot-table-lifecycle.ts @@ -0,0 +1,85 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { deleteTable, TABLE_LIMITS } from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' + +export interface DeleteCopilotTablesInput { + workspaceId: string + tableIds: string[] + assertNotAborted: () => void +} + +export interface ArchivedCopilotTable { + id: string + name: string +} + +export interface DeleteCopilotTablesResult { + deleted: ArchivedCopilotTable[] + failed: string[] +} + +/** Owns Copilot's ordered, best-effort multi-table archive operation. */ +export const deleteCopilotTables = defineAuthorizedTableUseCase({ + operation: tableOperations.delete, + resolveContext: ({ input }: { input: DeleteCopilotTablesInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ input, context }): Promise { + if ( + input.tableIds.length < 1 || + input.tableIds.length > TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE + ) { + throw new OrchestrationError( + 'validation', + `Table ID count must be between 1 and ${TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE}` + ) + } + if (input.tableIds.some((tableId) => typeof tableId !== 'string' || !tableId.trim())) { + throw new OrchestrationError('validation', 'Each table ID must be a non-empty string') + } + + const deleted: ArchivedCopilotTable[] = [] + const failed: string[] = [] + + for (const tableId of input.tableIds) { + try { + const tableContext = await resolveActiveTableContext({ + tableId, + assertedWorkspaceId: context.workspaceId, + }) + input.assertNotAborted() + const { archived } = await deleteTable(tableContext.tableId, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + }) + if (!archived) { + failed.push(tableId) + continue + } + + deleted.push({ id: tableId, name: archived.name }) + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') { + failed.push(tableId) + continue + } + throw error + } + } + + return { deleted, failed } + }, + projectAudit: ({ result }) => + result.deleted.map((table) => ({ + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Archived table "${table.name}"`, + })), +}) diff --git a/apps/sim/lib/table/application/exports.test.ts b/apps/sim/lib/table/application/exports.test.ts new file mode 100644 index 00000000000..736ecd7c0c2 --- /dev/null +++ b/apps/sim/lib/table/application/exports.test.ts @@ -0,0 +1,167 @@ +/** + * @vitest-environment node + */ + +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + cancel: vi.fn(), + create: vi.fn(), + getTable: vi.fn(), + require: vi.fn(), + resolveContext: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_EXPORTED: 'table.exported' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: vi.fn(), +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: vi.fn(), +})) +vi.mock('@/lib/table', () => ({ getTableById: mocks.getTable })) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveContext, + resolveTableWorkspaceContext: vi.fn(async (workspaceId: string) => ({ + workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + })), +})) +vi.mock('@/lib/table/orchestration/export-resource', () => ({ + cancelTableExportResource: mocks.cancel, + createTableExportResource: mocks.create, + requireTableExport: mocks.require, + tableExportResult: vi.fn(), +})) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + generatePresignedDownloadUrl: vi.fn(), +})) + +import { + cancelTableExportUseCase, + createTableExportUseCase, + readTableExportUseCase, +} from '@/lib/table/application/exports' + +const now = new Date('2026-08-01T00:00:00.000Z') +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { columns: [] }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'owner-1', + archivedAt: null, + createdAt: now, + updatedAt: now, +} +const record = { + id: 'export-1', + tableId: 'table-1', + workspaceId: 'workspace-1', + type: 'export', + status: 'running', + payload: { format: 'csv' }, + rowsProcessed: 0, + error: null, + startedAt: now, + updatedAt: now, + completedAt: null, +} +const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', +} +const executor: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + resourceScope: { tableId: 'table-1' }, + delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, +} + +describe('table export application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.getTable.mockResolvedValue(table) + mocks.create.mockResolvedValue(record) + mocks.require.mockResolvedValue(record) + mocks.cancel.mockResolvedValue({ ...record, status: 'canceled' }) + }) + + it('returns domain records for create and read operations', async () => { + await expect( + createTableExportUseCase.execute({ + principal, + input: { tableId: 'table-1', workspaceId: 'workspace-1', format: 'csv' }, + }) + ).resolves.toEqual({ export: record }) + + await expect( + readTableExportUseCase.execute({ + principal, + input: { exportId: 'export-1', workspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ export: record }) + + expect(record.startedAt).toBeInstanceOf(Date) + }) + + it('returns the authoritative canceled domain record', async () => { + await expect( + cancelTableExportUseCase.execute({ + principal, + input: { exportId: 'export-1', workspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ export: { status: 'canceled', startedAt: now } }) + }) + + it('supports exact table-scoped executor create and unscoped resource reads', async () => { + await expect( + createTableExportUseCase.execute({ + principal: executor, + input: { tableId: 'table-1', workspaceId: 'workspace-1', format: 'csv' }, + }) + ).resolves.toEqual({ export: record }) + await expect( + readTableExportUseCase.execute({ + principal: { ...executor, resourceScope: undefined }, + input: { exportId: 'export-1', workspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ export: record }) + }) + + it('rejects a mismatched executor table scope before export mutation', async () => { + await expect( + createTableExportUseCase.execute({ + principal: { ...executor, resourceScope: { tableId: 'table-other' } }, + input: { tableId: 'table-1', workspaceId: 'workspace-1', format: 'csv' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.create).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/exports.ts b/apps/sim/lib/table/application/exports.ts index 9ff24c64d42..83414144bce 100644 --- a/apps/sim/lib/table/application/exports.ts +++ b/apps/sim/lib/table/application/exports.ts @@ -1,6 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { createLogger } from '@sim/logger' -import type { V2TableExport } from '@/lib/api/contracts/v2/tables' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getTableById, type TableDefinition } from '@/lib/table' import type { TableAuthorizationContext } from '@/lib/table/application/authorization' @@ -16,7 +15,6 @@ import { requireTableExport, type TableExportRecord, tableExportResult, - toV2TableExport, } from '@/lib/table/orchestration/export-resource' import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' @@ -35,7 +33,7 @@ export interface TableExportResourceInput { } export interface TableExportResult { - export: V2TableExport + export: TableExportRecord } export interface DownloadTableExportResult { @@ -46,7 +44,6 @@ export interface DownloadTableExportResult { interface TableExportContext extends TableAuthorizationContext { exportId: string - tableId: string table: TableDefinition record: TableExportRecord } @@ -63,7 +60,6 @@ async function resolveTableExportContext( return { ...workspace, exportId: record.id, - tableId: table.id, table, record, } @@ -85,7 +81,7 @@ export const createTableExportUseCase = defineAuthorizedTableUseCase({ format: input.format, principalKind: principal.kind, }) - return { export: toV2TableExport(record, true) } + return { export: record } }, projectAudit: ({ input, context }) => ({ action: AuditAction.TABLE_EXPORTED, @@ -102,7 +98,7 @@ export const readTableExportUseCase = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: TableExportResourceInput }) => resolveTableExportContext(input), async execute({ context }): Promise { - return { export: toV2TableExport(context.record) } + return { export: context.record } }, }) @@ -114,11 +110,11 @@ export const cancelTableExportUseCase = defineAuthorizedTableUseCase({ const record = await cancelTableExportResource(context.record) logger.info('Canceled table export', { exportId: record.id, - tableId: context.tableId, + tableId: context.table.id, workspaceId: context.workspaceId, principalKind: principal.kind, }) - return { export: toV2TableExport(record) } + return { export: record } }, }) diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts new file mode 100644 index 00000000000..228467271b3 --- /dev/null +++ b/apps/sim/lib/table/application/groups.test.ts @@ -0,0 +1,584 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { TableDefinition, WorkflowGroup } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + addGroup: vi.fn(), + addOutput: vi.fn(), + audit: vi.fn(), + deleteOutput: vi.fn(), + getEnrichment: vi.fn(), + loadWorkflowOutputs: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), + signal: vi.fn(), + updateGroup: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/utils/id', () => ({ generateId: () => 'generated-id' })) +vi.mock('@/enrichments/registry', () => ({ getEnrichment: mocks.getEnrichment })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: vi.fn() })) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveContext, +})) +vi.mock('@/lib/table/column-naming', () => ({ + columnTypeForLeaf: (leafType: string | undefined) => + leafType === 'number' ? 'number' : 'string', + deriveOutputColumnName: (path: string, taken: Set) => { + const base = path.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() + if (!taken.has(base)) return base + return `${base}_0` + }, +})) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) +vi.mock('@/lib/table/workflow-columns', () => ({ runWorkflowColumn: vi.fn() })) +vi.mock('@/lib/table/workflow-groups/service', () => ({ + addWorkflowGroup: mocks.addGroup, + addWorkflowGroupOutput: mocks.addOutput, + deleteWorkflowGroup: vi.fn(), + deleteWorkflowGroupOutput: mocks.deleteOutput, + updateWorkflowGroup: mocks.updateGroup, +})) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) +vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ + loadResolvedWorkflowOutputs: mocks.loadWorkflowOutputs, +})) + +import { + addWorkflowTableGroupOutput, + createTableEnrichmentGroup, + createTableGroupUseCase, + createWorkflowTableGroup, + deleteTableGroupOutputUseCase, + updateTableGroupUseCase, + updateWorkflowTableGroup, +} from '@/lib/table/application/groups' + +const group: WorkflowGroup = { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'column-result' }], +} +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { + columns: [ + { id: 'column-name', name: 'name', type: 'string' }, + { id: 'column-result', name: 'result', type: 'string', workflowGroupId: 'group-1' }, + ], + workflowGroups: [group], + }, + metadata: null, + rowCount: 1, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'owner-1', + archivedAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + resourceScope: { tableId: 'table-1' }, +} +const resolvedWorkflow = { + workflowId: 'workflow-1', + outputs: [ + { + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + path: 'content', + leafType: 'string', + }, + { + blockId: 'block-2', + blockName: 'Scorer', + blockType: 'function', + path: 'score', + leafType: 'number', + }, + ], + executionOrderByBlockId: { 'block-1': 1, 'block-2': 2 }, +} + +function tableWithGroup(nextGroup: WorkflowGroup, columns = table.schema.columns): TableDefinition { + return { + ...table, + schema: { ...table.schema, columns, workflowGroups: [nextGroup] }, + updatedAt: new Date('2026-08-02T00:00:00.000Z'), + } +} + +describe('workflow and enrichment Table application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolveWorkflowContext.mockResolvedValue({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + }) + mocks.loadWorkflowOutputs.mockResolvedValue(resolvedWorkflow) + mocks.addGroup.mockImplementation(async ({ group: nextGroup, outputColumns }) => + tableWithGroup(nextGroup, [...table.schema.columns, ...outputColumns]) + ) + mocks.addOutput.mockResolvedValue(table) + mocks.deleteOutput.mockResolvedValue(table) + mocks.updateGroup.mockImplementation(async (input) => + tableWithGroup({ + ...group, + ...(input.workflowId ? { workflowId: input.workflowId } : {}), + ...(input.name ? { name: input.name } : {}), + ...(input.outputs ? { outputs: input.outputs } : {}), + ...(input.autoRun !== undefined ? { autoRun: input.autoRun } : {}), + }) + ) + mocks.getEnrichment.mockReturnValue({ + id: 'company-domain', + name: 'Company Domain', + inputs: [{ id: 'company', name: 'Company', type: 'string', required: true }], + outputs: [{ id: 'domain', name: 'domain', type: 'string' }], + }) + }) + + it('owns workflow resolution plus group and column construction', async () => { + const result = await createWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + workflowId: 'workflow-1', + name: 'Scoring', + outputs: [{ blockId: 'block-2', path: 'score' }], + }, + }) + + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.addGroup).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: table.id, + workspaceId: table.workspaceId, + group: expect.objectContaining({ + id: 'generated-id', + workflowId: 'workflow-1', + name: 'Scoring', + autoRun: false, + outputs: [{ blockId: 'block-2', path: 'score', columnName: 'score' }], + }), + outputColumns: [ + expect.objectContaining({ + name: 'score', + type: 'number', + workflowGroupId: 'generated-id', + }), + ], + }), + 'request-1' + ) + expect(result.group.id).toBe('generated-id') + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) + + it('persists disabled auto-run on a newly created workflow group', async () => { + const result = await createWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-2', path: 'score' }], + autoRun: false, + }, + }) + + expect(result.group.autoRun).toBe(false) + expect(mocks.addGroup).toHaveBeenCalledWith( + expect.objectContaining({ + group: expect.objectContaining({ autoRun: false }), + autoRun: false, + }), + 'request-1' + ) + }) + + it('conceals a cross-workspace workflow before group mutation or effects', async () => { + mocks.resolveWorkflowContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Workflow not found') + ) + + await expect( + createWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + workflowId: 'workflow-other', + outputs: [{ blockId: 'block-2', path: 'score' }], + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: 'workflow-other', + assertedWorkspaceId: table.workspaceId, + }) + expect(mocks.addGroup).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('preserves the internal create contract for an invalid related workflow', async () => { + mocks.resolveWorkflowContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Workflow not found') + ) + + await expect( + createTableGroupUseCase.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + group: { + id: 'group-new', + workflowId: 'workflow-other', + outputs: [{ blockId: 'block-2', path: 'score', columnName: 'score' }], + }, + outputColumns: [{ name: 'score', type: 'number' }], + }, + }) + ).rejects.toMatchObject({ code: 'validation', message: 'Invalid workflow ID' }) + + expect(mocks.addGroup).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('preserves the internal update contract for an invalid related workflow', async () => { + mocks.resolveWorkflowContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Workflow not found') + ) + + await expect( + updateTableGroupUseCase.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + workflowId: 'workflow-other', + }, + }) + ).rejects.toMatchObject({ code: 'validation', message: 'Invalid workflow ID' }) + + expect(mocks.updateGroup).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rejects an invalid output before constructing or mutating the group', async () => { + await expect( + createWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + workflowId: 'workflow-1', + outputs: [{ blockId: 'missing', path: 'value' }], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.addGroup).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rejects oversized workflow output construction before resolution or mutation', async () => { + await expect( + createWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + workflowId: 'workflow-1', + outputs: Array.from({ length: 1001 }, (_, index) => ({ + blockId: `block-${index}`, + path: 'content', + })), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + expect(mocks.addGroup).not.toHaveBeenCalled() + }) + + it('constructs new columns while preserving existing bindings during restructure', async () => { + await updateWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + outputs: [ + { blockId: 'block-1', path: 'content', columnName: 'ignored-rename' }, + { blockId: 'block-2', path: 'score', columnName: 'score_value' }, + ], + }, + }) + + expect(mocks.updateGroup).toHaveBeenCalledWith( + expect.objectContaining({ + outputs: [ + { blockId: 'block-1', path: 'content', columnName: 'column-result' }, + { blockId: 'block-2', path: 'score', columnName: 'score_value' }, + ], + newOutputColumns: [ + expect.objectContaining({ + name: 'score_value', + type: 'number', + workflowGroupId: group.id, + }), + ], + }), + 'request-1' + ) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) + + it('allows a replacement output to reuse the removed output column name', async () => { + await updateWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + outputs: [{ blockId: 'block-2', path: 'score', columnName: 'result' }], + }, + }) + + expect(mocks.updateGroup).toHaveBeenCalledWith( + expect.objectContaining({ + outputs: [{ blockId: 'block-2', path: 'score', columnName: 'result' }], + newOutputColumns: [ + expect.objectContaining({ + name: 'result', + type: 'number', + workflowGroupId: group.id, + }), + ], + }), + 'request-1' + ) + }) + + it('propagates a concurrent schema conflict without audit or effects', async () => { + const conflict = Object.assign(new Error('retry the update'), { code: 'conflict' }) + mocks.updateGroup.mockRejectedValueOnce(conflict) + + await expect( + updateWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + mappingUpdates: [{ columnName: 'column-result', blockId: 'block-2', path: 'score' }], + }, + }) + ).rejects.toBe(conflict) + + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('does not audit or signal an authoritative no-op group update', async () => { + mocks.updateGroup.mockResolvedValueOnce(table) + + const result = await updateWorkflowTableGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + name: group.name, + }, + }) + + expect(result.changed).toBe(false) + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('passes authorized output type and ordering to the add-output mutation', async () => { + await addWorkflowTableGroupOutput.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + blockId: 'block-2', + path: 'score', + }, + }) + + expect(mocks.addOutput).toHaveBeenCalledWith( + expect.objectContaining({ + resolvedOutput: expect.objectContaining({ + workflowId: 'workflow-1', + columnType: 'number', + order: expect.arrayContaining([ + expect.objectContaining({ blockId: 'block-2', executionDistance: 2 }), + ]), + }), + }), + 'request-1' + ) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) + + it('rejects adding a workflow output to an enrichment group before resolution or mutation', async () => { + mocks.resolveContext.mockResolvedValueOnce({ + tableId: table.id, + table: tableWithGroup({ + id: 'enrichment-group-1', + type: 'enrichment', + workflowId: '', + enrichmentId: 'company-domain', + outputs: [], + }), + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + + await expect( + addWorkflowTableGroupOutput.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: 'enrichment-group-1', + blockId: 'block-2', + path: 'score', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + expect(mocks.loadWorkflowOutputs).not.toHaveBeenCalled() + expect(mocks.addOutput).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('validates enrichment mappings before constructing the group', async () => { + await expect( + createTableEnrichmentGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + enrichmentId: 'company-domain', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.addGroup).not.toHaveBeenCalled() + + const result = await createTableEnrichmentGroup.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + enrichmentId: 'company-domain', + inputMappings: [{ inputName: 'company', columnName: 'name' }], + }, + }) + + expect(mocks.addGroup).toHaveBeenCalledWith( + expect.objectContaining({ + group: expect.objectContaining({ + id: 'generated-id', + enrichmentId: 'company-domain', + inputMappings: [{ inputName: 'company', columnName: 'name' }], + dependencies: { columns: ['name'] }, + outputs: [{ blockId: '', path: '', outputId: 'domain', columnName: 'domain' }], + }), + outputColumns: [ + expect.objectContaining({ name: 'domain', workflowGroupId: 'generated-id' }), + ], + }), + 'request-1' + ) + expect(result.group.enrichmentId).toBe('company-domain') + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) + + it('deletes an output with authoritative audit and schema effects', async () => { + await deleteTableGroupOutputUseCase.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + columnName: 'result', + }, + }) + + expect(mocks.deleteOutput).toHaveBeenCalledWith( + { + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + columnName: 'result', + }, + 'request-1' + ) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) +}) diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index 665e887374f..143a4a4213a 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -1,29 +1,42 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' -import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' import { generateId } from '@sim/utils/id' import type { V2AddWorkflowGroupBody } from '@/lib/api/contracts/v2/tables' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' -import type { - DeleteWorkflowGroupData, - TableDefinition, - TableSchema, - UpdateWorkflowGroupData, - WorkflowGroup, +import { + type ColumnDefinition, + type DeleteWorkflowGroupData, + getColumnId, + TABLE_LIMITS, + type TableDefinition, + type TableSchema, + type UpdateWorkflowGroupData, + type WorkflowGroup, + type WorkflowGroupDependencies, + type WorkflowGroupDeploymentMode, + type WorkflowGroupInputMapping, + type WorkflowGroupOutput, } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' import { resolveActiveTableContext } from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' -import { startTableRun } from '@/lib/table/application/runs' +import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' import { signalTableSchemaChanged } from '@/lib/table/events' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' import { addWorkflowGroup, + addWorkflowGroupOutput, deleteWorkflowGroup, + deleteWorkflowGroupOutput, updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import type { ResolveWorkflowOutputsResult } from '@/lib/workflows/application/resolve-workflow-outputs' +import { loadResolvedWorkflowOutputs } from '@/lib/workflows/application/resolve-workflow-outputs' +import { getEnrichment } from '@/enrichments/registry' const logger = createLogger('TableGroupApplication') @@ -42,14 +55,116 @@ function groupFromTable(table: TableDefinition, groupId: string): WorkflowGroup return group } -async function requireWorkflowInTableWorkspace( +async function resolveWorkflowForAuthorizedTableCommand( + workflowId: string, + workspaceId: string +): Promise { + const workflowContext = await resolveActiveWorkflowApplicationContext({ + workflowId, + assertedWorkspaceId: workspaceId, + }) + return loadResolvedWorkflowOutputs(workflowContext) +} + +async function resolveRelatedWorkflowForTableRoute( workflowId: string, workspaceId: string -): Promise { - const workflow = await getActiveWorkflowContext(workflowId) - if (!workflow || workflow.workspaceId !== workspaceId) { - throw new OrchestrationError('validation', 'Workflow not found in this workspace') +): Promise { + try { + return await resolveWorkflowForAuthorizedTableCommand(workflowId, workspaceId) + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') { + throw new OrchestrationError('validation', 'Invalid workflow ID') + } + throw error + } +} + +function requireWorkflowOutputs( + resolved: ResolveWorkflowOutputsResult, + workflowId: string +): NonNullable { + if (!resolved.outputs) { + throw new OrchestrationError('validation', `Workflow has no pickable outputs: ${workflowId}`) } + return resolved.outputs +} + +function requireBoundedGroupItems(items: readonly unknown[] | undefined, label: string): void { + if ((items?.length ?? 0) > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `${label} cannot exceed ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} entries` + ) + } +} + +function validateRequestedOutputs( + requested: Array<{ blockId: string; path: string }>, + resolved: ResolveWorkflowOutputsResult, + workflowId: string +): NonNullable { + const outputs = requireWorkflowOutputs(resolved, workflowId) + const valid = new Set(outputs.map((output) => `${output.blockId}::${output.path}`)) + const invalid = requested.filter((output) => !valid.has(`${output.blockId}::${output.path}`)) + if (invalid.length === 0) return outputs + + const sample = outputs + .slice(0, 12) + .map((output) => ` - ${output.blockId} (${output.blockName}) → ${output.path}`) + .join('\n') + const invalidList = invalid.map((output) => ` - ${output.blockId} → ${output.path}`).join('\n') + throw new OrchestrationError( + 'validation', + `Invalid output(s) for workflow ${workflowId}:\n${invalidList}\n\nValid options${outputs.length > 12 ? ' (first 12)' : ''}:\n${sample}` + ) +} + +function workflowOutputColumnType( + requestedType: string | undefined, + resolvedLeafType: string | undefined +): ColumnDefinition['type'] { + if (requestedType === undefined) return columnTypeForLeaf(resolvedLeafType) + const type = columnTypeForLeaf(requestedType) + if (type !== requestedType) { + throw new OrchestrationError( + 'validation', + `Invalid workflow output column type "${requestedType}"` + ) + } + return type +} + +function attributedUserId( + principal: Parameters[0], + billedAccountUserId: string +): string { + return resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: billedAccountUserId, + }).attributedUserId +} + +function dispatchGroupAutoRun(params: { + tableId: string + workspaceId: string + groupId: string + actorUserId: string + label: string +}): void { + runDetached(params.label, async () => { + await runWorkflowColumn({ + tableId: params.tableId, + workspaceId: params.workspaceId, + groupIds: [params.groupId], + mode: 'all', + requestId: generateRequestId(), + triggeredByUserId: params.actorUserId, + }) + logger.info('Started table group auto-run', { + tableId: params.tableId, + groupId: params.groupId, + }) + }) } export const listTableGroupsUseCase = defineAuthorizedTableUseCase({ @@ -78,8 +193,11 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ principal, input, context }) { + requireBoundedGroupItems(input.group.outputs, 'Workflow group outputs') + requireBoundedGroupItems(input.outputColumns, 'Workflow group output columns') + requireBoundedGroupItems(input.group.inputMappings, 'Workflow group input mappings') if (input.group.workflowId) { - await requireWorkflowInTableWorkspace(input.group.workflowId, context.workspaceId) + await resolveRelatedWorkflowForTableRoute(input.group.workflowId, context.workspaceId) } const outputNames = new Set(input.group.outputs.map((output) => output.columnName)) const orphan = input.outputColumns.find((column) => !outputNames.has(column.name)) @@ -90,9 +208,7 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ ) } - const attribution = resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }) + const actorUserId = attributedUserId(principal, context.billedAccountUserId) const groupId = input.group.id ?? generateId() const table = await addWorkflowGroup( { @@ -105,11 +221,11 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ })), autoRun: input.autoRun ?? false, suppressAutoRunDispatch: true, - actorUserId: attribution.attributedUserId, + actorUserId, }, generateRequestId() ) - return { table, group: groupFromTable(table, groupId) } + return { table, group: groupFromTable(table, groupId), actorUserId } }, projectAudit({ result }) { return { @@ -121,25 +237,290 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ metadata: { op: 'add_group', groupId: result.group.id }, } }, - afterSuccess({ principal, input, context, result, request }) { + afterSuccess({ input, context, result }) { signalTableSchemaChanged(context.table.id) if (input.autoRun === true) { - runDetached('table-group-create-auto-run', async () => { - await startTableRun.execute({ - principal, - input: { - kind: 'selection', - tableId: context.table.id, - assertedWorkspaceId: context.workspaceId, - groupIds: [result.group.id], - mode: 'all', - }, - request, - }) - logger.info('Started table group auto-run', { - tableId: context.table.id, - groupId: result.group.id, - }) + dispatchGroupAutoRun({ + tableId: context.table.id, + workspaceId: context.workspaceId, + groupId: result.group.id, + actorUserId: result.actorUserId, + label: 'table-group-create-auto-run', + }) + } + }, +}) + +export interface CreateWorkflowTableGroupInput extends TableGroupInput { + workflowId: string + outputs: Array<{ + blockId: string + path: string + columnName?: string + columnType?: string + }> + name?: string + dependencies?: WorkflowGroupDependencies + deploymentMode?: WorkflowGroupDeploymentMode + autoRun?: boolean +} + +/** Creates a workflow-backed group from requested workflow output coordinates. */ +export const createWorkflowTableGroup = defineAuthorizedTableUseCase({ + operation: tableOperations.createGroup, + resolveContext: ({ input }: { input: CreateWorkflowTableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + requireBoundedGroupItems(input.outputs, 'Workflow group outputs') + if (input.outputs.length === 0) { + throw new OrchestrationError('validation', 'At least one workflow output is required') + } + if (input.outputs.some((output) => !output.blockId || !output.path)) { + throw new OrchestrationError( + 'validation', + 'Each output entry must include both blockId and path' + ) + } + + const resolvedWorkflow = await resolveWorkflowForAuthorizedTableCommand( + input.workflowId, + context.workspaceId + ) + const canonicalOutputs = validateRequestedOutputs( + input.outputs, + resolvedWorkflow, + input.workflowId + ) + const leafTypeByKey = new Map( + canonicalOutputs.map((output) => [`${output.blockId}::${output.path}`, output.leafType]) + ) + const taken = new Set(context.table.schema.columns.map((column) => column.name)) + const groupId = generateId() + const outputs: WorkflowGroupOutput[] = [] + const outputColumns: ColumnDefinition[] = [] + for (const requested of input.outputs) { + const columnName = requested.columnName ?? deriveOutputColumnName(requested.path, taken) + taken.add(columnName) + outputs.push({ + blockId: requested.blockId, + path: requested.path, + columnName, + }) + outputColumns.push({ + name: columnName, + type: workflowOutputColumnType( + requested.columnType, + leafTypeByKey.get(`${requested.blockId}::${requested.path}`) + ), + required: false, + unique: false, + workflowGroupId: groupId, + }) + } + + const group: WorkflowGroup = { + id: groupId, + workflowId: input.workflowId, + ...(input.name ? { name: input.name } : {}), + ...(input.dependencies ? { dependencies: input.dependencies } : {}), + ...(input.deploymentMode ? { deploymentMode: input.deploymentMode } : {}), + autoRun: input.autoRun ?? false, + outputs, + } + const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const table = await addWorkflowGroup( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + group, + outputColumns, + autoRun: input.autoRun ?? false, + suppressAutoRunDispatch: true, + actorUserId, + }, + generateRequestId() + ) + return { table, group: groupFromTable(table, groupId), actorUserId } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Added workflow group "${result.group.id}" to table "${result.table.name}"`, + metadata: { op: 'add_workflow_group', groupId: result.group.id }, + } + }, + afterSuccess({ input, context, result }) { + signalTableSchemaChanged(context.tableId) + if (input.autoRun === true) { + dispatchGroupAutoRun({ + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: result.group.id, + actorUserId: result.actorUserId, + label: 'table-workflow-group-create-auto-run', + }) + } + }, +}) + +export interface CreateTableEnrichmentGroupInput extends TableGroupInput { + enrichmentId: string + inputMappings?: Array<{ inputName: string; columnName: string }> + outputColumnNames?: Record + dependencies?: WorkflowGroupDependencies + name?: string + autoRun?: boolean +} + +/** Creates an enrichment group from the code-defined enrichment registry. */ +export const createTableEnrichmentGroup = defineAuthorizedTableUseCase({ + operation: tableOperations.createGroup, + resolveContext: ({ input }: { input: CreateTableEnrichmentGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + requireBoundedGroupItems(input.inputMappings, 'Enrichment input mappings') + if (Object.keys(input.outputColumnNames ?? {}).length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + throw new OrchestrationError( + 'validation', + `Enrichment output names cannot exceed ${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE} entries` + ) + } + const enrichment = getEnrichment(input.enrichmentId) + if (!enrichment) { + throw new OrchestrationError( + 'validation', + `Unknown enrichment "${input.enrichmentId}". Call list_enrichments to see available ids.` + ) + } + + const enrichmentInputIds = new Set( + enrichment.inputs.map((enrichmentInput) => enrichmentInput.id) + ) + const mappingByInput = new Map() + for (const mapping of input.inputMappings ?? []) { + if (!enrichmentInputIds.has(mapping.inputName)) { + throw new OrchestrationError( + 'validation', + `Enrichment "${enrichment.name}" has no input "${mapping.inputName}"` + ) + } + if (mappingByInput.has(mapping.inputName)) { + throw new OrchestrationError( + 'validation', + `Enrichment input "${mapping.inputName}" cannot be mapped more than once` + ) + } + mappingByInput.set(mapping.inputName, mapping.columnName) + } + const enrichmentOutputIds = new Set(enrichment.outputs.map((output) => output.id)) + for (const outputId of Object.keys(input.outputColumnNames ?? {})) { + if (!enrichmentOutputIds.has(outputId)) { + throw new OrchestrationError( + 'validation', + `Enrichment "${enrichment.name}" has no output "${outputId}"` + ) + } + } + const existingColumns = new Set(context.table.schema.columns.map((column) => column.name)) + for (const enrichmentInput of enrichment.inputs) { + const mapped = mappingByInput.get(enrichmentInput.id) + if (enrichmentInput.required && !mapped) { + throw new OrchestrationError( + 'validation', + `Enrichment "${enrichment.name}" requires input "${enrichmentInput.id}" to be mapped to a column` + ) + } + if (mapped && !existingColumns.has(mapped)) { + throw new OrchestrationError( + 'validation', + `Mapped column "${mapped}" for input "${enrichmentInput.id}" does not exist on table ${context.tableId}` + ) + } + } + + const inputMappings: WorkflowGroupInputMapping[] = enrichment.inputs + .filter((enrichmentInput) => mappingByInput.has(enrichmentInput.id)) + .map((enrichmentInput) => ({ + inputName: enrichmentInput.id, + columnName: mappingByInput.get(enrichmentInput.id) as string, + })) + const taken = new Set(context.table.schema.columns.map((column) => column.name)) + const groupId = generateId() + const outputs: WorkflowGroupOutput[] = [] + const outputColumns: ColumnDefinition[] = [] + for (const output of enrichment.outputs) { + const desired = (input.outputColumnNames?.[output.id] ?? '').trim() || output.name + const columnName = deriveOutputColumnName(desired, taken) + taken.add(columnName) + outputs.push({ blockId: '', path: '', outputId: output.id, columnName }) + outputColumns.push({ + name: columnName, + type: output.type, + required: false, + unique: false, + workflowGroupId: groupId, + }) + } + + const name = input.name ?? enrichment.name + const group: WorkflowGroup = { + id: groupId, + workflowId: '', + enrichmentId: input.enrichmentId, + name, + type: 'enrichment', + dependencies: input.dependencies ?? { columns: inputMappings.map((item) => item.columnName) }, + outputs, + inputMappings, + autoRun: input.autoRun ?? false, + } + const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const table = await addWorkflowGroup( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + group, + outputColumns, + autoRun: input.autoRun ?? false, + suppressAutoRunDispatch: true, + actorUserId, + }, + generateRequestId() + ) + return { table, group: groupFromTable(table, groupId), actorUserId } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Added enrichment "${result.group.name ?? result.group.id}" to table "${result.table.name}"`, + metadata: { + op: 'add_enrichment', + groupId: result.group.id, + enrichmentId: result.group.enrichmentId, + }, + } + }, + afterSuccess({ input, context, result }) { + signalTableSchemaChanged(context.tableId) + if (input.autoRun === true) { + dispatchGroupAutoRun({ + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: result.group.id, + actorUserId: result.actorUserId, + label: 'table-enrichment-group-create-auto-run', }) } }, @@ -160,21 +541,61 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ principal, input, context }) { - if (input.workflowId !== undefined) { - await requireWorkflowInTableWorkspace(input.workflowId, context.workspaceId) - } - const attribution = resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }) + requireBoundedGroupItems(input.outputs, 'Workflow group outputs') + requireBoundedGroupItems(input.newOutputColumns, 'Workflow group output columns') + requireBoundedGroupItems(input.mappingUpdates, 'Workflow group mapping updates') + requireBoundedGroupItems(input.inputMappings, 'Workflow group input mappings') const previousGroup = (context.table.schema.workflowGroups ?? []).find( (group) => group.id === input.groupId ) + const workflowMetadataRequired = + input.workflowId !== undefined || + input.outputs !== undefined || + (input.mappingUpdates?.length ?? 0) > 0 + const targetWorkflowId = input.workflowId ?? previousGroup?.workflowId + let resolvedWorkflow: ResolveWorkflowOutputsResult | undefined + if (workflowMetadataRequired) { + if (!targetWorkflowId) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + resolvedWorkflow = await resolveRelatedWorkflowForTableRoute( + targetWorkflowId, + context.workspaceId + ) + if (input.outputs && input.outputs.length > 0) { + validateRequestedOutputs(input.outputs, resolvedWorkflow, targetWorkflowId) + } + } + const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const hasMappingUpdates = Boolean(input.mappingUpdates && input.mappingUpdates.length > 0) + if (hasMappingUpdates && !resolvedWorkflow) { + throw new Error('Workflow metadata is required for workflow group mapping updates') + } + const resolvedMappingTypes = + input.mappingUpdates && input.mappingUpdates.length > 0 && resolvedWorkflow + ? { + workflowId: resolvedWorkflow.workflowId, + columns: input.mappingUpdates.map((mapping) => { + const output = resolvedWorkflow.outputs?.find( + (candidate) => + candidate.blockId === mapping.blockId && candidate.path === mapping.path + ) + if (!output) { + throw new OrchestrationError( + 'validation', + `Output ${mapping.blockId}::${mapping.path} is not a valid pickable output on workflow ${targetWorkflowId}` + ) + } + return { columnName: mapping.columnName, type: columnTypeForLeaf(output.leafType) } + }), + } + : undefined const table = await updateWorkflowGroup( { tableId: context.table.id, workspaceId: context.workspaceId, groupId: input.groupId, - actorUserId: attribution.attributedUserId, + actorUserId, suppressAutoRunDispatch: true, ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : {}), ...(input.name !== undefined ? { name: input.name } : {}), @@ -189,6 +610,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ } : {}), ...(input.mappingUpdates !== undefined ? { mappingUpdates: input.mappingUpdates } : {}), + ...(resolvedMappingTypes ? { resolvedMappingTypes } : {}), ...(input.inputMappings !== undefined ? { inputMappings: input.inputMappings } : {}), ...(input.deploymentMode !== undefined ? { deploymentMode: input.deploymentMode } : {}), ...(input.type !== undefined ? { type: input.type } : {}), @@ -204,6 +626,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ JSON.stringify(context.table.schema) !== JSON.stringify(table.schema) || JSON.stringify(context.table.metadata) !== JSON.stringify(table.metadata), startAutoRun: previousGroup?.autoRun !== true && input.autoRun === true, + actorUserId, } }, projectAudit({ result }) { @@ -217,25 +640,204 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ metadata: { op: 'update_group', groupId: result.group.id }, } }, - afterSuccess({ principal, context, result, request }) { + afterSuccess({ context, result }) { if (result.changed) signalTableSchemaChanged(context.table.id) if (result.startAutoRun) { - runDetached('table-group-update-auto-run', async () => { - await startTableRun.execute({ - principal, - input: { - kind: 'selection', - tableId: context.table.id, - assertedWorkspaceId: context.workspaceId, - groupIds: [result.group.id], - mode: 'all', - }, - request, + dispatchGroupAutoRun({ + tableId: context.table.id, + workspaceId: context.workspaceId, + groupId: result.group.id, + actorUserId: result.actorUserId, + label: 'table-group-update-auto-run', + }) + } + }, +}) + +export interface UpdateWorkflowTableGroupInput extends TableGroupInput { + groupId: string + workflowId?: string + name?: string + dependencies?: WorkflowGroupDependencies + outputs?: Array<{ + blockId: string + path: string + columnName?: string + columnType?: string + }> + mappingUpdates?: Array<{ columnName: string; blockId: string; path: string }> + deploymentMode?: WorkflowGroupDeploymentMode + autoRun?: boolean +} + +/** Updates a workflow-backed group from output coordinates rather than caller-built columns. */ +export const updateWorkflowTableGroup = defineAuthorizedTableUseCase({ + operation: tableOperations.updateGroup, + resolveContext: ({ input }: { input: UpdateWorkflowTableGroupInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + requireBoundedGroupItems(input.outputs, 'Workflow group outputs') + requireBoundedGroupItems(input.mappingUpdates, 'Workflow group mapping updates') + const previousGroup = context.table.schema.workflowGroups?.find( + (candidate) => candidate.id === input.groupId + ) + if (!previousGroup) { + throw new OrchestrationError('not_found', `Workflow group "${input.groupId}" not found`) + } + if (previousGroup.type === 'enrichment' || !previousGroup.workflowId) { + throw new OrchestrationError( + 'validation', + `Workflow group "${input.groupId}" is not backed by a workflow` + ) + } + + const targetWorkflowId = input.workflowId ?? previousGroup.workflowId + const workflowMetadataRequired = + input.workflowId !== undefined || + input.outputs !== undefined || + (input.mappingUpdates?.length ?? 0) > 0 + const resolvedWorkflow = workflowMetadataRequired + ? await resolveWorkflowForAuthorizedTableCommand(targetWorkflowId, context.workspaceId) + : undefined + if (input.outputs && resolvedWorkflow) { + validateRequestedOutputs(input.outputs, resolvedWorkflow, targetWorkflowId) + } else if (input.workflowId && resolvedWorkflow) { + validateRequestedOutputs(previousGroup.outputs, resolvedWorkflow, targetWorkflowId) + } + + let outputs: WorkflowGroupOutput[] | undefined + let newOutputColumns: ColumnDefinition[] | undefined + if (input.outputs) { + if (!resolvedWorkflow) { + throw new Error('Workflow metadata is required to restructure workflow outputs') + } + const canonicalOutputs = requireWorkflowOutputs(resolvedWorkflow, targetWorkflowId) + const leafTypeByKey = new Map( + canonicalOutputs.map((output) => [`${output.blockId}::${output.path}`, output.leafType]) + ) + const existingByKey = new Map( + previousGroup.outputs.map((output) => [`${output.blockId}::${output.path}`, output]) + ) + const requestedKeys = new Set( + input.outputs.map((output) => `${output.blockId}::${output.path}`) + ) + const releasedColumnIds = new Set( + previousGroup.outputs + .filter((output) => !requestedKeys.has(`${output.blockId}::${output.path}`)) + .map((output) => output.columnName) + ) + const taken = new Set( + context.table.schema.columns + .filter((column) => !releasedColumnIds.has(getColumnId(column))) + .map((column) => column.name) + ) + outputs = [] + newOutputColumns = [] + for (const requested of input.outputs) { + const key = `${requested.blockId}::${requested.path}` + const existing = existingByKey.get(key) + if (existing) { + outputs.push(existing) + continue + } + const requestedName = requested.columnName?.trim() + const columnName = requestedName || deriveOutputColumnName(requested.path, taken) + if (taken.has(columnName)) { + throw new OrchestrationError('validation', `Column "${columnName}" already exists`) + } + taken.add(columnName) + outputs.push({ + blockId: requested.blockId, + path: requested.path, + columnName, }) - logger.info('Started table group auto-run', { - tableId: context.table.id, - groupId: result.group.id, + newOutputColumns.push({ + name: columnName, + type: workflowOutputColumnType(requested.columnType, leafTypeByKey.get(key)), + required: false, + unique: false, + workflowGroupId: input.groupId, }) + } + } + + const resolvedMappingTypes = + input.mappingUpdates && input.mappingUpdates.length > 0 && resolvedWorkflow + ? { + workflowId: resolvedWorkflow.workflowId, + columns: input.mappingUpdates.map((mapping) => { + const output = resolvedWorkflow.outputs?.find( + (candidate) => + candidate.blockId === mapping.blockId && candidate.path === mapping.path + ) + if (!output) { + throw new OrchestrationError( + 'validation', + `Output ${mapping.blockId}::${mapping.path} is not a valid pickable output on workflow ${targetWorkflowId}` + ) + } + return { columnName: mapping.columnName, type: columnTypeForLeaf(output.leafType) } + }), + } + : undefined + if (input.mappingUpdates?.length && !resolvedMappingTypes) { + throw new Error('Workflow metadata is required for workflow group mapping updates') + } + + const actorUserId = attributedUserId(principal, context.billedAccountUserId) + const table = await updateWorkflowGroup( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: input.groupId, + actorUserId, + suppressAutoRunDispatch: true, + ...(input.workflowId !== undefined ? { workflowId: input.workflowId } : {}), + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.dependencies !== undefined ? { dependencies: input.dependencies } : {}), + ...(outputs !== undefined ? { outputs } : {}), + ...(newOutputColumns !== undefined ? { newOutputColumns } : {}), + ...(input.mappingUpdates !== undefined ? { mappingUpdates: input.mappingUpdates } : {}), + ...(resolvedMappingTypes ? { resolvedMappingTypes } : {}), + ...(input.deploymentMode !== undefined ? { deploymentMode: input.deploymentMode } : {}), + ...(input.autoRun !== undefined ? { autoRun: input.autoRun } : {}), + }, + generateRequestId() + ) + const group = groupFromTable(table, input.groupId) + return { + table, + group, + changed: + JSON.stringify(context.table.schema) !== JSON.stringify(table.schema) || + JSON.stringify(context.table.metadata) !== JSON.stringify(table.metadata), + startAutoRun: previousGroup.autoRun !== true && input.autoRun === true, + actorUserId, + } + }, + projectAudit({ result }) { + if (!result.changed) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Updated workflow group "${result.group.id}" in table "${result.table.name}"`, + metadata: { op: 'update_workflow_group', groupId: result.group.id }, + } + }, + afterSuccess({ context, result }) { + if (result.changed) signalTableSchemaChanged(context.tableId) + if (result.startAutoRun) { + dispatchGroupAutoRun({ + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: result.group.id, + actorUserId: result.actorUserId, + label: 'table-workflow-group-update-auto-run', }) } }, @@ -277,3 +879,131 @@ export const deleteTableGroupUseCase = defineAuthorizedTableUseCase({ signalTableSchemaChanged(context.table.id) }, }) + +export interface AddTableGroupOutputInput extends TableGroupInput { + groupId: string + blockId: string + path: string + columnName?: string +} + +export const addWorkflowTableGroupOutput = defineAuthorizedTableUseCase({ + operation: tableOperations.updateGroup, + resolveContext: ({ input }: { input: AddTableGroupOutputInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, input, context }) { + const group = context.table.schema.workflowGroups?.find( + (candidate) => candidate.id === input.groupId + ) + if (!group) + throw new OrchestrationError('not_found', `Workflow group "${input.groupId}" not found`) + if (group.type === 'enrichment' || !group.workflowId) { + throw new OrchestrationError( + 'validation', + `Workflow group "${input.groupId}" is not backed by a workflow` + ) + } + const resolvedWorkflow = await resolveWorkflowForAuthorizedTableCommand( + group.workflowId, + context.workspaceId + ) + const outputs = requireWorkflowOutputs(resolvedWorkflow, group.workflowId) + const output = outputs.find( + (candidate) => candidate.blockId === input.blockId && candidate.path === input.path + ) + if (!output) { + throw new OrchestrationError( + 'validation', + `Output ${input.blockId}::${input.path} is not a valid pickable output on workflow ${group.workflowId}` + ) + } + const table = await addWorkflowGroupOutput( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: input.groupId, + blockId: input.blockId, + path: input.path, + columnName: input.columnName, + actorUserId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + resolvedOutput: { + workflowId: resolvedWorkflow.workflowId, + columnType: columnTypeForLeaf(output.leafType), + order: outputs.map((candidate, discoveryIndex) => { + const distance = resolvedWorkflow.executionOrderByBlockId[candidate.blockId] + return { + blockId: candidate.blockId, + path: candidate.path, + executionDistance: + distance === undefined || distance < 0 ? Number.POSITIVE_INFINITY : distance, + discoveryIndex, + } + }), + }, + }, + generateRequestId() + ) + return { table, groupId: input.groupId } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Added an output to workflow group "${result.groupId}"`, + metadata: { op: 'add_group_output', groupId: result.groupId }, + } + }, + afterSuccess({ context }) { + signalTableSchemaChanged(context.tableId) + }, +}) + +export interface DeleteTableGroupOutputInput extends TableGroupInput { + groupId: string + columnName: string +} + +export const deleteTableGroupOutputUseCase = defineAuthorizedTableUseCase({ + operation: tableOperations.updateGroup, + resolveContext: ({ input }: { input: DeleteTableGroupOutputInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ input, context }) { + const table = await deleteWorkflowGroupOutput( + { + tableId: context.tableId, + workspaceId: context.workspaceId, + groupId: input.groupId, + columnName: input.columnName, + }, + generateRequestId() + ) + return { table, groupId: input.groupId, columnName: input.columnName } + }, + projectAudit({ result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted an output from workflow group "${result.groupId}"`, + metadata: { + op: 'delete_group_output', + groupId: result.groupId, + columnName: result.columnName, + }, + } + }, + afterSuccess({ context }) { + signalTableSchemaChanged(context.tableId) + }, +}) diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts new file mode 100644 index 00000000000..79d3fae1691 --- /dev/null +++ b/apps/sim/lib/table/application/imports.test.ts @@ -0,0 +1,419 @@ +/** + * @vitest-environment node + */ + +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + abortUpload: vi.fn(), + assertUploadBinding: vi.fn(), + cancelResource: vi.fn(), + createParts: vi.fn(), + createResource: vi.fn(), + completeUpload: vi.fn(), + findResource: vi.fn(), + getResource: vi.fn(), + getUpload: vi.fn(), + getWorkspaceFile: vi.fn(), + resolvePermission: vi.fn(), + resolveTableContext: vi.fn(), + resolveWorkspaceContext: vi.fn(), + startUploadedImport: vi.fn(), + tableImportBodyFromUpload: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/folders/locks', () => ({ withFolderTreeLock: vi.fn() })) +vi.mock('@/lib/folders/queries', () => ({ + loadActiveFolderPathIndex: vi.fn(), + resolveFolderPathFromIndex: vi.fn(), +})) + +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveTableContext, + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, +})) + +vi.mock('@/lib/table/orchestration/import-resource', () => ({ + abortAuthorizedTableImportUpload: mocks.abortUpload, + cancelTableImportResource: mocks.cancelResource, + createAuthorizedTableImportResource: mocks.createResource, + findTableImportResource: mocks.findResource, + getPrincipalTableImportUpload: mocks.getUpload, + getTableImportResource: mocks.getResource, + startUploadedTableImport: mocks.startUploadedImport, + tableImportBodyFromUpload: mocks.tableImportBodyFromUpload, +})) + +vi.mock('@/lib/uploads/upload-session/application', () => ({ + requestOrigin: () => 'http://localhost:3000', +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + assertUploadSessionAuthBinding: mocks.assertUploadBinding, + completeUploadSession: mocks.completeUpload, + createUploadPartUrls: mocks.createParts, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getWorkspaceFile, +})) + +import { + cancelTableImportUseCase, + completeTableImportUseCase, + createTableImportPartsUseCase, + createTableImportUseCase, + readTableImportUseCase, +} from '@/lib/table/application/imports' + +const createdAt = new Date('2026-08-01T00:00:00.000Z') +const record = { + id: 'import-1', + workspaceId: 'workspace-1', + userId: 'uploader-1', + source: { type: 'upload' as const, name: 'people.csv', contentType: 'text/csv', size: 128 }, + target: { type: 'new' as const, name: 'People' }, + options: {}, + tableId: null, + status: 'uploading' as const, + rowsProcessed: 0, + error: null, + createdAt, + updatedAt: createdAt, + completedAt: null, +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const reader = { kind: 'session' as const, userId: 'reader-2', sessionId: 'session-2' } +const workspaceKey = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', +} +const executor: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'executor-user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, +} +const upload = { + id: 'import-1', + workspaceId: 'workspace-1', + userId: 'uploader-1', + fileName: 'people.csv', +} +const workspaceFile = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'people.csv', + key: 'workspace/workspace-1/people.csv', +} + +describe('table import application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.getResource.mockResolvedValue(record) + mocks.cancelResource.mockResolvedValue({ ...record, status: 'canceled' }) + mocks.getUpload.mockResolvedValue(upload) + mocks.tableImportBodyFromUpload.mockReturnValue({ + workspaceId: 'workspace-1', + source: record.source, + target: record.target, + }) + mocks.createParts.mockResolvedValue([{ partNumber: 1, url: 'https://storage/part-1' }]) + mocks.abortUpload.mockResolvedValue({ ...record, status: 'canceled' }) + mocks.findResource.mockResolvedValue(null) + mocks.completeUpload.mockImplementation( + async ({ + session, + finalize, + }: { + session: unknown + finalize: (value: unknown) => unknown + }) => { + await finalize(session) + return { session } + } + ) + mocks.startUploadedImport.mockResolvedValue({ ...record, status: 'ready' }) + mocks.createResource.mockResolvedValue({ record, upload: null }) + mocks.getWorkspaceFile.mockResolvedValue(workspaceFile) + }) + + it('creates an import through the domain resource boundary without presenting a v2 DTO', async () => { + const request = new Request('http://localhost:3000/api/table/imports', { method: 'POST' }) + + await expect( + createTableImportUseCase.execute({ + principal: reader, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: record.target, + }, + }, + request, + }) + ).resolves.toEqual({ import: { record, upload: null } }) + + expect(mocks.createResource).toHaveBeenCalledWith({ + body: { + workspaceId: 'workspace-1', + source: record.source, + target: record.target, + }, + userId: 'reader-2', + principal: reader, + localOrigin: 'http://localhost:3000', + resolvedFolderId: undefined, + workspaceFile: undefined, + }) + expect(record.createdAt).toBe(createdAt) + }) + + it('creates an upload import for an unscoped current-workflow executor principal', async () => { + const request = new Request('http://localhost:3000/api/table/imports', { method: 'POST' }) + + await createTableImportUseCase.execute({ + principal: executor, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: record.target, + }, + }, + request, + }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'executor-user-1', + 'workspace-1', + null, + undefined, + { forUpdate: undefined } + ) + expect(mocks.createResource).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'executor-user-1', + principal: executor, + }) + ) + }) + + it('reads a durable import by workspace role rather than uploader identity', async () => { + await expect( + readTableImportUseCase.execute({ + principal: reader, + input: { importId: 'import-1', workspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ import: record }) + + expect(mocks.getResource).toHaveBeenCalledWith({ + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'reader-2', + 'workspace-1', + null, + undefined, + { forUpdate: undefined } + ) + }) + + it('resolves a workspace-file source canonically inside the authorized import command', async () => { + await createTableImportUseCase.execute({ + principal: reader, + input: { + body: { + workspaceId: 'workspace-1', + source: { type: 'workspace_file', fileId: 'file-1' }, + target: record.target, + }, + }, + }) + + expect(mocks.getWorkspaceFile).toHaveBeenLastCalledWith('workspace-1', 'file-1', { + throwOnError: true, + }) + expect(mocks.createResource).toHaveBeenCalledWith(expect.objectContaining({ workspaceFile })) + }) + + it('conceals a cross-workspace workspace-file id before import mutation', async () => { + mocks.getWorkspaceFile.mockResolvedValueOnce(null) + + await expect( + createTableImportUseCase.execute({ + principal: reader, + input: { + body: { + workspaceId: 'workspace-1', + source: { type: 'workspace_file', fileId: 'file-other' }, + target: record.target, + }, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.createResource).not.toHaveBeenCalled() + }) + + it('lets a workspace key cancel the durable workspace resource', async () => { + await cancelTableImportUseCase.execute({ + principal: workspaceKey, + input: { importId: 'import-1', workspaceId: 'workspace-1' }, + }) + + expect(mocks.cancelResource).toHaveBeenCalledWith(record) + }) + + it('preserves exact principal and token binding on upload control legs', async () => { + const request = new Request('http://localhost:3000/api/v2/tables/imports/import-1/parts', { + method: 'POST', + }) + await createTableImportPartsUseCase.execute({ + principal: workspaceKey, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + partNumbers: [1], + }, + request, + }) + await cancelTableImportUseCase.execute({ + principal: workspaceKey, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + await completeTableImportUseCase.execute({ + principal: workspaceKey, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + + expect(mocks.getUpload).toHaveBeenNthCalledWith(1, { + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: workspaceKey, + uploadToken: 'signed-token', + }) + expect(mocks.getUpload).toHaveBeenNthCalledWith(2, { + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: workspaceKey, + uploadToken: 'signed-token', + }) + expect(mocks.getUpload).toHaveBeenNthCalledWith(3, { + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: workspaceKey, + uploadToken: 'signed-token', + }) + expect(mocks.abortUpload).toHaveBeenCalledWith(upload, workspaceKey) + expect(mocks.assertUploadBinding).toHaveBeenCalledWith(upload, workspaceKey) + }) + + it('threads the exact executor principal through upload control and finalization', async () => { + const request = new Request('http://localhost:3000/api/table/imports/import-1/parts', { + method: 'POST', + }) + + await createTableImportPartsUseCase.execute({ + principal: executor, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + partNumbers: [1], + }, + request, + }) + await completeTableImportUseCase.execute({ + principal: executor, + input: { + importId: 'import-1', + workspaceId: 'workspace-1', + uploadToken: 'signed-token', + }, + }) + + expect(mocks.getUpload).toHaveBeenNthCalledWith(1, { + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: executor, + uploadToken: 'signed-token', + }) + expect(mocks.getUpload).toHaveBeenNthCalledWith(2, { + importId: 'import-1', + assertedWorkspaceId: 'workspace-1', + principal: executor, + uploadToken: 'signed-token', + }) + expect(mocks.assertUploadBinding).toHaveBeenCalledWith(upload, executor) + }) + + it('rejects delegated HTTP import creation before canonical load or mutation', async () => { + const delegated = { + kind: 'delegated' as const, + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + } + + await expect( + createTableImportUseCase.execute({ + principal: delegated as never, + input: { + body: { + workspaceId: 'workspace-1', + source: record.source, + target: record.target, + }, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() + expect(mocks.resolveTableContext).not.toHaveBeenCalled() + expect(mocks.createResource).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index 370770fdd3e..3f27c72c9df 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -1,10 +1,5 @@ import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' -import type { - V2CreateTableImportBody, - V2CreateTableImportData, - V2TableImport, -} from '@/lib/api/contracts/v2/tables' import { authorizeWorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' @@ -23,6 +18,8 @@ import { import { tableOperations } from '@/lib/table/application/operations' import { abortAuthorizedTableImportUpload, + type CreateTableImportRequest, + type CreateTableImportResult as CreateTableImportResourceResult, cancelTableImportResource, createAuthorizedTableImportResource, findTableImportResource, @@ -31,9 +28,11 @@ import { startUploadedTableImport, type TableImportResource, tableImportBodyFromUpload, - toV2CreateTableImport, - toV2TableImport, } from '@/lib/table/orchestration/import-resource' +import { + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { requestOrigin } from '@/lib/uploads/upload-session/application' import { assertUploadSessionAuthBinding, @@ -41,12 +40,11 @@ import { createUploadPartUrls, type UploadSessionRecord, } from '@/lib/uploads/upload-session/service' -import { readWorkspaceFileContentRecord } from '@/lib/workspace-files/application/read-workspace-file-record' const logger = createLogger('TableImportApplication') export interface CreateTableImportInput { - body: V2CreateTableImportBody + body: CreateTableImportRequest } export interface TableImportResourceInput { @@ -67,11 +65,11 @@ export interface CancelTableImportInput extends TableImportResourceInput { } export interface CreateTableImportResult { - import: V2CreateTableImportData + import: CreateTableImportResourceResult } export interface TableImportResult { - import: V2TableImport + import: TableImportResource } export interface CreateTableImportPartsResult { @@ -90,10 +88,11 @@ interface TableImportUploadContext extends TableAuthorizationContext { async function resolveCreateTableImportContext(input: CreateTableImportInput) { if (input.body.target.type === 'existing') { - return resolveActiveTableContext({ + const { tableId: _tableId, ...context } = await resolveActiveTableContext({ tableId: input.body.target.tableId, assertedWorkspaceId: input.body.workspaceId, }) + return context } return resolveTableWorkspaceContext(input.body.workspaceId) } @@ -109,7 +108,6 @@ async function resolveTableImportContext( return { ...workspace, importId: record.id, - ...(record.tableId ? { tableId: record.tableId } : {}), record, } } @@ -129,14 +127,13 @@ async function resolveTableImportUploadContext( return { ...workspace, importId: upload.id, - ...(body.target.type === 'existing' ? { tableId: body.target.tableId } : {}), upload, } } async function resolveImportFolderId( workspaceId: string, - body: V2CreateTableImportBody + body: CreateTableImportRequest ): Promise { if (body.target.type !== 'new') return undefined const path = body.target.folderPath ?? ROOT_FOLDER_PATH @@ -152,34 +149,30 @@ async function resolveImportFolderId( }) } +async function loadAuthorizedTableImportWorkspaceFile( + workspaceId: string, + fileId: string +): Promise { + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return file +} + export const createTableImportUseCase = defineAuthorizedTableUseCase({ operation: tableOperations.createImport, resolveContext: ({ input }: { input: CreateTableImportInput }) => resolveCreateTableImportContext(input), async execute({ principal, input, context, request }): Promise { - if (principal.kind === 'delegated') { - throw new OrchestrationError( - 'forbidden', - input.body.source.type === 'upload' - ? 'Delegated principals cannot initiate table import uploads' - : 'Delegated principals cannot initiate workspace-file table imports' - ) - } const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) const folderId = await resolveImportFolderId(context.workspaceId, input.body) const workspaceFile = input.body.source.type === 'workspace_file' - ? ( - await readWorkspaceFileContentRecord.execute({ - principal, - input: { - fileId: input.body.source.fileId, - assertedWorkspaceId: context.workspaceId, - }, - }) - ).file + ? await loadAuthorizedTableImportWorkspaceFile( + context.workspaceId, + input.body.source.fileId + ) : undefined if (input.body.source.type === 'upload' && !request) { throw new Error('Table import upload creation requires a request context') @@ -199,7 +192,7 @@ export const createTableImportUseCase = defineAuthorizedTableUseCase({ targetType: input.body.target.type, principalKind: principal.kind, }) - return { import: toV2CreateTableImport(created) } + return { import: created } }, }) @@ -208,7 +201,7 @@ export const readTableImportUseCase = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: TableImportResourceInput }) => resolveTableImportContext(input), async execute({ context }): Promise { - return { import: toV2TableImport(context.record) } + return { import: context.record } }, }) @@ -242,7 +235,7 @@ export const completeTableImportUseCase = defineAuthorizedTableUseCase({ importId: context.upload.id, assertedWorkspaceId: context.workspaceId, }) - if (existing) return { import: toV2TableImport(existing) } + if (existing) return { import: existing } const completed = await completeUploadSession({ session: context.upload, @@ -261,7 +254,7 @@ export const completeTableImportUseCase = defineAuthorizedTableUseCase({ tableId: started.tableId, principalKind: principal.kind, }) - return { import: toV2TableImport(started) } + return { import: started } }, }) @@ -292,6 +285,6 @@ export const cancelTableImportUseCase = defineAuthorizedTableUseCase({ tableId: record.tableId, principalKind: principal.kind, }) - return { import: toV2TableImport(record) } + return { import: record } }, }) diff --git a/apps/sim/lib/table/application/operations.test.ts b/apps/sim/lib/table/application/operations.test.ts index 07722033747..3b9dc95cc27 100644 --- a/apps/sim/lib/table/application/operations.test.ts +++ b/apps/sim/lib/table/application/operations.test.ts @@ -53,9 +53,42 @@ describe('table operation registry', () => { expect(tableOperations.cancelExport.minimumRole).toBe('read') }) - it('keeps delegated table operations Copilot-only', () => { + it('admits executor delegation only for the intentional internal route operations', () => { + const executorOnlyOperations = new Set([ + tableOperations.createImport.id, + tableOperations.readImport.id, + tableOperations.createImportParts.id, + tableOperations.completeImport.id, + tableOperations.cancelImport.id, + tableOperations.createExport.id, + tableOperations.readExport.id, + tableOperations.cancelExport.id, + tableOperations.downloadExport.id, + ]) + const sharedGroupOperations = new Set([ + tableOperations.createGroup.id, + tableOperations.updateGroup.id, + tableOperations.deleteGroup.id, + ]) + for (const operation of Object.values(tableOperations)) { - expect(operation.delegatedServices).toEqual(['copilot']) + expect(operation.delegatedServices).toEqual( + executorOnlyOperations.has(operation.id) + ? ['executor'] + : sharedGroupOperations.has(operation.id) + ? ['copilot', 'executor'] + : ['copilot'] + ) } }) + + it('separates Copilot workspace-file imports from the credential-bound upload lifecycle', () => { + expect(tableOperations.createImport.delegatedServices).toEqual(['executor']) + expect(tableOperations.createImportParts.delegatedServices).toEqual(['executor']) + expect(tableOperations.completeImport.delegatedServices).toEqual(['executor']) + expect(tableOperations.createFromWorkspaceFile.principalKinds).toEqual(['delegated']) + expect(tableOperations.createFromWorkspaceFile.delegatedServices).toEqual(['copilot']) + expect(tableOperations.importWorkspaceFile.principalKinds).toEqual(['delegated']) + expect(tableOperations.importWorkspaceFile.delegatedServices).toEqual(['copilot']) + }) }) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 8662437787f..9d0f6009346 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -5,6 +5,16 @@ const ALL_PRINCIPAL_POLICY = { delegatedServices: ['copilot'], } as const +const ALL_TABLE_TOOL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot', 'executor'], +} as const + +const INTERNAL_EXECUTOR_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['executor'], +} as const + function readOperation(id: Id) { return defineWorkspaceOperation({ id, @@ -23,6 +33,43 @@ function writeOperation(id: Id) { }) } +function toolWriteOperation(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_TABLE_TOOL_PRINCIPAL_POLICY, + }) +} + +function internalExecutorReadOperation(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'read', + workspaceApiKey: 'allow', + ...INTERNAL_EXECUTOR_PRINCIPAL_POLICY, + }) +} + +function internalExecutorWriteOperation(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'write', + workspaceApiKey: 'allow', + ...INTERNAL_EXECUTOR_PRINCIPAL_POLICY, + }) +} + +function delegatedWriteOperation(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }) +} + export const tableOperations = { list: readOperation('tables.list'), read: readOperation('tables.read'), @@ -53,20 +100,22 @@ export const tableOperations = { updateView: writeOperation('tables.views.update'), deleteView: writeOperation('tables.views.delete'), listGroups: readOperation('tables.groups.list'), - createGroup: writeOperation('tables.groups.create'), - updateGroup: writeOperation('tables.groups.update'), - deleteGroup: writeOperation('tables.groups.delete'), + createGroup: toolWriteOperation('tables.groups.create'), + updateGroup: toolWriteOperation('tables.groups.update'), + deleteGroup: toolWriteOperation('tables.groups.delete'), startRun: writeOperation('tables.runs.start'), cancelRuns: writeOperation('tables.runs.cancel'), - createImport: writeOperation('tables.imports.create'), - readImport: readOperation('tables.imports.read'), - createImportParts: writeOperation('tables.imports.create_parts'), - completeImport: writeOperation('tables.imports.complete'), - cancelImport: writeOperation('tables.imports.cancel'), - createExport: readOperation('tables.exports.create'), - readExport: readOperation('tables.exports.read'), - cancelExport: readOperation('tables.exports.cancel'), - downloadExport: readOperation('tables.exports.download'), + createImport: internalExecutorWriteOperation('tables.imports.create'), + createFromWorkspaceFile: delegatedWriteOperation('tables.imports.create_from_workspace_file'), + importWorkspaceFile: delegatedWriteOperation('tables.imports.workspace_file'), + readImport: internalExecutorReadOperation('tables.imports.read'), + createImportParts: internalExecutorWriteOperation('tables.imports.create_parts'), + completeImport: internalExecutorWriteOperation('tables.imports.complete'), + cancelImport: internalExecutorWriteOperation('tables.imports.cancel'), + createExport: internalExecutorReadOperation('tables.exports.create'), + readExport: internalExecutorReadOperation('tables.exports.read'), + cancelExport: internalExecutorReadOperation('tables.exports.cancel'), + downloadExport: internalExecutorReadOperation('tables.exports.download'), } as const export type TableOperation = (typeof tableOperations)[keyof typeof tableOperations] diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index 55db224860d..f06658ece14 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -8,21 +8,31 @@ import type { TableDefinition } from '@/lib/table/types' const { mockReplaceRowsPrimitive, mockDeleteRowsByIds, + mockLoadSecretProvenance, + mockAssertRowCapacity, + mockNotifyTableRowUsage, mockQueryRows, mockRecordAudit, + mockReplaceRowsWithTx, mockResolveContext, mockResolvePermission, mockSignalRowsChanged, mockUpsertRow, + mockWithLockedTable, } = vi.hoisted(() => ({ mockReplaceRowsPrimitive: vi.fn(), mockDeleteRowsByIds: vi.fn(), + mockLoadSecretProvenance: vi.fn(), + mockAssertRowCapacity: vi.fn(), + mockNotifyTableRowUsage: vi.fn(), mockQueryRows: vi.fn(), mockRecordAudit: vi.fn(), + mockReplaceRowsWithTx: vi.fn(), mockResolveContext: vi.fn(), mockResolvePermission: vi.fn(), mockSignalRowsChanged: vi.fn(), mockUpsertRow: vi.fn(), + mockWithLockedTable: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -69,6 +79,25 @@ vi.mock('@/lib/table', () => ({ upsertRow: mockUpsertRow, validateBatchRows: vi.fn(), validateRowData: vi.fn(), + withLockedTable: mockWithLockedTable, +})) + +vi.mock('@/lib/table/billing', () => ({ + assertRowCapacity: mockAssertRowCapacity, + notifyTableRowUsage: mockNotifyTableRowUsage, +})) + +vi.mock('@/lib/table/column-types', () => ({ + columnTypeOf: (column: { type: string }) => ({ id: column.type }), +})) + +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: () => ({ complete: true, columns: {} }), + loadTableRowSecretProvenance: mockLoadSecretProvenance, +})) + +vi.mock('@/lib/table/rows/service', () => ({ + replaceTableRowsWithTx: mockReplaceRowsWithTx, })) vi.mock('@/lib/table/application/context', () => ({ @@ -81,7 +110,9 @@ vi.mock('@/lib/table/events', () => ({ import { deleteTableRows, + ProjectedWireRowsValidationError, queryTableRows, + replaceProjectedWireRows, replaceTableRows, TableRowsValidationError, tablePredicateNamesToFilter, @@ -118,6 +149,167 @@ describe('table predicate translation', () => { }) }) +describe('replaceProjectedWireRows application command', () => { + const freshTable: TableDefinition = { + ...TABLE, + schema: { + columns: [ + { id: 'column-fresh', name: 'full_name', type: 'string' }, + { id: 'column-score', name: 'score', type: 'number' }, + ], + }, + } + const delegatedPrincipal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: TABLE.workspaceId, + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-01-01'), + expiresAt: new Date('2099-01-01'), + resourceScope: { tableId: TABLE.id }, + } + + beforeEach(() => { + vi.clearAllMocks() + mockResolvePermission.mockResolvedValue('write') + mockResolveContext.mockResolvedValue({ + tableId: TABLE.id, + table: TABLE, + workspaceId: TABLE.workspaceId, + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mockAssertRowCapacity.mockResolvedValue(10_000) + mockWithLockedTable.mockImplementation( + async (_tableId: string, run: (table: TableDefinition, trx: unknown) => unknown) => + run(freshTable, { kind: 'transaction' }) + ) + mockReplaceRowsWithTx.mockResolvedValue({ deletedCount: 2, insertedCount: 1 }) + }) + + it('validates and replaces against the fresh schema held under the table lock', async () => { + const result = await replaceProjectedWireRows.execute({ + principal: delegatedPrincipal, + input: { + tableId: TABLE.id, + assertedWorkspaceId: TABLE.workspaceId, + sourceRows: [{ full_name: 'Ada' }], + projectedRows: [{ full_name: 'Ada' }], + requestId: 'request-1', + }, + }) + + expect(mockWithLockedTable).toHaveBeenCalledWith(TABLE.id, expect.any(Function), { + expectedWorkspaceId: TABLE.workspaceId, + }) + expect(mockReplaceRowsWithTx).toHaveBeenCalledWith( + { kind: 'transaction' }, + { + tableId: TABLE.id, + workspaceId: TABLE.workspaceId, + rows: [{ 'column-fresh': 'Ada' }], + userId: 'user-1', + secretProvenance: [{ complete: true, columns: {} }], + }, + freshTable, + 'request-1' + ) + expect(result.table).toBe(freshTable) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + operation: 'tables.rows.replace', + rowsDeleted: 2, + rowsInserted: 1, + }), + }) + ) + expect(mockSignalRowsChanged).toHaveBeenCalledWith(TABLE.id) + expect(mockNotifyTableRowUsage).toHaveBeenCalledWith({ + workspaceId: TABLE.workspaceId, + currentRowCount: 0, + addedRows: 1, + limit: 10_000, + }) + }) + + it('rejects a projected row that only matched the stale pre-lock schema', async () => { + await expect( + replaceProjectedWireRows.execute({ + principal: delegatedPrincipal, + input: { + tableId: TABLE.id, + assertedWorkspaceId: TABLE.workspaceId, + sourceRows: [{ name: 'Ada' }], + projectedRows: [{ name: 'Ada' }], + }, + }) + ).rejects.toBeInstanceOf(ProjectedWireRowsValidationError) + + expect(mockReplaceRowsWithTx).not.toHaveBeenCalled() + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) + + it('rejects delegated table-scope mismatch before opening the mutation lock', async () => { + await expect( + replaceProjectedWireRows.execute({ + principal: { + ...delegatedPrincipal, + resourceScope: { tableId: 'table-other' }, + }, + input: { + tableId: TABLE.id, + sourceRows: [{ full_name: 'Ada' }], + projectedRows: [{ full_name: 'Ada' }], + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mockWithLockedTable).not.toHaveBeenCalled() + expect(mockReplaceRowsWithTx).not.toHaveBeenCalled() + }) + + it('does not audit or signal when the authoritative replacement is a no-op', async () => { + mockReplaceRowsWithTx.mockResolvedValueOnce({ deletedCount: 0, insertedCount: 0 }) + + await replaceProjectedWireRows.execute({ + principal: delegatedPrincipal, + input: { + tableId: TABLE.id, + sourceRows: [{ full_name: 'Ada' }], + projectedRows: [{ full_name: 'Ada' }], + }, + }) + + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + }) + + it('propagates replacement failures without audit or shared effects', async () => { + const failure = new Error('database unavailable') + mockReplaceRowsWithTx.mockRejectedValueOnce(failure) + + await expect( + replaceProjectedWireRows.execute({ + principal: delegatedPrincipal, + input: { + tableId: TABLE.id, + sourceRows: [{ full_name: 'Ada' }], + projectedRows: [{ full_name: 'Ada' }], + }, + }) + ).rejects.toBe(failure) + + expect(mockRecordAudit).not.toHaveBeenCalled() + expect(mockSignalRowsChanged).not.toHaveBeenCalled() + expect(mockNotifyTableRowUsage).not.toHaveBeenCalled() + }) +}) + describe('replaceTableRows application use case', () => { beforeEach(() => { vi.clearAllMocks() @@ -234,6 +426,51 @@ describe('row query and upsert application semantics', () => { expect(mockQueryRows).not.toHaveBeenCalled() }) + it('rejects an oversized page before querying storage', async () => { + await expect( + queryTableRows.execute({ + principal: PRINCIPAL, + input: { tableId: TABLE.id, limit: 1001 }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mockQueryRows).not.toHaveBeenCalled() + expect(mockLoadSecretProvenance).not.toHaveBeenCalled() + }) + + it('loads requested persisted provenance inside the authorized application query', async () => { + const row = { + id: 'row-1', + tableId: TABLE.id, + data: { 'column-name': 'Ada' }, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } + const provenance = { complete: true, columns: {} } + mockQueryRows.mockResolvedValueOnce({ + rows: [row], + rowCount: 1, + totalCount: null, + nextCursor: null, + }) + mockLoadSecretProvenance.mockResolvedValueOnce(provenance) + + const result = await queryTableRows.execute({ + principal: PRINCIPAL, + input: { + tableId: TABLE.id, + limit: 10, + includePersistedSecretProvenance: true, + }, + }) + + expect(mockLoadSecretProvenance).toHaveBeenCalledWith([row], { + userId: 'user-1', + workspaceId: TABLE.workspaceId, + }) + expect(result.secretProvenance).toBe(provenance) + }) + it('audits only the authoritative deleted count and suppresses no-op audit', async () => { mockDeleteRowsByIds.mockResolvedValueOnce({ deletedCount: 1, diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 999b7bd133f..c8881b0c822 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -1,7 +1,9 @@ +import { isDeepStrictEqual } from 'node:util' import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { requirePrincipalSubjectUserId, resolvePrincipalAttribution } from '@sim/auth/principal' import { getRequestContext } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { isPlainRecord } from '@sim/utils/object' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { BulkDeleteByIdsResult, @@ -34,11 +36,14 @@ import { upsertRow, validateBatchRows, validateRowData, + withLockedTable, } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' import { resolveActiveTableContext } from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' +import { assertRowCapacity, notifyTableRowUsage } from '@/lib/table/billing' import { buildIdByName } from '@/lib/table/column-keys' +import { columnTypeOf } from '@/lib/table/column-types' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged } from '@/lib/table/events' import { predicateToFilter } from '@/lib/table/query-builder/converters' @@ -49,7 +54,12 @@ import { validateStoragePredicate, } from '@/lib/table/query-builder/validate' import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' +import { + createExactEmptyTableRowSecretProvenance, + loadTableRowSecretProvenance, +} from '@/lib/table/rows/secret-provenance' import type { FindRowMatch } from '@/lib/table/rows/service' +import { replaceTableRowsWithTx } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' export class TableRowsValidationError extends OrchestrationError { @@ -72,6 +82,21 @@ interface TableResult { table: TableDefinition } +type TableRowsProvenance = Awaited> + +async function loadAuthorizedRowsProvenance( + principal: Parameters[0], + workspaceId: string, + rows: TableRow[], + include: boolean | undefined +): Promise { + if (!include) return undefined + return loadTableRowSecretProvenance(rows, { + userId: requirePrincipalSubjectUserId(principal), + workspaceId, + }) +} + function requestId(input: TableScopedInput): string { return input.requestId ?? getRequestContext()?.requestId ?? generateId().slice(0, 8) } @@ -176,6 +201,7 @@ export interface QueryTableRowsInput extends TableScopedInput { limit?: number cursor?: string includeTotal?: boolean + includePersistedSecretProvenance?: boolean } export interface QueryTableRowsResult extends TableResult { @@ -183,12 +209,13 @@ export interface QueryTableRowsResult extends TableResult { rowCount: number totalCount: number | null nextCursor: string | null + secretProvenance?: TableRowsProvenance } export const queryTableRows = defineAuthorizedTableUseCase({ operation: tableOperations.queryRows, resolveContext: ({ input }: { input: QueryTableRowsInput }) => resolveActiveTableContext(input), - async execute({ input, context }): Promise { + async execute({ principal, input, context }): Promise { try { if (input.limit !== undefined) { requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_QUERY_LIMIT, 'Limit') @@ -221,7 +248,16 @@ export const queryTableRows = defineAuthorizedTableUseCase({ }, requestId(input) ) - return { table: context.table, ...result } + return { + table: context.table, + ...result, + secretProvenance: await loadAuthorizedRowsProvenance( + principal, + context.workspaceId, + result.rows, + input.includePersistedSecretProvenance + ), + } } catch (error) { rethrowQueryValidation(error) } @@ -270,19 +306,30 @@ export const findTableRows = defineAuthorizedTableUseCase({ export interface ReadTableRowInput extends TableScopedInput { rowId: string + includePersistedSecretProvenance?: boolean } export interface ReadTableRowResult extends TableResult { row: TableRow + secretProvenance?: TableRowsProvenance } export const readTableRow = defineAuthorizedTableUseCase({ operation: tableOperations.readRow, resolveContext: ({ input }: { input: ReadTableRowInput }) => resolveActiveTableContext(input), - async execute({ input, context }): Promise { + async execute({ principal, input, context }): Promise { const row = await getRowById(context.tableId, input.rowId, context.workspaceId) if (!row) throw new OrchestrationError('not_found', 'Row not found') - return { table: context.table, row } + return { + table: context.table, + row, + secretProvenance: await loadAuthorizedRowsProvenance( + principal, + context.workspaceId, + [row], + input.includePersistedSecretProvenance + ), + } }, }) @@ -428,15 +475,143 @@ export const replaceTableRows = defineAuthorizedTableUseCase({ }, }) +const PROJECTED_WIRE_ROWS_LIMIT = 10_000 +const PROJECTED_SECRET_COLUMN_TYPE_ERROR = + 'Tool output could not be persisted safely because a resolved secret is incompatible with the target column type.' + +export class ProjectedWireRowsValidationError extends TableRowsValidationError { + constructor(message: string) { + super(message) + this.name = 'ProjectedWireRowsValidationError' + } +} + +export interface ReplaceProjectedWireRowsInput extends TableScopedInput { + sourceRows: Array> + projectedRows: unknown +} + +export interface ReplaceProjectedWireRowsResult extends TableResult, ReplaceRowsResult {} + +function projectedRowsForTable( + table: TableDefinition, + sourceRows: Array>, + value: unknown +): RowData[] { + if (!Array.isArray(value) || !value.every(isPlainRecord)) { + throw new ProjectedWireRowsValidationError('Table rows could not be persisted safely') + } + if (value.length !== sourceRows.length) { + throw new ProjectedWireRowsValidationError( + 'Projected table rows must align one-to-one with source rows' + ) + } + if (value.length > PROJECTED_WIRE_ROWS_LIMIT) { + throw new ProjectedWireRowsValidationError( + `Table row replacement limit exceeded: got ${value.length}, max is ${PROJECTED_WIRE_ROWS_LIMIT}` + ) + } + + const columnsByName = new Map(table.schema.columns.map((column) => [column.name, column])) + for (let rowIndex = 0; rowIndex < value.length; rowIndex += 1) { + const projected = value[rowIndex] + for (const [name, projectedValue] of Object.entries(projected)) { + const column = columnsByName.get(name) + if (!column || isDeepStrictEqual(sourceRows[rowIndex]?.[name], projectedValue)) continue + const type = columnTypeOf(column).id + if (type !== 'string' && type !== 'json') { + throw new ProjectedWireRowsValidationError(PROJECTED_SECRET_COLUMN_TYPE_ERROR) + } + } + + if (!Object.keys(projected).some((name) => columnsByName.has(name))) { + throw new ProjectedWireRowsValidationError( + `Row ${rowIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((column) => column.name).join(', ')})` + ) + } + } + + const idByName = buildIdByName(table.schema) + return value.map((row) => rowDataNameToId(row as RowData, idByName)) +} + +/** Atomically validates name-keyed projected rows against the locked schema and replaces the table. */ +export const replaceProjectedWireRows = defineAuthorizedTableUseCase({ + operation: tableOperations.replaceRows, + resolveContext: ({ input }: { input: ReplaceProjectedWireRowsInput }) => + resolveActiveTableContext(input), + async execute({ principal, input, context }): Promise { + if (input.sourceRows.length > PROJECTED_WIRE_ROWS_LIMIT) { + throw new ProjectedWireRowsValidationError( + `Table row replacement limit exceeded: got ${input.sourceRows.length}, max is ${PROJECTED_WIRE_ROWS_LIMIT}` + ) + } + const rowLimit = await assertRowCapacity({ + workspaceId: context.workspaceId, + currentRowCount: 0, + addedRows: input.sourceRows.length, + }) + const result = await withLockedTable( + context.tableId, + async (table, trx) => { + const rows = projectedRowsForTable(table, input.sourceRows, input.projectedRows) + const replacement = await replaceTableRowsWithTx( + trx, + { + tableId: table.id, + workspaceId: table.workspaceId, + rows, + userId: actorUserId(principal, context.billedAccountUserId), + secretProvenance: rows.map(createExactEmptyTableRowSecretProvenance), + }, + table, + requestId(input) + ) + return { table, ...replacement } + }, + { expectedWorkspaceId: context.workspaceId } + ) + notifyTableRowUsage({ + workspaceId: context.workspaceId, + currentRowCount: 0, + addedRows: result.insertedCount, + limit: rowLimit, + }) + return result + }, + projectAudit({ result }) { + if (result.deletedCount === 0 && result.insertedCount === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Replaced rows in table "${result.table.name}"`, + metadata: { + op: 'replace_projected_rows', + rowsDeleted: result.deletedCount, + rowsInserted: result.insertedCount, + }, + } + }, + afterSuccess({ context, result }) { + if (result.deletedCount > 0 || result.insertedCount > 0) { + signalTableRowsChanged(context.tableId) + } + }, +}) + export interface UpdateTableRowInput extends TableScopedInput { rowId: string data: RowData secretProvenance?: TableRowSecretProvenanceWrite + includePersistedSecretProvenance?: boolean } export interface UpdateTableRowResult extends TableResult { row: TableRow changed: boolean + secretProvenance?: TableRowsProvenance } export const updateTableRow = defineAuthorizedTableUseCase({ @@ -457,7 +632,17 @@ export const updateTableRow = defineAuthorizedTableUseCase({ requestId(input) ) if (!row) throw new Error('Unconditional table row update was rejected') - return { table: context.table, row, changed: Object.keys(data).length > 0 } + return { + table: context.table, + row, + changed: Object.keys(data).length > 0, + secretProvenance: await loadAuthorizedRowsProvenance( + principal, + context.workspaceId, + [row], + input.includePersistedSecretProvenance + ), + } }, afterSuccess: ({ context, result }) => { if (result.changed) signalTableRowsChanged(context.tableId) diff --git a/apps/sim/lib/table/application/workspace-file-imports.test.ts b/apps/sim/lib/table/application/workspace-file-imports.test.ts new file mode 100644 index 00000000000..b7f8cfdb73c --- /dev/null +++ b/apps/sim/lib/table/application/workspace-file-imports.test.ts @@ -0,0 +1,355 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + batchInsert: vi.fn(), + createTable: vi.fn(), + deleteTable: vi.fn(), + fetchFile: vi.fn(), + inferSchema: vi.fn(), + loadFileContext: vi.fn(), + markJob: vi.fn(), + parseRows: vi.fn(), + provenance: vi.fn(), + releaseJob: vi.fn(), + replaceRows: vi.fn(), + resolveFile: vi.fn(), + resolvePermission: vi.fn(), + resolveTableContext: vi.fn(), + resolveWorkspaceContext: vi.fn(), + runDetached: vi.fn(), + signal: vi.fn(), + validateMapping: vi.fn(), + CsvImportValidationError: class extends Error {}, +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { TABLE_CREATED: 'table.created', TABLE_UPDATED: 'table.updated' }, + AuditResourceType: { TABLE: 'table' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@sim/utils/id', () => ({ generateId: () => 'request-id-1234' })) +vi.mock('@/lib/core/config/env-flags', () => ({ isTriggerDevEnabled: false })) +vi.mock('@/lib/core/utils/background', () => ({ runDetached: mocks.runDetached })) +vi.mock('@/lib/table', () => ({ + batchInsertRows: mocks.batchInsert, + buildAutoMapping: vi.fn(() => ({ name: 'name' })), + coerceRowsForTable: (rows: unknown[]) => rows, + CsvImportValidationError: mocks.CsvImportValidationError, + CSV_ASYNC_IMPORT_THRESHOLD_BYTES: 8 * 1024 * 1024, + CSV_MAX_BATCH_SIZE: 1000, + getWorkspaceTableLimits: vi.fn(() => ({ maxRowsPerTable: 100, maxTables: 5 })), + inferSchemaFromCsv: mocks.inferSchema, + parseFileRows: mocks.parseRows, + replaceTableRows: mocks.replaceRows, + sanitizeName: (value: string) => value, + TABLE_LIMITS: { MAX_TABLE_NAME_LENGTH: 128 }, + validateMapping: mocks.validateMapping, +})) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveTableContext, + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, +})) +vi.mock('@/lib/table/events', () => ({ signalTableRowsChanged: mocks.signal })) +vi.mock('@/lib/table/import-runner', () => ({ runTableImport: vi.fn() })) +vi.mock('@/lib/table/jobs/service', () => ({ + markTableJobRunningInWorkspace: mocks.markJob, + releaseJobClaimInWorkspace: mocks.releaseJob, +})) +vi.mock('@/lib/table/rows/secret-provenance', () => ({ + createExactEmptyTableRowSecretProvenance: () => ({ complete: true, columns: {} }), +})) +vi.mock('@/lib/table/service', () => ({ + createTable: mocks.createTable, + deleteTable: mocks.deleteTable, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchFile, + loadActiveWorkspaceFileContext: mocks.loadFileContext, + resolveWorkspaceFileReference: mocks.resolveFile, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ + getBoundWorkspaceFileSecretProvenance: mocks.provenance, +})) + +import { + createTableFromWorkspaceFile, + importWorkspaceFileIntoTable, +} from '@/lib/table/application/workspace-file-imports' + +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: 'Imported', + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, + metadata: null, + rowCount: 0, + maxRows: 100, + workspaceId: 'workspace-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} +const sourceFile = { + id: 'file-1', + workspaceId: 'workspace-1', + key: 'workspace/workspace-1/people.csv', + name: 'people.csv', + type: 'text/csv', + size: 128, +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), +} +const tablePrincipal = { ...principal, resourceScope: { tableId: 'table-1' } } + +describe('workspace-file Table application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveWorkspaceContext.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolveTableContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolveFile.mockResolvedValue(sourceFile) + mocks.loadFileContext.mockResolvedValue(sourceFile) + mocks.provenance.mockResolvedValue({ status: 'exact', entries: [] }) + mocks.fetchFile.mockResolvedValue(Buffer.from('name\nAda')) + mocks.parseRows.mockResolvedValue({ headers: ['name'], rows: [{ name: 'Ada' }] }) + mocks.inferSchema.mockReturnValue({ + columns: [{ name: 'name', type: 'string' }], + headerToColumn: new Map([['name', 'name']]), + }) + mocks.createTable.mockResolvedValue(table) + mocks.deleteTable.mockResolvedValue(undefined) + mocks.batchInsert.mockImplementation(async ({ rows }: { rows: unknown[] }) => + rows.map((_, index) => ({ id: `row-${index}` })) + ) + mocks.replaceRows.mockResolvedValue({ insertedCount: 1, deletedCount: 2 }) + mocks.markJob.mockResolvedValue(true) + mocks.releaseJob.mockResolvedValue(true) + mocks.validateMapping.mockReturnValue({ + effectiveMap: new Map([['name', 'name']]), + mappedHeaders: ['name'], + skippedHeaders: [], + }) + }) + + it('owns canonical file resolution, bounded parsing, table creation, audit, and effects', async () => { + const result = await createTableFromWorkspaceFile.execute({ + principal, + input: { + workspaceId: 'workspace-1', + fileReference: 'files/people.csv', + name: 'People', + }, + }) + + expect(result).toMatchObject({ kind: 'inline', insertedCount: 1, table }) + expect(mocks.resolveFile).toHaveBeenCalledWith('workspace-1', 'files/people.csv') + expect(mocks.fetchFile).toHaveBeenCalledWith(sourceFile, { maxBytes: 50 * 1024 * 1024 }) + expect(mocks.createTable).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace-1', userId: 'user-1', maxTables: 5 }), + 'request-' + ) + expect(mocks.batchInsert).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith(table.id) + }) + + it('conceals cross-workspace files before parsing or table mutation', async () => { + mocks.resolveFile.mockResolvedValueOnce({ ...sourceFile, workspaceId: 'workspace-other' }) + + await expect( + createTableFromWorkspaceFile.execute({ + principal, + input: { workspaceId: 'workspace-1', fileReference: 'files/people.csv' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.fetchFile).not.toHaveBeenCalled() + expect(mocks.createTable).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('rejects non-delegated upload identities before canonical workspace or file loading', async () => { + await expect( + createTableFromWorkspaceFile.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + } as never, + input: { workspaceId: 'workspace-1', fileReference: 'files/people.csv' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() + expect(mocks.resolveFile).not.toHaveBeenCalled() + }) + + it('preserves large-file background admission without buffering inline', async () => { + mocks.resolveFile.mockResolvedValueOnce({ ...sourceFile, size: 8 * 1024 * 1024 }) + + const result = await createTableFromWorkspaceFile.execute({ + principal, + input: { workspaceId: 'workspace-1', fileReference: 'files/people.csv' }, + }) + + expect(result).toMatchObject({ kind: 'background', table, jobId: 'request-id-1234' }) + expect(mocks.fetchFile).not.toHaveBeenCalled() + expect(mocks.runDetached).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledTimes(1) + }) + + it('rolls back a partially-created table and emits no audit or effect on insertion failure', async () => { + const failure = new Error('database unavailable') + mocks.batchInsert.mockRejectedValueOnce(failure) + + await expect( + createTableFromWorkspaceFile.execute({ + principal, + input: { workspaceId: 'workspace-1', fileReference: 'files/people.csv' }, + }) + ).rejects.toBe(failure) + + expect(mocks.deleteTable).toHaveBeenCalledWith(table.id, 'request-') + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('holds the concurrency claim across file loading and inline mutation', async () => { + const events: string[] = [] + mocks.markJob.mockImplementationOnce(async () => { + events.push('claim') + return true + }) + mocks.fetchFile.mockImplementationOnce(async () => { + events.push('load') + return Buffer.from('name\nAda') + }) + mocks.batchInsert.mockImplementationOnce(async () => { + events.push('mutate') + return [{ id: 'row-1' }] + }) + mocks.releaseJob.mockImplementationOnce(async () => { + events.push('release') + return true + }) + + await importWorkspaceFileIntoTable.execute({ + principal: tablePrincipal, + input: { + tableId: table.id, + assertedWorkspaceId: table.workspaceId, + fileReference: 'files/people.csv', + mode: 'append', + }, + }) + + expect(events).toEqual(['claim', 'load', 'mutate', 'release']) + }) + + it('rejects a concurrent import claim before buffering or mutating rows', async () => { + mocks.markJob.mockResolvedValueOnce(false) + + await expect( + importWorkspaceFileIntoTable.execute({ + principal: tablePrincipal, + input: { + tableId: table.id, + assertedWorkspaceId: table.workspaceId, + fileReference: 'files/people.csv', + mode: 'append', + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + + expect(mocks.fetchFile).not.toHaveBeenCalled() + expect(mocks.batchInsert).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('preserves append partial-failure semantics and releases the claim on abort', async () => { + mocks.parseRows.mockResolvedValueOnce({ + headers: ['name'], + rows: Array.from({ length: 1001 }, (_, index) => ({ name: `Person ${index}` })), + }) + const stopped = new Error('stopped') + let checks = 0 + const assertNotAborted = vi.fn(() => { + checks += 1 + if (checks === 3) throw stopped + }) + + await expect( + importWorkspaceFileIntoTable.execute({ + principal: tablePrincipal, + input: { + tableId: table.id, + assertedWorkspaceId: table.workspaceId, + fileReference: 'files/people.csv', + mode: 'append', + assertNotAborted, + }, + }) + ).rejects.toBe(stopped) + + expect(mocks.batchInsert).toHaveBeenCalledTimes(1) + expect(mocks.releaseJob).toHaveBeenCalledWith(table.id, table.workspaceId, 'request-id-1234') + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() + }) + + it('rejects non-empty secret provenance before parsing or mutation', async () => { + mocks.provenance.mockResolvedValueOnce({ status: 'exact', entries: [{ name: 'SECRET' }] }) + + await expect( + importWorkspaceFileIntoTable.execute({ + principal: tablePrincipal, + input: { + tableId: table.id, + assertedWorkspaceId: table.workspaceId, + fileReference: 'files/people.csv', + mode: 'append', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.fetchFile).not.toHaveBeenCalled() + expect(mocks.markJob).not.toHaveBeenCalled() + expect(mocks.batchInsert).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/workspace-file-imports.ts b/apps/sim/lib/table/application/workspace-file-imports.ts new file mode 100644 index 00000000000..f0e710a0660 --- /dev/null +++ b/apps/sim/lib/table/application/workspace-file-imports.ts @@ -0,0 +1,548 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { isTriggerDevEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { runDetached } from '@/lib/core/utils/background' +import { + batchInsertRows, + buildAutoMapping, + type ColumnDefinition, + CSV_ASYNC_IMPORT_THRESHOLD_BYTES, + CSV_MAX_BATCH_SIZE, + type CsvHeaderMapping, + CsvImportValidationError, + coerceRowsForTable, + getWorkspaceTableLimits, + inferSchemaFromCsv, + parseFileRows, + type RowData, + replaceTableRows, + sanitizeName, + TABLE_LIMITS, + type TableDefinition, + validateMapping, +} from '@/lib/table' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + resolveActiveTableContext, + resolveTableWorkspaceContext, +} from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { signalTableRowsChanged } from '@/lib/table/events' +import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' +import { + markTableJobRunningInWorkspace, + releaseJobClaimInWorkspace, +} from '@/lib/table/jobs/service' +import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' +import { createTable, deleteTable } from '@/lib/table/service' +import { + fetchWorkspaceFileBuffer, + loadActiveWorkspaceFileContext, + resolveWorkspaceFileReference, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' + +const logger = createLogger('TableWorkspaceFileImportApplication') + +export interface TableWorkspaceFileSource { + id: string + workspaceId: string + key: string + name: string + type: string + size: number +} + +const MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 + +export interface CreateTableFromWorkspaceFileInput { + workspaceId: string + fileReference: string + name?: string + description?: string + assertNotAborted?: () => void +} + +export type CreateTableFromWorkspaceFileResult = + | { + kind: 'empty' + sourceFile: TableWorkspaceFileSource + } + | { + kind: 'background' + table: TableDefinition + jobId: string + sourceFile: TableWorkspaceFileSource + } + | { + kind: 'inline' + table: TableDefinition + columns: ColumnDefinition[] + insertedCount: number + droppedRows: number + maxRowsPerTable: number + sourceFile: TableWorkspaceFileSource + } + +export interface ImportWorkspaceFileInput { + tableId: string + assertedWorkspaceId: string + fileReference: string + mode: 'append' | 'replace' + mapping?: CsvHeaderMapping + assertNotAborted?: () => void +} + +export type ImportWorkspaceFileResult = + | { + kind: 'background' + table: TableDefinition + jobId: string + mode: 'append' | 'replace' + sourceFileName: string + } + | { + kind: 'empty' + table: TableDefinition + mode: 'append' | 'replace' + } + | { + kind: 'inline' + table: TableDefinition + mode: 'append' + matchedColumns: string[] + skippedColumns: string[] + insertedCount: number + sourceFileName: string + } + | { + kind: 'inline' + table: TableDefinition + mode: 'replace' + matchedColumns: string[] + skippedColumns: string[] + insertedCount: number + deletedCount: number + sourceFileName: string + } + +function requestId(): string { + return generateId().slice(0, 8) +} + +async function resolveSafeSourceFile( + workspaceId: string, + reference: string +): Promise { + const file = await resolveWorkspaceFileReference(workspaceId, reference) + if (!file) { + if (reference.replace(/^\/+/, '').startsWith('uploads/')) { + throw new OrchestrationError( + 'validation', + `Cannot import "${reference}": chat uploads are not workspace files. Use materialize_file to save it to a files/... path first, then pass that canonical path.` + ) + } + throw new OrchestrationError( + 'not_found', + `File not found: "${reference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` + ) + } + const canonical = await loadActiveWorkspaceFileContext(file.id) + if (!canonical || canonical.workspaceId !== workspaceId || file.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Workspace file not found') + } + const provenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, { + fileId: file.id, + key: file.key, + context: 'workspace', + }) + if (provenance.status !== 'exact' || provenance.entries.length > 0) { + throw new OrchestrationError( + 'validation', + `Cannot import "${reference}": the file cannot be verified as free of resolved secrets.` + ) + } + return file +} + +function shouldImportInBackground(file: TableWorkspaceFileSource): boolean { + const extension = file.name.split('.').pop()?.toLowerCase() + return ( + (extension === 'csv' || extension === 'tsv') && file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES + ) +} + +async function loadInlineRows(file: WorkspaceFileRecord) { + const content = await fetchWorkspaceFileBuffer(file, { maxBytes: MAX_INLINE_FILE_BYTES }) + return parseFileRows(content, file.name, file.type) +} + +async function batchInsertAll(params: { + table: TableDefinition + rows: RowData[] + workspaceId: string + userId: string + assertNotAborted?: () => void +}): Promise { + let inserted = 0 + for (let index = 0; index < params.rows.length; index += CSV_MAX_BATCH_SIZE) { + params.assertNotAborted?.() + const batch = params.rows.slice(index, index + CSV_MAX_BATCH_SIZE) + const result = await batchInsertRows( + { + tableId: params.table.id, + rows: batch, + workspaceId: params.workspaceId, + userId: params.userId, + secretProvenance: batch.map(createExactEmptyTableRowSecretProvenance), + }, + { ...params.table, rowCount: params.table.rowCount + inserted }, + requestId() + ) + inserted += result.length + } + return inserted +} + +async function dispatchImportJob(payload: TableImportPayload): Promise { + if (isTriggerDevEnabled) { + 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', payload, { + tags: [`tableId:${payload.tableId}`, `jobId:${payload.importId}`], + region: await resolveTriggerRegion(), + }) + } catch (error) { + try { + const released = await releaseJobClaimInWorkspace( + payload.tableId, + payload.workspaceId, + payload.importId + ) + if (!released) throw new Error('Table import claim was no longer active') + } catch (cleanupError) { + logger.error('Failed to release table import claim after dispatch failure', { + tableId: payload.tableId, + jobId: payload.importId, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + return + } + runDetached('table-import', () => runTableImport(payload)) +} + +async function withReleasedTableJobClaim( + tableId: string, + workspaceId: string, + jobId: string, + run: () => Promise +): Promise { + let result: T + try { + result = await run() + } catch (error) { + try { + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) throw new Error('Table import claim was no longer active') + } catch (cleanupError) { + logger.error('Failed to release table import claim after operation failure', { + tableId, + workspaceId, + jobId, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + const released = await releaseJobClaimInWorkspace(tableId, workspaceId, jobId) + if (!released) throw new Error('Table import claim was no longer active') + return result +} + +export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({ + operation: tableOperations.createFromWorkspaceFile, + resolveContext: ({ input }: { input: CreateTableFromWorkspaceFileInput }) => + resolveTableWorkspaceContext(input.workspaceId), + async execute({ principal, input, context }): Promise { + const sourceFile = await resolveSafeSourceFile(context.workspaceId, input.fileReference) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const limits = await getWorkspaceTableLimits(context.workspaceId) + const name = + input.name ?? + sanitizeName(sourceFile.name.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ) + const description = input.description ?? `Imported from ${sourceFile.name}` + + if (shouldImportInBackground(sourceFile)) { + input.assertNotAborted?.() + const jobId = generateId() + const table = await createTable( + { + name, + description, + schema: { columns: [{ name: 'column_1', type: 'string' }] }, + workspaceId: context.workspaceId, + userId, + maxRows: limits.maxRowsPerTable, + maxTables: limits.maxTables, + jobStatus: 'running', + jobType: 'import', + jobId, + }, + requestId() + ) + try { + await dispatchImportJob({ + importId: jobId, + tableId: table.id, + workspaceId: context.workspaceId, + userId, + fileKey: sourceFile.key, + fileName: sourceFile.name, + delimiter: sourceFile.name.toLowerCase().endsWith('.tsv') ? '\t' : ',', + mode: 'create', + deleteSourceFile: false, + }) + } catch (error) { + try { + await deleteTable(table.id, requestId()) + } catch (cleanupError) { + logger.error('Failed to remove placeholder table after import dispatch failure', { + tableId: table.id, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + return { kind: 'background', table, jobId, sourceFile } + } + + const { headers, rows: sourceRows } = await loadInlineRows(sourceFile) + if (sourceRows.length === 0) { + return { kind: 'empty', sourceFile } + } + const { columns, headerToColumn } = inferSchemaFromCsv(headers, sourceRows) + input.assertNotAborted?.() + const droppedRows = Math.max(0, sourceRows.length - limits.maxRowsPerTable) + const rows = droppedRows > 0 ? sourceRows.slice(0, limits.maxRowsPerTable) : sourceRows + const table = await createTable( + { + name, + description, + schema: { columns }, + workspaceId: context.workspaceId, + userId, + maxTables: limits.maxTables, + }, + requestId() + ) + try { + const insertedCount = await batchInsertAll({ + table, + rows: coerceRowsForTable(rows, table.schema, headerToColumn), + workspaceId: context.workspaceId, + userId, + assertNotAborted: input.assertNotAborted, + }) + return { + kind: 'inline', + table, + columns, + insertedCount, + droppedRows, + maxRowsPerTable: limits.maxRowsPerTable, + sourceFile, + } + } catch (error) { + try { + await deleteTable(table.id, requestId()) + } catch (cleanupError) { + logger.error('Failed to roll back table after import failure', { + tableId: table.id, + error: getErrorMessage(cleanupError), + }) + } + throw error + } + }, + projectAudit({ result }) { + if (result.kind === 'empty') return [] + return { + action: AuditAction.TABLE_CREATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Created table "${result.table.name}" from workspace file`, + metadata: { sourceFileId: result.sourceFile.id, importMode: result.kind }, + } + }, + afterSuccess({ result }) { + if (result.kind === 'inline' && result.insertedCount > 0) { + signalTableRowsChanged(result.table.id) + } + }, +}) + +export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({ + operation: tableOperations.importWorkspaceFile, + resolveContext: ({ input }: { input: ImportWorkspaceFileInput }) => + resolveActiveTableContext({ + tableId: input.tableId, + assertedWorkspaceId: input.assertedWorkspaceId, + }), + async execute({ principal, input, context }): Promise { + const sourceFile = await resolveSafeSourceFile(context.workspaceId, input.fileReference) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + + if (shouldImportInBackground(sourceFile)) { + input.assertNotAborted?.() + const jobId = generateId() + const claimed = await markTableJobRunningInWorkspace( + context.table.id, + context.workspaceId, + jobId, + 'import' + ) + if (!claimed) + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + await dispatchImportJob({ + importId: jobId, + tableId: context.table.id, + workspaceId: context.workspaceId, + userId, + fileKey: sourceFile.key, + fileName: sourceFile.name, + delimiter: sourceFile.name.toLowerCase().endsWith('.tsv') ? '\t' : ',', + mode: input.mode, + mapping: input.mapping, + deleteSourceFile: false, + }) + return { + kind: 'background', + table: context.table, + jobId, + mode: input.mode, + sourceFileName: sourceFile.name, + } + } + + const jobId = generateId() + const claimed = await markTableJobRunningInWorkspace( + context.table.id, + context.workspaceId, + jobId, + 'import' + ) + if (!claimed) + throw new OrchestrationError('conflict', 'A job is already in progress for this table') + return withReleasedTableJobClaim(context.table.id, context.workspaceId, jobId, async () => { + const { headers, rows: sourceRows } = await loadInlineRows(sourceFile) + input.assertNotAborted?.() + if (sourceRows.length === 0) { + return { kind: 'empty', table: context.table, mode: input.mode } + } + const mapping = input.mapping ?? buildAutoMapping(headers, context.table.schema) + let validation: ReturnType + try { + validation = validateMapping({ + csvHeaders: headers, + mapping, + tableSchema: context.table.schema, + }) + } catch (error) { + if (!(error instanceof CsvImportValidationError)) throw error + throw new OrchestrationError('validation', error.message) + } + if (validation.mappedHeaders.length === 0) { + throw new OrchestrationError( + 'validation', + `No matching columns between file (${headers.join(', ')}) and table (${context.table.schema.columns.map((column) => column.name).join(', ')})` + ) + } + const rows = coerceRowsForTable(sourceRows, context.table.schema, validation.effectiveMap) + if (input.mode === 'replace') { + const result = await replaceTableRows( + { + tableId: context.table.id, + rows, + workspaceId: context.workspaceId, + userId, + secretProvenance: rows.map(createExactEmptyTableRowSecretProvenance), + }, + context.table, + requestId() + ) + return { + kind: 'inline', + table: context.table, + mode: input.mode, + matchedColumns: validation.mappedHeaders, + skippedColumns: validation.skippedHeaders, + insertedCount: result.insertedCount, + deletedCount: result.deletedCount, + sourceFileName: sourceFile.name, + } + } + const insertedCount = await batchInsertAll({ + table: context.table, + rows, + workspaceId: context.workspaceId, + userId, + assertNotAborted: input.assertNotAborted, + }) + return { + kind: 'inline', + table: context.table, + mode: input.mode, + matchedColumns: validation.mappedHeaders, + skippedColumns: validation.skippedHeaders, + insertedCount, + sourceFileName: sourceFile.name, + } + }) + }, + projectAudit({ result }) { + if (result.kind !== 'inline') return [] + const affected = result.insertedCount + (result.mode === 'replace' ? result.deletedCount : 0) + if (affected === 0) return [] + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Imported workspace file into table "${result.table.name}"`, + metadata: { + op: 'workspace_file_import', + mode: result.mode, + rowsInserted: result.insertedCount, + ...(result.mode === 'replace' ? { rowsDeleted: result.deletedCount } : {}), + }, + } + }, + afterSuccess({ result }) { + if ( + result.kind === 'inline' && + (result.insertedCount > 0 || (result.mode === 'replace' && result.deletedCount > 0)) + ) { + signalTableRowsChanged(result.table.id) + } + }, +}) diff --git a/apps/sim/lib/table/orchestration/import-resource.test.ts b/apps/sim/lib/table/orchestration/import-resource.test.ts index b281965477d..143b360d4e6 100644 --- a/apps/sim/lib/table/orchestration/import-resource.test.ts +++ b/apps/sim/lib/table/orchestration/import-resource.test.ts @@ -7,7 +7,6 @@ const { mockCreateTable, mockCreateUploadSession, mockDbLimit, - mockGetUserEntityPermissions, mockGetUserSettings, mockGetWorkspaceFile, mockGetWorkspaceTableLimits, @@ -16,7 +15,6 @@ const { mockCreateTable: vi.fn(), mockCreateUploadSession: vi.fn(), mockDbLimit: vi.fn(), - mockGetUserEntityPermissions: vi.fn(), mockGetUserSettings: vi.fn(), mockGetWorkspaceFile: vi.fn(), mockGetWorkspaceTableLimits: vi.fn(), @@ -47,16 +45,23 @@ vi.mock('@/lib/uploads/upload-session/service', () => ({ getOwnedUploadSession: vi.fn(), })) vi.mock('@/lib/users/queries', () => ({ getUserSettings: mockGetUserSettings })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import' -import { createTableImportResource } from '@/lib/table/orchestration/import-resource' +import { createAuthorizedTableImportResource } from '@/lib/table/orchestration/import-resource' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const SOURCE = { type: 'workspace_file' as const, fileId: 'file-1' } const TARGET = { type: 'new' as const, name: 'imported_data' } +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + +function createImport(body: Parameters[0]['body']) { + return createAuthorizedTableImportResource({ + body, + userId: 'user-1', + principal, + localOrigin: 'http://localhost:3000', + }) +} function workspaceFile(size: number) { return { @@ -73,10 +78,9 @@ function workspaceFile(size: number) { } } -describe('createTableImportResource workspace file size', () => { +describe('createAuthorizedTableImportResource workspace file size', () => { beforeEach(() => { vi.clearAllMocks() - mockGetUserEntityPermissions.mockResolvedValue('write') mockGetWorkspaceTableLimits.mockResolvedValue({ maxTables: 100, maxRowsPerTable: 10_000 }) mockCreateTable.mockResolvedValue({ id: 'table-1' }) mockGetUserSettings.mockResolvedValue({ timezone: 'UTC' }) @@ -106,11 +110,7 @@ describe('createTableImportResource workspace file size', () => { it('accepts a workspace CSV at the exact byte limit', async () => { mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES)) - const result = await createTableImportResource( - { workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }, - 'user-1', - 'http://localhost:3000' - ) + const result = await createImport({ workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }) expect(result.upload).toBeNull() expect(mockCreateTable).toHaveBeenCalledOnce() @@ -121,21 +121,16 @@ describe('createTableImportResource workspace file size', () => { mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES + 1)) await expect( - createTableImportResource( - { workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }, - 'user-1', - 'http://localhost:3000' - ) + createImport({ workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET }) ).rejects.toMatchObject({ code: 'validation' }) expect(mockCreateTable).not.toHaveBeenCalled() expect(mockRunDetached).not.toHaveBeenCalled() }) }) -describe('createTableImportResource upload size', () => { +describe('createAuthorizedTableImportResource upload size', () => { beforeEach(() => { vi.clearAllMocks() - mockGetUserEntityPermissions.mockResolvedValue('write') mockCreateUploadSession.mockResolvedValue({ id: 'import-1', userId: 'user-1', @@ -149,20 +144,16 @@ describe('createTableImportResource upload size', () => { }) it('creates an upload session for a CSV at the exact byte limit', async () => { - await createTableImportResource( - { - workspaceId: WORKSPACE_ID, - source: { - type: 'upload', - name: 'data.csv', - contentType: 'text/csv', - size: CSV_MAX_FILE_SIZE_BYTES, - }, - target: TARGET, + await createImport({ + workspaceId: WORKSPACE_ID, + source: { + type: 'upload', + name: 'data.csv', + contentType: 'text/csv', + size: CSV_MAX_FILE_SIZE_BYTES, }, - 'user-1', - 'http://localhost:3000' - ) + target: TARGET, + }) expect(mockCreateUploadSession).toHaveBeenCalledWith( expect.objectContaining({ fileSize: CSV_MAX_FILE_SIZE_BYTES, purpose: 'table_import' }) @@ -171,20 +162,16 @@ describe('createTableImportResource upload size', () => { it('rejects an upload one byte over the limit before creating a session', async () => { await expect( - createTableImportResource( - { - workspaceId: WORKSPACE_ID, - source: { - type: 'upload', - name: 'data.csv', - contentType: 'text/csv', - size: CSV_MAX_FILE_SIZE_BYTES + 1, - }, - target: TARGET, + createImport({ + workspaceId: WORKSPACE_ID, + source: { + type: 'upload', + name: 'data.csv', + contentType: 'text/csv', + size: CSV_MAX_FILE_SIZE_BYTES + 1, }, - 'user-1', - 'http://localhost:3000' - ) + target: TARGET, + }) ).rejects.toMatchObject({ code: 'validation' }) expect(mockCreateUploadSession).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index b61cab84a64..d58ca0c9a57 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -38,12 +38,13 @@ import { type UploadSessionRecord, } from '@/lib/uploads/upload-session/service' import { getUserSettings } from '@/lib/users/queries' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('TableImportResource') type TableImportStatus = 'uploading' | 'running' | 'ready' | 'failed' | 'canceled' | 'expired' +export type CreateTableImportRequest = V2CreateTableImportBody + export interface TableImportResource { id: string workspaceId: string @@ -66,7 +67,7 @@ export interface CreateTableImportResult { } interface AuthorizedTableImportResourceParams { - body: V2CreateTableImportBody + body: CreateTableImportRequest userId: string principal?: Principal localOrigin?: string @@ -128,22 +129,6 @@ export async function createAuthorizedTableImportResource( return createTableImportResourceCore(params) } -/** Legacy internal resource entry point retained until internal JWTs carry signed workspace scope. */ -export async function createTableImportResource( - body: V2CreateTableImportBody, - userId: string, - localOrigin: string, - resolvedFolderId?: string | null -): Promise { - await assertWorkspaceWrite(userId, body.workspaceId) - return createTableImportResourceCore({ - body, - userId, - localOrigin, - resolvedFolderId, - }) -} - export async function startUploadedTableImport( upload: UploadSessionRecord ): Promise { @@ -195,24 +180,6 @@ export async function getPrincipalTableImportUpload(params: { return upload } -/** Legacy internal lookup retained until its bearer token can bind a full Principal. */ -export async function getOwnedTableImportUpload(params: { - importId: string - workspaceId: string - userId: string - uploadToken: string -}): Promise { - const upload = await getOwnedUploadSession({ - uploadId: params.importId, - workspaceId: params.workspaceId, - userId: params.userId, - purpose: 'table_import', - uploadToken: params.uploadToken, - }) - tableImportBodyFromUpload(upload) - return upload -} - export async function abortAuthorizedTableImportUpload( upload: UploadSessionRecord, principal: Principal @@ -222,18 +189,6 @@ export async function abortAuthorizedTableImportUpload( return resourceFromUpload(await abortUploadSession(upload), body) } -/** Legacy internal cancellation retained until its bearer token can bind a full Principal. */ -export async function abortTableImportUpload(params: { - importId: string - workspaceId: string - userId: string - uploadToken: string -}): Promise { - const upload = await getOwnedTableImportUpload(params) - const body = tableImportBodyFromUpload(upload) - return resourceFromUpload(await abortUploadSession(upload), body) -} - export async function getTableImportResource(params: { importId: string assertedWorkspaceId?: string @@ -279,28 +234,6 @@ export async function findTableImportResource(params: { } } -export async function getOwnedTableImport(params: { - importId: string - workspaceId: string - userId: string -}): Promise { - const record = await findOwnedTableImport(params) - if (!record) throw new OrchestrationError('not_found', 'Table import not found') - return record -} - -export async function findOwnedTableImport(params: { - importId: string - workspaceId: string - userId: string -}): Promise { - const record = await findTableImportResource({ - importId: params.importId, - assertedWorkspaceId: params.workspaceId, - }) - return record?.userId === params.userId ? record : null -} - export async function cancelTableImportResource( record: TableImportResource ): Promise { @@ -596,13 +529,6 @@ async function requireWorkspaceSource( return resolved } -async function assertWorkspaceWrite(userId: string, workspaceId: string): Promise { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'write' && permission !== 'admin') { - throw new OrchestrationError('forbidden', 'Access denied') - } -} - function uploadStatus(upload: UploadSessionRecord): TableImportStatus { switch (upload.status) { case 'uploading': diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 6d95f1c9f54..cc009203728 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -939,6 +939,11 @@ export interface UpdateWorkflowGroupData { * source. */ mappingUpdates?: Array<{ columnName: string; blockId: string; path: string }> + /** Workflow-authorized column types for mapping updates. */ + resolvedMappingTypes?: { + workflowId: string + columns: Array<{ columnName: string; type: ColumnDefinition['type'] }> + } /** Replace the group's input mappings. Omit to leave them unchanged. */ inputMappings?: WorkflowGroupInputMapping[] /** Change which workflow state the group runs against. Omit to leave unchanged. */ diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 394a16922f3..35378a997b9 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -19,6 +19,7 @@ import { getColumnId, remapGroupColumnRefs, } from '@/lib/table/column-keys' +import { deriveOutputColumnName } from '@/lib/table/column-naming' import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { assertColumnDestructive, assertSchemaMutable } from '@/lib/table/mutation-locks' import { stripGroupExecutions } from '@/lib/table/rows/executions' @@ -249,60 +250,22 @@ export async function updateWorkflowGroup( ): Promise { const mappingUpdates = data.mappingUpdates ?? [] - // Phase 1 (no lock): when there are mapping updates, load the workflow once to - // resolve each remap's new leaf type. Kept OFF the advisory-lock critical - // section so concurrent group edits on the same table don't time out waiting - // on this DB load. Best-effort — a resolution failure leaves column types - // unchanged (workflow deleted, block removed). The result is applied against - // the fresh schema under the lock in phase 2. + // Phase 1 (no lock): consume the output types resolved and authorized by the + // application command. Resolution stays outside the advisory-lock critical + // section so concurrent group edits do not hold the schema lock during the + // workflow read. Missing metadata is an application-boundary violation. const remapLeafTypeByColumn = new Map() // The workflow id the leaf types above were resolved against. Phase 2 only // applies the resolved types if the group still points at this workflow under // the lock — a concurrent `workflowId` change would make them stale. let resolvedForWorkflowId: string | undefined if (mappingUpdates.length > 0) { - const preTable = await getTableById(data.tableId) - if (!preTable || (data.workspaceId && preTable.workspaceId !== data.workspaceId)) { - throw new OrchestrationError('not_found', 'Table not found') + if (!data.resolvedMappingTypes) { + throw new Error('Workflow group mapping updates require authorized resolved output types') } - try { - const preGroup = preTable?.schema.workflowGroups?.find((g) => g.id === data.groupId) - const targetWorkflowId = data.workflowId ?? preGroup?.workflowId - if (targetWorkflowId) { - resolvedForWorkflowId = targetWorkflowId - const [ - { loadWorkflowFromNormalizedTables }, - { flattenWorkflowOutputs }, - { columnTypeForLeaf }, - ] = await Promise.all([ - import('@/lib/workflows/persistence/utils'), - import('@/lib/workflows/blocks/flatten-outputs'), - import('@/lib/table/column-naming'), - ]) - const normalized = await loadWorkflowFromNormalizedTables(targetWorkflowId) - if (normalized) { - const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ - id: b.id, - type: b.type, - name: b.name, - triggerMode: (b as { triggerMode?: boolean }).triggerMode, - subBlocks: b.subBlocks as Record | undefined, - })) - const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? []) - const flatByKey = new Map(flattened.map((f) => [`${f.blockId}::${f.path}`, f])) - for (const u of mappingUpdates) { - const match = flatByKey.get(`${u.blockId}::${u.path}`) - if (!match) continue - const newType = columnTypeForLeaf(match.leafType) - if (newType) remapLeafTypeByColumn.set(u.columnName, newType) - } - } - } - } catch (err) { - logger.warn( - `[${requestId}] Could not resolve new leaf types for remap on group ${data.groupId}; leaving column types unchanged:`, - err - ) + resolvedForWorkflowId = data.resolvedMappingTypes.workflowId + for (const resolved of data.resolvedMappingTypes.columns) { + remapLeafTypeByColumn.set(resolved.columnName, resolved.type) } } @@ -387,24 +350,22 @@ export async function updateWorkflowGroup( }) // Only apply the out-of-lock leaf-type resolution if the group still - // points at the workflow we resolved against. If a concurrent writer - // changed `workflowId` between phase 1 and now, those types are stale — - // leave column types unchanged (best-effort, same as a resolution - // failure) rather than stamping types from the old workflow. + // points at the workflow we resolved against. A concurrent workflow + // remap invalidates the command snapshot and must be retried. const finalWorkflowId = data.workflowId ?? group.workflowId if (remapLeafTypeById.size > 0 && resolvedForWorkflowId !== finalWorkflowId) { - logger.warn( - `[${requestId}] Workflow group "${data.groupId}" workflowId changed between leaf-type resolution and apply; leaving remapped column types unchanged.` + throw new OrchestrationError( + 'conflict', + `Workflow group "${data.groupId}" changed concurrently; retry the update.` ) - } else { - const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) - for (const u of mappingUpdatesNorm) { - const newType = remapLeafTypeById.get(u.columnName) - if (!newType) continue - const oldType = colById.get(u.columnName)?.type - if (newType !== oldType) { - remappedColumnTypes.set(u.columnName, newType) - } + } + const colById = new Map(schema.columns.map((c) => [getColumnId(c), c])) + for (const u of mappingUpdatesNorm) { + const newType = remapLeafTypeById.get(u.columnName) + if (!newType) continue + const oldType = colById.get(u.columnName)?.type + if (newType !== oldType) { + remappedColumnTypes.set(u.columnName, newType) } } } @@ -657,15 +618,22 @@ export async function addWorkflowGroupOutput( columnName?: string /** The member adding the output — billed/gated for any backfill-triggered re-run. */ actorUserId?: string | null + resolvedOutput: { + workflowId: string + columnType: ColumnDefinition['type'] + order: Array<{ + blockId: string + path: string + executionDistance: number + discoveryIndex: number + }> + } }, requestId: string ): Promise { - // Phase 1 (no lock): load the workflow and resolve the pickable output plus - // its execution-order index. This depends only on the workflow graph (which - // is stable), so it runs OFF the advisory-lock critical section — holding the - // lock during this DB load would make concurrent adders on the same table - // time out waiting (the Mothership fan-out this fix targets). Phase 2 - // re-validates that the group still maps to the same workflow under the lock. + // Phase 1 (no lock): validate the authorized workflow metadata against the + // group's current workflow. Phase 2 re-validates the same binding under the + // table lock before applying the mutation. const preTable = await getTableById(data.tableId) if (!preTable || (data.workspaceId && preTable.workspaceId !== data.workspaceId)) { throw new OrchestrationError('not_found', 'Table not found') @@ -675,38 +643,16 @@ export async function addWorkflowGroupOutput( throw new OrchestrationError('not_found', `Workflow group "${data.groupId}" not found`) } const workflowId = preGroup.workflowId - - const [ - { loadWorkflowFromNormalizedTables }, - { flattenWorkflowOutputs, getBlockExecutionOrder }, - { columnTypeForLeaf, deriveOutputColumnName }, - ] = await Promise.all([ - import('@/lib/workflows/persistence/utils'), - import('@/lib/workflows/blocks/flatten-outputs'), - import('@/lib/table/column-naming'), - ]) - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) { - throw new OrchestrationError('not_found', `Workflow ${workflowId} not found`) + if (data.resolvedOutput.workflowId !== workflowId) { + throw new OrchestrationError('not_found', 'Workflow not found') } - const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ - id: b.id, - type: b.type, - name: b.name, - triggerMode: (b as { triggerMode?: boolean }).triggerMode, - subBlocks: b.subBlocks as Record | undefined, - })) - const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? []) - const match = flattened.find((f) => f.blockId === data.blockId && f.path === data.path) - if (!match) { - throw new OrchestrationError( - 'validation', - `Output ${data.blockId}::${data.path} is not a valid pickable output on workflow ${workflowId}` - ) - } - const newColumnType = columnTypeForLeaf(match.leafType) - const distances = getBlockExecutionOrder(blocks, normalized.edges ?? []) - const flatIndex = new Map(flattened.map((f, i) => [`${f.blockId}::${f.path}`, i])) + const newColumnType = data.resolvedOutput.columnType + const resolvedOrder = new Map( + data.resolvedOutput.order.map((output) => [ + `${output.blockId}::${output.path}`, + [output.executionDistance, output.discoveryIndex] as const, + ]) + ) // Phase 2 (locked): re-read fresh, validate against the current schema, and // write. The critical section holds no I/O — just the in-memory splice + the @@ -776,10 +722,10 @@ export async function addWorkflowGroupOutput( // — regardless of whether they were added at create time or one-by-one. const groupColIdsBefore = new Set(group.outputs.map((o) => o.columnName)) const orderKey = (o: { blockId: string; path: string }) => { - const d = distances[o.blockId] - const dist = d === undefined || d < 0 ? Number.POSITIVE_INFINITY : d - const idx = flatIndex.get(`${o.blockId}::${o.path}`) ?? Number.POSITIVE_INFINITY - return [dist, idx] as const + return ( + resolvedOrder.get(`${o.blockId}::${o.path}`) ?? + ([Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY] as const) + ) } const allGroupOutputs = [...group.outputs, newOutput].sort((a, b) => { const [da, ia] = orderKey(a) diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index aa2c88c5d31..842306fa89a 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import type { Principal } from '@sim/auth/principal' +import type { Principal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { sha256Hex } from '@sim/security/hash' import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { eq, inArray, isNull } from 'drizzle-orm' @@ -74,6 +74,21 @@ import { const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' const FINAL_KEY = `workspace/${WORKSPACE_ID}/final-file.bin` +const executorPrincipal: WorkflowExecutionDelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: WORKSPACE_ID, + delegationId: 'delegation-1', + audience: 'sim:tables', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, +} describe('upload sessions', () => { beforeEach(() => { @@ -353,6 +368,89 @@ describe('upload sessions', () => { ).rejects.toMatchObject({ code: 'not_found' }) }) + it('binds table-import uploads to the canonical executor workflow execution', async () => { + const row = uploadRow({ + purpose: 'table_import', + storageContext: 'table-import', + finalKey: `table-import/${WORKSPACE_ID}/upload-1/people.csv`, + fileName: 'people.csv', + contentType: 'text/csv', + }) + dbChainMockFns.returning.mockResolvedValueOnce([row]) + + await createUploadSession({ + id: row.id, + workspaceId: WORKSPACE_ID, + userId: 'user-1', + principal: executorPrincipal, + purpose: 'table_import', + fileName: 'people.csv', + contentType: 'text/csv', + fileSize: 4, + localOrigin: 'http://localhost:3000', + }) + + expect(dbChainMockFns.values.mock.calls[0][0].metadata.authBinding).toEqual({ + version: 1, + workspaceId: WORKSPACE_ID, + principal: { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + audience: 'sim:tables', + workflowId: 'workflow-1', + executionId: 'execution-1', + }, + }) + }) + + it('accepts refreshed executor tokens only for the same immutable upload binding', () => { + const session = sessionRecord({ + purpose: 'table_import', + storageContext: 'table-import', + metadata: { + authBinding: createUploadSessionAuthBinding(executorPrincipal, WORKSPACE_ID, { + executorDelegationAudience: 'sim:tables', + }), + }, + }) + + expect(() => + assertUploadSessionAuthBinding(session, { + ...executorPrincipal, + delegationId: 'refreshed-token-jti', + }) + ).not.toThrow() + expect(() => + assertUploadSessionAuthBinding(session, { + ...executorPrincipal, + delegationId: 'other-execution-token', + delegationContext: { + kind: 'workflow_execution', + workflowId: 'workflow-1', + executionId: 'execution-2', + }, + }) + ).toThrow('Upload session not found') + expect(() => + assertUploadSessionAuthBinding(session, { + ...executorPrincipal, + workspaceId: 'different-workspace', + }) + ).toThrow('Upload session not found') + }) + + it('does not admit executor delegation outside the explicit Table upload policy', () => { + expect(() => createUploadSessionAuthBinding(executorPrincipal, WORKSPACE_ID)).toThrow( + 'Delegated principal cannot create this upload' + ) + expect(() => + createUploadSessionAuthBinding(executorPrincipal, WORKSPACE_ID, { + executorDelegationAudience: 'sim:workspace-files', + }) + ).toThrow('Delegated principal cannot create this upload') + }) + it('fails closed for legacy table-import sessions without a binding', () => { const legacyImport = sessionRecord({ purpose: 'table_import', diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index a24b99e6a4f..237c671e3cf 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -1,4 +1,4 @@ -import type { Principal } from '@sim/auth/principal' +import type { Principal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { db, dbFor } from '@sim/db' import { uploadSession } from '@sim/db/schema' import { safeCompare } from '@sim/security/compare' @@ -106,6 +106,14 @@ export interface UploadSessionAuthBinding { | { kind: 'session'; userId: string; sessionId: string } | { kind: 'personal_api_key'; userId: string; keyId: string } | { kind: 'workspace_api_key'; workspaceId: string; keyId: string } + | { + kind: 'delegated' + serviceId: 'executor' + subjectUserId: string + audience: string + workflowId: string + executionId?: string + } } export interface CreatedUploadSession extends UploadSessionRecord { @@ -122,6 +130,30 @@ export class UploadSessionError extends OrchestrationError { } } +function isExecutorWorkflowExecutionPrincipal( + principal: Principal +): principal is WorkflowExecutionDelegatedPrincipal { + if ( + principal.kind !== 'delegated' || + principal.serviceId !== 'executor' || + !('delegationContext' in principal) + ) { + return false + } + const context = principal.delegationContext + return ( + typeof context === 'object' && + context !== null && + 'kind' in context && + context.kind === 'workflow_execution' && + 'workflowId' in context && + typeof context.workflowId === 'string' && + (!('executionId' in context) || + context.executionId === undefined || + typeof context.executionId === 'string') + ) +} + interface CreateUploadSessionBaseParams { id?: string userId: string @@ -170,7 +202,9 @@ export async function createUploadSession( metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) } else if (params.purpose === 'table_import' && params.principal) { if (!workspaceId) throw new Error('table_import upload is missing workspaceId') - metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) + metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId, { + executorDelegationAudience: 'sim:tables', + }) } const { storageContext, finalKey } = resolveUploadStorage(params, id) const method: UploadTransferMethod = @@ -369,7 +403,8 @@ export async function getPrincipalKnowledgeDocumentUploadSession(params: { export function createUploadSessionAuthBinding( principal: Principal, - workspaceId: string + workspaceId: string, + options: { executorDelegationAudience?: string } = {} ): UploadSessionAuthBinding { switch (principal.kind) { case 'session': @@ -397,8 +432,30 @@ export function createUploadSessionAuthBinding( workspaceId, principal: { kind: principal.kind, workspaceId, keyId: principal.keyId }, } - case 'delegated': - throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') + case 'delegated': { + if ( + options.executorDelegationAudience === undefined || + !isExecutorWorkflowExecutionPrincipal(principal) || + principal.audience !== options.executorDelegationAudience || + principal.workspaceId !== workspaceId + ) { + throw new UploadSessionError('forbidden', 'Delegated principal cannot create this upload') + } + return { + version: 1, + workspaceId, + principal: { + kind: principal.kind, + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + audience: principal.audience, + workflowId: principal.delegationContext.workflowId, + ...(principal.delegationContext.executionId + ? { executionId: principal.delegationContext.executionId } + : {}), + }, + } + } } } @@ -427,9 +484,16 @@ export function assertUploadSessionAuthBinding( ? principal.kind === 'personal_api_key' && bound.userId === principal.userId && bound.keyId === principal.keyId - : principal.kind === 'workspace_api_key' && - bound.workspaceId === principal.workspaceId && - bound.keyId === principal.keyId) + : bound.kind === 'workspace_api_key' + ? principal.kind === 'workspace_api_key' && + bound.workspaceId === principal.workspaceId && + bound.keyId === principal.keyId + : isExecutorWorkflowExecutionPrincipal(principal) && + principal.workspaceId === session.workspaceId && + principal.subjectUserId === bound.subjectUserId && + principal.audience === bound.audience && + principal.delegationContext.workflowId === bound.workflowId && + principal.delegationContext.executionId === bound.executionId) if (!matches) throw uploadNotFound() } @@ -1190,6 +1254,15 @@ function isUploadSessionAuthBinding(value: unknown): value is UploadSessionAuthB if (principal.kind === 'personal_api_key') { return typeof principal.userId === 'string' && typeof principal.keyId === 'string' } + if (principal.kind === 'delegated') { + return ( + principal.serviceId === 'executor' && + typeof principal.subjectUserId === 'string' && + typeof principal.audience === 'string' && + typeof principal.workflowId === 'string' && + (principal.executionId === undefined || typeof principal.executionId === 'string') + ) + } return ( principal.kind === 'workspace_api_key' && typeof principal.workspaceId === 'string' && diff --git a/apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts b/apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts new file mode 100644 index 00000000000..6ccf06f983a --- /dev/null +++ b/apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + flatten: vi.fn(), + load: vi.fn(), + order: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) + +vi.mock('@/lib/workflows/blocks/flatten-outputs', () => ({ + flattenWorkflowOutputs: mocks.flatten, + getBlockExecutionOrder: mocks.order, +})) + +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: mocks.load, +})) + +import { resolveWorkflowOutputs } from '@/lib/workflows/application/resolve-workflow-outputs' + +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-08-01T00:00:00.000Z'), + expiresAt: new Date('2099-08-01T00:00:00.000Z'), +} + +describe('resolveWorkflowOutputs', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveContext.mockResolvedValue({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + workflow: { id: 'workflow-1' }, + }) + mocks.load.mockResolvedValue({ + blocks: { block1: { id: 'block-1', type: 'agent', name: 'Agent', subBlocks: {} } }, + edges: [], + }) + mocks.flatten.mockReturnValue([ + { + blockId: 'block-1', + blockName: 'Agent', + blockType: 'agent', + path: 'content', + leafType: 'string', + }, + ]) + mocks.order.mockReturnValue({ 'block-1': 1 }) + }) + + it('binds the canonical workflow load to the trusted Copilot workspace', async () => { + await expect( + resolveWorkflowOutputs.execute({ + principal, + input: { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toMatchObject({ + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content' }], + executionOrderByBlockId: { 'block-1': 1 }, + }) + + expect(mocks.resolveContext).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.load).toHaveBeenCalledWith('workflow-1') + }) + + it('conceals cross-workspace workflow ids before loading workflow state', async () => { + mocks.resolveContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Workflow not found') + ) + + await expect( + resolveWorkflowOutputs.execute({ + principal, + input: { workflowId: 'workflow-other', assertedWorkspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Workflow not found' }) + expect(mocks.load).not.toHaveBeenCalled() + }) + + it('rejects expired delegated scope before loading workflow state', async () => { + await expect( + resolveWorkflowOutputs.execute({ + principal: { ...principal, expiresAt: new Date(0) }, + input: { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.load).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/resolve-workflow-outputs.ts b/apps/sim/lib/workflows/application/resolve-workflow-outputs.ts new file mode 100644 index 00000000000..209d26107f6 --- /dev/null +++ b/apps/sim/lib/workflows/application/resolve-workflow-outputs.ts @@ -0,0 +1,57 @@ +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { + type ActiveWorkflowApplicationContext, + resolveActiveWorkflowApplicationContext, +} from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { + type FlattenedBlockOutput, + flattenWorkflowOutputs, + getBlockExecutionOrder, +} from '@/lib/workflows/blocks/flatten-outputs' +import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' + +export interface ResolveWorkflowOutputsInput { + workflowId: string + assertedWorkspaceId: string +} + +export interface ResolveWorkflowOutputsResult { + workflowId: string + outputs: FlattenedBlockOutput[] | null + executionOrderByBlockId: Record +} + +/** Loads output metadata after a top-level application command has authorized this workflow context. */ +export async function loadResolvedWorkflowOutputs( + context: ActiveWorkflowApplicationContext +): Promise { + const normalized = await loadWorkflowFromNormalizedTables(context.workflowId) + if (!normalized) { + return { workflowId: context.workflowId, outputs: null, executionOrderByBlockId: {} } + } + const blocks = Object.values(normalized.blocks ?? {}).map((block) => ({ + id: block.id, + type: block.type, + name: block.name, + triggerMode: (block as { triggerMode?: boolean }).triggerMode, + subBlocks: block.subBlocks as Record | undefined, + })) + return { + workflowId: context.workflowId, + outputs: flattenWorkflowOutputs(blocks, normalized.edges ?? []), + executionOrderByBlockId: getBlockExecutionOrder(blocks, normalized.edges ?? []), + } +} + +export const resolveWorkflowOutputs = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: ({ input }: { input: ResolveWorkflowOutputsInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: input.assertedWorkspaceId, + }), + async execute({ context }): Promise { + return loadResolvedWorkflowOutputs(context) + }, +})