From 0db443cc8fe1b007d6624096c5682f98228b535d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 14:36:40 -0700 Subject: [PATCH 1/8] refactor: enforce copilot table application boundary --- .../v2/tables/[tableId]/exports/route.test.ts | 3 + .../api/v2/tables/[tableId]/exports/route.ts | 3 +- .../api/v2/tables/exports/[exportId]/route.ts | 5 +- .../imports/[importId]/complete/route.test.ts | 3 + .../imports/[importId]/complete/route.ts | 3 +- .../api/v2/tables/imports/[importId]/route.ts | 5 +- .../app/api/v2/tables/imports/route.test.ts | 3 + apps/sim/app/api/v2/tables/imports/route.ts | 3 +- apps/sim/app/api/v2/tables/presenters.test.ts | 82 ++ apps/sim/app/api/v2/tables/presenters.ts | 19 + .../application/execute-table-use-case.ts | 23 - .../application/execute-workflow-use-case.ts | 8 + .../tools/server/table/user-table.test.ts | 208 ++- .../copilot/tools/server/table/user-table.ts | 1241 ++++------------- .../sim/lib/table/application/columns.test.ts | 119 ++ apps/sim/lib/table/application/columns.ts | 38 + .../application/copilot-bulk-rows.test.ts | 242 ++++ .../table/application/copilot-bulk-rows.ts | 424 ++++++ .../sim/lib/table/application/exports.test.ts | 128 ++ apps/sim/lib/table/application/exports.ts | 10 +- apps/sim/lib/table/application/groups.test.ts | 253 ++++ apps/sim/lib/table/application/groups.ts | 216 ++- .../sim/lib/table/application/imports.test.ts | 256 ++++ apps/sim/lib/table/application/imports.ts | 35 +- .../lib/table/application/operations.test.ts | 14 +- apps/sim/lib/table/application/operations.ts | 27 +- .../workspace-file-imports.test.ts | 213 +++ .../application/workspace-file-imports.ts | 476 +++++++ .../table/orchestration/import-resource.ts | 4 +- apps/sim/lib/table/types.ts | 5 + apps/sim/lib/table/workflow-groups/service.ts | 153 +- .../resolve-workflow-outputs.test.ts | 122 ++ .../application/resolve-workflow-outputs.ts | 47 + .../resolve-workspace-file-reference.test.ts | 34 + .../resolve-workspace-file-reference.ts | 35 + 35 files changed, 3338 insertions(+), 1122 deletions(-) create mode 100644 apps/sim/app/api/v2/tables/presenters.test.ts create mode 100644 apps/sim/app/api/v2/tables/presenters.ts create mode 100644 apps/sim/lib/copilot/application/execute-workflow-use-case.ts create mode 100644 apps/sim/lib/table/application/columns.test.ts create mode 100644 apps/sim/lib/table/application/copilot-bulk-rows.test.ts create mode 100644 apps/sim/lib/table/application/copilot-bulk-rows.ts create mode 100644 apps/sim/lib/table/application/exports.test.ts create mode 100644 apps/sim/lib/table/application/groups.test.ts create mode 100644 apps/sim/lib/table/application/imports.test.ts create mode 100644 apps/sim/lib/table/application/workspace-file-imports.test.ts create mode 100644 apps/sim/lib/table/application/workspace-file-imports.ts create mode 100644 apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts create mode 100644 apps/sim/lib/workflows/application/resolve-workflow-outputs.ts 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/copilot/application/execute-table-use-case.ts b/apps/sim/lib/copilot/application/execute-table-use-case.ts index b973f5fd1bf..dce0203c4dc 100644 --- a/apps/sim/lib/copilot/application/execute-table-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-table-use-case.ts @@ -42,26 +42,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.ts b/apps/sim/lib/copilot/application/execute-workflow-use-case.ts new file mode 100644 index 00000000000..612069baad4 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-workflow-use-case.ts @@ -0,0 +1,8 @@ +import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' +import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization' +import { workflowOperations } from '@/lib/workflows/application/operations' + +export const executeCopilotWorkflowUseCase = createCopilotWorkspaceUseCaseExecutor({ + audience: WORKFLOW_DELEGATION_AUDIENCE, + operations: workflowOperations, +}) 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..8e9bb1e1240 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 { @@ -27,7 +27,9 @@ const { mockRunTableImport, mockRunTableDelete, mockRunTableUpdate, + mockExecuteCopilotFileUseCase, mockExecuteCopilotTableUseCase, + mockExecuteCopilotWorkflowUseCase, fakeEnrichment, } = vi.hoisted(() => ({ mockUpdateColumnType: vi.fn(), @@ -50,7 +52,9 @@ const { mockRunTableImport: vi.fn(), mockRunTableDelete: vi.fn(), mockRunTableUpdate: vi.fn(), + mockExecuteCopilotFileUseCase: vi.fn(), mockExecuteCopilotTableUseCase: vi.fn(), + mockExecuteCopilotWorkflowUseCase: vi.fn(), fakeEnrichment: { id: 'work-email', name: 'Work Email', @@ -75,7 +79,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ fetchWorkspaceFileBuffer: mockDownloadWorkspaceFile, })) vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ - resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, + readSafeWorkspaceFileReference: { operation: { id: 'files.content.read' } }, })) vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ readWorkspaceFileContent: { @@ -96,7 +100,12 @@ vi.mock('@/lib/copilot/auth/file-delegation', () => ({ })) vi.mock('@/lib/copilot/auth/table-delegation', () => ({ - messageForCopilotTableError: (error: unknown) => getErrorMessage(error, 'Table operation failed'), + 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' + }, resolveCopilotTablePrincipal: (_context: unknown, tableId?: string) => ({ kind: 'delegated', serviceId: 'copilot', @@ -111,10 +120,53 @@ vi.mock('@/lib/copilot/auth/table-delegation', () => ({ })) vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ - admitCopilotTableOperation: vi.fn(), executeCopilotTableUseCase: mockExecuteCopilotTableUseCase, })) +vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ + executeCopilotFileUseCase: mockExecuteCopilotFileUseCase, +})) + +vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ + executeCopilotWorkflowUseCase: 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/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, })) @@ -190,10 +242,54 @@ import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table import { decodeCursor, encodeCursor } from '@/lib/table/rows/cursor' beforeEach(() => { + 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 }) }), + } + } + ) mockExecuteCopilotTableUseCase.mockImplementation( async ( _context: unknown, - useCase: { operation: { id: string } }, + useCase: { + operation: { id: string } + execute?: (args: { + principal: Record + input: Record + }) => Promise + }, input: Record ) => { const table = await mockGetTableById(input.tableId) @@ -206,7 +302,7 @@ beforeEach(() => { 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') + throw new OrchestrationError('validation', 'Cursor is not valid for a sorted query') } const cursor = input.cursor ? decodeCursor(String(input.cursor)) : undefined const result = await mockQueryRows(table, { @@ -220,6 +316,29 @@ beforeEach(() => { }) return { table, ...result } } + case 'tables.read': { + if (!table || table.workspaceId !== input.workspaceId) { + 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 { table } + } + case 'tables.groups.create': { + if (!table) throw Object.assign(new Error('Table not found'), { code: 'not_found' }) + const updated = await mockAddWorkflowGroup( + { + tableId: input.tableId, + workspaceId: input.workspaceId, + group: input.group, + outputColumns: input.outputColumns, + autoRun: input.autoRun, + }, + 'request-1' + ) + return { table: updated, group: input.group } + } case 'tables.columns.update': { if (!table) throw new Error('Table not found') const updates = input.updates as Record @@ -245,8 +364,25 @@ beforeEach(() => { }) return { table: next, changed: true } } - default: - throw new Error(`Unexpected application operation ${useCase.operation.id}`) + default: { + if (!useCase.execute) { + throw new Error(`Unexpected application operation ${useCase.operation.id}`) + } + return useCase.execute({ + principal: { + 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), + ...(input.tableId ? { resourceScope: { tableId: input.tableId } } : {}), + }, + input, + }) + } } } ) @@ -559,6 +695,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', @@ -629,7 +766,7 @@ 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( @@ -639,8 +776,8 @@ describe('userTableServerTool.create_from_file', () => { 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 () => { @@ -719,6 +856,55 @@ describe('userTableServerTool.create', () => { }) }) +describe('userTableServerTool workflow scope', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue(buildTable()) + mockAddWorkflowGroup.mockResolvedValue(buildTable()) + }) + + it('conceals a cross-workspace workflow id before persisting a group', async () => { + mockExecuteCopilotWorkflowUseCase.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' }], + }, + }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result).toEqual({ success: false, message: 'Operation failed: Workflow not found' }) + expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.read' }) }), + { + workflowId: 'workflow-cross-workspace', + assertedWorkspaceId: 'workspace-1', + } + ) + expect(mockAddWorkflowGroup).not.toHaveBeenCalled() + }) + + it('conceals unknown application failures from tool output', async () => { + mockExecuteCopilotTableUseCase.mockRejectedValueOnce(new Error('database host unavailable')) + + const result = await userTableServerTool.execute( + { operation: 'query_rows', args: { tableId: 'tbl_1' } }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + 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() 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..39f60dab4eb 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -1,12 +1,9 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { - admitCopilotTableOperation, - executeCopilotTableUseCase, -} from '@/lib/copilot/application/execute-table-use-case' -import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +import { executeCopilotWorkflowUseCase } from '@/lib/copilot/application/execute-workflow-use-case' import { messageForCopilotTableError, resolveCopilotTablePrincipal, @@ -17,34 +14,35 @@ import { 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 { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' 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 { addTableColumnUseCase, + deleteTableColumnsUseCase, deleteTableColumnUseCase, updateTableColumnUseCase, } from '@/lib/table/application/columns' import { + copilotBatchUpdateRows, + copilotDeleteRowsByFilter, + copilotUpdateRowsByFilter, +} from '@/lib/table/application/copilot-bulk-rows' +import { + addTableGroupOutputUseCase, createTableGroupUseCase, + deleteTableGroupOutputUseCase, deleteTableGroupUseCase, updateTableGroupUseCase, } from '@/lib/table/application/groups' -import { type TableOperation, tableOperations } from '@/lib/table/application/operations' import { createTableRows, deleteTableRow, @@ -60,68 +58,35 @@ import { readTableUseCase, updateTableUseCase, } from '@/lib/table/application/tables' +import { + createTableFromWorkspaceFile, + importWorkspaceFileIntoTable, + type TableWorkspaceFileSource, +} from '@/lib/table/application/workspace-file-imports' 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 { 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 { resolveWorkflowOutputs } from '@/lib/workflows/application/resolve-workflow-outputs' +import type { FlattenedBlockOutput } from '@/lib/workflows/blocks/flatten-outputs' +import { readSafeWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' const logger = createLogger('UserTableServerTool') @@ -139,110 +104,35 @@ 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, workspaceId: string, - principal: ReturnType -) { - let record + context: ServerToolContext, + maxBytes?: number +): Promise<{ file: TableWorkspaceFileSource; content?: Buffer }> { try { - record = await resolveWorkspaceFileReference({ - principal, - operation: fileOperations.readContent, + const result = await executeCopilotFileUseCase(context, readSafeWorkspaceFileReference, { workspaceId, reference: fileReference, + maxBytes, }) - } catch { + return { file: result.file as TableWorkspaceFileSource, content: result.content } + } catch (error) { + if (asOrchestrationError(error)?.code !== 'not_found') throw error // 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( + throw new OrchestrationError( + 'validation', `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( + throw new OrchestrationError( + 'not_found', `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', - }) - 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 } /** @@ -255,209 +145,15 @@ function shouldImportInBackground(record: { name: string; size: number }): boole 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, +function resolveAuthorizedWorkflowOutputs( + workflowId: 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 ?? []) + context: ServerToolContext +) { + return executeCopilotWorkflowUseCase(context, resolveWorkflowOutputs, { + workflowId, + assertedWorkspaceId: workspaceId, + }) } /** @@ -525,37 +221,6 @@ 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 }>, context: ServerToolContext @@ -597,14 +262,6 @@ export const userTableServerTool: BaseServerTool 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) { @@ -986,95 +643,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 +686,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 +754,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 +832,84 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const filePrincipal = resolveCopilotFilePrincipal(context) - const record = await resolveWorkspaceFileRecordOrThrow( + const { file: record } = await resolveWorkspaceFileRecordOrThrow( fileReference, workspaceId, - filePrincipal + context ) - - // 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). + const tableName = + args.name || + sanitizeName(record.name.replace(/\.[^.]+$/, ''), 'imported_table').slice( + 0, + TABLE_LIMITS.MAX_TABLE_NAME_LENGTH + ) + const description = args.description || `Imported from ${record.name}` 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 + const result = await executeCopilotTableUseCase(context, createTableFromWorkspaceFile, { + kind: 'background', + workspaceId, + sourceFile: record, + name: tableName, + description, + }) + if (result.kind !== 'background') { + throw new Error('Background table import returned an inline result') } 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) + const { content } = await resolveWorkspaceFileRecordOrThrow( + fileReference, + workspaceId, + context, + MAX_INLINE_FILE_BYTES + ) + if (!content) throw new Error('Workspace file content was not loaded') + const { headers, rows } = await parseFileRows(content, record.name, record.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 result = await executeCopilotTableUseCase(context, createTableFromWorkspaceFile, { + kind: 'inline', + workspaceId, + sourceFile: record, + name: tableName, + description, + columns, + headerToColumn, + rows, }) - - const createdMessage = `Created table "${table.name}" with ${columns.length} columns and ${inserted.toLocaleString()} rows from "${file.name}"` + if (result.kind !== 'inline') { + throw new Error('Inline table import returned a background result') + } + const createdMessage = `Created table "${result.table.name}" with ${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, + tableId: result.table.id, + tableName: result.table.name, columns: columns.map((c) => ({ name: c.name, type: c.type })), - rowCount: inserted, - sourceFile: file.name, + rowCount: result.insertedCount, + sourceFile: record.name, }, } } @@ -1551,182 +944,100 @@ 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( + await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId, workspaceId }, + { tableId } + ) + const { file: record } = await resolveWorkspaceFileRecordOrThrow( fileReference, workspaceId, - filePrincipal + context ) - - // 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' + const result = await executeCopilotTableUseCase( + context, + importWorkspaceFileIntoTable, + { + kind: 'background', + tableId, + assertedWorkspaceId: workspaceId, + sourceFile: record, + mode, + mapping: rawMapping, + }, + { tableId } ) - if (!claimed) { - return { success: false, message: 'A job is already in progress for this table' } + if (result.kind !== 'background') { + throw new Error('Background table import returned an inline result') } - 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, - }) 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 "${record.name}" 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, + const result = await executeCopilotTableUseCase( + context, + importWorkspaceFileIntoTable, + { + kind: 'inline', + tableId, + assertedWorkspaceId: workspaceId, + sourceFile: record, mode, - matchedColumns: validation.mappedHeaders.length, - rows: inserted, - userId: context.userId, - }) - + mapping: rawMapping, + loadRows: async () => { + const { content } = await resolveWorkspaceFileRecordOrThrow( + fileReference, + workspaceId, + context, + MAX_INLINE_FILE_BYTES + ) + if (!content) throw new Error('Workspace file content was not loaded') + return parseFileRows(content, record.name, record.type) + }, + }, + { tableId } + ) + 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 "${record.name}": 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: record.name, }, } - }) + } + return { + success: true, + message: `Imported ${result.insertedCount} rows into "${result.table.name}" from "${record.name}" (${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: record.name, + }, + } } case 'add_column': { @@ -1836,19 +1147,13 @@ export const userTableServerTool: BaseServerTool data: { schema: updated.schema }, } } - await executeCopilotTableUseCase( + assertNotAborted() + const { table: updated } = 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(', ')}`, @@ -1962,7 +1267,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, @@ -2013,7 +1323,12 @@ export const userTableServerTool: BaseServerTool } } - const flattened = await loadFlattenedWorkflowOutputs(workflowId) + const resolvedWorkflow = await resolveAuthorizedWorkflowOutputs( + workflowId, + workspaceId, + context + ) + const flattened = resolvedWorkflow.outputs if (!flattened) { return { success: false, @@ -2071,6 +1386,7 @@ export const userTableServerTool: BaseServerTool group, outputColumns, autoRun, + resolvedWorkflow, }, { tableId: args.tableId } ) @@ -2098,34 +1414,47 @@ export const userTableServerTool: BaseServerTool { 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 + const mappingUpdates = args.mappingUpdates as + | Array<{ columnName: string; blockId: string; path: string }> + | undefined + const explicitWorkflowId = args.workflowId as string | undefined + const existingGroup = tableForUpdate.schema.workflowGroups?.find((g) => g.id === groupId) + const targetWorkflowId = explicitWorkflowId ?? existingGroup?.workflowId + const workflowMetadataRequired = + explicitWorkflowId !== undefined || + updateOutputs !== undefined || + (mappingUpdates?.length ?? 0) > 0 + let resolvedWorkflow: + | Awaited> + | undefined + if (workflowMetadataRequired) { 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) { + resolvedWorkflow = await resolveAuthorizedWorkflowOutputs( + targetWorkflowId, + workspaceId, + context + ) + const flattened = resolvedWorkflow.outputs + if ((updateOutputs?.length ?? 0) > 0 && !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 } + if (updateOutputs && updateOutputs.length > 0 && flattened) { + const validationError = validateOutputsAgainstWorkflow( + updateOutputs.map((o) => ({ blockId: o.blockId, path: o.path })), + flattened, + targetWorkflowId + ) + if (validationError) { + return { success: false, message: validationError } + } } } assertNotAborted() @@ -2136,14 +1465,13 @@ export const userTableServerTool: BaseServerTool tableId: args.tableId, workspaceId, groupId, - workflowId: args.workflowId as string | undefined, + workflowId: explicitWorkflowId, 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, + mappingUpdates, + resolvedWorkflow, deploymentMode: parseDeploymentMode(args.deploymentMode), autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, }, @@ -2190,25 +1518,38 @@ 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 { table: tableForAdd } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) + const workflowId = tableForAdd.schema.workflowGroups?.find( + (candidate) => candidate.id === groupId + )?.workflowId + if (!workflowId) { + return { success: false, message: `Workflow group not found: ${groupId}` } } - const requestId = generateId().slice(0, 8) + const resolvedWorkflow = await resolveAuthorizedWorkflowOutputs( + workflowId, + workspaceId, + context + ) assertNotAborted() - const updated = await addWorkflowGroupOutput( + const { table: updated } = await executeCopilotTableUseCase( + context, + addTableGroupOutputUseCase, { tableId: args.tableId, + workspaceId, groupId, blockId, path, columnName, - actorUserId: context.userId, - workspaceId, + resolvedWorkflow, }, - requestId + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Added output to workflow group ${groupId}`, @@ -2227,17 +1568,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}`, @@ -2379,10 +1716,12 @@ export const userTableServerTool: BaseServerTool 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}` } - } + const { table: tableForEnrichment } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) // Validate the input mapping: every required input must be mapped, and // each mapped column must already exist on the table. @@ -2457,13 +1796,13 @@ export const userTableServerTool: BaseServerTool 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 } = await executeCopilotTableUseCase( + context, + createTableGroupUseCase, + { tableId: args.tableId, workspaceId, group, outputColumns, autoRun }, + { tableId: args.tableId } ) - signalTableSchemaChanged(args.tableId) return { success: true, message: `Added enrichment "${name}" with ${outputs.length} output column(s)${ 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..2e5177f58a6 --- /dev/null +++ b/apps/sim/lib/table/application/columns.test.ts @@ -0,0 +1,119 @@ +/** + * @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', () => ({ + addTableColumn: vi.fn(), + deleteColumn: vi.fn(), + deleteColumns: mocks.deleteColumns, +})) +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: [{ name: 'name', 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' }, +} + +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(table) + }) + + it('owns canonical mutation, audit, and schema effects', async () => { + 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(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith('table-1') + }) + + 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..5c5667683fe 100644 --- a/apps/sim/lib/table/application/columns.ts +++ b/apps/sim/lib/table/application/columns.ts @@ -6,6 +6,7 @@ import { type ColumnDefinition, type ColumnType, deleteColumn, + deleteColumns, type SelectOption, type TableDefinition, } from '@/lib/table' @@ -156,5 +157,42 @@ export const deleteTableColumnUseCase = defineAuthorizedTableUseCase({ }, }) +export interface DeleteTableColumnsInput extends TableColumnInput { + columnNames: 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 }> { + if (input.columnNames.length < 1) { + throw new Error('At least one column name is required') + } + const table = await deleteColumns( + { tableId: context.table.id, columnNames: input.columnNames }, + generateRequestId(), + { expectedWorkspaceId: context.workspaceId } + ) + return { table } + }, + projectAudit({ input, context, result }) { + return { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: result.table.id, + resourceName: result.table.name, + description: `Deleted ${input.columnNames.length} columns from table "${context.table.name}"`, + metadata: { columnNames: input.columnNames }, + } + }, + afterSuccess({ context }) { + 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/exports.test.ts b/apps/sim/lib/table/application/exports.test.ts new file mode 100644 index 00000000000..9df4fce7d20 --- /dev/null +++ b/apps/sim/lib/table/application/exports.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ + +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', +} + +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 } }) + }) +}) diff --git a/apps/sim/lib/table/application/exports.ts b/apps/sim/lib/table/application/exports.ts index 9ff24c64d42..8002989ef7d 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 { @@ -85,7 +83,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 +100,7 @@ export const readTableExportUseCase = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: TableExportResourceInput }) => resolveTableExportContext(input), async execute({ context }): Promise { - return { export: toV2TableExport(context.record) } + return { export: context.record } }, }) @@ -118,7 +116,7 @@ export const cancelTableExportUseCase = defineAuthorizedTableUseCase({ 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..2665b3838e8 --- /dev/null +++ b/apps/sim/lib/table/application/groups.test.ts @@ -0,0 +1,253 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableDefinition, WorkflowGroup } from '@/lib/table/types' + +const mocks = vi.hoisted(() => ({ + addGroup: vi.fn(), + addOutput: vi.fn(), + audit: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: 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('@/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/application/runs', () => ({ startTableRun: { execute: vi.fn() } })) +vi.mock('@/lib/table/column-naming', () => ({ columnTypeForLeaf: () => 'string' })) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) +vi.mock('@/lib/table/workflow-groups/service', () => ({ + addWorkflowGroup: mocks.addGroup, + addWorkflowGroupOutput: mocks.addOutput, + deleteWorkflowGroup: vi.fn(), + deleteWorkflowGroupOutput: vi.fn(), + updateWorkflowGroup: mocks.updateGroup, +})) +vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ + resolveWorkflowOutputs: { execute: vi.fn() }, +})) + +import { + addTableGroupOutputUseCase, + createTableGroupUseCase, + updateTableGroupUseCase, +} from '@/lib/table/application/groups' + +const group: WorkflowGroup = { + id: 'group-1', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'result' }], +} +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: null, + schema: { + columns: [ + { id: 'column-1', name: 'name', type: 'string' }, + { + id: 'column-2', + 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', + 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: 'Agent 2', + blockType: 'agent', + path: 'score', + leafType: 'number', + }, + ], + executionOrderByBlockId: { 'block-1': 1, 'block-2': 2 }, +} + +describe('table group 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.addGroup.mockResolvedValue(table) + mocks.addOutput.mockResolvedValue(table) + mocks.updateGroup.mockResolvedValue(table) + }) + + it('accepts only Workflow-authorized metadata for delegated group creation', async () => { + await createTableGroupUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + group, + outputColumns: [{ name: 'result', type: 'string', workflowGroupId: 'group-1' }], + resolvedWorkflow, + }, + }) + + expect(mocks.addGroup).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledTimes(1) + + await expect( + createTableGroupUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + group: { ...group, workflowId: 'workflow-cross-workspace' }, + outputColumns: [{ name: 'result', type: 'string' }], + resolvedWorkflow, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.addGroup).toHaveBeenCalledTimes(1) + }) + + it('prevents mismatched workflow metadata from being persisted on output add', async () => { + await expect( + addTableGroupOutputUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + groupId: 'group-1', + blockId: 'block-2', + path: 'score', + resolvedWorkflow: { ...resolvedWorkflow, workflowId: 'workflow-cross-workspace' }, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.addOutput).not.toHaveBeenCalled() + }) + + it('passes authorized output type and ordering to the canonical mutation', async () => { + await addTableGroupOutputUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + groupId: 'group-1', + blockId: 'block-2', + path: 'score', + resolvedWorkflow, + }, + }) + + expect(mocks.addOutput).toHaveBeenCalledWith( + expect.objectContaining({ + tableId: 'table-1', + workspaceId: 'workspace-1', + resolvedOutput: expect.objectContaining({ + workflowId: 'workflow-1', + columnType: 'string', + order: expect.arrayContaining([ + expect.objectContaining({ blockId: 'block-2', executionDistance: 2 }), + ]), + }), + }), + 'request-1' + ) + }) + + it('validates mapping updates against authorized output metadata before mutation', async () => { + await expect( + updateTableGroupUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + groupId: 'group-1', + mappingUpdates: [{ columnName: 'result', blockId: 'missing', path: 'value' }], + resolvedWorkflow, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.updateGroup).not.toHaveBeenCalled() + }) + + it('passes authorized mapping types to the canonical group update', async () => { + await updateTableGroupUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + groupId: 'group-1', + mappingUpdates: [{ columnName: 'result', blockId: 'block-2', path: 'score' }], + resolvedWorkflow, + }, + }) + + expect(mocks.updateGroup).toHaveBeenCalledWith( + expect.objectContaining({ + resolvedMappingTypes: { + workflowId: 'workflow-1', + columns: [{ columnName: 'result', type: 'string' }], + }, + }), + 'request-1' + ) + }) +}) diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index 665e887374f..3ae75799599 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -1,7 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { type Principal, 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' @@ -18,12 +17,17 @@ import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized import { resolveActiveTableContext } from '@/lib/table/application/context' import { tableOperations } from '@/lib/table/application/operations' import { startTableRun } from '@/lib/table/application/runs' +import { columnTypeForLeaf } from '@/lib/table/column-naming' import { signalTableSchemaChanged } from '@/lib/table/events' import { addWorkflowGroup, + addWorkflowGroupOutput, deleteWorkflowGroup, + deleteWorkflowGroupOutput, updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' +import type { ResolveWorkflowOutputsResult } from '@/lib/workflows/application/resolve-workflow-outputs' +import { resolveWorkflowOutputs } from '@/lib/workflows/application/resolve-workflow-outputs' const logger = createLogger('TableGroupApplication') @@ -42,14 +46,25 @@ function groupFromTable(table: TableDefinition, groupId: string): WorkflowGroup return group } -async function requireWorkflowInTableWorkspace( +async function resolveAuthorizedWorkflowForTableGroup( + principal: Principal, 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') + workspaceId: string, + provided?: ResolveWorkflowOutputsResult +): Promise { + if (provided) { + if (provided.workflowId !== workflowId) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + return provided + } + if (principal.kind === 'delegated') { + throw new OrchestrationError('not_found', 'Workflow not found') } + return resolveWorkflowOutputs.execute({ + principal, + input: { workflowId, assertedWorkspaceId: workspaceId }, + }) } export const listTableGroupsUseCase = defineAuthorizedTableUseCase({ @@ -68,6 +83,7 @@ export interface CreateTableGroupInput extends TableGroupInput { group: V2AddWorkflowGroupBody['group'] outputColumns: V2AddWorkflowGroupBody['outputColumns'] autoRun?: boolean + resolvedWorkflow?: ResolveWorkflowOutputsResult } export const createTableGroupUseCase = defineAuthorizedTableUseCase({ @@ -79,7 +95,12 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ }), async execute({ principal, input, context }) { if (input.group.workflowId) { - await requireWorkflowInTableWorkspace(input.group.workflowId, context.workspaceId) + await resolveAuthorizedWorkflowForTableGroup( + principal, + input.group.workflowId, + context.workspaceId, + input.resolvedWorkflow + ) } const outputNames = new Set(input.group.outputs.map((output) => output.columnName)) const orphan = input.outputColumns.find((column) => !outputNames.has(column.name)) @@ -150,7 +171,9 @@ export interface UpdateTableGroupInput Omit< UpdateWorkflowGroupData, 'tableId' | 'workspaceId' | 'actorUserId' | 'suppressAutoRunDispatch' - > {} + > { + resolvedWorkflow?: ResolveWorkflowOutputsResult +} export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ operation: tableOperations.updateGroup, @@ -160,15 +183,52 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ principal, input, context }) { - if (input.workflowId !== undefined) { - await requireWorkflowInTableWorkspace(input.workflowId, context.workspaceId) + 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 resolveAuthorizedWorkflowForTableGroup( + principal, + targetWorkflowId, + context.workspaceId, + input.resolvedWorkflow + ) } const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) - const previousGroup = (context.table.schema.workflowGroups ?? []).find( - (group) => group.id === input.groupId - ) + 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, @@ -189,6 +249,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 } : {}), @@ -277,3 +338,128 @@ export const deleteTableGroupUseCase = defineAuthorizedTableUseCase({ signalTableSchemaChanged(context.table.id) }, }) + +export interface AddTableGroupOutputInput extends TableGroupInput { + groupId: string + blockId: string + path: string + columnName?: string + resolvedWorkflow: ResolveWorkflowOutputsResult +} + +export const addTableGroupOutputUseCase = 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.workflowId !== input.resolvedWorkflow.workflowId) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + const outputs = input.resolvedWorkflow.outputs + if (!outputs) { + throw new OrchestrationError('validation', 'Workflow has no pickable outputs') + } + 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: input.resolvedWorkflow.workflowId, + columnType: columnTypeForLeaf(output.leafType), + order: outputs.map((candidate, discoveryIndex) => { + const distance = input.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..a6678398a3d --- /dev/null +++ b/apps/sim/lib/table/application/imports.test.ts @@ -0,0 +1,256 @@ +/** + * @vitest-environment node + */ + +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(), + 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/workspace-files/application/read-workspace-file-record', () => ({ + readWorkspaceFileContentRecord: { execute: vi.fn() }, +})) + +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 upload = { + id: 'import-1', + workspaceId: 'workspace-1', + userId: 'uploader-1', + fileName: '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' }) + }) + + 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('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('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..27e005198c2 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,8 +28,6 @@ import { startUploadedTableImport, type TableImportResource, tableImportBodyFromUpload, - toV2CreateTableImport, - toV2TableImport, } from '@/lib/table/orchestration/import-resource' import { requestOrigin } from '@/lib/uploads/upload-session/application' import { @@ -46,7 +41,7 @@ import { readWorkspaceFileContentRecord } from '@/lib/workspace-files/applicatio const logger = createLogger('TableImportApplication') export interface CreateTableImportInput { - body: V2CreateTableImportBody + body: CreateTableImportRequest } export interface TableImportResourceInput { @@ -67,11 +62,11 @@ export interface CancelTableImportInput extends TableImportResourceInput { } export interface CreateTableImportResult { - import: V2CreateTableImportData + import: CreateTableImportResourceResult } export interface TableImportResult { - import: V2TableImport + import: TableImportResource } export interface CreateTableImportPartsResult { @@ -136,7 +131,7 @@ async function resolveTableImportUploadContext( 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 @@ -157,14 +152,6 @@ export const createTableImportUseCase = defineAuthorizedTableUseCase({ 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, }) @@ -199,7 +186,7 @@ export const createTableImportUseCase = defineAuthorizedTableUseCase({ targetType: input.body.target.type, principalKind: principal.kind, }) - return { import: toV2CreateTableImport(created) } + return { import: created } }, }) @@ -208,7 +195,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 +229,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 +248,7 @@ export const completeTableImportUseCase = defineAuthorizedTableUseCase({ tableId: started.tableId, principalKind: principal.kind, }) - return { import: toV2TableImport(started) } + return { import: started } }, }) @@ -292,6 +279,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..87ecaae425d 100644 --- a/apps/sim/lib/table/application/operations.test.ts +++ b/apps/sim/lib/table/application/operations.test.ts @@ -55,7 +55,19 @@ describe('table operation registry', () => { it('keeps delegated table operations Copilot-only', () => { for (const operation of Object.values(tableOperations)) { - expect(operation.delegatedServices).toEqual(['copilot']) + if (operation.principalKinds.includes('delegated')) { + expect(operation.delegatedServices).toEqual(['copilot']) + } else { + expect(operation.delegatedServices).toBeUndefined() + } } }) + + it('separates Copilot file imports from the credential-bound HTTP lifecycle', () => { + expect(tableOperations.createImport.principalKinds).not.toContain('delegated') + expect(tableOperations.createImportParts.principalKinds).not.toContain('delegated') + expect(tableOperations.completeImport.principalKinds).not.toContain('delegated') + expect(tableOperations.createFromWorkspaceFile.principalKinds).toEqual(['delegated']) + expect(tableOperations.importWorkspaceFile.principalKinds).toEqual(['delegated']) + }) }) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 8662437787f..da9d98895ad 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -23,6 +23,25 @@ function writeOperation(id: Id) { }) } +function delegatedWriteOperation(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }) +} + +function nonDelegatedWriteOperation(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }) +} + export const tableOperations = { list: readOperation('tables.list'), read: readOperation('tables.read'), @@ -58,10 +77,12 @@ export const tableOperations = { deleteGroup: writeOperation('tables.groups.delete'), startRun: writeOperation('tables.runs.start'), cancelRuns: writeOperation('tables.runs.cancel'), - createImport: writeOperation('tables.imports.create'), + createImport: nonDelegatedWriteOperation('tables.imports.create'), + createFromWorkspaceFile: delegatedWriteOperation('tables.imports.create_from_workspace_file'), + importWorkspaceFile: delegatedWriteOperation('tables.imports.workspace_file'), readImport: readOperation('tables.imports.read'), - createImportParts: writeOperation('tables.imports.create_parts'), - completeImport: writeOperation('tables.imports.complete'), + createImportParts: nonDelegatedWriteOperation('tables.imports.create_parts'), + completeImport: nonDelegatedWriteOperation('tables.imports.complete'), cancelImport: writeOperation('tables.imports.cancel'), createExport: readOperation('tables.exports.create'), readExport: readOperation('tables.exports.read'), 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..6bd2c7476d2 --- /dev/null +++ b/apps/sim/lib/table/application/workspace-file-imports.test.ts @@ -0,0 +1,213 @@ +/** + * @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(), + markJob: vi.fn(), + releaseJob: vi.fn(), + resolveTableContext: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkspaceContext: vi.fn(), + signal: vi.fn(), +})) + +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: vi.fn() })) +vi.mock('@/lib/table', () => ({ + batchInsertRows: mocks.batchInsert, + buildAutoMapping: vi.fn(() => ({ name: 'name' })), + coerceRowsForTable: (rows: unknown[]) => rows, + CSV_MAX_BATCH_SIZE: 1000, + getWorkspaceTableLimits: vi.fn(() => ({ maxRowsPerTable: 100, maxTables: 5 })), + replaceTableRows: vi.fn(), + validateMapping: vi.fn(() => ({ + effectiveMap: new Map([['name', 'name']]), + mappedHeaders: ['name'], + skippedHeaders: [], + })), +})) +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, +})) + +import { + createTableFromWorkspaceFile, + importWorkspaceFileIntoTable, +} from '@/lib/table/application/workspace-file-imports' + +const table: TableDefinition = { + id: 'table-1', + name: 'People', + description: 'Imported', + schema: { columns: [{ 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 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'), +} +const input = { + kind: 'inline' as const, + workspaceId: 'workspace-1', + sourceFile: { + id: 'file-1', + workspaceId: 'workspace-1', + key: 'workspace/workspace-1/people.csv', + name: 'people.csv', + type: 'text/csv', + size: 128, + }, + name: 'People', + description: 'Imported', + columns: [{ name: 'name', type: 'string' as const }], + headerToColumn: new Map([['name', 'name']]), + rows: [{ name: 'Ada' }], +} + +describe('Copilot workspace-file table creation', () => { + 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.createTable.mockResolvedValue(table) + mocks.deleteTable.mockResolvedValue(undefined) + mocks.batchInsert.mockResolvedValue([{ id: 'row-1' }]) + mocks.markJob.mockResolvedValue(true) + mocks.releaseJob.mockResolvedValue(true) + }) + + it('owns table creation, row insertion, audit, and shared effects', async () => { + await expect(createTableFromWorkspaceFile.execute({ principal, input })).resolves.toMatchObject( + { kind: 'inline', insertedCount: 1, table } + ) + + 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-1') + }) + + it('rejects HTTP-capable workspace keys before canonical loading or mutation', async () => { + await expect( + createTableFromWorkspaceFile.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + } as never, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() + expect(mocks.createTable).not.toHaveBeenCalled() + }) + + it('holds the table job claim across inline file loading and mutation', async () => { + const events: string[] = [] + mocks.markJob.mockImplementationOnce(async () => { + events.push('claim') + return true + }) + mocks.batchInsert.mockImplementationOnce(async () => { + events.push('mutate') + return [{ id: 'row-1' }] + }) + mocks.releaseJob.mockImplementationOnce(async () => { + events.push('release') + return true + }) + + await importWorkspaceFileIntoTable.execute({ + principal, + input: { + kind: 'inline', + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + sourceFile: input.sourceFile, + mode: 'append', + loadRows: async () => { + events.push('load') + return { headers: ['name'], rows: [{ name: 'Ada' }] } + }, + }, + }) + + expect(events).toEqual(['claim', 'load', 'mutate', 'release']) + }) + + it('rolls back and propagates unknown insertion failures without audit or effects', async () => { + const failure = new Error('database unavailable') + mocks.batchInsert.mockRejectedValueOnce(failure) + + await expect(createTableFromWorkspaceFile.execute({ principal, input })).rejects.toBe(failure) + expect(mocks.deleteTable).toHaveBeenCalledWith('table-1', 'request-') + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).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..743ef638bbf --- /dev/null +++ b/apps/sim/lib/table/application/workspace-file-imports.ts @@ -0,0 +1,476 @@ +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_MAX_BATCH_SIZE, + type CsvHeaderMapping, + coerceRowsForTable, + getWorkspaceTableLimits, + type RowData, + replaceTableRows, + 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' + +const logger = createLogger('TableWorkspaceFileImportApplication') + +export interface TableWorkspaceFileSource { + id: string + workspaceId: string + key: string + name: string + type: string + size: number +} + +interface CreateTableFromWorkspaceFileBaseInput { + workspaceId: string + sourceFile: TableWorkspaceFileSource + name: string + description: string +} + +export type CreateTableFromWorkspaceFileInput = CreateTableFromWorkspaceFileBaseInput & + ( + | { kind: 'background' } + | { + kind: 'inline' + columns: ColumnDefinition[] + headerToColumn: Map + rows: Record[] + } + ) + +export type CreateTableFromWorkspaceFileResult = + | { + kind: 'background' + table: TableDefinition + jobId: string + sourceFile: TableWorkspaceFileSource + } + | { + kind: 'inline' + table: TableDefinition + columns: ColumnDefinition[] + insertedCount: number + droppedRows: number + maxRowsPerTable: number + sourceFile: TableWorkspaceFileSource + } + +interface ImportWorkspaceFileBaseInput { + tableId: string + assertedWorkspaceId: string + sourceFile: TableWorkspaceFileSource + mode: 'append' | 'replace' + mapping?: CsvHeaderMapping +} + +export type ImportWorkspaceFileInput = ImportWorkspaceFileBaseInput & + ( + | { kind: 'background' } + | { + kind: 'inline' + loadRows: () => Promise<{ headers: string[]; rows: Record[] }> + } + ) + +export type ImportWorkspaceFileResult = + | { + kind: 'background' + table: TableDefinition + jobId: string + mode: 'append' | 'replace' + } + | { + 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) +} + +function requireCanonicalSource( + source: TableWorkspaceFileSource, + workspaceId: string +): TableWorkspaceFileSource { + if (!source.id || !source.key || !source.name || source.workspaceId !== workspaceId) { + throw new OrchestrationError('not_found', 'Workspace file not found') + } + return source +} + +async function batchInsertAll(params: { + table: TableDefinition + rows: RowData[] + workspaceId: string + userId: string +}): Promise { + let inserted = 0 + for (let index = 0; index < params.rows.length; index += CSV_MAX_BATCH_SIZE) { + 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 = requireCanonicalSource(input.sourceFile, context.workspaceId) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + const limits = await getWorkspaceTableLimits(context.workspaceId) + + if (input.kind === 'background') { + const jobId = generateId() + const table = await createTable( + { + name: input.name, + description: input.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: input.kind, table, jobId, sourceFile } + } + + const droppedRows = Math.max(0, input.rows.length - limits.maxRowsPerTable) + const rows = droppedRows > 0 ? input.rows.slice(0, limits.maxRowsPerTable) : input.rows + const table = await createTable( + { + name: input.name, + description: input.description, + schema: { columns: input.columns }, + workspaceId: context.workspaceId, + userId, + maxTables: limits.maxTables, + }, + requestId() + ) + try { + const insertedCount = await batchInsertAll({ + table, + rows: coerceRowsForTable(rows, table.schema, input.headerToColumn), + workspaceId: context.workspaceId, + userId, + }) + return { + kind: input.kind, + table, + columns: input.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 }) { + 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 = requireCanonicalSource(input.sourceFile, context.workspaceId) + const userId = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId + + if (input.kind === 'background') { + 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: input.kind, table: context.table, jobId, mode: input.mode } + } + + 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 input.loadRows() + if (sourceRows.length === 0) { + return { kind: 'empty', table: context.table, mode: input.mode } + } + const mapping = input.mapping ?? buildAutoMapping(headers, context.table.schema) + const validation = validateMapping({ + csvHeaders: headers, + mapping, + tableSchema: context.table.schema, + }) + 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: input.kind, + 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, + }) + return { + kind: input.kind, + 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.ts b/apps/sim/lib/table/orchestration/import-resource.ts index b61cab84a64..58bd842f99f 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -44,6 +44,8 @@ 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 +68,7 @@ export interface CreateTableImportResult { } interface AuthorizedTableImportResourceParams { - body: V2CreateTableImportBody + body: CreateTableImportRequest userId: string principal?: Principal localOrigin?: string 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..f585c866773 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' @@ -261,48 +262,55 @@ export async function updateWorkflowGroup( // 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') - } - 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) + if (data.resolvedMappingTypes) { + resolvedForWorkflowId = data.resolvedMappingTypes.workflowId + for (const resolved of data.resolvedMappingTypes.columns) { + remapLeafTypeByColumn.set(resolved.columnName, resolved.type) + } + } else { + const preTable = await getTableById(data.tableId) + if (!preTable || (data.workspaceId && preTable.workspaceId !== data.workspaceId)) { + throw new OrchestrationError('not_found', 'Table not found') + } + 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 + ) } - } catch (err) { - logger.warn( - `[${requestId}] Could not resolve new leaf types for remap on group ${data.groupId}; leaving column types unchanged:`, - err - ) } } @@ -657,15 +665,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 +690,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 +769,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/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..5782752459a --- /dev/null +++ b/apps/sim/lib/workflows/application/resolve-workflow-outputs.ts @@ -0,0 +1,47 @@ +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { 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 +} + +export const resolveWorkflowOutputs = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: ({ input }: { input: ResolveWorkflowOutputsInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: input.assertedWorkspaceId, + }), + async execute({ context }): 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 ?? []), + } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts index cd69a123118..4bdd9c58ee8 100644 --- a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ fetchBuffer: vi.fn(), + getProvenance: vi.fn(), loadContext: vi.fn(), resolvePermission: vi.fn(), resolveStoredReference: vi.fn(), @@ -21,9 +22,14 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: mocks.resolveStoredReference, })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ + getBoundWorkspaceFileSecretProvenance: mocks.getProvenance, +})) + import { defineWorkspaceOperation } from '@/lib/core/application' import { fileOperations } from '@/lib/workspace-files/application/operations' import { + readSafeWorkspaceFileReference, readWorkspaceFileReference, resolveWorkspaceFileReference, } from '@/lib/workspace-files/application/resolve-workspace-file-reference' @@ -51,6 +57,7 @@ describe('workspace file reference application service', () => { mocks.loadContext.mockResolvedValue(context) mocks.resolvePermission.mockResolvedValue('admin') mocks.fetchBuffer.mockResolvedValue(Buffer.from('source')) + mocks.getProvenance.mockResolvedValue({ status: 'exact', entries: [] }) }) it('uses one fixed semantic use case for an authorized reference lookup', async () => { @@ -104,4 +111,31 @@ describe('workspace file reference application service', () => { expect(mocks.resolveStoredReference).not.toHaveBeenCalled() expect(mocks.loadContext).not.toHaveBeenCalled() }) + + it('returns only workspace files with exact empty secret provenance', async () => { + await expect( + readSafeWorkspaceFileReference.execute({ + principal, + input: { workspaceId: 'workspace-1', reference: 'files/source.txt', maxBytes: 512 }, + }) + ).resolves.toEqual({ file, content: Buffer.from('source') }) + + expect(mocks.getProvenance).toHaveBeenCalledWith('workspace-1', { + fileId: 'file-1', + key: file.key, + context: 'workspace', + }) + }) + + it('rejects unknown provenance before reading file content', async () => { + mocks.getProvenance.mockResolvedValueOnce({ status: 'unknown' }) + + await expect( + readSafeWorkspaceFileReference.execute({ + principal, + input: { workspaceId: 'workspace-1', reference: 'files/source.txt', maxBytes: 512 }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts index 89962635c5c..ae26f743705 100644 --- a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts @@ -7,6 +7,7 @@ import { resolveWorkspaceFileReference as resolveStoredWorkspaceFileReference, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' @@ -123,3 +124,37 @@ export async function readWorkspaceFileReference({ input: { workspaceId, reference, maxBytes }, }) } + +export interface ReadSafeWorkspaceFileReferenceInput extends WorkspaceFileReferenceInput { + maxBytes?: number +} + +export interface ReadSafeWorkspaceFileReferenceResult { + file: WorkspaceFileRecord + content?: Buffer +} + +export const readSafeWorkspaceFileReference = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readContent, + resolveContext: ({ input }: { input: ReadSafeWorkspaceFileReferenceInput }) => + resolveWorkspaceFileReferenceContext({ input }), + async execute({ input, context }): Promise { + const provenance = await getBoundWorkspaceFileSecretProvenance(context.workspaceId, { + fileId: context.file.id, + key: context.file.key, + context: 'workspace', + }) + if (provenance.status !== 'exact' || provenance.entries.length > 0) { + throw new OrchestrationError( + 'validation', + `Cannot import "${input.reference}": the file cannot be verified as free of resolved secrets.` + ) + } + return { + file: context.file, + ...(input.maxBytes === undefined + ? {} + : { content: await fetchWorkspaceFileBuffer(context.file, { maxBytes: input.maxBytes }) }), + } + }, +}) From a5b72b284fbb0fb1628243878c010886f95f64ce Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 17:45:22 -0700 Subject: [PATCH 2/8] fix(tables): finish application boundary migration --- .../app/api/table/[tableId]/exports/route.ts | 58 ++- .../api/table/[tableId]/groups/route.test.ts | 251 +++---------- .../app/api/table/[tableId]/groups/route.ts | 257 +++---------- .../exports/[exportId]/download/route.ts | 66 ++-- .../app/api/table/exports/[exportId]/route.ts | 93 ++--- .../imports/[importId]/complete/route.ts | 70 ++-- .../table/imports/[importId]/parts/route.ts | 60 ++- .../app/api/table/imports/[importId]/route.ts | 102 ++--- apps/sim/app/api/table/imports/route.ts | 46 +-- .../api/table/table-transfer-routes.test.ts | 112 ++++++ apps/sim/lib/api/contracts/table-transfers.ts | 4 +- .../execute-table-use-case.test.ts | 63 ++++ .../lib/copilot/request/tools/tables.test.ts | 35 +- apps/sim/lib/copilot/request/tools/tables.ts | 28 +- .../tools/server/table/user-table.test.ts | 11 - .../copilot/tools/server/table/user-table.ts | 354 +++++++----------- apps/sim/lib/table/application/groups.test.ts | 56 ++- apps/sim/lib/table/application/groups.ts | 3 - .../sim/lib/table/application/imports.test.ts | 33 ++ .../orchestration/import-resource.test.ts | 79 ++-- .../table/orchestration/import-resource.ts | 76 ---- .../resolve-workspace-file-reference.test.ts | 16 + 22 files changed, 781 insertions(+), 1092 deletions(-) create mode 100644 apps/sim/app/api/table/table-transfer-routes.test.ts create mode 100644 apps/sim/lib/copilot/application/execute-table-use-case.test.ts diff --git a/apps/sim/app/api/table/[tableId]/exports/route.ts b/apps/sim/app/api/table/[tableId]/exports/route.ts index 525f455b81b..3f39d2c3489 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, + internalSessionAuth, +} from '@/lib/api/server/routes' +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: internalSessionAuth, + 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..bcac61e3181 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,72 @@ /** * @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(), -})) - -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, - } -}) +interface CapturedDefinition { + contract: { method: string; path: string } + auth: unknown + operation: { id: string } + useCase: unknown +} -vi.mock('@/lib/table/workflow-groups/service', () => ({ - addWorkflowGroup: mockAddWorkflowGroup, - updateWorkflowGroup: mockUpdateWorkflowGroup, - deleteWorkflowGroup: vi.fn(), +const mocks = vi.hoisted(() => ({ + auth: { kind: 'session-only' }, + definitions: [] as CapturedDefinition[], + useCases: { + create: { operation: { id: 'tables.groups.create' } }, + remove: { operation: { id: 'tables.groups.delete' } }, + update: { operation: { id: 'tables.groups.update' } }, + }, })) -import { PATCH, POST } from '@/app/api/table/[tableId]/groups/route' +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 }), + }, + internalSessionAuth: 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-authenticated 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..9739b9d5309 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, + internalSessionAuth, +} from '@/lib/api/server/routes' 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: 'First-party table group mutations are authenticated browser operations', +}) -/** - * 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: internalSessionAuth, + 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: internalSessionAuth, + 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: internalSessionAuth, + 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..0fb93869efc 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, + internalSessionAuth, +} from '@/lib/api/server/routes' +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: internalSessionAuth, + 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..ef411f2f7ca 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, + internalSessionAuth, +} from '@/lib/api/server/routes' +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: internalSessionAuth, + 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: internalSessionAuth, + 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..2dbfcb777cd 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, + internalSessionAuth, +} from '@/lib/api/server/routes' +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: internalSessionAuth, + 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..32562eb1435 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, + internalSessionAuth, +} from '@/lib/api/server/routes' +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: internalSessionAuth, + 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..1fb9fcf813b 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, + internalSessionAuth, +} from '@/lib/api/server/routes' +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: internalSessionAuth, + 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: internalSessionAuth, + 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..df41ef31907 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, + internalSessionAuth, +} from '@/lib/api/server/routes' +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: internalSessionAuth, + 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..6a0c647e3d1 --- /dev/null +++ b/apps/sim/app/api/table/table-transfer-routes.test.ts @@ -0,0 +1,112 @@ +/** + * @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-only' }, + 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 }), + }, + internalSessionAuth: 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-authenticated 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/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..b484d3fd0ac --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-table-use-case.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { afterEach, 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, +} + +describe('executeCopilotTableUseCase', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('uses the shared in-process Copilot identity with the table audience', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + const execute = vi.fn().mockResolvedValue({ tableId: 'table-1' }) + + await expect( + executeCopilotTableUseCase( + trustedContext, + { operation: tableOperations.read, execute }, + { tableId: 'table-1', workspaceId: 'workspace-1' } + ) + ).resolves.toEqual({ tableId: 'table-1' }) + + expect(execute).toHaveBeenCalledWith({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + audience: 'sim:tables', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + }, + input: { tableId: 'table-1', workspaceId: 'workspace-1' }, + }) + }) + + it('rejects untrusted Copilot context before application execution', () => { + const execute = vi.fn() + + expect(() => + executeCopilotTableUseCase( + { ...trustedContext, copilotToolExecution: false }, + { operation: tableOperations.read, execute }, + { tableId: 'table-1', workspaceId: 'workspace-1' } + ) + ).toThrow('trusted Copilot execution context') + expect(execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 9d0eb39c9a3..0ed917b729d 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -6,10 +6,19 @@ 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 { mockExecuteCopilotTableUseCase, mockReadTable, mockReplaceTableRows, mockSpanAddEvent } = + vi.hoisted(() => ({ + mockExecuteCopilotTableUseCase: vi.fn( + (_context: unknown, useCase: { execute: (args: unknown) => unknown }, input: unknown) => + useCase.execute({ input }) + ), + mockReadTable: vi.fn(), + mockReplaceTableRows: vi.fn(), + mockSpanAddEvent: vi.fn(), + })) + +vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ + executeCopilotTableUseCase: mockExecuteCopilotTableUseCase, })) vi.mock('@/lib/table/application/tables', () => ({ @@ -125,6 +134,7 @@ describe('maybeWriteOutputToTable', () => { }) it('replaces rows through the service with name keys remapped to column ids', async () => { + const context = buildContext() const result = await maybeWriteOutputToTable( FunctionExecute.id, { outputTable: 'tbl_1' }, @@ -137,10 +147,25 @@ describe('maybeWriteOutputToTable', () => { ], }, }, - buildContext() + context ) expect(result.success).toBe(true) + expect(mockExecuteCopilotTableUseCase).toHaveBeenNthCalledWith( + 1, + context, + expect.objectContaining({ execute: mockReadTable }), + { tableId: 'tbl_1', workspaceId: 'workspace-1' } + ) + expect(mockExecuteCopilotTableUseCase).toHaveBeenNthCalledWith( + 2, + context, + expect.objectContaining({ execute: mockReplaceTableRows }), + expect.objectContaining({ + tableId: 'tbl_1', + assertedWorkspaceId: 'workspace-1', + }) + ) expect(mockReplaceTableRows).toHaveBeenCalledTimes(1) const [{ input }] = mockReplaceTableRows.mock.calls[0] expect(input).toMatchObject({ diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts index edf6dcb33af..80526441788 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -2,10 +2,8 @@ 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 { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +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' @@ -61,10 +59,11 @@ 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 { table } = await executeCopilotTableUseCase(context, readTableUseCase, { + tableId, + workspaceId, }) const persistenceProjection = context.resolvedSecretTraceRegistry ? projectToolOutputForPersistence(rows, context.resolvedSecretTraceRegistry) @@ -93,14 +92,11 @@ async function replaceTableRowsFromWire( error: `Row ${emptyIndex + 1} has no keys matching columns on table "${table.name}" (columns: ${table.schema.columns.map((c) => c.name).join(', ')})`, } } - const replacement = await replaceTableRows.execute({ - principal, - input: { - tableId: table.id, - assertedWorkspaceId: principal.workspaceId, - rows: projectedRows, - secretProvenance: projectedRows.map(createExactEmptyTableRowSecretProvenance), - }, + const replacement = await executeCopilotTableUseCase(context, replaceTableRows, { + tableId: table.id, + assertedWorkspaceId: workspaceId, + rows: projectedRows, + secretProvenance: projectedRows.map(createExactEmptyTableRowSecretProvenance), }) if (replacement.insertedCount !== projectedRows.length) { throw new Error('Table row replacement inserted an unexpected row count') 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 8e9bb1e1240..f96072c218e 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 @@ -106,17 +106,6 @@ vi.mock('@/lib/copilot/auth/table-delegation', () => ({ ? (classified.message ?? 'Table operation failed') : '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, - }), })) vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ 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 39f60dab4eb..d5f2798d390 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -4,10 +4,7 @@ import { generateId } from '@sim/utils/id' import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' import { executeCopilotWorkflowUseCase } from '@/lib/copilot/application/execute-workflow-use-case' -import { - messageForCopilotTableError, - resolveCopilotTablePrincipal, -} from '@/lib/copilot/auth/table-delegation' +import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -255,9 +252,7 @@ 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.') @@ -297,12 +292,10 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const { table } = await executeCopilotTableUseCase( - context, - readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } - ) + const { table } = await executeCopilotTableUseCase(context, readTableUseCase, { + tableId: args.tableId, + workspaceId, + }) return { success: true, @@ -319,12 +312,10 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const { table } = await executeCopilotTableUseCase( - context, - readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } - ) + const { table } = await executeCopilotTableUseCase(context, readTableUseCase, { + tableId: args.tableId, + workspaceId, + }) return { success: true, @@ -358,12 +349,10 @@ export const userTableServerTool: BaseServerTool for (const tableId of tableIds) { try { assertNotAborted() - await executeCopilotTableUseCase( - context, - deleteTableUseCase, - { tableId, workspaceId }, - { tableId } - ) + await executeCopilotTableUseCase(context, deleteTableUseCase, { + tableId, + workspaceId, + }) deleted.push(tableId) } catch (error) { const classified = messageForCopilotTableError(error, '') @@ -394,19 +383,14 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - createTableRows, - { - kind: 'single', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - data: args.data, - position: args.position as number | undefined, - secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), - }, - { tableId: args.tableId } - ) + const result = await executeCopilotTableUseCase(context, createTableRows, { + kind: 'single', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + data: args.data, + position: args.position as number | undefined, + secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), + }) if (result.kind !== 'single') throw new Error('Single row insert returned a batch') const { table, row } = result const toNamedRow = namedRowMapper(table.schema.columns) @@ -436,18 +420,13 @@ export const userTableServerTool: BaseServerTool assertNotAborted() const sourceRows = args.rows as RowData[] - const result = await executeCopilotTableUseCase( - context, - createTableRows, - { - kind: 'batch', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rows: sourceRows, - secretProvenance: sourceRows.map(createExactEmptyTableRowSecretProvenance), - }, - { tableId: args.tableId } - ) + const result = await executeCopilotTableUseCase(context, createTableRows, { + kind: 'batch', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rows: sourceRows, + secretProvenance: sourceRows.map(createExactEmptyTableRowSecretProvenance), + }) if (result.kind !== 'batch') throw new Error('Batch row insert returned one row') const { table, rows } = result const toNamedRow = namedRowMapper(table.schema.columns) @@ -476,16 +455,11 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const { table: rowTable, row } = await executeCopilotTableUseCase( - context, - readTableRow, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowId: args.rowId, - }, - { tableId: args.tableId } - ) + const { table: rowTable, row } = await executeCopilotTableUseCase(context, readTableRow, { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: args.rowId, + }) await importRowsForModel([row], context) const toNamedRow = namedRowMapper(rowTable.schema.columns) @@ -514,22 +488,17 @@ export const userTableServerTool: BaseServerTool return { success: false, message: queryLimitError } } - const result = await executeCopilotTableUseCase( - context, - queryTableRows, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - predicate: args.filter - ? normalizeTablePredicate(args.filter as TablePredicateInput) - : undefined, - sort: args.order as SortSpec | undefined, - limit: args.limit, - cursor: args.cursor, - includeTotal: !args.cursor, - }, - { tableId: args.tableId } - ) + const result = await executeCopilotTableUseCase(context, queryTableRows, { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + predicate: args.filter + ? normalizeTablePredicate(args.filter as TablePredicateInput) + : undefined, + sort: args.order as SortSpec | undefined, + limit: args.limit, + cursor: args.cursor, + includeTotal: !args.cursor, + }) const { table } = result const toNamedRow = namedRowMapper(table.schema.columns) await importRowsForModel(result.rows, context) @@ -578,8 +547,7 @@ export const userTableServerTool: BaseServerTool rowId: args.rowId, data: args.data, secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), - }, - { tableId: args.tableId } + } ) const toNamedRow = namedRowMapper(table.schema.columns) await importRowsForModel([updatedRow], context) @@ -608,16 +576,11 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - await executeCopilotTableUseCase( - context, - deleteTableRow, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowId: args.rowId, - }, - { tableId: args.tableId } - ) + await executeCopilotTableUseCase(context, deleteTableRow, { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: args.rowId, + }) return { success: true, @@ -644,18 +607,13 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - copilotUpdateRowsByFilter, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - filter: normalizeTablePredicate(args.filter as TablePredicateInput), - data: args.data as RowData, - limit: args.limit, - }, - { tableId: args.tableId } - ) + const result = await executeCopilotTableUseCase(context, copilotUpdateRowsByFilter, { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + filter: normalizeTablePredicate(args.filter as TablePredicateInput), + data: args.data as RowData, + limit: args.limit, + }) if (result.kind === 'background') { return { success: true, @@ -687,17 +645,12 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - copilotDeleteRowsByFilter, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - filter: normalizeTablePredicate(args.filter as TablePredicateInput), - limit: args.limit, - }, - { tableId: args.tableId } - ) + const result = await executeCopilotTableUseCase(context, copilotDeleteRowsByFilter, { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + filter: normalizeTablePredicate(args.filter as TablePredicateInput), + limit: args.limit, + }) if (result.kind === 'background') { return { success: true, @@ -755,16 +708,11 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - copilotBatchUpdateRows, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - updates: updates as Array<{ rowId: string; data: RowData }>, - }, - { tableId: args.tableId } - ) + const result = await executeCopilotTableUseCase(context, copilotBatchUpdateRows, { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + updates: updates as Array<{ rowId: string; data: RowData }>, + }) return { success: true, @@ -794,17 +742,12 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - deleteTableRows, - { - kind: 'ids', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowIds, - }, - { tableId: args.tableId } - ) + const result = await executeCopilotTableUseCase(context, deleteTableRows, { + kind: 'ids', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowIds, + }) if (result.kind !== 'ids') throw new Error('Row ID deletion returned a filter result') return { @@ -944,12 +887,7 @@ export const userTableServerTool: BaseServerTool } const mode: 'append' | 'replace' = rawMode === 'replace' ? 'replace' : 'append' - await executeCopilotTableUseCase( - context, - readTableUseCase, - { tableId, workspaceId }, - { tableId } - ) + await executeCopilotTableUseCase(context, readTableUseCase, { tableId, workspaceId }) const { file: record } = await resolveWorkspaceFileRecordOrThrow( fileReference, workspaceId, @@ -957,19 +895,14 @@ export const userTableServerTool: BaseServerTool ) if (shouldImportInBackground(record)) { assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - importWorkspaceFileIntoTable, - { - kind: 'background', - tableId, - assertedWorkspaceId: workspaceId, - sourceFile: record, - mode, - mapping: rawMapping, - }, - { tableId } - ) + const result = await executeCopilotTableUseCase(context, importWorkspaceFileIntoTable, { + kind: 'background', + tableId, + assertedWorkspaceId: workspaceId, + sourceFile: record, + mode, + mapping: rawMapping, + }) if (result.kind !== 'background') { throw new Error('Background table import returned an inline result') } @@ -981,29 +914,24 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - importWorkspaceFileIntoTable, - { - kind: 'inline', - tableId, - assertedWorkspaceId: workspaceId, - sourceFile: record, - mode, - mapping: rawMapping, - loadRows: async () => { - const { content } = await resolveWorkspaceFileRecordOrThrow( - fileReference, - workspaceId, - context, - MAX_INLINE_FILE_BYTES - ) - if (!content) throw new Error('Workspace file content was not loaded') - return parseFileRows(content, record.name, record.type) - }, + const result = await executeCopilotTableUseCase(context, importWorkspaceFileIntoTable, { + kind: 'inline', + tableId, + assertedWorkspaceId: workspaceId, + sourceFile: record, + mode, + mapping: rawMapping, + loadRows: async () => { + const { content } = await resolveWorkspaceFileRecordOrThrow( + fileReference, + workspaceId, + context, + MAX_INLINE_FILE_BYTES + ) + if (!content) throw new Error('Workspace file content was not loaded') + return parseFileRows(content, record.name, record.type) }, - { tableId } - ) + }) if (result.kind === 'empty') { return { success: false, message: 'File contains no data rows' } } @@ -1079,8 +1007,7 @@ export const userTableServerTool: BaseServerTool const { table: updated } = await executeCopilotTableUseCase( context, addTableColumnUseCase, - { tableId: args.tableId, workspaceId, column: columnToAdd }, - { tableId: args.tableId } + { tableId: args.tableId, workspaceId, column: columnToAdd } ) return { success: true, @@ -1110,8 +1037,7 @@ export const userTableServerTool: BaseServerTool workspaceId, columnName: colName, updates: { name: newColName }, - }, - { tableId: args.tableId } + } ) return { success: true, @@ -1138,8 +1064,7 @@ export const userTableServerTool: BaseServerTool const { table: updated } = await executeCopilotTableUseCase( context, deleteTableColumnUseCase, - { tableId: args.tableId, workspaceId, columnName: names[0] }, - { tableId: args.tableId } + { tableId: args.tableId, workspaceId, columnName: names[0] } ) return { success: true, @@ -1151,8 +1076,7 @@ export const userTableServerTool: BaseServerTool const { table: updated } = await executeCopilotTableUseCase( context, deleteTableColumnsUseCase, - { tableId: args.tableId, workspaceId, columnNames: names }, - { tableId: args.tableId } + { tableId: args.tableId, workspaceId, columnNames: names } ) return { success: true, @@ -1219,8 +1143,7 @@ export const userTableServerTool: BaseServerTool ...(multiple !== undefined ? { multiple } : {}), ...(currencyCode !== undefined ? { currencyCode } : {}), }, - }, - { tableId: args.tableId } + } ) return { success: true, @@ -1241,12 +1164,11 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - updateTableUseCase, - { tableId: args.tableId, workspaceId, name: newName }, - { tableId: args.tableId } - ) + const result = await executeCopilotTableUseCase(context, updateTableUseCase, { + tableId: args.tableId, + workspaceId, + name: newName, + }) if (result.failure) { throw result.failure } @@ -1310,8 +1232,7 @@ export const userTableServerTool: BaseServerTool const { table: tableForGroup } = await executeCopilotTableUseCase( context, readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } + { tableId: args.tableId, workspaceId } ) for (const o of rawOutputs) { @@ -1387,8 +1308,7 @@ export const userTableServerTool: BaseServerTool outputColumns, autoRun, resolvedWorkflow, - }, - { tableId: args.tableId } + } ) return { success: true, @@ -1410,8 +1330,7 @@ export const userTableServerTool: BaseServerTool const { table: tableForUpdate } = await executeCopilotTableUseCase( context, readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } + { tableId: args.tableId, workspaceId } ) const updateOutputs = args.outputs as WorkflowGroupOutput[] | undefined const mappingUpdates = args.mappingUpdates as @@ -1474,8 +1393,7 @@ export const userTableServerTool: BaseServerTool resolvedWorkflow, deploymentMode: parseDeploymentMode(args.deploymentMode), autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, - }, - { tableId: args.tableId } + } ) return { success: true, @@ -1495,8 +1413,7 @@ export const userTableServerTool: BaseServerTool const { table: updated } = await executeCopilotTableUseCase( context, deleteTableGroupUseCase, - { tableId: args.tableId, workspaceId, groupId }, - { tableId: args.tableId } + { tableId: args.tableId, workspaceId, groupId } ) return { success: true, @@ -1521,8 +1438,7 @@ export const userTableServerTool: BaseServerTool const { table: tableForAdd } = await executeCopilotTableUseCase( context, readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } + { tableId: args.tableId, workspaceId } ) const workflowId = tableForAdd.schema.workflowGroups?.find( (candidate) => candidate.id === groupId @@ -1547,8 +1463,7 @@ export const userTableServerTool: BaseServerTool path, columnName, resolvedWorkflow, - }, - { tableId: args.tableId } + } ) return { success: true, @@ -1572,8 +1487,7 @@ export const userTableServerTool: BaseServerTool const { table: updated } = await executeCopilotTableUseCase( context, deleteTableGroupOutputUseCase, - { tableId: args.tableId, groupId, columnName, workspaceId }, - { tableId: args.tableId } + { tableId: args.tableId, groupId, columnName, workspaceId } ) return { success: true, @@ -1620,19 +1534,14 @@ export const userTableServerTool: BaseServerTool rowIds = rawRowIds as string[] } assertNotAborted() - const { dispatchId } = await executeCopilotTableUseCase( - context, - startTableRun, - { - kind: 'selection', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - groupIds, - mode: runMode, - rowIds, - }, - { tableId: args.tableId } - ) + const { dispatchId } = await executeCopilotTableUseCase(context, startTableRun, { + kind: 'selection', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + groupIds, + mode: runMode, + rowIds, + }) const scopeLabel = rowIds ? `${rowIds.length} row(s) by id` : runMode return { success: true, @@ -1670,8 +1579,7 @@ export const userTableServerTool: BaseServerTool scope: 'all', tableId: args.tableId, assertedWorkspaceId: workspaceId, - }, - { tableId: args.tableId } + } ) return { success: true, @@ -1719,8 +1627,7 @@ export const userTableServerTool: BaseServerTool const { table: tableForEnrichment } = await executeCopilotTableUseCase( context, readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } + { tableId: args.tableId, workspaceId } ) // Validate the input mapping: every required input must be mapped, and @@ -1800,8 +1707,7 @@ export const userTableServerTool: BaseServerTool const { table: updated } = await executeCopilotTableUseCase( context, createTableGroupUseCase, - { tableId: args.tableId, workspaceId, group, outputColumns, autoRun }, - { tableId: args.tableId } + { tableId: args.tableId, workspaceId, group, outputColumns, autoRun } ) return { success: true, diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts index 2665b3838e8..7f0866fa53b 100644 --- a/apps/sim/lib/table/application/groups.test.ts +++ b/apps/sim/lib/table/application/groups.test.ts @@ -9,8 +9,10 @@ const mocks = vi.hoisted(() => ({ addGroup: vi.fn(), addOutput: vi.fn(), audit: vi.fn(), + deleteOutput: vi.fn(), resolveContext: vi.fn(), resolvePermission: vi.fn(), + resolveWorkflow: vi.fn(), signal: vi.fn(), updateGroup: vi.fn(), })) @@ -44,16 +46,17 @@ vi.mock('@/lib/table/workflow-groups/service', () => ({ addWorkflowGroup: mocks.addGroup, addWorkflowGroupOutput: mocks.addOutput, deleteWorkflowGroup: vi.fn(), - deleteWorkflowGroupOutput: vi.fn(), + deleteWorkflowGroupOutput: mocks.deleteOutput, updateWorkflowGroup: mocks.updateGroup, })) vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ - resolveWorkflowOutputs: { execute: vi.fn() }, + resolveWorkflowOutputs: { execute: mocks.resolveWorkflow }, })) import { addTableGroupOutputUseCase, createTableGroupUseCase, + deleteTableGroupOutputUseCase, updateTableGroupUseCase, } from '@/lib/table/application/groups' @@ -133,9 +136,34 @@ describe('table group application use cases', () => { }) mocks.addGroup.mockResolvedValue(table) mocks.addOutput.mockResolvedValue(table) + mocks.deleteOutput.mockResolvedValue(table) mocks.updateGroup.mockResolvedValue(table) }) + it('lets the Workflow application policy reject missing delegated metadata before mutation', async () => { + mocks.resolveWorkflow.mockRejectedValue( + new Error('Delegated workspace access is no longer valid') + ) + + await expect( + createTableGroupUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + group, + outputColumns: [{ name: 'result', type: 'string', workflowGroupId: 'group-1' }], + }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mocks.resolveWorkflow).toHaveBeenCalledWith({ + principal, + input: { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-1' }, + }) + expect(mocks.addGroup).not.toHaveBeenCalled() + }) + it('accepts only Workflow-authorized metadata for delegated group creation', async () => { await createTableGroupUseCase.execute({ principal, @@ -212,6 +240,30 @@ describe('table group application use cases', () => { ) }) + it('deletes an output through the canonical mutation with audit and schema effects', async () => { + await deleteTableGroupOutputUseCase.execute({ + principal, + input: { + tableId: 'table-1', + workspaceId: 'workspace-1', + groupId: 'group-1', + columnName: 'result', + }, + }) + + expect(mocks.deleteOutput).toHaveBeenCalledWith( + { + tableId: 'table-1', + workspaceId: 'workspace-1', + groupId: 'group-1', + columnName: 'result', + }, + 'request-1' + ) + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.signal).toHaveBeenCalledWith('table-1') + }) + it('validates mapping updates against authorized output metadata before mutation', async () => { await expect( updateTableGroupUseCase.execute({ diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index 3ae75799599..46213a161ce 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -58,9 +58,6 @@ async function resolveAuthorizedWorkflowForTableGroup( } return provided } - if (principal.kind === 'delegated') { - throw new OrchestrationError('not_found', 'Workflow not found') - } return resolveWorkflowOutputs.execute({ principal, input: { workflowId, assertedWorkspaceId: workspaceId }, diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index a6678398a3d..f68e5dca152 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -139,6 +139,39 @@ describe('table import application use cases', () => { } ) mocks.startUploadedImport.mockResolvedValue({ ...record, status: 'ready' }) + mocks.createResource.mockResolvedValue({ record, upload: null }) + }) + + 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('reads a durable import by workspace role rather than uploader identity', async () => { 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 58bd842f99f..d58ca0c9a57 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -38,7 +38,6 @@ 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') @@ -130,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 { @@ -197,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 @@ -224,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 @@ -281,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 { @@ -598,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/workspace-files/application/resolve-workspace-file-reference.test.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts index 4bdd9c58ee8..c2511500d17 100644 --- a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts @@ -127,6 +127,22 @@ describe('workspace file reference application service', () => { }) }) + it('conceals a canonical file from another workspace before authorization or content access', async () => { + mocks.resolveStoredReference.mockResolvedValueOnce({ ...file, workspaceId: 'workspace-2' }) + mocks.loadContext.mockResolvedValueOnce({ ...context, workspaceId: 'workspace-2' }) + + await expect( + readSafeWorkspaceFileReference.execute({ + principal, + input: { workspaceId: 'workspace-1', reference: 'files/source.txt', maxBytes: 512 }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getProvenance).not.toHaveBeenCalled() + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) + it('rejects unknown provenance before reading file content', async () => { mocks.getProvenance.mockResolvedValueOnce({ status: 'unknown' }) From 32dc7a21fa9ed72894c04cb58cdc927155c8466e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 17:58:21 -0700 Subject: [PATCH 3/8] fix(tables): restore scoped copilot imports --- .../execute-table-use-case.test.ts | 19 +++++- .../copilot/tools/server/table/user-table.ts | 2 + .../table/application/authorization.test.ts | 6 +- .../lib/table/application/authorization.ts | 5 +- .../workspace-file-imports.test.ts | 62 +++++++++++++++++-- .../application/workspace-file-imports.ts | 24 +++++-- 6 files changed, 102 insertions(+), 16 deletions(-) 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 index b484d3fd0ac..80dee91d5a4 100644 --- a/apps/sim/lib/copilot/application/execute-table-use-case.test.ts +++ b/apps/sim/lib/copilot/application/execute-table-use-case.test.ts @@ -42,12 +42,29 @@ describe('executeCopilotTableUseCase', () => { audience: 'sim:tables', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2026-01-01T00:05:00Z'), - resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + resourceScope: { + chatId: 'chat-1', + executionId: 'execution-1', + tableId: 'table-1', + }, }, input: { tableId: 'table-1', workspaceId: 'workspace-1' }, }) }) + it('fails fast when a table-scoped input has no valid table id', () => { + const execute = vi.fn() + + expect(() => + executeCopilotTableUseCase( + trustedContext, + { operation: tableOperations.read, execute }, + { tableId: '', workspaceId: 'workspace-1' } + ) + ).toThrow('invalid table ID') + expect(execute).not.toHaveBeenCalled() + }) + it('rejects untrusted Copilot context before application execution', () => { const execute = vi.fn() 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 d5f2798d390..8811be332cc 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -834,6 +834,7 @@ export const userTableServerTool: BaseServerTool columns, headerToColumn, rows, + assertNotAborted, }) if (result.kind !== 'inline') { throw new Error('Inline table import returned a background result') @@ -921,6 +922,7 @@ export const userTableServerTool: BaseServerTool sourceFile: record, mode, mapping: rawMapping, + assertNotAborted, loadRows: async () => { const { content } = await resolveWorkspaceFileRecordOrThrow( fileReference, diff --git a/apps/sim/lib/table/application/authorization.test.ts b/apps/sim/lib/table/application/authorization.test.ts index f5a2ae99f7b..65cc5a3ac0f 100644 --- a/apps/sim/lib/table/application/authorization.test.ts +++ b/apps/sim/lib/table/application/authorization.test.ts @@ -122,7 +122,7 @@ describe('table operation authorization', () => { ) }) - it('rejects wrong-audience, expired, cross-workspace, and wrong-table delegations before lookup', async () => { + 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 +150,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..e6b3930f6ae 100644 --- a/apps/sim/lib/table/application/authorization.ts +++ b/apps/sim/lib/table/application/authorization.ts @@ -24,10 +24,7 @@ export const tableDelegationPolicy: WorkspaceDelegationPolicy, context: TableAuthorizationContext ) { - return ( - principal.resourceScope?.tableId === undefined || - principal.resourceScope.tableId === context.tableId - ) + return context.tableId === undefined || principal.resourceScope?.tableId === 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 index 6bd2c7476d2..86721b0ebd9 100644 --- a/apps/sim/lib/table/application/workspace-file-imports.test.ts +++ b/apps/sim/lib/table/application/workspace-file-imports.test.ts @@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), resolveWorkspaceContext: vi.fn(), signal: vi.fn(), + validateMapping: vi.fn(), + CsvImportValidationError: class extends Error {}, })) vi.mock('@sim/audit', () => ({ @@ -39,14 +41,11 @@ vi.mock('@/lib/table', () => ({ batchInsertRows: mocks.batchInsert, buildAutoMapping: vi.fn(() => ({ name: 'name' })), coerceRowsForTable: (rows: unknown[]) => rows, + CsvImportValidationError: mocks.CsvImportValidationError, CSV_MAX_BATCH_SIZE: 1000, getWorkspaceTableLimits: vi.fn(() => ({ maxRowsPerTable: 100, maxTables: 5 })), replaceTableRows: vi.fn(), - validateMapping: vi.fn(() => ({ - effectiveMap: new Map([['name', 'name']]), - mappedHeaders: ['name'], - skippedHeaders: [], - })), + validateMapping: mocks.validateMapping, })) vi.mock('@/lib/table/application/context', () => ({ resolveActiveTableContext: mocks.resolveTableContext, @@ -94,6 +93,7 @@ const principal = { 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 = { kind: 'inline' as const, @@ -136,6 +136,11 @@ describe('Copilot workspace-file table creation', () => { mocks.batchInsert.mockResolvedValue([{ id: 'row-1' }]) mocks.markJob.mockResolvedValue(true) mocks.releaseJob.mockResolvedValue(true) + mocks.validateMapping.mockReturnValue({ + effectiveMap: new Map([['name', 'name']]), + mappedHeaders: ['name'], + skippedHeaders: [], + }) }) it('owns table creation, row insertion, audit, and shared effects', async () => { @@ -201,6 +206,53 @@ describe('Copilot workspace-file table creation', () => { expect(events).toEqual(['claim', 'load', 'mutate', 'release']) }) + it('checks for a user stop after loading and before every inline insert batch', async () => { + const assertNotAborted = vi.fn() + + await importWorkspaceFileIntoTable.execute({ + principal, + input: { + kind: 'inline', + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + sourceFile: input.sourceFile, + mode: 'append', + assertNotAborted, + loadRows: async () => ({ + headers: ['name'], + rows: Array.from({ length: 1001 }, (_, index) => ({ name: `Person ${index}` })), + }), + }, + }) + + expect(assertNotAborted).toHaveBeenCalledTimes(3) + expect(mocks.batchInsert).toHaveBeenCalledTimes(2) + }) + + it('classifies mapping failures before mutation so Copilot can correct them', async () => { + mocks.validateMapping.mockImplementationOnce(() => { + throw new mocks.CsvImportValidationError('Mapping references an unknown column') + }) + + await expect( + importWorkspaceFileIntoTable.execute({ + principal, + input: { + kind: 'inline', + tableId: 'table-1', + assertedWorkspaceId: 'workspace-1', + sourceFile: input.sourceFile, + mode: 'append', + loadRows: async () => ({ headers: ['name'], rows: [{ name: 'Ada' }] }), + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Mapping references an unknown column', + }) + expect(mocks.batchInsert).not.toHaveBeenCalled() + }) + it('rolls back and propagates unknown insertion failures without audit or effects', async () => { const failure = new Error('database unavailable') mocks.batchInsert.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/table/application/workspace-file-imports.ts b/apps/sim/lib/table/application/workspace-file-imports.ts index 743ef638bbf..f49b07734ed 100644 --- a/apps/sim/lib/table/application/workspace-file-imports.ts +++ b/apps/sim/lib/table/application/workspace-file-imports.ts @@ -12,6 +12,7 @@ import { type ColumnDefinition, CSV_MAX_BATCH_SIZE, type CsvHeaderMapping, + CsvImportValidationError, coerceRowsForTable, getWorkspaceTableLimits, type RowData, @@ -60,6 +61,7 @@ export type CreateTableFromWorkspaceFileInput = CreateTableFromWorkspaceFileBase columns: ColumnDefinition[] headerToColumn: Map rows: Record[] + assertNotAborted?: () => void } ) @@ -94,6 +96,7 @@ export type ImportWorkspaceFileInput = ImportWorkspaceFileBaseInput & | { kind: 'inline' loadRows: () => Promise<{ headers: string[]; rows: Record[] }> + assertNotAborted?: () => void } ) @@ -148,9 +151,11 @@ async function batchInsertAll(params: { 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( { @@ -303,6 +308,7 @@ export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({ rows: coerceRowsForTable(rows, table.schema, input.headerToColumn), workspaceId: context.workspaceId, userId, + assertNotAborted: input.assertNotAborted, }) return { kind: input.kind, @@ -391,15 +397,22 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({ 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 input.loadRows() + input.assertNotAborted?.() if (sourceRows.length === 0) { return { kind: 'empty', table: context.table, mode: input.mode } } const mapping = input.mapping ?? buildAutoMapping(headers, context.table.schema) - const validation = validateMapping({ - csvHeaders: headers, - mapping, - tableSchema: 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', @@ -435,6 +448,7 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({ rows, workspaceId: context.workspaceId, userId, + assertNotAborted: input.assertNotAborted, }) return { kind: input.kind, From 3b0f9b057921e77c13091633196020d12c97eda6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sun, 9 Aug 2026 00:36:39 -0700 Subject: [PATCH 4/8] fix(tables): compose copilot commands atomically --- .../execute-table-use-case.test.ts | 80 -- .../application/execute-table-use-case.ts | 44 - .../application/table-commands.test.ts | 131 +++ .../lib/copilot/application/table-commands.ts | 137 +++ apps/sim/lib/copilot/auth/table-delegation.ts | 3 + .../lib/copilot/request/tools/tables.test.ts | 551 ++++-------- apps/sim/lib/copilot/request/tools/tables.ts | 82 +- .../tools/server/table/user-table.test.ts | 203 ++--- .../copilot/tools/server/table/user-table.ts | 829 ++++++------------ apps/sim/lib/table/application/groups.test.ts | 363 +++++--- apps/sim/lib/table/application/groups.ts | 650 ++++++++++++-- .../sim/lib/table/application/imports.test.ts | 49 +- apps/sim/lib/table/application/imports.ts | 27 +- apps/sim/lib/table/application/rows.test.ts | 189 ++++ apps/sim/lib/table/application/rows.ts | 137 ++- .../workspace-file-imports.test.ts | 250 ++++-- .../application/workspace-file-imports.ts | 164 ++-- apps/sim/lib/table/workflow-groups/service.ts | 93 +- .../application/resolve-workflow-outputs.ts | 44 +- 19 files changed, 2322 insertions(+), 1704 deletions(-) delete mode 100644 apps/sim/lib/copilot/application/execute-table-use-case.test.ts delete mode 100644 apps/sim/lib/copilot/application/execute-table-use-case.ts create mode 100644 apps/sim/lib/copilot/application/table-commands.test.ts create mode 100644 apps/sim/lib/copilot/application/table-commands.ts 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 deleted file mode 100644 index 80dee91d5a4..00000000000 --- a/apps/sim/lib/copilot/application/execute-table-use-case.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @vitest-environment node - */ -import { afterEach, 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, -} - -describe('executeCopilotTableUseCase', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('uses the shared in-process Copilot identity with the table audience', async () => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) - const execute = vi.fn().mockResolvedValue({ tableId: 'table-1' }) - - await expect( - executeCopilotTableUseCase( - trustedContext, - { operation: tableOperations.read, execute }, - { tableId: 'table-1', workspaceId: 'workspace-1' } - ) - ).resolves.toEqual({ tableId: 'table-1' }) - - expect(execute).toHaveBeenCalledWith({ - principal: { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'copilot-tool:tool-call-1', - audience: 'sim:tables', - issuedAt: new Date('2026-01-01T00:00:00Z'), - expiresAt: new Date('2026-01-01T00:05:00Z'), - resourceScope: { - chatId: 'chat-1', - executionId: 'execution-1', - tableId: 'table-1', - }, - }, - input: { tableId: 'table-1', workspaceId: 'workspace-1' }, - }) - }) - - it('fails fast when a table-scoped input has no valid table id', () => { - const execute = vi.fn() - - expect(() => - executeCopilotTableUseCase( - trustedContext, - { operation: tableOperations.read, execute }, - { tableId: '', workspaceId: 'workspace-1' } - ) - ).toThrow('invalid table ID') - expect(execute).not.toHaveBeenCalled() - }) - - it('rejects untrusted Copilot context before application execution', () => { - const execute = vi.fn() - - expect(() => - executeCopilotTableUseCase( - { ...trustedContext, copilotToolExecution: false }, - { operation: tableOperations.read, execute }, - { tableId: 'table-1', workspaceId: 'workspace-1' } - ) - ).toThrow('trusted Copilot execution context') - 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 deleted file mode 100644 index dce0203c4dc..00000000000 --- a/apps/sim/lib/copilot/application/execute-table-use-case.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' -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 ->({ - domain: 'table', - delegation: { - audience: tableDelegationPolicy.audience, - ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, - createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, - }, - operations: tableOperations, - projectResourceScope: ({ tableId }) => (tableId ? { tableId } : {}), -}) - -/** Enters a registered table application use case under trusted Copilot delegation. */ -export function executeCopilotTableUseCase( - context: CopilotTableDelegationContext | undefined, - useCase: OperationUseCase, - input: I, - options: ExecuteCopilotTableUseCaseOptions = {} -): Promise { - return executeTableUseCase(context, useCase, input, options) -} 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..e2037f4d367 --- /dev/null +++ b/apps/sim/lib/copilot/application/table-commands.test.ts @@ -0,0 +1,131 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + addOutput: vi.fn(), + createEnrichment: vi.fn(), + createFromFile: vi.fn(), + createWorkflowGroup: vi.fn(), + importFile: vi.fn(), + replaceProjectedRows: vi.fn(), + resolvePrincipal: vi.fn(), + updateWorkflowGroup: vi.fn(), +})) + +vi.mock('@/lib/copilot/auth/table-delegation', () => ({ + resolveCopilotTablePrincipal: mocks.resolvePrincipal, +})) +vi.mock('@/lib/table/application/groups', () => ({ + addWorkflowTableGroupOutput: { execute: mocks.addOutput }, + createTableEnrichmentGroup: { execute: mocks.createEnrichment }, + createWorkflowTableGroup: { execute: mocks.createWorkflowGroup }, + updateWorkflowTableGroup: { execute: mocks.updateWorkflowGroup }, +})) +vi.mock('@/lib/table/application/rows', () => ({ + replaceProjectedWireRows: { execute: mocks.replaceProjectedRows }, +})) +vi.mock('@/lib/table/application/workspace-file-imports', () => ({ + createTableFromWorkspaceFile: { execute: mocks.createFromFile }, + importWorkspaceFileIntoTable: { execute: mocks.importFile }, +})) + +import { + copilotAddWorkflowTableGroupOutputPolicy, + copilotCreateTableEnrichmentGroupPolicy, + copilotCreateTableFromWorkspaceFilePolicy, + copilotCreateWorkflowTableGroupPolicy, + copilotImportWorkspaceFileIntoTablePolicy, + copilotReplaceProjectedWireRowsPolicy, + copilotUpdateWorkflowTableGroupPolicy, + executeCopilotAddWorkflowTableGroupOutput, + executeCopilotCreateTableEnrichmentGroup, + executeCopilotCreateTableFromWorkspaceFile, + executeCopilotCreateWorkflowTableGroup, + executeCopilotImportWorkspaceFileIntoTable, + executeCopilotReplaceProjectedWireRows, + executeCopilotUpdateWorkflowTableGroup, +} from '@/lib/copilot/application/table-commands' + +const context = { + userId: 'user-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} +const principal = { kind: 'delegated', audience: 'sim:tables' } + +describe('fixed Copilot Table application commands', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePrincipal.mockReturnValue(principal) + }) + + it.each([ + ['replace projected rows', executeCopilotReplaceProjectedWireRows, mocks.replaceProjectedRows], + ['create workflow group', executeCopilotCreateWorkflowTableGroup, mocks.createWorkflowGroup], + ['update workflow group', executeCopilotUpdateWorkflowTableGroup, mocks.updateWorkflowGroup], + ['add workflow output', executeCopilotAddWorkflowTableGroupOutput, mocks.addOutput], + ['create enrichment group', executeCopilotCreateTableEnrichmentGroup, mocks.createEnrichment], + ['import a workspace file', executeCopilotImportWorkspaceFileIntoTable, mocks.importFile], + ])( + 'dispatches %s to exactly one code-defined Table command', + async (_label, execute, command) => { + command.mockResolvedValue({ ok: true }) + const input = { tableId: 'table-1', workspaceId: 'workspace-1' } + + await expect(execute(context, input as never)).resolves.toEqual({ ok: true }) + + expect(mocks.resolvePrincipal).toHaveBeenCalledWith(context, 'table-1') + expect(command).toHaveBeenCalledWith({ principal, input }) + expect(command).toHaveBeenCalledTimes(1) + } + ) + + it('uses a workspace-scoped Table principal for create-from-file', async () => { + mocks.createFromFile.mockResolvedValue({ kind: 'empty' }) + const input = { workspaceId: 'workspace-1', fileReference: 'files/people.csv' } + + await executeCopilotCreateTableFromWorkspaceFile(context, input) + + expect(mocks.resolvePrincipal).toHaveBeenCalledWith(context) + expect(mocks.createFromFile).toHaveBeenCalledWith({ principal, input }) + }) + + it('declares inherited request-rate admission and no direct provider cost for every command', () => { + const policies = [ + copilotReplaceProjectedWireRowsPolicy, + copilotCreateWorkflowTableGroupPolicy, + 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.resolvePrincipal.mockImplementationOnce(() => { + throw error + }) + + expect(() => + executeCopilotReplaceProjectedWireRows(undefined, { + tableId: 'table-1', + sourceRows: [], + projectedRows: [], + }) + ).toThrow(error) + expect(mocks.replaceProjectedRows).not.toHaveBeenCalled() + }) +}) 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..bc621c10e42 --- /dev/null +++ b/apps/sim/lib/copilot/application/table-commands.ts @@ -0,0 +1,137 @@ +import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' +import { resolveCopilotTablePrincipal } from '@/lib/copilot/auth/table-delegation' +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 copilotReplaceProjectedWireRowsPolicy = { + rate: INHERITED_COPILOT_RATE_POLICY, + cost: NO_DIRECT_PROVIDER_COST_POLICY, +} as const + +export function executeCopilotReplaceProjectedWireRows( + context: CopilotTableDelegationContext | undefined, + input: ReplaceProjectedWireRowsInput +) { + return replaceProjectedWireRows.execute({ + principal: resolveCopilotTablePrincipal(context, input.tableId), + input, + }) +} + +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 createWorkflowTableGroup.execute({ + principal: resolveCopilotTablePrincipal(context, input.tableId), + input, + }) +} + +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 updateWorkflowTableGroup.execute({ + principal: resolveCopilotTablePrincipal(context, input.tableId), + input, + }) +} + +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 addWorkflowTableGroupOutput.execute({ + principal: resolveCopilotTablePrincipal(context, input.tableId), + input, + }) +} + +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 createTableEnrichmentGroup.execute({ + principal: resolveCopilotTablePrincipal(context, input.tableId), + input, + }) +} + +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 createTableFromWorkspaceFile.execute({ + principal: resolveCopilotTablePrincipal(context), + 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 importWorkspaceFileIntoTable.execute({ + principal: resolveCopilotTablePrincipal(context, input.tableId), + input, + }) +} diff --git a/apps/sim/lib/copilot/auth/table-delegation.ts b/apps/sim/lib/copilot/auth/table-delegation.ts index 83a055b99c1..645e3a6081c 100644 --- a/apps/sim/lib/copilot/auth/table-delegation.ts +++ b/apps/sim/lib/copilot/auth/table-delegation.ts @@ -15,6 +15,9 @@ export function resolveCopilotTablePrincipal( context: CopilotTableDelegationContext | undefined, tableId?: string ): DelegatedPrincipal { + if (tableId !== undefined && !tableId.trim()) { + throw new Error('Table delegation requires a non-empty table ID') + } return createCopilotApplicationPrincipal(requireTrustedCopilotExecutionContext(context), { audience: tableDelegationPolicy.audience, ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/copilot/request/tools/tables.test.ts index 0ed917b729d..42d15ce282c 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/copilot/request/tools/tables.test.ts @@ -6,35 +6,25 @@ import { loggerMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TableDefinition } from '@/lib/table' -const { mockExecuteCopilotTableUseCase, mockReadTable, mockReplaceTableRows, mockSpanAddEvent } = - vi.hoisted(() => ({ - mockExecuteCopilotTableUseCase: vi.fn( - (_context: unknown, useCase: { execute: (args: unknown) => unknown }, input: unknown) => - useCase.execute({ input }) - ), - mockReadTable: vi.fn(), - mockReplaceTableRows: vi.fn(), - mockSpanAddEvent: vi.fn(), - })) - -vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ - executeCopilotTableUseCase: mockExecuteCopilotTableUseCase, +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' @@ -44,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, @@ -92,440 +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, - })) - }) - - it('rejects a table from another workspace without touching it', async () => { - mockReadTable.mockRejectedValue(new Error('Table not found')) - - const result = await maybeWriteOutputToTable( - FunctionExecute.id, - { outputTable: 'tbl_1' }, - { success: true, output: { result: [{ name: 'Alice' }] } }, - buildContext() - ) - - 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' }) + mocks.executeReplace.mockImplementation( + async (_context: ExecutionContext, input: { sourceRows: unknown[] }) => ({ + table, + deletedCount: 0, + insertedCount: input.sourceRows.length, + }) ) - - 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 () => { + 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', age: 30 }, - { name: 'Bob', age: 40 }, - ], - }, - }, + { outputTable: 'table-1' }, + { success: true, output: { result: rows } }, context ) - expect(result.success).toBe(true) - expect(mockExecuteCopilotTableUseCase).toHaveBeenNthCalledWith( - 1, - context, - expect.objectContaining({ execute: mockReadTable }), - { tableId: 'tbl_1', workspaceId: 'workspace-1' } - ) - expect(mockExecuteCopilotTableUseCase).toHaveBeenNthCalledWith( - 2, - context, - expect.objectContaining({ execute: mockReplaceTableRows }), - expect.objectContaining({ - tableId: 'tbl_1', - assertedWorkspaceId: 'workspace-1', - }) - ) - expect(mockReplaceTableRows).toHaveBeenCalledTimes(1) - const [{ input }] = mockReplaceTableRows.mock.calls[0] - expect(input).toMatchObject({ - tableId: 'tbl_1', + expect(result).toEqual({ + success: true, + output: { + message: 'Wrote 2 rows to table table-1', + tableId: 'table-1', + rowCount: 2, + }, + }) + 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() + expect(mocks.executeReplace).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 }) + 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(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' }] } }, + { outputTable: 'table-1' }, + { success: true, output: { result: [{ wrong: true }] } }, buildContext() ) - expect(result.success).toBe(false) - expect(result.error).toContain('Row 2 has no keys matching columns') - expect(mockReplaceTableRows).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() - ) - - 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' }] } }, - 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 80526441788..5e38eaba239 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -1,8 +1,6 @@ -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 { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +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' @@ -16,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,49 +36,32 @@ async function replaceTableRowsFromWire( > { const workspaceId = context.workspaceId if (!workspaceId) throw new Error('Table persistence requires a workspace ID') - const { table } = await executeCopilotTableUseCase(context, readTableUseCase, { - tableId, - workspaceId, - }) 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 executeCopilotTableUseCase(context, replaceTableRows, { - tableId: table.id, - assertedWorkspaceId: 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 f96072c218e..04073e3d4e7 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 @@ -28,8 +28,9 @@ const { mockRunTableDelete, mockRunTableUpdate, mockExecuteCopilotFileUseCase, - mockExecuteCopilotTableUseCase, mockExecuteCopilotWorkflowUseCase, + mockLoadWorkspaceFileContext, + mockResolveWorkflowContext, fakeEnrichment, } = vi.hoisted(() => ({ mockUpdateColumnType: vi.fn(), @@ -53,8 +54,9 @@ const { mockRunTableDelete: vi.fn(), mockRunTableUpdate: vi.fn(), mockExecuteCopilotFileUseCase: vi.fn(), - mockExecuteCopilotTableUseCase: vi.fn(), mockExecuteCopilotWorkflowUseCase: vi.fn(), + mockLoadWorkspaceFileContext: vi.fn(), + mockResolveWorkflowContext: vi.fn(), fakeEnrichment: { id: 'work-email', name: 'Work Email', @@ -75,8 +77,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, + loadActiveWorkspaceFileContext: mockLoadWorkspaceFileContext, })) vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ readSafeWorkspaceFileReference: { operation: { id: 'files.content.read' } }, @@ -106,10 +112,17 @@ vi.mock('@/lib/copilot/auth/table-delegation', () => ({ ? (classified.message ?? 'Table operation failed') : 'Table operation failed' }, -})) - -vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ - executeCopilotTableUseCase: mockExecuteCopilotTableUseCase, + 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), + ...(tableId ? { resourceScope: { tableId } } : {}), + }), })) vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ @@ -156,6 +169,14 @@ vi.mock('@/lib/table/application/context', () => ({ }), })) +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', () => ({ getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, })) @@ -227,10 +248,32 @@ 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' beforeEach(() => { + mockLoadWorkspaceFileContext.mockResolvedValue({ workspaceId: 'workspace-1' }) + mockResolveWorkflowContext.mockImplementation( + async ({ + workflowId, + assertedWorkspaceId, + }: { + workflowId: string + assertedWorkspaceId: string + }) => ({ + workflowId, + workspaceId: assertedWorkspaceId, + }) + ) mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ workflowId: 'workflow-1', outputs: [ @@ -269,112 +312,6 @@ beforeEach(() => { } } ) - mockExecuteCopilotTableUseCase.mockImplementation( - async ( - _context: unknown, - useCase: { - operation: { id: string } - execute?: (args: { - principal: Record - input: Record - }) => Promise - }, - 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 OrchestrationError('validation', '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.read': { - if (!table || table.workspaceId !== input.workspaceId) { - 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 { table } - } - case 'tables.groups.create': { - if (!table) throw Object.assign(new Error('Table not found'), { code: 'not_found' }) - const updated = await mockAddWorkflowGroup( - { - tableId: input.tableId, - workspaceId: input.workspaceId, - group: input.group, - outputColumns: input.outputColumns, - autoRun: input.autoRun, - }, - 'request-1' - ) - return { table: updated, group: input.group } - } - 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: { - if (!useCase.execute) { - throw new Error(`Unexpected application operation ${useCase.operation.id}`) - } - return useCase.execute({ - principal: { - 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), - ...(input.tableId ? { resourceScope: { tableId: input.tableId } } : {}), - }, - input, - }) - } - } - } - ) }) function buildTable(overrides: Partial = {}): TableDefinition { @@ -849,11 +786,19 @@ describe('userTableServerTool workflow scope', () => { beforeEach(() => { vi.clearAllMocks() mockGetTableById.mockResolvedValue(buildTable()) - mockAddWorkflowGroup.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 () => { - mockExecuteCopilotWorkflowUseCase.mockRejectedValueOnce( + mockResolveWorkflowContext.mockRejectedValueOnce( new OrchestrationError('not_found', 'Workflow not found') ) @@ -870,19 +815,15 @@ describe('userTableServerTool workflow scope', () => { ) expect(result).toEqual({ success: false, message: 'Operation failed: Workflow not found' }) - expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.read' }) }), - { - workflowId: 'workflow-cross-workspace', - assertedWorkspaceId: 'workspace-1', - } - ) + expect(mockResolveWorkflowContext).toHaveBeenCalledWith({ + workflowId: 'workflow-cross-workspace', + assertedWorkspaceId: 'workspace-1', + }) expect(mockAddWorkflowGroup).not.toHaveBeenCalled() }) it('conceals unknown application failures from tool output', async () => { - mockExecuteCopilotTableUseCase.mockRejectedValueOnce(new Error('database host unavailable')) + mockQueryRows.mockRejectedValueOnce(new Error('database host unavailable')) const result = await userTableServerTool.execute( { operation: 'query_rows', args: { tableId: 'tbl_1' } }, @@ -934,7 +875,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 () => { @@ -1638,6 +1587,6 @@ describe('userTableServerTool.delete bounds', () => { 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 8811be332cc..813de67af65 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -1,27 +1,25 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' -import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' import { executeCopilotWorkflowUseCase } from '@/lib/copilot/application/execute-workflow-use-case' -import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' +import { + executeCopilotAddWorkflowTableGroupOutput, + executeCopilotCreateTableEnrichmentGroup, + executeCopilotCreateTableFromWorkspaceFile, + executeCopilotCreateWorkflowTableGroup, + executeCopilotImportWorkspaceFileIntoTable, + executeCopilotUpdateWorkflowTableGroup, +} from '@/lib/copilot/application/table-commands' +import { + messageForCopilotTableError, + resolveCopilotTablePrincipal, +} 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 { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' -import { - COLUMN_TYPES, - CSV_ASYNC_IMPORT_THRESHOLD_BYTES, - CSV_MAX_BATCH_SIZE, - type CsvHeaderMapping, - inferSchemaFromCsv, - parseFileRows, - sanitizeName, - TABLE_LIMITS, -} from '@/lib/table' +import { COLUMN_TYPES, CSV_MAX_BATCH_SIZE, type CsvHeaderMapping, TABLE_LIMITS } from '@/lib/table' import { addTableColumnUseCase, deleteTableColumnsUseCase, @@ -34,11 +32,8 @@ import { copilotUpdateRowsByFilter, } from '@/lib/table/application/copilot-bulk-rows' import { - addTableGroupOutputUseCase, - createTableGroupUseCase, deleteTableGroupOutputUseCase, deleteTableGroupUseCase, - updateTableGroupUseCase, } from '@/lib/table/application/groups' import { createTableRows, @@ -55,13 +50,7 @@ import { readTableUseCase, updateTableUseCase, } from '@/lib/table/application/tables' -import { - createTableFromWorkspaceFile, - importWorkspaceFileIntoTable, - type TableWorkspaceFileSource, -} from '@/lib/table/application/workspace-file-imports' import { namedRowMapper } from '@/lib/table/cell-format' -import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' import { isSupportedCurrencyCode } from '@/lib/table/currency' import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate' import { @@ -70,20 +59,14 @@ import { } from '@/lib/table/rows/secret-provenance' import { normalizeSelectOptionsInput } from '@/lib/table/select-options' import type { - ColumnDefinition, RowData, SortSpec, TablePredicateInput, TableSchema, - WorkflowGroup, WorkflowGroupDependencies, WorkflowGroupDeploymentMode, - WorkflowGroupInputMapping, - WorkflowGroupOutput, } from '@/lib/table/types' import { resolveWorkflowOutputs } from '@/lib/workflows/application/resolve-workflow-outputs' -import type { FlattenedBlockOutput } from '@/lib/workflows/blocks/flatten-outputs' -import { readSafeWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' const logger = createLogger('UserTableServerTool') @@ -99,48 +82,6 @@ type UserTableResult = { } const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE -const MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 - -async function resolveWorkspaceFileRecordOrThrow( - fileReference: string, - workspaceId: string, - context: ServerToolContext, - maxBytes?: number -): Promise<{ file: TableWorkspaceFileSource; content?: Buffer }> { - try { - const result = await executeCopilotFileUseCase(context, readSafeWorkspaceFileReference, { - workspaceId, - reference: fileReference, - maxBytes, - }) - return { file: result.file as TableWorkspaceFileSource, content: result.content } - } catch (error) { - if (asOrchestrationError(error)?.code !== 'not_found') throw error - // 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 OrchestrationError( - 'validation', - `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 OrchestrationError( - 'not_found', - `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` - ) - } -} - -/** - * 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 -} function resolveAuthorizedWorkflowOutputs( workflowId: string, @@ -153,27 +94,6 @@ function resolveAuthorizedWorkflowOutputs( }) } -/** - * 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.` -} - /** * Narrows a raw `deploymentMode` arg to the `'live' | 'deployed'` union, or * `undefined` when absent/invalid (leaving the group's existing value — which @@ -255,6 +175,7 @@ export const userTableServerTool: BaseServerTool const workspaceId = context.workspaceId const assertNotAborted = () => assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.') + const tablePrincipal = (tableId?: string) => resolveCopilotTablePrincipal(context, tableId) try { switch (operation) { @@ -270,11 +191,14 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const { table } = await executeCopilotTableUseCase(context, createTableUseCase, { - name: args.name, - description: args.description, - schema: normalizeSchemaSelectColumns(args.schema as TableSchema), - workspaceId, + const { table } = await createTableUseCase.execute({ + principal: tablePrincipal(), + input: { + name: args.name, + description: args.description, + schema: normalizeSchemaSelectColumns(args.schema as TableSchema), + workspaceId, + }, }) return { @@ -292,9 +216,9 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const { table } = await executeCopilotTableUseCase(context, readTableUseCase, { - tableId: args.tableId, - workspaceId, + const { table } = await readTableUseCase.execute({ + principal: tablePrincipal(args.tableId), + input: { tableId: args.tableId, workspaceId }, }) return { @@ -312,9 +236,9 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const { table } = await executeCopilotTableUseCase(context, readTableUseCase, { - tableId: args.tableId, - workspaceId, + const { table } = await readTableUseCase.execute({ + principal: tablePrincipal(args.tableId), + input: { tableId: args.tableId, workspaceId }, }) return { @@ -349,9 +273,9 @@ export const userTableServerTool: BaseServerTool for (const tableId of tableIds) { try { assertNotAborted() - await executeCopilotTableUseCase(context, deleteTableUseCase, { - tableId, - workspaceId, + await deleteTableUseCase.execute({ + principal: tablePrincipal(tableId), + input: { tableId, workspaceId }, }) deleted.push(tableId) } catch (error) { @@ -383,13 +307,16 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase(context, createTableRows, { - kind: 'single', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - data: args.data, - position: args.position as number | undefined, - secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), + const result = await createTableRows.execute({ + principal: tablePrincipal(args.tableId), + input: { + kind: 'single', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + data: args.data, + position: args.position as number | undefined, + secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), + }, }) if (result.kind !== 'single') throw new Error('Single row insert returned a batch') const { table, row } = result @@ -420,12 +347,15 @@ export const userTableServerTool: BaseServerTool assertNotAborted() const sourceRows = args.rows as RowData[] - const result = await executeCopilotTableUseCase(context, createTableRows, { - kind: 'batch', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rows: sourceRows, - secretProvenance: sourceRows.map(createExactEmptyTableRowSecretProvenance), + const result = await createTableRows.execute({ + principal: tablePrincipal(args.tableId), + input: { + kind: 'batch', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rows: sourceRows, + secretProvenance: sourceRows.map(createExactEmptyTableRowSecretProvenance), + }, }) if (result.kind !== 'batch') throw new Error('Batch row insert returned one row') const { table, rows } = result @@ -455,10 +385,13 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const { table: rowTable, row } = await executeCopilotTableUseCase(context, readTableRow, { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowId: args.rowId, + const { table: rowTable, row } = await readTableRow.execute({ + principal: tablePrincipal(args.tableId), + input: { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: args.rowId, + }, }) await importRowsForModel([row], context) @@ -488,16 +421,19 @@ export const userTableServerTool: BaseServerTool return { success: false, message: queryLimitError } } - const result = await executeCopilotTableUseCase(context, queryTableRows, { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - predicate: args.filter - ? normalizeTablePredicate(args.filter as TablePredicateInput) - : undefined, - sort: args.order as SortSpec | undefined, - limit: args.limit, - cursor: args.cursor, - includeTotal: !args.cursor, + const result = await queryTableRows.execute({ + principal: tablePrincipal(args.tableId), + input: { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + predicate: args.filter + ? normalizeTablePredicate(args.filter as TablePredicateInput) + : undefined, + sort: args.order as SortSpec | undefined, + limit: args.limit, + cursor: args.cursor, + includeTotal: !args.cursor, + }, }) const { table } = result const toNamedRow = namedRowMapper(table.schema.columns) @@ -538,17 +474,16 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const { table, row: updatedRow } = await executeCopilotTableUseCase( - context, - updateTableRow, - { + const { table, row: updatedRow } = await updateTableRow.execute({ + principal: tablePrincipal(args.tableId), + input: { tableId: args.tableId, assertedWorkspaceId: workspaceId, rowId: args.rowId, data: args.data, secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), - } - ) + }, + }) const toNamedRow = namedRowMapper(table.schema.columns) await importRowsForModel([updatedRow], context) @@ -576,10 +511,13 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - await executeCopilotTableUseCase(context, deleteTableRow, { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowId: args.rowId, + await deleteTableRow.execute({ + principal: tablePrincipal(args.tableId), + input: { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: args.rowId, + }, }) return { @@ -607,12 +545,15 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase(context, copilotUpdateRowsByFilter, { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - filter: normalizeTablePredicate(args.filter as TablePredicateInput), - data: args.data as RowData, - limit: args.limit, + const result = await copilotUpdateRowsByFilter.execute({ + principal: tablePrincipal(args.tableId), + input: { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + filter: normalizeTablePredicate(args.filter as TablePredicateInput), + data: args.data as RowData, + limit: args.limit, + }, }) if (result.kind === 'background') { return { @@ -645,11 +586,14 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase(context, copilotDeleteRowsByFilter, { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - filter: normalizeTablePredicate(args.filter as TablePredicateInput), - limit: args.limit, + const result = await copilotDeleteRowsByFilter.execute({ + principal: tablePrincipal(args.tableId), + input: { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + filter: normalizeTablePredicate(args.filter as TablePredicateInput), + limit: args.limit, + }, }) if (result.kind === 'background') { return { @@ -708,10 +652,13 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase(context, copilotBatchUpdateRows, { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - updates: updates as Array<{ rowId: string; data: RowData }>, + const result = await copilotBatchUpdateRows.execute({ + principal: tablePrincipal(args.tableId), + input: { + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + updates: updates as Array<{ rowId: string; data: RowData }>, + }, }) return { @@ -742,11 +689,14 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase(context, deleteTableRows, { - kind: 'ids', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowIds, + const result = await deleteTableRows.execute({ + principal: tablePrincipal(args.tableId), + input: { + kind: 'ids', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowIds, + }, }) if (result.kind !== 'ids') throw new Error('Row ID deletion returned a filter result') @@ -775,30 +725,19 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const { file: record } = await resolveWorkspaceFileRecordOrThrow( - fileReference, + assertNotAborted() + const result = await executeCopilotCreateTableFromWorkspaceFile(context, { workspaceId, - context - ) - const tableName = - args.name || - sanitizeName(record.name.replace(/\.[^.]+$/, ''), 'imported_table').slice( - 0, - TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - ) - const description = args.description || `Imported from ${record.name}` - if (shouldImportInBackground(record)) { - assertNotAborted() - const result = await executeCopilotTableUseCase(context, createTableFromWorkspaceFile, { - kind: 'background', - workspaceId, - sourceFile: record, - name: tableName, - description, - }) - if (result.kind !== 'background') { - throw new Error('Background table import returned an inline result') - } + 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 "${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.`, @@ -811,35 +750,7 @@ export const userTableServerTool: BaseServerTool } } - const { content } = await resolveWorkspaceFileRecordOrThrow( - fileReference, - workspaceId, - context, - MAX_INLINE_FILE_BYTES - ) - if (!content) throw new Error('Workspace file content was not loaded') - const { headers, rows } = await parseFileRows(content, record.name, record.type) - if (rows.length === 0) { - return { success: false, message: 'File contains no data rows' } - } - - const { columns, headerToColumn } = inferSchemaFromCsv(headers, rows) - assertNotAborted() - const result = await executeCopilotTableUseCase(context, createTableFromWorkspaceFile, { - kind: 'inline', - workspaceId, - sourceFile: record, - name: tableName, - description, - columns, - headerToColumn, - rows, - assertNotAborted, - }) - if (result.kind !== 'inline') { - throw new Error('Inline table import returned a background result') - } - const createdMessage = `Created table "${result.table.name}" with ${columns.length} columns and ${result.insertedCount.toLocaleString()} rows from "${record.name}"` + const createdMessage = `Created table "${result.table.name}" with ${result.columns.length} columns and ${result.insertedCount.toLocaleString()} rows from "${record.name}"` const message = result.droppedRows > 0 ? `${createdMessage}. Dropped ${result.droppedRows.toLocaleString()} row(s) that exceed this plan's limit of ${result.maxRowsPerTable.toLocaleString()} rows per table.` @@ -851,7 +762,10 @@ export const userTableServerTool: BaseServerTool data: { tableId: result.table.id, tableName: result.table.name, - columns: columns.map((c) => ({ name: c.name, type: c.type })), + columns: result.columns.map((column) => ({ + name: column.name, + type: column.type, + })), rowCount: result.insertedCount, sourceFile: record.name, }, @@ -888,52 +802,22 @@ export const userTableServerTool: BaseServerTool } const mode: 'append' | 'replace' = rawMode === 'replace' ? 'replace' : 'append' - await executeCopilotTableUseCase(context, readTableUseCase, { tableId, workspaceId }) - const { file: record } = await resolveWorkspaceFileRecordOrThrow( - fileReference, - workspaceId, - context - ) - if (shouldImportInBackground(record)) { - assertNotAborted() - const result = await executeCopilotTableUseCase(context, importWorkspaceFileIntoTable, { - kind: 'background', - tableId, - assertedWorkspaceId: workspaceId, - sourceFile: record, - mode, - mapping: rawMapping, - }) - if (result.kind !== 'background') { - throw new Error('Background table import returned an inline result') - } - return { - success: true, - message: `Started background ${mode} import of "${record.name}" 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 }, - } - } - assertNotAborted() - const result = await executeCopilotTableUseCase(context, importWorkspaceFileIntoTable, { - kind: 'inline', + const result = await executeCopilotImportWorkspaceFileIntoTable(context, { tableId, assertedWorkspaceId: workspaceId, - sourceFile: record, + fileReference, mode, mapping: rawMapping, assertNotAborted, - loadRows: async () => { - const { content } = await resolveWorkspaceFileRecordOrThrow( - fileReference, - workspaceId, - context, - MAX_INLINE_FILE_BYTES - ) - if (!content) throw new Error('Workspace file content was not loaded') - return parseFileRows(content, record.name, record.type) - }, }) + if (result.kind === 'background') { + return { + success: true, + 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 }, + } + } if (result.kind === 'empty') { return { success: false, message: 'File contains no data rows' } } @@ -942,7 +826,7 @@ export const userTableServerTool: BaseServerTool if (result.mode === 'replace') { return { success: true, - message: `Replaced rows in "${result.table.name}" from "${record.name}": deleted ${result.deletedCount}, inserted ${result.insertedCount}`, + message: `Replaced rows in "${result.table.name}" from "${result.sourceFileName}": deleted ${result.deletedCount}, inserted ${result.insertedCount}`, data: { tableId: result.table.id, tableName: result.table.name, @@ -951,13 +835,13 @@ export const userTableServerTool: BaseServerTool skippedColumns: result.skippedColumns, deletedCount: result.deletedCount, insertedCount: result.insertedCount, - sourceFile: record.name, + sourceFile: result.sourceFileName, }, } } return { success: true, - message: `Imported ${result.insertedCount} rows into "${result.table.name}" from "${record.name}" (${result.matchedColumns.length} columns matched)`, + 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, @@ -965,7 +849,7 @@ export const userTableServerTool: BaseServerTool matchedColumns: result.matchedColumns, skippedColumns: result.skippedColumns, rowCount: result.insertedCount, - sourceFile: record.name, + sourceFile: result.sourceFileName, }, } } @@ -1006,11 +890,10 @@ export const userTableServerTool: BaseServerTool col.type === 'select' ? { ...col, options: normalizeSelectOptionsInput(col.options) } : { ...col, options: undefined } - const { table: updated } = await executeCopilotTableUseCase( - context, - addTableColumnUseCase, - { tableId: args.tableId, workspaceId, column: columnToAdd } - ) + const { table: updated } = await addTableColumnUseCase.execute({ + principal: tablePrincipal(args.tableId), + input: { tableId: args.tableId, workspaceId, column: columnToAdd }, + }) return { success: true, message: `Added column "${col.name}" (${col.type}) to table`, @@ -1031,16 +914,15 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'columnName and newName are required' } } assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - updateTableColumnUseCase, - { + const { table: updated } = await updateTableColumnUseCase.execute({ + principal: tablePrincipal(args.tableId), + input: { tableId: args.tableId, workspaceId, columnName: colName, updates: { name: newColName }, - } - ) + }, + }) return { success: true, message: `Renamed column "${colName}" to "${newColName}"`, @@ -1063,11 +945,10 @@ export const userTableServerTool: BaseServerTool } if (names.length === 1) { assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - deleteTableColumnUseCase, - { tableId: args.tableId, workspaceId, columnName: names[0] } - ) + const { table: updated } = await deleteTableColumnUseCase.execute({ + principal: tablePrincipal(args.tableId), + input: { tableId: args.tableId, workspaceId, columnName: names[0] }, + }) return { success: true, message: `Deleted column "${names[0]}"`, @@ -1075,11 +956,10 @@ export const userTableServerTool: BaseServerTool } } assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - deleteTableColumnsUseCase, - { tableId: args.tableId, workspaceId, columnNames: names } - ) + const { table: updated } = await deleteTableColumnsUseCase.execute({ + principal: tablePrincipal(args.tableId), + input: { tableId: args.tableId, workspaceId, columnNames: names }, + }) return { success: true, message: `Deleted ${names.length} columns: ${names.join(', ')}`, @@ -1129,10 +1009,9 @@ export const userTableServerTool: BaseServerTool } } assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - updateTableColumnUseCase, - { + const { table: updated } = await updateTableColumnUseCase.execute({ + principal: tablePrincipal(args.tableId), + input: { tableId: args.tableId, workspaceId, columnName: colName, @@ -1145,8 +1024,8 @@ export const userTableServerTool: BaseServerTool ...(multiple !== undefined ? { multiple } : {}), ...(currencyCode !== undefined ? { currencyCode } : {}), }, - } - ) + }, + }) return { success: true, message: `Updated column "${colName}"`, @@ -1166,10 +1045,9 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await executeCopilotTableUseCase(context, updateTableUseCase, { - tableId: args.tableId, - workspaceId, - name: newName, + const result = await updateTableUseCase.execute({ + principal: tablePrincipal(args.tableId), + input: { tableId: args.tableId, workspaceId, name: newName }, }) if (result.failure) { throw result.failure @@ -1231,12 +1109,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 } - ) - for (const o of rawOutputs) { if (!o.blockId || !o.path) { return { @@ -1246,77 +1118,26 @@ export const userTableServerTool: BaseServerTool } } - const resolvedWorkflow = await resolveAuthorizedWorkflowOutputs( - workflowId, - workspaceId, - context - ) - const flattened = resolvedWorkflow.outputs - 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, - resolvedWorkflow, - } - ) + 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, }, } @@ -1329,74 +1150,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 } - ) - const updateOutputs = args.outputs as WorkflowGroupOutput[] | undefined + 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 - const existingGroup = tableForUpdate.schema.workflowGroups?.find((g) => g.id === groupId) - const targetWorkflowId = explicitWorkflowId ?? existingGroup?.workflowId - const workflowMetadataRequired = - explicitWorkflowId !== undefined || - updateOutputs !== undefined || - (mappingUpdates?.length ?? 0) > 0 - let resolvedWorkflow: - | Awaited> - | undefined - if (workflowMetadataRequired) { - if (!targetWorkflowId) { - return { - success: false, - message: `Cannot validate outputs — workflow group ${groupId} not found and no workflowId provided`, - } - } - resolvedWorkflow = await resolveAuthorizedWorkflowOutputs( - targetWorkflowId, - workspaceId, - context - ) - const flattened = resolvedWorkflow.outputs - if ((updateOutputs?.length ?? 0) > 0 && !flattened) { - return { - success: false, - message: `Workflow not found or has no blocks: ${targetWorkflowId}`, - } - } - if (updateOutputs && updateOutputs.length > 0 && flattened) { - const validationError = validateOutputsAgainstWorkflow( - updateOutputs.map((o) => ({ blockId: o.blockId, path: o.path })), - flattened, - targetWorkflowId - ) - if (validationError) { - return { success: false, message: validationError } - } - } - } assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - updateTableGroupUseCase, - { - tableId: args.tableId, - workspaceId, - groupId, - workflowId: explicitWorkflowId, - name: args.name as string | undefined, - dependencies: args.dependencies as WorkflowGroupDependencies | undefined, - outputs: updateOutputs, - newOutputColumns: args.newOutputColumns as ColumnDefinition[] | undefined, - mappingUpdates, - resolvedWorkflow, - deploymentMode: parseDeploymentMode(args.deploymentMode), - autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, - } - ) + 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}`, @@ -1412,11 +1190,10 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'groupId is required for delete_workflow_group' } } assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - deleteTableGroupUseCase, - { tableId: args.tableId, workspaceId, groupId } - ) + const { table: updated } = await deleteTableGroupUseCase.execute({ + principal: tablePrincipal(args.tableId), + input: { tableId: args.tableId, workspaceId, groupId }, + }) return { success: true, message: `Deleted workflow group ${groupId}`, @@ -1437,36 +1214,15 @@ export const userTableServerTool: BaseServerTool message: 'groupId, blockId, and path are required for add_workflow_group_output', } } - const { table: tableForAdd } = await executeCopilotTableUseCase( - context, - readTableUseCase, - { tableId: args.tableId, workspaceId } - ) - const workflowId = tableForAdd.schema.workflowGroups?.find( - (candidate) => candidate.id === groupId - )?.workflowId - if (!workflowId) { - return { success: false, message: `Workflow group not found: ${groupId}` } - } - const resolvedWorkflow = await resolveAuthorizedWorkflowOutputs( - workflowId, - workspaceId, - context - ) assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - addTableGroupOutputUseCase, - { - tableId: args.tableId, - workspaceId, - groupId, - blockId, - path, - columnName, - resolvedWorkflow, - } - ) + const { table: updated } = await executeCopilotAddWorkflowTableGroupOutput(context, { + tableId: args.tableId, + workspaceId, + groupId, + blockId, + path, + columnName, + }) return { success: true, message: `Added output to workflow group ${groupId}`, @@ -1486,11 +1242,10 @@ export const userTableServerTool: BaseServerTool } } assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - deleteTableGroupOutputUseCase, - { tableId: args.tableId, groupId, columnName, workspaceId } - ) + const { table: updated } = await deleteTableGroupOutputUseCase.execute({ + principal: tablePrincipal(args.tableId), + input: { tableId: args.tableId, groupId, columnName, workspaceId }, + }) return { success: true, message: `Removed output "${columnName}" from workflow group ${groupId}`, @@ -1536,13 +1291,16 @@ export const userTableServerTool: BaseServerTool rowIds = rawRowIds as string[] } assertNotAborted() - const { dispatchId } = await executeCopilotTableUseCase(context, startTableRun, { - kind: 'selection', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - groupIds, - mode: runMode, - rowIds, + const { dispatchId } = await startTableRun.execute({ + principal: tablePrincipal(args.tableId), + input: { + kind: 'selection', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + groupIds, + mode: runMode, + rowIds, + }, }) const scopeLabel = rowIds ? `${rowIds.length} row(s) by id` : runMode return { @@ -1567,22 +1325,22 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'rowId is required when scope is "row"' } } assertNotAborted() - const { cancelled } = await executeCopilotTableUseCase( - context, - cancelTableRuns, - scope === 'row' - ? { - scope: 'row', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowId: rowId as string, - } - : { - scope: 'all', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - } - ) + const { cancelled } = await cancelTableRuns.execute({ + principal: tablePrincipal(args.tableId), + input: + scope === 'row' + ? { + scope: 'row', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: rowId as string, + } + : { + scope: 'all', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + }, + }) return { success: true, message: `Cancelled ${cancelled} run(s)`, @@ -1618,105 +1376,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 { table: tableForEnrichment } = await executeCopilotTableUseCase( - context, - readTableUseCase, - { tableId: args.tableId, workspaceId } - ) - - // 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, - } assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( + const { table: updated, group } = await executeCopilotCreateTableEnrichmentGroup( context, - createTableGroupUseCase, - { tableId: args.tableId, workspaceId, group, outputColumns, autoRun } + { + 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, + } ) 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/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts index 7f0866fa53b..1e83c247d58 100644 --- a/apps/sim/lib/table/application/groups.test.ts +++ b/apps/sim/lib/table/application/groups.test.ts @@ -10,9 +10,11 @@ const mocks = vi.hoisted(() => ({ addOutput: vi.fn(), audit: vi.fn(), deleteOutput: vi.fn(), + getEnrichment: vi.fn(), + loadWorkflowOutputs: vi.fn(), resolveContext: vi.fn(), resolvePermission: vi.fn(), - resolveWorkflow: vi.fn(), + resolveWorkflowContext: vi.fn(), signal: vi.fn(), updateGroup: vi.fn(), })) @@ -22,7 +24,6 @@ vi.mock('@sim/audit', () => ({ 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 @@ -32,16 +33,24 @@ vi.mock('@sim/platform-authz/workspace', () => ({ }, 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/application/runs', () => ({ startTableRun: { execute: vi.fn() } })) -vi.mock('@/lib/table/column-naming', () => ({ columnTypeForLeaf: () => 'string' })) +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, @@ -49,21 +58,25 @@ vi.mock('@/lib/table/workflow-groups/service', () => ({ deleteWorkflowGroupOutput: mocks.deleteOutput, updateWorkflowGroup: mocks.updateGroup, })) +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ - resolveWorkflowOutputs: { execute: mocks.resolveWorkflow }, + loadResolvedWorkflowOutputs: mocks.loadWorkflowOutputs, })) import { - addTableGroupOutputUseCase, - createTableGroupUseCase, + addWorkflowTableGroupOutput, + createTableEnrichmentGroup, + 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: 'result' }], + outputs: [{ blockId: 'block-1', path: 'content', columnName: 'column-result' }], } const table: TableDefinition = { id: 'table-1', @@ -71,13 +84,8 @@ const table: TableDefinition = { description: null, schema: { columns: [ - { id: 'column-1', name: 'name', type: 'string' }, - { - id: 'column-2', - name: 'result', - type: 'string', - workflowGroupId: 'group-1', - }, + { id: 'column-name', name: 'name', type: 'string' }, + { id: 'column-result', name: 'result', type: 'string', workflowGroupId: 'group-1' }, ], workflowGroups: [group], }, @@ -92,7 +100,7 @@ const table: TableDefinition = { } const principal = { kind: 'delegated' as const, - serviceId: 'copilot', + serviceId: 'copilot' as const, subjectUserId: 'user-1', workspaceId: 'workspace-1', delegationId: 'copilot-tool:tool-1', @@ -113,8 +121,8 @@ const resolvedWorkflow = { }, { blockId: 'block-2', - blockName: 'Agent 2', - blockType: 'agent', + blockName: 'Scorer', + blockType: 'function', path: 'score', leafType: 'number', }, @@ -122,7 +130,15 @@ const resolvedWorkflow = { executionOrderByBlockId: { 'block-1': 1, 'block-2': 2 }, } -describe('table group application use cases', () => { +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') @@ -134,103 +150,206 @@ describe('table group application use cases', () => { allowPersonalApiKeys: true, billedAccountUserId: 'billing-owner-1', }) - mocks.addGroup.mockResolvedValue(table) + 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.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('lets the Workflow application policy reject missing delegated metadata before mutation', async () => { - mocks.resolveWorkflow.mockRejectedValue( - new Error('Delegated workspace access is no longer valid') + 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', + 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('conceals a cross-workspace workflow before group mutation or effects', async () => { + mocks.resolveWorkflowContext.mockRejectedValueOnce( + Object.assign(new Error('Workflow not found'), { code: 'not_found' }) ) await expect( - createTableGroupUseCase.execute({ + createWorkflowTableGroup.execute({ principal, input: { - tableId: 'table-1', - workspaceId: 'workspace-1', - group, - outputColumns: [{ name: 'result', type: 'string', workflowGroupId: 'group-1' }], + tableId: table.id, + workspaceId: table.workspaceId, + workflowId: 'workflow-other', + outputs: [{ blockId: 'block-2', path: 'score' }], }, }) - ).rejects.toThrow('Delegated workspace access is no longer valid') + ).rejects.toMatchObject({ code: 'not_found' }) - expect(mocks.resolveWorkflow).toHaveBeenCalledWith({ - principal, - input: { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-1' }, + 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('accepts only Workflow-authorized metadata for delegated group creation', async () => { - await createTableGroupUseCase.execute({ + 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('constructs new columns while preserving existing bindings during restructure', async () => { + await updateWorkflowTableGroup.execute({ principal, input: { - tableId: 'table-1', - workspaceId: 'workspace-1', - group, - outputColumns: [{ name: 'result', type: 'string', workflowGroupId: 'group-1' }], - resolvedWorkflow, + 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.addGroup).toHaveBeenCalledTimes(1) + 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('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( - createTableGroupUseCase.execute({ + updateWorkflowTableGroup.execute({ principal, input: { - tableId: 'table-1', - workspaceId: 'workspace-1', - group: { ...group, workflowId: 'workflow-cross-workspace' }, - outputColumns: [{ name: 'result', type: 'string' }], - resolvedWorkflow, + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, + mappingUpdates: [{ columnName: 'column-result', blockId: 'block-2', path: 'score' }], }, }) - ).rejects.toMatchObject({ code: 'not_found' }) - expect(mocks.addGroup).toHaveBeenCalledTimes(1) + ).rejects.toBe(conflict) + + expect(mocks.audit).not.toHaveBeenCalled() + expect(mocks.signal).not.toHaveBeenCalled() }) - it('prevents mismatched workflow metadata from being persisted on output add', async () => { - await expect( - addTableGroupOutputUseCase.execute({ - principal, - input: { - tableId: 'table-1', - workspaceId: 'workspace-1', - groupId: 'group-1', - blockId: 'block-2', - path: 'score', - resolvedWorkflow: { ...resolvedWorkflow, workflowId: 'workflow-cross-workspace' }, - }, - }) - ).rejects.toMatchObject({ code: 'not_found' }) - expect(mocks.addOutput).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 canonical mutation', async () => { - await addTableGroupOutputUseCase.execute({ + it('passes authorized output type and ordering to the add-output mutation', async () => { + await addWorkflowTableGroupOutput.execute({ principal, input: { - tableId: 'table-1', - workspaceId: 'workspace-1', - groupId: 'group-1', + tableId: table.id, + workspaceId: table.workspaceId, + groupId: group.id, blockId: 'block-2', path: 'score', - resolvedWorkflow, }, }) expect(mocks.addOutput).toHaveBeenCalledWith( expect.objectContaining({ - tableId: 'table-1', - workspaceId: 'workspace-1', resolvedOutput: expect.objectContaining({ workflowId: 'workflow-1', - columnType: 'string', + columnType: 'number', order: expect.arrayContaining([ expect.objectContaining({ blockId: 'block-2', executionDistance: 2 }), ]), @@ -238,68 +357,74 @@ describe('table group application use cases', () => { }), 'request-1' ) - }) - - it('deletes an output through the canonical mutation with audit and schema effects', async () => { - await deleteTableGroupOutputUseCase.execute({ - principal, - input: { - tableId: 'table-1', - workspaceId: 'workspace-1', - groupId: 'group-1', - columnName: 'result', - }, - }) - - expect(mocks.deleteOutput).toHaveBeenCalledWith( - { - tableId: 'table-1', - workspaceId: 'workspace-1', - groupId: 'group-1', - columnName: 'result', - }, - 'request-1' - ) expect(mocks.audit).toHaveBeenCalledTimes(1) - expect(mocks.signal).toHaveBeenCalledWith('table-1') + expect(mocks.signal).toHaveBeenCalledWith(table.id) }) - it('validates mapping updates against authorized output metadata before mutation', async () => { + it('validates enrichment mappings before constructing the group', async () => { await expect( - updateTableGroupUseCase.execute({ + createTableEnrichmentGroup.execute({ principal, input: { - tableId: 'table-1', - workspaceId: 'workspace-1', - groupId: 'group-1', - mappingUpdates: [{ columnName: 'result', blockId: 'missing', path: 'value' }], - resolvedWorkflow, + tableId: table.id, + workspaceId: table.workspaceId, + enrichmentId: 'company-domain', }, }) ).rejects.toMatchObject({ code: 'validation' }) - expect(mocks.updateGroup).not.toHaveBeenCalled() - }) + expect(mocks.addGroup).not.toHaveBeenCalled() - it('passes authorized mapping types to the canonical group update', async () => { - await updateTableGroupUseCase.execute({ + const result = await createTableEnrichmentGroup.execute({ principal, input: { - tableId: 'table-1', - workspaceId: 'workspace-1', - groupId: 'group-1', - mappingUpdates: [{ columnName: 'result', blockId: 'block-2', path: 'score' }], - resolvedWorkflow, + tableId: table.id, + workspaceId: table.workspaceId, + enrichmentId: 'company-domain', + inputMappings: [{ inputName: 'company', columnName: 'name' }], }, }) - expect(mocks.updateGroup).toHaveBeenCalledWith( + expect(mocks.addGroup).toHaveBeenCalledWith( expect.objectContaining({ - resolvedMappingTypes: { - workflowId: 'workflow-1', - columns: [{ columnName: 'result', type: 'string' }], - }, + 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 46213a161ce..81c3e78651b 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import type { V2AddWorkflowGroupBody } from '@/lib/api/contracts/v2/tables' @@ -7,18 +7,23 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import type { + ColumnDefinition, DeleteWorkflowGroupData, TableDefinition, TableSchema, UpdateWorkflowGroupData, WorkflowGroup, + WorkflowGroupDependencies, + WorkflowGroupDeploymentMode, + WorkflowGroupInputMapping, + 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 } from '@/lib/table/column-naming' +import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' import { signalTableSchemaChanged } from '@/lib/table/events' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' import { addWorkflowGroup, addWorkflowGroupOutput, @@ -26,8 +31,10 @@ import { 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 { resolveWorkflowOutputs } 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') @@ -46,21 +53,92 @@ function groupFromTable(table: TableDefinition, groupId: string): WorkflowGroup return group } -async function resolveAuthorizedWorkflowForTableGroup( - principal: Principal, +async function resolveWorkflowForAuthorizedTableCommand( workflowId: string, - workspaceId: string, - provided?: ResolveWorkflowOutputsResult + workspaceId: string ): Promise { - if (provided) { - if (provided.workflowId !== workflowId) { - throw new OrchestrationError('not_found', 'Workflow not found') - } - return provided + const workflowContext = await resolveActiveWorkflowApplicationContext({ + workflowId, + assertedWorkspaceId: workspaceId, + }) + return loadResolvedWorkflowOutputs(workflowContext) +} + +function requireWorkflowOutputs( + resolved: ResolveWorkflowOutputsResult, + workflowId: string +): NonNullable { + if (!resolved.outputs) { + throw new OrchestrationError('validation', `Workflow has no pickable outputs: ${workflowId}`) } - return resolveWorkflowOutputs.execute({ - principal, - input: { workflowId, assertedWorkspaceId: workspaceId }, + return resolved.outputs +} + +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, + }) }) } @@ -80,7 +158,6 @@ export interface CreateTableGroupInput extends TableGroupInput { group: V2AddWorkflowGroupBody['group'] outputColumns: V2AddWorkflowGroupBody['outputColumns'] autoRun?: boolean - resolvedWorkflow?: ResolveWorkflowOutputsResult } export const createTableGroupUseCase = defineAuthorizedTableUseCase({ @@ -92,12 +169,7 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ }), async execute({ principal, input, context }) { if (input.group.workflowId) { - await resolveAuthorizedWorkflowForTableGroup( - principal, - input.group.workflowId, - context.workspaceId, - input.resolvedWorkflow - ) + await resolveWorkflowForAuthorizedTableCommand(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)) @@ -108,9 +180,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( { @@ -123,11 +193,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 { @@ -139,25 +209,281 @@ 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 }) { + 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 } : {}), + 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 }) { + 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', }) } }, @@ -168,9 +494,7 @@ export interface UpdateTableGroupInput Omit< UpdateWorkflowGroupData, 'tableId' | 'workspaceId' | 'actorUserId' | 'suppressAutoRunDispatch' - > { - resolvedWorkflow?: ResolveWorkflowOutputsResult -} + > {} export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ operation: tableOperations.updateGroup, @@ -193,16 +517,15 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ if (!targetWorkflowId) { throw new OrchestrationError('not_found', 'Workflow not found') } - resolvedWorkflow = await resolveAuthorizedWorkflowForTableGroup( - principal, + resolvedWorkflow = await resolveWorkflowForAuthorizedTableCommand( targetWorkflowId, - context.workspaceId, - input.resolvedWorkflow + context.workspaceId ) + if (input.outputs && input.outputs.length > 0) { + validateRequestedOutputs(input.outputs, resolvedWorkflow, targetWorkflowId) + } } - const attribution = resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, - }) + 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') @@ -231,7 +554,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ 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 } : {}), @@ -262,6 +585,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 }) { @@ -275,25 +599,190 @@ 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 }) { + 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 taken = new Set(context.table.schema.columns.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', }) } }, @@ -341,10 +830,9 @@ export interface AddTableGroupOutputInput extends TableGroupInput { blockId: string path: string columnName?: string - resolvedWorkflow: ResolveWorkflowOutputsResult } -export const addTableGroupOutputUseCase = defineAuthorizedTableUseCase({ +export const addWorkflowTableGroupOutput = defineAuthorizedTableUseCase({ operation: tableOperations.updateGroup, resolveContext: ({ input }: { input: AddTableGroupOutputInput }) => resolveActiveTableContext({ @@ -357,13 +845,11 @@ export const addTableGroupOutputUseCase = defineAuthorizedTableUseCase({ ) if (!group) throw new OrchestrationError('not_found', `Workflow group "${input.groupId}" not found`) - if (group.workflowId !== input.resolvedWorkflow.workflowId) { - throw new OrchestrationError('not_found', 'Workflow not found') - } - const outputs = input.resolvedWorkflow.outputs - if (!outputs) { - throw new OrchestrationError('validation', 'Workflow has no pickable outputs') - } + 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 ) @@ -385,10 +871,10 @@ export const addTableGroupOutputUseCase = defineAuthorizedTableUseCase({ workspaceBillingOwnerUserId: context.billedAccountUserId, }).attributedUserId, resolvedOutput: { - workflowId: input.resolvedWorkflow.workflowId, + workflowId: resolvedWorkflow.workflowId, columnType: columnTypeForLeaf(output.leafType), order: outputs.map((candidate, discoveryIndex) => { - const distance = input.resolvedWorkflow.executionOrderByBlockId[candidate.blockId] + const distance = resolvedWorkflow.executionOrderByBlockId[candidate.blockId] return { blockId: candidate.blockId, path: candidate.path, diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index f68e5dca152..dac2b72a411 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ findResource: vi.fn(), getResource: vi.fn(), getUpload: vi.fn(), + getWorkspaceFile: vi.fn(), resolvePermission: vi.fn(), resolveTableContext: vi.fn(), resolveWorkspaceContext: vi.fn(), @@ -63,8 +64,8 @@ vi.mock('@/lib/uploads/upload-session/service', () => ({ createUploadPartUrls: mocks.createParts, })) -vi.mock('@/lib/workspace-files/application/read-workspace-file-record', () => ({ - readWorkspaceFileContentRecord: { execute: vi.fn() }, +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getWorkspaceFile, })) import { @@ -109,6 +110,12 @@ const upload = { 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(() => { @@ -140,6 +147,7 @@ describe('table import application use cases', () => { ) 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 () => { @@ -195,6 +203,43 @@ describe('table import application use cases', () => { ) }) + 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, diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index 27e005198c2..e2439874afd 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -29,6 +29,10 @@ import { type TableImportResource, tableImportBodyFromUpload, } 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, @@ -36,7 +40,6 @@ 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') @@ -147,6 +150,15 @@ 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 }) => @@ -158,15 +170,10 @@ export const createTableImportUseCase = defineAuthorizedTableUseCase({ 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') diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index 55db224860d..3b3f616d245 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -8,21 +8,29 @@ import type { TableDefinition } from '@/lib/table/types' const { mockReplaceRowsPrimitive, mockDeleteRowsByIds, + mockAssertRowCapacity, + mockNotifyTableRowUsage, mockQueryRows, mockRecordAudit, + mockReplaceRowsWithTx, mockResolveContext, mockResolvePermission, mockSignalRowsChanged, mockUpsertRow, + mockWithLockedTable, } = vi.hoisted(() => ({ mockReplaceRowsPrimitive: vi.fn(), mockDeleteRowsByIds: 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 +77,24 @@ 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: {} }), +})) + +vi.mock('@/lib/table/rows/service', () => ({ + replaceTableRowsWithTx: mockReplaceRowsWithTx, })) vi.mock('@/lib/table/application/context', () => ({ @@ -81,7 +107,9 @@ vi.mock('@/lib/table/events', () => ({ import { deleteTableRows, + ProjectedWireRowsValidationError, queryTableRows, + replaceProjectedWireRows, replaceTableRows, TableRowsValidationError, tablePredicateNamesToFilter, @@ -118,6 +146,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() diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 999b7bd133f..f7c51b6bb0d 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 { 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,9 @@ import { validateStoragePredicate, } from '@/lib/table/query-builder/validate' import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' +import { createExactEmptyTableRowSecretProvenance } 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 { @@ -191,7 +198,9 @@ export const queryTableRows = defineAuthorizedTableUseCase({ async execute({ input, context }): Promise { try { if (input.limit !== undefined) { - requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_QUERY_LIMIT, 'Limit') + if (!Number.isSafeInteger(input.limit) || input.limit < 1) { + throw new TableRowsValidationError('Limit must be 1 or greater') + } } let predicate = input.predicate if (predicate) { @@ -428,6 +437,132 @@ 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 diff --git a/apps/sim/lib/table/application/workspace-file-imports.test.ts b/apps/sim/lib/table/application/workspace-file-imports.test.ts index 86721b0ebd9..40a1092e8cb 100644 --- a/apps/sim/lib/table/application/workspace-file-imports.test.ts +++ b/apps/sim/lib/table/application/workspace-file-imports.test.ts @@ -10,11 +10,19 @@ const mocks = vi.hoisted(() => ({ 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(), - resolveTableContext: 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 {}, @@ -36,15 +44,20 @@ vi.mock('@sim/platform-authz/workspace', () => ({ })) 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: vi.fn() })) +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 })), - replaceTableRows: vi.fn(), + 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', () => ({ @@ -64,6 +77,14 @@ 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, @@ -74,7 +95,7 @@ const table: TableDefinition = { id: 'table-1', name: 'People', description: 'Imported', - schema: { columns: [{ name: 'name', type: 'string' }] }, + schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, metadata: null, rowCount: 0, maxRows: 100, @@ -84,9 +105,17 @@ const table: TableDefinition = { 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', + serviceId: 'copilot' as const, subjectUserId: 'user-1', workspaceId: 'workspace-1', delegationId: 'copilot-tool:tool-1', @@ -95,25 +124,8 @@ const principal = { expiresAt: new Date('2099-08-01T00:00:00.000Z'), resourceScope: { tableId: 'table-1' }, } -const input = { - kind: 'inline' as const, - workspaceId: 'workspace-1', - sourceFile: { - id: 'file-1', - workspaceId: 'workspace-1', - key: 'workspace/workspace-1/people.csv', - name: 'people.csv', - type: 'text/csv', - size: 128, - }, - name: 'People', - description: 'Imported', - columns: [{ name: 'name', type: 'string' as const }], - headerToColumn: new Map([['name', 'name']]), - rows: [{ name: 'Ada' }], -} -describe('Copilot workspace-file table creation', () => { +describe('workspace-file Table application commands', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolvePermission.mockResolvedValue('write') @@ -131,9 +143,21 @@ describe('Copilot workspace-file table creation', () => { 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.mockResolvedValue([{ id: 'row-1' }]) + 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({ @@ -143,21 +167,44 @@ describe('Copilot workspace-file table creation', () => { }) }) - it('owns table creation, row insertion, audit, and shared effects', async () => { - await expect(createTableFromWorkspaceFile.execute({ principal, input })).resolves.toMatchObject( - { kind: 'inline', insertedCount: 1, table } - ) + 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-1') + expect(mocks.signal).toHaveBeenCalledWith(table.id) }) - it('rejects HTTP-capable workspace keys before canonical loading or mutation', async () => { + 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: { @@ -165,20 +212,54 @@ describe('Copilot workspace-file table creation', () => { workspaceId: 'workspace-1', keyId: 'workspace-key-1', } as never, - input, + input: { workspaceId: 'workspace-1', fileReference: 'files/people.csv' }, }) ).rejects.toMatchObject({ code: 'forbidden' }) expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() - expect(mocks.createTable).not.toHaveBeenCalled() + expect(mocks.resolveFile).not.toHaveBeenCalled() }) - it('holds the table job claim across inline file loading and mutation', async () => { + 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' }] @@ -191,75 +272,84 @@ describe('Copilot workspace-file table creation', () => { await importWorkspaceFileIntoTable.execute({ principal, input: { - kind: 'inline', - tableId: 'table-1', - assertedWorkspaceId: 'workspace-1', - sourceFile: input.sourceFile, + tableId: table.id, + assertedWorkspaceId: table.workspaceId, + fileReference: 'files/people.csv', mode: 'append', - loadRows: async () => { - events.push('load') - return { headers: ['name'], rows: [{ name: 'Ada' }] } - }, }, }) expect(events).toEqual(['claim', 'load', 'mutate', 'release']) }) - it('checks for a user stop after loading and before every inline insert batch', async () => { - const assertNotAborted = vi.fn() + it('rejects a concurrent import claim before buffering or mutating rows', async () => { + mocks.markJob.mockResolvedValueOnce(false) - await importWorkspaceFileIntoTable.execute({ - principal, - input: { - kind: 'inline', - tableId: 'table-1', - assertedWorkspaceId: 'workspace-1', - sourceFile: input.sourceFile, - mode: 'append', - assertNotAborted, - loadRows: async () => ({ - headers: ['name'], - rows: Array.from({ length: 1001 }, (_, index) => ({ name: `Person ${index}` })), - }), - }, - }) + await expect( + importWorkspaceFileIntoTable.execute({ + principal, + input: { + tableId: table.id, + assertedWorkspaceId: table.workspaceId, + fileReference: 'files/people.csv', + mode: 'append', + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) - expect(assertNotAborted).toHaveBeenCalledTimes(3) - expect(mocks.batchInsert).toHaveBeenCalledTimes(2) + expect(mocks.fetchFile).not.toHaveBeenCalled() + expect(mocks.batchInsert).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() }) - it('classifies mapping failures before mutation so Copilot can correct them', async () => { - mocks.validateMapping.mockImplementationOnce(() => { - throw new mocks.CsvImportValidationError('Mapping references an unknown column') + 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, input: { - kind: 'inline', - tableId: 'table-1', - assertedWorkspaceId: 'workspace-1', - sourceFile: input.sourceFile, + tableId: table.id, + assertedWorkspaceId: table.workspaceId, + fileReference: 'files/people.csv', mode: 'append', - loadRows: async () => ({ headers: ['name'], rows: [{ name: 'Ada' }] }), + assertNotAborted, }, }) - ).rejects.toMatchObject({ - code: 'validation', - message: 'Mapping references an unknown column', - }) - expect(mocks.batchInsert).not.toHaveBeenCalled() - }) + ).rejects.toBe(stopped) - it('rolls back and propagates unknown insertion failures without audit or effects', async () => { - const failure = new Error('database unavailable') - mocks.batchInsert.mockRejectedValueOnce(failure) - - await expect(createTableFromWorkspaceFile.execute({ principal, input })).rejects.toBe(failure) - expect(mocks.deleteTable).toHaveBeenCalledWith('table-1', 'request-') + 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, + 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 index f49b07734ed..f0e710a0660 100644 --- a/apps/sim/lib/table/application/workspace-file-imports.ts +++ b/apps/sim/lib/table/application/workspace-file-imports.ts @@ -10,13 +10,18 @@ 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' @@ -34,6 +39,13 @@ import { } 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') @@ -46,26 +58,21 @@ export interface TableWorkspaceFileSource { size: number } -interface CreateTableFromWorkspaceFileBaseInput { +const MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 + +export interface CreateTableFromWorkspaceFileInput { workspaceId: string - sourceFile: TableWorkspaceFileSource - name: string - description: string + fileReference: string + name?: string + description?: string + assertNotAborted?: () => void } -export type CreateTableFromWorkspaceFileInput = CreateTableFromWorkspaceFileBaseInput & - ( - | { kind: 'background' } - | { - kind: 'inline' - columns: ColumnDefinition[] - headerToColumn: Map - rows: Record[] - assertNotAborted?: () => void - } - ) - export type CreateTableFromWorkspaceFileResult = + | { + kind: 'empty' + sourceFile: TableWorkspaceFileSource + } | { kind: 'background' table: TableDefinition @@ -82,30 +89,22 @@ export type CreateTableFromWorkspaceFileResult = sourceFile: TableWorkspaceFileSource } -interface ImportWorkspaceFileBaseInput { +export interface ImportWorkspaceFileInput { tableId: string assertedWorkspaceId: string - sourceFile: TableWorkspaceFileSource + fileReference: string mode: 'append' | 'replace' mapping?: CsvHeaderMapping + assertNotAborted?: () => void } -export type ImportWorkspaceFileInput = ImportWorkspaceFileBaseInput & - ( - | { kind: 'background' } - | { - kind: 'inline' - loadRows: () => Promise<{ headers: string[]; rows: Record[] }> - assertNotAborted?: () => void - } - ) - export type ImportWorkspaceFileResult = | { kind: 'background' table: TableDefinition jobId: string mode: 'append' | 'replace' + sourceFileName: string } | { kind: 'empty' @@ -136,14 +135,51 @@ function requestId(): string { return generateId().slice(0, 8) } -function requireCanonicalSource( - source: TableWorkspaceFileSource, - workspaceId: string -): TableWorkspaceFileSource { - if (!source.id || !source.key || !source.name || source.workspaceId !== workspaceId) { +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') } - return source + 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: { @@ -240,18 +276,26 @@ export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: CreateTableFromWorkspaceFileInput }) => resolveTableWorkspaceContext(input.workspaceId), async execute({ principal, input, context }): Promise { - const sourceFile = requireCanonicalSource(input.sourceFile, context.workspaceId) + 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 (input.kind === 'background') { + if (shouldImportInBackground(sourceFile)) { + input.assertNotAborted?.() const jobId = generateId() const table = await createTable( { - name: input.name, - description: input.description, + name, + description, schema: { columns: [{ name: 'column_1', type: 'string' }] }, workspaceId: context.workspaceId, userId, @@ -286,16 +330,22 @@ export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({ } throw error } - return { kind: input.kind, table, jobId, sourceFile } + return { kind: 'background', table, jobId, sourceFile } } - const droppedRows = Math.max(0, input.rows.length - limits.maxRowsPerTable) - const rows = droppedRows > 0 ? input.rows.slice(0, limits.maxRowsPerTable) : input.rows + 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: input.name, - description: input.description, - schema: { columns: input.columns }, + name, + description, + schema: { columns }, workspaceId: context.workspaceId, userId, maxTables: limits.maxTables, @@ -305,15 +355,15 @@ export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({ try { const insertedCount = await batchInsertAll({ table, - rows: coerceRowsForTable(rows, table.schema, input.headerToColumn), + rows: coerceRowsForTable(rows, table.schema, headerToColumn), workspaceId: context.workspaceId, userId, assertNotAborted: input.assertNotAborted, }) return { - kind: input.kind, + kind: 'inline', table, - columns: input.columns, + columns, insertedCount, droppedRows, maxRowsPerTable: limits.maxRowsPerTable, @@ -332,6 +382,7 @@ export const createTableFromWorkspaceFile = defineAuthorizedTableUseCase({ } }, projectAudit({ result }) { + if (result.kind === 'empty') return [] return { action: AuditAction.TABLE_CREATED, resourceType: AuditResourceType.TABLE, @@ -356,12 +407,13 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.assertedWorkspaceId, }), async execute({ principal, input, context }): Promise { - const sourceFile = requireCanonicalSource(input.sourceFile, context.workspaceId) + const sourceFile = await resolveSafeSourceFile(context.workspaceId, input.fileReference) const userId = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }).attributedUserId - if (input.kind === 'background') { + if (shouldImportInBackground(sourceFile)) { + input.assertNotAborted?.() const jobId = generateId() const claimed = await markTableJobRunningInWorkspace( context.table.id, @@ -383,7 +435,13 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({ mapping: input.mapping, deleteSourceFile: false, }) - return { kind: input.kind, table: context.table, jobId, mode: input.mode } + return { + kind: 'background', + table: context.table, + jobId, + mode: input.mode, + sourceFileName: sourceFile.name, + } } const jobId = generateId() @@ -396,7 +454,7 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({ 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 input.loadRows() + const { headers, rows: sourceRows } = await loadInlineRows(sourceFile) input.assertNotAborted?.() if (sourceRows.length === 0) { return { kind: 'empty', table: context.table, mode: input.mode } @@ -433,7 +491,7 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({ requestId() ) return { - kind: input.kind, + kind: 'inline', table: context.table, mode: input.mode, matchedColumns: validation.mappedHeaders, @@ -451,7 +509,7 @@ export const importWorkspaceFileIntoTable = defineAuthorizedTableUseCase({ assertNotAborted: input.assertNotAborted, }) return { - kind: input.kind, + kind: 'inline', table: context.table, mode: input.mode, matchedColumns: validation.mappedHeaders, diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index f585c866773..35378a997b9 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -250,67 +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) { - if (data.resolvedMappingTypes) { - resolvedForWorkflowId = data.resolvedMappingTypes.workflowId - for (const resolved of data.resolvedMappingTypes.columns) { - remapLeafTypeByColumn.set(resolved.columnName, resolved.type) - } - } else { - const preTable = await getTableById(data.tableId) - if (!preTable || (data.workspaceId && preTable.workspaceId !== data.workspaceId)) { - throw new OrchestrationError('not_found', 'Table not found') - } - 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 - ) - } + if (!data.resolvedMappingTypes) { + throw new Error('Workflow group mapping updates require authorized resolved output types') + } + resolvedForWorkflowId = data.resolvedMappingTypes.workflowId + for (const resolved of data.resolvedMappingTypes.columns) { + remapLeafTypeByColumn.set(resolved.columnName, resolved.type) } } @@ -395,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) } } } diff --git a/apps/sim/lib/workflows/application/resolve-workflow-outputs.ts b/apps/sim/lib/workflows/application/resolve-workflow-outputs.ts index 5782752459a..209d26107f6 100644 --- a/apps/sim/lib/workflows/application/resolve-workflow-outputs.ts +++ b/apps/sim/lib/workflows/application/resolve-workflow-outputs.ts @@ -1,5 +1,8 @@ import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' -import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { + type ActiveWorkflowApplicationContext, + resolveActiveWorkflowApplicationContext, +} from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { type FlattenedBlockOutput, @@ -19,6 +22,28 @@ export interface ResolveWorkflowOutputsResult { 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 }) => @@ -27,21 +52,6 @@ export const resolveWorkflowOutputs = defineAuthorizedWorkflowUseCase({ assertedWorkspaceId: input.assertedWorkspaceId, }), async execute({ context }): 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 ?? []), - } + return loadResolvedWorkflowOutputs(context) }, }) From e52c04733807f8cde637512450637000975a3f88 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sun, 9 Aug 2026 00:46:40 -0700 Subject: [PATCH 5/8] fix(tables): preserve workflow group scheduling --- apps/sim/lib/table/application/groups.test.ts | 49 +++++++++++++++++++ apps/sim/lib/table/application/groups.ts | 38 +++++++++----- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts index 1e83c247d58..59b0cd674f9 100644 --- a/apps/sim/lib/table/application/groups.test.ts +++ b/apps/sim/lib/table/application/groups.test.ts @@ -201,6 +201,7 @@ describe('workflow and enrichment Table application commands', () => { id: 'generated-id', workflowId: 'workflow-1', name: 'Scoring', + autoRun: false, outputs: [{ blockId: 'block-2', path: 'score', columnName: 'score' }], }), outputColumns: [ @@ -218,6 +219,28 @@ describe('workflow and enrichment Table application commands', () => { 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( Object.assign(new Error('Workflow not found'), { code: 'not_found' }) @@ -295,6 +318,32 @@ describe('workflow and enrichment Table application commands', () => { 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) diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index 81c3e78651b..93c647a7f5d 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -6,17 +6,18 @@ import type { V2AddWorkflowGroupBody } from '@/lib/api/contracts/v2/tables' import { OrchestrationError } from '@/lib/core/orchestration/types' import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' -import type { - ColumnDefinition, - DeleteWorkflowGroupData, - TableDefinition, - TableSchema, - UpdateWorkflowGroupData, - WorkflowGroup, - WorkflowGroupDependencies, - WorkflowGroupDeploymentMode, - WorkflowGroupInputMapping, - WorkflowGroupOutput, +import { + type ColumnDefinition, + type DeleteWorkflowGroupData, + getColumnId, + 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' @@ -298,6 +299,7 @@ export const createWorkflowTableGroup = defineAuthorizedTableUseCase({ ...(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) @@ -678,7 +680,19 @@ export const updateWorkflowTableGroup = defineAuthorizedTableUseCase({ const existingByKey = new Map( previousGroup.outputs.map((output) => [`${output.blockId}::${output.path}`, output]) ) - const taken = new Set(context.table.schema.columns.map((column) => column.name)) + 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) { From ecae9b89ddadb42f867abb31c5c0764cdcb5e827 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sun, 9 Aug 2026 04:21:55 -0700 Subject: [PATCH 6/8] fix(tables): complete fixed copilot composition --- .../execute-workflow-use-case.test.ts | 70 +++++++++ .../application/execute-workflow-use-case.ts | 41 ++++- .../application/table-commands.test.ts | 21 +++ .../lib/copilot/application/table-commands.ts | 19 +++ .../lib/copilot/auth/table-delegation.test.ts | 5 + .../tools/server/table/user-table.test.ts | 2 +- .../copilot/tools/server/table/user-table.ts | 32 +--- .../copilot-table-lifecycle.test.ts | 143 ++++++++++++++++++ .../application/copilot-table-lifecycle.ts | 87 +++++++++++ 9 files changed, 388 insertions(+), 32 deletions(-) create mode 100644 apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts create mode 100644 apps/sim/lib/table/application/copilot-table-lifecycle.test.ts create mode 100644 apps/sim/lib/table/application/copilot-table-lifecycle.ts 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 index 612069baad4..a91acba9560 100644 --- a/apps/sim/lib/copilot/application/execute-workflow-use-case.ts +++ b/apps/sim/lib/copilot/application/execute-workflow-use-case.ts @@ -1,8 +1,35 @@ -import { createCopilotWorkspaceUseCaseExecutor } from '@/lib/copilot/application/execute-workspace-use-case' -import { WORKFLOW_DELEGATION_AUDIENCE } from '@/lib/workflows/application/authorization' -import { workflowOperations } from '@/lib/workflows/application/operations' +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 const executeCopilotWorkflowUseCase = createCopilotWorkspaceUseCaseExecutor({ - audience: WORKFLOW_DELEGATION_AUDIENCE, - operations: workflowOperations, -}) +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 index e2037f4d367..b4e0154a09c 100644 --- a/apps/sim/lib/copilot/application/table-commands.test.ts +++ b/apps/sim/lib/copilot/application/table-commands.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ createEnrichment: vi.fn(), createFromFile: vi.fn(), createWorkflowGroup: vi.fn(), + deleteTables: vi.fn(), importFile: vi.fn(), replaceProjectedRows: vi.fn(), resolvePrincipal: vi.fn(), @@ -24,6 +25,9 @@ vi.mock('@/lib/table/application/groups', () => ({ createWorkflowTableGroup: { execute: mocks.createWorkflowGroup }, updateWorkflowTableGroup: { execute: mocks.updateWorkflowGroup }, })) +vi.mock('@/lib/table/application/copilot-table-lifecycle', () => ({ + deleteCopilotTables: { execute: mocks.deleteTables }, +})) vi.mock('@/lib/table/application/rows', () => ({ replaceProjectedWireRows: { execute: mocks.replaceProjectedRows }, })) @@ -37,6 +41,7 @@ import { copilotCreateTableEnrichmentGroupPolicy, copilotCreateTableFromWorkspaceFilePolicy, copilotCreateWorkflowTableGroupPolicy, + copilotDeleteTablesPolicy, copilotImportWorkspaceFileIntoTablePolicy, copilotReplaceProjectedWireRowsPolicy, copilotUpdateWorkflowTableGroupPolicy, @@ -44,6 +49,7 @@ import { executeCopilotCreateTableEnrichmentGroup, executeCopilotCreateTableFromWorkspaceFile, executeCopilotCreateWorkflowTableGroup, + executeCopilotDeleteTables, executeCopilotImportWorkspaceFileIntoTable, executeCopilotReplaceProjectedWireRows, executeCopilotUpdateWorkflowTableGroup, @@ -94,10 +100,25 @@ describe('fixed Copilot Table application commands', () => { expect(mocks.createFromFile).toHaveBeenCalledWith({ principal, input }) }) + it('uses one workspace-scoped Table command for best-effort multi-table deletion', async () => { + mocks.deleteTables.mockResolvedValue({ deleted: ['table-1'], failed: ['table-2'] }) + const input = { workspaceId: 'workspace-1', tableIds: ['table-1', 'table-2'] } + + await expect(executeCopilotDeleteTables(context, input)).resolves.toEqual({ + deleted: ['table-1'], + failed: ['table-2'], + }) + + expect(mocks.resolvePrincipal).toHaveBeenCalledWith(context) + expect(mocks.deleteTables).toHaveBeenCalledWith({ principal, input }) + expect(mocks.deleteTables).toHaveBeenCalledTimes(1) + }) + it('declares inherited request-rate admission and no direct provider cost for every command', () => { const policies = [ copilotReplaceProjectedWireRowsPolicy, copilotCreateWorkflowTableGroupPolicy, + copilotDeleteTablesPolicy, copilotUpdateWorkflowTableGroupPolicy, copilotAddWorkflowTableGroupOutputPolicy, copilotCreateTableEnrichmentGroupPolicy, diff --git a/apps/sim/lib/copilot/application/table-commands.ts b/apps/sim/lib/copilot/application/table-commands.ts index bc621c10e42..d1ee2778fcb 100644 --- a/apps/sim/lib/copilot/application/table-commands.ts +++ b/apps/sim/lib/copilot/application/table-commands.ts @@ -1,5 +1,9 @@ import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' import { resolveCopilotTablePrincipal } from '@/lib/copilot/auth/table-delegation' +import { + type DeleteCopilotTablesInput, + deleteCopilotTables, +} from '@/lib/table/application/copilot-table-lifecycle' import { type AddTableGroupOutputInput, addWorkflowTableGroupOutput, @@ -31,6 +35,21 @@ const NO_DIRECT_PROVIDER_COST_POLICY = { 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 deleteCopilotTables.execute({ + principal: resolveCopilotTablePrincipal(context), + input, + }) +} + export const copilotReplaceProjectedWireRowsPolicy = { rate: INHERITED_COPILOT_RATE_POLICY, cost: NO_DIRECT_PROVIDER_COST_POLICY, diff --git a/apps/sim/lib/copilot/auth/table-delegation.test.ts b/apps/sim/lib/copilot/auth/table-delegation.test.ts index 33dc57ea01f..5e6ae0dbab4 100644 --- a/apps/sim/lib/copilot/auth/table-delegation.test.ts +++ b/apps/sim/lib/copilot/auth/table-delegation.test.ts @@ -41,4 +41,9 @@ describe('Copilot table delegation', () => { resolveCopilotTablePrincipal({ ...context, toolCallId: undefined }, 'table-1') ).toThrow('tool call ID') }) + + it('rejects an empty table scope before principal construction', () => { + expect(() => resolveCopilotTablePrincipal(context, '')).toThrow('non-empty table ID') + expect(() => resolveCopilotTablePrincipal(context, ' ')).toThrow('non-empty table ID') + }) }) 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 04073e3d4e7..237fc114997 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 @@ -130,7 +130,7 @@ vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ })) vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ - executeCopilotWorkflowUseCase: mockExecuteCopilotWorkflowUseCase, + executeCopilotResolveWorkflowOutputs: mockExecuteCopilotWorkflowUseCase, })) vi.mock('@sim/platform-authz/workspace', () => ({ 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 813de67af65..0e69a0d21e8 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -1,11 +1,12 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { executeCopilotWorkflowUseCase } from '@/lib/copilot/application/execute-workflow-use-case' +import { executeCopilotResolveWorkflowOutputs } from '@/lib/copilot/application/execute-workflow-use-case' import { executeCopilotAddWorkflowTableGroupOutput, executeCopilotCreateTableEnrichmentGroup, executeCopilotCreateTableFromWorkspaceFile, executeCopilotCreateWorkflowTableGroup, + executeCopilotDeleteTables, executeCopilotImportWorkspaceFileIntoTable, executeCopilotUpdateWorkflowTableGroup, } from '@/lib/copilot/application/table-commands' @@ -46,7 +47,6 @@ import { import { cancelTableRuns, startTableRun } from '@/lib/table/application/runs' import { createTableUseCase, - deleteTableUseCase, readTableUseCase, updateTableUseCase, } from '@/lib/table/application/tables' @@ -66,7 +66,6 @@ import type { WorkflowGroupDependencies, WorkflowGroupDeploymentMode, } from '@/lib/table/types' -import { resolveWorkflowOutputs } from '@/lib/workflows/application/resolve-workflow-outputs' const logger = createLogger('UserTableServerTool') @@ -88,7 +87,7 @@ function resolveAuthorizedWorkflowOutputs( workspaceId: string, context: ServerToolContext ) { - return executeCopilotWorkflowUseCase(context, resolveWorkflowOutputs, { + return executeCopilotResolveWorkflowOutputs(context, { workflowId, assertedWorkspaceId: workspaceId, }) @@ -267,26 +266,11 @@ 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 deleteTableUseCase.execute({ - principal: tablePrincipal(tableId), - input: { tableId, workspaceId }, - }) - deleted.push(tableId) - } catch (error) { - const classified = messageForCopilotTableError(error, '') - if (classified === 'Table not found') { - failed.push(tableId) - continue - } - throw error - } - } + assertNotAborted() + const { deleted, failed } = await executeCopilotDeleteTables(context, { + tableIds, + workspaceId, + }) return { success: deleted.length > 0, 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..6cf803c36b8 --- /dev/null +++ b/apps/sim/lib/table/application/copilot-table-lifecycle.test.ts @@ -0,0 +1,143 @@ +/** + * @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 result = await deleteCopilotTables.execute({ + principal, + input: { workspaceId: 'workspace-1', tableIds: ['table-1', 'table-2'] }, + }) + + expect(result).toEqual({ deleted: ['table-1', '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(mocks.audit).toHaveBeenCalledTimes(2) + }) + + 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'] }, + }) + ).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'] }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveActiveTableContext).not.toHaveBeenCalled() + expect(mocks.deleteTable).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('preserves audit for completed items before a later mutation 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'] }, + }) + ).rejects.toBe(failure) + + expect(mocks.audit).toHaveBeenCalledTimes(1) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ resourceId: 'table-1', action: 'table.deleted' }) + ) + }) +}) 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..b8e920246de --- /dev/null +++ b/apps/sim/lib/table/application/copilot-table-lifecycle.ts @@ -0,0 +1,87 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { resolvePrincipalAuditAttribution } from '@sim/auth/principal' +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[] +} + +export interface DeleteCopilotTablesResult { + deleted: string[] + 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({ principal, input, context, request }): 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: string[] = [] + const failed: string[] = [] + const auditAttribution = resolvePrincipalAuditAttribution(principal) + + for (const tableId of input.tableIds) { + try { + const tableContext = await resolveActiveTableContext({ + tableId, + assertedWorkspaceId: context.workspaceId, + }) + const { archived } = await deleteTable(tableContext.tableId, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + }) + if (!archived) { + failed.push(tableId) + continue + } + + deleted.push(tableId) + recordAudit({ + workspaceId: context.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: archived.name, + description: `Archived table "${archived.name}"`, + metadata: { + operation: tableOperations.delete.id, + actor: auditAttribution.actor, + }, + request, + }) + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') { + failed.push(tableId) + continue + } + throw error + } + } + + return { deleted, failed } + }, +}) From d7df662db1a96ca15acf46f6c100d11e72d3e3e1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sun, 9 Aug 2026 10:54:32 -0700 Subject: [PATCH 7/8] fix(tables): reject enrichment output mutation --- apps/sim/lib/table/application/groups.test.ts | 36 +++++++++++++++++++ apps/sim/lib/table/application/groups.ts | 6 ++++ 2 files changed, 42 insertions(+) diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts index 59b0cd674f9..7befb9446b7 100644 --- a/apps/sim/lib/table/application/groups.test.ts +++ b/apps/sim/lib/table/application/groups.test.ts @@ -410,6 +410,42 @@ describe('workflow and enrichment Table application commands', () => { 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({ diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index 93c647a7f5d..a719978bdcb 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -859,6 +859,12 @@ export const addWorkflowTableGroupOutput = defineAuthorizedTableUseCase({ ) 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 From 6e9c39551da72f5198a8f7ffbaf54cc625ca13ab Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sun, 9 Aug 2026 18:14:40 -0700 Subject: [PATCH 8/8] fix(tables): complete authorized application boundary --- .../app/api/table/[tableId]/exports/route.ts | 4 +- .../api/table/[tableId]/groups/route.test.ts | 7 +- .../app/api/table/[tableId]/groups/route.ts | 10 +- .../exports/[exportId]/download/route.ts | 4 +- .../app/api/table/exports/[exportId]/route.ts | 6 +- .../imports/[importId]/complete/route.ts | 4 +- .../table/imports/[importId]/parts/route.ts | 4 +- .../app/api/table/imports/[importId]/route.ts | 6 +- apps/sim/app/api/table/imports/route.ts | 4 +- .../api/table/table-transfer-routes.test.ts | 7 +- .../execute-table-use-case.test.ts | 96 +++++ .../application/execute-table-use-case.ts | 34 ++ .../application/table-commands.test.ts | 125 ++++--- .../lib/copilot/application/table-commands.ts | 42 +-- .../lib/copilot/auth/table-delegation.test.ts | 49 --- apps/sim/lib/copilot/auth/table-delegation.ts | 25 +- .../tools/server/table/user-table.test.ts | 225 ++++++++---- .../copilot/tools/server/table/user-table.ts | 337 ++++++++++-------- apps/sim/lib/table/api/index.ts | 5 +- apps/sim/lib/table/api/route-policies.test.ts | 190 ++++++++++ apps/sim/lib/table/api/route-policies.ts | 15 +- .../table/application/authorization.test.ts | 27 ++ .../lib/table/application/authorization.ts | 4 +- .../sim/lib/table/application/columns.test.ts | 76 +++- apps/sim/lib/table/application/columns.ts | 38 +- .../copilot-table-lifecycle.test.ts | 73 +++- .../application/copilot-table-lifecycle.ts | 42 ++- .../sim/lib/table/application/exports.test.ts | 39 ++ apps/sim/lib/table/application/exports.ts | 4 +- apps/sim/lib/table/application/groups.test.ts | 71 +++- apps/sim/lib/table/application/groups.ts | 47 ++- .../sim/lib/table/application/imports.test.ts | 85 +++++ apps/sim/lib/table/application/imports.ts | 5 +- .../lib/table/application/operations.test.ts | 41 ++- apps/sim/lib/table/application/operations.ts | 64 +++- apps/sim/lib/table/application/rows.test.ts | 48 +++ apps/sim/lib/table/application/rows.ts | 70 +++- .../workspace-file-imports.test.ts | 10 +- .../uploads/upload-session/service.test.ts | 100 +++++- .../sim/lib/uploads/upload-session/service.ts | 89 ++++- .../resolve-workspace-file-reference.test.ts | 50 --- .../resolve-workspace-file-reference.ts | 35 -- 42 files changed, 1630 insertions(+), 587 deletions(-) create mode 100644 apps/sim/lib/copilot/application/execute-table-use-case.test.ts create mode 100644 apps/sim/lib/copilot/application/execute-table-use-case.ts delete mode 100644 apps/sim/lib/copilot/auth/table-delegation.test.ts create mode 100644 apps/sim/lib/table/api/route-policies.test.ts diff --git a/apps/sim/app/api/table/[tableId]/exports/route.ts b/apps/sim/app/api/table/[tableId]/exports/route.ts index 3f39d2c3489..228fc3be0db 100644 --- a/apps/sim/app/api/table/[tableId]/exports/route.ts +++ b/apps/sim/app/api/table/[tableId]/exports/route.ts @@ -3,15 +3,15 @@ import { defineInternalJsonRoute, internalPlainOrchestrationErrorPolicy, internalRateLimits, - internalSessionAuth, } 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' export const POST = defineInternalJsonRoute({ contract: createTableExportResourceContract, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, operation: tableOperations.createExport, rateLimit: internalRateLimits.none({ reason: 'Existing authenticated table export creation has no request-rate policy', 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 bcac61e3181..24b6c533a57 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.test.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.test.ts @@ -11,7 +11,7 @@ interface CapturedDefinition { } const mocks = vi.hoisted(() => ({ - auth: { kind: 'session-only' }, + auth: { kind: 'session-or-executor' }, definitions: [] as CapturedDefinition[], useCases: { create: { operation: { id: 'tables.groups.create' } }, @@ -31,9 +31,10 @@ vi.mock('@/lib/api/server/routes', () => ({ internalRateLimits: { none: ({ reason }: { reason: string }) => ({ kind: 'none', reason }), }, - internalSessionAuth: mocks.auth, })) +vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth })) + vi.mock('@/lib/table/application/groups', () => ({ createTableGroupUseCase: mocks.useCases.create, deleteTableGroupUseCase: mocks.useCases.remove, @@ -53,7 +54,7 @@ function definition(method: string): CapturedDefinition { } describe('/api/table/[tableId]/groups', () => { - it('routes every mutation through its session-authenticated application use case', () => { + it('routes every mutation through its session-or-executor application use case', () => { const expected = [ ['POST', mocks.useCases.create], ['PATCH', mocks.useCases.update], diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 9739b9d5309..70ab6758ade 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -9,8 +9,8 @@ import { internalErrorResponse, internalPlainOrchestrationErrorPolicy, internalRateLimits, - internalSessionAuth, } from '@/lib/api/server/routes' +import { internalTableSessionOrExecutorAuth } from '@/lib/table/api' import { createTableGroupUseCase, deleteTableGroupUseCase, @@ -28,7 +28,7 @@ const errorPolicy = extendInternalErrorPolicy(internalPlainOrchestrationErrorPol ) const rateLimit = internalRateLimits.none({ - reason: 'First-party table group mutations are authenticated browser operations', + reason: 'Existing authenticated table group mutations have no request-rate policy', }) function presentTable(table: TableDefinition) { @@ -45,7 +45,7 @@ export const POST = defineInternalJsonRoute({ contract: addWorkflowGroupContract, operation: tableOperations.createGroup, useCase: createTableGroupUseCase, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), @@ -56,7 +56,7 @@ export const PATCH = defineInternalJsonRoute({ contract: updateWorkflowGroupContract, operation: tableOperations.updateGroup, useCase: updateTableGroupUseCase, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), @@ -67,7 +67,7 @@ export const DELETE = defineInternalJsonRoute({ contract: deleteWorkflowGroupContract, operation: tableOperations.deleteGroup, useCase: deleteTableGroupUseCase, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, rateLimit, errorPolicy, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), 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 0fb93869efc..71c9ca300c3 100644 --- a/apps/sim/app/api/table/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/table/exports/[exportId]/download/route.ts @@ -3,14 +3,14 @@ import { defineInternalJsonRoute, internalPlainOrchestrationErrorPolicy, internalRateLimits, - internalSessionAuth, } 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' export const GET = defineInternalJsonRoute({ contract: downloadTableExportResourceContract, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, operation: tableOperations.downloadExport, rateLimit: internalRateLimits.none({ reason: 'Existing authenticated table export download signing has no request-rate policy', diff --git a/apps/sim/app/api/table/exports/[exportId]/route.ts b/apps/sim/app/api/table/exports/[exportId]/route.ts index ef411f2f7ca..5e4d3a8c14c 100644 --- a/apps/sim/app/api/table/exports/[exportId]/route.ts +++ b/apps/sim/app/api/table/exports/[exportId]/route.ts @@ -6,8 +6,8 @@ import { defineInternalJsonRoute, internalPlainOrchestrationErrorPolicy, internalRateLimits, - internalSessionAuth, } 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' @@ -18,7 +18,7 @@ const rateLimit = internalRateLimits.none({ export const GET = defineInternalJsonRoute({ contract: getTableExportResourceContract, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, operation: tableOperations.readExport, rateLimit, errorPolicy: internalPlainOrchestrationErrorPolicy, @@ -32,7 +32,7 @@ export const GET = defineInternalJsonRoute({ export const DELETE = defineInternalJsonRoute({ contract: cancelTableExportResourceContract, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, operation: tableOperations.cancelExport, rateLimit, errorPolicy: internalPlainOrchestrationErrorPolicy, 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 2dbfcb777cd..6952511835d 100644 --- a/apps/sim/app/api/table/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/complete/route.ts @@ -3,15 +3,15 @@ import { defineInternalJsonRoute, internalPlainOrchestrationErrorPolicy, internalRateLimits, - internalSessionAuth, } 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' export const POST = defineInternalJsonRoute({ contract: completeTableImportResourceContract, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, operation: tableOperations.completeImport, rateLimit: internalRateLimits.none({ reason: 'Existing authenticated table import completion has no request-rate policy', 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 32562eb1435..4a3b4261c60 100644 --- a/apps/sim/app/api/table/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/parts/route.ts @@ -3,14 +3,14 @@ import { defineInternalJsonRoute, internalPlainOrchestrationErrorPolicy, internalRateLimits, - internalSessionAuth, } 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' export const POST = defineInternalJsonRoute({ contract: createTableImportPartUrlsContract, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, operation: tableOperations.createImportParts, rateLimit: internalRateLimits.none({ reason: 'Existing authenticated table import part signing has no request-rate policy', diff --git a/apps/sim/app/api/table/imports/[importId]/route.ts b/apps/sim/app/api/table/imports/[importId]/route.ts index 1fb9fcf813b..60dd58dc125 100644 --- a/apps/sim/app/api/table/imports/[importId]/route.ts +++ b/apps/sim/app/api/table/imports/[importId]/route.ts @@ -6,8 +6,8 @@ import { defineInternalJsonRoute, internalPlainOrchestrationErrorPolicy, internalRateLimits, - internalSessionAuth, } 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' @@ -18,7 +18,7 @@ const rateLimit = internalRateLimits.none({ export const GET = defineInternalJsonRoute({ contract: getTableImportResourceContract, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, operation: tableOperations.readImport, rateLimit, errorPolicy: internalPlainOrchestrationErrorPolicy, @@ -32,7 +32,7 @@ export const GET = defineInternalJsonRoute({ export const DELETE = defineInternalJsonRoute({ contract: cancelTableImportResourceContract, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, operation: tableOperations.cancelImport, rateLimit, errorPolicy: internalPlainOrchestrationErrorPolicy, diff --git a/apps/sim/app/api/table/imports/route.ts b/apps/sim/app/api/table/imports/route.ts index df41ef31907..143207c2e5e 100644 --- a/apps/sim/app/api/table/imports/route.ts +++ b/apps/sim/app/api/table/imports/route.ts @@ -3,15 +3,15 @@ import { defineInternalJsonRoute, internalPlainOrchestrationErrorPolicy, internalRateLimits, - internalSessionAuth, } 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 = defineInternalJsonRoute({ contract: createTableImportResourceContract, - auth: internalSessionAuth, + auth: internalTableSessionOrExecutorAuth, operation: tableOperations.createImport, rateLimit: internalRateLimits.none({ reason: 'Existing authenticated table import creation has no request-rate policy', diff --git a/apps/sim/app/api/table/table-transfer-routes.test.ts b/apps/sim/app/api/table/table-transfer-routes.test.ts index 6a0c647e3d1..7511900d81f 100644 --- a/apps/sim/app/api/table/table-transfer-routes.test.ts +++ b/apps/sim/app/api/table/table-transfer-routes.test.ts @@ -15,7 +15,7 @@ interface CapturedDefinition { } const mocks = vi.hoisted(() => ({ - auth: { kind: 'session-only' }, + auth: { kind: 'session-or-executor' }, definitions: [] as CapturedDefinition[], useCases: { cancelExport: { operation: { id: 'tables.exports.cancel' } }, @@ -39,9 +39,10 @@ vi.mock('@/lib/api/server/routes', () => ({ internalRateLimits: { none: ({ reason }: { reason: string }) => ({ kind: 'none', reason }), }, - internalSessionAuth: mocks.auth, })) +vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth })) + vi.mock('@/lib/table/application/imports', () => ({ cancelTableImportUseCase: mocks.useCases.cancelImport, completeTableImportUseCase: mocks.useCases.completeImport, @@ -83,7 +84,7 @@ function definition(method: string, path: string): CapturedDefinition { } describe('internal table transfer routes', () => { - it('routes every ordinary transfer control leg through session-authenticated use cases', () => { + 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], 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 new file mode 100644 index 00000000000..d634d93141c --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-table-use-case.ts @@ -0,0 +1,34 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' +import type { OperationUseCase } from '@/lib/core/application' +import { tableDelegationPolicy } from '@/lib/table/application/authorization' +import { type TableOperation, tableOperations } from '@/lib/table/application/operations' + +interface ExecuteCopilotTableUseCaseOptions { + tableId?: string +} + +const executeTableUseCase = createCopilotApplicationAdapter< + TableOperation, + ExecuteCopilotTableUseCaseOptions +>({ + domain: 'table', + delegation: { + audience: tableDelegationPolicy.audience, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: tableOperations, + projectResourceScope: ({ tableId }) => (tableId ? { tableId } : {}), +}) + +/** Normalizes trusted Copilot authentication before entering a Table application use case. */ +export function executeCopilotTableUseCase( + context: CopilotTableDelegationContext | undefined, + useCase: OperationUseCase, + input: I, + options: ExecuteCopilotTableUseCaseOptions = {} +): Promise { + return executeTableUseCase(context, useCase, input, options) +} diff --git a/apps/sim/lib/copilot/application/table-commands.test.ts b/apps/sim/lib/copilot/application/table-commands.test.ts index b4e0154a09c..900f8b76dfe 100644 --- a/apps/sim/lib/copilot/application/table-commands.test.ts +++ b/apps/sim/lib/copilot/application/table-commands.test.ts @@ -5,35 +5,37 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - addOutput: vi.fn(), - createEnrichment: vi.fn(), - createFromFile: vi.fn(), - createWorkflowGroup: vi.fn(), - deleteTables: vi.fn(), - importFile: vi.fn(), - replaceProjectedRows: vi.fn(), - resolvePrincipal: vi.fn(), - updateWorkflowGroup: vi.fn(), + 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/auth/table-delegation', () => ({ - resolveCopilotTablePrincipal: mocks.resolvePrincipal, +vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ + executeCopilotTableUseCase: mocks.executeTableUseCase, })) vi.mock('@/lib/table/application/groups', () => ({ - addWorkflowTableGroupOutput: { execute: mocks.addOutput }, - createTableEnrichmentGroup: { execute: mocks.createEnrichment }, - createWorkflowTableGroup: { execute: mocks.createWorkflowGroup }, - updateWorkflowTableGroup: { execute: mocks.updateWorkflowGroup }, + 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: { execute: mocks.deleteTables }, + deleteCopilotTables: mocks.useCases.deleteTables, })) vi.mock('@/lib/table/application/rows', () => ({ - replaceProjectedWireRows: { execute: mocks.replaceProjectedRows }, + replaceProjectedWireRows: mocks.useCases.replaceProjectedRows, })) vi.mock('@/lib/table/application/workspace-file-imports', () => ({ - createTableFromWorkspaceFile: { execute: mocks.createFromFile }, - importWorkspaceFileIntoTable: { execute: mocks.importFile }, + createTableFromWorkspaceFile: mocks.useCases.createFromFile, + importWorkspaceFileIntoTable: mocks.useCases.importFile, })) import { @@ -61,57 +63,86 @@ const context = { toolCallId: 'tool-call-1', copilotToolExecution: true, } -const principal = { kind: 'delegated', audience: 'sim:tables' } - describe('fixed Copilot Table application commands', () => { beforeEach(() => { vi.clearAllMocks() - mocks.resolvePrincipal.mockReturnValue(principal) }) it.each([ - ['replace projected rows', executeCopilotReplaceProjectedWireRows, mocks.replaceProjectedRows], - ['create workflow group', executeCopilotCreateWorkflowTableGroup, mocks.createWorkflowGroup], - ['update workflow group', executeCopilotUpdateWorkflowTableGroup, mocks.updateWorkflowGroup], - ['add workflow output', executeCopilotAddWorkflowTableGroupOutput, mocks.addOutput], - ['create enrichment group', executeCopilotCreateTableEnrichmentGroup, mocks.createEnrichment], - ['import a workspace file', executeCopilotImportWorkspaceFileIntoTable, mocks.importFile], + [ + '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, command) => { - command.mockResolvedValue({ ok: true }) + 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.resolvePrincipal).toHaveBeenCalledWith(context, 'table-1') - expect(command).toHaveBeenCalledWith({ principal, input }) - expect(command).toHaveBeenCalledTimes(1) + 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.createFromFile.mockResolvedValue({ kind: 'empty' }) + mocks.executeTableUseCase.mockResolvedValue({ kind: 'empty' }) const input = { workspaceId: 'workspace-1', fileReference: 'files/people.csv' } await executeCopilotCreateTableFromWorkspaceFile(context, input) - expect(mocks.resolvePrincipal).toHaveBeenCalledWith(context) - expect(mocks.createFromFile).toHaveBeenCalledWith({ principal, input }) + expect(mocks.executeTableUseCase).toHaveBeenCalledWith( + context, + mocks.useCases.createFromFile, + input + ) }) it('uses one workspace-scoped Table command for best-effort multi-table deletion', async () => { - mocks.deleteTables.mockResolvedValue({ deleted: ['table-1'], failed: ['table-2'] }) - const input = { workspaceId: 'workspace-1', tableIds: ['table-1', 'table-2'] } - - await expect(executeCopilotDeleteTables(context, input)).resolves.toEqual({ - deleted: ['table-1'], + 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.resolvePrincipal).toHaveBeenCalledWith(context) - expect(mocks.deleteTables).toHaveBeenCalledWith({ principal, input }) - expect(mocks.deleteTables).toHaveBeenCalledTimes(1) + 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', () => { @@ -136,7 +167,7 @@ describe('fixed Copilot Table application commands', () => { it('rejects an untrusted context before application execution', async () => { const error = new Error('trusted Copilot execution context required') - mocks.resolvePrincipal.mockImplementationOnce(() => { + mocks.executeTableUseCase.mockImplementationOnce(() => { throw error }) @@ -147,6 +178,6 @@ describe('fixed Copilot Table application commands', () => { projectedRows: [], }) ).toThrow(error) - expect(mocks.replaceProjectedRows).not.toHaveBeenCalled() + 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 index d1ee2778fcb..66f8ead69f4 100644 --- a/apps/sim/lib/copilot/application/table-commands.ts +++ b/apps/sim/lib/copilot/application/table-commands.ts @@ -1,5 +1,5 @@ +import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' -import { resolveCopilotTablePrincipal } from '@/lib/copilot/auth/table-delegation' import { type DeleteCopilotTablesInput, deleteCopilotTables, @@ -44,10 +44,7 @@ export function executeCopilotDeleteTables( context: CopilotTableDelegationContext | undefined, input: DeleteCopilotTablesInput ) { - return deleteCopilotTables.execute({ - principal: resolveCopilotTablePrincipal(context), - input, - }) + return executeCopilotTableUseCase(context, deleteCopilotTables, input) } export const copilotReplaceProjectedWireRowsPolicy = { @@ -59,9 +56,8 @@ export function executeCopilotReplaceProjectedWireRows( context: CopilotTableDelegationContext | undefined, input: ReplaceProjectedWireRowsInput ) { - return replaceProjectedWireRows.execute({ - principal: resolveCopilotTablePrincipal(context, input.tableId), - input, + return executeCopilotTableUseCase(context, replaceProjectedWireRows, input, { + tableId: input.tableId, }) } @@ -74,9 +70,8 @@ export function executeCopilotCreateWorkflowTableGroup( context: CopilotTableDelegationContext | undefined, input: CreateWorkflowTableGroupInput ) { - return createWorkflowTableGroup.execute({ - principal: resolveCopilotTablePrincipal(context, input.tableId), - input, + return executeCopilotTableUseCase(context, createWorkflowTableGroup, input, { + tableId: input.tableId, }) } @@ -89,9 +84,8 @@ export function executeCopilotUpdateWorkflowTableGroup( context: CopilotTableDelegationContext | undefined, input: UpdateWorkflowTableGroupInput ) { - return updateWorkflowTableGroup.execute({ - principal: resolveCopilotTablePrincipal(context, input.tableId), - input, + return executeCopilotTableUseCase(context, updateWorkflowTableGroup, input, { + tableId: input.tableId, }) } @@ -104,9 +98,8 @@ export function executeCopilotAddWorkflowTableGroupOutput( context: CopilotTableDelegationContext | undefined, input: AddTableGroupOutputInput ) { - return addWorkflowTableGroupOutput.execute({ - principal: resolveCopilotTablePrincipal(context, input.tableId), - input, + return executeCopilotTableUseCase(context, addWorkflowTableGroupOutput, input, { + tableId: input.tableId, }) } @@ -119,9 +112,8 @@ export function executeCopilotCreateTableEnrichmentGroup( context: CopilotTableDelegationContext | undefined, input: CreateTableEnrichmentGroupInput ) { - return createTableEnrichmentGroup.execute({ - principal: resolveCopilotTablePrincipal(context, input.tableId), - input, + return executeCopilotTableUseCase(context, createTableEnrichmentGroup, input, { + tableId: input.tableId, }) } @@ -134,10 +126,7 @@ export function executeCopilotCreateTableFromWorkspaceFile( context: CopilotTableDelegationContext | undefined, input: CreateTableFromWorkspaceFileInput ) { - return createTableFromWorkspaceFile.execute({ - principal: resolveCopilotTablePrincipal(context), - input, - }) + return executeCopilotTableUseCase(context, createTableFromWorkspaceFile, input) } export const copilotImportWorkspaceFileIntoTablePolicy = { @@ -149,8 +138,7 @@ export function executeCopilotImportWorkspaceFileIntoTable( context: CopilotTableDelegationContext | undefined, input: ImportWorkspaceFileInput ) { - return importWorkspaceFileIntoTable.execute({ - principal: resolveCopilotTablePrincipal(context, input.tableId), - input, + 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 5e6ae0dbab4..00000000000 --- a/apps/sim/lib/copilot/auth/table-delegation.test.ts +++ /dev/null @@ -1,49 +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') - }) - - it('rejects an empty table scope before principal construction', () => { - expect(() => resolveCopilotTablePrincipal(context, '')).toThrow('non-empty table ID') - expect(() => resolveCopilotTablePrincipal(context, ' ')).toThrow('non-empty table ID') - }) -}) diff --git a/apps/sim/lib/copilot/auth/table-delegation.ts b/apps/sim/lib/copilot/auth/table-delegation.ts index 645e3a6081c..7e516616fcb 100644 --- a/apps/sim/lib/copilot/auth/table-delegation.ts +++ b/apps/sim/lib/copilot/auth/table-delegation.ts @@ -1,31 +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 { - if (tableId !== undefined && !tableId.trim()) { - throw new Error('Table delegation requires a non-empty table ID') - } - 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/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 237fc114997..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 @@ -23,6 +23,7 @@ const { mockReleaseJobClaim, mockQueryRows, mockDeleteRowsByFilter, + mockDeleteColumns, mockUpdateRowsByFilter, mockRunTableImport, mockRunTableDelete, @@ -30,6 +31,7 @@ const { mockExecuteCopilotFileUseCase, mockExecuteCopilotWorkflowUseCase, mockLoadWorkspaceFileContext, + mockLoadTableRowSecretProvenance, mockResolveWorkflowContext, fakeEnrichment, } = vi.hoisted(() => ({ @@ -49,6 +51,7 @@ const { mockReleaseJobClaim: vi.fn(), mockQueryRows: vi.fn(), mockDeleteRowsByFilter: vi.fn(), + mockDeleteColumns: vi.fn(), mockUpdateRowsByFilter: vi.fn(), mockRunTableImport: vi.fn(), mockRunTableDelete: vi.fn(), @@ -56,6 +59,7 @@ const { mockExecuteCopilotFileUseCase: vi.fn(), mockExecuteCopilotWorkflowUseCase: vi.fn(), mockLoadWorkspaceFileContext: vi.fn(), + mockLoadTableRowSecretProvenance: vi.fn(), mockResolveWorkflowContext: vi.fn(), fakeEnrichment: { id: 'work-email', @@ -84,9 +88,6 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ fetchWorkspaceFileBuffer: mockDownloadWorkspaceFile, loadActiveWorkspaceFileContext: mockLoadWorkspaceFileContext, })) -vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ - readSafeWorkspaceFileReference: { operation: { id: 'files.content.read' } }, -})) vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ readWorkspaceFileContent: { execute: async () => ({ content: await mockDownloadWorkspaceFile() }), @@ -112,17 +113,6 @@ vi.mock('@/lib/copilot/auth/table-delegation', () => ({ ? (classified.message ?? 'Table operation failed') : '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), - ...(tableId ? { resourceScope: { tableId } } : {}), - }), })) vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ @@ -204,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, @@ -225,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, @@ -259,9 +259,16 @@ vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' import { encodeCursor } from '@/lib/table/rows/cursor' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' beforeEach(() => { mockLoadWorkspaceFileContext.mockResolvedValue({ workspaceId: 'workspace-1' }) + mockLoadTableRowSecretProvenance.mockResolvedValue({ + version: 1, + complete: true, + entries: [], + scope: { userId: 'user-1', workspaceId: 'workspace-1' }, + }) mockResolveWorkflowContext.mockImplementation( async ({ workflowId, @@ -337,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() @@ -370,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) @@ -392,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) @@ -435,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) @@ -452,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/) @@ -466,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) @@ -479,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) @@ -493,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) @@ -503,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) @@ -524,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) @@ -544,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() @@ -578,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) @@ -596,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) @@ -612,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) @@ -631,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() @@ -664,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) @@ -680,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) @@ -697,7 +713,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) @@ -717,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() @@ -746,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) @@ -772,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) @@ -782,6 +798,43 @@ 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() @@ -811,7 +864,7 @@ describe('userTableServerTool workflow scope', () => { outputs: [{ blockId: 'block-1', path: 'content' }], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result).toEqual({ success: false, message: 'Operation failed: Workflow not found' }) @@ -827,7 +880,7 @@ describe('userTableServerTool workflow scope', () => { const result = await userTableServerTool.execute( { operation: 'query_rows', args: { tableId: 'tbl_1' } }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result).toEqual({ success: false, message: 'Operation failed: Table operation failed' }) @@ -843,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) @@ -899,7 +952,7 @@ describe('userTableServerTool.add_enrichment', () => { ], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -944,7 +997,7 @@ describe('userTableServerTool.add_enrichment', () => { autoRun: true, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -960,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) @@ -978,7 +1031,7 @@ describe('userTableServerTool.add_enrichment', () => { inputMappings: [{ inputName: 'fullName', columnName: 'name' }], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -999,7 +1052,7 @@ describe('userTableServerTool.add_enrichment', () => { ], }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -1031,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 () => { @@ -1059,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) @@ -1076,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) @@ -1098,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') @@ -1117,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) @@ -1159,7 +1238,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { limit: 5000, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -1182,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) @@ -1204,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) @@ -1223,7 +1302,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { limit: 100, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(false) @@ -1245,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() @@ -1284,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) @@ -1303,7 +1382,7 @@ describe('userTableServerTool.delete_rows_by_filter', () => { limit: 100, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -1339,7 +1418,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { limit: 5000, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -1363,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) @@ -1381,7 +1460,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { age: 1 }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) expect(result.success).toBe(true) @@ -1405,7 +1484,7 @@ describe('userTableServerTool.update_rows_by_filter', () => { data: { age: 1 }, }, }, - { userId: 'user-1', workspaceId: 'workspace-1' } + buildToolContext() ) await flushDetached() @@ -1446,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() @@ -1472,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) @@ -1491,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() @@ -1533,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() @@ -1549,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) @@ -1562,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) @@ -1580,7 +1659,7 @@ 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({ 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 0e69a0d21e8..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,5 +1,6 @@ import { createLogger } from '@sim/logger' 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 { executeCopilotAddWorkflowTableGroupOutput, @@ -10,10 +11,7 @@ import { executeCopilotImportWorkspaceFileIntoTable, executeCopilotUpdateWorkflowTableGroup, } from '@/lib/copilot/application/table-commands' -import { - messageForCopilotTableError, - resolveCopilotTablePrincipal, -} from '@/lib/copilot/auth/table-delegation' +import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -53,10 +51,7 @@ import { import { namedRowMapper } from '@/lib/table/cell-format' import { isSupportedCurrencyCode } from '@/lib/table/currency' import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate' -import { - createExactEmptyTableRowSecretProvenance, - loadTableRowSecretProvenance, -} from '@/lib/table/rows/secret-provenance' +import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' import { normalizeSelectOptionsInput } from '@/lib/table/select-options' import type { RowData, @@ -66,6 +61,7 @@ import type { WorkflowGroupDependencies, WorkflowGroupDeploymentMode, } from '@/lib/table/types' +import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('UserTableServerTool') @@ -103,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 } @@ -137,26 +131,18 @@ function normalizeSchemaSelectColumns(schema: TableSchema): TableSchema { } } -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 = { @@ -174,8 +160,6 @@ export const userTableServerTool: BaseServerTool const workspaceId = context.workspaceId const assertNotAborted = () => assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.') - const tablePrincipal = (tableId?: string) => resolveCopilotTablePrincipal(context, tableId) - try { switch (operation) { case 'create': { @@ -190,14 +174,11 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const { table } = await createTableUseCase.execute({ - principal: tablePrincipal(), - input: { - name: args.name, - description: args.description, - schema: normalizeSchemaSelectColumns(args.schema as TableSchema), - workspaceId, - }, + const { table } = await executeCopilotTableUseCase(context, createTableUseCase, { + name: args.name, + description: args.description, + schema: normalizeSchemaSelectColumns(args.schema as TableSchema), + workspaceId, }) return { @@ -215,10 +196,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const { table } = await readTableUseCase.execute({ - principal: tablePrincipal(args.tableId), - input: { tableId: args.tableId, workspaceId }, - }) + const { table } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) return { success: true, @@ -235,10 +218,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const { table } = await readTableUseCase.execute({ - principal: tablePrincipal(args.tableId), - input: { tableId: args.tableId, workspaceId }, - }) + const { table } = await executeCopilotTableUseCase( + context, + readTableUseCase, + { tableId: args.tableId, workspaceId }, + { tableId: args.tableId } + ) return { success: true, @@ -267,10 +252,12 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const { deleted, failed } = await executeCopilotDeleteTables(context, { + const { deleted: archived, failed } = await executeCopilotDeleteTables(context, { tableIds, workspaceId, + assertNotAborted, }) + const deleted = archived.map((table) => table.id) return { success: deleted.length > 0, @@ -291,9 +278,10 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await createTableRows.execute({ - principal: tablePrincipal(args.tableId), - input: { + const result = await executeCopilotTableUseCase( + context, + createTableRows, + { kind: 'single', tableId: args.tableId, assertedWorkspaceId: workspaceId, @@ -301,7 +289,8 @@ export const userTableServerTool: BaseServerTool position: args.position as number | undefined, secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), }, - }) + { tableId: args.tableId } + ) if (result.kind !== 'single') throw new Error('Single row insert returned a batch') const { table, row } = result const toNamedRow = namedRowMapper(table.schema.columns) @@ -331,16 +320,18 @@ export const userTableServerTool: BaseServerTool assertNotAborted() const sourceRows = args.rows as RowData[] - const result = await createTableRows.execute({ - principal: tablePrincipal(args.tableId), - input: { + const result = await executeCopilotTableUseCase( + context, + createTableRows, + { kind: 'batch', tableId: args.tableId, assertedWorkspaceId: workspaceId, rows: sourceRows, secretProvenance: sourceRows.map(createExactEmptyTableRowSecretProvenance), }, - }) + { tableId: args.tableId } + ) if (result.kind !== 'batch') throw new Error('Batch row insert returned one row') const { table, rows } = result const toNamedRow = namedRowMapper(table.schema.columns) @@ -369,15 +360,22 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const { table: rowTable, row } = await readTableRow.execute({ - principal: tablePrincipal(args.tableId), - input: { + const { + table: rowTable, + row, + secretProvenance, + } = await executeCopilotTableUseCase( + context, + readTableRow, + { tableId: args.tableId, assertedWorkspaceId: workspaceId, rowId: args.rowId, + includePersistedSecretProvenance: Boolean(context.resolvedSecretTraceRegistry), }, - }) - await importRowsForModel([row], context) + { tableId: args.tableId } + ) + await importRowsProvenanceForModel(secretProvenance, [row.data], context) const toNamedRow = namedRowMapper(rowTable.schema.columns) return { @@ -400,28 +398,35 @@ 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 } } - const result = await queryTableRows.execute({ - principal: tablePrincipal(args.tableId), - input: { + const result = await executeCopilotTableUseCase( + context, + queryTableRows, + { tableId: args.tableId, assertedWorkspaceId: workspaceId, predicate: args.filter ? 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), }, - }) - const { table } = result + { tableId: args.tableId } + ) + 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 @@ -434,7 +439,7 @@ export const userTableServerTool: BaseServerTool success: true, message, data: { - ...result, + ...queryResult, rows: result.rows.map((r) => ({ ...r, data: toNamedRow(r.data), @@ -458,18 +463,25 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const { table, row: updatedRow } = await updateTableRow.execute({ - principal: tablePrincipal(args.tableId), - input: { + const { + table, + row: updatedRow, + secretProvenance, + } = await executeCopilotTableUseCase( + context, + updateTableRow, + { tableId: args.tableId, assertedWorkspaceId: workspaceId, 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, @@ -495,14 +507,16 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - await deleteTableRow.execute({ - principal: tablePrincipal(args.tableId), - input: { + await executeCopilotTableUseCase( + context, + deleteTableRow, + { tableId: args.tableId, assertedWorkspaceId: workspaceId, rowId: args.rowId, }, - }) + { tableId: args.tableId } + ) return { success: true, @@ -529,16 +543,18 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await copilotUpdateRowsByFilter.execute({ - principal: tablePrincipal(args.tableId), - input: { + const result = await executeCopilotTableUseCase( + context, + copilotUpdateRowsByFilter, + { tableId: args.tableId, assertedWorkspaceId: workspaceId, filter: normalizeTablePredicate(args.filter as TablePredicateInput), data: args.data as RowData, limit: args.limit, }, - }) + { tableId: args.tableId } + ) if (result.kind === 'background') { return { success: true, @@ -570,15 +586,17 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await copilotDeleteRowsByFilter.execute({ - principal: tablePrincipal(args.tableId), - input: { + 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.kind === 'background') { return { success: true, @@ -636,14 +654,16 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await copilotBatchUpdateRows.execute({ - principal: tablePrincipal(args.tableId), - input: { + const result = await executeCopilotTableUseCase( + context, + copilotBatchUpdateRows, + { tableId: args.tableId, assertedWorkspaceId: workspaceId, updates: updates as Array<{ rowId: string; data: RowData }>, }, - }) + { tableId: args.tableId } + ) return { success: true, @@ -673,15 +693,17 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await deleteTableRows.execute({ - principal: tablePrincipal(args.tableId), - input: { + const result = await executeCopilotTableUseCase( + context, + deleteTableRows, + { kind: 'ids', tableId: args.tableId, assertedWorkspaceId: workspaceId, rowIds, }, - }) + { tableId: args.tableId } + ) if (result.kind !== 'ids') throw new Error('Row ID deletion returned a filter result') return { @@ -874,10 +896,12 @@ export const userTableServerTool: BaseServerTool col.type === 'select' ? { ...col, options: normalizeSelectOptionsInput(col.options) } : { ...col, options: undefined } - const { table: updated } = await addTableColumnUseCase.execute({ - principal: tablePrincipal(args.tableId), - input: { tableId: args.tableId, workspaceId, column: columnToAdd }, - }) + const { table: updated } = await executeCopilotTableUseCase( + context, + addTableColumnUseCase, + { tableId: args.tableId, workspaceId, column: columnToAdd }, + { tableId: args.tableId } + ) return { success: true, message: `Added column "${col.name}" (${col.type}) to table`, @@ -898,15 +922,17 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'columnName and newName are required' } } assertNotAborted() - const { table: updated } = await updateTableColumnUseCase.execute({ - principal: tablePrincipal(args.tableId), - input: { + const { table: updated } = await executeCopilotTableUseCase( + context, + updateTableColumnUseCase, + { tableId: args.tableId, workspaceId, columnName: colName, updates: { name: newColName }, }, - }) + { tableId: args.tableId } + ) return { success: true, message: `Renamed column "${colName}" to "${newColName}"`, @@ -929,10 +955,12 @@ export const userTableServerTool: BaseServerTool } if (names.length === 1) { assertNotAborted() - const { table: updated } = await deleteTableColumnUseCase.execute({ - principal: tablePrincipal(args.tableId), - input: { tableId: args.tableId, workspaceId, columnName: names[0] }, - }) + const { table: updated } = await executeCopilotTableUseCase( + context, + deleteTableColumnUseCase, + { tableId: args.tableId, workspaceId, columnName: names[0] }, + { tableId: args.tableId } + ) return { success: true, message: `Deleted column "${names[0]}"`, @@ -940,13 +968,17 @@ export const userTableServerTool: BaseServerTool } } assertNotAborted() - const { table: updated } = await deleteTableColumnsUseCase.execute({ - principal: tablePrincipal(args.tableId), - input: { tableId: args.tableId, workspaceId, columnNames: names }, - }) + const { table: updated, deletedColumns } = await executeCopilotTableUseCase( + context, + deleteTableColumnsUseCase, + { tableId: args.tableId, workspaceId, columnNames: names }, + { tableId: 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 }, } } @@ -993,9 +1025,10 @@ export const userTableServerTool: BaseServerTool } } assertNotAborted() - const { table: updated } = await updateTableColumnUseCase.execute({ - principal: tablePrincipal(args.tableId), - input: { + const { table: updated } = await executeCopilotTableUseCase( + context, + updateTableColumnUseCase, + { tableId: args.tableId, workspaceId, columnName: colName, @@ -1009,7 +1042,8 @@ export const userTableServerTool: BaseServerTool ...(currencyCode !== undefined ? { currencyCode } : {}), }, }, - }) + { tableId: args.tableId } + ) return { success: true, message: `Updated column "${colName}"`, @@ -1029,10 +1063,12 @@ export const userTableServerTool: BaseServerTool } assertNotAborted() - const result = await updateTableUseCase.execute({ - principal: tablePrincipal(args.tableId), - input: { tableId: args.tableId, workspaceId, name: newName }, - }) + const result = await executeCopilotTableUseCase( + context, + updateTableUseCase, + { tableId: args.tableId, workspaceId, name: newName }, + { tableId: args.tableId } + ) if (result.failure) { throw result.failure } @@ -1174,10 +1210,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'groupId is required for delete_workflow_group' } } assertNotAborted() - const { table: updated } = await deleteTableGroupUseCase.execute({ - principal: tablePrincipal(args.tableId), - input: { tableId: args.tableId, workspaceId, groupId }, - }) + const { table: updated } = await executeCopilotTableUseCase( + context, + deleteTableGroupUseCase, + { tableId: args.tableId, workspaceId, groupId }, + { tableId: args.tableId } + ) return { success: true, message: `Deleted workflow group ${groupId}`, @@ -1226,10 +1264,12 @@ export const userTableServerTool: BaseServerTool } } assertNotAborted() - const { table: updated } = await deleteTableGroupOutputUseCase.execute({ - principal: tablePrincipal(args.tableId), - input: { tableId: args.tableId, groupId, columnName, workspaceId }, - }) + const { table: updated } = await executeCopilotTableUseCase( + context, + deleteTableGroupOutputUseCase, + { tableId: args.tableId, groupId, columnName, workspaceId }, + { tableId: args.tableId } + ) return { success: true, message: `Removed output "${columnName}" from workflow group ${groupId}`, @@ -1275,9 +1315,10 @@ export const userTableServerTool: BaseServerTool rowIds = rawRowIds as string[] } assertNotAborted() - const { dispatchId } = await startTableRun.execute({ - principal: tablePrincipal(args.tableId), - input: { + const { dispatchId } = await executeCopilotTableUseCase( + context, + startTableRun, + { kind: 'selection', tableId: args.tableId, assertedWorkspaceId: workspaceId, @@ -1285,7 +1326,8 @@ export const userTableServerTool: BaseServerTool mode: runMode, rowIds, }, - }) + { tableId: args.tableId } + ) const scopeLabel = rowIds ? `${rowIds.length} row(s) by id` : runMode return { success: true, @@ -1309,22 +1351,23 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'rowId is required when scope is "row"' } } assertNotAborted() - const { cancelled } = await cancelTableRuns.execute({ - principal: tablePrincipal(args.tableId), - input: - scope === 'row' - ? { - scope: 'row', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowId: rowId as string, - } - : { - scope: 'all', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - }, - }) + const { cancelled } = await executeCopilotTableUseCase( + context, + cancelTableRuns, + scope === 'row' + ? { + scope: 'row', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + rowId: rowId as string, + } + : { + scope: 'all', + tableId: args.tableId, + assertedWorkspaceId: workspaceId, + }, + { tableId: args.tableId } + ) return { success: true, message: `Cancelled ${cancelled} run(s)`, 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 65cc5a3ac0f..297359a3b42 100644 --- a/apps/sim/lib/table/application/authorization.test.ts +++ b/apps/sim/lib/table/application/authorization.test.ts @@ -122,6 +122,33 @@ describe('table operation authorization', () => { ) }) + 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, diff --git a/apps/sim/lib/table/application/authorization.ts b/apps/sim/lib/table/application/authorization.ts index e6b3930f6ae..85330ac85c9 100644 --- a/apps/sim/lib/table/application/authorization.ts +++ b/apps/sim/lib/table/application/authorization.ts @@ -24,7 +24,9 @@ export const tableDelegationPolicy: WorkspaceDelegationPolicy, context: TableAuthorizationContext ) { - return context.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 index 2e5177f58a6..dfa9f41ccc5 100644 --- a/apps/sim/lib/table/application/columns.test.ts +++ b/apps/sim/lib/table/application/columns.test.ts @@ -29,9 +29,11 @@ vi.mock('@sim/platform-authz/workspace', () => ({ })) 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, @@ -45,7 +47,13 @@ const table: TableDefinition = { id: 'table-1', name: 'People', description: null, - schema: { columns: [{ name: 'name', type: 'string' }] }, + 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, @@ -67,6 +75,12 @@ const principal = { 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() @@ -79,11 +93,11 @@ describe('multi-column delete application use case', () => { allowPersonalApiKeys: true, billedAccountUserId: 'billing-owner-1', }) - mocks.deleteColumns.mockResolvedValue(table) + mocks.deleteColumns.mockResolvedValue(tableAfterDelete) }) it('owns canonical mutation, audit, and schema effects', async () => { - await deleteTableColumnsUseCase.execute({ + const result = await deleteTableColumnsUseCase.execute({ principal, input: { tableId: 'table-1', @@ -97,10 +111,66 @@ describe('multi-column delete application use case', () => { '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') diff --git a/apps/sim/lib/table/application/columns.ts b/apps/sim/lib/table/application/columns.ts index 5c5667683fe..4a56b2c1b4a 100644 --- a/apps/sim/lib/table/application/columns.ts +++ b/apps/sim/lib/table/application/columns.ts @@ -1,5 +1,6 @@ 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, @@ -7,7 +8,9 @@ import { type ColumnType, deleteColumn, deleteColumns, + getColumnId, type SelectOption, + TABLE_LIMITS, type TableDefinition, } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' @@ -161,6 +164,11 @@ export interface DeleteTableColumnsInput extends TableColumnInput { columnNames: string[] } +interface DeletedTableColumn { + id: string + name: string +} + export const deleteTableColumnsUseCase = defineAuthorizedTableUseCase({ operation: tableOperations.deleteColumn, resolveContext: ({ input }: { input: DeleteTableColumnsInput }) => @@ -168,29 +176,43 @@ export const deleteTableColumnsUseCase = defineAuthorizedTableUseCase({ tableId: input.tableId, assertedWorkspaceId: input.workspaceId, }), - async execute({ input, context }): Promise<{ table: TableDefinition }> { + async execute({ input, context }): Promise<{ + table: TableDefinition + deletedColumns: DeletedTableColumn[] + }> { if (input.columnNames.length < 1) { - throw new Error('At least one column name is required') + 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 } ) - return { table } + 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({ input, context, result }) { + 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 ${input.columnNames.length} columns from table "${context.table.name}"`, - metadata: { columnNames: input.columnNames }, + 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 }) { - signalTableSchemaChanged(context.table.id) + afterSuccess({ context, result }) { + if (result.deletedColumns.length > 0) signalTableSchemaChanged(context.table.id) }, }) diff --git a/apps/sim/lib/table/application/copilot-table-lifecycle.test.ts b/apps/sim/lib/table/application/copilot-table-lifecycle.test.ts index 6cf803c36b8..51e04aa5bad 100644 --- a/apps/sim/lib/table/application/copilot-table-lifecycle.test.ts +++ b/apps/sim/lib/table/application/copilot-table-lifecycle.test.ts @@ -72,12 +72,23 @@ describe('deleteCopilotTables', () => { }) 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'] }, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + assertNotAborted, + }, }) - expect(result).toEqual({ deleted: ['table-1', 'table-2'], failed: [] }) + 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', @@ -86,7 +97,12 @@ describe('deleteCopilotTables', () => { 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 () => { @@ -97,7 +113,11 @@ describe('deleteCopilotTables', () => { await expect( deleteCopilotTables.execute({ principal, - input: { workspaceId: 'workspace-1', tableIds: ['table-other'] }, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-other'], + assertNotAborted: vi.fn(), + }, }) ).resolves.toEqual({ deleted: [], failed: ['table-other'] }) @@ -111,7 +131,11 @@ describe('deleteCopilotTables', () => { await expect( deleteCopilotTables.execute({ principal, - input: { workspaceId: 'workspace-1', tableIds: ['table-1'] }, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-1'], + assertNotAborted: vi.fn(), + }, }) ).rejects.toMatchObject({ code: 'forbidden' }) @@ -120,7 +144,7 @@ describe('deleteCopilotTables', () => { expect(mocks.audit).not.toHaveBeenCalled() }) - it('preserves audit for completed items before a later mutation fails', async () => { + it('does not project partial audit when the compound command fails', async () => { const failure = new Error('delete storage unavailable') mocks.deleteTable .mockResolvedValueOnce({ @@ -131,13 +155,42 @@ describe('deleteCopilotTables', () => { await expect( deleteCopilotTables.execute({ principal, - input: { workspaceId: 'workspace-1', tableIds: ['table-1', 'table-2'] }, + input: { + workspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + assertNotAborted: vi.fn(), + }, }) ).rejects.toBe(failure) - expect(mocks.audit).toHaveBeenCalledTimes(1) - expect(mocks.audit).toHaveBeenCalledWith( - expect.objectContaining({ resourceId: 'table-1', action: 'table.deleted' }) - ) + 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 index b8e920246de..57369da7510 100644 --- a/apps/sim/lib/table/application/copilot-table-lifecycle.ts +++ b/apps/sim/lib/table/application/copilot-table-lifecycle.ts @@ -1,5 +1,4 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { resolvePrincipalAuditAttribution } from '@sim/auth/principal' +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' @@ -13,10 +12,16 @@ 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: string[] + deleted: ArchivedCopilotTable[] failed: string[] } @@ -25,7 +30,7 @@ export const deleteCopilotTables = defineAuthorizedTableUseCase({ operation: tableOperations.delete, resolveContext: ({ input }: { input: DeleteCopilotTablesInput }) => resolveTableWorkspaceContext(input.workspaceId), - async execute({ principal, input, context, request }): Promise { + async execute({ input, context }): Promise { if ( input.tableIds.length < 1 || input.tableIds.length > TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE @@ -39,9 +44,8 @@ export const deleteCopilotTables = defineAuthorizedTableUseCase({ throw new OrchestrationError('validation', 'Each table ID must be a non-empty string') } - const deleted: string[] = [] + const deleted: ArchivedCopilotTable[] = [] const failed: string[] = [] - const auditAttribution = resolvePrincipalAuditAttribution(principal) for (const tableId of input.tableIds) { try { @@ -49,6 +53,7 @@ export const deleteCopilotTables = defineAuthorizedTableUseCase({ tableId, assertedWorkspaceId: context.workspaceId, }) + input.assertNotAborted() const { archived } = await deleteTable(tableContext.tableId, generateRequestId(), { expectedWorkspaceId: context.workspaceId, }) @@ -57,22 +62,7 @@ export const deleteCopilotTables = defineAuthorizedTableUseCase({ continue } - deleted.push(tableId) - recordAudit({ - workspaceId: context.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.TABLE_DELETED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: archived.name, - description: `Archived table "${archived.name}"`, - metadata: { - operation: tableOperations.delete.id, - actor: auditAttribution.actor, - }, - request, - }) + deleted.push({ id: tableId, name: archived.name }) } catch (error) { if (asOrchestrationError(error)?.code === 'not_found') { failed.push(tableId) @@ -84,4 +74,12 @@ export const deleteCopilotTables = defineAuthorizedTableUseCase({ 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 index 9df4fce7d20..736ecd7c0c2 100644 --- a/apps/sim/lib/table/application/exports.test.ts +++ b/apps/sim/lib/table/application/exports.test.ts @@ -2,6 +2,7 @@ * @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' @@ -81,6 +82,18 @@ const principal = { 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(() => { @@ -125,4 +138,30 @@ describe('table export application use cases', () => { }) ).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 8002989ef7d..83414144bce 100644 --- a/apps/sim/lib/table/application/exports.ts +++ b/apps/sim/lib/table/application/exports.ts @@ -44,7 +44,6 @@ export interface DownloadTableExportResult { interface TableExportContext extends TableAuthorizationContext { exportId: string - tableId: string table: TableDefinition record: TableExportRecord } @@ -61,7 +60,6 @@ async function resolveTableExportContext( return { ...workspace, exportId: record.id, - tableId: table.id, table, record, } @@ -112,7 +110,7 @@ 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, }) diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts index 7befb9446b7..228467271b3 100644 --- a/apps/sim/lib/table/application/groups.test.ts +++ b/apps/sim/lib/table/application/groups.test.ts @@ -3,6 +3,7 @@ */ 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(() => ({ @@ -68,8 +69,10 @@ vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ import { addWorkflowTableGroupOutput, createTableEnrichmentGroup, + createTableGroupUseCase, createWorkflowTableGroup, deleteTableGroupOutputUseCase, + updateTableGroupUseCase, updateWorkflowTableGroup, } from '@/lib/table/application/groups' @@ -243,7 +246,7 @@ describe('workflow and enrichment Table application commands', () => { it('conceals a cross-workspace workflow before group mutation or effects', async () => { mocks.resolveWorkflowContext.mockRejectedValueOnce( - Object.assign(new Error('Workflow not found'), { code: 'not_found' }) + new OrchestrationError('not_found', 'Workflow not found') ) await expect( @@ -267,6 +270,52 @@ describe('workflow and enrichment Table application commands', () => { 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({ @@ -284,6 +333,26 @@ describe('workflow and enrichment Table application commands', () => { 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, diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index a719978bdcb..143a4a4213a 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -3,13 +3,14 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' 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 ColumnDefinition, type DeleteWorkflowGroupData, getColumnId, + TABLE_LIMITS, type TableDefinition, type TableSchema, type UpdateWorkflowGroupData, @@ -65,6 +66,20 @@ async function resolveWorkflowForAuthorizedTableCommand( return loadResolvedWorkflowOutputs(workflowContext) } +async function resolveRelatedWorkflowForTableRoute( + workflowId: string, + workspaceId: string +): 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 @@ -75,6 +90,15 @@ function requireWorkflowOutputs( 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, @@ -169,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 resolveWorkflowForAuthorizedTableCommand(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)) @@ -247,6 +274,7 @@ export const createWorkflowTableGroup = defineAuthorizedTableUseCase({ 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') } @@ -359,6 +387,13 @@ export const createTableEnrichmentGroup = defineAuthorizedTableUseCase({ 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( @@ -506,6 +541,10 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ assertedWorkspaceId: input.workspaceId, }), async execute({ principal, input, context }) { + 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 ) @@ -519,7 +558,7 @@ export const updateTableGroupUseCase = defineAuthorizedTableUseCase({ if (!targetWorkflowId) { throw new OrchestrationError('not_found', 'Workflow not found') } - resolvedWorkflow = await resolveWorkflowForAuthorizedTableCommand( + resolvedWorkflow = await resolveRelatedWorkflowForTableRoute( targetWorkflowId, context.workspaceId ) @@ -640,6 +679,8 @@ export const updateWorkflowTableGroup = defineAuthorizedTableUseCase({ 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 ) diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index dac2b72a411..79d3fae1691 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -2,6 +2,7 @@ * @vitest-environment node */ +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -104,6 +105,21 @@ const workspaceKey = { 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', @@ -182,6 +198,36 @@ describe('table import application use cases', () => { 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({ @@ -302,6 +348,45 @@ describe('table import application use cases', () => { 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, diff --git a/apps/sim/lib/table/application/imports.ts b/apps/sim/lib/table/application/imports.ts index e2439874afd..3f27c72c9df 100644 --- a/apps/sim/lib/table/application/imports.ts +++ b/apps/sim/lib/table/application/imports.ts @@ -88,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) } @@ -107,7 +108,6 @@ async function resolveTableImportContext( return { ...workspace, importId: record.id, - ...(record.tableId ? { tableId: record.tableId } : {}), record, } } @@ -127,7 +127,6 @@ async function resolveTableImportUploadContext( return { ...workspace, importId: upload.id, - ...(body.target.type === 'existing' ? { tableId: body.target.tableId } : {}), upload, } } diff --git a/apps/sim/lib/table/application/operations.test.ts b/apps/sim/lib/table/application/operations.test.ts index 87ecaae425d..3b9dc95cc27 100644 --- a/apps/sim/lib/table/application/operations.test.ts +++ b/apps/sim/lib/table/application/operations.test.ts @@ -53,21 +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)) { - if (operation.principalKinds.includes('delegated')) { - expect(operation.delegatedServices).toEqual(['copilot']) - } else { - expect(operation.delegatedServices).toBeUndefined() - } + expect(operation.delegatedServices).toEqual( + executorOnlyOperations.has(operation.id) + ? ['executor'] + : sharedGroupOperations.has(operation.id) + ? ['copilot', 'executor'] + : ['copilot'] + ) } }) - it('separates Copilot file imports from the credential-bound HTTP lifecycle', () => { - expect(tableOperations.createImport.principalKinds).not.toContain('delegated') - expect(tableOperations.createImportParts.principalKinds).not.toContain('delegated') - expect(tableOperations.completeImport.principalKinds).not.toContain('delegated') + 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 da9d98895ad..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,22 +33,40 @@ function writeOperation(id: Id) { }) } -function delegatedWriteOperation(id: Id) { +function toolWriteOperation(id: Id) { return defineWorkspaceOperation({ id, minimumRole: 'write', - workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], + workspaceApiKey: 'allow', + ...ALL_TABLE_TOOL_PRINCIPAL_POLICY, + }) +} + +function internalExecutorReadOperation(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'read', + workspaceApiKey: 'allow', + ...INTERNAL_EXECUTOR_PRINCIPAL_POLICY, }) } -function nonDelegatedWriteOperation(id: Id) { +function internalExecutorWriteOperation(id: Id) { return defineWorkspaceOperation({ id, minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + ...INTERNAL_EXECUTOR_PRINCIPAL_POLICY, + }) +} + +function delegatedWriteOperation(id: Id) { + return defineWorkspaceOperation({ + id, + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], }) } @@ -72,22 +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: nonDelegatedWriteOperation('tables.imports.create'), + createImport: internalExecutorWriteOperation('tables.imports.create'), createFromWorkspaceFile: delegatedWriteOperation('tables.imports.create_from_workspace_file'), importWorkspaceFile: delegatedWriteOperation('tables.imports.workspace_file'), - readImport: readOperation('tables.imports.read'), - createImportParts: nonDelegatedWriteOperation('tables.imports.create_parts'), - completeImport: nonDelegatedWriteOperation('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'), + 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 3b3f616d245..f06658ece14 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -8,6 +8,7 @@ import type { TableDefinition } from '@/lib/table/types' const { mockReplaceRowsPrimitive, mockDeleteRowsByIds, + mockLoadSecretProvenance, mockAssertRowCapacity, mockNotifyTableRowUsage, mockQueryRows, @@ -21,6 +22,7 @@ const { } = vi.hoisted(() => ({ mockReplaceRowsPrimitive: vi.fn(), mockDeleteRowsByIds: vi.fn(), + mockLoadSecretProvenance: vi.fn(), mockAssertRowCapacity: vi.fn(), mockNotifyTableRowUsage: vi.fn(), mockQueryRows: vi.fn(), @@ -91,6 +93,7 @@ vi.mock('@/lib/table/column-types', () => ({ vi.mock('@/lib/table/rows/secret-provenance', () => ({ createExactEmptyTableRowSecretProvenance: () => ({ complete: true, columns: {} }), + loadTableRowSecretProvenance: mockLoadSecretProvenance, })) vi.mock('@/lib/table/rows/service', () => ({ @@ -423,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 f7c51b6bb0d..c8881b0c822 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -1,6 +1,6 @@ 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' @@ -54,7 +54,10 @@ import { validateStoragePredicate, } from '@/lib/table/query-builder/validate' import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' -import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' +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' @@ -79,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) } @@ -183,6 +201,7 @@ export interface QueryTableRowsInput extends TableScopedInput { limit?: number cursor?: string includeTotal?: boolean + includePersistedSecretProvenance?: boolean } export interface QueryTableRowsResult extends TableResult { @@ -190,17 +209,16 @@ 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) { - if (!Number.isSafeInteger(input.limit) || input.limit < 1) { - throw new TableRowsValidationError('Limit must be 1 or greater') - } + requireIntegerInRange(input.limit, 1, TABLE_LIMITS.MAX_QUERY_LIMIT, 'Limit') } let predicate = input.predicate if (predicate) { @@ -230,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) } @@ -279,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 + ), + } }, }) @@ -567,11 +605,13 @@ 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({ @@ -592,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 index 40a1092e8cb..b7f8cfdb73c 100644 --- a/apps/sim/lib/table/application/workspace-file-imports.test.ts +++ b/apps/sim/lib/table/application/workspace-file-imports.test.ts @@ -122,8 +122,8 @@ const principal = { 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 tablePrincipal = { ...principal, resourceScope: { tableId: 'table-1' } } describe('workspace-file Table application commands', () => { beforeEach(() => { @@ -270,7 +270,7 @@ describe('workspace-file Table application commands', () => { }) await importWorkspaceFileIntoTable.execute({ - principal, + principal: tablePrincipal, input: { tableId: table.id, assertedWorkspaceId: table.workspaceId, @@ -287,7 +287,7 @@ describe('workspace-file Table application commands', () => { await expect( importWorkspaceFileIntoTable.execute({ - principal, + principal: tablePrincipal, input: { tableId: table.id, assertedWorkspaceId: table.workspaceId, @@ -316,7 +316,7 @@ describe('workspace-file Table application commands', () => { await expect( importWorkspaceFileIntoTable.execute({ - principal, + principal: tablePrincipal, input: { tableId: table.id, assertedWorkspaceId: table.workspaceId, @@ -338,7 +338,7 @@ describe('workspace-file Table application commands', () => { await expect( importWorkspaceFileIntoTable.execute({ - principal, + principal: tablePrincipal, input: { tableId: table.id, assertedWorkspaceId: table.workspaceId, 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/workspace-files/application/resolve-workspace-file-reference.test.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts index c2511500d17..cd69a123118 100644 --- a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts @@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ fetchBuffer: vi.fn(), - getProvenance: vi.fn(), loadContext: vi.fn(), resolvePermission: vi.fn(), resolveStoredReference: vi.fn(), @@ -22,14 +21,9 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: mocks.resolveStoredReference, })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ - getBoundWorkspaceFileSecretProvenance: mocks.getProvenance, -})) - import { defineWorkspaceOperation } from '@/lib/core/application' import { fileOperations } from '@/lib/workspace-files/application/operations' import { - readSafeWorkspaceFileReference, readWorkspaceFileReference, resolveWorkspaceFileReference, } from '@/lib/workspace-files/application/resolve-workspace-file-reference' @@ -57,7 +51,6 @@ describe('workspace file reference application service', () => { mocks.loadContext.mockResolvedValue(context) mocks.resolvePermission.mockResolvedValue('admin') mocks.fetchBuffer.mockResolvedValue(Buffer.from('source')) - mocks.getProvenance.mockResolvedValue({ status: 'exact', entries: [] }) }) it('uses one fixed semantic use case for an authorized reference lookup', async () => { @@ -111,47 +104,4 @@ describe('workspace file reference application service', () => { expect(mocks.resolveStoredReference).not.toHaveBeenCalled() expect(mocks.loadContext).not.toHaveBeenCalled() }) - - it('returns only workspace files with exact empty secret provenance', async () => { - await expect( - readSafeWorkspaceFileReference.execute({ - principal, - input: { workspaceId: 'workspace-1', reference: 'files/source.txt', maxBytes: 512 }, - }) - ).resolves.toEqual({ file, content: Buffer.from('source') }) - - expect(mocks.getProvenance).toHaveBeenCalledWith('workspace-1', { - fileId: 'file-1', - key: file.key, - context: 'workspace', - }) - }) - - it('conceals a canonical file from another workspace before authorization or content access', async () => { - mocks.resolveStoredReference.mockResolvedValueOnce({ ...file, workspaceId: 'workspace-2' }) - mocks.loadContext.mockResolvedValueOnce({ ...context, workspaceId: 'workspace-2' }) - - await expect( - readSafeWorkspaceFileReference.execute({ - principal, - input: { workspaceId: 'workspace-1', reference: 'files/source.txt', maxBytes: 512 }, - }) - ).rejects.toMatchObject({ code: 'not_found' }) - - expect(mocks.resolvePermission).not.toHaveBeenCalled() - expect(mocks.getProvenance).not.toHaveBeenCalled() - expect(mocks.fetchBuffer).not.toHaveBeenCalled() - }) - - it('rejects unknown provenance before reading file content', async () => { - mocks.getProvenance.mockResolvedValueOnce({ status: 'unknown' }) - - await expect( - readSafeWorkspaceFileReference.execute({ - principal, - input: { workspaceId: 'workspace-1', reference: 'files/source.txt', maxBytes: 512 }, - }) - ).rejects.toMatchObject({ code: 'validation' }) - expect(mocks.fetchBuffer).not.toHaveBeenCalled() - }) }) diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts index ae26f743705..89962635c5c 100644 --- a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts @@ -7,7 +7,6 @@ import { resolveWorkspaceFileReference as resolveStoredWorkspaceFileReference, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' @@ -124,37 +123,3 @@ export async function readWorkspaceFileReference({ input: { workspaceId, reference, maxBytes }, }) } - -export interface ReadSafeWorkspaceFileReferenceInput extends WorkspaceFileReferenceInput { - maxBytes?: number -} - -export interface ReadSafeWorkspaceFileReferenceResult { - file: WorkspaceFileRecord - content?: Buffer -} - -export const readSafeWorkspaceFileReference = defineAuthorizedWorkspaceFileUseCase({ - operation: fileOperations.readContent, - resolveContext: ({ input }: { input: ReadSafeWorkspaceFileReferenceInput }) => - resolveWorkspaceFileReferenceContext({ input }), - async execute({ input, context }): Promise { - const provenance = await getBoundWorkspaceFileSecretProvenance(context.workspaceId, { - fileId: context.file.id, - key: context.file.key, - context: 'workspace', - }) - if (provenance.status !== 'exact' || provenance.entries.length > 0) { - throw new OrchestrationError( - 'validation', - `Cannot import "${input.reference}": the file cannot be verified as free of resolved secrets.` - ) - } - return { - file: context.file, - ...(input.maxBytes === undefined - ? {} - : { content: await fetchWorkspaceFileBuffer(context.file, { maxBytes: input.maxBytes }) }), - } - }, -})